@cyanautomation/kaseki-agent 1.4.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 (459) hide show
  1. package/.dockerignore +54 -0
  2. package/.eslintignore +11 -0
  3. package/.eslintrc.json +95 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +53 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.md +53 -0
  6. package/.github/ISSUE_TEMPLATE/security.md +51 -0
  7. package/.github/PULL_REQUEST_TEMPLATE/default.md +71 -0
  8. package/.github/dependabot.yml +38 -0
  9. package/.github/skills/dependency-cache-optimization/SKILL.md +526 -0
  10. package/.github/skills/docker-image-management/SKILL.md +532 -0
  11. package/.github/skills/frontend-design/SKILL.md +782 -0
  12. package/.github/skills/prompt-engineering/SKILL.md +360 -0
  13. package/.github/skills/quality-gate-config/SKILL.md +591 -0
  14. package/.github/skills/result-report-analysis/SKILL.md +576 -0
  15. package/.github/skills/test-automation/SKILL.md +593 -0
  16. package/.github/skills/workflow-diagnosis/SKILL.md +468 -0
  17. package/.github/workflows/build-docker-image.yml +453 -0
  18. package/.github/workflows/release.yml +68 -0
  19. package/.releaserc.json +135 -0
  20. package/CHANGELOG.md +117 -0
  21. package/CLAUDE.md +336 -0
  22. package/CONTRIBUTING.md +339 -0
  23. package/Dockerfile +217 -0
  24. package/README.md +1527 -0
  25. package/STYLE.md +521 -0
  26. package/add-js-extensions.d.ts +9 -0
  27. package/add-js-extensions.d.ts.map +1 -0
  28. package/add-js-extensions.js.map +1 -0
  29. package/dist/add-js-extensions.d.ts +9 -0
  30. package/dist/add-js-extensions.d.ts.map +1 -0
  31. package/dist/add-js-extensions.js +52 -0
  32. package/dist/add-js-extensions.js.map +1 -0
  33. package/dist/ansi-colors.d.ts +26 -0
  34. package/dist/ansi-colors.d.ts.map +1 -0
  35. package/dist/ansi-colors.js +51 -0
  36. package/dist/ansi-colors.js.map +1 -0
  37. package/dist/cli/BaseCommand.d.ts +18 -0
  38. package/dist/cli/BaseCommand.d.ts.map +1 -0
  39. package/dist/cli/BaseCommand.js +31 -0
  40. package/dist/cli/BaseCommand.js.map +1 -0
  41. package/dist/cli/KasekiCLI.d.ts +30 -0
  42. package/dist/cli/KasekiCLI.d.ts.map +1 -0
  43. package/dist/cli/KasekiCLI.js +134 -0
  44. package/dist/cli/KasekiCLI.js.map +1 -0
  45. package/dist/cli/commands/ConfigCommand.d.ts +13 -0
  46. package/dist/cli/commands/ConfigCommand.d.ts.map +1 -0
  47. package/dist/cli/commands/ConfigCommand.js +131 -0
  48. package/dist/cli/commands/ConfigCommand.js.map +1 -0
  49. package/dist/cli/commands/DoctorCommand.d.ts +45 -0
  50. package/dist/cli/commands/DoctorCommand.d.ts.map +1 -0
  51. package/dist/cli/commands/DoctorCommand.js +309 -0
  52. package/dist/cli/commands/DoctorCommand.js.map +1 -0
  53. package/dist/cli/commands/ListCommand.d.ts +9 -0
  54. package/dist/cli/commands/ListCommand.d.ts.map +1 -0
  55. package/dist/cli/commands/ListCommand.js +81 -0
  56. package/dist/cli/commands/ListCommand.js.map +1 -0
  57. package/dist/cli/commands/ReportCommand.d.ts +9 -0
  58. package/dist/cli/commands/ReportCommand.d.ts.map +1 -0
  59. package/dist/cli/commands/ReportCommand.js +98 -0
  60. package/dist/cli/commands/ReportCommand.js.map +1 -0
  61. package/dist/cli/commands/RunCommand.d.ts +13 -0
  62. package/dist/cli/commands/RunCommand.d.ts.map +1 -0
  63. package/dist/cli/commands/RunCommand.js +191 -0
  64. package/dist/cli/commands/RunCommand.js.map +1 -0
  65. package/dist/cli/commands/SecretsCommand.d.ts +9 -0
  66. package/dist/cli/commands/SecretsCommand.d.ts.map +1 -0
  67. package/dist/cli/commands/SecretsCommand.js +109 -0
  68. package/dist/cli/commands/SecretsCommand.js.map +1 -0
  69. package/dist/cli/commands/ServeCommand.d.ts +9 -0
  70. package/dist/cli/commands/ServeCommand.d.ts.map +1 -0
  71. package/dist/cli/commands/ServeCommand.js +50 -0
  72. package/dist/cli/commands/ServeCommand.js.map +1 -0
  73. package/dist/cli/commands/SetupCommand.d.ts +42 -0
  74. package/dist/cli/commands/SetupCommand.d.ts.map +1 -0
  75. package/dist/cli/commands/SetupCommand.js +249 -0
  76. package/dist/cli/commands/SetupCommand.js.map +1 -0
  77. package/dist/cli.d.ts +9 -0
  78. package/dist/cli.d.ts.map +1 -0
  79. package/dist/cli.js +130 -0
  80. package/dist/cli.js.map +1 -0
  81. package/dist/config/ConfigManager.d.ts +395 -0
  82. package/dist/config/ConfigManager.d.ts.map +1 -0
  83. package/dist/config/ConfigManager.js +446 -0
  84. package/dist/config/ConfigManager.js.map +1 -0
  85. package/dist/docker/DockerManager.d.ts +69 -0
  86. package/dist/docker/DockerManager.d.ts.map +1 -0
  87. package/dist/docker/DockerManager.js +266 -0
  88. package/dist/docker/DockerManager.js.map +1 -0
  89. package/dist/event-aggregator.d.ts +71 -0
  90. package/dist/event-aggregator.d.ts.map +1 -0
  91. package/dist/event-aggregator.js +95 -0
  92. package/dist/event-aggregator.js.map +1 -0
  93. package/dist/github-app-token.d.ts +16 -0
  94. package/dist/github-app-token.d.ts.map +1 -0
  95. package/dist/github-app-token.js +148 -0
  96. package/dist/github-app-token.js.map +1 -0
  97. package/dist/idempotency-store.d.ts +61 -0
  98. package/dist/idempotency-store.d.ts.map +1 -0
  99. package/dist/idempotency-store.js +321 -0
  100. package/dist/idempotency-store.js.map +1 -0
  101. package/dist/index.d.ts +25 -0
  102. package/dist/index.d.ts.map +1 -0
  103. package/dist/index.js +31 -0
  104. package/dist/index.js.map +1 -0
  105. package/dist/instance/InstanceManager.d.ts +81 -0
  106. package/dist/instance/InstanceManager.d.ts.map +1 -0
  107. package/dist/instance/InstanceManager.js +220 -0
  108. package/dist/instance/InstanceManager.js.map +1 -0
  109. package/dist/instance-metadata-reader.d.ts +48 -0
  110. package/dist/instance-metadata-reader.d.ts.map +1 -0
  111. package/dist/instance-metadata-reader.js +94 -0
  112. package/dist/instance-metadata-reader.js.map +1 -0
  113. package/dist/instance-state-derivation.d.ts +42 -0
  114. package/dist/instance-state-derivation.d.ts.map +1 -0
  115. package/dist/instance-state-derivation.js +133 -0
  116. package/dist/instance-state-derivation.js.map +1 -0
  117. package/dist/job-scheduler.d.ts +124 -0
  118. package/dist/job-scheduler.d.ts.map +1 -0
  119. package/dist/job-scheduler.js +992 -0
  120. package/dist/job-scheduler.js.map +1 -0
  121. package/dist/kaseki-api-client.d.ts +89 -0
  122. package/dist/kaseki-api-client.d.ts.map +1 -0
  123. package/dist/kaseki-api-client.js +405 -0
  124. package/dist/kaseki-api-client.js.map +1 -0
  125. package/dist/kaseki-api-config.d.ts +34 -0
  126. package/dist/kaseki-api-config.d.ts.map +1 -0
  127. package/dist/kaseki-api-config.js +113 -0
  128. package/dist/kaseki-api-config.js.map +1 -0
  129. package/dist/kaseki-api-routes.d.ts +13 -0
  130. package/dist/kaseki-api-routes.d.ts.map +1 -0
  131. package/dist/kaseki-api-routes.js +559 -0
  132. package/dist/kaseki-api-routes.js.map +1 -0
  133. package/dist/kaseki-api-service-wrapper.d.ts +43 -0
  134. package/dist/kaseki-api-service-wrapper.d.ts.map +1 -0
  135. package/dist/kaseki-api-service-wrapper.js +150 -0
  136. package/dist/kaseki-api-service-wrapper.js.map +1 -0
  137. package/dist/kaseki-api-service.d.ts +16 -0
  138. package/dist/kaseki-api-service.d.ts.map +1 -0
  139. package/dist/kaseki-api-service.js +143 -0
  140. package/dist/kaseki-api-service.js.map +1 -0
  141. package/dist/kaseki-api-types.d.ts +440 -0
  142. package/dist/kaseki-api-types.d.ts.map +1 -0
  143. package/dist/kaseki-api-types.js +64 -0
  144. package/dist/kaseki-api-types.js.map +1 -0
  145. package/dist/kaseki-cli-lib.d.ts +219 -0
  146. package/dist/kaseki-cli-lib.d.ts.map +1 -0
  147. package/dist/kaseki-cli-lib.js +523 -0
  148. package/dist/kaseki-cli-lib.js.map +1 -0
  149. package/dist/kaseki-cli.d.ts +38 -0
  150. package/dist/kaseki-cli.d.ts.map +1 -0
  151. package/dist/kaseki-cli.js +559 -0
  152. package/dist/kaseki-cli.js.map +1 -0
  153. package/dist/kaseki-report.d.ts +3 -0
  154. package/dist/kaseki-report.d.ts.map +1 -0
  155. package/dist/kaseki-report.js +140 -0
  156. package/dist/kaseki-report.js.map +1 -0
  157. package/dist/lib/subprocess-helpers.d.ts +98 -0
  158. package/dist/lib/subprocess-helpers.d.ts.map +1 -0
  159. package/dist/lib/subprocess-helpers.js +136 -0
  160. package/dist/lib/subprocess-helpers.js.map +1 -0
  161. package/dist/logger.d.ts +39 -0
  162. package/dist/logger.d.ts.map +1 -0
  163. package/dist/logger.js +79 -0
  164. package/dist/logger.js.map +1 -0
  165. package/dist/metrics.d.ts +19 -0
  166. package/dist/metrics.d.ts.map +1 -0
  167. package/dist/metrics.js +59 -0
  168. package/dist/metrics.js.map +1 -0
  169. package/dist/middleware/job-lookup.d.ts +27 -0
  170. package/dist/middleware/job-lookup.d.ts.map +1 -0
  171. package/dist/middleware/job-lookup.js +28 -0
  172. package/dist/middleware/job-lookup.js.map +1 -0
  173. package/dist/pi-event-filter.d.ts +3 -0
  174. package/dist/pi-event-filter.d.ts.map +1 -0
  175. package/dist/pi-event-filter.js +126 -0
  176. package/dist/pi-event-filter.js.map +1 -0
  177. package/dist/pi-progress-stream.d.ts +3 -0
  178. package/dist/pi-progress-stream.d.ts.map +1 -0
  179. package/dist/pi-progress-stream.js +205 -0
  180. package/dist/pi-progress-stream.js.map +1 -0
  181. package/dist/pi-progress-summarizer.d.ts +61 -0
  182. package/dist/pi-progress-summarizer.d.ts.map +1 -0
  183. package/dist/pi-progress-summarizer.js +246 -0
  184. package/dist/pi-progress-summarizer.js.map +1 -0
  185. package/dist/pre-flight-validator.d.ts +72 -0
  186. package/dist/pre-flight-validator.d.ts.map +1 -0
  187. package/dist/pre-flight-validator.js +513 -0
  188. package/dist/pre-flight-validator.js.map +1 -0
  189. package/dist/progress-stream-utils.d.ts +3 -0
  190. package/dist/progress-stream-utils.d.ts.map +1 -0
  191. package/dist/progress-stream-utils.js +15 -0
  192. package/dist/progress-stream-utils.js.map +1 -0
  193. package/dist/result-cache.d.ts +52 -0
  194. package/dist/result-cache.d.ts.map +1 -0
  195. package/dist/result-cache.js +134 -0
  196. package/dist/result-cache.js.map +1 -0
  197. package/dist/routes/artifact-routes.d.ts +10 -0
  198. package/dist/routes/artifact-routes.d.ts.map +1 -0
  199. package/dist/routes/artifact-routes.js +126 -0
  200. package/dist/routes/artifact-routes.js.map +1 -0
  201. package/dist/routes/log-routes.d.ts +8 -0
  202. package/dist/routes/log-routes.d.ts.map +1 -0
  203. package/dist/routes/log-routes.js +345 -0
  204. package/dist/routes/log-routes.js.map +1 -0
  205. package/dist/routes/status-routes.d.ts +8 -0
  206. package/dist/routes/status-routes.d.ts.map +1 -0
  207. package/dist/routes/status-routes.js +82 -0
  208. package/dist/routes/status-routes.js.map +1 -0
  209. package/dist/routes/webhook-routes.d.ts +6 -0
  210. package/dist/routes/webhook-routes.d.ts.map +1 -0
  211. package/dist/routes/webhook-routes.js +86 -0
  212. package/dist/routes/webhook-routes.js.map +1 -0
  213. package/dist/run-artifact-metadata-cache.d.ts +42 -0
  214. package/dist/run-artifact-metadata-cache.d.ts.map +1 -0
  215. package/dist/run-artifact-metadata-cache.js +139 -0
  216. package/dist/run-artifact-metadata-cache.js.map +1 -0
  217. package/dist/secret-value-cache.d.ts +13 -0
  218. package/dist/secret-value-cache.d.ts.map +1 -0
  219. package/dist/secret-value-cache.js +44 -0
  220. package/dist/secret-value-cache.js.map +1 -0
  221. package/dist/secrets/SecretsManager.d.ts +80 -0
  222. package/dist/secrets/SecretsManager.d.ts.map +1 -0
  223. package/dist/secrets/SecretsManager.js +306 -0
  224. package/dist/secrets/SecretsManager.js.map +1 -0
  225. package/dist/test-utils.d.ts +55 -0
  226. package/dist/test-utils.d.ts.map +1 -0
  227. package/dist/test-utils.js +48 -0
  228. package/dist/test-utils.js.map +1 -0
  229. package/dist/timestamp-tracker.d.ts +75 -0
  230. package/dist/timestamp-tracker.d.ts.map +1 -0
  231. package/dist/timestamp-tracker.js +121 -0
  232. package/dist/timestamp-tracker.js.map +1 -0
  233. package/dist/utils/failure-artifact-writer.d.ts +29 -0
  234. package/dist/utils/failure-artifact-writer.d.ts.map +1 -0
  235. package/dist/utils/failure-artifact-writer.js +157 -0
  236. package/dist/utils/failure-artifact-writer.js.map +1 -0
  237. package/dist/utils/file-helpers.d.ts +41 -0
  238. package/dist/utils/file-helpers.d.ts.map +1 -0
  239. package/dist/utils/file-helpers.js +143 -0
  240. package/dist/utils/file-helpers.js.map +1 -0
  241. package/dist/utils/http-client-factory.d.ts +46 -0
  242. package/dist/utils/http-client-factory.d.ts.map +1 -0
  243. package/dist/utils/http-client-factory.js +114 -0
  244. package/dist/utils/http-client-factory.js.map +1 -0
  245. package/dist/utils/progress-normalizer.d.ts +13 -0
  246. package/dist/utils/progress-normalizer.d.ts.map +1 -0
  247. package/dist/utils/progress-normalizer.js +57 -0
  248. package/dist/utils/progress-normalizer.js.map +1 -0
  249. package/dist/utils/response-helpers.d.ts +34 -0
  250. package/dist/utils/response-helpers.d.ts.map +1 -0
  251. package/dist/utils/response-helpers.js +78 -0
  252. package/dist/utils/response-helpers.js.map +1 -0
  253. package/dist/utils/route-helpers.d.ts +17 -0
  254. package/dist/utils/route-helpers.d.ts.map +1 -0
  255. package/dist/utils/route-helpers.js +22 -0
  256. package/dist/utils/route-helpers.js.map +1 -0
  257. package/dist/utils/status-response-builder.d.ts +23 -0
  258. package/dist/utils/status-response-builder.d.ts.map +1 -0
  259. package/dist/utils/status-response-builder.js +144 -0
  260. package/dist/utils/status-response-builder.js.map +1 -0
  261. package/dist/utils/type-guards.d.ts +37 -0
  262. package/dist/utils/type-guards.d.ts.map +1 -0
  263. package/dist/utils/type-guards.js +45 -0
  264. package/dist/utils/type-guards.js.map +1 -0
  265. package/dist/utils/utf8-helpers.d.ts +32 -0
  266. package/dist/utils/utf8-helpers.d.ts.map +1 -0
  267. package/dist/utils/utf8-helpers.js +97 -0
  268. package/dist/utils/utf8-helpers.js.map +1 -0
  269. package/dist/utils/webhook-event-builder.d.ts +26 -0
  270. package/dist/utils/webhook-event-builder.d.ts.map +1 -0
  271. package/dist/utils/webhook-event-builder.js +77 -0
  272. package/dist/utils/webhook-event-builder.js.map +1 -0
  273. package/dist/webhook-manager.d.ts +56 -0
  274. package/dist/webhook-manager.d.ts.map +1 -0
  275. package/dist/webhook-manager.js +359 -0
  276. package/dist/webhook-manager.js.map +1 -0
  277. package/docker/workspace-cache/package-lock.json +13 -0
  278. package/docker/workspace-cache/package.json +7 -0
  279. package/docker-compose.yml +53 -0
  280. package/docs/API.md +708 -0
  281. package/docs/BACKLOG.md +19 -0
  282. package/docs/BUILD_STRATEGY.md +404 -0
  283. package/docs/CLI.md +569 -0
  284. package/docs/DEPLOYMENT.md +521 -0
  285. package/docs/DEVELOPMENT.md +459 -0
  286. package/docs/DOCKER_SETUP.md +522 -0
  287. package/docs/ENHANCED_PROGRESS_LOGS.md +264 -0
  288. package/docs/IMPLEMENTATION_SUMMARY.md +549 -0
  289. package/docs/INTEGRATION_EXAMPLE.md +217 -0
  290. package/docs/NPM_SETUP.md +468 -0
  291. package/docs/PHASE1-4_IMPLEMENTATION.md +302 -0
  292. package/docs/PHASE1_COMPLETION.md +192 -0
  293. package/docs/PHASE2_COMPLETION.md +134 -0
  294. package/docs/PHASE6_MIGRATION.md +392 -0
  295. package/docs/PRINTF_SAFETY_FIX.md +282 -0
  296. package/docs/QUALITY_GATES.md +369 -0
  297. package/docs/SETUP_GUIDE.md +482 -0
  298. package/docs/TASK_PROMPT_TEMPLATES.md +533 -0
  299. package/docs/VALIDATION_FIX.md +139 -0
  300. package/docs/VERIFICATION_CHECKLIST.md +335 -0
  301. package/docs/repo-maturity.md +760 -0
  302. package/fix-tests.d.ts +9 -0
  303. package/fix-tests.d.ts.map +1 -0
  304. package/fix-tests.js.map +1 -0
  305. package/fix-tests.ts +53 -0
  306. package/jest.config.ts +31 -0
  307. package/kaseki +183 -0
  308. package/kaseki-agent.sh +1961 -0
  309. package/ops/logrotate/kaseki +10 -0
  310. package/package.json +83 -0
  311. package/perf/README.md +54 -0
  312. package/perf/pi-event-filter.benchmark.test.ts +98 -0
  313. package/run-kaseki-json.test.sh +106 -0
  314. package/run-kaseki.sh +990 -0
  315. package/scripts/allowlist-helper.sh +56 -0
  316. package/scripts/cleanup-kaseki.sh +168 -0
  317. package/scripts/deploy-pi-template.sh +293 -0
  318. package/scripts/docker-entrypoint.sh +71 -0
  319. package/scripts/dry-run-allowlist.sh +161 -0
  320. package/scripts/kaseki-activate.sh +396 -0
  321. package/scripts/kaseki-api.service +62 -0
  322. package/scripts/kaseki-container-entrypoint-wrapper.sh +119 -0
  323. package/scripts/kaseki-container-setup-remote.sh +172 -0
  324. package/scripts/kaseki-container-setup.sh +193 -0
  325. package/scripts/kaseki-healthcheck.sh +95 -0
  326. package/scripts/kaseki-install.sh +50 -0
  327. package/scripts/kaseki-maturity-score.sh +291 -0
  328. package/scripts/kaseki-performance-metrics.sh +122 -0
  329. package/scripts/kaseki-preflight.sh +270 -0
  330. package/scripts/kaseki-setup.sh +265 -0
  331. package/scripts/pi-setup-remote.sh +213 -0
  332. package/scripts/setup-github-labels.sh +42 -0
  333. package/scripts/suggest-allowlist.sh +68 -0
  334. package/scripts/templates/MULTI_HOST_DISTRIBUTED.md +337 -0
  335. package/scripts/templates/REST_API_SERVICE.md +490 -0
  336. package/scripts/templates/SINGLE_HOST_CLI.md +194 -0
  337. package/scripts/test-github-app.sh +248 -0
  338. package/src/add-js-extensions.ts +61 -0
  339. package/src/ansi-colors.test.ts +62 -0
  340. package/src/ansi-colors.ts +67 -0
  341. package/src/cli/BaseCommand.ts +40 -0
  342. package/src/cli/KasekiCLI.ts +154 -0
  343. package/src/cli/commands/ConfigCommand.ts +145 -0
  344. package/src/cli/commands/DoctorCommand.ts +329 -0
  345. package/src/cli/commands/ListCommand.ts +105 -0
  346. package/src/cli/commands/ReportCommand.ts +110 -0
  347. package/src/cli/commands/RunCommand.ts +218 -0
  348. package/src/cli/commands/SecretsCommand.ts +120 -0
  349. package/src/cli/commands/ServeCommand.ts +62 -0
  350. package/src/cli/commands/SetupCommand.ts +301 -0
  351. package/src/cli.ts +138 -0
  352. package/src/config/ConfigManager.ts +476 -0
  353. package/src/docker/DockerManager.ts +319 -0
  354. package/src/docker-entrypoint-packaging.test.ts +33 -0
  355. package/src/event-aggregator.test.ts +117 -0
  356. package/src/event-aggregator.ts +126 -0
  357. package/src/github-app-token.ts +215 -0
  358. package/src/idempotency-store.test.ts +117 -0
  359. package/src/idempotency-store.ts +385 -0
  360. package/src/index.ts +89 -0
  361. package/src/instance/InstanceManager.ts +285 -0
  362. package/src/instance-metadata-reader.test.ts +190 -0
  363. package/src/instance-metadata-reader.ts +129 -0
  364. package/src/instance-state-derivation.test.ts +263 -0
  365. package/src/instance-state-derivation.ts +148 -0
  366. package/src/job-scheduler.test.ts +1236 -0
  367. package/src/job-scheduler.ts +1117 -0
  368. package/src/kaseki-api-client.ts +488 -0
  369. package/src/kaseki-api-config.test.ts +315 -0
  370. package/src/kaseki-api-config.ts +175 -0
  371. package/src/kaseki-api-routes.test.ts +1615 -0
  372. package/src/kaseki-api-routes.ts +643 -0
  373. package/src/kaseki-api-service-wrapper.ts +188 -0
  374. package/src/kaseki-api-service.test.ts +418 -0
  375. package/src/kaseki-api-service.ts +192 -0
  376. package/src/kaseki-api-types.ts +320 -0
  377. package/src/kaseki-cli-lib.test.ts +552 -0
  378. package/src/kaseki-cli-lib.ts +760 -0
  379. package/src/kaseki-cli.ts +682 -0
  380. package/src/kaseki-report.test.ts +118 -0
  381. package/src/kaseki-report.ts +192 -0
  382. package/src/lib/subprocess-helpers.ts +177 -0
  383. package/src/logger.ts +114 -0
  384. package/src/metrics.ts +66 -0
  385. package/src/middleware/job-lookup.test.ts +113 -0
  386. package/src/middleware/job-lookup.ts +45 -0
  387. package/src/pi-event-filter.test.ts +183 -0
  388. package/src/pi-event-filter.ts +183 -0
  389. package/src/pi-progress-stream.ts +287 -0
  390. package/src/pi-progress-summarizer.test.ts +302 -0
  391. package/src/pi-progress-summarizer.ts +287 -0
  392. package/src/pre-flight-validator.test.ts +512 -0
  393. package/src/pre-flight-validator.ts +618 -0
  394. package/src/progress-stream-utils.test.ts +35 -0
  395. package/src/progress-stream-utils.ts +14 -0
  396. package/src/result-cache.test.ts +195 -0
  397. package/src/result-cache.ts +181 -0
  398. package/src/routes/artifact-routes.ts +169 -0
  399. package/src/routes/log-routes.ts +391 -0
  400. package/src/routes/status-routes.ts +92 -0
  401. package/src/routes/webhook-routes.ts +97 -0
  402. package/src/run-artifact-metadata-cache.test.ts +80 -0
  403. package/src/run-artifact-metadata-cache.ts +184 -0
  404. package/src/secret-value-cache.test.ts +66 -0
  405. package/src/secret-value-cache.ts +55 -0
  406. package/src/secrets/SecretsManager.ts +343 -0
  407. package/src/test-utils.ts +81 -0
  408. package/src/timestamp-tracker.test.ts +134 -0
  409. package/src/timestamp-tracker.ts +132 -0
  410. package/src/utils/failure-artifact-writer.ts +187 -0
  411. package/src/utils/file-helpers.test.ts +235 -0
  412. package/src/utils/file-helpers.ts +150 -0
  413. package/src/utils/http-client-factory.test.ts +245 -0
  414. package/src/utils/http-client-factory.ts +157 -0
  415. package/src/utils/progress-normalizer.test.ts +442 -0
  416. package/src/utils/progress-normalizer.ts +68 -0
  417. package/src/utils/response-helpers.test.ts +122 -0
  418. package/src/utils/response-helpers.ts +101 -0
  419. package/src/utils/route-helpers.ts +30 -0
  420. package/src/utils/status-response-builder.ts +159 -0
  421. package/src/utils/type-guards.ts +52 -0
  422. package/src/utils/utf8-helpers.ts +102 -0
  423. package/src/utils/webhook-event-builder.test.ts +143 -0
  424. package/src/utils/webhook-event-builder.ts +87 -0
  425. package/src/webhook-manager.test.ts +152 -0
  426. package/src/webhook-manager.ts +445 -0
  427. package/templates/allowlist-api-route.txt +7 -0
  428. package/templates/allowlist-comprehensive.txt +8 -0
  429. package/templates/allowlist-parser-fix.txt +6 -0
  430. package/templates/allowlist-ui-component.txt +9 -0
  431. package/templates/allowlist-utility.txt +9 -0
  432. package/test/actual-model-metadata.test.sh +102 -0
  433. package/test/dry-run.test.sh +131 -0
  434. package/test/fixtures/kaseki-report-exit-codes/metadata-exit-0.json +1 -0
  435. package/test/fixtures/kaseki-report-exit-codes/metadata-exit-1.json +1 -0
  436. package/test/fixtures/kaseki-report-exit-codes/metadata-exit-invalid.json +1 -0
  437. package/test/fixtures/kaseki-report-exit-codes/metadata-exit-str-0.json +1 -0
  438. package/test/fixtures/kaseki-report-exit-codes/metadata-exit-str-1.json +1 -0
  439. package/test/kaseki-api.integration.test.sh +165 -0
  440. package/test/pi-event-filter-failure.test.sh +83 -0
  441. package/test/printf-safety-focused.test.sh +99 -0
  442. package/test/printf-safety-results/results/restoration.jsonl +10 -0
  443. package/test/printf-safety-results/results/test.jsonl +0 -0
  444. package/test/printf-safety.test.sh +297 -0
  445. package/test/validation-fix.test.sh +79 -0
  446. package/test/validation-integration.test.sh +109 -0
  447. package/tests/allowlist-glob.test.sh +61 -0
  448. package/tests/dependency-cache-key.test.sh +48 -0
  449. package/tests/dependency-restore-mode.test.sh +48 -0
  450. package/tests/doctor-template-parity.test.sh +95 -0
  451. package/tests/github-operations.test.sh +142 -0
  452. package/tests/npm-install-flags.test.sh +58 -0
  453. package/tests/quality-gates.test.sh +178 -0
  454. package/tests/repo-memory.test.sh +103 -0
  455. package/tests/restore-disallowed-changes.test.sh +80 -0
  456. package/tests/validation-missing-npm-scripts.test.sh +93 -0
  457. package/tests/validation-strict-mode.test.sh +118 -0
  458. package/tsconfig.changed.json +7 -0
  459. package/tsconfig.json +39 -0
