@shipfox/api-runners 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (465) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +187 -0
  5. package/LICENSE +21 -0
  6. package/dist/config.d.ts +30 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +208 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/core/demand.d.ts +33 -0
  11. package/dist/core/demand.d.ts.map +1 -0
  12. package/dist/core/demand.js +169 -0
  13. package/dist/core/demand.js.map +1 -0
  14. package/dist/core/entities/ephemeral-registration-token.d.ts +14 -0
  15. package/dist/core/entities/ephemeral-registration-token.d.ts.map +1 -0
  16. package/dist/core/entities/ephemeral-registration-token.js +3 -0
  17. package/dist/core/entities/ephemeral-registration-token.js.map +1 -0
  18. package/dist/core/entities/manual-registration-token.d.ts +12 -0
  19. package/dist/core/entities/manual-registration-token.d.ts.map +1 -0
  20. package/dist/core/entities/manual-registration-token.js +3 -0
  21. package/dist/core/entities/manual-registration-token.js.map +1 -0
  22. package/dist/core/entities/provisioned-runner.d.ts +24 -0
  23. package/dist/core/entities/provisioned-runner.d.ts.map +1 -0
  24. package/dist/core/entities/provisioned-runner.js +3 -0
  25. package/dist/core/entities/provisioned-runner.js.map +1 -0
  26. package/dist/core/entities/provisioner-token.d.ts +18 -0
  27. package/dist/core/entities/provisioner-token.d.ts.map +1 -0
  28. package/dist/core/entities/provisioner-token.js +3 -0
  29. package/dist/core/entities/provisioner-token.js.map +1 -0
  30. package/dist/core/entities/reservation.d.ts +10 -0
  31. package/dist/core/entities/reservation.d.ts.map +1 -0
  32. package/dist/core/entities/reservation.js +3 -0
  33. package/dist/core/entities/reservation.js.map +1 -0
  34. package/dist/core/entities/runner-session.d.ts +18 -0
  35. package/dist/core/entities/runner-session.d.ts.map +1 -0
  36. package/dist/core/entities/runner-session.js +3 -0
  37. package/dist/core/entities/runner-session.js.map +1 -0
  38. package/dist/core/ephemeral-registration-tokens.d.ts +31 -0
  39. package/dist/core/ephemeral-registration-tokens.d.ts.map +1 -0
  40. package/dist/core/ephemeral-registration-tokens.js +61 -0
  41. package/dist/core/ephemeral-registration-tokens.js.map +1 -0
  42. package/dist/core/errors.d.ts +62 -0
  43. package/dist/core/errors.d.ts.map +1 -0
  44. package/dist/core/errors.js +92 -0
  45. package/dist/core/errors.js.map +1 -0
  46. package/dist/core/index.d.ts +14 -0
  47. package/dist/core/index.d.ts.map +1 -0
  48. package/dist/core/index.js +10 -0
  49. package/dist/core/index.js.map +1 -0
  50. package/dist/core/job-executions.d.ts +14 -0
  51. package/dist/core/job-executions.d.ts.map +1 -0
  52. package/dist/core/job-executions.js +37 -0
  53. package/dist/core/job-executions.js.map +1 -0
  54. package/dist/core/maintenance-policy.d.ts +2 -0
  55. package/dist/core/maintenance-policy.d.ts.map +1 -0
  56. package/dist/core/maintenance-policy.js +3 -0
  57. package/dist/core/maintenance-policy.js.map +1 -0
  58. package/dist/core/maintenance.d.ts +33 -0
  59. package/dist/core/maintenance.d.ts.map +1 -0
  60. package/dist/core/maintenance.js +55 -0
  61. package/dist/core/maintenance.js.map +1 -0
  62. package/dist/core/manual-registration-tokens.d.ts +17 -0
  63. package/dist/core/manual-registration-tokens.d.ts.map +1 -0
  64. package/dist/core/manual-registration-tokens.js +28 -0
  65. package/dist/core/manual-registration-tokens.js.map +1 -0
  66. package/dist/core/provisioned-runners.d.ts +67 -0
  67. package/dist/core/provisioned-runners.d.ts.map +1 -0
  68. package/dist/core/provisioned-runners.js +162 -0
  69. package/dist/core/provisioned-runners.js.map +1 -0
  70. package/dist/core/provisioner-tokens.d.ts +20 -0
  71. package/dist/core/provisioner-tokens.d.ts.map +1 -0
  72. package/dist/core/provisioner-tokens.js +36 -0
  73. package/dist/core/provisioner-tokens.js.map +1 -0
  74. package/dist/core/rate-limit.d.ts +35 -0
  75. package/dist/core/rate-limit.d.ts.map +1 -0
  76. package/dist/core/rate-limit.js +74 -0
  77. package/dist/core/rate-limit.js.map +1 -0
  78. package/dist/core/runner-sessions.d.ts +26 -0
  79. package/dist/core/runner-sessions.d.ts.map +1 -0
  80. package/dist/core/runner-sessions.js +41 -0
  81. package/dist/core/runner-sessions.js.map +1 -0
  82. package/dist/core/runner-tool-capabilities.d.ts +24 -0
  83. package/dist/core/runner-tool-capabilities.d.ts.map +1 -0
  84. package/dist/core/runner-tool-capabilities.js +43 -0
  85. package/dist/core/runner-tool-capabilities.js.map +1 -0
  86. package/dist/db/db.d.ts +4478 -0
  87. package/dist/db/db.d.ts.map +1 -0
  88. package/dist/db/db.js +36 -0
  89. package/dist/db/db.js.map +1 -0
  90. package/dist/db/ephemeral-registration-tokens.d.ts +47 -0
  91. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -0
  92. package/dist/db/ephemeral-registration-tokens.js +170 -0
  93. package/dist/db/ephemeral-registration-tokens.js.map +1 -0
  94. package/dist/db/index.d.ts +19 -0
  95. package/dist/db/index.d.ts.map +1 -0
  96. package/dist/db/index.js +15 -0
  97. package/dist/db/index.js.map +1 -0
  98. package/dist/db/job-executions.d.ts +128 -0
  99. package/dist/db/job-executions.d.ts.map +1 -0
  100. package/dist/db/job-executions.js +354 -0
  101. package/dist/db/job-executions.js.map +1 -0
  102. package/dist/db/manual-registration-tokens.d.ts +16 -0
  103. package/dist/db/manual-registration-tokens.d.ts.map +1 -0
  104. package/dist/db/manual-registration-tokens.js +37 -0
  105. package/dist/db/manual-registration-tokens.js.map +1 -0
  106. package/dist/db/provisioned-runners.d.ts +79 -0
  107. package/dist/db/provisioned-runners.d.ts.map +1 -0
  108. package/dist/db/provisioned-runners.js +466 -0
  109. package/dist/db/provisioned-runners.js.map +1 -0
  110. package/dist/db/provisioner-tokens.d.ts +26 -0
  111. package/dist/db/provisioner-tokens.d.ts.map +1 -0
  112. package/dist/db/provisioner-tokens.js +54 -0
  113. package/dist/db/provisioner-tokens.js.map +1 -0
  114. package/dist/db/rate-limits.d.ts +18 -0
  115. package/dist/db/rate-limits.d.ts.map +1 -0
  116. package/dist/db/rate-limits.js +44 -0
  117. package/dist/db/rate-limits.js.map +1 -0
  118. package/dist/db/reservations.d.ts +48 -0
  119. package/dist/db/reservations.d.ts.map +1 -0
  120. package/dist/db/reservations.js +162 -0
  121. package/dist/db/reservations.js.map +1 -0
  122. package/dist/db/runner-sessions.d.ts +18 -0
  123. package/dist/db/runner-sessions.d.ts.map +1 -0
  124. package/dist/db/runner-sessions.js +38 -0
  125. package/dist/db/runner-sessions.js.map +1 -0
  126. package/dist/db/schema/common.d.ts +2 -0
  127. package/dist/db/schema/common.d.ts.map +1 -0
  128. package/dist/db/schema/common.js +4 -0
  129. package/dist/db/schema/common.js.map +1 -0
  130. package/dist/db/schema/ephemeral-registration-tokens.d.ts +199 -0
  131. package/dist/db/schema/ephemeral-registration-tokens.d.ts.map +1 -0
  132. package/dist/db/schema/ephemeral-registration-tokens.js +45 -0
  133. package/dist/db/schema/ephemeral-registration-tokens.js.map +1 -0
  134. package/dist/db/schema/manual-registration-tokens.d.ts +165 -0
  135. package/dist/db/schema/manual-registration-tokens.d.ts.map +1 -0
  136. package/dist/db/schema/manual-registration-tokens.js +40 -0
  137. package/dist/db/schema/manual-registration-tokens.js.map +1 -0
  138. package/dist/db/schema/outbox.d.ts +195 -0
  139. package/dist/db/schema/outbox.d.ts.map +1 -0
  140. package/dist/db/schema/outbox.js +5 -0
  141. package/dist/db/schema/outbox.js.map +1 -0
  142. package/dist/db/schema/pending-job-executions.d.ts +189 -0
  143. package/dist/db/schema/pending-job-executions.d.ts.map +1 -0
  144. package/dist/db/schema/pending-job-executions.js +21 -0
  145. package/dist/db/schema/pending-job-executions.js.map +1 -0
  146. package/dist/db/schema/provisioned-runners.d.ts +379 -0
  147. package/dist/db/schema/provisioned-runners.d.ts.map +1 -0
  148. package/dist/db/schema/provisioned-runners.js +83 -0
  149. package/dist/db/schema/provisioned-runners.js.map +1 -0
  150. package/dist/db/schema/provisioner-tokens.d.ts +216 -0
  151. package/dist/db/schema/provisioner-tokens.d.ts.map +1 -0
  152. package/dist/db/schema/provisioner-tokens.js +48 -0
  153. package/dist/db/schema/provisioner-tokens.js.map +1 -0
  154. package/dist/db/schema/rate-limits.d.ts +145 -0
  155. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  156. package/dist/db/schema/rate-limits.js +25 -0
  157. package/dist/db/schema/rate-limits.js.map +1 -0
  158. package/dist/db/schema/reservations.d.ts +157 -0
  159. package/dist/db/schema/reservations.d.ts.map +1 -0
  160. package/dist/db/schema/reservations.js +34 -0
  161. package/dist/db/schema/reservations.js.map +1 -0
  162. package/dist/db/schema/runner-sessions.d.ts +298 -0
  163. package/dist/db/schema/runner-sessions.d.ts.map +1 -0
  164. package/dist/db/schema/runner-sessions.js +61 -0
  165. package/dist/db/schema/runner-sessions.js.map +1 -0
  166. package/dist/db/schema/running-job-executions.d.ts +334 -0
  167. package/dist/db/schema/running-job-executions.d.ts.map +1 -0
  168. package/dist/db/schema/running-job-executions.js +41 -0
  169. package/dist/db/schema/running-job-executions.js.map +1 -0
  170. package/dist/index.d.ts +6 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +56 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/metrics/index.d.ts +3 -0
  175. package/dist/metrics/index.d.ts.map +1 -0
  176. package/dist/metrics/index.js +4 -0
  177. package/dist/metrics/index.js.map +1 -0
  178. package/dist/metrics/instance.d.ts +34 -0
  179. package/dist/metrics/instance.d.ts.map +1 -0
  180. package/dist/metrics/instance.js +60 -0
  181. package/dist/metrics/instance.js.map +1 -0
  182. package/dist/metrics/service.d.ts +2 -0
  183. package/dist/metrics/service.d.ts.map +1 -0
  184. package/dist/metrics/service.js +21 -0
  185. package/dist/metrics/service.js.map +1 -0
  186. package/dist/presentation/auth/index.d.ts +3 -0
  187. package/dist/presentation/auth/index.d.ts.map +1 -0
  188. package/dist/presentation/auth/index.js +4 -0
  189. package/dist/presentation/auth/index.js.map +1 -0
  190. package/dist/presentation/auth/provisioner-token-auth.d.ts +3 -0
  191. package/dist/presentation/auth/provisioner-token-auth.d.ts.map +1 -0
  192. package/dist/presentation/auth/provisioner-token-auth.js +81 -0
  193. package/dist/presentation/auth/provisioner-token-auth.js.map +1 -0
  194. package/dist/presentation/auth/runner-registration-token-auth.d.ts +17 -0
  195. package/dist/presentation/auth/runner-registration-token-auth.d.ts.map +1 -0
  196. package/dist/presentation/auth/runner-registration-token-auth.js +77 -0
  197. package/dist/presentation/auth/runner-registration-token-auth.js.map +1 -0
  198. package/dist/presentation/dto/active-provisioner.d.ts +8 -0
  199. package/dist/presentation/dto/active-provisioner.d.ts.map +1 -0
  200. package/dist/presentation/dto/active-provisioner.js +10 -0
  201. package/dist/presentation/dto/active-provisioner.js.map +1 -0
  202. package/dist/presentation/dto/index.d.ts +7 -0
  203. package/dist/presentation/dto/index.d.ts.map +1 -0
  204. package/dist/presentation/dto/index.js +8 -0
  205. package/dist/presentation/dto/index.js.map +1 -0
  206. package/dist/presentation/dto/manual-registration-token.d.ts +12 -0
  207. package/dist/presentation/dto/manual-registration-token.d.ts.map +1 -0
  208. package/dist/presentation/dto/manual-registration-token.js +14 -0
  209. package/dist/presentation/dto/manual-registration-token.js.map +1 -0
  210. package/dist/presentation/dto/mint-registration-tokens.d.ts +5 -0
  211. package/dist/presentation/dto/mint-registration-tokens.d.ts.map +1 -0
  212. package/dist/presentation/dto/mint-registration-tokens.js +19 -0
  213. package/dist/presentation/dto/mint-registration-tokens.js.map +1 -0
  214. package/dist/presentation/dto/poll-demand.d.ts +4 -0
  215. package/dist/presentation/dto/poll-demand.d.ts.map +1 -0
  216. package/dist/presentation/dto/poll-demand.js +19 -0
  217. package/dist/presentation/dto/poll-demand.js.map +1 -0
  218. package/dist/presentation/dto/provisioned-runners.d.ts +38 -0
  219. package/dist/presentation/dto/provisioned-runners.d.ts.map +1 -0
  220. package/dist/presentation/dto/provisioned-runners.js +44 -0
  221. package/dist/presentation/dto/provisioned-runners.js.map +1 -0
  222. package/dist/presentation/dto/provisioner-token.d.ts +15 -0
  223. package/dist/presentation/dto/provisioner-token.d.ts.map +1 -0
  224. package/dist/presentation/dto/provisioner-token.js +17 -0
  225. package/dist/presentation/dto/provisioner-token.js.map +1 -0
  226. package/dist/presentation/index.d.ts +4 -0
  227. package/dist/presentation/index.d.ts.map +1 -0
  228. package/dist/presentation/index.js +5 -0
  229. package/dist/presentation/index.js.map +1 -0
  230. package/dist/presentation/routes/create-manual-registration-token.d.ts +2 -0
  231. package/dist/presentation/routes/create-manual-registration-token.d.ts.map +1 -0
  232. package/dist/presentation/routes/create-manual-registration-token.js +44 -0
  233. package/dist/presentation/routes/create-manual-registration-token.js.map +1 -0
  234. package/dist/presentation/routes/create-provisioner-token.d.ts +2 -0
  235. package/dist/presentation/routes/create-provisioner-token.d.ts.map +1 -0
  236. package/dist/presentation/routes/create-provisioner-token.js +42 -0
  237. package/dist/presentation/routes/create-provisioner-token.js.map +1 -0
  238. package/dist/presentation/routes/get-provisioner-me.d.ts +2 -0
  239. package/dist/presentation/routes/get-provisioner-me.d.ts.map +1 -0
  240. package/dist/presentation/routes/get-provisioner-me.js +22 -0
  241. package/dist/presentation/routes/get-provisioner-me.js.map +1 -0
  242. package/dist/presentation/routes/heartbeat.d.ts +2 -0
  243. package/dist/presentation/routes/heartbeat.d.ts.map +1 -0
  244. package/dist/presentation/routes/heartbeat.js +64 -0
  245. package/dist/presentation/routes/heartbeat.js.map +1 -0
  246. package/dist/presentation/routes/index.d.ts +4 -0
  247. package/dist/presentation/routes/index.d.ts.map +1 -0
  248. package/dist/presentation/routes/index.js +97 -0
  249. package/dist/presentation/routes/index.js.map +1 -0
  250. package/dist/presentation/routes/list-active-provisioners.d.ts +2 -0
  251. package/dist/presentation/routes/list-active-provisioners.d.ts.map +1 -0
  252. package/dist/presentation/routes/list-active-provisioners.js +32 -0
  253. package/dist/presentation/routes/list-active-provisioners.js.map +1 -0
  254. package/dist/presentation/routes/list-active-runners.d.ts +2 -0
  255. package/dist/presentation/routes/list-active-runners.d.ts.map +1 -0
  256. package/dist/presentation/routes/list-active-runners.js +32 -0
  257. package/dist/presentation/routes/list-active-runners.js.map +1 -0
  258. package/dist/presentation/routes/list-manual-registration-tokens.d.ts +2 -0
  259. package/dist/presentation/routes/list-manual-registration-tokens.d.ts.map +1 -0
  260. package/dist/presentation/routes/list-manual-registration-tokens.js +32 -0
  261. package/dist/presentation/routes/list-manual-registration-tokens.js.map +1 -0
  262. package/dist/presentation/routes/list-provisioner-tokens.d.ts +2 -0
  263. package/dist/presentation/routes/list-provisioner-tokens.d.ts.map +1 -0
  264. package/dist/presentation/routes/list-provisioner-tokens.js +32 -0
  265. package/dist/presentation/routes/list-provisioner-tokens.js.map +1 -0
  266. package/dist/presentation/routes/mint-registration-tokens.d.ts +2 -0
  267. package/dist/presentation/routes/mint-registration-tokens.d.ts.map +1 -0
  268. package/dist/presentation/routes/mint-registration-tokens.js +74 -0
  269. package/dist/presentation/routes/mint-registration-tokens.js.map +1 -0
  270. package/dist/presentation/routes/poll-demand.d.ts +2 -0
  271. package/dist/presentation/routes/poll-demand.d.ts.map +1 -0
  272. package/dist/presentation/routes/poll-demand.js +53 -0
  273. package/dist/presentation/routes/poll-demand.js.map +1 -0
  274. package/dist/presentation/routes/rate-limit.d.ts +4 -0
  275. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  276. package/dist/presentation/routes/rate-limit.js +94 -0
  277. package/dist/presentation/routes/rate-limit.js.map +1 -0
  278. package/dist/presentation/routes/reconcile-provisioned-runners.d.ts +2 -0
  279. package/dist/presentation/routes/reconcile-provisioned-runners.d.ts.map +1 -0
  280. package/dist/presentation/routes/reconcile-provisioned-runners.js +27 -0
  281. package/dist/presentation/routes/reconcile-provisioned-runners.js.map +1 -0
  282. package/dist/presentation/routes/register.d.ts +2 -0
  283. package/dist/presentation/routes/register.d.ts.map +1 -0
  284. package/dist/presentation/routes/register.js +63 -0
  285. package/dist/presentation/routes/register.js.map +1 -0
  286. package/dist/presentation/routes/report-provisioned-runners.d.ts +2 -0
  287. package/dist/presentation/routes/report-provisioned-runners.d.ts.map +1 -0
  288. package/dist/presentation/routes/report-provisioned-runners.js +37 -0
  289. package/dist/presentation/routes/report-provisioned-runners.js.map +1 -0
  290. package/dist/presentation/routes/request-job.d.ts +2 -0
  291. package/dist/presentation/routes/request-job.d.ts.map +1 -0
  292. package/dist/presentation/routes/request-job.js +45 -0
  293. package/dist/presentation/routes/request-job.js.map +1 -0
  294. package/dist/presentation/routes/revoke-manual-registration-token.d.ts +2 -0
  295. package/dist/presentation/routes/revoke-manual-registration-token.d.ts.map +1 -0
  296. package/dist/presentation/routes/revoke-manual-registration-token.js +42 -0
  297. package/dist/presentation/routes/revoke-manual-registration-token.js.map +1 -0
  298. package/dist/presentation/routes/revoke-provisioner-token.d.ts +2 -0
  299. package/dist/presentation/routes/revoke-provisioner-token.d.ts.map +1 -0
  300. package/dist/presentation/routes/revoke-provisioner-token.js +44 -0
  301. package/dist/presentation/routes/revoke-provisioner-token.js.map +1 -0
  302. package/dist/presentation/routes/workspace-membership.d.ts +6 -0
  303. package/dist/presentation/routes/workspace-membership.d.ts.map +1 -0
  304. package/dist/presentation/routes/workspace-membership.js +6 -0
  305. package/dist/presentation/routes/workspace-membership.js.map +1 -0
  306. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +3 -0
  307. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +1 -0
  308. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js +14 -0
  309. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +1 -0
  310. package/dist/temporal/activities/index.d.ts +9 -0
  311. package/dist/temporal/activities/index.d.ts.map +1 -0
  312. package/dist/temporal/activities/index.js +12 -0
  313. package/dist/temporal/activities/index.js.map +1 -0
  314. package/dist/temporal/activities/maintenance-activities.d.ts +31 -0
  315. package/dist/temporal/activities/maintenance-activities.d.ts.map +1 -0
  316. package/dist/temporal/activities/maintenance-activities.js +18 -0
  317. package/dist/temporal/activities/maintenance-activities.js.map +1 -0
  318. package/dist/temporal/constants.d.ts +2 -0
  319. package/dist/temporal/constants.d.ts.map +1 -0
  320. package/dist/temporal/constants.js +3 -0
  321. package/dist/temporal/constants.js.map +1 -0
  322. package/dist/temporal/workflows/index.d.ts +2 -0
  323. package/dist/temporal/workflows/index.d.ts.map +1 -0
  324. package/dist/temporal/workflows/index.js +3 -0
  325. package/dist/temporal/workflows/index.js.map +1 -0
  326. package/dist/temporal/workflows/stuck-job-detector.d.ts +2 -0
  327. package/dist/temporal/workflows/stuck-job-detector.d.ts.map +1 -0
  328. package/dist/temporal/workflows/stuck-job-detector.js +61 -0
  329. package/dist/temporal/workflows/stuck-job-detector.js.map +1 -0
  330. package/dist/tsconfig.test.tsbuildinfo +1 -0
  331. package/drizzle/0000_initial.sql +185 -0
  332. package/drizzle/meta/0000_snapshot.json +1582 -0
  333. package/drizzle/meta/_journal.json +13 -0
  334. package/drizzle.config.ts +7 -0
  335. package/package.json +82 -0
  336. package/src/config.test.ts +374 -0
  337. package/src/config.ts +330 -0
  338. package/src/core/demand.test.ts +301 -0
  339. package/src/core/demand.ts +239 -0
  340. package/src/core/entities/ephemeral-registration-token.ts +13 -0
  341. package/src/core/entities/manual-registration-token.ts +11 -0
  342. package/src/core/entities/provisioned-runner.ts +30 -0
  343. package/src/core/entities/provisioner-token.ts +16 -0
  344. package/src/core/entities/reservation.ts +9 -0
  345. package/src/core/entities/runner-session.ts +18 -0
  346. package/src/core/ephemeral-registration-tokens.test.ts +108 -0
  347. package/src/core/ephemeral-registration-tokens.ts +112 -0
  348. package/src/core/errors.ts +128 -0
  349. package/src/core/index.ts +56 -0
  350. package/src/core/job-executions.ts +47 -0
  351. package/src/core/maintenance-policy.ts +1 -0
  352. package/src/core/maintenance.test.ts +251 -0
  353. package/src/core/maintenance.ts +74 -0
  354. package/src/core/manual-registration-tokens.test.ts +44 -0
  355. package/src/core/manual-registration-tokens.ts +51 -0
  356. package/src/core/provisioned-runners.test.ts +101 -0
  357. package/src/core/provisioned-runners.ts +301 -0
  358. package/src/core/provisioner-tokens.test.ts +61 -0
  359. package/src/core/provisioner-tokens.ts +61 -0
  360. package/src/core/rate-limit.test.ts +158 -0
  361. package/src/core/rate-limit.ts +121 -0
  362. package/src/core/runner-sessions.test.ts +209 -0
  363. package/src/core/runner-sessions.ts +66 -0
  364. package/src/core/runner-tool-capabilities.test.ts +141 -0
  365. package/src/core/runner-tool-capabilities.ts +75 -0
  366. package/src/db/db.ts +38 -0
  367. package/src/db/ephemeral-registration-tokens.test.ts +332 -0
  368. package/src/db/ephemeral-registration-tokens.ts +348 -0
  369. package/src/db/index.ts +90 -0
  370. package/src/db/job-executions.test.ts +1500 -0
  371. package/src/db/job-executions.ts +647 -0
  372. package/src/db/manual-registration-tokens.test.ts +99 -0
  373. package/src/db/manual-registration-tokens.ts +87 -0
  374. package/src/db/provisioned-runners.test.ts +1812 -0
  375. package/src/db/provisioned-runners.ts +886 -0
  376. package/src/db/provisioner-tokens.test.ts +133 -0
  377. package/src/db/provisioner-tokens.ts +142 -0
  378. package/src/db/rate-limits.test.ts +74 -0
  379. package/src/db/rate-limits.ts +69 -0
  380. package/src/db/reservations.test.ts +399 -0
  381. package/src/db/reservations.ts +296 -0
  382. package/src/db/runner-sessions.test.ts +153 -0
  383. package/src/db/runner-sessions.ts +97 -0
  384. package/src/db/schema/common.ts +3 -0
  385. package/src/db/schema/ephemeral-registration-tokens.ts +58 -0
  386. package/src/db/schema/manual-registration-tokens.ts +40 -0
  387. package/src/db/schema/outbox.ts +4 -0
  388. package/src/db/schema/pending-job-executions.ts +25 -0
  389. package/src/db/schema/provisioned-runners.ts +89 -0
  390. package/src/db/schema/provisioner-tokens.ts +50 -0
  391. package/src/db/schema/rate-limits.ts +27 -0
  392. package/src/db/schema/reservations.ts +38 -0
  393. package/src/db/schema/runner-sessions.ts +77 -0
  394. package/src/db/schema/running-job-executions.ts +50 -0
  395. package/src/index.ts +63 -0
  396. package/src/metrics/index.ts +13 -0
  397. package/src/metrics/instance.ts +115 -0
  398. package/src/metrics/service.ts +22 -0
  399. package/src/presentation/auth/index.ts +6 -0
  400. package/src/presentation/auth/provisioner-token-auth.ts +76 -0
  401. package/src/presentation/auth/runner-registration-token-auth.ts +105 -0
  402. package/src/presentation/dto/active-provisioner.ts +15 -0
  403. package/src/presentation/dto/index.ts +13 -0
  404. package/src/presentation/dto/manual-registration-token.ts +23 -0
  405. package/src/presentation/dto/mint-registration-tokens.ts +35 -0
  406. package/src/presentation/dto/poll-demand.ts +20 -0
  407. package/src/presentation/dto/provisioned-runners.ts +88 -0
  408. package/src/presentation/dto/provisioner-token.ts +29 -0
  409. package/src/presentation/index.ts +6 -0
  410. package/src/presentation/routes/create-manual-registration-token.ts +43 -0
  411. package/src/presentation/routes/create-provisioner-token.ts +41 -0
  412. package/src/presentation/routes/get-provisioner-me.ts +22 -0
  413. package/src/presentation/routes/heartbeat.test.ts +325 -0
  414. package/src/presentation/routes/heartbeat.ts +80 -0
  415. package/src/presentation/routes/index.ts +86 -0
  416. package/src/presentation/routes/list-active-provisioners.ts +25 -0
  417. package/src/presentation/routes/list-active-runners.test.ts +366 -0
  418. package/src/presentation/routes/list-active-runners.ts +25 -0
  419. package/src/presentation/routes/list-manual-registration-tokens.ts +25 -0
  420. package/src/presentation/routes/list-provisioner-tokens.ts +25 -0
  421. package/src/presentation/routes/manual-registration-tokens.test.ts +213 -0
  422. package/src/presentation/routes/mint-registration-tokens.test.ts +453 -0
  423. package/src/presentation/routes/mint-registration-tokens.ts +82 -0
  424. package/src/presentation/routes/poll-demand-release.test.ts +80 -0
  425. package/src/presentation/routes/poll-demand.test.ts +323 -0
  426. package/src/presentation/routes/poll-demand.ts +56 -0
  427. package/src/presentation/routes/provisioner-me.test.ts +172 -0
  428. package/src/presentation/routes/provisioner-tokens.test.ts +219 -0
  429. package/src/presentation/routes/rate-limit.ts +115 -0
  430. package/src/presentation/routes/reconcile-provisioned-runners.test.ts +380 -0
  431. package/src/presentation/routes/reconcile-provisioned-runners.ts +30 -0
  432. package/src/presentation/routes/register.test.ts +453 -0
  433. package/src/presentation/routes/register.ts +75 -0
  434. package/src/presentation/routes/report-provisioned-runners.test.ts +249 -0
  435. package/src/presentation/routes/report-provisioned-runners.ts +40 -0
  436. package/src/presentation/routes/request-job.test.ts +222 -0
  437. package/src/presentation/routes/request-job.ts +47 -0
  438. package/src/presentation/routes/revoke-manual-registration-token.ts +35 -0
  439. package/src/presentation/routes/revoke-provisioner-token.ts +37 -0
  440. package/src/presentation/routes/workspace-membership.ts +9 -0
  441. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +147 -0
  442. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.ts +17 -0
  443. package/src/temporal/activities/index.ts +17 -0
  444. package/src/temporal/activities/maintenance-activities.test.ts +104 -0
  445. package/src/temporal/activities/maintenance-activities.ts +41 -0
  446. package/src/temporal/constants.ts +1 -0
  447. package/src/temporal/workflows/index.ts +1 -0
  448. package/src/temporal/workflows/stuck-job-detector.test.ts +93 -0
  449. package/src/temporal/workflows/stuck-job-detector.ts +67 -0
  450. package/test/env.ts +11 -0
  451. package/test/factories/ephemeral-registration-token.ts +41 -0
  452. package/test/factories/manual-registration-token.ts +37 -0
  453. package/test/factories/pending-job.ts +44 -0
  454. package/test/factories/provisioned-runner.ts +78 -0
  455. package/test/factories/provisioner-token.ts +41 -0
  456. package/test/factories/reservation.ts +33 -0
  457. package/test/factories/runner-session.ts +32 -0
  458. package/test/globalSetup.ts +23 -0
  459. package/test/index.ts +16 -0
  460. package/test/setup.ts +17 -0
  461. package/tsconfig.build.json +9 -0
  462. package/tsconfig.build.tsbuildinfo +1 -0
  463. package/tsconfig.json +3 -0
  464. package/tsconfig.test.json +8 -0
  465. package/vitest.config.ts +14 -0
