@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,1236 @@
1
+ import { EventEmitter } from 'events';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { JobScheduler } from './job-scheduler';
5
+ import { WebhookManager } from './webhook-manager';
6
+ import { secretValueCache } from './secret-value-cache';
7
+
8
+ const mockSpawn = jest.fn();
9
+ const mockSpawnSync = jest.fn();
10
+
11
+ jest.mock('child_process', () => ({
12
+ spawn: (...args: unknown[]) => mockSpawn(...args),
13
+ spawnSync: (...args: unknown[]) => mockSpawnSync(...args),
14
+ }));
15
+
16
+ class MockProcess extends EventEmitter {
17
+ pid = 12345;
18
+ kill = jest.fn((_signal?: NodeJS.Signals) => true);
19
+ unref = jest.fn(() => this);
20
+ }
21
+
22
+ const tempDirs: string[] = [];
23
+
24
+ function createResultsDir(): string {
25
+ const dir = fs.mkdtempSync('/tmp/kaseki-job-scheduler-test-');
26
+ tempDirs.push(dir);
27
+ return dir;
28
+ }
29
+
30
+ function cleanupResultsDirs(): void {
31
+ while (tempDirs.length > 0) {
32
+ const dir = tempDirs.pop();
33
+ if (dir) {
34
+ fs.rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ }
37
+ }
38
+
39
+ function createMockWebhookManager(): WebhookManager {
40
+ return new WebhookManager(createResultsDir());
41
+ }
42
+
43
+ type TestPersistedStatus = 'queued' | 'running' | 'completed' | 'failed';
44
+
45
+ function persistedJob(
46
+ resultsDir: string,
47
+ id: string,
48
+ status: TestPersistedStatus,
49
+ createdAt: string,
50
+ completedAt?: string
51
+ ): Record<string, unknown> {
52
+ return {
53
+ id,
54
+ status,
55
+ request: { repoUrl: 'https://github.com/org/repo', ref: 'main' },
56
+ createdAt,
57
+ completedAt,
58
+ resultDir: path.join(resultsDir, id),
59
+ correlationId: `${id}-correlation`,
60
+ requestId: `${id}-request`,
61
+ };
62
+ }
63
+
64
+ function persistedRuntimeJob(resultsDir: string, id: string, status: TestPersistedStatus, createdAt: string): unknown {
65
+ const completedAt = status === 'completed' || status === 'failed' ? new Date(createdAt) : undefined;
66
+ return {
67
+ id,
68
+ status,
69
+ request: { repoUrl: 'https://github.com/org/repo', ref: 'main' },
70
+ createdAt: new Date(createdAt),
71
+ completedAt,
72
+ resultDir: path.join(resultsDir, id),
73
+ correlationId: `${id}-correlation`,
74
+ requestId: `${id}-request`,
75
+ finalized: status === 'completed' || status === 'failed',
76
+ };
77
+ }
78
+
79
+ describe('JobScheduler timeout lifecycle', () => {
80
+ beforeEach(() => {
81
+ jest.useFakeTimers();
82
+ jest.clearAllMocks();
83
+ secretValueCache.clear();
84
+ });
85
+
86
+ afterEach(() => {
87
+ secretValueCache.clear();
88
+ delete process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS;
89
+ jest.useRealTimers();
90
+ cleanupResultsDirs();
91
+ });
92
+
93
+ test('timeout followed by quick exit sets timeout failure on exit', async () => {
94
+ const proc = new MockProcess();
95
+ mockSpawn.mockReturnValue(proc);
96
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
97
+
98
+ const scheduler = new JobScheduler(
99
+ {
100
+ port: 8080,
101
+ apiKeys: ['test-key'],
102
+ resultsDir: createResultsDir(),
103
+
104
+ maxConcurrentRuns: 1,
105
+ defaultTaskMode: 'patch',
106
+ maxDiffBytes: 200000,
107
+ agentTimeoutSeconds: 1,
108
+ logLevel: 'info',
109
+ },
110
+ createMockWebhookManager()
111
+ );
112
+
113
+ const job = await scheduler.submitJob({
114
+ repoUrl: 'https://github.com/org/repo',
115
+ ref: 'main',
116
+ });
117
+
118
+ expect(job.status).toBe('running');
119
+
120
+ jest.advanceTimersByTime(1000);
121
+
122
+ expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
123
+ expect(job.status).toBe('running');
124
+ expect(job.completedAt).toBeUndefined();
125
+
126
+ proc.emit('exit', 0);
127
+
128
+ expect(job.status).toBe('failed');
129
+ expect(job.exitCode).toBe(124);
130
+ expect(job.error).toMatch(/Agent timeout/);
131
+ expect(job.failureClass).toBe('timeout');
132
+ expect(job.completedAt).toBeDefined();
133
+ const runDir = `${scheduler['config'].resultsDir}/${job.id}`;
134
+ expect(fs.readFileSync(`${runDir}/analysis.md`, 'utf-8').trim().length).toBeGreaterThan(0);
135
+ expect(fs.readFileSync(`${runDir}/metadata.json`, 'utf-8').trim().length).toBeGreaterThan(0);
136
+ expect(fs.readFileSync(`${runDir}/stderr.log`, 'utf-8').trim().length).toBeGreaterThan(0);
137
+ });
138
+
139
+ test('passes parent results directory as host log directory', async () => {
140
+ const proc = new MockProcess();
141
+ mockSpawn.mockReturnValue(proc);
142
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
143
+ const resultsDir = createResultsDir();
144
+
145
+ const scheduler = new JobScheduler(
146
+ {
147
+ port: 8080,
148
+ apiKeys: ['test-key'],
149
+ resultsDir,
150
+ maxConcurrentRuns: 1,
151
+ defaultTaskMode: 'patch',
152
+ maxDiffBytes: 200000,
153
+ agentTimeoutSeconds: 30,
154
+ logLevel: 'info',
155
+ },
156
+ createMockWebhookManager()
157
+ );
158
+
159
+ const job = await scheduler.submitJob({
160
+ repoUrl: 'https://github.com/org/repo',
161
+ ref: 'main',
162
+ });
163
+
164
+ expect(mockSpawn).toHaveBeenCalledWith(
165
+ 'bash',
166
+ expect.arrayContaining(['--controller', 'run', 'https://github.com/org/repo', 'main', job.id]),
167
+ expect.objectContaining({
168
+ env: expect.objectContaining({
169
+ KASEKI_LOG_DIR: resultsDir,
170
+ }),
171
+ }),
172
+ );
173
+ expect(fs.existsSync(job.resultDir || '')).toBe(false);
174
+ });
175
+
176
+ test('hydrates GitHub App ID values from configured secret files for controller runs', async () => {
177
+ const proc = new MockProcess();
178
+ mockSpawn.mockReturnValue(proc);
179
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
180
+ const resultsDir = createResultsDir();
181
+ const secretsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-github-secrets-'));
182
+ const appIdFile = path.join(secretsDir, 'github_app_id');
183
+ const clientIdFile = path.join(secretsDir, 'github_client_id');
184
+ const previousEnv = {
185
+ GITHUB_APP_ID: process.env.GITHUB_APP_ID,
186
+ GITHUB_APP_ID_FILE: process.env.GITHUB_APP_ID_FILE,
187
+ GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID,
188
+ GITHUB_APP_CLIENT_ID_FILE: process.env.GITHUB_APP_CLIENT_ID_FILE,
189
+ };
190
+ fs.writeFileSync(appIdFile, '12345\n');
191
+ fs.writeFileSync(clientIdFile, 'Iv123client\n');
192
+ delete process.env.GITHUB_APP_ID;
193
+ delete process.env.GITHUB_APP_CLIENT_ID;
194
+ process.env.GITHUB_APP_ID_FILE = appIdFile;
195
+ process.env.GITHUB_APP_CLIENT_ID_FILE = clientIdFile;
196
+
197
+ try {
198
+ const scheduler = new JobScheduler(
199
+ {
200
+ port: 8080,
201
+ apiKeys: ['test-key'],
202
+ resultsDir,
203
+ maxConcurrentRuns: 1,
204
+ defaultTaskMode: 'patch',
205
+ maxDiffBytes: 200000,
206
+ agentTimeoutSeconds: 30,
207
+ logLevel: 'info',
208
+ },
209
+ createMockWebhookManager()
210
+ );
211
+
212
+ await scheduler.submitJob({
213
+ repoUrl: 'https://github.com/org/repo',
214
+ ref: 'main',
215
+ });
216
+
217
+ expect(mockSpawn).toHaveBeenCalledWith(
218
+ 'bash',
219
+ expect.any(Array),
220
+ expect.objectContaining({
221
+ env: expect.objectContaining({
222
+ GITHUB_APP_ID: '12345',
223
+ GITHUB_APP_CLIENT_ID: 'Iv123client',
224
+ GITHUB_APP_ID_FILE: appIdFile,
225
+ GITHUB_APP_CLIENT_ID_FILE: clientIdFile,
226
+ }),
227
+ }),
228
+ );
229
+ } finally {
230
+ for (const [key, value] of Object.entries(previousEnv)) {
231
+ if (value === undefined) {
232
+ delete process.env[key];
233
+ } else {
234
+ process.env[key] = value;
235
+ }
236
+ }
237
+ fs.rmSync(secretsDir, { recursive: true, force: true });
238
+ }
239
+ });
240
+
241
+ test('prefers inline GitHub App ID values over configured secret files for controller runs', async () => {
242
+ const proc = new MockProcess();
243
+ mockSpawn.mockReturnValue(proc);
244
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
245
+ const resultsDir = createResultsDir();
246
+ const secretsDir = fs.mkdtempSync(path.join('/tmp', 'kaseki-github-secrets-'));
247
+ const appIdFile = path.join(secretsDir, 'github_app_id');
248
+ const clientIdFile = path.join(secretsDir, 'github_client_id');
249
+ const previousEnv = {
250
+ GITHUB_APP_ID: process.env.GITHUB_APP_ID,
251
+ GITHUB_APP_ID_FILE: process.env.GITHUB_APP_ID_FILE,
252
+ GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID,
253
+ GITHUB_APP_CLIENT_ID_FILE: process.env.GITHUB_APP_CLIENT_ID_FILE,
254
+ };
255
+ fs.writeFileSync(appIdFile, '12345\n');
256
+ fs.writeFileSync(clientIdFile, 'Iv123client\n');
257
+ process.env.GITHUB_APP_ID = '67890';
258
+ process.env.GITHUB_APP_CLIENT_ID = 'IvInlineClient';
259
+ process.env.GITHUB_APP_ID_FILE = appIdFile;
260
+ process.env.GITHUB_APP_CLIENT_ID_FILE = clientIdFile;
261
+
262
+ try {
263
+ const scheduler = new JobScheduler(
264
+ {
265
+ port: 8080,
266
+ apiKeys: ['test-key'],
267
+ resultsDir,
268
+ maxConcurrentRuns: 1,
269
+ defaultTaskMode: 'patch',
270
+ maxDiffBytes: 200000,
271
+ agentTimeoutSeconds: 30,
272
+ logLevel: 'info',
273
+ },
274
+ createMockWebhookManager()
275
+ );
276
+
277
+ await scheduler.submitJob({
278
+ repoUrl: 'https://github.com/org/repo',
279
+ ref: 'main',
280
+ });
281
+
282
+ expect(mockSpawn).toHaveBeenCalledWith(
283
+ 'bash',
284
+ expect.any(Array),
285
+ expect.objectContaining({
286
+ env: expect.objectContaining({
287
+ GITHUB_APP_ID: '67890',
288
+ GITHUB_APP_CLIENT_ID: 'IvInlineClient',
289
+ }),
290
+ }),
291
+ );
292
+ } finally {
293
+ for (const [key, value] of Object.entries(previousEnv)) {
294
+ if (value === undefined) {
295
+ delete process.env[key];
296
+ } else {
297
+ process.env[key] = value;
298
+ }
299
+ }
300
+ fs.rmSync(secretsDir, { recursive: true, force: true });
301
+ }
302
+ });
303
+
304
+ test('uses configured default timeout when request timeoutSeconds is omitted', async () => {
305
+ const proc = new MockProcess();
306
+ mockSpawn.mockReturnValue(proc);
307
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
308
+
309
+ const scheduler = new JobScheduler(
310
+ {
311
+ port: 8080,
312
+ apiKeys: ['test-key'],
313
+ resultsDir: createResultsDir(),
314
+ maxConcurrentRuns: 1,
315
+ defaultTaskMode: 'patch',
316
+ maxDiffBytes: 200000,
317
+ agentTimeoutSeconds: 42,
318
+ logLevel: 'info',
319
+ },
320
+ createMockWebhookManager()
321
+ );
322
+
323
+ const job = await scheduler.submitJob({
324
+ repoUrl: 'https://github.com/org/repo',
325
+ ref: 'main',
326
+ });
327
+
328
+ expect(job.effectiveTimeoutSeconds).toBe(42);
329
+ expect(mockSpawn).toHaveBeenCalledWith(
330
+ 'bash',
331
+ expect.any(Array),
332
+ expect.objectContaining({
333
+ env: expect.objectContaining({
334
+ KASEKI_AGENT_TIMEOUT_SECONDS: '42',
335
+ }),
336
+ }),
337
+ );
338
+ });
339
+
340
+ test('uses explicit request timeoutSeconds when provided', async () => {
341
+ const proc = new MockProcess();
342
+ mockSpawn.mockReturnValue(proc);
343
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
344
+
345
+ const scheduler = new JobScheduler(
346
+ {
347
+ port: 8080,
348
+ apiKeys: ['test-key'],
349
+ resultsDir: createResultsDir(),
350
+ maxConcurrentRuns: 1,
351
+ defaultTaskMode: 'patch',
352
+ maxDiffBytes: 200000,
353
+ agentTimeoutSeconds: 42,
354
+ logLevel: 'info',
355
+ },
356
+ createMockWebhookManager()
357
+ );
358
+
359
+ const job = await scheduler.submitJob({
360
+ repoUrl: 'https://github.com/org/repo',
361
+ ref: 'main',
362
+ timeoutSeconds: 90,
363
+ });
364
+
365
+ expect(job.effectiveTimeoutSeconds).toBe(90);
366
+ expect(mockSpawn).toHaveBeenCalledWith(
367
+ 'bash',
368
+ expect.any(Array),
369
+ expect.objectContaining({
370
+ env: expect.objectContaining({
371
+ KASEKI_AGENT_TIMEOUT_SECONDS: '90',
372
+ }),
373
+ }),
374
+ );
375
+ });
376
+
377
+ test('passes requested publish mode to controller runs', async () => {
378
+ const proc = new MockProcess();
379
+ mockSpawn.mockReturnValue(proc);
380
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
381
+
382
+ const scheduler = new JobScheduler(
383
+ {
384
+ port: 8080,
385
+ apiKeys: ['test-key'],
386
+ resultsDir: createResultsDir(),
387
+ maxConcurrentRuns: 1,
388
+ defaultTaskMode: 'patch',
389
+ maxDiffBytes: 200000,
390
+ agentTimeoutSeconds: 30,
391
+ logLevel: 'info',
392
+ },
393
+ createMockWebhookManager()
394
+ );
395
+
396
+ await scheduler.submitJob({
397
+ repoUrl: 'https://github.com/org/repo',
398
+ ref: 'main',
399
+ publishMode: 'draft_pr',
400
+ });
401
+
402
+ expect(mockSpawn).toHaveBeenCalledWith(
403
+ 'bash',
404
+ expect.any(Array),
405
+ expect.objectContaining({
406
+ env: expect.objectContaining({
407
+ KASEKI_PUBLISH_MODE: 'draft_pr',
408
+ }),
409
+ }),
410
+ );
411
+ });
412
+
413
+ test('passes controller-style allowlist and validation aliases to worker env', async () => {
414
+ const proc = new MockProcess();
415
+ mockSpawn.mockReturnValue(proc);
416
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
417
+
418
+ const scheduler = new JobScheduler(
419
+ {
420
+ port: 8080,
421
+ apiKeys: ['test-key'],
422
+ resultsDir: createResultsDir(),
423
+ maxConcurrentRuns: 1,
424
+ defaultTaskMode: 'patch',
425
+ maxDiffBytes: 200000,
426
+ agentTimeoutSeconds: 30,
427
+ logLevel: 'info',
428
+ },
429
+ createMockWebhookManager()
430
+ );
431
+
432
+ await scheduler.submitJob({
433
+ repoUrl: 'https://github.com/org/repo',
434
+ ref: 'main',
435
+ allowlist: { include: ['src/lib/network-safety.ts', 'src/lib/network-safety.test.ts'] },
436
+ validation: { commands: ['npm test -- src/lib/network-safety.test.ts'] },
437
+ });
438
+
439
+ expect(mockSpawn).toHaveBeenCalledWith(
440
+ 'bash',
441
+ expect.any(Array),
442
+ expect.objectContaining({
443
+ env: expect.objectContaining({
444
+ KASEKI_CHANGED_FILES_ALLOWLIST: 'src/lib/network-safety.ts src/lib/network-safety.test.ts',
445
+ KASEKI_VALIDATION_COMMANDS: 'npm test -- src/lib/network-safety.test.ts',
446
+ }),
447
+ }),
448
+ );
449
+ });
450
+
451
+ test('startup check requests run the worker in dry-run inspect mode', async () => {
452
+ const proc = new MockProcess();
453
+ mockSpawn.mockReturnValue(proc);
454
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
455
+ const resultsDir = createResultsDir();
456
+
457
+ const scheduler = new JobScheduler(
458
+ {
459
+ port: 8080,
460
+ apiKeys: ['test-key'],
461
+ resultsDir,
462
+ maxConcurrentRuns: 1,
463
+ defaultTaskMode: 'patch',
464
+ maxDiffBytes: 200000,
465
+ agentTimeoutSeconds: 30,
466
+ logLevel: 'info',
467
+ },
468
+ createMockWebhookManager()
469
+ );
470
+
471
+ await scheduler.submitJob({
472
+ repoUrl: 'https://github.com/org/repo',
473
+ ref: 'main',
474
+ startupCheck: true,
475
+ });
476
+
477
+ expect(mockSpawn).toHaveBeenCalledWith(
478
+ 'bash',
479
+ expect.arrayContaining(['--controller', 'run', 'https://github.com/org/repo', 'main']),
480
+ expect.objectContaining({
481
+ env: expect.objectContaining({
482
+ KASEKI_DRY_RUN: '1',
483
+ KASEKI_TASK_MODE: 'inspect',
484
+ KASEKI_VALIDATION_COMMANDS: 'none',
485
+ }),
486
+ }),
487
+ );
488
+ });
489
+
490
+ test('cancelled running jobs get non-empty API failure artifacts', async () => {
491
+ const proc = new MockProcess();
492
+ mockSpawn.mockReturnValue(proc);
493
+ mockSpawnSync.mockReturnValue({ stdout: 'kaseki-1\n', stderr: '', status: 0 });
494
+ const resultsDir = createResultsDir();
495
+
496
+ const scheduler = new JobScheduler(
497
+ {
498
+ port: 8080,
499
+ apiKeys: ['test-key'],
500
+ resultsDir,
501
+ maxConcurrentRuns: 1,
502
+ defaultTaskMode: 'patch',
503
+ maxDiffBytes: 200000,
504
+ agentTimeoutSeconds: 30,
505
+ logLevel: 'info',
506
+ },
507
+ createMockWebhookManager()
508
+ );
509
+
510
+ const job = await scheduler.submitJob({
511
+ repoUrl: 'https://github.com/org/repo',
512
+ ref: 'main',
513
+ });
514
+
515
+ scheduler.cancelJob(job.id);
516
+
517
+ const failurePath = `${resultsDir}/${job.id}/failure.json`;
518
+ const summaryPath = `${resultsDir}/${job.id}/result-summary.md`;
519
+ const analysisPath = `${resultsDir}/${job.id}/analysis.md`;
520
+ const metadataPath = `${resultsDir}/${job.id}/metadata.json`;
521
+ const stderrPath = `${resultsDir}/${job.id}/stderr.log`;
522
+ expect(fs.statSync(failurePath).size).toBeGreaterThan(0);
523
+ expect(fs.statSync(summaryPath).size).toBeGreaterThan(0);
524
+ expect(fs.statSync(analysisPath).size).toBeGreaterThan(0);
525
+ expect(fs.statSync(metadataPath).size).toBeGreaterThan(0);
526
+ expect(fs.statSync(stderrPath).size).toBeGreaterThan(0);
527
+ expect(JSON.parse(fs.readFileSync(failurePath, 'utf-8'))).toMatchObject({
528
+ failureClass: 'cancelled',
529
+ exitCode: 143,
530
+ apiFinalized: true,
531
+ cleanup: {
532
+ attempted: true,
533
+ ok: true,
534
+ },
535
+ });
536
+ });
537
+
538
+ test('timeout escalates to SIGKILL when process hangs', async () => {
539
+ const proc = new MockProcess();
540
+ mockSpawn.mockReturnValue(proc);
541
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
542
+
543
+ const scheduler = new JobScheduler(
544
+ {
545
+ port: 8080,
546
+ apiKeys: ['test-key'],
547
+ resultsDir: createResultsDir(),
548
+ maxConcurrentRuns: 1,
549
+ defaultTaskMode: 'patch',
550
+ maxDiffBytes: 200000,
551
+ agentTimeoutSeconds: 1,
552
+ logLevel: 'info',
553
+ },
554
+ createMockWebhookManager()
555
+ );
556
+
557
+ await scheduler.submitJob({
558
+ repoUrl: 'https://github.com/org/repo',
559
+ ref: 'main',
560
+ });
561
+
562
+ jest.advanceTimersByTime(1000);
563
+ expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
564
+
565
+ jest.advanceTimersByTime(5000);
566
+ expect(proc.kill).toHaveBeenCalledWith('SIGKILL');
567
+ });
568
+
569
+ test('timeout path does not double-finalize when kill then exit race', async () => {
570
+ const proc = new MockProcess();
571
+ mockSpawn.mockReturnValue(proc);
572
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
573
+
574
+ const scheduler = new JobScheduler(
575
+ {
576
+ port: 8080,
577
+ apiKeys: ['test-key'],
578
+ resultsDir: createResultsDir(),
579
+ maxConcurrentRuns: 1,
580
+ defaultTaskMode: 'patch',
581
+ maxDiffBytes: 200000,
582
+ agentTimeoutSeconds: 1,
583
+ logLevel: 'info',
584
+ },
585
+ createMockWebhookManager()
586
+ );
587
+
588
+ const processQueueSpy = jest.spyOn(scheduler as unknown as { processQueue: () => void }, 'processQueue');
589
+ const job = await scheduler.submitJob({
590
+ repoUrl: 'https://github.com/org/repo',
591
+ ref: 'main',
592
+ });
593
+
594
+ jest.advanceTimersByTime(1000);
595
+ jest.advanceTimersByTime(5000);
596
+ proc.emit('exit', null);
597
+
598
+ expect(job.finalized).toBe(true);
599
+
600
+ // Once from submit, once from single guarded completion.
601
+ expect(processQueueSpy).toHaveBeenCalledTimes(2);
602
+ });
603
+
604
+ test('cancel immediately before process exit emits one terminal webhook', async () => {
605
+ const proc = new MockProcess();
606
+ mockSpawn.mockReturnValue(proc);
607
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
608
+ const webhookManager = {
609
+ enqueueWebhook: jest.fn(),
610
+ } as unknown as WebhookManager;
611
+
612
+ const scheduler = new JobScheduler(
613
+ {
614
+ port: 8080,
615
+ apiKeys: ['test-key'],
616
+ resultsDir: createResultsDir(),
617
+ maxConcurrentRuns: 1,
618
+ defaultTaskMode: 'patch',
619
+ maxDiffBytes: 200000,
620
+ agentTimeoutSeconds: 30,
621
+ logLevel: 'info',
622
+ },
623
+ webhookManager
624
+ );
625
+
626
+ const job = await scheduler.submitJob({
627
+ repoUrl: 'https://github.com/org/repo',
628
+ ref: 'main',
629
+ webhookConfig: { url: 'https://example.com/webhook' },
630
+ });
631
+
632
+ scheduler.cancelJob(job.id);
633
+ proc.emit('exit', 0);
634
+
635
+ expect(job.status).toBe('failed');
636
+ expect(job.failureClass).toBe('cancelled');
637
+ const terminalEvents = (webhookManager.enqueueWebhook as jest.Mock).mock.calls
638
+ .map((call) => call[1].eventType)
639
+ .filter((eventType) => ['job.completed', 'job.failed', 'job.cancelled'].includes(eventType));
640
+ expect(terminalEvents).toEqual(['job.cancelled']);
641
+ });
642
+
643
+ test('cancel immediately after process exit keeps one terminal webhook', async () => {
644
+ const proc = new MockProcess();
645
+ mockSpawn.mockReturnValue(proc);
646
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
647
+ const webhookManager = {
648
+ enqueueWebhook: jest.fn(),
649
+ } as unknown as WebhookManager;
650
+
651
+ const scheduler = new JobScheduler(
652
+ {
653
+ port: 8080,
654
+ apiKeys: ['test-key'],
655
+ resultsDir: createResultsDir(),
656
+ maxConcurrentRuns: 1,
657
+ defaultTaskMode: 'patch',
658
+ maxDiffBytes: 200000,
659
+ agentTimeoutSeconds: 30,
660
+ logLevel: 'info',
661
+ },
662
+ webhookManager
663
+ );
664
+
665
+ const job = await scheduler.submitJob({
666
+ repoUrl: 'https://github.com/org/repo',
667
+ ref: 'main',
668
+ webhookConfig: { url: 'https://example.com/webhook' },
669
+ });
670
+
671
+ proc.emit('exit', 0);
672
+ scheduler.cancelJob(job.id);
673
+
674
+ expect(job.status).toBe('completed');
675
+ const terminalEvents = (webhookManager.enqueueWebhook as jest.Mock).mock.calls
676
+ .map((call) => call[1].eventType)
677
+ .filter((eventType) => ['job.completed', 'job.failed', 'job.cancelled'].includes(eventType));
678
+ expect(terminalEvents).toEqual(['job.completed']);
679
+ });
680
+ });
681
+
682
+ describe('JobScheduler instance allocation and live progress', () => {
683
+ beforeEach(() => {
684
+ jest.clearAllMocks();
685
+ mockSpawn.mockReturnValue(new MockProcess());
686
+ });
687
+
688
+ afterEach(() => {
689
+ secretValueCache.clear();
690
+ delete process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS;
691
+ cleanupResultsDirs();
692
+ });
693
+
694
+ test('run timeout timers do not keep the event loop alive', async () => {
695
+ const proc = new MockProcess();
696
+ mockSpawn.mockReturnValue(proc);
697
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
698
+
699
+ const scheduler = new JobScheduler(
700
+ {
701
+ port: 8080,
702
+ apiKeys: ['test-key'],
703
+ resultsDir: createResultsDir(),
704
+ maxConcurrentRuns: 1,
705
+ defaultTaskMode: 'patch',
706
+ maxDiffBytes: 200000,
707
+ agentTimeoutSeconds: 30,
708
+ logLevel: 'info',
709
+ },
710
+ createMockWebhookManager()
711
+ );
712
+
713
+ const job = await scheduler.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'main' });
714
+
715
+ expect(job.timeout?.hasRef()).toBe(false);
716
+
717
+ scheduler.shutdown();
718
+ });
719
+
720
+ test('allocates after existing result directories and persists monotonic next id', async () => {
721
+ const resultsDir = createResultsDir();
722
+ fs.mkdirSync(`${resultsDir}/kaseki-12`);
723
+
724
+ const scheduler = new JobScheduler(
725
+ {
726
+ port: 8080,
727
+ apiKeys: ['test-key'],
728
+ resultsDir,
729
+ maxConcurrentRuns: 1,
730
+ defaultTaskMode: 'patch',
731
+ maxDiffBytes: 200000,
732
+ agentTimeoutSeconds: 30,
733
+ logLevel: 'info',
734
+ },
735
+ createMockWebhookManager()
736
+ );
737
+
738
+ const first = await scheduler.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'main' });
739
+ const second = await scheduler.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'main' });
740
+
741
+ expect(first.id).toBe('kaseki-13');
742
+ expect(second.id).toBe('kaseki-14');
743
+ expect(fs.readFileSync(`${resultsDir}/.kaseki-api-next-id`, 'utf-8').trim()).toBe('15');
744
+
745
+ scheduler.shutdown();
746
+ });
747
+
748
+ test('parses live docker progress lines', async () => {
749
+ mockSpawnSync.mockReturnValue({
750
+ stdout: '[progress] clone repository info: started\n[progress] pi coding agent: working; events=42\n',
751
+ stderr: '',
752
+ status: 0,
753
+ });
754
+
755
+ const scheduler = new JobScheduler(
756
+ {
757
+ port: 8080,
758
+ apiKeys: ['test-key'],
759
+ resultsDir: createResultsDir(),
760
+ maxConcurrentRuns: 1,
761
+ defaultTaskMode: 'patch',
762
+ maxDiffBytes: 200000,
763
+ agentTimeoutSeconds: 30,
764
+ logLevel: 'info',
765
+ },
766
+ createMockWebhookManager()
767
+ );
768
+
769
+ expect(scheduler.getLiveProgressEvents('kaseki-7', 1)).toEqual([
770
+ expect.objectContaining({
771
+ source: 'docker-logs',
772
+ stage: 'pi coding agent',
773
+ message: 'working; events=42',
774
+ }),
775
+ ]);
776
+ });
777
+
778
+ test('reuses fresh live docker progress cache entries', async () => {
779
+ mockSpawnSync.mockReset();
780
+ process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS = '2000';
781
+ mockSpawnSync.mockReturnValue({
782
+ stdout: '[progress] clone repository info: started\n',
783
+ stderr: '',
784
+ status: 0,
785
+ });
786
+
787
+ const scheduler = new JobScheduler(
788
+ {
789
+ port: 8080,
790
+ apiKeys: ['test-key'],
791
+ resultsDir: createResultsDir(),
792
+ maxConcurrentRuns: 1,
793
+ defaultTaskMode: 'patch',
794
+ maxDiffBytes: 200000,
795
+ agentTimeoutSeconds: 30,
796
+ logLevel: 'info',
797
+ },
798
+ createMockWebhookManager()
799
+ );
800
+
801
+ const first = scheduler.getLiveProgressEvents('kaseki-7', 25);
802
+ const second = scheduler.getLiveProgressEvents('kaseki-7', 25);
803
+
804
+ expect(first).toEqual([
805
+ expect.objectContaining({
806
+ stage: 'clone repository info',
807
+ message: 'started',
808
+ }),
809
+ ]);
810
+ expect(second).toEqual(first);
811
+ expect(mockSpawnSync).toHaveBeenCalledTimes(1);
812
+ expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['logs', '--tail', '200', 'kaseki-7'], expect.any(Object));
813
+ delete process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS;
814
+ });
815
+
816
+ test('refreshes live docker progress cache entries after TTL expiry', async () => {
817
+ mockSpawnSync.mockReset();
818
+ process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS = '0';
819
+ mockSpawnSync
820
+ .mockReturnValueOnce({
821
+ stdout: '[progress] clone repository info: started\n',
822
+ stderr: '',
823
+ status: 0,
824
+ })
825
+ .mockReturnValueOnce({
826
+ stdout: '[progress] pi coding agent: refreshed\n',
827
+ stderr: '',
828
+ status: 0,
829
+ });
830
+
831
+ const scheduler = new JobScheduler(
832
+ {
833
+ port: 8080,
834
+ apiKeys: ['test-key'],
835
+ resultsDir: createResultsDir(),
836
+ maxConcurrentRuns: 1,
837
+ defaultTaskMode: 'patch',
838
+ maxDiffBytes: 200000,
839
+ agentTimeoutSeconds: 30,
840
+ logLevel: 'info',
841
+ },
842
+ createMockWebhookManager()
843
+ );
844
+
845
+ expect(scheduler.getLiveProgressEvents('kaseki-7', 25)).toEqual([
846
+ expect.objectContaining({
847
+ stage: 'clone repository info',
848
+ message: 'started',
849
+ }),
850
+ ]);
851
+
852
+ expect(scheduler.getLiveProgressEvents('kaseki-7', 25)).toEqual([
853
+ expect.objectContaining({
854
+ stage: 'pi coding agent',
855
+ message: 'refreshed',
856
+ }),
857
+ ]);
858
+ expect(mockSpawnSync).toHaveBeenCalledTimes(2);
859
+ delete process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS;
860
+ });
861
+
862
+ test('clears live docker progress cache when running jobs are cancelled', async () => {
863
+ mockSpawnSync.mockReset();
864
+ process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS = '2000';
865
+ const proc = new MockProcess();
866
+ mockSpawn.mockReturnValue(proc);
867
+ mockSpawnSync.mockImplementation((command: string, args: string[]) => {
868
+ if (command === 'docker' && args[0] === 'logs') {
869
+ return {
870
+ stdout: mockSpawnSync.mock.calls.filter((call) => call[1]?.[0] === 'logs').length === 1
871
+ ? '[progress] clone repository info: started\n'
872
+ : '[progress] pi coding agent: after cancel\n',
873
+ stderr: '',
874
+ status: 0,
875
+ };
876
+ }
877
+ return { stdout: '', stderr: '', status: 0 };
878
+ });
879
+
880
+ const scheduler = new JobScheduler(
881
+ {
882
+ port: 8080,
883
+ apiKeys: ['test-key'],
884
+ resultsDir: createResultsDir(),
885
+ maxConcurrentRuns: 1,
886
+ defaultTaskMode: 'patch',
887
+ maxDiffBytes: 200000,
888
+ agentTimeoutSeconds: 30,
889
+ logLevel: 'info',
890
+ },
891
+ createMockWebhookManager()
892
+ );
893
+ const job = await scheduler.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'main' });
894
+
895
+ expect(scheduler.getLiveProgressEvents(job.id, 25)).toEqual([
896
+ expect.objectContaining({
897
+ stage: 'clone repository info',
898
+ message: 'started',
899
+ }),
900
+ ]);
901
+
902
+ scheduler.cancelJob(job.id);
903
+
904
+ expect(scheduler.getLiveProgressEvents(job.id, 25)).toEqual([
905
+ expect.objectContaining({
906
+ stage: 'pi coding agent',
907
+ message: 'after cancel',
908
+ }),
909
+ ]);
910
+ expect(mockSpawnSync.mock.calls.filter((call) => call[1]?.[0] === 'logs')).toHaveLength(2);
911
+ delete process.env.KASEKI_LIVE_PROGRESS_CACHE_TTL_MS;
912
+ });
913
+ });
914
+
915
+ describe('JobScheduler shutdown lifecycle', () => {
916
+ beforeEach(() => {
917
+ jest.useFakeTimers();
918
+ jest.clearAllMocks();
919
+ secretValueCache.clear();
920
+ });
921
+
922
+ afterEach(() => {
923
+ secretValueCache.clear();
924
+ jest.useRealTimers();
925
+ cleanupResultsDirs();
926
+ });
927
+
928
+ test('shutdown terminates running children and marks jobs as shutdown-aborted', async () => {
929
+ const proc = new MockProcess();
930
+ mockSpawn.mockReturnValue(proc);
931
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
932
+
933
+ const scheduler = new JobScheduler(
934
+ {
935
+ port: 8080,
936
+ apiKeys: ['test-key'],
937
+ resultsDir: createResultsDir(),
938
+ maxConcurrentRuns: 1,
939
+ defaultTaskMode: 'patch',
940
+ maxDiffBytes: 200000,
941
+ agentTimeoutSeconds: 30,
942
+ logLevel: 'info',
943
+ },
944
+ createMockWebhookManager()
945
+ );
946
+
947
+ const job = await scheduler.submitJob({
948
+ repoUrl: 'https://github.com/org/repo',
949
+ ref: 'main',
950
+ });
951
+
952
+ scheduler.shutdown();
953
+
954
+ expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
955
+ expect(job.status).toBe('failed');
956
+ expect(job.failureClass).toBe('shutdown_aborted');
957
+ expect(job.error).toBe('Job aborted during scheduler shutdown');
958
+ expect(job.exitCode).toBe(143);
959
+ expect(job.finalized).toBe(true);
960
+
961
+ jest.advanceTimersByTime(5000);
962
+ expect(proc.kill).toHaveBeenCalledTimes(1);
963
+ });
964
+
965
+ test('shutdown does not escalate if child exits during grace period', async () => {
966
+ const proc = new MockProcess();
967
+ mockSpawn.mockReturnValue(proc);
968
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
969
+
970
+ const scheduler = new JobScheduler(
971
+ {
972
+ port: 8080,
973
+ apiKeys: ['test-key'],
974
+ resultsDir: createResultsDir(),
975
+ maxConcurrentRuns: 1,
976
+ defaultTaskMode: 'patch',
977
+ maxDiffBytes: 200000,
978
+ agentTimeoutSeconds: 30,
979
+ logLevel: 'info',
980
+ },
981
+ createMockWebhookManager()
982
+ );
983
+
984
+ await scheduler.submitJob({
985
+ repoUrl: 'https://github.com/org/repo',
986
+ ref: 'main',
987
+ });
988
+
989
+ scheduler.shutdown();
990
+ proc.emit('exit', 0);
991
+
992
+ jest.advanceTimersByTime(5000);
993
+
994
+ expect(proc.kill).toHaveBeenCalledTimes(1);
995
+ expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
996
+ });
997
+ });
998
+
999
+ describe('JobScheduler persistence merge safety', () => {
1000
+ beforeEach(() => {
1001
+ jest.clearAllMocks();
1002
+ mockSpawn.mockReturnValue(new MockProcess());
1003
+ mockSpawnSync.mockReturnValue({ stdout: '', stderr: '', status: 0 });
1004
+ });
1005
+
1006
+ afterEach(() => {
1007
+ cleanupResultsDirs();
1008
+ });
1009
+
1010
+ test('persistJobs skips writes when index lock is already held', async () => {
1011
+ const resultsDir = createResultsDir();
1012
+ const config = {
1013
+ port: 8080,
1014
+ apiKeys: ['test-key'],
1015
+ resultsDir,
1016
+ maxConcurrentRuns: 0,
1017
+ defaultTaskMode: 'patch' as const,
1018
+ maxDiffBytes: 200000,
1019
+ agentTimeoutSeconds: 30,
1020
+ logLevel: 'info' as const,
1021
+ };
1022
+
1023
+ const scheduler = new JobScheduler(config, createMockWebhookManager());
1024
+ await scheduler.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'main' });
1025
+ const indexPath = `${resultsDir}/.kaseki-api-jobs.json`;
1026
+ const before = fs.readFileSync(indexPath, 'utf-8');
1027
+
1028
+ fs.mkdirSync(`${resultsDir}/.kaseki-api-jobs.lock`, { mode: 0o700 });
1029
+ await scheduler.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'feature/locked' });
1030
+ (scheduler as unknown as { persistJobs: () => void }).persistJobs();
1031
+
1032
+ const after = fs.readFileSync(indexPath, 'utf-8');
1033
+ expect(after).toBe(before);
1034
+ });
1035
+
1036
+ test('loadPersistedJobs does not read index when lock is already held', async () => {
1037
+ const resultsDir = createResultsDir();
1038
+ const indexPath = `${resultsDir}/.kaseki-api-jobs.json`;
1039
+ fs.writeFileSync(
1040
+ indexPath,
1041
+ `${JSON.stringify({ version: 1, updatedAt: '2026-05-04T00:00:00.000Z', jobs: [{ id: 'kaseki-1', status: 'queued', request: { repoUrl: 'https://github.com/org/repo', ref: 'main' }, createdAt: '2026-05-04T00:00:00.000Z', resultDir: `${resultsDir}/kaseki-1`, correlationId: 'c1', requestId: 'r1' }] }, null, 2)}
1042
+ `,
1043
+ 'utf-8'
1044
+ );
1045
+ fs.mkdirSync(`${resultsDir}/.kaseki-api-jobs.lock`, { mode: 0o700 });
1046
+
1047
+ const scheduler = new JobScheduler(
1048
+ {
1049
+ port: 8080,
1050
+ apiKeys: ['test-key'],
1051
+ resultsDir,
1052
+ maxConcurrentRuns: 0,
1053
+ defaultTaskMode: 'patch',
1054
+ maxDiffBytes: 200000,
1055
+ agentTimeoutSeconds: 30,
1056
+ logLevel: 'info',
1057
+ },
1058
+ createMockWebhookManager()
1059
+ );
1060
+
1061
+ expect(scheduler.getJob('kaseki-1')).toBeUndefined();
1062
+ });
1063
+ test('interleaved persist writes do not regress newer job state', async () => {
1064
+ const resultsDir = createResultsDir();
1065
+ const config = {
1066
+ port: 8080,
1067
+ apiKeys: ['test-key'],
1068
+ resultsDir,
1069
+ maxConcurrentRuns: 0,
1070
+ defaultTaskMode: 'patch' as const,
1071
+ maxDiffBytes: 200000,
1072
+ agentTimeoutSeconds: 30,
1073
+ logLevel: 'info' as const,
1074
+ };
1075
+
1076
+ const schedulerA = new JobScheduler(config, createMockWebhookManager());
1077
+ const first = await schedulerA.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'main' });
1078
+
1079
+ const schedulerB = new JobScheduler(config, createMockWebhookManager());
1080
+ const staleCopy = schedulerB.getJob(first.id);
1081
+ expect(staleCopy?.status).toBe('queued');
1082
+
1083
+ const firstFromA = schedulerA.getJob(first.id);
1084
+ expect(firstFromA).toBeDefined();
1085
+ if (!firstFromA) {
1086
+ throw new Error('Expected first job from scheduler A');
1087
+ }
1088
+ firstFromA.status = 'completed';
1089
+ firstFromA.exitCode = 0;
1090
+ firstFromA.completedAt = new Date('2026-05-04T00:00:01.000Z');
1091
+ (schedulerA as unknown as { persistJobs: () => void }).persistJobs();
1092
+
1093
+ await schedulerB.submitJob({ repoUrl: 'https://github.com/org/repo', ref: 'feature/branch' });
1094
+ (schedulerB as unknown as { persistJobs: () => void }).persistJobs();
1095
+
1096
+ const raw = JSON.parse(fs.readFileSync(`${resultsDir}/.kaseki-api-jobs.json`, 'utf-8')) as {
1097
+ jobs: Array<{ id: string; status: string; completedAt?: string; exitCode?: number }>;
1098
+ };
1099
+ const mergedFirst = raw.jobs.find((job) => job.id === first.id);
1100
+ expect(mergedFirst?.status).toBe('completed');
1101
+ expect(mergedFirst?.exitCode).toBe(0);
1102
+ expect(mergedFirst?.completedAt).toBe('2026-05-04T00:00:01.000Z');
1103
+ });
1104
+ test('mergePersistedJobs preserves all active jobs while limiting terminal history', () => {
1105
+ const resultsDir = createResultsDir();
1106
+ const scheduler = new JobScheduler(
1107
+ {
1108
+ port: 8080,
1109
+ apiKeys: ['test-key'],
1110
+ resultsDir,
1111
+ maxConcurrentRuns: 0,
1112
+ defaultTaskMode: 'patch',
1113
+ maxDiffBytes: 200000,
1114
+ agentTimeoutSeconds: 30,
1115
+ logLevel: 'info',
1116
+ jobIndexMaxEntries: 1,
1117
+ },
1118
+ createMockWebhookManager()
1119
+ );
1120
+ const mergePersistedJobs = (scheduler as unknown as {
1121
+ mergePersistedJobs: (existing: Array<Record<string, unknown>>, incoming: Array<Record<string, unknown>>) => Array<{ id: string }>;
1122
+ }).mergePersistedJobs.bind(scheduler);
1123
+
1124
+ const jobs = mergePersistedJobs(
1125
+ [
1126
+ persistedJob(resultsDir, 'kaseki-1', 'completed', '2026-05-01T00:00:00.000Z', '2026-05-01T00:10:00.000Z'),
1127
+ persistedJob(resultsDir, 'kaseki-2', 'failed', '2026-05-02T00:00:00.000Z', '2026-05-02T00:10:00.000Z'),
1128
+ persistedJob(resultsDir, 'kaseki-3', 'queued', '2026-04-01T00:00:00.000Z'),
1129
+ ],
1130
+ [persistedJob(resultsDir, 'kaseki-4', 'running', '2026-04-02T00:00:00.000Z')]
1131
+ );
1132
+
1133
+ expect(jobs.map((job) => job.id)).toEqual(['kaseki-2', 'kaseki-4', 'kaseki-3']);
1134
+ });
1135
+
1136
+ test('persistJobs truncates old terminal jobs and writes compact JSON at the retention limit', () => {
1137
+ const resultsDir = createResultsDir();
1138
+ const scheduler = new JobScheduler(
1139
+ {
1140
+ port: 8080,
1141
+ apiKeys: ['test-key'],
1142
+ resultsDir,
1143
+ maxConcurrentRuns: 0,
1144
+ defaultTaskMode: 'patch',
1145
+ maxDiffBytes: 200000,
1146
+ agentTimeoutSeconds: 30,
1147
+ logLevel: 'info',
1148
+ jobIndexMaxEntries: 2,
1149
+ },
1150
+ createMockWebhookManager()
1151
+ );
1152
+ const jobMap = (scheduler as unknown as { jobs: Map<string, unknown> }).jobs;
1153
+ jobMap.set('kaseki-1', persistedRuntimeJob(resultsDir, 'kaseki-1', 'completed', '2026-05-01T00:00:00.000Z'));
1154
+ jobMap.set('kaseki-2', persistedRuntimeJob(resultsDir, 'kaseki-2', 'failed', '2026-05-02T00:00:00.000Z'));
1155
+ jobMap.set('kaseki-3', persistedRuntimeJob(resultsDir, 'kaseki-3', 'completed', '2026-05-03T00:00:00.000Z'));
1156
+ (scheduler as unknown as { persistJobs: () => void }).persistJobs();
1157
+
1158
+ const rawIndex = fs.readFileSync(`${resultsDir}/.kaseki-api-jobs.json`, 'utf-8');
1159
+ const parsed = JSON.parse(rawIndex) as { jobs: Array<{ id: string }> };
1160
+ expect(parsed.jobs.map((job) => job.id)).toEqual(['kaseki-3', 'kaseki-2']);
1161
+ expect(rawIndex).not.toContain('\n "');
1162
+ });
1163
+
1164
+ test('mergePersistedJobs uses deterministic sorting for equal timestamps', () => {
1165
+ const resultsDir = createResultsDir();
1166
+ const scheduler = new JobScheduler(
1167
+ {
1168
+ port: 8080,
1169
+ apiKeys: ['test-key'],
1170
+ resultsDir,
1171
+ maxConcurrentRuns: 0,
1172
+ defaultTaskMode: 'patch',
1173
+ maxDiffBytes: 200000,
1174
+ agentTimeoutSeconds: 30,
1175
+ logLevel: 'info',
1176
+ jobIndexMaxEntries: 10,
1177
+ },
1178
+ createMockWebhookManager()
1179
+ );
1180
+ const mergePersistedJobs = (scheduler as unknown as {
1181
+ mergePersistedJobs: (existing: Array<Record<string, unknown>>, incoming: Array<Record<string, unknown>>) => Array<{ id: string }>;
1182
+ }).mergePersistedJobs.bind(scheduler);
1183
+
1184
+ const jobs = mergePersistedJobs(
1185
+ [
1186
+ persistedJob(resultsDir, 'kaseki-2', 'completed', '2026-05-01T00:00:00.000Z', '2026-05-01T00:10:00.000Z'),
1187
+ persistedJob(resultsDir, 'kaseki-10', 'completed', '2026-05-01T00:00:00.000Z', '2026-05-01T00:10:00.000Z'),
1188
+ persistedJob(resultsDir, 'kaseki-1', 'queued', '2026-05-01T00:00:00.000Z'),
1189
+ ],
1190
+ []
1191
+ );
1192
+
1193
+ expect(jobs.map((job) => job.id)).toEqual(['kaseki-10', 'kaseki-2', 'kaseki-1']);
1194
+ });
1195
+
1196
+ });
1197
+
1198
+ describe('JobScheduler artifact cache invalidation', () => {
1199
+ beforeEach(() => {
1200
+ jest.clearAllMocks();
1201
+ secretValueCache.clear();
1202
+ });
1203
+
1204
+ afterEach(() => {
1205
+ secretValueCache.clear();
1206
+ cleanupResultsDirs();
1207
+ });
1208
+
1209
+ test('clears artifact content cache when queued jobs are cancelled', async () => {
1210
+ const resultsDir = createResultsDir();
1211
+ const artifactCache = { clearForJob: jest.fn() };
1212
+ const scheduler = new JobScheduler(
1213
+ {
1214
+ port: 8080,
1215
+ apiKeys: ['test-key'],
1216
+ resultsDir,
1217
+ maxConcurrentRuns: 0,
1218
+ defaultTaskMode: 'patch',
1219
+ maxDiffBytes: 200000,
1220
+ agentTimeoutSeconds: 1,
1221
+ logLevel: 'info',
1222
+ },
1223
+ createMockWebhookManager(),
1224
+ artifactCache
1225
+ );
1226
+
1227
+ const job = await scheduler.submitJob({
1228
+ repoUrl: 'https://github.com/org/repo',
1229
+ ref: 'main',
1230
+ });
1231
+
1232
+ scheduler.cancelJob(job.id);
1233
+
1234
+ expect(artifactCache.clearForJob).toHaveBeenCalledWith(job.id);
1235
+ });
1236
+ });