@@ -0,0 +1,1615 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import express, { Express } from 'express';
4
+ import { AddressInfo, Server } from 'net';
5
+ import { classifyDockerFailure, decodeUtf8TailSafely, tailLogByLines } from './kaseki-api-routes';
6
+ import { readArtifactContent } from './routes/artifact-routes';
7
+ import { ResultCache } from './result-cache';
8
+ import { createApiRouter } from './kaseki-api-routes';
9
+ import { IdempotencyStore } from './idempotency-store';
10
+ import { PreFlightValidator } from './pre-flight-validator';
11
+ import {
12
+ createMockScheduler,
13
+ createTestConfig,
14
+ type TestScheduler,
15
+ } from './test-utils';
16
+
17
+ /**
18
+ * Complete test app setup for kaseki-api-routes testing.
19
+ * Returns { app, server, port, idempotencyStore, preFlightValidator }.
20
+ * Call server.close() in finally block to clean up.
21
+ *
22
+ * @param scheduler Mock scheduler (use createMockScheduler from test-utils)
23
+ * @param config Test config (use createTestConfig from test-utils)
24
+ * @returns Object with Express app, HTTP server, port number, and stores
25
+ */
26
+ async function createTestApp(
27
+ scheduler: TestScheduler,
28
+ config: ReturnType<typeof createTestConfig>,
29
+ ): Promise<{
30
+ app: Express;
31
+ server: Server;
32
+ port: number;
33
+ idempotencyStore: IdempotencyStore;
34
+ preFlightValidator: PreFlightValidator;
35
+ }> {
36
+ const idempotencyStore = new IdempotencyStore(config.resultsDir, 24);
37
+ const preFlightValidator = new PreFlightValidator();
38
+
39
+ const app = express();
40
+ app.use(express.json());
41
+ app.use('/api', createApiRouter(scheduler as any, config, idempotencyStore, preFlightValidator));
42
+
43
+ const server = app.listen(0);
44
+ const port = (server.address() as AddressInfo).port;
45
+
46
+ return {
47
+ app,
48
+ server,
49
+ port,
50
+ idempotencyStore,
51
+ preFlightValidator,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Clean shutdown of server and idempotency store.
57
+ */
58
+ async function cleanupTestApp(server: Server, idempotencyStore: IdempotencyStore): Promise<void> {
59
+ await new Promise<void>((resolve) => server.close(() => resolve()));
60
+ await idempotencyStore.shutdown();
61
+ }
62
+
63
+ describe('kaseki-api-routes log truncation helpers', () => {
64
+ test('decodeUtf8TailSafely trims incomplete 2-byte sequence split at chunk boundary', () => {
65
+ const input = Buffer.concat([Buffer.from('cafe ', 'utf-8'), Buffer.from([0xc3])]);
66
+ expect(decodeUtf8TailSafely(input)).toBe('cafe ');
67
+ });
68
+
69
+ test('decodeUtf8TailSafely trims incomplete 3-byte sequence split at chunk boundary', () => {
70
+ const input = Buffer.concat([Buffer.from('prefix ', 'utf-8'), Buffer.from([0xe4, 0xbd])]);
71
+ expect(decodeUtf8TailSafely(input)).toBe('prefix ');
72
+ });
73
+
74
+ test('decodeUtf8TailSafely trims incomplete 4-byte sequence split at chunk boundary', () => {
75
+ const input = Buffer.concat([Buffer.from('emoji ', 'utf-8'), Buffer.from([0xf0, 0x9f, 0x98])]);
76
+ expect(decodeUtf8TailSafely(input)).toBe('emoji ');
77
+ });
78
+
79
+ test('decodeUtf8TailSafely keeps chunks that start with continuation bytes when tail is complete', () => {
80
+ const input = Buffer.concat([Buffer.from([0x98, 0x80]), Buffer.from('alpha 你好 😀 beta', 'utf-8')]);
81
+ expect(decodeUtf8TailSafely(input)).toBe('��alpha 你好 😀 beta');
82
+ });
83
+
84
+ test('decodeUtf8TailSafely keeps pure ASCII tails unchanged', () => {
85
+ const input = Buffer.from('line1\nline2\nASCII tail', 'utf-8');
86
+ expect(decodeUtf8TailSafely(input)).toBe('line1\nline2\nASCII tail');
87
+ });
88
+
89
+ test.each([
90
+ {
91
+ name: 'empty content',
92
+ content: '',
93
+ lineCount: 3,
94
+ expected: '',
95
+ },
96
+ {
97
+ name: 'exact boundary',
98
+ content: 'a\nb\nc',
99
+ lineCount: 3,
100
+ expected: 'a\nb\nc',
101
+ },
102
+ {
103
+ name: 'over-requested lines',
104
+ content: 'a\nb\nc',
105
+ lineCount: 10,
106
+ expected: 'a\nb\nc',
107
+ },
108
+ {
109
+ name: 'CRLF input',
110
+ content: 'a\r\nb\r\nc\r\nd',
111
+ lineCount: 2,
112
+ expected: 'c\nd',
113
+ },
114
+ {
115
+ name: 'trailing newline handling',
116
+ content: 'a\nb\nc\n',
117
+ lineCount: 2,
118
+ expected: 'c\n',
119
+ },
120
+ ])('tailLogByLines handles $name', ({ content, lineCount, expected }) => {
121
+ expect(tailLogByLines(content, lineCount)).toBe(expected);
122
+ });
123
+ });
124
+
125
+ describe('kaseki-api-routes artifact read behavior', () => {
126
+ let testDir: string;
127
+ let artifactPath: string;
128
+ let cache: ResultCache;
129
+
130
+ beforeEach(() => {
131
+ testDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-test-'));
132
+ artifactPath = path.join(testDir, 'pi-summary.json');
133
+ cache = new ResultCache(10, 60_000);
134
+ });
135
+
136
+ afterEach(() => {
137
+ fs.rmSync(testDir, { recursive: true, force: true });
138
+ });
139
+
140
+ test('returns fresh artifact content for running jobs when file changes between reads', () => {
141
+ fs.writeFileSync(artifactPath, '{"version":1}');
142
+ const firstRead = readArtifactContent(artifactPath, 'running', cache);
143
+ expect(firstRead).toBe('{"version":1}');
144
+
145
+ fs.writeFileSync(artifactPath, '{"version":2}');
146
+ const secondRead = readArtifactContent(artifactPath, 'running', cache);
147
+ expect(secondRead).toBe('{"version":2}');
148
+ });
149
+ });
150
+
151
+ describe('kaseki-api-routes readiness and metrics endpoints', () => {
152
+ let resultsDir: string;
153
+
154
+ beforeEach(() => {
155
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-ready-metrics-test-'));
156
+ });
157
+
158
+ afterEach(() => {
159
+ fs.rmSync(resultsDir, { recursive: true, force: true });
160
+ });
161
+
162
+ test('GET /api/ready returns 200 when scheduler dependencies are ready', async () => {
163
+ const scheduler = createMockScheduler();
164
+ scheduler.getReadiness.mockReturnValue({ ready: true, reasons: [] });
165
+ const config = createTestConfig(resultsDir);
166
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
167
+
168
+ try {
169
+ const res = await fetch(`http://127.0.0.1:${port}/api/ready`);
170
+ expect(res.status).toBe(200);
171
+ const body = (await res.json()) as any;
172
+ expect(body.status).toBe('ready');
173
+ } finally {
174
+ await cleanupTestApp(server, idempotencyStore);
175
+ }
176
+ });
177
+
178
+ test('GET /api/ready returns 503 with machine-readable reasons when not ready', async () => {
179
+ const scheduler = createMockScheduler();
180
+ scheduler.getReadiness.mockReturnValue({ ready: false, reasons: ['results_dir_unwritable:EACCES'] });
181
+ const config = createTestConfig(resultsDir);
182
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
183
+
184
+ try {
185
+ const res = await fetch(`http://127.0.0.1:${port}/api/ready`);
186
+ expect(res.status).toBe(503);
187
+ const body = (await res.json()) as any;
188
+ expect(body.status).toBe('not_ready');
189
+ expect(body.reasons).toContain('results_dir_unwritable:EACCES');
190
+ } finally {
191
+ await cleanupTestApp(server, idempotencyStore);
192
+ }
193
+ });
194
+
195
+ test('GET /api/metrics returns prometheus content type and expected metric keys', async () => {
196
+ const scheduler = createMockScheduler();
197
+ const config = createTestConfig(resultsDir);
198
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
199
+
200
+ try {
201
+ const res = await fetch(`http://127.0.0.1:${port}/api/metrics`, {
202
+ headers: { Authorization: 'Bearer test-key' },
203
+ });
204
+ expect(res.status).toBe(200);
205
+ expect(res.headers.get('content-type')).toContain('text/plain');
206
+ const body = await res.text();
207
+ expect(body).toContain('kaseki_queue_pending');
208
+ expect(body).toContain('kaseki_runs_total');
209
+ expect(body).toContain('kaseki_run_duration_seconds');
210
+ } finally {
211
+ await cleanupTestApp(server, idempotencyStore);
212
+ }
213
+ });
214
+ });
215
+
216
+ describe('kaseki-api-routes preflight diagnostics', () => {
217
+ const githubEnvKeys = [
218
+ 'GITHUB_APP_ID',
219
+ 'GITHUB_APP_ID_FILE',
220
+ 'GITHUB_APP_CLIENT_ID',
221
+ 'GITHUB_APP_CLIENT_ID_FILE',
222
+ 'GITHUB_APP_PRIVATE_KEY',
223
+ 'GITHUB_APP_PRIVATE_KEY_FILE',
224
+ 'OPENROUTER_API_KEY',
225
+ ];
226
+
227
+ function restoreEnv(snapshot: Record<string, string | undefined>): void {
228
+ for (const key of githubEnvKeys) {
229
+ const value = snapshot[key];
230
+ if (value === undefined) {
231
+ delete process.env[key];
232
+ } else {
233
+ process.env[key] = value;
234
+ }
235
+ }
236
+ }
237
+
238
+ test('classifies Docker socket permission failures with actionable remediation', () => {
239
+ const result = classifyDockerFailure(
240
+ 'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock'
241
+ );
242
+
243
+ expect(result.detail).toMatch(/socket is not accessible/);
244
+ expect(result.remediation).toMatch(/group_add/);
245
+ });
246
+
247
+ test('classifies unreachable Docker daemon separately from image misses', () => {
248
+ const result = classifyDockerFailure('Cannot connect to the Docker daemon at unix:///var/run/docker.sock');
249
+
250
+ expect(result.detail).toMatch(/unreachable/);
251
+ expect(result.remediation).toMatch(/daemon/);
252
+ });
253
+
254
+ test('GET /api/preflight reports readable GitHub App file credentials', async () => {
255
+ const snapshot = Object.fromEntries(githubEnvKeys.map((key) => [key, process.env[key]]));
256
+ const resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-preflight-github-'));
257
+ const secretsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-preflight-secrets-'));
258
+ const appIdFile = path.join(secretsDir, 'github_app_id');
259
+ const clientIdFile = path.join(secretsDir, 'github_client_id');
260
+ const keyFile = path.join(secretsDir, 'github_app_private_key');
261
+ fs.writeFileSync(appIdFile, '12345\n');
262
+ fs.writeFileSync(clientIdFile, 'Iv123client\n');
263
+ fs.writeFileSync(keyFile, '-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----\n');
264
+ process.env.OPENROUTER_API_KEY = 'test-openrouter-key';
265
+ delete process.env.GITHUB_APP_ID;
266
+ delete process.env.GITHUB_APP_CLIENT_ID;
267
+ delete process.env.GITHUB_APP_PRIVATE_KEY;
268
+ process.env.GITHUB_APP_ID_FILE = appIdFile;
269
+ process.env.GITHUB_APP_CLIENT_ID_FILE = clientIdFile;
270
+ process.env.GITHUB_APP_PRIVATE_KEY_FILE = keyFile;
271
+
272
+ const scheduler = createMockScheduler();
273
+ const config = createTestConfig(resultsDir);
274
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
275
+
276
+ try {
277
+ const res = await fetch(`http://127.0.0.1:${port}/api/preflight`, {
278
+ headers: { Authorization: 'Bearer test-key' },
279
+ });
280
+ expect([200, 503]).toContain(res.status);
281
+ const body = (await res.json()) as any;
282
+ const githubCheck = body.checks.find((check: any) => check.name === 'github-app');
283
+ expect(githubCheck).toEqual(expect.objectContaining({
284
+ ok: true,
285
+ detail: expect.stringContaining('GitHub App credentials are readable'),
286
+ }));
287
+ } finally {
288
+ await cleanupTestApp(server, idempotencyStore);
289
+ fs.rmSync(resultsDir, { recursive: true, force: true });
290
+ fs.rmSync(secretsDir, { recursive: true, force: true });
291
+ restoreEnv(snapshot);
292
+ }
293
+ });
294
+
295
+ test('GET /api/preflight flags incomplete GitHub App configuration', async () => {
296
+ const snapshot = Object.fromEntries(githubEnvKeys.map((key) => [key, process.env[key]]));
297
+ const resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-preflight-github-missing-'));
298
+ process.env.OPENROUTER_API_KEY = 'test-openrouter-key';
299
+ process.env.GITHUB_APP_ID = '12345';
300
+ delete process.env.GITHUB_APP_ID_FILE;
301
+ delete process.env.GITHUB_APP_CLIENT_ID;
302
+ delete process.env.GITHUB_APP_CLIENT_ID_FILE;
303
+ delete process.env.GITHUB_APP_PRIVATE_KEY;
304
+ delete process.env.GITHUB_APP_PRIVATE_KEY_FILE;
305
+
306
+ const scheduler = createMockScheduler();
307
+ const config = createTestConfig(resultsDir);
308
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
309
+
310
+ try {
311
+ const res = await fetch(`http://127.0.0.1:${port}/api/preflight`, {
312
+ headers: { Authorization: 'Bearer test-key' },
313
+ });
314
+ expect([200, 503]).toContain(res.status);
315
+ const body = (await res.json()) as any;
316
+ const githubCheck = body.checks.find((check: any) => check.name === 'github-app');
317
+ expect(githubCheck).toEqual(expect.objectContaining({
318
+ ok: false,
319
+ detail: expect.stringContaining('GitHub App credentials are incomplete'),
320
+ }));
321
+ } finally {
322
+ await cleanupTestApp(server, idempotencyStore);
323
+ fs.rmSync(resultsDir, { recursive: true, force: true });
324
+ restoreEnv(snapshot);
325
+ }
326
+ });
327
+ });
328
+
329
+ describe('kaseki-api-routes tail file descriptor cleanup', () => {
330
+ afterEach(() => {
331
+ jest.resetModules();
332
+ jest.dontMock('fs');
333
+ });
334
+
335
+ test('closes file descriptor when readSync throws', () => {
336
+ const closeSyncMock = jest.fn();
337
+
338
+ jest.isolateModules(() => {
339
+ jest.doMock('fs', () => ({
340
+ ...jest.requireActual('fs'),
341
+ openSync: jest.fn(() => 42),
342
+ readSync: jest.fn(() => {
343
+ throw new Error('read failed');
344
+ }),
345
+ closeSync: closeSyncMock,
346
+ }));
347
+
348
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
349
+ const { readTailBytes } = require('./kaseki-api-routes') as typeof import('./kaseki-api-routes');
350
+ expect(() => readTailBytes('/tmp/fake.log', 200, 100)).toThrow('read failed');
351
+ });
352
+
353
+ expect(closeSyncMock).toHaveBeenCalledWith(42);
354
+ });
355
+ });
356
+
357
+ describe('kaseki-api-routes results artifacts endpoint', () => {
358
+ let resultsDir: string;
359
+
360
+ beforeEach(() => {
361
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-api-test-'));
362
+ });
363
+
364
+ afterEach(() => {
365
+ fs.rmSync(resultsDir, { recursive: true, force: true });
366
+ });
367
+
368
+ test('failed run can retrieve failure diagnostics artifacts', async () => {
369
+ const jobId = 'kaseki-failed-1';
370
+ const jobDir = path.join(resultsDir, jobId);
371
+ fs.mkdirSync(jobDir, { recursive: true });
372
+ fs.writeFileSync(path.join(jobDir, 'failure.json'), JSON.stringify({ failureClass: 'validation' }));
373
+ fs.writeFileSync(path.join(jobDir, 'stderr.log'), 'stderr output');
374
+ fs.writeFileSync(path.join(jobDir, 'stdout.log'), 'stdout output');
375
+
376
+ const scheduler = createMockScheduler({
377
+ [jobId]: { id: jobId, status: 'failed', createdAt: new Date(), resultDir: jobDir },
378
+ });
379
+ const config = createTestConfig(resultsDir);
380
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
381
+ const headers = { Authorization: 'Bearer test-key' };
382
+
383
+ try {
384
+ const failureRes = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/failure.json`, { headers });
385
+ expect(failureRes.status).toBe(200);
386
+ const failureBody = (await failureRes.json()) as any;
387
+ expect(failureBody.file).toBe('failure.json');
388
+ expect(failureBody.contentType).toBe('application/json');
389
+
390
+ const stderrRes = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/stderr.log`, { headers });
391
+ expect(stderrRes.status).toBe(200);
392
+ const stderrBody = (await stderrRes.json()) as any;
393
+ expect(stderrBody.file).toBe('stderr.log');
394
+ expect(stderrBody.contentType).toBe('text/plain');
395
+ expect(stderrBody.content).toBe('stderr output');
396
+
397
+ const stdoutRes = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/stdout.log`, { headers });
398
+ expect(stdoutRes.status).toBe(200);
399
+ const stdoutBody = (await stdoutRes.json()) as any;
400
+ expect(stdoutBody.file).toBe('stdout.log');
401
+ expect(stdoutBody.content).toBe('stdout output');
402
+ } finally {
403
+ await cleanupTestApp(server, idempotencyStore);
404
+ }
405
+ });
406
+
407
+ test('non-failed run is blocked from retrieving failure diagnostics artifacts', async () => {
408
+ const jobId = 'kaseki-running-1';
409
+ const jobDir = path.join(resultsDir, jobId);
410
+ fs.mkdirSync(jobDir, { recursive: true });
411
+ fs.writeFileSync(path.join(jobDir, 'failure.json'), JSON.stringify({ failureClass: 'validation' }));
412
+
413
+ const scheduler = createMockScheduler({
414
+ [jobId]: { id: jobId, status: 'running' as const, createdAt: new Date(), resultDir: jobDir },
415
+ });
416
+ const config = createTestConfig(resultsDir);
417
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
418
+ const headers = { Authorization: 'Bearer test-key' };
419
+
420
+ try {
421
+ const failureRes = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/failure.json`, { headers });
422
+ expect(failureRes.status).toBe(400);
423
+ const failureBody = (await failureRes.json()) as any;
424
+ expect(failureBody.title).toBe('Bad Request');
425
+ expect(failureBody.status).toBe(400);
426
+ expect(failureBody.detail).toContain('Artifact only available for failed runs: failure.json');
427
+ } finally {
428
+ await cleanupTestApp(server, idempotencyStore);
429
+ }
430
+ });
431
+ });
432
+
433
+ describe('kaseki-api-routes run artifacts inventory endpoint', () => {
434
+ let resultsDir: string;
435
+
436
+ beforeEach(() => {
437
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-artifacts-test-'));
438
+ });
439
+
440
+ afterEach(() => {
441
+ fs.rmSync(resultsDir, { recursive: true, force: true });
442
+ });
443
+
444
+ test('failed run reports partial artifacts with failure-triage recommendations', async () => {
445
+ const jobId = 'kaseki-failed-artifacts';
446
+ const jobDir = path.join(resultsDir, jobId);
447
+ fs.mkdirSync(jobDir, { recursive: true });
448
+ fs.writeFileSync(path.join(jobDir, 'failure.json'), JSON.stringify({ failureClass: 'validation' }));
449
+ fs.writeFileSync(path.join(jobDir, 'stderr.log'), 'stderr output');
450
+ fs.writeFileSync(path.join(jobDir, 'result-summary.md'), '# summary');
451
+
452
+ const scheduler = {
453
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
454
+ getJob: (id: string) =>
455
+ id === jobId
456
+ ? { id: jobId, status: 'failed', createdAt: new Date(), resultDir: jobDir, exitCode: 1 }
457
+ : undefined,
458
+ submitJob: jest.fn(),
459
+ listJobs: () => [],
460
+ cancelJob: jest.fn(),
461
+ } as any;
462
+
463
+ const config = {
464
+ port: 0,
465
+ apiKeys: ['test-key'],
466
+ resultsDir,
467
+ maxConcurrentRuns: 1,
468
+ defaultTaskMode: 'patch' as const,
469
+ maxDiffBytes: 200000,
470
+ agentTimeoutSeconds: 1200,
471
+ logLevel: 'info' as const,
472
+ };
473
+
474
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
475
+ const preFlightValidator = new PreFlightValidator();
476
+
477
+ const app = express();
478
+ app.use(express.json());
479
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
480
+ const server = app.listen(0);
481
+ const port = (server.address() as AddressInfo).port;
482
+
483
+ try {
484
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/artifacts`, {
485
+ headers: { Authorization: 'Bearer test-key' },
486
+ });
487
+ expect(response.status).toBe(200);
488
+ const body = (await response.json()) as any;
489
+ expect(body.runStatus).toBe('failed');
490
+ expect(body.exitCode).toBe(1);
491
+ expect(body.recommended).toContain('failure.json');
492
+ expect(body.recommended).toContain('stderr.log');
493
+ const failureFile = body.artifacts.find((artifact: any) => artifact.name === 'failure.json');
494
+ const missingFile = body.artifacts.find((artifact: any) => artifact.name === 'stdout.log');
495
+ expect(failureFile.available).toBe(true);
496
+ expect(missingFile.available).toBe(false);
497
+ } finally {
498
+ await new Promise<void>((resolve) => server.close(() => resolve()));
499
+ await idempotencyStore.shutdown();
500
+ }
501
+ });
502
+
503
+ test('running run does not expose failure-only artifacts as available', async () => {
504
+ const jobId = 'kaseki-running-artifacts';
505
+ const fallbackDir = path.join(resultsDir, jobId);
506
+ fs.mkdirSync(fallbackDir, { recursive: true });
507
+ fs.writeFileSync(path.join(fallbackDir, 'result-summary.md'), '# running summary');
508
+ fs.writeFileSync(path.join(fallbackDir, 'stderr.log'), 'not yet final');
509
+
510
+ const scheduler = {
511
+ getQueueStatus: () => ({ pending: 0, running: 1, maxConcurrent: 1 }),
512
+ getJob: (id: string) => (id === jobId ? { id: jobId, status: 'running', createdAt: new Date() } : undefined),
513
+ submitJob: jest.fn(),
514
+ listJobs: () => [],
515
+ cancelJob: jest.fn(),
516
+ } as any;
517
+
518
+ const config = {
519
+ port: 0,
520
+ apiKeys: ['test-key'],
521
+ resultsDir,
522
+ maxConcurrentRuns: 1,
523
+ defaultTaskMode: 'patch' as const,
524
+ maxDiffBytes: 200000,
525
+ agentTimeoutSeconds: 1200,
526
+ logLevel: 'info' as const,
527
+ };
528
+
529
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
530
+ const preFlightValidator = new PreFlightValidator();
531
+
532
+ const app = express();
533
+ app.use(express.json());
534
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
535
+ const server = app.listen(0);
536
+ const port = (server.address() as AddressInfo).port;
537
+
538
+ try {
539
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/artifacts`, {
540
+ headers: { Authorization: 'Bearer test-key' },
541
+ });
542
+ expect(response.status).toBe(200);
543
+ const body = (await response.json()) as any;
544
+ expect(body.runStatus).toBe('running');
545
+ expect(body.recommended).toContain('result-summary.md');
546
+ const stderrFile = body.artifacts.find((artifact: any) => artifact.name === 'stderr.log');
547
+ const summaryFile = body.artifacts.find((artifact: any) => artifact.name === 'result-summary.md');
548
+ expect(stderrFile.available).toBe(false);
549
+ expect(summaryFile.available).toBe(true);
550
+ } finally {
551
+ await new Promise<void>((resolve) => server.close(() => resolve()));
552
+ await idempotencyStore.shutdown();
553
+ }
554
+ });
555
+
556
+ test('returns 404 when run does not exist', async () => {
557
+ const scheduler = {
558
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
559
+ getJob: () => undefined,
560
+ submitJob: jest.fn(),
561
+ listJobs: () => [],
562
+ cancelJob: jest.fn(),
563
+ } as any;
564
+
565
+ const config = {
566
+ port: 0,
567
+ apiKeys: ['test-key'],
568
+ resultsDir: fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-notfound-test-')),
569
+ maxConcurrentRuns: 1,
570
+ defaultTaskMode: 'patch' as const,
571
+ maxDiffBytes: 200000,
572
+ agentTimeoutSeconds: 1200,
573
+ logLevel: 'info' as const,
574
+ };
575
+
576
+ const idempotencyStore = new IdempotencyStore(config.resultsDir, 24);
577
+ const preFlightValidator = new PreFlightValidator();
578
+
579
+ const app = express();
580
+ app.use(express.json());
581
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
582
+ const server = app.listen(0);
583
+ const port = (server.address() as AddressInfo).port;
584
+ try {
585
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/missing-run/artifacts`, {
586
+ headers: { Authorization: 'Bearer test-key' },
587
+ });
588
+ expect(response.status).toBe(404);
589
+ } finally {
590
+ await new Promise<void>((resolve) => server.close(() => resolve()));
591
+ await idempotencyStore.shutdown();
592
+ }
593
+ });
594
+ });
595
+
596
+ describe('kaseki-api-routes logs endpoint stderr fallback', () => {
597
+ let resultsDir: string;
598
+
599
+ beforeEach(() => {
600
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-logs-test-'));
601
+ });
602
+
603
+ afterEach(() => {
604
+ fs.rmSync(resultsDir, { recursive: true, force: true });
605
+ });
606
+
607
+ test('failed run with missing stderr returns synthetic fallback payload', async () => {
608
+ const jobId = 'kaseki-failed-missing-stderr';
609
+ const jobDir = path.join(resultsDir, jobId);
610
+ fs.mkdirSync(jobDir, { recursive: true });
611
+
612
+ const scheduler = {
613
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
614
+ getJob: (id: string) =>
615
+ id === jobId
616
+ ? {
617
+ id: jobId,
618
+ status: 'failed',
619
+ createdAt: new Date(),
620
+ resultDir: jobDir,
621
+ exitCode: 17,
622
+ failureClass: 'validator_error',
623
+ error: 'Validation step crashed',
624
+ }
625
+ : undefined,
626
+ submitJob: jest.fn(),
627
+ listJobs: () => [],
628
+ cancelJob: jest.fn(),
629
+ } as any;
630
+
631
+ const config = {
632
+ port: 0,
633
+ apiKeys: ['test-key'],
634
+ resultsDir,
635
+ maxConcurrentRuns: 1,
636
+ defaultTaskMode: 'patch' as const,
637
+ maxDiffBytes: 200000,
638
+ agentTimeoutSeconds: 1200,
639
+ logLevel: 'info' as const,
640
+ };
641
+
642
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
643
+ const preFlightValidator = new PreFlightValidator();
644
+ const app = express();
645
+ app.use(express.json());
646
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
647
+ const server = app.listen(0);
648
+ const port = (server.address() as AddressInfo).port;
649
+
650
+ try {
651
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/logs/stderr`, {
652
+ headers: { Authorization: 'Bearer test-key' },
653
+ });
654
+ expect(response.status).toBe(200);
655
+ const body = (await response.json()) as any;
656
+ expect(body.logType).toBe('stderr');
657
+ expect(body.content).toContain(`job id: ${jobId}`);
658
+ expect(body.content).toContain('exit code: 17');
659
+ expect(body.content).toContain('failure class: validator_error');
660
+ expect(body.content).toContain('job.error: Validation step crashed');
661
+ expect(body.content).toContain('canonical stderr.log was not generated');
662
+ expect(body.size).toBeGreaterThan(0);
663
+ } finally {
664
+ await new Promise<void>((resolve) => server.close(() => resolve()));
665
+ await idempotencyStore.shutdown();
666
+ }
667
+ });
668
+
669
+ test('non-failed run with missing stderr remains 404', async () => {
670
+ const jobId = 'kaseki-running-missing-stderr';
671
+ const jobDir = path.join(resultsDir, jobId);
672
+ fs.mkdirSync(jobDir, { recursive: true });
673
+
674
+ const scheduler = {
675
+ getQueueStatus: () => ({ pending: 0, running: 1, maxConcurrent: 1 }),
676
+ getJob: (id: string) =>
677
+ id === jobId
678
+ ? {
679
+ id: jobId,
680
+ status: 'running',
681
+ createdAt: new Date(),
682
+ resultDir: jobDir,
683
+ }
684
+ : undefined,
685
+ submitJob: jest.fn(),
686
+ listJobs: () => [],
687
+ cancelJob: jest.fn(),
688
+ } as any;
689
+
690
+ const config = {
691
+ port: 0,
692
+ apiKeys: ['test-key'],
693
+ resultsDir,
694
+ maxConcurrentRuns: 1,
695
+ defaultTaskMode: 'patch' as const,
696
+ maxDiffBytes: 200000,
697
+ agentTimeoutSeconds: 1200,
698
+ logLevel: 'info' as const,
699
+ };
700
+
701
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
702
+ const preFlightValidator = new PreFlightValidator();
703
+ const app = express();
704
+ app.use(express.json());
705
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
706
+ const server = app.listen(0);
707
+ const port = (server.address() as AddressInfo).port;
708
+
709
+ try {
710
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/logs/stderr`, {
711
+ headers: { Authorization: 'Bearer test-key' },
712
+ });
713
+ expect(response.status).toBe(404);
714
+ const body = (await response.json()) as any;
715
+ expect(body.detail).toContain('Log file not found: stderr');
716
+ } finally {
717
+ await new Promise<void>((resolve) => server.close(() => resolve()));
718
+ await idempotencyStore.shutdown();
719
+ }
720
+ });
721
+ });
722
+
723
+ describe('kaseki-api-routes controller replay and events', () => {
724
+ let resultsDir: string;
725
+
726
+ beforeEach(() => {
727
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-controller-test-'));
728
+ });
729
+
730
+ afterEach(() => {
731
+ fs.rmSync(resultsDir, { recursive: true, force: true });
732
+ });
733
+
734
+ test('idempotency replay returns the current job status instead of the original queued response', async () => {
735
+ const job = {
736
+ id: 'kaseki-99',
737
+ status: 'queued',
738
+ createdAt: new Date(),
739
+ resultDir: path.join(resultsDir, 'kaseki-99'),
740
+ correlationId: '11111111-1111-4111-8111-111111111111',
741
+ requestId: '22222222-2222-4222-8222-222222222222',
742
+ } as any;
743
+
744
+ const scheduler = {
745
+ getQueueStatus: () => ({ pending: 1, running: 0, maxConcurrent: 1 }),
746
+ getJob: (id: string) => (id === job.id ? job : undefined),
747
+ submitJob: jest.fn(() => job),
748
+ listJobs: () => [job],
749
+ cancelJob: jest.fn(),
750
+ } as any;
751
+
752
+ const config = {
753
+ port: 0,
754
+ apiKeys: ['test-key'],
755
+ resultsDir,
756
+ maxConcurrentRuns: 1,
757
+ defaultTaskMode: 'patch' as const,
758
+ maxDiffBytes: 200000,
759
+ agentTimeoutSeconds: 1200,
760
+ logLevel: 'info' as const,
761
+ };
762
+
763
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
764
+ const preFlightValidator = new PreFlightValidator();
765
+ const app = express();
766
+ app.use(express.json());
767
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
768
+ const server = app.listen(0);
769
+ const port = (server.address() as AddressInfo).port;
770
+ const headers = { Authorization: 'Bearer test-key', 'Content-Type': 'application/json' };
771
+ const body = JSON.stringify({
772
+ repoUrl: 'https://github.com/org/repo',
773
+ ref: 'main',
774
+ idempotencyKey: '33333333-3333-4333-8333-333333333333',
775
+ });
776
+
777
+ try {
778
+ const first = await fetch(`http://127.0.0.1:${port}/api/runs`, { method: 'POST', headers, body });
779
+ expect(first.status).toBe(202);
780
+
781
+ job.status = 'failed';
782
+ job.completedAt = new Date();
783
+ job.exitCode = 143;
784
+ job.failureClass = 'cancelled';
785
+ job.error = 'Job cancelled by API request';
786
+
787
+ const replay = await fetch(`http://127.0.0.1:${port}/api/runs`, { method: 'POST', headers, body });
788
+ expect(replay.status).toBe(200);
789
+ const replayBody = (await replay.json()) as any;
790
+ expect(replayBody).toMatchObject({
791
+ id: job.id,
792
+ status: 'failed',
793
+ cached: true,
794
+ exitCode: 143,
795
+ failureClass: 'cancelled',
796
+ });
797
+ } finally {
798
+ await new Promise<void>((resolve) => server.close(() => resolve()));
799
+ await idempotencyStore.shutdown();
800
+ }
801
+ });
802
+
803
+ test('events endpoint falls back to live docker progress for active runs', async () => {
804
+ const jobId = 'kaseki-live-events';
805
+ const scheduler = {
806
+ getQueueStatus: () => ({ pending: 0, running: 1, maxConcurrent: 1 }),
807
+ getJob: (id: string) => (id === jobId ? { id: jobId, status: 'running', createdAt: new Date() } : undefined),
808
+ getLiveProgressEvents: jest.fn(() => [{ source: 'docker-logs', stage: 'startup check', message: 'container booted' }]),
809
+ submitJob: jest.fn(),
810
+ listJobs: () => [],
811
+ cancelJob: jest.fn(),
812
+ } as any;
813
+
814
+ const config = {
815
+ port: 0,
816
+ apiKeys: ['test-key'],
817
+ resultsDir,
818
+ maxConcurrentRuns: 1,
819
+ defaultTaskMode: 'patch' as const,
820
+ maxDiffBytes: 200000,
821
+ agentTimeoutSeconds: 1200,
822
+ logLevel: 'info' as const,
823
+ };
824
+
825
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
826
+ const preFlightValidator = new PreFlightValidator();
827
+ const app = express();
828
+ app.use(express.json());
829
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
830
+ const server = app.listen(0);
831
+ const port = (server.address() as AddressInfo).port;
832
+
833
+ try {
834
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/events`, {
835
+ headers: { Authorization: 'Bearer test-key' },
836
+ });
837
+ expect(response.status).toBe(200);
838
+ const body = (await response.json()) as any;
839
+ expect(body.sources).toEqual(['docker-logs']);
840
+ expect(body.events[0]).toMatchObject({ stage: 'startup check', message: 'container booted' });
841
+ } finally {
842
+ await new Promise<void>((resolve) => server.close(() => resolve()));
843
+ await idempotencyStore.shutdown();
844
+ }
845
+ });
846
+
847
+ test('artifact listing treats zero-byte diagnostics as unavailable', async () => {
848
+ const jobId = 'kaseki-zero-artifacts';
849
+ const jobDir = path.join(resultsDir, jobId);
850
+ fs.mkdirSync(jobDir, { recursive: true });
851
+ fs.writeFileSync(path.join(jobDir, 'failure.json'), '');
852
+ fs.writeFileSync(path.join(jobDir, 'stderr.log'), 'stderr');
853
+
854
+ const scheduler = {
855
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
856
+ getJob: (id: string) => (id === jobId ? { id: jobId, status: 'failed', createdAt: new Date(), resultDir: jobDir } : undefined),
857
+ submitJob: jest.fn(),
858
+ listJobs: () => [],
859
+ cancelJob: jest.fn(),
860
+ } as any;
861
+
862
+ const config = {
863
+ port: 0,
864
+ apiKeys: ['test-key'],
865
+ resultsDir,
866
+ maxConcurrentRuns: 1,
867
+ defaultTaskMode: 'patch' as const,
868
+ maxDiffBytes: 200000,
869
+ agentTimeoutSeconds: 1200,
870
+ logLevel: 'info' as const,
871
+ };
872
+
873
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
874
+ const preFlightValidator = new PreFlightValidator();
875
+ const app = express();
876
+ app.use(express.json());
877
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
878
+ const server = app.listen(0);
879
+ const port = (server.address() as AddressInfo).port;
880
+
881
+ try {
882
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/artifacts`, {
883
+ headers: { Authorization: 'Bearer test-key' },
884
+ });
885
+ expect(response.status).toBe(200);
886
+ const body = (await response.json()) as any;
887
+ const failureFile = body.artifacts.find((artifact: any) => artifact.name === 'failure.json');
888
+ const stderrFile = body.artifacts.find((artifact: any) => artifact.name === 'stderr.log');
889
+ expect(failureFile.available).toBe(false);
890
+ expect(stderrFile.available).toBe(true);
891
+ } finally {
892
+ await new Promise<void>((resolve) => server.close(() => resolve()));
893
+ await idempotencyStore.shutdown();
894
+ }
895
+ });
896
+ });
897
+
898
+ describe('kaseki-api-routes status artifact hints', () => {
899
+ let resultsDir: string;
900
+
901
+ beforeEach(() => {
902
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-status-test-'));
903
+ });
904
+
905
+ afterEach(() => {
906
+ fs.rmSync(resultsDir, { recursive: true, force: true });
907
+ });
908
+
909
+ test('failed run reports deterministic artifact availability and prefers failure.json as diagnostic entrypoint', async () => {
910
+ const jobId = 'kaseki-failed-status-1';
911
+ const jobDir = path.join(resultsDir, jobId);
912
+ fs.mkdirSync(jobDir, { recursive: true });
913
+ fs.writeFileSync(path.join(jobDir, 'metadata.json'), '{"id":"meta"}');
914
+ fs.writeFileSync(path.join(jobDir, 'failure.json'), '{"failureClass":"validation"}');
915
+ fs.writeFileSync(path.join(jobDir, 'stderr.log'), 'stderr');
916
+
917
+ const scheduler = {
918
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
919
+ getJob: (id: string) =>
920
+ id === jobId
921
+ ? { id: jobId, status: 'failed', createdAt: new Date(), resultDir: jobDir, exitCode: 1 }
922
+ : undefined,
923
+ submitJob: jest.fn(),
924
+ listJobs: () => [],
925
+ cancelJob: jest.fn(),
926
+ } as any;
927
+
928
+ const config = {
929
+ port: 0,
930
+ apiKeys: ['test-key'],
931
+ resultsDir,
932
+ maxConcurrentRuns: 1,
933
+ defaultTaskMode: 'patch' as const,
934
+ maxDiffBytes: 200000,
935
+ agentTimeoutSeconds: 1200,
936
+ logLevel: 'info' as const,
937
+ };
938
+
939
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
940
+ const preFlightValidator = new PreFlightValidator();
941
+
942
+ const app = express();
943
+ app.use(express.json());
944
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
945
+ const server = app.listen(0);
946
+ const port = (server.address() as AddressInfo).port;
947
+
948
+ try {
949
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/status`, {
950
+ headers: { Authorization: 'Bearer test-key' },
951
+ });
952
+ expect(response.status).toBe(200);
953
+ const body = (await response.json()) as any;
954
+ expect(body.status).toBe('failed');
955
+ expect(body.artifacts).toEqual({
956
+ metadataJson: true,
957
+ analysisMd: false,
958
+ resultSummaryMd: false,
959
+ failureJson: true,
960
+ stderrLog: true,
961
+ availableFiles: ['metadata.json', 'failure.json', 'stderr.log'],
962
+ });
963
+ expect(body.diagnosticEntryPoint).toBe('failure.json');
964
+ } finally {
965
+ await new Promise<void>((resolve) => server.close(() => resolve()));
966
+ await idempotencyStore.shutdown();
967
+ }
968
+ });
969
+
970
+ test('runs list includes terminal exit code from result metadata when scheduler job lacks it', async () => {
971
+ const jobId = 'kaseki-list-exit-code';
972
+ const jobDir = path.join(resultsDir, jobId);
973
+ fs.mkdirSync(jobDir, { recursive: true });
974
+ fs.writeFileSync(path.join(jobDir, 'metadata.json'), '{"exit_code":127}');
975
+
976
+ const scheduler = {
977
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
978
+ getJob: jest.fn(),
979
+ submitJob: jest.fn(),
980
+ listJobs: () => [{ id: jobId, status: 'failed', createdAt: new Date('2026-05-07T12:00:00Z'), resultDir: jobDir }],
981
+ cancelJob: jest.fn(),
982
+ } as any;
983
+
984
+ const config = {
985
+ port: 0,
986
+ apiKeys: ['test-key'],
987
+ resultsDir,
988
+ maxConcurrentRuns: 1,
989
+ defaultTaskMode: 'patch' as const,
990
+ maxDiffBytes: 200000,
991
+ agentTimeoutSeconds: 1200,
992
+ logLevel: 'info' as const,
993
+ };
994
+
995
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
996
+ const preFlightValidator = new PreFlightValidator();
997
+ const app = express();
998
+ app.use(express.json());
999
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1000
+ const server = app.listen(0);
1001
+ const port = (server.address() as AddressInfo).port;
1002
+
1003
+ try {
1004
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs`, {
1005
+ headers: { Authorization: 'Bearer test-key' },
1006
+ });
1007
+ expect(response.status).toBe(200);
1008
+ const body = (await response.json()) as any;
1009
+ expect(body.runs[0]).toMatchObject({
1010
+ id: jobId,
1011
+ status: 'failed',
1012
+ exitCode: 127,
1013
+ });
1014
+ } finally {
1015
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1016
+ await idempotencyStore.shutdown();
1017
+ }
1018
+ });
1019
+
1020
+ test('failed run falls back to result-summary.md diagnostic entrypoint when failure.json is missing', async () => {
1021
+ const jobId = 'kaseki-failed-status-2';
1022
+ const jobDir = path.join(resultsDir, jobId);
1023
+ fs.mkdirSync(jobDir, { recursive: true });
1024
+ fs.writeFileSync(path.join(jobDir, 'result-summary.md'), '# summary');
1025
+
1026
+ const scheduler = {
1027
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
1028
+ getJob: (id: string) => (id === jobId ? { id: jobId, status: 'failed', createdAt: new Date(), resultDir: jobDir } : undefined),
1029
+ submitJob: jest.fn(),
1030
+ listJobs: () => [],
1031
+ cancelJob: jest.fn(),
1032
+ } as any;
1033
+
1034
+ const config = {
1035
+ port: 0,
1036
+ apiKeys: ['test-key'],
1037
+ resultsDir,
1038
+ maxConcurrentRuns: 1,
1039
+ defaultTaskMode: 'patch' as const,
1040
+ maxDiffBytes: 200000,
1041
+ agentTimeoutSeconds: 1200,
1042
+ logLevel: 'info' as const,
1043
+ };
1044
+
1045
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1046
+ const preFlightValidator = new PreFlightValidator();
1047
+
1048
+ const app = express();
1049
+ app.use(express.json());
1050
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1051
+ const server = app.listen(0);
1052
+ const port = (server.address() as AddressInfo).port;
1053
+
1054
+ try {
1055
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/status`, {
1056
+ headers: { Authorization: 'Bearer test-key' },
1057
+ });
1058
+ expect(response.status).toBe(200);
1059
+ const body = (await response.json()) as any;
1060
+ expect(body.artifacts).toEqual({
1061
+ metadataJson: false,
1062
+ analysisMd: false,
1063
+ resultSummaryMd: true,
1064
+ failureJson: false,
1065
+ stderrLog: false,
1066
+ availableFiles: ['result-summary.md'],
1067
+ });
1068
+ expect(body.diagnosticEntryPoint).toBe('result-summary.md');
1069
+ } finally {
1070
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1071
+ await idempotencyStore.shutdown();
1072
+ }
1073
+ });
1074
+
1075
+ test('failed run prefers analysis.md diagnostic entrypoint when failure.json is missing', async () => {
1076
+ const jobId = 'kaseki-failed-status-analysis';
1077
+ const jobDir = path.join(resultsDir, jobId);
1078
+ fs.mkdirSync(jobDir, { recursive: true });
1079
+ fs.writeFileSync(path.join(jobDir, 'analysis.md'), '# analysis');
1080
+
1081
+ const scheduler = {
1082
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
1083
+ getJob: (id: string) => (id === jobId ? { id: jobId, status: 'failed', createdAt: new Date(), resultDir: jobDir } : undefined),
1084
+ submitJob: jest.fn(),
1085
+ listJobs: () => [],
1086
+ cancelJob: jest.fn(),
1087
+ } as any;
1088
+ const config = { port: 0, apiKeys: ['test-key'], resultsDir, maxConcurrentRuns: 1, defaultTaskMode: 'patch' as const, maxDiffBytes: 200000, agentTimeoutSeconds: 1200, logLevel: 'info' as const };
1089
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1090
+ const preFlightValidator = new PreFlightValidator();
1091
+ const app = express();
1092
+ app.use(express.json());
1093
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1094
+ const server = app.listen(0);
1095
+ const port = (server.address() as AddressInfo).port;
1096
+ try {
1097
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/status`, { headers: { Authorization: 'Bearer test-key' } });
1098
+ expect(response.status).toBe(200);
1099
+ const body = (await response.json()) as any;
1100
+ expect(body.artifacts.analysisMd).toBe(true);
1101
+ expect(body.diagnosticEntryPoint).toBe('analysis.md');
1102
+ } finally {
1103
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1104
+ await idempotencyStore.shutdown();
1105
+ }
1106
+ });
1107
+
1108
+ test('running status returns structured progress from progress.jsonl', async () => {
1109
+ const jobId = 'kaseki-running-status-progress-file';
1110
+ const jobDir = path.join(resultsDir, jobId);
1111
+ fs.mkdirSync(jobDir, { recursive: true });
1112
+ fs.writeFileSync(
1113
+ path.join(jobDir, 'progress.jsonl'),
1114
+ `${JSON.stringify({ stage: 'pi coding agent', percentComplete: 42, timestamp: '2026-05-05T00:00:00.000Z' })}\n`
1115
+ );
1116
+
1117
+ const scheduler = {
1118
+ getQueueStatus: () => ({ pending: 0, running: 1, maxConcurrent: 1 }),
1119
+ getJob: (id: string) =>
1120
+ id === jobId ? { id: jobId, status: 'running', createdAt: new Date(), resultDir: jobDir, startedAt: new Date() } : undefined,
1121
+ getLiveProgressEvents: jest.fn(() => []),
1122
+ submitJob: jest.fn(),
1123
+ listJobs: () => [],
1124
+ cancelJob: jest.fn(),
1125
+ } as any;
1126
+
1127
+ const config = {
1128
+ port: 0,
1129
+ apiKeys: ['test-key'],
1130
+ resultsDir,
1131
+ maxConcurrentRuns: 1,
1132
+ defaultTaskMode: 'patch' as const,
1133
+ maxDiffBytes: 200000,
1134
+ agentTimeoutSeconds: 1200,
1135
+ logLevel: 'info' as const,
1136
+ };
1137
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1138
+ const preFlightValidator = new PreFlightValidator();
1139
+ const app = express();
1140
+ app.use(express.json());
1141
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1142
+ const server = app.listen(0);
1143
+ const port = (server.address() as AddressInfo).port;
1144
+
1145
+ try {
1146
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/status`, {
1147
+ headers: { Authorization: 'Bearer test-key' },
1148
+ });
1149
+ expect(response.status).toBe(200);
1150
+ const body = (await response.json()) as any;
1151
+ expect(body.progress).toEqual({
1152
+ stage: 'pi coding agent',
1153
+ percentComplete: 42,
1154
+ message: 'pi coding agent',
1155
+ updatedAt: '2026-05-05T00:00:00.000Z',
1156
+ });
1157
+ } finally {
1158
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1159
+ await idempotencyStore.shutdown();
1160
+ }
1161
+ });
1162
+
1163
+ test('running status falls back to live progress when progress.jsonl has no parseable final event', async () => {
1164
+ const jobId = 'kaseki-running-status-progress-malformed-file';
1165
+ const jobDir = path.join(resultsDir, jobId);
1166
+ fs.mkdirSync(jobDir, { recursive: true });
1167
+ fs.writeFileSync(path.join(jobDir, 'progress.jsonl'), `${JSON.stringify({ stage: 'older file event' })}\n{not-json}\n`);
1168
+
1169
+ const scheduler = {
1170
+ getQueueStatus: () => ({ pending: 0, running: 1, maxConcurrent: 1 }),
1171
+ getJob: (id: string) =>
1172
+ id === jobId ? { id: jobId, status: 'running', createdAt: new Date(), resultDir: jobDir, startedAt: new Date() } : undefined,
1173
+ getLiveProgressEvents: jest.fn(() => [
1174
+ { stage: 'live fallback', message: 'file tail was malformed', timestamp: '2026-05-05T00:00:01.000Z' },
1175
+ ]),
1176
+ submitJob: jest.fn(),
1177
+ listJobs: () => [],
1178
+ cancelJob: jest.fn(),
1179
+ } as any;
1180
+
1181
+ const config = {
1182
+ port: 0,
1183
+ apiKeys: ['test-key'],
1184
+ resultsDir,
1185
+ maxConcurrentRuns: 1,
1186
+ defaultTaskMode: 'patch' as const,
1187
+ maxDiffBytes: 200000,
1188
+ agentTimeoutSeconds: 1200,
1189
+ logLevel: 'info' as const,
1190
+ };
1191
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1192
+ const preFlightValidator = new PreFlightValidator();
1193
+ const app = express();
1194
+ app.use(express.json());
1195
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1196
+ const server = app.listen(0);
1197
+ const port = (server.address() as AddressInfo).port;
1198
+
1199
+ try {
1200
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/status`, {
1201
+ headers: { Authorization: 'Bearer test-key' },
1202
+ });
1203
+ expect(response.status).toBe(200);
1204
+ const body = (await response.json()) as any;
1205
+ expect(body.progress).toEqual({
1206
+ stage: 'live fallback',
1207
+ message: 'file tail was malformed',
1208
+ updatedAt: '2026-05-05T00:00:01.000Z',
1209
+ });
1210
+ expect(scheduler.getLiveProgressEvents).toHaveBeenCalledWith(jobId, 1);
1211
+ } finally {
1212
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1213
+ await idempotencyStore.shutdown();
1214
+ }
1215
+ });
1216
+
1217
+ test('running status returns structured progress from live docker fallback', async () => {
1218
+ const jobId = 'kaseki-running-status-progress-live';
1219
+ const scheduler = {
1220
+ getQueueStatus: () => ({ pending: 0, running: 1, maxConcurrent: 1 }),
1221
+ getJob: (id: string) => (id === jobId ? { id: jobId, status: 'running', createdAt: new Date(), startedAt: new Date() } : undefined),
1222
+ getLiveProgressEvents: jest.fn(() => [{ stage: 'startup check', message: 'container booted', timestamp: '2026-05-05T00:00:02.000Z' }]),
1223
+ submitJob: jest.fn(),
1224
+ listJobs: () => [],
1225
+ cancelJob: jest.fn(),
1226
+ } as any;
1227
+
1228
+ const config = {
1229
+ port: 0,
1230
+ apiKeys: ['test-key'],
1231
+ resultsDir,
1232
+ maxConcurrentRuns: 1,
1233
+ defaultTaskMode: 'patch' as const,
1234
+ maxDiffBytes: 200000,
1235
+ agentTimeoutSeconds: 1200,
1236
+ logLevel: 'info' as const,
1237
+ };
1238
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1239
+ const preFlightValidator = new PreFlightValidator();
1240
+ const app = express();
1241
+ app.use(express.json());
1242
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1243
+ const server = app.listen(0);
1244
+ const port = (server.address() as AddressInfo).port;
1245
+
1246
+ try {
1247
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs/${jobId}/status`, {
1248
+ headers: { Authorization: 'Bearer test-key' },
1249
+ });
1250
+ expect(response.status).toBe(200);
1251
+ const body = (await response.json()) as any;
1252
+ expect(body.progress).toEqual({
1253
+ stage: 'startup check',
1254
+ message: 'container booted',
1255
+ updatedAt: '2026-05-05T00:00:02.000Z',
1256
+ });
1257
+ } finally {
1258
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1259
+ await idempotencyStore.shutdown();
1260
+ }
1261
+ });
1262
+ });
1263
+
1264
+ describe('kaseki-api-routes idempotency concurrency', () => {
1265
+ let resultsDir: string;
1266
+
1267
+ beforeEach(() => {
1268
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-idem-test-'));
1269
+ });
1270
+
1271
+ afterEach(() => {
1272
+ fs.rmSync(resultsDir, { recursive: true, force: true });
1273
+ });
1274
+
1275
+ test('parallel requests with same idempotency key create exactly one job', async () => {
1276
+ let submitted = 0;
1277
+ const submitJob = jest.fn((runRequest: any) => {
1278
+ submitted += 1;
1279
+ return {
1280
+ id: `job-${submitted}`,
1281
+ status: 'queued',
1282
+ createdAt: new Date(),
1283
+ resultDir: path.join(resultsDir, `job-${submitted}`),
1284
+ requestId: runRequest.requestId,
1285
+ correlationId: runRequest.correlationId,
1286
+ };
1287
+ });
1288
+
1289
+ const scheduler = {
1290
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
1291
+ getJob: jest.fn(),
1292
+ submitJob,
1293
+ listJobs: () => [],
1294
+ cancelJob: jest.fn(),
1295
+ } as any;
1296
+
1297
+ const config = {
1298
+ port: 0,
1299
+ apiKeys: ['test-key'],
1300
+ resultsDir,
1301
+ maxConcurrentRuns: 1,
1302
+ defaultTaskMode: 'patch' as const,
1303
+ maxDiffBytes: 200000,
1304
+ agentTimeoutSeconds: 1200,
1305
+ logLevel: 'info' as const,
1306
+ };
1307
+
1308
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1309
+ const preFlightValidator = new PreFlightValidator();
1310
+
1311
+ const app = express();
1312
+ app.use(express.json());
1313
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1314
+
1315
+ const server = app.listen(0);
1316
+ const port = (server.address() as AddressInfo).port;
1317
+ const headers = { Authorization: 'Bearer test-key', 'Content-Type': 'application/json' };
1318
+ const body = {
1319
+ repoUrl: 'https://github.com/example/repo',
1320
+ ref: 'main',
1321
+ issueNumber: 123,
1322
+ idempotencyKey: '11111111-1111-4111-8111-111111111111',
1323
+ };
1324
+
1325
+ try {
1326
+ const requests = Array.from({ length: 8 }, () =>
1327
+ fetch(`http://127.0.0.1:${port}/api/runs`, {
1328
+ method: 'POST',
1329
+ headers,
1330
+ body: JSON.stringify(body),
1331
+ })
1332
+ );
1333
+ const responses = await Promise.all(requests);
1334
+ const payloads = await Promise.all(responses.map((r) => r.json() as Promise<any>));
1335
+
1336
+ expect(submitJob).toHaveBeenCalledTimes(1);
1337
+ expect(responses.every((r) => r.status === 200 || r.status === 202)).toBe(true);
1338
+ expect(new Set(payloads.map((p) => p.id)).size).toBe(1);
1339
+ } finally {
1340
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1341
+ await idempotencyStore.shutdown();
1342
+ }
1343
+ });
1344
+
1345
+ test('health endpoint remains responsive while run submission is contended', async () => {
1346
+ let resolveSubmission: any;
1347
+ const submissionGate = new Promise<any>((resolve) => {
1348
+ resolveSubmission = resolve;
1349
+ });
1350
+
1351
+ const scheduler = {
1352
+ getQueueStatus: () => ({ pending: 0, running: 0, maxConcurrent: 1 }),
1353
+ getJob: jest.fn(),
1354
+ submitJob: jest.fn(async () => submissionGate),
1355
+ listJobs: () => [],
1356
+ cancelJob: jest.fn(),
1357
+ } as any;
1358
+
1359
+ const config = {
1360
+ port: 0,
1361
+ apiKeys: ['test-key'],
1362
+ resultsDir,
1363
+ maxConcurrentRuns: 1,
1364
+ defaultTaskMode: 'patch' as const,
1365
+ maxDiffBytes: 200000,
1366
+ agentTimeoutSeconds: 1200,
1367
+ logLevel: 'info' as const,
1368
+ };
1369
+
1370
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1371
+ const preFlightValidator = new PreFlightValidator();
1372
+
1373
+ const app = express();
1374
+ app.use(express.json());
1375
+ app.use('/api', createApiRouter(scheduler, config, idempotencyStore, preFlightValidator));
1376
+
1377
+ const server = app.listen(0);
1378
+ const port = (server.address() as AddressInfo).port;
1379
+
1380
+ try {
1381
+ const runPromise = fetch(`http://127.0.0.1:${port}/api/runs`, {
1382
+ method: 'POST',
1383
+ headers: { Authorization: 'Bearer test-key', 'Content-Type': 'application/json' },
1384
+ body: JSON.stringify({ repoUrl: 'https://github.com/example/repo', ref: 'main' }),
1385
+ });
1386
+
1387
+ await new Promise((resolve) => setTimeout(resolve, 20));
1388
+
1389
+ const healthResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
1390
+ expect(healthResponse.status).toBe(200);
1391
+ const healthBody = (await healthResponse.json()) as any;
1392
+ expect(healthBody.status).toBeDefined();
1393
+
1394
+ if (resolveSubmission) resolveSubmission({
1395
+ id: 'job-1',
1396
+ status: 'queued',
1397
+ createdAt: new Date(),
1398
+ resultDir: path.join(resultsDir, 'job-1'),
1399
+ requestId: 'req-1',
1400
+ correlationId: 'corr-1',
1401
+ });
1402
+
1403
+ const runResponse = await runPromise;
1404
+ expect(runResponse.status).toBe(202);
1405
+ } finally {
1406
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1407
+ await idempotencyStore.shutdown();
1408
+ }
1409
+ });
1410
+
1411
+ });
1412
+
1413
+ describe('kaseki-api-routes timeoutSeconds validation', () => {
1414
+ let resultsDir: string;
1415
+
1416
+ beforeEach(() => {
1417
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-timeout-test-'));
1418
+ });
1419
+
1420
+ afterEach(() => {
1421
+ fs.rmSync(resultsDir, { recursive: true, force: true });
1422
+ });
1423
+
1424
+ test('rejects invalid timeoutSeconds with 400', async () => {
1425
+ const scheduler = createMockScheduler();
1426
+ const config = createTestConfig(resultsDir);
1427
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
1428
+
1429
+ try {
1430
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs`, {
1431
+ method: 'POST',
1432
+ headers: { Authorization: 'Bearer test-key', 'Content-Type': 'application/json' },
1433
+ body: JSON.stringify({
1434
+ repoUrl: 'https://github.com/example/repo',
1435
+ ref: 'main',
1436
+ timeoutSeconds: 10,
1437
+ }),
1438
+ });
1439
+ expect(response.status).toBe(400);
1440
+ const payload = (await response.json()) as any;
1441
+ expect(payload.detail).toMatch(/timeoutSeconds/);
1442
+ expect(scheduler.submitJob).not.toHaveBeenCalled();
1443
+ } finally {
1444
+ await cleanupTestApp(server, idempotencyStore);
1445
+ }
1446
+ });
1447
+ });
1448
+
1449
+ describe('kaseki-api-routes publish mode validation', () => {
1450
+ let resultsDir: string;
1451
+
1452
+ beforeEach(() => {
1453
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-publish-test-'));
1454
+ });
1455
+
1456
+ afterEach(() => {
1457
+ fs.rmSync(resultsDir, { recursive: true, force: true });
1458
+ });
1459
+
1460
+ test('rejects draft PR publishing when GitHub App credentials are not configured', async () => {
1461
+ const previousEnv = {
1462
+ GITHUB_APP_ID: process.env.GITHUB_APP_ID,
1463
+ GITHUB_APP_ID_FILE: process.env.GITHUB_APP_ID_FILE,
1464
+ GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID,
1465
+ GITHUB_APP_CLIENT_ID_FILE: process.env.GITHUB_APP_CLIENT_ID_FILE,
1466
+ GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY,
1467
+ GITHUB_APP_PRIVATE_KEY_FILE: process.env.GITHUB_APP_PRIVATE_KEY_FILE,
1468
+ };
1469
+ for (const key of Object.keys(previousEnv)) {
1470
+ delete process.env[key];
1471
+ }
1472
+
1473
+ const scheduler = createMockScheduler();
1474
+ const config = createTestConfig(resultsDir);
1475
+ const { server, port, idempotencyStore } = await createTestApp(scheduler, config);
1476
+
1477
+ try {
1478
+ const response = await fetch(`http://127.0.0.1:${port}/api/runs`, {
1479
+ method: 'POST',
1480
+ headers: {
1481
+ Authorization: 'Bearer test-key',
1482
+ 'Content-Type': 'application/json',
1483
+ },
1484
+ body: JSON.stringify({
1485
+ repoUrl: 'https://github.com/org/repo',
1486
+ publishMode: 'draft_pr',
1487
+ }),
1488
+ });
1489
+
1490
+ expect(response.status).toBe(400);
1491
+ const body = (await response.json()) as any;
1492
+ expect(body.detail).toContain('publishMode=draft_pr requires readable GitHub App credentials');
1493
+ expect(scheduler.submitJob).not.toHaveBeenCalled();
1494
+ } finally {
1495
+ await cleanupTestApp(server, idempotencyStore);
1496
+ for (const [key, value] of Object.entries(previousEnv)) {
1497
+ if (value === undefined) {
1498
+ delete process.env[key];
1499
+ } else {
1500
+ process.env[key] = value;
1501
+ }
1502
+ }
1503
+ }
1504
+ });
1505
+ });
1506
+
1507
+ describe('artifact content cache configuration in routes', () => {
1508
+ let resultsDir: string;
1509
+
1510
+ beforeEach(() => {
1511
+ resultsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-routes-artifact-cache-test-'));
1512
+ });
1513
+
1514
+ afterEach(() => {
1515
+ fs.rmSync(resultsDir, { recursive: true, force: true });
1516
+ });
1517
+
1518
+ test('uses the injected artifact cache and exposes its stats on metrics', async () => {
1519
+ const jobId = 'kaseki-artifact-cache-stats';
1520
+ const jobDir = path.join(resultsDir, jobId);
1521
+ fs.mkdirSync(jobDir, { recursive: true });
1522
+ fs.writeFileSync(path.join(jobDir, 'result-summary.md'), 'cached summary');
1523
+
1524
+ const scheduler = createMockScheduler({
1525
+ [jobId]: {
1526
+ id: jobId,
1527
+ status: 'completed',
1528
+ createdAt: new Date(),
1529
+ resultDir: jobDir,
1530
+ },
1531
+ });
1532
+ const config = {
1533
+ ...createTestConfig(resultsDir),
1534
+ artifactCacheMaxEntries: 1,
1535
+ artifactCacheTtlMs: 60_000,
1536
+ artifactCacheMaxFileBytes: 1024,
1537
+ };
1538
+ const artifactCache = new ResultCache({
1539
+ maxEntries: config.artifactCacheMaxEntries,
1540
+ ttlMs: config.artifactCacheTtlMs,
1541
+ maxFileBytes: config.artifactCacheMaxFileBytes,
1542
+ });
1543
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1544
+ const preFlightValidator = new PreFlightValidator();
1545
+ const app = express();
1546
+ app.use(express.json());
1547
+ app.use('/api', createApiRouter(scheduler as any, config, idempotencyStore, preFlightValidator, artifactCache));
1548
+ const server = app.listen(0);
1549
+ const port = (server.address() as AddressInfo).port;
1550
+ const headers = { Authorization: 'Bearer test-key' };
1551
+
1552
+ try {
1553
+ const first = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/result-summary.md`, { headers });
1554
+ expect(first.status).toBe(200);
1555
+ const second = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/result-summary.md`, { headers });
1556
+ expect(second.status).toBe(200);
1557
+
1558
+ const metrics = await fetch(`http://127.0.0.1:${port}/api/metrics`, { headers });
1559
+ expect(metrics.status).toBe(200);
1560
+ const body = await metrics.text();
1561
+ expect(body).toContain('kaseki_artifact_cache_entries 1');
1562
+ expect(body).toContain('kaseki_artifact_cache_hits_total 1');
1563
+ expect(body).toContain('kaseki_artifact_cache_misses_total 1');
1564
+ expect(body).toContain('kaseki_artifact_cache_max_entries 1');
1565
+ expect(body).toContain('kaseki_artifact_cache_max_file_bytes 1024');
1566
+ } finally {
1567
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1568
+ await idempotencyStore.shutdown();
1569
+ }
1570
+ });
1571
+
1572
+ test('honors configured max file bytes passed through artifact routes', async () => {
1573
+ const jobId = 'kaseki-artifact-cache-size-limit';
1574
+ const jobDir = path.join(resultsDir, jobId);
1575
+ fs.mkdirSync(jobDir, { recursive: true });
1576
+ fs.writeFileSync(path.join(jobDir, 'result-summary.md'), 'too large for cache');
1577
+
1578
+ const scheduler = createMockScheduler({
1579
+ [jobId]: {
1580
+ id: jobId,
1581
+ status: 'completed',
1582
+ createdAt: new Date(),
1583
+ resultDir: jobDir,
1584
+ },
1585
+ });
1586
+ const config = {
1587
+ ...createTestConfig(resultsDir),
1588
+ artifactCacheMaxEntries: 3,
1589
+ artifactCacheTtlMs: 60_000,
1590
+ artifactCacheMaxFileBytes: 4,
1591
+ };
1592
+ const artifactCache = new ResultCache({
1593
+ maxEntries: config.artifactCacheMaxEntries,
1594
+ ttlMs: config.artifactCacheTtlMs,
1595
+ maxFileBytes: config.artifactCacheMaxFileBytes,
1596
+ });
1597
+ const idempotencyStore = new IdempotencyStore(resultsDir, 24);
1598
+ const preFlightValidator = new PreFlightValidator();
1599
+ const app = express();
1600
+ app.use(express.json());
1601
+ app.use('/api', createApiRouter(scheduler as any, config, idempotencyStore, preFlightValidator, artifactCache));
1602
+ const server = app.listen(0);
1603
+ const port = (server.address() as AddressInfo).port;
1604
+ const headers = { Authorization: 'Bearer test-key' };
1605
+
1606
+ try {
1607
+ const response = await fetch(`http://127.0.0.1:${port}/api/results/${jobId}/result-summary.md`, { headers });
1608
+ expect(response.status).toBe(200);
1609
+ expect(artifactCache.getStats()).toMatchObject({ entries: 0, misses: 1, maxFileBytes: 4 });
1610
+ } finally {
1611
+ await new Promise<void>((resolve) => server.close(() => resolve()));
1612
+ await idempotencyStore.shutdown();
1613
+ }
1614
+ });
1615
+ });