@@ -0,0 +1,1812 @@
1
+ import {pgClient} from '@shipfox/node-postgres';
2
+ import {and, desc, eq, inArray, or, sql} from 'drizzle-orm';
3
+ import {db} from '#db/db.js';
4
+ import {createRunnerSessionConsumingEphemeralToken} from '#db/ephemeral-registration-tokens.js';
5
+ import {
6
+ listActiveProvisionedRunnerCountsByTemplateTx,
7
+ listActiveProvisionedRunners,
8
+ listProvisionerTerminateIntentRowsTx,
9
+ listProvisionerTerminateIntents,
10
+ reapStaleProvisionedRunners,
11
+ reconcileProvisionedRunners,
12
+ reportProvisionedRunners,
13
+ } from '#db/provisioned-runners.js';
14
+ import {provisionedRunners} from '#db/schema/provisioned-runners.js';
15
+ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
16
+ import {reservations} from '#db/schema/reservations.js';
17
+ import {runnerSessions} from '#db/schema/runner-sessions.js';
18
+ import {runningJobExecutions} from '#db/schema/running-job-executions.js';
19
+ import {
20
+ ephemeralRegistrationTokenFactory,
21
+ provisionedRunnerFactory,
22
+ provisionerTokenFactory,
23
+ reservationFactory,
24
+ runnerSessionFactory,
25
+ } from '#test/index.js';
26
+
27
+ function provisionedRunnerRowsFor(params: {workspaceId: string; provisionerId: string}) {
28
+ return db()
29
+ .select()
30
+ .from(provisionedRunners)
31
+ .where(
32
+ and(
33
+ eq(provisionedRunners.workspaceId, params.workspaceId),
34
+ eq(provisionedRunners.provisionerId, params.provisionerId),
35
+ ),
36
+ );
37
+ }
38
+
39
+ function reservationRowsFor(params: {workspaceId: string; provisionerId: string}) {
40
+ return db()
41
+ .select()
42
+ .from(reservations)
43
+ .where(
44
+ and(
45
+ eq(reservations.workspaceId, params.workspaceId),
46
+ eq(reservations.provisionerId, params.provisionerId),
47
+ ),
48
+ );
49
+ }
50
+
51
+ async function insertRunningJobRow(params: {
52
+ workspaceId: string;
53
+ provisionerId: string;
54
+ provisionedRunnerId: string;
55
+ jobExecutionId?: string;
56
+ startedAt?: Date;
57
+ cancellationRequestedAt?: Date | null;
58
+ }) {
59
+ const startedAt = params.startedAt ?? new Date('2025-01-01T00:00:00.000Z');
60
+ const runnerSession = await runnerSessionFactory.create({workspaceId: params.workspaceId});
61
+
62
+ await db()
63
+ .insert(runningJobExecutions)
64
+ .values({
65
+ workspaceId: params.workspaceId,
66
+ workflowRunId: crypto.randomUUID(),
67
+ workflowRunAttemptId: crypto.randomUUID(),
68
+ jobId: crypto.randomUUID(),
69
+ jobExecutionId: params.jobExecutionId ?? crypto.randomUUID(),
70
+ projectId: crypto.randomUUID(),
71
+ runnerSessionId: runnerSession.id,
72
+ provisionerId: params.provisionerId,
73
+ provisionedRunnerId: params.provisionedRunnerId,
74
+ requiredLabels: ['linux'],
75
+ runnerLabels: ['linux'],
76
+ startedAt,
77
+ lastHeartbeatAt: startedAt,
78
+ cancellationRequestedAt: params.cancellationRequestedAt ?? null,
79
+ });
80
+ }
81
+
82
+ describe('reportProvisionedRunners', () => {
83
+ let workspaceId: string;
84
+ let provisionerId: string;
85
+
86
+ beforeEach(() => {
87
+ workspaceId = crypto.randomUUID();
88
+ provisionerId = crypto.randomUUID();
89
+ });
90
+
91
+ it('dedupes duplicate provisioned runner ids in one batch', async () => {
92
+ const reportedAt = new Date();
93
+
94
+ const result = await reportProvisionedRunners({
95
+ workspaceId,
96
+ provisionerId,
97
+ events: [
98
+ event({provisionedRunnerId: 'provisioned-runner-1', state: 'starting', reportedAt}),
99
+ event({
100
+ provisionedRunnerId: 'provisioned-runner-1',
101
+ state: 'running',
102
+ reportedAt: new Date(reportedAt.getTime() + 1),
103
+ }),
104
+ ],
105
+ });
106
+
107
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
108
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
109
+ expect(rows).toHaveLength(1);
110
+ expect(rows[0]?.state).toBe('running');
111
+ });
112
+
113
+ it('uses state progression to dedupe equal-timestamp provisioned runner reports', async () => {
114
+ const reportedAt = new Date();
115
+
116
+ const result = await reportProvisionedRunners({
117
+ workspaceId,
118
+ provisionerId,
119
+ events: [
120
+ event({provisionedRunnerId: 'provisioned-runner-1', state: 'running', reportedAt}),
121
+ event({provisionedRunnerId: 'provisioned-runner-1', state: 'failed', reportedAt}),
122
+ event({provisionedRunnerId: 'provisioned-runner-2', state: 'failed', reportedAt}),
123
+ event({provisionedRunnerId: 'provisioned-runner-2', state: 'running', reportedAt}),
124
+ ],
125
+ });
126
+
127
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId}).orderBy(
128
+ provisionedRunners.provisionedRunnerId,
129
+ );
130
+ expect(result).toEqual({accepted: 2, reservationsReleased: 0, terminateIntentsHonored: []});
131
+ expect(rows.map((row) => row.state)).toEqual(['failed', 'failed']);
132
+ });
133
+
134
+ it('accepts delayed events that move the lifecycle forward', async () => {
135
+ const newest = new Date();
136
+ await reportProvisionedRunners({
137
+ workspaceId,
138
+ provisionerId,
139
+ events: [
140
+ event({provisionedRunnerId: 'provisioned-runner-1', state: 'running', reportedAt: newest}),
141
+ ],
142
+ });
143
+
144
+ await reportProvisionedRunners({
145
+ workspaceId,
146
+ provisionerId,
147
+ events: [
148
+ event({
149
+ provisionedRunnerId: 'provisioned-runner-1',
150
+ state: 'failed',
151
+ reason: 'late stale failure',
152
+ reportedAt: new Date(newest.getTime() - 1_000),
153
+ }),
154
+ ],
155
+ });
156
+
157
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
158
+ expect(rows[0]?.state).toBe('failed');
159
+ expect(rows[0]?.reason).toBe('late stale failure');
160
+ });
161
+
162
+ it('rejects older out-of-order events in the same lifecycle state', async () => {
163
+ const newest = new Date();
164
+ await reportProvisionedRunners({
165
+ workspaceId,
166
+ provisionerId,
167
+ events: [
168
+ event({
169
+ provisionedRunnerId: 'provisioned-runner-1',
170
+ state: 'running',
171
+ reason: 'fresh',
172
+ reportedAt: newest,
173
+ }),
174
+ ],
175
+ });
176
+
177
+ await reportProvisionedRunners({
178
+ workspaceId,
179
+ provisionerId,
180
+ events: [
181
+ event({
182
+ provisionedRunnerId: 'provisioned-runner-1',
183
+ state: 'running',
184
+ reason: 'stale',
185
+ reportedAt: new Date(newest.getTime() - 1_000),
186
+ }),
187
+ ],
188
+ });
189
+
190
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
191
+ expect(rows[0]?.state).toBe('running');
192
+ expect(rows[0]?.reason).toBe('fresh');
193
+ });
194
+
195
+ it('does not let equal-timestamp lower-priority reports flip terminal state', async () => {
196
+ const reservationId = await createReservation(1);
197
+ const reportedAt = new Date('2025-01-01T00:00:00.000Z');
198
+ await reportProvisionedRunners({
199
+ workspaceId,
200
+ provisionerId,
201
+ events: [
202
+ event({
203
+ provisionedRunnerId: 'provisioned-runner-1',
204
+ reservationId,
205
+ state: 'running',
206
+ reportedAt,
207
+ }),
208
+ ],
209
+ });
210
+
211
+ const terminal = await reportProvisionedRunners({
212
+ workspaceId,
213
+ provisionerId,
214
+ events: [
215
+ event({
216
+ provisionedRunnerId: 'provisioned-runner-1',
217
+ reservationId,
218
+ state: 'failed',
219
+ reportedAt,
220
+ }),
221
+ ],
222
+ });
223
+ const revived = await reportProvisionedRunners({
224
+ workspaceId,
225
+ provisionerId,
226
+ events: [
227
+ event({
228
+ provisionedRunnerId: 'provisioned-runner-1',
229
+ reservationId,
230
+ state: 'running',
231
+ reportedAt,
232
+ }),
233
+ ],
234
+ });
235
+
236
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
237
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
238
+ expect(terminal).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
239
+ expect(revived).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
240
+ expect(provisionedRunnerRows[0]?.state).toBe('failed');
241
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
242
+ expect(reservationRows).toHaveLength(0);
243
+ });
244
+
245
+ it('clamps future reported times so they do not pin provisioned runner state', async () => {
246
+ await reportProvisionedRunners({
247
+ workspaceId,
248
+ provisionerId,
249
+ events: [
250
+ event({
251
+ provisionedRunnerId: 'provisioned-runner-1',
252
+ state: 'starting',
253
+ reportedAt: new Date(Date.now() + 60 * 60 * 1000),
254
+ }),
255
+ ],
256
+ });
257
+
258
+ await reportProvisionedRunners({
259
+ workspaceId,
260
+ provisionerId,
261
+ events: [
262
+ event({
263
+ provisionedRunnerId: 'provisioned-runner-1',
264
+ state: 'running',
265
+ reportedAt: new Date(),
266
+ }),
267
+ ],
268
+ });
269
+
270
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
271
+ expect(rows[0]?.state).toBe('running');
272
+ expect(rows[0]?.reportedAt.getTime()).toBeLessThan(Date.now() + 10_000);
273
+ });
274
+
275
+ it('records lifecycle milestone timestamps from one report batch', async () => {
276
+ const startedAt = new Date('2025-01-01T00:00:00.000Z');
277
+ const stoppingAt = new Date('2025-01-01T00:01:00.000Z');
278
+ const stoppedAt = new Date('2025-01-01T00:02:00.000Z');
279
+ const terminatedAt = new Date('2025-01-01T00:03:00.000Z');
280
+
281
+ const result = await reportProvisionedRunners({
282
+ workspaceId,
283
+ provisionerId,
284
+ events: [
285
+ event({
286
+ provisionedRunnerId: 'provisioned-runner-1',
287
+ state: 'running',
288
+ reportedAt: startedAt,
289
+ }),
290
+ event({
291
+ provisionedRunnerId: 'provisioned-runner-1',
292
+ state: 'stopping',
293
+ reportedAt: stoppingAt,
294
+ }),
295
+ event({
296
+ provisionedRunnerId: 'provisioned-runner-1',
297
+ state: 'stopped',
298
+ reportedAt: stoppedAt,
299
+ }),
300
+ event({
301
+ provisionedRunnerId: 'provisioned-runner-1',
302
+ state: 'terminated',
303
+ reportedAt: terminatedAt,
304
+ }),
305
+ ],
306
+ });
307
+
308
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
309
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
310
+ expect(rows[0]?.state).toBe('terminated');
311
+ expect(rows[0]?.reportedAt.toISOString()).toBe(terminatedAt.toISOString());
312
+ expect(rows[0]?.startedAt?.toISOString()).toBe(startedAt.toISOString());
313
+ expect(rows[0]?.stoppingAt?.toISOString()).toBe(stoppingAt.toISOString());
314
+ expect(rows[0]?.stoppedAt?.toISOString()).toBe(stoppedAt.toISOString());
315
+ expect(rows[0]?.failedAt).toBeNull();
316
+ expect(rows[0]?.terminatedAt?.toISOString()).toBe(terminatedAt.toISOString());
317
+ });
318
+
319
+ it('records delayed lower-state milestones without reviving current state', async () => {
320
+ const terminatedAt = new Date('2025-01-01T00:03:00.000Z');
321
+ const startedAt = new Date('2025-01-01T00:00:00.000Z');
322
+ await reportProvisionedRunners({
323
+ workspaceId,
324
+ provisionerId,
325
+ events: [
326
+ event({
327
+ provisionedRunnerId: 'provisioned-runner-1',
328
+ state: 'terminated',
329
+ reportedAt: terminatedAt,
330
+ }),
331
+ ],
332
+ });
333
+
334
+ await reportProvisionedRunners({
335
+ workspaceId,
336
+ provisionerId,
337
+ events: [
338
+ event({
339
+ provisionedRunnerId: 'provisioned-runner-1',
340
+ state: 'running',
341
+ reportedAt: startedAt,
342
+ }),
343
+ ],
344
+ });
345
+
346
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
347
+ expect(rows[0]?.state).toBe('terminated');
348
+ expect(rows[0]?.reportedAt.toISOString()).toBe(terminatedAt.toISOString());
349
+ expect(rows[0]?.startedAt?.toISOString()).toBe(startedAt.toISOString());
350
+ expect(rows[0]?.terminatedAt?.toISOString()).toBe(terminatedAt.toISOString());
351
+ });
352
+
353
+ it('uses server update time for active provisioned runner windows', async () => {
354
+ await reportProvisionedRunners({
355
+ workspaceId,
356
+ provisionerId,
357
+ events: [
358
+ event({
359
+ provisionedRunnerId: 'provisioned-runner-1',
360
+ state: 'running',
361
+ reportedAt: new Date(Date.now() + 60 * 60 * 1000),
362
+ }),
363
+ ],
364
+ });
365
+ await db().execute(
366
+ sql`UPDATE runners_provisioned_runners SET updated_at = now() - interval '10 minutes'`,
367
+ );
368
+
369
+ const active = await listActiveProvisionedRunners({workspaceId, windowSeconds: 60});
370
+
371
+ expect(active).toEqual([]);
372
+ });
373
+
374
+ it('releases one reservation unit for a terminal unclaimed provisioned runner', async () => {
375
+ const reservationId = await createReservation(2);
376
+
377
+ const result = await reportProvisionedRunners({
378
+ workspaceId,
379
+ provisionerId,
380
+ events: [
381
+ event({provisionedRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'}),
382
+ ],
383
+ });
384
+
385
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
386
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
387
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
388
+ expect(reservationRows[0]?.count).toBe(1);
389
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
390
+ });
391
+
392
+ it('does not release a reservation owned by another workspace or provisioner', async () => {
393
+ const otherWorkspaceReservationId = await createReservation(1, {
394
+ workspaceId: crypto.randomUUID(),
395
+ provisionerId,
396
+ });
397
+ const peerProvisionerReservationId = await createReservation(1, {
398
+ workspaceId,
399
+ provisionerId: crypto.randomUUID(),
400
+ });
401
+
402
+ const result = await reportProvisionedRunners({
403
+ workspaceId,
404
+ provisionerId,
405
+ events: [
406
+ event({
407
+ provisionedRunnerId: 'provisioned-runner-1',
408
+ reservationId: otherWorkspaceReservationId,
409
+ state: 'failed',
410
+ }),
411
+ event({
412
+ provisionedRunnerId: 'provisioned-runner-2',
413
+ reservationId: peerProvisionerReservationId,
414
+ state: 'failed',
415
+ }),
416
+ ],
417
+ });
418
+
419
+ const reservationRows = await db()
420
+ .select()
421
+ .from(reservations)
422
+ .where(inArray(reservations.id, [otherWorkspaceReservationId, peerProvisionerReservationId]));
423
+ expect(result).toEqual({accepted: 2, reservationsReleased: 0, terminateIntentsHonored: []});
424
+ expect(reservationRows).toHaveLength(2);
425
+ expect(reservationRows.every((reservation) => reservation.count === 1)).toBe(true);
426
+ });
427
+
428
+ it('releases a reservation only once across repeated terminal reports', async () => {
429
+ const reservationId = await createReservation(2);
430
+
431
+ await reportProvisionedRunners({
432
+ workspaceId,
433
+ provisionerId,
434
+ events: [
435
+ event({provisionedRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'}),
436
+ ],
437
+ });
438
+ const result = await reportProvisionedRunners({
439
+ workspaceId,
440
+ provisionerId,
441
+ events: [
442
+ event({provisionedRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'}),
443
+ ],
444
+ });
445
+
446
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
447
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
448
+ expect(reservationRows[0]?.count).toBe(1);
449
+ });
450
+
451
+ it('does not let a newer running report revive a terminal provisioned runner', async () => {
452
+ const reservationId = await createReservation(2);
453
+ const failedAt = new Date();
454
+ await reportProvisionedRunners({
455
+ workspaceId,
456
+ provisionerId,
457
+ events: [
458
+ event({
459
+ provisionedRunnerId: 'provisioned-runner-1',
460
+ reservationId,
461
+ state: 'failed',
462
+ reportedAt: failedAt,
463
+ }),
464
+ ],
465
+ });
466
+
467
+ await reportProvisionedRunners({
468
+ workspaceId,
469
+ provisionerId,
470
+ events: [
471
+ event({
472
+ provisionedRunnerId: 'provisioned-runner-1',
473
+ reservationId,
474
+ state: 'running',
475
+ reportedAt: new Date(failedAt.getTime() + 1_000),
476
+ }),
477
+ ],
478
+ });
479
+
480
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
481
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
482
+ expect(provisionedRunnerRows[0]?.state).toBe('failed');
483
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
484
+ expect(reservationRows[0]?.count).toBe(1);
485
+ });
486
+
487
+ it('tracks provider cleanup as terminated', async () => {
488
+ const reservationId = await createReservation(2);
489
+
490
+ const result = await reportProvisionedRunners({
491
+ workspaceId,
492
+ provisionerId,
493
+ events: [
494
+ event({provisionedRunnerId: 'provisioned-runner-1', reservationId, state: 'terminated'}),
495
+ ],
496
+ });
497
+
498
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
499
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
500
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
501
+ expect(reservationRows[0]?.count).toBe(1);
502
+ expect(provisionedRunnerRows[0]?.state).toBe('terminated');
503
+ expect(provisionedRunnerRows[0]?.terminatedAt).toBeInstanceOf(Date);
504
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
505
+ });
506
+
507
+ it('releases multiple units from the same reservation in one batch', async () => {
508
+ const reservationId = await createReservation(3);
509
+
510
+ const result = await reportProvisionedRunners({
511
+ workspaceId,
512
+ provisionerId,
513
+ events: [
514
+ event({provisionedRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'}),
515
+ event({provisionedRunnerId: 'provisioned-runner-2', reservationId, state: 'stopped'}),
516
+ ],
517
+ });
518
+
519
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
520
+ expect(result).toEqual({accepted: 2, reservationsReleased: 2, terminateIntentsHonored: []});
521
+ expect(reservationRows[0]?.count).toBe(1);
522
+ });
523
+
524
+ it('deletes a one-unit reservation instead of violating the positive count check', async () => {
525
+ const reservationId = await createReservation(1);
526
+
527
+ const result = await reportProvisionedRunners({
528
+ workspaceId,
529
+ provisionerId,
530
+ events: [
531
+ event({provisionedRunnerId: 'provisioned-runner-1', reservationId, state: 'failed'}),
532
+ ],
533
+ });
534
+
535
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
536
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
537
+ expect(reservationRows).toHaveLength(0);
538
+ });
539
+
540
+ it('flags release without retrying when the reservation is already expired', async () => {
541
+ await reservationFactory.create({
542
+ workspaceId,
543
+ provisionerId,
544
+ requiredLabels: ['linux'],
545
+ count: 1,
546
+ expiresAt: new Date(Date.now() - 60_000),
547
+ });
548
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
549
+ if (!reservation) throw new Error('Expected reservation');
550
+
551
+ const result = await reportProvisionedRunners({
552
+ workspaceId,
553
+ provisionerId,
554
+ events: [
555
+ event({
556
+ provisionedRunnerId: 'provisioned-runner-1',
557
+ reservationId: reservation.id,
558
+ state: 'failed',
559
+ }),
560
+ ],
561
+ });
562
+
563
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
564
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
565
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
566
+ });
567
+
568
+ it('does not release a reservation for a provisioned runner that already has a runner session', async () => {
569
+ const reservationId = await createReservation(1);
570
+
571
+ const result = await reportProvisionedRunners({
572
+ workspaceId,
573
+ provisionerId,
574
+ events: [
575
+ event({
576
+ provisionedRunnerId: 'provisioned-runner-1',
577
+ reservationId,
578
+ state: 'failed',
579
+ runnerSessionId: crypto.randomUUID(),
580
+ }),
581
+ ],
582
+ });
583
+
584
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
585
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
586
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
587
+ expect(reservationRows[0]?.count).toBe(1);
588
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeNull();
589
+ });
590
+
591
+ it('uses the consumed ephemeral token session before releasing a terminal report', async () => {
592
+ const reservationId = await createReservation(1);
593
+ const token = await ephemeralRegistrationTokenFactory.create({
594
+ workspaceId,
595
+ provisionerId,
596
+ reservationId,
597
+ provisionedRunnerId: 'provisioned-runner-1',
598
+ });
599
+ const session = await createRunnerSessionConsumingEphemeralToken({
600
+ ephemeralTokenId: token.id,
601
+ workspaceId,
602
+ labels: ['linux'],
603
+ maxClaims: 1,
604
+ });
605
+
606
+ const result = await reportProvisionedRunners({
607
+ workspaceId,
608
+ provisionerId,
609
+ events: [
610
+ event({
611
+ provisionedRunnerId: 'provisioned-runner-1',
612
+ reservationId,
613
+ state: 'failed',
614
+ runnerSessionId: crypto.randomUUID(),
615
+ }),
616
+ ],
617
+ });
618
+
619
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
620
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
621
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
622
+ expect(reservationRows[0]?.count).toBe(1);
623
+ expect(provisionedRunnerRows[0]?.runnerSessionId).toBe(session.id);
624
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeNull();
625
+ });
626
+
627
+ it('preserves claimed runner session metadata when a terminal state wins the batch', async () => {
628
+ const reservationId = await createReservation(1);
629
+ const runnerSessionId = crypto.randomUUID();
630
+ const reportedAt = new Date('2025-01-01T00:00:00.000Z');
631
+
632
+ const result = await reportProvisionedRunners({
633
+ workspaceId,
634
+ provisionerId,
635
+ events: [
636
+ event({
637
+ provisionedRunnerId: 'provisioned-runner-1',
638
+ reservationId,
639
+ state: 'running',
640
+ runnerSessionId,
641
+ reportedAt,
642
+ }),
643
+ event({
644
+ provisionedRunnerId: 'provisioned-runner-1',
645
+ reservationId,
646
+ state: 'failed',
647
+ reportedAt: new Date(reportedAt.getTime() + 1_000),
648
+ }),
649
+ ],
650
+ });
651
+
652
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
653
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
654
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
655
+ expect(reservationRows[0]?.count).toBe(1);
656
+ expect(provisionedRunnerRows[0]?.state).toBe('failed');
657
+ expect(provisionedRunnerRows[0]?.runnerSessionId).toBe(runnerSessionId);
658
+ expect(provisionedRunnerRows[0]?.reservationReleasedAt).toBeNull();
659
+ });
660
+
661
+ it('returns honored terminate intents only for the first active-to-terminated transition', async () => {
662
+ await provisionedRunnerFactory.create({
663
+ workspaceId,
664
+ provisionerId,
665
+ provisionedRunnerId: 'provisioned-runner-1',
666
+ state: 'running',
667
+ });
668
+ await insertRunningJobRow({
669
+ workspaceId,
670
+ provisionerId,
671
+ provisionedRunnerId: 'provisioned-runner-1',
672
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
673
+ });
674
+
675
+ const first = await reportProvisionedRunners({
676
+ workspaceId,
677
+ provisionerId,
678
+ events: [event({provisionedRunnerId: 'provisioned-runner-1', state: 'terminated'})],
679
+ });
680
+ const second = await reportProvisionedRunners({
681
+ workspaceId,
682
+ provisionerId,
683
+ events: [event({provisionedRunnerId: 'provisioned-runner-1', state: 'terminated'})],
684
+ });
685
+
686
+ expect(first.terminateIntentsHonored).toEqual([
687
+ {provisionedRunnerId: 'provisioned-runner-1', reason: 'job-cancelled'},
688
+ ]);
689
+ expect(second.terminateIntentsHonored).toEqual([]);
690
+ });
691
+
692
+ async function createReservation(
693
+ count: number,
694
+ overrides?: {workspaceId?: string; provisionerId?: string},
695
+ ): Promise<string> {
696
+ await reservationFactory.create({
697
+ workspaceId: overrides?.workspaceId ?? workspaceId,
698
+ provisionerId: overrides?.provisionerId ?? provisionerId,
699
+ requiredLabels: ['linux'],
700
+ count,
701
+ expiresAt: new Date(Date.now() + 60_000),
702
+ });
703
+ const [reservation] = await db()
704
+ .select()
705
+ .from(reservations)
706
+ .where(
707
+ and(
708
+ eq(reservations.workspaceId, overrides?.workspaceId ?? workspaceId),
709
+ eq(reservations.provisionerId, overrides?.provisionerId ?? provisionerId),
710
+ ),
711
+ )
712
+ .orderBy(desc(reservations.id))
713
+ .limit(1);
714
+ if (!reservation) throw new Error('Expected reservation');
715
+ return reservation.id;
716
+ }
717
+
718
+ function event(params: {
719
+ provisionedRunnerId?: string;
720
+ reservationId?: string | null;
721
+ state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
722
+ reportedAt?: Date;
723
+ reason?: string | null;
724
+ runnerSessionId?: string | null;
725
+ }) {
726
+ return {
727
+ provisionedRunnerId: params.provisionedRunnerId ?? 'provisioned-runner-1',
728
+ reservationId: params.reservationId ?? null,
729
+ templateKey: 'linux',
730
+ labels: ['linux'],
731
+ state: params.state ?? 'running',
732
+ reason: params.reason ?? null,
733
+ runnerSessionId: params.runnerSessionId ?? null,
734
+ providerKind: 'docker',
735
+ reportedAt: params.reportedAt ?? new Date(),
736
+ };
737
+ }
738
+ });
739
+
740
+ describe('listActiveProvisionedRunnerCountsByTemplateTx', () => {
741
+ let workspaceId: string;
742
+ let provisionerId: string;
743
+
744
+ beforeEach(() => {
745
+ workspaceId = crypto.randomUUID();
746
+ provisionerId = crypto.randomUUID();
747
+ });
748
+
749
+ it('groups starting and running provisioned runners by template and ignores non-divergence states', async () => {
750
+ await provisionedRunnerFactory.create({
751
+ workspaceId,
752
+ provisionerId,
753
+ provisionedRunnerId: 'starting-1',
754
+ templateKey: 'linux',
755
+ state: 'starting',
756
+ });
757
+ await provisionedRunnerFactory.create({
758
+ workspaceId,
759
+ provisionerId,
760
+ provisionedRunnerId: 'running-1',
761
+ templateKey: 'linux',
762
+ state: 'running',
763
+ });
764
+ await provisionedRunnerFactory.create({
765
+ workspaceId,
766
+ provisionerId,
767
+ provisionedRunnerId: 'running-2',
768
+ templateKey: 'linux',
769
+ state: 'running',
770
+ });
771
+ await provisionedRunnerFactory.create({
772
+ workspaceId,
773
+ provisionerId,
774
+ provisionedRunnerId: 'stopping-1',
775
+ templateKey: 'linux',
776
+ state: 'stopping',
777
+ });
778
+ await provisionedRunnerFactory.create({
779
+ workspaceId,
780
+ provisionerId,
781
+ provisionedRunnerId: 'null-template',
782
+ templateKey: null,
783
+ state: 'running',
784
+ });
785
+
786
+ const result = await db().transaction((tx) =>
787
+ listActiveProvisionedRunnerCountsByTemplateTx(tx, {workspaceId, provisionerId}),
788
+ );
789
+
790
+ expect(result).toEqual(
791
+ expect.arrayContaining([
792
+ {templateKey: 'linux', state: 'starting', count: 1},
793
+ {templateKey: 'linux', state: 'running', count: 2},
794
+ ]),
795
+ );
796
+ expect(result).toHaveLength(2);
797
+ });
798
+ });
799
+
800
+ describe('listProvisionerTerminateIntents', () => {
801
+ let workspaceId: string;
802
+ let provisionerId: string;
803
+
804
+ beforeEach(() => {
805
+ workspaceId = crypto.randomUUID();
806
+ provisionerId = crypto.randomUUID();
807
+ });
808
+
809
+ it('includes active provisioned runners whose latest bound job is cancelled', async () => {
810
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1'});
811
+ await insertRunningJobRow({
812
+ workspaceId,
813
+ provisionerId,
814
+ provisionedRunnerId: 'provisioned-runner-1',
815
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
816
+ });
817
+
818
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
819
+
820
+ expect(result).toEqual(['provisioned-runner-1']);
821
+ });
822
+
823
+ it('returns structured rows with bounded reasons from the shared query', async () => {
824
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1'});
825
+ await insertRunningJobRow({
826
+ workspaceId,
827
+ provisionerId,
828
+ provisionedRunnerId: 'provisioned-runner-1',
829
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
830
+ });
831
+
832
+ const result = await db().transaction((tx) =>
833
+ listProvisionerTerminateIntentRowsTx(tx, {workspaceId, provisionerId, limit: 1000}),
834
+ );
835
+
836
+ expect(result).toEqual([
837
+ {provisionedRunnerId: 'provisioned-runner-1', reason: 'job-cancelled'},
838
+ ]);
839
+ });
840
+
841
+ it('excludes active provisioned runners whose latest bound job is healthy', async () => {
842
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1'});
843
+ await insertRunningJobRow({
844
+ workspaceId,
845
+ provisionerId,
846
+ provisionedRunnerId: 'provisioned-runner-1',
847
+ });
848
+
849
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
850
+
851
+ expect(result).toEqual([]);
852
+ });
853
+
854
+ it('excludes terminal provisioned runners with cancelled bound jobs', async () => {
855
+ await createProvisionedRunner({
856
+ provisionedRunnerId: 'provisioned-runner-1',
857
+ state: 'terminated',
858
+ });
859
+ await insertRunningJobRow({
860
+ workspaceId,
861
+ provisionerId,
862
+ provisionedRunnerId: 'provisioned-runner-1',
863
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
864
+ });
865
+
866
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
867
+
868
+ expect(result).toEqual([]);
869
+ });
870
+
871
+ it('excludes a cancelled job when it is not the latest bound job', async () => {
872
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1'});
873
+ await insertRunningJobRow({
874
+ workspaceId,
875
+ provisionerId,
876
+ provisionedRunnerId: 'provisioned-runner-1',
877
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
878
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
879
+ });
880
+ await insertRunningJobRow({
881
+ workspaceId,
882
+ provisionerId,
883
+ provisionedRunnerId: 'provisioned-runner-1',
884
+ startedAt: new Date('2025-01-01T00:02:00.000Z'),
885
+ });
886
+
887
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
888
+
889
+ expect(result).toEqual([]);
890
+ });
891
+
892
+ it('excludes cancelled jobs for another provisioner', async () => {
893
+ const otherProvisionerId = crypto.randomUUID();
894
+ await createProvisionedRunner({
895
+ provisionedRunnerId: 'provisioned-runner-1',
896
+ provisionerId: otherProvisionerId,
897
+ });
898
+ await insertRunningJobRow({
899
+ workspaceId,
900
+ provisionedRunnerId: 'provisioned-runner-1',
901
+ provisionerId: otherProvisionerId,
902
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
903
+ });
904
+
905
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
906
+
907
+ expect(result).toEqual([]);
908
+ });
909
+
910
+ it('returns one id for duplicate cancelled bound jobs on the same provisioned runner', async () => {
911
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1'});
912
+ await insertRunningJobRow({
913
+ workspaceId,
914
+ provisionerId,
915
+ provisionedRunnerId: 'provisioned-runner-1',
916
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
917
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
918
+ });
919
+ await insertRunningJobRow({
920
+ workspaceId,
921
+ provisionerId,
922
+ provisionedRunnerId: 'provisioned-runner-1',
923
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
924
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
925
+ });
926
+
927
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 1000});
928
+
929
+ expect(result).toEqual(['provisioned-runner-1']);
930
+ });
931
+
932
+ it('returns a deterministic subset when the limit truncates results', async () => {
933
+ for (const provisionedRunnerId of [
934
+ 'provisioned-runner-c',
935
+ 'provisioned-runner-a',
936
+ 'provisioned-runner-b',
937
+ ]) {
938
+ await createProvisionedRunner({provisionedRunnerId});
939
+ await insertRunningJobRow({
940
+ workspaceId,
941
+ provisionerId,
942
+ provisionedRunnerId,
943
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
944
+ });
945
+ }
946
+
947
+ const result = await listProvisionerTerminateIntents({workspaceId, provisionerId, limit: 2});
948
+
949
+ expect(result).toEqual(['provisioned-runner-a', 'provisioned-runner-b']);
950
+ });
951
+
952
+ async function createProvisionedRunner(params: {
953
+ provisionedRunnerId: string;
954
+ provisionerId?: string;
955
+ state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
956
+ }) {
957
+ return await provisionedRunnerFactory.create({
958
+ workspaceId,
959
+ provisionerId: params.provisionerId ?? provisionerId,
960
+ provisionedRunnerId: params.provisionedRunnerId,
961
+ state: params.state ?? 'running',
962
+ });
963
+ }
964
+ });
965
+
966
+ describe('reapStaleProvisionedRunners', () => {
967
+ let workspaceId: string;
968
+ let provisionerId: string;
969
+
970
+ beforeEach(async () => {
971
+ workspaceId = crypto.randomUUID();
972
+ const provisioner = await provisionerTokenFactory.create({workspaceId});
973
+ provisionerId = provisioner.id;
974
+ });
975
+
976
+ it('fails stale unclaimed provisioned runners and releases reservations', async () => {
977
+ const reservationId = await createReservation(2);
978
+ await createProvisionedRunner({
979
+ provisionedRunnerId: 'provisioned-runner-1',
980
+ reservationId,
981
+ reportedAt: staleAt(),
982
+ updatedAt: staleAt(),
983
+ });
984
+
985
+ const result = await reapStaleProvisionedRunners({thresholdSeconds: 60, limit: 100});
986
+
987
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
988
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
989
+ expect(result).toEqual({reaped: 1, reservationsReleased: 1});
990
+ expect(provisionedRunner).toMatchObject({
991
+ state: 'failed',
992
+ reason: 'stale-provisioner',
993
+ });
994
+ expect(provisionedRunner?.failedAt).toBeInstanceOf(Date);
995
+ expect(provisionedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
996
+ expect(reservation?.id).toBe(reservationId);
997
+ expect(reservation?.count).toBe(1);
998
+ });
999
+
1000
+ it('skips fresh rows, live provisioners, terminal rows, running jobs, and fresh sessions', async () => {
1001
+ await createProvisionedRunner({
1002
+ provisionedRunnerId: 'fresh-row',
1003
+ reportedAt: new Date(),
1004
+ updatedAt: new Date(),
1005
+ });
1006
+ await createProvisionedRunner({
1007
+ provisionedRunnerId: 'live-provisioner',
1008
+ reportedAt: staleAt(),
1009
+ updatedAt: staleAt(),
1010
+ });
1011
+ await createProvisionedRunner({
1012
+ provisionedRunnerId: 'terminal-row',
1013
+ reportedAt: staleAt(),
1014
+ updatedAt: staleAt(),
1015
+ state: 'failed',
1016
+ });
1017
+ await createProvisionedRunner({
1018
+ provisionedRunnerId: 'running-job',
1019
+ reportedAt: staleAt(),
1020
+ updatedAt: staleAt(),
1021
+ });
1022
+ await insertRunningJob({provisionedRunnerId: 'running-job'});
1023
+ const freshSession = await createProvisionedRunner({
1024
+ provisionedRunnerId: 'fresh-session',
1025
+ reportedAt: staleAt(),
1026
+ updatedAt: staleAt(),
1027
+ });
1028
+ await createLinkedSession({
1029
+ provisionedRunnerId: freshSession.provisionedRunnerId,
1030
+ updatedAt: new Date(),
1031
+ });
1032
+ await db()
1033
+ .update(provisionerTokens)
1034
+ .set({lastSeenAt: new Date()})
1035
+ .where(eq(provisionerTokens.id, provisionerId));
1036
+
1037
+ const result = await reapStaleProvisionedRunners({thresholdSeconds: 60, limit: 100});
1038
+
1039
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId}).orderBy(
1040
+ provisionedRunners.provisionedRunnerId,
1041
+ );
1042
+ expect(result).toEqual({reaped: 0, reservationsReleased: 0});
1043
+ expect(rows.map((row) => [row.provisionedRunnerId, row.state])).toEqual([
1044
+ ['fresh-row', 'running'],
1045
+ ['fresh-session', 'running'],
1046
+ ['live-provisioner', 'running'],
1047
+ ['running-job', 'running'],
1048
+ ['terminal-row', 'failed'],
1049
+ ]);
1050
+ });
1051
+
1052
+ it('releases reservations for stale rows whose linked session is no longer live', async () => {
1053
+ const reservationId = await createReservation(1);
1054
+ await createProvisionedRunner({
1055
+ provisionedRunnerId: 'stale-session',
1056
+ reservationId,
1057
+ runnerSessionId: '00000000-0000-4000-8000-000000000001',
1058
+ reportedAt: staleAt(),
1059
+ updatedAt: staleAt(),
1060
+ });
1061
+ const session = await createLinkedSession({
1062
+ provisionedRunnerId: 'stale-session',
1063
+ updatedAt: staleAt(),
1064
+ });
1065
+ await db()
1066
+ .update(provisionedRunners)
1067
+ .set({runnerSessionId: session.id})
1068
+ .where(eq(provisionedRunners.provisionedRunnerId, 'stale-session'));
1069
+
1070
+ const result = await reapStaleProvisionedRunners({thresholdSeconds: 60, limit: 100});
1071
+
1072
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1073
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1074
+ expect(result).toEqual({reaped: 1, reservationsReleased: 1});
1075
+ expect(provisionedRunner?.state).toBe('failed');
1076
+ expect(provisionedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
1077
+ expect(reservationRows.find((row) => row.id === reservationId)).toBeUndefined();
1078
+ });
1079
+
1080
+ it('flags expired releases and drains only the configured batch size', async () => {
1081
+ const activeReservationId = await createReservation(2);
1082
+ const expiredReservationId = await createReservation(1, {
1083
+ expiresAt: new Date(Date.now() - 60_000),
1084
+ });
1085
+ await createProvisionedRunner({
1086
+ provisionedRunnerId: 'stale-1',
1087
+ reservationId: activeReservationId,
1088
+ reportedAt: staleAt(240_000),
1089
+ updatedAt: staleAt(240_000),
1090
+ });
1091
+ await createProvisionedRunner({
1092
+ provisionedRunnerId: 'stale-2',
1093
+ reservationId: expiredReservationId,
1094
+ reportedAt: staleAt(180_000),
1095
+ updatedAt: staleAt(180_000),
1096
+ });
1097
+ await createProvisionedRunner({
1098
+ provisionedRunnerId: 'stale-3',
1099
+ reportedAt: staleAt(120_000),
1100
+ updatedAt: staleAt(120_000),
1101
+ });
1102
+
1103
+ const first = await reapStaleProvisionedRunners({thresholdSeconds: 60, limit: 2});
1104
+ const second = await reapStaleProvisionedRunners({thresholdSeconds: 60, limit: 2});
1105
+
1106
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1107
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1108
+ expect(first).toEqual({reaped: 2, reservationsReleased: 1});
1109
+ expect(second).toEqual({reaped: 1, reservationsReleased: 0});
1110
+ expect(reservationRows.find((row) => row.id === activeReservationId)?.count).toBe(1);
1111
+ expect(reservationRows.find((row) => row.id === expiredReservationId)?.count).toBe(1);
1112
+ expect(provisionedRunnerRows.every((row) => row.state === 'failed')).toBe(true);
1113
+ expect(
1114
+ provisionedRunnerRows
1115
+ .filter((row) => row.reservationId)
1116
+ .every((row) => row.reservationReleasedAt instanceof Date),
1117
+ ).toBe(true);
1118
+ });
1119
+
1120
+ it('does not double-release reservations when terminal report and reaper queue on the workspace lock', async () => {
1121
+ const reservationId = await createReservation(2);
1122
+ await createProvisionedRunner({
1123
+ provisionedRunnerId: 'provisioned-runner-1',
1124
+ reservationId,
1125
+ reportedAt: staleAt(),
1126
+ updatedAt: staleAt(),
1127
+ });
1128
+ const releaseWorkspaceLock = deferred<void>();
1129
+ const lockHolderReady = deferred<void>();
1130
+ const lockHolder = db().transaction(async (tx) => {
1131
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${workspaceId}))`);
1132
+ lockHolderReady.resolve();
1133
+ await releaseWorkspaceLock.promise;
1134
+ });
1135
+
1136
+ await lockHolderReady.promise;
1137
+ const report = reportProvisionedRunners({
1138
+ workspaceId,
1139
+ provisionerId,
1140
+ events: [
1141
+ event({
1142
+ provisionedRunnerId: 'provisioned-runner-1',
1143
+ reservationId,
1144
+ state: 'failed',
1145
+ reportedAt: new Date(),
1146
+ }),
1147
+ ],
1148
+ });
1149
+ await waitForLockWait({queryLike: '%pg_advisory_xact_lock%'});
1150
+ const reaper = reapStaleProvisionedRunners({thresholdSeconds: 60, limit: 100});
1151
+ try {
1152
+ await waitForLockWait({minWaiters: 2, queryLike: '%pg_advisory_xact_lock%'});
1153
+ } finally {
1154
+ releaseWorkspaceLock.resolve();
1155
+ }
1156
+ const [reportResult, reaperResult] = await Promise.all([report, reaper, lockHolder]);
1157
+
1158
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1159
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1160
+ expect(reportResult.reservationsReleased + reaperResult.reservationsReleased).toBe(1);
1161
+ expect(provisionedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
1162
+ expect(reservation?.count).toBe(1);
1163
+ });
1164
+
1165
+ async function createReservation(count: number, overrides?: {expiresAt?: Date}): Promise<string> {
1166
+ await reservationFactory.create({
1167
+ workspaceId,
1168
+ provisionerId,
1169
+ requiredLabels: ['linux'],
1170
+ count,
1171
+ expiresAt: overrides?.expiresAt ?? new Date(Date.now() + 60_000),
1172
+ });
1173
+ const [reservation] = await db()
1174
+ .select()
1175
+ .from(reservations)
1176
+ .where(
1177
+ and(
1178
+ eq(reservations.workspaceId, workspaceId),
1179
+ eq(reservations.provisionerId, provisionerId),
1180
+ ),
1181
+ )
1182
+ .orderBy(desc(reservations.id))
1183
+ .limit(1);
1184
+ if (!reservation) throw new Error('Expected reservation');
1185
+ return reservation.id;
1186
+ }
1187
+
1188
+ async function createProvisionedRunner(params: {
1189
+ provisionedRunnerId: string;
1190
+ reservationId?: string | null;
1191
+ runnerSessionId?: string | null;
1192
+ reportedAt: Date;
1193
+ updatedAt: Date;
1194
+ state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
1195
+ }) {
1196
+ const row = await provisionedRunnerFactory.create({
1197
+ workspaceId,
1198
+ provisionerId,
1199
+ provisionedRunnerId: params.provisionedRunnerId,
1200
+ reservationId: params.reservationId ?? null,
1201
+ runnerSessionId: params.runnerSessionId ?? null,
1202
+ reportedAt: params.reportedAt,
1203
+ state: params.state ?? 'running',
1204
+ });
1205
+ await db()
1206
+ .update(provisionedRunners)
1207
+ .set({updatedAt: params.updatedAt})
1208
+ .where(eq(provisionedRunners.id, row.id));
1209
+ return {...row, updatedAt: params.updatedAt};
1210
+ }
1211
+
1212
+ async function createLinkedSession(params: {provisionedRunnerId: string; updatedAt: Date}) {
1213
+ const session = await runnerSessionFactory.create({workspaceId});
1214
+ await db()
1215
+ .update(runnerSessions)
1216
+ .set({
1217
+ registrationTokenKind: 'ephemeral',
1218
+ provisionerId,
1219
+ provisionedRunnerId: params.provisionedRunnerId,
1220
+ maxClaims: 1,
1221
+ updatedAt: params.updatedAt,
1222
+ })
1223
+ .where(eq(runnerSessions.id, session.id));
1224
+ return session;
1225
+ }
1226
+
1227
+ async function insertRunningJob(params: {provisionedRunnerId: string}) {
1228
+ const runnerSession = await runnerSessionFactory.create({workspaceId});
1229
+
1230
+ await db()
1231
+ .insert(runningJobExecutions)
1232
+ .values({
1233
+ workspaceId,
1234
+ workflowRunId: crypto.randomUUID(),
1235
+ workflowRunAttemptId: crypto.randomUUID(),
1236
+ jobId: crypto.randomUUID(),
1237
+ jobExecutionId: crypto.randomUUID(),
1238
+ projectId: crypto.randomUUID(),
1239
+ runnerSessionId: runnerSession.id,
1240
+ provisionerId,
1241
+ provisionedRunnerId: params.provisionedRunnerId,
1242
+ requiredLabels: ['linux'],
1243
+ runnerLabels: ['linux'],
1244
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
1245
+ lastHeartbeatAt: new Date('2025-01-01T00:00:00.000Z'),
1246
+ });
1247
+ }
1248
+
1249
+ function staleAt(ageMs = 120_000): Date {
1250
+ return new Date(Date.now() - ageMs);
1251
+ }
1252
+
1253
+ function event(params: {
1254
+ provisionedRunnerId: string;
1255
+ reservationId?: string | null;
1256
+ state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
1257
+ reportedAt?: Date;
1258
+ }) {
1259
+ return {
1260
+ provisionedRunnerId: params.provisionedRunnerId,
1261
+ reservationId: params.reservationId ?? null,
1262
+ templateKey: 'linux',
1263
+ labels: ['linux'],
1264
+ state: params.state ?? 'running',
1265
+ reason: null,
1266
+ runnerSessionId: null,
1267
+ providerKind: 'docker',
1268
+ reportedAt: params.reportedAt ?? new Date(),
1269
+ };
1270
+ }
1271
+ });
1272
+
1273
+ describe('reconcileProvisionedRunners', () => {
1274
+ let workspaceId: string;
1275
+ let provisionerId: string;
1276
+
1277
+ beforeEach(() => {
1278
+ workspaceId = crypto.randomUUID();
1279
+ provisionerId = crypto.randomUUID();
1280
+ });
1281
+
1282
+ it('terminates stale absent provisioned runners and releases reservations', async () => {
1283
+ const reservationId = await createReservation(2);
1284
+ await createProvisionedRunner({
1285
+ provisionedRunnerId: 'provisioned-runner-1',
1286
+ reservationId,
1287
+ reportedAt: staleReportedAt(),
1288
+ });
1289
+
1290
+ const result = await reconcileProvisionedRunners({
1291
+ workspaceId,
1292
+ provisionerId,
1293
+ observedProvisionedRunnerIds: ['observed-runner'],
1294
+ terminateGraceSeconds: 60,
1295
+ });
1296
+
1297
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1298
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1299
+ expect(result.absentIds).toEqual(['provisioned-runner-1']);
1300
+ expect(result.reservationsReleased).toBe(1);
1301
+ expect(provisionedRunner?.state).toBe('terminated');
1302
+ expect(provisionedRunner?.terminatedAt).toBeInstanceOf(Date);
1303
+ expect(reservation?.count).toBe(1);
1304
+ });
1305
+
1306
+ it('treats an empty observed set as read-only', async () => {
1307
+ const reservationId = await createReservation(2);
1308
+ await createProvisionedRunner({
1309
+ provisionedRunnerId: 'provisioned-runner-1',
1310
+ reservationId,
1311
+ reportedAt: staleReportedAt(),
1312
+ });
1313
+
1314
+ const result = await reconcileProvisionedRunners({
1315
+ workspaceId,
1316
+ provisionerId,
1317
+ observedProvisionedRunnerIds: [],
1318
+ terminateGraceSeconds: 60,
1319
+ });
1320
+
1321
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1322
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1323
+ expect(result.absentIds).toEqual([]);
1324
+ expect(result.reservationsReleased).toBe(0);
1325
+ expect(provisionedRunner?.state).toBe('running');
1326
+ expect(provisionedRunner?.terminatedAt).toBeNull();
1327
+ expect(provisionedRunner?.reservationReleasedAt).toBeNull();
1328
+ expect(reservation?.count).toBe(2);
1329
+ });
1330
+
1331
+ it('keeps fresh absent provisioned runners inside the grace window', async () => {
1332
+ await createProvisionedRunner({
1333
+ provisionedRunnerId: 'provisioned-runner-1',
1334
+ reportedAt: new Date(),
1335
+ });
1336
+
1337
+ const result = await reconcileProvisionedRunners({
1338
+ workspaceId,
1339
+ provisionerId,
1340
+ observedProvisionedRunnerIds: ['observed-runner'],
1341
+ terminateGraceSeconds: 60,
1342
+ });
1343
+
1344
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1345
+ expect(result.absentIds).toEqual([]);
1346
+ expect(result.reservationsReleased).toBe(0);
1347
+ expect(provisionedRunner?.state).toBe('running');
1348
+ });
1349
+
1350
+ it('respects a fresh report that commits after reconcile selects a stale absent row', async () => {
1351
+ const reservationId = await createReservation(1);
1352
+ await createProvisionedRunner({
1353
+ provisionedRunnerId: 'provisioned-runner-1',
1354
+ reservationId,
1355
+ reportedAt: staleReportedAt(),
1356
+ });
1357
+ const releaseReportTransaction = deferred<void>();
1358
+ const reportTransactionUpdated = deferred<void>();
1359
+
1360
+ const reportTransaction = db().transaction(async (tx) => {
1361
+ await tx
1362
+ .update(provisionedRunners)
1363
+ .set({reportedAt: sql`now()`, updatedAt: sql`now()`})
1364
+ .where(
1365
+ and(
1366
+ eq(provisionedRunners.workspaceId, workspaceId),
1367
+ eq(provisionedRunners.provisionerId, provisionerId),
1368
+ eq(provisionedRunners.provisionedRunnerId, 'provisioned-runner-1'),
1369
+ ),
1370
+ );
1371
+ reportTransactionUpdated.resolve();
1372
+ await releaseReportTransaction.promise;
1373
+ });
1374
+
1375
+ await reportTransactionUpdated.promise;
1376
+ const reconcile = reconcileProvisionedRunners({
1377
+ workspaceId,
1378
+ provisionerId,
1379
+ observedProvisionedRunnerIds: ['observed-runner'],
1380
+ terminateGraceSeconds: 60,
1381
+ });
1382
+ try {
1383
+ await waitForLockWait({queryLike: '%provisioned_runners%'});
1384
+ } finally {
1385
+ releaseReportTransaction.resolve();
1386
+ }
1387
+ const [result] = await Promise.all([reconcile, reportTransaction]);
1388
+
1389
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1390
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1391
+ expect(result.absentIds).toEqual([]);
1392
+ expect(result.reservationsReleased).toBe(0);
1393
+ expect(provisionedRunner?.state).toBe('running');
1394
+ expect(provisionedRunner?.reservationReleasedAt).toBeNull();
1395
+ expect(reservation?.count).toBe(1);
1396
+ });
1397
+
1398
+ it('terminates only stale rows when the observed set is non-empty', async () => {
1399
+ await createProvisionedRunner({
1400
+ provisionedRunnerId: 'stale-runner',
1401
+ reportedAt: staleReportedAt(),
1402
+ });
1403
+ await createProvisionedRunner({
1404
+ provisionedRunnerId: 'fresh-runner',
1405
+ reportedAt: new Date(),
1406
+ });
1407
+
1408
+ const result = await reconcileProvisionedRunners({
1409
+ workspaceId,
1410
+ provisionerId,
1411
+ observedProvisionedRunnerIds: ['observed-runner'],
1412
+ terminateGraceSeconds: 60,
1413
+ });
1414
+
1415
+ const rows = await provisionedRunnerRowsFor({workspaceId, provisionerId}).orderBy(
1416
+ provisionedRunners.provisionedRunnerId,
1417
+ );
1418
+ expect(result.absentIds).toEqual(['stale-runner']);
1419
+ expect(rows.map((row) => [row.provisionedRunnerId, row.state])).toEqual([
1420
+ ['fresh-runner', 'running'],
1421
+ ['stale-runner', 'terminated'],
1422
+ ]);
1423
+ });
1424
+
1425
+ it('releases reservation units, deletes one-unit reservations, and flags expired releases', async () => {
1426
+ const sharedReservationId = await createReservation(3);
1427
+ const oneUnitReservationId = await createReservation(1);
1428
+ const expiredReservationId = await createReservation(1, {
1429
+ expiresAt: new Date(Date.now() - 60_000),
1430
+ });
1431
+ await createProvisionedRunner({
1432
+ provisionedRunnerId: 'shared-1',
1433
+ reservationId: sharedReservationId,
1434
+ reportedAt: staleReportedAt(),
1435
+ });
1436
+ await createProvisionedRunner({
1437
+ provisionedRunnerId: 'shared-2',
1438
+ reservationId: sharedReservationId,
1439
+ reportedAt: staleReportedAt(),
1440
+ });
1441
+ await createProvisionedRunner({
1442
+ provisionedRunnerId: 'one-unit',
1443
+ reservationId: oneUnitReservationId,
1444
+ reportedAt: staleReportedAt(),
1445
+ });
1446
+ await createProvisionedRunner({
1447
+ provisionedRunnerId: 'expired',
1448
+ reservationId: expiredReservationId,
1449
+ reportedAt: staleReportedAt(),
1450
+ });
1451
+
1452
+ const result = await reconcileProvisionedRunners({
1453
+ workspaceId,
1454
+ provisionerId,
1455
+ observedProvisionedRunnerIds: ['observed-runner'],
1456
+ terminateGraceSeconds: 60,
1457
+ });
1458
+
1459
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1460
+ const provisionedRunnerRows = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1461
+ expect(result.reservationsReleased).toBe(3);
1462
+ expect(reservationRows).toHaveLength(2);
1463
+ expect(reservationRows.find((row) => row.id === sharedReservationId)?.count).toBe(1);
1464
+ expect(reservationRows.find((row) => row.id === expiredReservationId)?.count).toBe(1);
1465
+ expect(reservationRows.find((row) => row.id === oneUnitReservationId)).toBeUndefined();
1466
+ expect(provisionedRunnerRows.every((row) => row.reservationReleasedAt instanceof Date)).toBe(
1467
+ true,
1468
+ );
1469
+ });
1470
+
1471
+ it('is idempotent across repeated reconciles', async () => {
1472
+ const reservationId = await createReservation(2);
1473
+ await createProvisionedRunner({
1474
+ provisionedRunnerId: 'provisioned-runner-1',
1475
+ reservationId,
1476
+ reportedAt: staleReportedAt(),
1477
+ });
1478
+
1479
+ const first = await reconcileProvisionedRunners({
1480
+ workspaceId,
1481
+ provisionerId,
1482
+ observedProvisionedRunnerIds: ['observed-runner'],
1483
+ terminateGraceSeconds: 60,
1484
+ });
1485
+ const second = await reconcileProvisionedRunners({
1486
+ workspaceId,
1487
+ provisionerId,
1488
+ observedProvisionedRunnerIds: ['observed-runner'],
1489
+ terminateGraceSeconds: 60,
1490
+ });
1491
+
1492
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1493
+ expect(first.reservationsReleased).toBe(1);
1494
+ expect(second.reservationsReleased).toBe(0);
1495
+ expect(reservation?.count).toBe(1);
1496
+ });
1497
+
1498
+ it('does not touch provisioned runners from another workspace or provisioner', async () => {
1499
+ const otherWorkspaceId = crypto.randomUUID();
1500
+ const otherProvisionerId = crypto.randomUUID();
1501
+ await createProvisionedRunner({
1502
+ provisionedRunnerId: 'owned-runner',
1503
+ reportedAt: staleReportedAt(),
1504
+ });
1505
+ await provisionedRunnerFactory.create({
1506
+ workspaceId: otherWorkspaceId,
1507
+ provisionerId,
1508
+ provisionedRunnerId: 'other-workspace-runner',
1509
+ reportedAt: staleReportedAt(),
1510
+ state: 'running',
1511
+ });
1512
+ await provisionedRunnerFactory.create({
1513
+ workspaceId,
1514
+ provisionerId: otherProvisionerId,
1515
+ provisionedRunnerId: 'other-provisioner-runner',
1516
+ reportedAt: staleReportedAt(),
1517
+ state: 'running',
1518
+ });
1519
+
1520
+ await reconcileProvisionedRunners({
1521
+ workspaceId,
1522
+ provisionerId,
1523
+ observedProvisionedRunnerIds: ['observed-runner'],
1524
+ terminateGraceSeconds: 60,
1525
+ });
1526
+
1527
+ const rows = await db()
1528
+ .select()
1529
+ .from(provisionedRunners)
1530
+ .where(
1531
+ or(
1532
+ and(
1533
+ eq(provisionedRunners.workspaceId, workspaceId),
1534
+ eq(provisionedRunners.provisionerId, provisionerId),
1535
+ ),
1536
+ and(
1537
+ eq(provisionedRunners.workspaceId, otherWorkspaceId),
1538
+ eq(provisionedRunners.provisionerId, provisionerId),
1539
+ ),
1540
+ and(
1541
+ eq(provisionedRunners.workspaceId, workspaceId),
1542
+ eq(provisionedRunners.provisionerId, otherProvisionerId),
1543
+ ),
1544
+ ),
1545
+ )
1546
+ .orderBy(provisionedRunners.provisionedRunnerId);
1547
+ expect(rows.map((row) => [row.provisionedRunnerId, row.state])).toEqual([
1548
+ ['other-provisioner-runner', 'running'],
1549
+ ['other-workspace-runner', 'running'],
1550
+ ['owned-runner', 'terminated'],
1551
+ ]);
1552
+ });
1553
+
1554
+ it('terminates session-bound absent runners without releasing their reservation', async () => {
1555
+ const reservationId = await createReservation(1);
1556
+ await createProvisionedRunner({
1557
+ provisionedRunnerId: 'provisioned-runner-1',
1558
+ reservationId,
1559
+ runnerSessionId: crypto.randomUUID(),
1560
+ reportedAt: staleReportedAt(),
1561
+ });
1562
+
1563
+ const result = await reconcileProvisionedRunners({
1564
+ workspaceId,
1565
+ provisionerId,
1566
+ observedProvisionedRunnerIds: ['observed-runner'],
1567
+ terminateGraceSeconds: 60,
1568
+ });
1569
+
1570
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1571
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1572
+ expect(result.reservationsReleased).toBe(0);
1573
+ expect(provisionedRunner?.state).toBe('terminated');
1574
+ expect(provisionedRunner?.reservationReleasedAt).toBeNull();
1575
+ expect(reservation?.count).toBe(1);
1576
+ });
1577
+
1578
+ it('returns a deterministic newest running job execution bound to an observed provisioned runner', async () => {
1579
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1'});
1580
+ const lowerJobId = '00000000-0000-4000-8000-000000000001';
1581
+ const higherJobId = '00000000-0000-4000-8000-000000000002';
1582
+ const lowerJobExecutionId = '10000000-0000-4000-8000-000000000001';
1583
+ const higherJobExecutionId = '10000000-0000-4000-8000-000000000002';
1584
+ await insertRunningJob({
1585
+ jobId: lowerJobId,
1586
+ jobExecutionId: lowerJobExecutionId,
1587
+ provisionedRunnerId: 'provisioned-runner-1',
1588
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
1589
+ });
1590
+ await insertRunningJob({
1591
+ jobId: higherJobId,
1592
+ jobExecutionId: higherJobExecutionId,
1593
+ provisionedRunnerId: 'provisioned-runner-1',
1594
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
1595
+ });
1596
+
1597
+ const result = await reconcileProvisionedRunners({
1598
+ workspaceId,
1599
+ provisionerId,
1600
+ observedProvisionedRunnerIds: ['provisioned-runner-1'],
1601
+ terminateGraceSeconds: 60,
1602
+ });
1603
+
1604
+ expect(
1605
+ result.boundJobExecutionsByProvisionedRunnerId.get('provisioned-runner-1')?.jobExecutionId,
1606
+ ).toBe(higherJobExecutionId);
1607
+ });
1608
+
1609
+ it('does not let a later running report revive a reconcile-terminated runner', async () => {
1610
+ const reportedAt = staleReportedAt();
1611
+ await createProvisionedRunner({provisionedRunnerId: 'provisioned-runner-1', reportedAt});
1612
+ await reconcileProvisionedRunners({
1613
+ workspaceId,
1614
+ provisionerId,
1615
+ observedProvisionedRunnerIds: ['observed-runner'],
1616
+ terminateGraceSeconds: 60,
1617
+ });
1618
+
1619
+ await reportProvisionedRunners({
1620
+ workspaceId,
1621
+ provisionerId,
1622
+ events: [
1623
+ event({
1624
+ provisionedRunnerId: 'provisioned-runner-1',
1625
+ state: 'running',
1626
+ reportedAt: new Date(reportedAt.getTime() + 120_000),
1627
+ }),
1628
+ ],
1629
+ });
1630
+
1631
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1632
+ expect(provisionedRunner?.state).toBe('terminated');
1633
+ });
1634
+
1635
+ it('does not double-release reservations when terminal report and reconcile queue on the workspace lock', async () => {
1636
+ const reservationId = await createReservation(2);
1637
+ await createProvisionedRunner({
1638
+ provisionedRunnerId: 'provisioned-runner-1',
1639
+ reservationId,
1640
+ reportedAt: staleReportedAt(),
1641
+ });
1642
+ const releaseWorkspaceLock = deferred<void>();
1643
+ const lockHolderReady = deferred<void>();
1644
+ const lockHolder = db().transaction(async (tx) => {
1645
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${workspaceId}))`);
1646
+ lockHolderReady.resolve();
1647
+ await releaseWorkspaceLock.promise;
1648
+ });
1649
+
1650
+ await lockHolderReady.promise;
1651
+ const report = reportProvisionedRunners({
1652
+ workspaceId,
1653
+ provisionerId,
1654
+ events: [
1655
+ event({
1656
+ provisionedRunnerId: 'provisioned-runner-1',
1657
+ reservationId,
1658
+ state: 'failed',
1659
+ reportedAt: new Date(),
1660
+ }),
1661
+ ],
1662
+ });
1663
+ await waitForLockWait({queryLike: '%pg_advisory_xact_lock%'});
1664
+ const reconcile = reconcileProvisionedRunners({
1665
+ workspaceId,
1666
+ provisionerId,
1667
+ observedProvisionedRunnerIds: ['observed-runner'],
1668
+ terminateGraceSeconds: 60,
1669
+ });
1670
+ try {
1671
+ await waitForLockWait({minWaiters: 2, queryLike: '%pg_advisory_xact_lock%'});
1672
+ } finally {
1673
+ releaseWorkspaceLock.resolve();
1674
+ }
1675
+ const [reportResult, reconcileResult] = await Promise.all([report, reconcile, lockHolder]);
1676
+
1677
+ const [provisionedRunner] = await provisionedRunnerRowsFor({workspaceId, provisionerId});
1678
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1679
+ expect(reportResult.reservationsReleased + reconcileResult.reservationsReleased).toBe(1);
1680
+ expect(provisionedRunner?.state).toSatisfy(
1681
+ (state: string | undefined) => state === 'failed' || state === 'terminated',
1682
+ );
1683
+ expect(provisionedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
1684
+ expect(reservation?.count).toBe(1);
1685
+ });
1686
+
1687
+ async function createReservation(count: number, overrides?: {expiresAt?: Date}): Promise<string> {
1688
+ await reservationFactory.create({
1689
+ workspaceId,
1690
+ provisionerId,
1691
+ requiredLabels: ['linux'],
1692
+ count,
1693
+ expiresAt: overrides?.expiresAt ?? new Date(Date.now() + 60_000),
1694
+ });
1695
+ const [reservation] = await db()
1696
+ .select()
1697
+ .from(reservations)
1698
+ .where(
1699
+ and(
1700
+ eq(reservations.workspaceId, workspaceId),
1701
+ eq(reservations.provisionerId, provisionerId),
1702
+ ),
1703
+ )
1704
+ .orderBy(desc(reservations.id))
1705
+ .limit(1);
1706
+ if (!reservation) throw new Error('Expected reservation');
1707
+ return reservation.id;
1708
+ }
1709
+
1710
+ async function createProvisionedRunner(params: {
1711
+ provisionedRunnerId: string;
1712
+ reservationId?: string | null;
1713
+ runnerSessionId?: string | null;
1714
+ reportedAt?: Date;
1715
+ }) {
1716
+ return await provisionedRunnerFactory.create({
1717
+ workspaceId,
1718
+ provisionerId,
1719
+ provisionedRunnerId: params.provisionedRunnerId,
1720
+ reservationId: params.reservationId ?? null,
1721
+ runnerSessionId: params.runnerSessionId ?? null,
1722
+ reportedAt: params.reportedAt ?? new Date(),
1723
+ state: 'running',
1724
+ });
1725
+ }
1726
+
1727
+ async function insertRunningJob(params: {
1728
+ jobId: string;
1729
+ jobExecutionId?: string;
1730
+ provisionedRunnerId: string;
1731
+ startedAt: Date;
1732
+ }) {
1733
+ const runnerSession = await runnerSessionFactory.create({workspaceId});
1734
+
1735
+ await db()
1736
+ .insert(runningJobExecutions)
1737
+ .values({
1738
+ workspaceId,
1739
+ jobId: params.jobId,
1740
+ jobExecutionId: params.jobExecutionId ?? crypto.randomUUID(),
1741
+ workflowRunId: crypto.randomUUID(),
1742
+ workflowRunAttemptId: crypto.randomUUID(),
1743
+ projectId: crypto.randomUUID(),
1744
+ runnerSessionId: runnerSession.id,
1745
+ provisionerId,
1746
+ provisionedRunnerId: params.provisionedRunnerId,
1747
+ requiredLabels: ['linux'],
1748
+ runnerLabels: ['linux'],
1749
+ startedAt: params.startedAt,
1750
+ lastHeartbeatAt: params.startedAt,
1751
+ });
1752
+ }
1753
+
1754
+ function staleReportedAt(): Date {
1755
+ return new Date(Date.now() - 120_000);
1756
+ }
1757
+
1758
+ function event(params: {
1759
+ provisionedRunnerId: string;
1760
+ reservationId?: string | null;
1761
+ state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
1762
+ reportedAt?: Date;
1763
+ }) {
1764
+ return {
1765
+ provisionedRunnerId: params.provisionedRunnerId,
1766
+ reservationId: params.reservationId ?? null,
1767
+ templateKey: 'linux',
1768
+ labels: ['linux'],
1769
+ state: params.state ?? 'running',
1770
+ reason: null,
1771
+ runnerSessionId: null,
1772
+ providerKind: 'docker',
1773
+ reportedAt: params.reportedAt ?? new Date(),
1774
+ };
1775
+ }
1776
+ });
1777
+
1778
+ function deferred<T>() {
1779
+ let resolve!: (value: T | PromiseLike<T>) => void;
1780
+ let reject!: (reason?: unknown) => void;
1781
+ const promise = new Promise<T>((innerResolve, innerReject) => {
1782
+ resolve = innerResolve;
1783
+ reject = innerReject;
1784
+ });
1785
+ return {promise, resolve, reject};
1786
+ }
1787
+
1788
+ async function waitForLockWait(params?: {minWaiters?: number; queryLike?: string}) {
1789
+ const minWaiters = params?.minWaiters ?? 1;
1790
+ const deadline = Date.now() + 2_000;
1791
+ while (Date.now() < deadline) {
1792
+ const result = await pgClient().query<{count: number}>(
1793
+ `
1794
+ SELECT count(*)::int AS count
1795
+ FROM pg_stat_activity
1796
+ WHERE datname = current_database()
1797
+ AND pid <> pg_backend_pid()
1798
+ AND state = 'active'
1799
+ AND wait_event_type = 'Lock'
1800
+ AND ($1::text IS NULL OR query ILIKE $1)
1801
+ `,
1802
+ [params?.queryLike ?? null],
1803
+ );
1804
+ if ((result.rows[0]?.count ?? 0) >= minWaiters) return;
1805
+ await sleep(10);
1806
+ }
1807
+ throw new Error(`Timed out waiting for ${minWaiters} blocked lock waiter(s)`);
1808
+ }
1809
+
1810
+ async function sleep(ms: number) {
1811
+ await new Promise((resolve) => setTimeout(resolve, ms));
1812
+ }