@wardby/cli 0.1.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 (412) hide show
  1. package/.env.example +131 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +282 -0
  5. package/bin/wardby.js +3 -0
  6. package/deploy/local/docker-compose.yml +19 -0
  7. package/dist/claude-coding-worker/driver.d.ts +70 -0
  8. package/dist/claude-coding-worker/driver.js +128 -0
  9. package/dist/claude-coding-worker/main.d.ts +2 -0
  10. package/dist/claude-coding-worker/main.js +41 -0
  11. package/dist/claude-coding-worker/sdk.d.ts +4 -0
  12. package/dist/claude-coding-worker/sdk.js +43 -0
  13. package/dist/claude-coding-worker/tool-relay.d.ts +1 -0
  14. package/dist/claude-coding-worker/tool-relay.js +9 -0
  15. package/dist/cli-help.d.ts +1 -0
  16. package/dist/cli-help.js +20 -0
  17. package/dist/cli.d.ts +18 -0
  18. package/dist/cli.js +629 -0
  19. package/dist/coding/observability.d.ts +59 -0
  20. package/dist/coding/observability.js +77 -0
  21. package/dist/coding/profile.d.ts +88 -0
  22. package/dist/coding/profile.js +137 -0
  23. package/dist/coding/protocol.d.ts +345 -0
  24. package/dist/coding/protocol.js +445 -0
  25. package/dist/coding/provider.d.ts +4 -0
  26. package/dist/coding/provider.js +18 -0
  27. package/dist/coding-proxy/main.d.ts +1 -0
  28. package/dist/coding-proxy/main.js +51 -0
  29. package/dist/coding-worker/artifact.d.ts +16 -0
  30. package/dist/coding-worker/artifact.js +37 -0
  31. package/dist/coding-worker/driver.d.ts +46 -0
  32. package/dist/coding-worker/driver.js +158 -0
  33. package/dist/coding-worker/errors.d.ts +1 -0
  34. package/dist/coding-worker/errors.js +23 -0
  35. package/dist/coding-worker/isolation-probe.d.ts +1 -0
  36. package/dist/coding-worker/isolation-probe.js +146 -0
  37. package/dist/coding-worker/keeper.d.ts +4 -0
  38. package/dist/coding-worker/keeper.js +29 -0
  39. package/dist/coding-worker/main.d.ts +2 -0
  40. package/dist/coding-worker/main.js +43 -0
  41. package/dist/coding-worker/sdk.d.ts +26 -0
  42. package/dist/coding-worker/sdk.js +28 -0
  43. package/dist/coding-worker/types.d.ts +64 -0
  44. package/dist/coding-worker/types.js +1 -0
  45. package/dist/config/providers.d.ts +158 -0
  46. package/dist/config/providers.js +162 -0
  47. package/dist/core/budget-groups.d.ts +72 -0
  48. package/dist/core/budget-groups.js +149 -0
  49. package/dist/core/budget.d.ts +63 -0
  50. package/dist/core/budget.js +69 -0
  51. package/dist/core/coding-queue.d.ts +32 -0
  52. package/dist/core/coding-queue.js +54 -0
  53. package/dist/core/cron.d.ts +31 -0
  54. package/dist/core/cron.js +43 -0
  55. package/dist/core/datastores.d.ts +41 -0
  56. package/dist/core/datastores.js +105 -0
  57. package/dist/core/db.d.ts +2 -0
  58. package/dist/core/db.js +2 -0
  59. package/dist/core/dispatch.d.ts +81 -0
  60. package/dist/core/dispatch.js +184 -0
  61. package/dist/core/engine-native.d.ts +29 -0
  62. package/dist/core/engine-native.js +259 -0
  63. package/dist/core/http-runtime.d.ts +7 -0
  64. package/dist/core/http-runtime.js +101 -0
  65. package/dist/core/lease.d.ts +13 -0
  66. package/dist/core/lease.js +22 -0
  67. package/dist/core/logger.d.ts +13 -0
  68. package/dist/core/logger.js +45 -0
  69. package/dist/core/memory-tools.d.ts +11 -0
  70. package/dist/core/memory-tools.js +122 -0
  71. package/dist/core/reconciler.d.ts +45 -0
  72. package/dist/core/reconciler.js +152 -0
  73. package/dist/core/runner.d.ts +87 -0
  74. package/dist/core/runner.js +529 -0
  75. package/dist/core/scheduler.d.ts +47 -0
  76. package/dist/core/scheduler.js +121 -0
  77. package/dist/core/secrets.d.ts +46 -0
  78. package/dist/core/secrets.js +100 -0
  79. package/dist/core/subagent-memory-tools.d.ts +18 -0
  80. package/dist/core/subagent-memory-tools.js +121 -0
  81. package/dist/core/timing.d.ts +7 -0
  82. package/dist/core/timing.js +7 -0
  83. package/dist/core/webhooks.d.ts +23 -0
  84. package/dist/core/webhooks.js +113 -0
  85. package/dist/env.d.ts +11 -0
  86. package/dist/env.js +13 -0
  87. package/dist/import/budgets.d.ts +16 -0
  88. package/dist/import/budgets.js +21 -0
  89. package/dist/import/bundle.d.ts +16 -0
  90. package/dist/import/bundle.js +61 -0
  91. package/dist/import/cli-args.d.ts +2 -0
  92. package/dist/import/cli-args.js +44 -0
  93. package/dist/import/create.d.ts +34 -0
  94. package/dist/import/create.js +363 -0
  95. package/dist/import/index.d.ts +25 -0
  96. package/dist/import/index.js +130 -0
  97. package/dist/import/model-gate.d.ts +1 -0
  98. package/dist/import/model-gate.js +3 -0
  99. package/dist/import/neutral-schema.d.ts +369 -0
  100. package/dist/import/neutral-schema.js +109 -0
  101. package/dist/import/preflight.d.ts +41 -0
  102. package/dist/import/preflight.js +71 -0
  103. package/dist/import/report.d.ts +5 -0
  104. package/dist/import/report.js +98 -0
  105. package/dist/import/tool-scan.d.ts +8 -0
  106. package/dist/import/tool-scan.js +20 -0
  107. package/dist/mcp/auth/ownership.d.ts +113 -0
  108. package/dist/mcp/auth/ownership.js +87 -0
  109. package/dist/mcp/auth/principal.d.ts +9 -0
  110. package/dist/mcp/auth/principal.js +9 -0
  111. package/dist/mcp/auth/resource-server.d.ts +47 -0
  112. package/dist/mcp/auth/resource-server.js +82 -0
  113. package/dist/mcp/auth/self-hosted/browser.d.ts +3 -0
  114. package/dist/mcp/auth/self-hosted/browser.js +273 -0
  115. package/dist/mcp/auth/self-hosted/cli.d.ts +2 -0
  116. package/dist/mcp/auth/self-hosted/cli.js +39 -0
  117. package/dist/mcp/auth/self-hosted/credentials.d.ts +58 -0
  118. package/dist/mcp/auth/self-hosted/credentials.js +106 -0
  119. package/dist/mcp/auth/self-hosted/rate-limit.d.ts +13 -0
  120. package/dist/mcp/auth/self-hosted/rate-limit.js +20 -0
  121. package/dist/mcp/auth/self-hosted/session.d.ts +36 -0
  122. package/dist/mcp/auth/self-hosted/session.js +110 -0
  123. package/dist/mcp/capabilities.d.ts +11 -0
  124. package/dist/mcp/capabilities.js +10 -0
  125. package/dist/mcp/context.d.ts +46 -0
  126. package/dist/mcp/context.js +1 -0
  127. package/dist/mcp/errors.d.ts +13 -0
  128. package/dist/mcp/errors.js +22 -0
  129. package/dist/mcp/index.d.ts +32 -0
  130. package/dist/mcp/index.js +187 -0
  131. package/dist/mcp/jsonrpc.d.ts +8 -0
  132. package/dist/mcp/jsonrpc.js +1 -0
  133. package/dist/mcp/server.d.ts +107 -0
  134. package/dist/mcp/server.js +203 -0
  135. package/dist/mcp/tasks/manager.d.ts +63 -0
  136. package/dist/mcp/tasks/manager.js +103 -0
  137. package/dist/mcp/tools/agents.d.ts +2 -0
  138. package/dist/mcp/tools/agents.js +375 -0
  139. package/dist/mcp/tools/budget-groups.d.ts +2 -0
  140. package/dist/mcp/tools/budget-groups.js +136 -0
  141. package/dist/mcp/tools/datastore.d.ts +2 -0
  142. package/dist/mcp/tools/datastore.js +217 -0
  143. package/dist/mcp/tools/memory.d.ts +3 -0
  144. package/dist/mcp/tools/memory.js +60 -0
  145. package/dist/mcp/tools/models.d.ts +8 -0
  146. package/dist/mcp/tools/models.js +15 -0
  147. package/dist/mcp/tools/runs.d.ts +2 -0
  148. package/dist/mcp/tools/runs.js +61 -0
  149. package/dist/mcp/tools/scheduling.d.ts +2 -0
  150. package/dist/mcp/tools/scheduling.js +48 -0
  151. package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -0
  152. package/dist/mcp/tools/secret-elicitation-form.js +93 -0
  153. package/dist/mcp/tools/secret-elicitation-server.d.ts +5 -0
  154. package/dist/mcp/tools/secret-elicitation-server.js +57 -0
  155. package/dist/mcp/tools/secret-elicitation.d.ts +38 -0
  156. package/dist/mcp/tools/secret-elicitation.js +52 -0
  157. package/dist/mcp/tools/secrets.d.ts +13 -0
  158. package/dist/mcp/tools/secrets.js +124 -0
  159. package/dist/mcp/tools/subagents.d.ts +2 -0
  160. package/dist/mcp/tools/subagents.js +133 -0
  161. package/dist/mcp/tools/text-result.d.ts +6 -0
  162. package/dist/mcp/tools/text-result.js +3 -0
  163. package/dist/mcp/tools/tools.d.ts +2 -0
  164. package/dist/mcp/tools/tools.js +211 -0
  165. package/dist/mcp/tools/trigger.d.ts +2 -0
  166. package/dist/mcp/tools/trigger.js +120 -0
  167. package/dist/mcp/tools/webhooks.d.ts +2 -0
  168. package/dist/mcp/tools/webhooks.js +34 -0
  169. package/dist/mcp/transport/http-limits.d.ts +17 -0
  170. package/dist/mcp/transport/http-limits.js +92 -0
  171. package/dist/mcp/transport/stdio.d.ts +23 -0
  172. package/dist/mcp/transport/stdio.js +6 -0
  173. package/dist/mcp/transport/streamable-http.d.ts +34 -0
  174. package/dist/mcp/transport/streamable-http.js +158 -0
  175. package/dist/mcp/unattended-schedules.d.ts +15 -0
  176. package/dist/mcp/unattended-schedules.js +21 -0
  177. package/dist/mcp/webhooks/ingress.d.ts +17 -0
  178. package/dist/mcp/webhooks/ingress.js +33 -0
  179. package/dist/observability/config.d.ts +6 -0
  180. package/dist/observability/config.js +20 -0
  181. package/dist/observability/metrics-server.d.ts +11 -0
  182. package/dist/observability/metrics-server.js +47 -0
  183. package/dist/observability/metrics.d.ts +36 -0
  184. package/dist/observability/metrics.js +158 -0
  185. package/dist/providers/auth/authorization-server.d.ts +41 -0
  186. package/dist/providers/auth/authorization-server.js +1 -0
  187. package/dist/providers/auth/delegating.d.ts +44 -0
  188. package/dist/providers/auth/delegating.js +105 -0
  189. package/dist/providers/auth/index.d.ts +11 -0
  190. package/dist/providers/auth/index.js +20 -0
  191. package/dist/providers/auth/release-gate.d.ts +1 -0
  192. package/dist/providers/auth/release-gate.js +4 -0
  193. package/dist/providers/auth/self-hosted.d.ts +78 -0
  194. package/dist/providers/auth/self-hosted.js +377 -0
  195. package/dist/providers/auth/subject.d.ts +1 -0
  196. package/dist/providers/auth/subject.js +6 -0
  197. package/dist/providers/auth/types.d.ts +63 -0
  198. package/dist/providers/auth/types.js +22 -0
  199. package/dist/providers/coding-proxy/deny-port.d.ts +5 -0
  200. package/dist/providers/coding-proxy/deny-port.js +45 -0
  201. package/dist/providers/coding-proxy/environment-credentials.d.ts +7 -0
  202. package/dist/providers/coding-proxy/environment-credentials.js +16 -0
  203. package/dist/providers/coding-proxy/index.d.ts +7 -0
  204. package/dist/providers/coding-proxy/index.js +7 -0
  205. package/dist/providers/coding-proxy/memory-ledger.d.ts +17 -0
  206. package/dist/providers/coding-proxy/memory-ledger.js +103 -0
  207. package/dist/providers/coding-proxy/metering.d.ts +20 -0
  208. package/dist/providers/coding-proxy/metering.js +161 -0
  209. package/dist/providers/coding-proxy/prisma-ledger.d.ts +15 -0
  210. package/dist/providers/coding-proxy/prisma-ledger.js +213 -0
  211. package/dist/providers/coding-proxy/proxy.d.ts +75 -0
  212. package/dist/providers/coding-proxy/proxy.js +721 -0
  213. package/dist/providers/coding-proxy/runtime.d.ts +18 -0
  214. package/dist/providers/coding-proxy/runtime.js +39 -0
  215. package/dist/providers/coding-proxy/secure-fetch.d.ts +9 -0
  216. package/dist/providers/coding-proxy/secure-fetch.js +72 -0
  217. package/dist/providers/coding-proxy/server.d.ts +17 -0
  218. package/dist/providers/coding-proxy/server.js +139 -0
  219. package/dist/providers/coding-proxy/types.d.ts +99 -0
  220. package/dist/providers/coding-proxy/types.js +1 -0
  221. package/dist/providers/datastore/index.d.ts +2 -0
  222. package/dist/providers/datastore/index.js +2 -0
  223. package/dist/providers/datastore/postgres.d.ts +20 -0
  224. package/dist/providers/datastore/postgres.js +130 -0
  225. package/dist/providers/datastore/scoped.d.ts +10 -0
  226. package/dist/providers/datastore/scoped.js +46 -0
  227. package/dist/providers/datastore/types.d.ts +31 -0
  228. package/dist/providers/datastore/types.js +7 -0
  229. package/dist/providers/email/types.d.ts +46 -0
  230. package/dist/providers/email/types.js +8 -0
  231. package/dist/providers/engine/index.d.ts +1 -0
  232. package/dist/providers/engine/index.js +1 -0
  233. package/dist/providers/engine/types.d.ts +67 -0
  234. package/dist/providers/engine/types.js +16 -0
  235. package/dist/providers/executor/build.d.ts +6 -0
  236. package/dist/providers/executor/build.js +15 -0
  237. package/dist/providers/executor/composition.d.ts +14 -0
  238. package/dist/providers/executor/composition.js +123 -0
  239. package/dist/providers/executor/container.d.ts +184 -0
  240. package/dist/providers/executor/container.js +936 -0
  241. package/dist/providers/executor/dbos-status.d.ts +50 -0
  242. package/dist/providers/executor/dbos-status.js +64 -0
  243. package/dist/providers/executor/dbos.d.ts +53 -0
  244. package/dist/providers/executor/dbos.js +269 -0
  245. package/dist/providers/executor/in-process.d.ts +18 -0
  246. package/dist/providers/executor/in-process.js +38 -0
  247. package/dist/providers/executor/index.d.ts +8 -0
  248. package/dist/providers/executor/index.js +8 -0
  249. package/dist/providers/executor/routing.d.ts +48 -0
  250. package/dist/providers/executor/routing.js +70 -0
  251. package/dist/providers/executor/types.d.ts +55 -0
  252. package/dist/providers/executor/types.js +1 -0
  253. package/dist/providers/index.d.ts +43 -0
  254. package/dist/providers/index.js +18 -0
  255. package/dist/providers/jobs/docker-isolation.d.ts +166 -0
  256. package/dist/providers/jobs/docker-isolation.js +724 -0
  257. package/dist/providers/jobs/docker.d.ts +127 -0
  258. package/dist/providers/jobs/docker.js +1027 -0
  259. package/dist/providers/jobs/fake-kubernetes-api.d.ts +98 -0
  260. package/dist/providers/jobs/fake-kubernetes-api.js +142 -0
  261. package/dist/providers/jobs/fake.d.ts +23 -0
  262. package/dist/providers/jobs/fake.js +132 -0
  263. package/dist/providers/jobs/kubernetes-api.d.ts +60 -0
  264. package/dist/providers/jobs/kubernetes-api.js +20 -0
  265. package/dist/providers/jobs/kubernetes-client.d.ts +36 -0
  266. package/dist/providers/jobs/kubernetes-client.js +171 -0
  267. package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +49 -0
  268. package/dist/providers/jobs/kubernetes-dry-run-fixture.js +141 -0
  269. package/dist/providers/jobs/kubernetes-isolation.d.ts +147 -0
  270. package/dist/providers/jobs/kubernetes-isolation.js +607 -0
  271. package/dist/providers/jobs/kubernetes-platform.d.ts +177 -0
  272. package/dist/providers/jobs/kubernetes-platform.js +280 -0
  273. package/dist/providers/jobs/kubernetes-preflight.d.ts +59 -0
  274. package/dist/providers/jobs/kubernetes-preflight.js +339 -0
  275. package/dist/providers/jobs/kubernetes-witness.d.ts +10 -0
  276. package/dist/providers/jobs/kubernetes-witness.js +193 -0
  277. package/dist/providers/jobs/kubernetes.d.ts +127 -0
  278. package/dist/providers/jobs/kubernetes.js +874 -0
  279. package/dist/providers/jobs/safe-extract.d.ts +9 -0
  280. package/dist/providers/jobs/safe-extract.js +325 -0
  281. package/dist/providers/jobs/types.d.ts +78 -0
  282. package/dist/providers/jobs/types.js +6 -0
  283. package/dist/providers/jobs/workspace-swap.d.ts +2 -0
  284. package/dist/providers/jobs/workspace-swap.js +47 -0
  285. package/dist/providers/llm/anthropic.d.ts +13 -0
  286. package/dist/providers/llm/anthropic.js +21 -0
  287. package/dist/providers/llm/bedrock.d.ts +19 -0
  288. package/dist/providers/llm/bedrock.js +33 -0
  289. package/dist/providers/llm/claude-messages.d.ts +106 -0
  290. package/dist/providers/llm/claude-messages.js +157 -0
  291. package/dist/providers/llm/claude-provider.d.ts +40 -0
  292. package/dist/providers/llm/claude-provider.js +31 -0
  293. package/dist/providers/llm/index.d.ts +10 -0
  294. package/dist/providers/llm/index.js +10 -0
  295. package/dist/providers/llm/openai.d.ts +23 -0
  296. package/dist/providers/llm/openai.js +156 -0
  297. package/dist/providers/llm/pricing-anthropic.d.ts +4 -0
  298. package/dist/providers/llm/pricing-anthropic.js +30 -0
  299. package/dist/providers/llm/pricing-bedrock-claude.d.ts +12 -0
  300. package/dist/providers/llm/pricing-bedrock-claude.js +37 -0
  301. package/dist/providers/llm/pricing-core.d.ts +26 -0
  302. package/dist/providers/llm/pricing-core.js +17 -0
  303. package/dist/providers/llm/pricing.d.ts +30 -0
  304. package/dist/providers/llm/pricing.js +74 -0
  305. package/dist/providers/llm/registration.d.ts +8 -0
  306. package/dist/providers/llm/registration.js +39 -0
  307. package/dist/providers/llm/routing.d.ts +25 -0
  308. package/dist/providers/llm/routing.js +32 -0
  309. package/dist/providers/llm/types.d.ts +85 -0
  310. package/dist/providers/llm/types.js +12 -0
  311. package/dist/providers/memory/index.d.ts +2 -0
  312. package/dist/providers/memory/index.js +2 -0
  313. package/dist/providers/memory/postgres.d.ts +20 -0
  314. package/dist/providers/memory/postgres.js +72 -0
  315. package/dist/providers/memory/types.d.ts +32 -0
  316. package/dist/providers/memory/types.js +17 -0
  317. package/dist/providers/secrets/app-key.d.ts +8 -0
  318. package/dist/providers/secrets/app-key.js +50 -0
  319. package/dist/providers/secrets/index.d.ts +10 -0
  320. package/dist/providers/secrets/index.js +13 -0
  321. package/dist/providers/secrets/transfer-envelope.d.ts +12 -0
  322. package/dist/providers/secrets/transfer-envelope.js +35 -0
  323. package/dist/providers/secrets/types.d.ts +18 -0
  324. package/dist/providers/secrets/types.js +12 -0
  325. package/dist/providers/storage/types.d.ts +18 -0
  326. package/dist/providers/storage/types.js +7 -0
  327. package/dist/providers/vcs/git.d.ts +105 -0
  328. package/dist/providers/vcs/git.js +710 -0
  329. package/dist/providers/vcs/github.d.ts +105 -0
  330. package/dist/providers/vcs/github.js +394 -0
  331. package/dist/providers/vcs/index.d.ts +6 -0
  332. package/dist/providers/vcs/index.js +26 -0
  333. package/dist/providers/vcs/types.d.ts +118 -0
  334. package/dist/providers/vcs/types.js +1 -0
  335. package/dist/sandbox/bounded-json.d.ts +2 -0
  336. package/dist/sandbox/bounded-json.js +36 -0
  337. package/dist/sandbox/bridge.d.ts +15 -0
  338. package/dist/sandbox/bridge.js +78 -0
  339. package/dist/sandbox/eval-core.d.ts +37 -0
  340. package/dist/sandbox/eval-core.js +159 -0
  341. package/dist/sandbox/fetch-policy.d.ts +24 -0
  342. package/dist/sandbox/fetch-policy.js +96 -0
  343. package/dist/sandbox/generated/zod-to-json-schema.bundle.js +5 -0
  344. package/dist/sandbox/generated/zod.bundle.js +1 -0
  345. package/dist/sandbox/host-functions.d.ts +29 -0
  346. package/dist/sandbox/host-functions.js +168 -0
  347. package/dist/sandbox/limits.d.ts +39 -0
  348. package/dist/sandbox/limits.js +39 -0
  349. package/dist/sandbox/parser-worker/__fixtures__/crash.d.ts +1 -0
  350. package/dist/sandbox/parser-worker/__fixtures__/crash.js +6 -0
  351. package/dist/sandbox/parser-worker/__fixtures__/echo.d.ts +1 -0
  352. package/dist/sandbox/parser-worker/__fixtures__/echo.js +5 -0
  353. package/dist/sandbox/parser-worker/__fixtures__/reject.d.ts +1 -0
  354. package/dist/sandbox/parser-worker/__fixtures__/reject.js +5 -0
  355. package/dist/sandbox/parser-worker/__fixtures__/spin-forever.d.ts +1 -0
  356. package/dist/sandbox/parser-worker/__fixtures__/spin-forever.js +11 -0
  357. package/dist/sandbox/parser-worker/pool.d.ts +13 -0
  358. package/dist/sandbox/parser-worker/pool.js +111 -0
  359. package/dist/sandbox/parser-worker/worker.d.ts +14 -0
  360. package/dist/sandbox/parser-worker/worker.js +63 -0
  361. package/dist/sandbox/pii-redaction.d.ts +1 -0
  362. package/dist/sandbox/pii-redaction.js +29 -0
  363. package/dist/sandbox/prelude.d.ts +26 -0
  364. package/dist/sandbox/prelude.js +408 -0
  365. package/dist/sandbox/quickjs-module.d.ts +13 -0
  366. package/dist/sandbox/quickjs-module.js +19 -0
  367. package/dist/sandbox/run-in-sandbox.d.ts +34 -0
  368. package/dist/sandbox/run-in-sandbox.js +53 -0
  369. package/dist/sandbox/safe-fetch.d.ts +26 -0
  370. package/dist/sandbox/safe-fetch.js +140 -0
  371. package/dist/sandbox/tool-capabilities.d.ts +66 -0
  372. package/dist/sandbox/tool-capabilities.js +103 -0
  373. package/dist/sandbox/zod-params.d.ts +22 -0
  374. package/dist/sandbox/zod-params.js +60 -0
  375. package/dist/serve.d.ts +14 -0
  376. package/dist/serve.js +54 -0
  377. package/dist/wardby-bin.d.ts +2 -0
  378. package/dist/wardby-bin.js +20 -0
  379. package/package.json +123 -0
  380. package/prisma/migrations/20260905000000_init/migration.sql +34 -0
  381. package/prisma/migrations/20260905010000_scheduler_durability/migration.sql +31 -0
  382. package/prisma/migrations/20260905020000_tools_multiturn/migration.sql +47 -0
  383. package/prisma/migrations/20260905030000_cache_tool_json_schema/migration.sql +13 -0
  384. package/prisma/migrations/20260906000000_mcp_phase4/migration.sql +115 -0
  385. package/prisma/migrations/20260906010000_run_final_text/migration.sql +7 -0
  386. package/prisma/migrations/20260906020000_run_turns/migration.sql +6 -0
  387. package/prisma/migrations/20260906030000_task_principal_id/migration.sql +5 -0
  388. package/prisma/migrations/20260906040000_secure_self_hosted_oauth/migration.sql +67 -0
  389. package/prisma/migrations/20260906050000_phase5_coding_agents/migration.sql +52 -0
  390. package/prisma/migrations/20260906060000_tool_capability_scoping/migration.sql +22 -0
  391. package/prisma/migrations/20260906070000_datastore_pii_flag/migration.sql +6 -0
  392. package/prisma/migrations/20260906080000_secret_elicitation_outcomes/migration.sql +13 -0
  393. package/prisma/migrations/20260907010000_coding_proxy_ledger/migration.sql +57 -0
  394. package/prisma/migrations/20260907020000_budget_groups/migration.sql +24 -0
  395. package/prisma/migrations/20260907030000_coding_webhook_task_override/migration.sql +4 -0
  396. package/prisma/migrations/20260908010000_run_execution_backend/migration.sql +2 -0
  397. package/prisma/migrations/20260908020000_coding_run_audit_metadata/migration.sql +7 -0
  398. package/prisma/migrations/20260910010000_agent_secret_bound_name/migration.sql +16 -0
  399. package/prisma/migrations/20260911010000_coding_worker_toolchains/migration.sql +5 -0
  400. package/prisma/migrations/20260912010000_coding_provider_contract/migration.sql +24 -0
  401. package/prisma/migrations/20260912020000_coding_proxy_protocol/migration.sql +10 -0
  402. package/prisma/migrations/20260912030000_agent_memory/migration.sql +18 -0
  403. package/prisma/migrations/20260912040000_named_shared_datastores/migration.sql +68 -0
  404. package/prisma/migrations/20260913000000_run_trigger_webhook/migration.sql +2 -0
  405. package/prisma/migrations/20260913010000_agent_sub_agent/migration.sql +32 -0
  406. package/prisma/migrations/20260913020000_run_task_override/migration.sql +5 -0
  407. package/prisma/migrations/20260913030000_coding_run_root/migration.sql +11 -0
  408. package/prisma/migrations/20260922010000_coding_run_queued_at/migration.sql +8 -0
  409. package/prisma/migrations/20260922020000_coding_workspace_disk/migration.sql +8 -0
  410. package/prisma/migrations/migration_lock.toml +3 -0
  411. package/prisma/schema.prisma +723 -0
  412. package/scripts/git-askpass.sh +11 -0
@@ -0,0 +1,874 @@
1
+ /**
2
+ * KubernetesJobLauncher: runs one Codex coding job as one pod (a trusted
3
+ * `keeper` container that owns the storage volume, plus the untrusted
4
+ * `worker`), with its own NetworkPolicy and capability Secret.
5
+ *
6
+ * All job state lives in Kubernetes — a per-run record ConfigMap updated with
7
+ * optimistic concurrency (`resourceVersion`) — rather than in local files or
8
+ * in-process timers, so any control-plane replica can observe, collect, stop,
9
+ * or remove any run, and a restarted process loses nothing.
10
+ *
11
+ * Containers in a pod start together, so the worker cannot simply be started
12
+ * later the way Docker's is. Instead its command waits for a seeded marker
13
+ * the launcher writes through the keeper only after the pod and policy have
14
+ * been read back and attested against the canonical builders, the pod's
15
+ * NetworkPolicy is observed to be enforced (CNIs program a new pod's policy
16
+ * a few seconds after it starts), and the workspace and input have been
17
+ * seeded. Seeding is real wall-clock time bounded only by the run's own
18
+ * timeoutSec, so enforcement is re-probed one last time immediately before
19
+ * the marker write, with nothing else awaited in between — a NetworkPolicy
20
+ * landing after the first proof but before release must still be caught.
21
+ * Until that gate opens, nothing untrusted runs.
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import { copyFile, lstat, mkdtemp, rm } from "node:fs/promises";
25
+ import { tmpdir } from "node:os";
26
+ import { join, resolve, sep } from "node:path";
27
+ import { PassThrough, Writable } from "node:stream";
28
+ import { finished } from "node:stream/promises";
29
+ import { createHash } from "node:crypto";
30
+ import { isIP } from "node:net";
31
+ import { logger } from "../../core/logger.js";
32
+ import { MAX_CODING_ARTIFACT_BYTES, parseCodingAgentOutputJson } from "../../coding/protocol.js";
33
+ import { CODING_PROXY_DENY_PORT, CODING_PROXY_PORT } from "./docker-isolation.js";
34
+ import { SAFE_WORKER_DIAGNOSTIC } from "./docker.js";
35
+ import { KubernetesAlreadyExistsError, KubernetesConflictError } from "./kubernetes-api.js";
36
+ import { ENFORCEMENT_PROBE_DENY_REACHABLE, ENFORCEMENT_PROBE_PROVEN, ENFORCEMENT_PROBE_DENY_REFUSED, ENFORCEMENT_PROBE_PROXY_UNREACHABLE, KEEPER_CONTAINER, KEEPER_SEEDED_MARKER, KUBERNETES_ISOLATION_ERROR, STORAGE_ROOT, WORKER_CONTAINER, assertRunNetworkPolicyMatches, assertRunPodMatches, buildCapabilitySecret, buildRunNetworkPolicy, buildRunPod, enforcementProbeScript, kubernetesRunNames, kubernetesRunNamesForToken, runLabels, validateKubernetesSpec, } from "./kubernetes-isolation.js";
37
+ import { readProxyWitness } from "./kubernetes-witness.js";
38
+ import { safeExtract } from "./safe-extract.js";
39
+ import { replaceDirectoryFromStaging } from "./workspace-swap.js";
40
+ const BACKEND = "kubernetes";
41
+ const RECORD_SCHEMA_VERSION = 1;
42
+ const RECORD_KEY = "record.json";
43
+ const STOP_GRACE_SECONDS = 10;
44
+ const DIAGNOSTIC_TAIL_LINES = 8;
45
+ const DIAGNOSTIC_LIMIT_BYTES = 4096;
46
+ const MAX_WORKSPACE_ENTRIES = 100_000;
47
+ const MAX_RECORD_ATTEMPTS = 5;
48
+ const READY_POLL_MS = 250;
49
+ const DEFAULT_READY_TIMEOUT_MS = 120_000;
50
+ const DEFAULT_ENFORCEMENT_TIMEOUT_MS = 30_000;
51
+ const ENFORCEMENT_POLL_MS = 500;
52
+ /** Consecutive proven probes required (8787 reachable, 8788 blocked): one dropped SYN must not open the gate. */
53
+ const ENFORCEMENT_BLOCKED_STREAK = 3;
54
+ /** Bound for one enforcement probe exec (the probe makes two sequential connects, so it gives up after at most 6 s). */
55
+ const ENFORCEMENT_EXEC_TIMEOUT_MS = 10_000;
56
+ /**
57
+ * What the gate reports at its bound, by the last probe's exit code. Each names a different place
58
+ * to look, and each carries the reason in its own message rather than in the documentation: an
59
+ * operator reads the error, not this file.
60
+ *
61
+ * Anything not listed here is an exit code the probe never produces, so the probe did not run to
62
+ * completion (a crash, a missing interpreter, an OOM-killed keeper) and NOTHING was measured. That
63
+ * must not be reported as "the policy was not enforced": it says nothing whatever about the policy,
64
+ * and sending an operator to the CNI over a dead exec wastes the one clue they were given.
65
+ */
66
+ const ENFORCEMENT_VERDICTS = {
67
+ [ENFORCEMENT_PROBE_PROVEN]: {
68
+ code: "kubernetes_policy_not_enforced",
69
+ // Reaching the bound on a proven probe means the streak kept being broken: enforcement was
70
+ // observed, but never ENFORCEMENT_BLOCKED_STREAK times running, so it is not stable evidence.
71
+ detail: `the last probe was proven, but never ${ENFORCEMENT_BLOCKED_STREAK} consecutive times within the bound — enforcement is flapping rather than absent`,
72
+ },
73
+ [ENFORCEMENT_PROBE_DENY_REACHABLE]: {
74
+ code: "kubernetes_policy_not_enforced",
75
+ detail: `the deny port ${CODING_PROXY_DENY_PORT} accepted a connection, so no policy is blocking it (or the policy is not port-scoped) — look at the CNI`,
76
+ },
77
+ [ENFORCEMENT_PROBE_PROXY_UNREACHABLE]: {
78
+ code: "kubernetes_policy_witness_unavailable",
79
+ detail: `the proxy port ${CODING_PROXY_PORT} could not be reached, so nothing could be witnessed — look at the proxy pod and its Service, not the CNI`,
80
+ },
81
+ [ENFORCEMENT_PROBE_DENY_REFUSED]: {
82
+ code: "kubernetes_policy_witness_unserved",
83
+ detail: `the deny port ${CODING_PROXY_DENY_PORT} answered with a refusal (RST) instead of being dropped. Two causes, ` +
84
+ "either of which voids the witness: nothing is serving the deny port, or this cluster's CNI rejects " +
85
+ "instead of dropping, in which case a genuine denial is indistinguishable from an unserved port and " +
86
+ "this cluster needs a different witness",
87
+ },
88
+ };
89
+ const ENFORCEMENT_PROBE_DID_NOT_RUN = {
90
+ code: "kubernetes_policy_probe_unusable",
91
+ detail: "the probe exited with a code it never produces, so it did not run to completion (a crash, a missing " +
92
+ "interpreter, or an OOM-killed keeper). Nothing was measured, so this says nothing about the policy",
93
+ };
94
+ /** Same shape as ENFORCEMENT_VERDICTS/ENFORCEMENT_PROBE_DID_NOT_RUN, but for the `pre_marker` stage. */
95
+ const ENFORCEMENT_VERDICTS_PRE_MARKER = {
96
+ [ENFORCEMENT_PROBE_PROVEN]: {
97
+ code: "kubernetes_policy_enforcement_lost_before_marker",
98
+ detail: `the policy was proven enforced earlier in this launch, but the re-probe run immediately before ` +
99
+ `opening the gate never reached ${ENFORCEMENT_BLOCKED_STREAK} consecutive proven probes within the ` +
100
+ "bound — enforcement held at the initial proof but is flapping now, immediately before release",
101
+ },
102
+ [ENFORCEMENT_PROBE_DENY_REACHABLE]: {
103
+ code: "kubernetes_policy_enforcement_lost_before_marker",
104
+ detail: `the deny port ${CODING_PROXY_DENY_PORT} was proven blocked earlier in this launch, but the re-probe ` +
105
+ "run immediately before opening the gate found it reachable — a NetworkPolicy change during seeding " +
106
+ "(GitOps drift, a stale re-apply, anything else with NetworkPolicy write access in this namespace) " +
107
+ "silently reopened this run's egress; look at what changed policies in this namespace, not just the CNI",
108
+ },
109
+ [ENFORCEMENT_PROBE_PROXY_UNREACHABLE]: {
110
+ code: "kubernetes_policy_witness_unavailable_before_marker",
111
+ detail: `the proxy port ${CODING_PROXY_PORT} could not be reached on the re-probe run immediately before ` +
112
+ "opening the gate, so enforcement could not be re-confirmed — look at the proxy pod and its Service",
113
+ },
114
+ [ENFORCEMENT_PROBE_DENY_REFUSED]: {
115
+ code: "kubernetes_policy_witness_unserved_before_marker",
116
+ detail: `the deny port ${CODING_PROXY_DENY_PORT} answered with a refusal (RST) instead of being dropped on ` +
117
+ "the re-probe run immediately before opening the gate — the witness itself became unserved during seeding",
118
+ },
119
+ };
120
+ const ENFORCEMENT_PROBE_DID_NOT_RUN_PRE_MARKER = {
121
+ code: "kubernetes_policy_probe_unusable_before_marker",
122
+ detail: "the re-probe run immediately before opening the gate exited with a code it never produces, so it did " +
123
+ "not run to completion. Nothing was measured, so this says nothing about whether the policy still holds",
124
+ };
125
+ /** The gate's verdict, carrying the exit code, the address it probed, and which stage produced it. */
126
+ function enforcementVerdict(exitCode, proxyIp, stage) {
127
+ const table = stage === "initial" ? ENFORCEMENT_VERDICTS : ENFORCEMENT_VERDICTS_PRE_MARKER;
128
+ const fallback = stage === "initial" ? ENFORCEMENT_PROBE_DID_NOT_RUN : ENFORCEMENT_PROBE_DID_NOT_RUN_PRE_MARKER;
129
+ const { code, detail } = table[exitCode] ?? fallback;
130
+ return new Error(`${code}: probing ${proxyIp}, the last probe exited ${exitCode}; ${detail}`, {
131
+ cause: { exitCode, address: proxyIp, stage },
132
+ });
133
+ }
134
+ /** Bound for small keeper commands (seeded marker, result artifact read). */
135
+ const SHORT_EXEC_TIMEOUT_MS = 60_000;
136
+ const FATAL_WAITING_REASONS = new Set([
137
+ "ErrImagePull",
138
+ "ImagePullBackOff",
139
+ "InvalidImageName",
140
+ "CreateContainerConfigError",
141
+ "CreateContainerError",
142
+ "CrashLoopBackOff",
143
+ ]);
144
+ const CAPABILITY = /^rrp_[A-Za-z0-9_-]{16,512}$/;
145
+ const TOKEN = /^[a-f0-9]{20}$/;
146
+ const WORKSPACE_STORAGE = `${STORAGE_ROOT}/workspace`;
147
+ const INPUT_STORAGE = `${STORAGE_ROOT}/input`;
148
+ const RESULT_PATH = `${STORAGE_ROOT}/output/result.json`;
149
+ const kubernetesLog = logger.child({ module: "kubernetes-jobs" });
150
+ const PHASES = new Set(["provisioning", "active", "succeeded", "failed", "stopped", "lost", "removed"]);
151
+ const TERMINAL_PHASES = new Set(["succeeded", "failed", "stopped", "lost", "removed"]);
152
+ // ---------------------------------------------------------------------------
153
+ // Pure helpers: the lifecycle rules.
154
+ function stableSpecHash(spec) {
155
+ return createHash("sha256")
156
+ .update(JSON.stringify({
157
+ ...spec,
158
+ labels: Object.fromEntries(Object.entries(spec.labels).sort(([left], [right]) => left.localeCompare(right))),
159
+ }))
160
+ .digest("hex");
161
+ }
162
+ function isTerminal(phase) {
163
+ return TERMINAL_PHASES.has(phase);
164
+ }
165
+ function statusFor(record) {
166
+ switch (record.phase) {
167
+ case "provisioning":
168
+ return { state: "pending" };
169
+ case "active":
170
+ return { state: "running" };
171
+ case "succeeded":
172
+ return { state: "succeeded" };
173
+ case "failed":
174
+ return { state: "failed", ...(record.result?.reason === "timed_out" ? { reason: "timed_out" } : {}) };
175
+ case "stopped":
176
+ return { state: "stopped" };
177
+ case "lost":
178
+ return { state: "lost" };
179
+ case "removed":
180
+ throw new Error("job_removed");
181
+ }
182
+ }
183
+ function resultFor(phase) {
184
+ switch (phase) {
185
+ case "succeeded":
186
+ return { exitCode: 0, reason: "completed" };
187
+ case "failed":
188
+ return { exitCode: 1, reason: "failed" };
189
+ case "stopped":
190
+ return { exitCode: 143, reason: "stopped" };
191
+ case "lost":
192
+ return { exitCode: 1, reason: "lost" };
193
+ }
194
+ }
195
+ function parseRecord(configMap) {
196
+ let decoded;
197
+ try {
198
+ decoded = JSON.parse(configMap.data?.[RECORD_KEY] ?? "");
199
+ }
200
+ catch {
201
+ throw new Error("kubernetes_record_invalid");
202
+ }
203
+ if (decoded === null ||
204
+ typeof decoded !== "object" ||
205
+ decoded.schemaVersion !== RECORD_SCHEMA_VERSION ||
206
+ typeof decoded.runId !== "string" ||
207
+ typeof decoded.specHash !== "string" ||
208
+ !Number.isSafeInteger(decoded.diskMb) ||
209
+ !Number.isFinite(decoded.createdAt) ||
210
+ !Number.isFinite(decoded.deadlineAt) ||
211
+ !PHASES.has(decoded.phase)) {
212
+ throw new Error("kubernetes_record_invalid");
213
+ }
214
+ return decoded;
215
+ }
216
+ /** Slack for clock skew between the kubelet's finishedAt and the control plane's deadline. */
217
+ const FINISHED_AT_SKEW_MS = 5_000;
218
+ /** Whether a worker's exit 0 happened on a live pod, not past the deadline, with a parseable finish time. */
219
+ function exitedCleanlyInTime(pod, finishedAt, deadlineAt) {
220
+ if (pod.status?.reason === "DeadlineExceeded" || pod.metadata?.deletionTimestamp)
221
+ return false;
222
+ if (finishedAt === undefined || finishedAt === null)
223
+ return false;
224
+ const finished = new Date(finishedAt).getTime();
225
+ return Number.isFinite(finished) && finished <= deadlineAt + FINISHED_AT_SKEW_MS;
226
+ }
227
+ /** Maps a pod read-back onto the next record state; `undefined` means "no change". Never called for terminal records. */
228
+ function observePod(pod, record, now, readyTimeoutMs) {
229
+ if (!pod) {
230
+ if (record.phase === "provisioning" && now - record.createdAt < readyTimeoutMs)
231
+ return undefined;
232
+ return { phase: "lost", result: resultFor("lost") };
233
+ }
234
+ const worker = pod.status?.containerStatuses?.find((status) => status.name === WORKER_CONTAINER);
235
+ const terminated = worker?.state?.terminated;
236
+ // Exit 0 counts as success only if the worker finished on its own, before the deadline, on a live pod:
237
+ // a SIGTERM from the kubelet's deadline or a pod deletion can be trapped by the untrusted worker and
238
+ // turned into exit 0, and by then the keeper holding the result is gone too.
239
+ if (terminated?.exitCode === 0 && exitedCleanlyInTime(pod, terminated.finishedAt, record.deadlineAt)) {
240
+ return { phase: "succeeded", result: resultFor("succeeded") };
241
+ }
242
+ if (now >= record.deadlineAt || pod.status?.reason === "DeadlineExceeded") {
243
+ return { phase: "failed", result: { exitCode: 124, reason: "timed_out" }, deleteGraceSeconds: STOP_GRACE_SECONDS };
244
+ }
245
+ if (terminated) {
246
+ // An exit 0 that failed the guard above (pod being deleted, no usable finish time) is not a success.
247
+ if (terminated.exitCode === 0)
248
+ return { phase: "failed", result: resultFor("failed") };
249
+ return {
250
+ phase: "failed",
251
+ result: {
252
+ exitCode: terminated.reason === "OOMKilled" ? 137 : Math.max(1, terminated.exitCode ?? 1),
253
+ reason: "failed",
254
+ },
255
+ };
256
+ }
257
+ if (pod.status?.phase === "Failed")
258
+ return { phase: "failed", result: resultFor("failed") };
259
+ // Nothing to record: a provisioning record is promoted only by the launch that owns it (after the
260
+ // gate opens), and refresh() never calls this for a terminal one, so the phase is already right.
261
+ return undefined;
262
+ }
263
+ /** Collects at most `limit` bytes; anything more only sets `exceeded`. */
264
+ class BoundedCollector extends Writable {
265
+ limit;
266
+ chunks = [];
267
+ bytes = 0;
268
+ exceeded = false;
269
+ constructor(limit) {
270
+ super();
271
+ this.limit = limit;
272
+ }
273
+ _write(chunk, _encoding, callback) {
274
+ this.bytes += chunk.length;
275
+ if (this.bytes > this.limit)
276
+ this.exceeded = true;
277
+ else
278
+ this.chunks.push(chunk);
279
+ callback();
280
+ }
281
+ text() {
282
+ return Buffer.concat(this.chunks).toString("utf8");
283
+ }
284
+ }
285
+ /** Archives a trusted local directory with the host's `tar`, under a minimal environment. */
286
+ export function hostTarArchive(directory) {
287
+ const child = spawn("tar", ["-C", directory, "-cf", "-", "."], {
288
+ shell: false,
289
+ stdio: ["ignore", "pipe", "ignore"],
290
+ // COPYFILE_DISABLE stops macOS tar from adding `._*` metadata entries.
291
+ env: { PATH: "/usr/local/bin:/usr/bin:/bin", COPYFILE_DISABLE: "1" },
292
+ });
293
+ // Take ownership of stdout immediately. A small tree archives in milliseconds, and when the child
294
+ // exits Node resume()s (discards) any stdout pipe nobody has consumed yet — which would silently
295
+ // drop the whole archive before the exec WebSocket connects. Piping into a PassThrough buffers it
296
+ // (with backpressure on tar) until the exec attaches.
297
+ const stream = new PassThrough();
298
+ child.stdout.pipe(stream);
299
+ child.stdout.once("error", (error) => stream.destroy(error));
300
+ // Without a listener, an unhandled "error" event on a stream surfaces asynchronously as an
301
+ // uncaughtException and crashes the process (not a synchronous throw) — and `destroy(error)`
302
+ // above emits exactly that on `stream`. The failure is still observable through `done` (the
303
+ // child's close/error handlers below) and through the exec/extract that reads `stream`.
304
+ stream.on("error", () => undefined);
305
+ const done = new Promise((resolvePromise) => {
306
+ child.once("error", () => resolvePromise(-1));
307
+ child.once("close", (code) => resolvePromise(code ?? -1));
308
+ });
309
+ stream.once("close", () => {
310
+ // A seed that fails destroys this stream; don't leave tar blocked on a full pipe.
311
+ if (!stream.readableEnded && child.exitCode === null && child.signalCode === null)
312
+ child.kill("SIGKILL");
313
+ });
314
+ return { stream, done };
315
+ }
316
+ function errorWithCode(code, cause) {
317
+ return cause === undefined ? new Error(code) : new Error(code, { cause });
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ export class KubernetesJobLauncher {
321
+ options;
322
+ api;
323
+ namespace;
324
+ workspaceRoot;
325
+ now;
326
+ sleep;
327
+ readyTimeoutMs;
328
+ createArchive;
329
+ warn;
330
+ enforcementTimeoutMs;
331
+ preflightResult;
332
+ constructor(options) {
333
+ this.options = options;
334
+ this.api = options.api;
335
+ this.namespace = options.config.namespace;
336
+ this.workspaceRoot = resolve(options.workspaceRoot);
337
+ if (this.workspaceRoot === resolve("/"))
338
+ throw new Error("kubernetes_launcher_path_invalid");
339
+ this.now = options.now ?? Date.now;
340
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)));
341
+ this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
342
+ this.enforcementTimeoutMs = options.enforcementTimeoutMs ?? DEFAULT_ENFORCEMENT_TIMEOUT_MS;
343
+ this.createArchive = options.createArchive ?? hostTarArchive;
344
+ this.warn = options.onWarning ?? ((message) => kubernetesLog.warn(message));
345
+ }
346
+ /**
347
+ * Every per-run object name derives from the run id, so the handle is known before any cluster call.
348
+ * The executor persists it first; a crash mid-launch then still has a handle to clean up with (the
349
+ * launcher itself has no `list` permission and so can never rediscover stray objects).
350
+ */
351
+ plannedHandle(spec) {
352
+ try {
353
+ validateKubernetesSpec(spec);
354
+ return { backend: BACKEND, id: `${this.namespace}/${kubernetesRunNames(spec.runId).token}` };
355
+ }
356
+ catch {
357
+ // An invalid spec has no handle; launch() rejects with the real reason.
358
+ return undefined;
359
+ }
360
+ }
361
+ async launch(spec) {
362
+ validateKubernetesSpec(spec);
363
+ await this.runPreflight();
364
+ const names = kubernetesRunNames(spec.runId);
365
+ const handle = { backend: BACKEND, id: `${this.namespace}/${names.token}` };
366
+ const specHash = stableSpecHash(spec);
367
+ const existing = await this.readRecord(names);
368
+ if (existing)
369
+ return this.existingLaunch(existing.record, specHash, handle);
370
+ const createdAt = this.now();
371
+ const record = {
372
+ schemaVersion: RECORD_SCHEMA_VERSION,
373
+ runId: spec.runId,
374
+ specHash,
375
+ diskMb: spec.limits.diskMb,
376
+ createdAt,
377
+ deadlineAt: createdAt + spec.timeoutSec * 1000,
378
+ phase: "provisioning",
379
+ };
380
+ try {
381
+ // The record is the idempotency fence before any other cluster side effect.
382
+ await this.api.createConfigMap(this.namespace, this.recordConfigMap(names, record));
383
+ }
384
+ catch (error) {
385
+ if (!(error instanceof KubernetesAlreadyExistsError))
386
+ throw error;
387
+ const raced = await this.readRecord(names);
388
+ if (!raced)
389
+ throw error;
390
+ return this.existingLaunch(raced.record, specHash, handle);
391
+ }
392
+ try {
393
+ await this.provision(spec, names, record);
394
+ }
395
+ catch (error) {
396
+ // Record the failure before deleting the pod, so a replica that observes "no pod" in between
397
+ // can't record `lost` first and have this more specific outcome dropped.
398
+ await this.updateRecord(names, () => ({
399
+ phase: "failed",
400
+ result: { exitCode: 1, reason: "failed", diagnostic: "kubernetes_provisioning_failed" },
401
+ })).catch(() => undefined);
402
+ await this.cleanupRun(names, 0);
403
+ throw error;
404
+ }
405
+ return structuredClone(handle);
406
+ }
407
+ async status(handle) {
408
+ const names = this.namesFor(handle);
409
+ const found = names && (await this.readRecord(names));
410
+ if (!names || !found)
411
+ throw new Error("job_not_found");
412
+ if (found.record.phase === "removed")
413
+ throw new Error("job_removed");
414
+ return structuredClone(statusFor(await this.refresh(names, found.record)));
415
+ }
416
+ async collect(handle) {
417
+ const names = this.namesFor(handle);
418
+ const found = names && (await this.readRecord(names));
419
+ if (!names || !found)
420
+ throw new Error("job_not_found");
421
+ if (found.record.phase === "removed")
422
+ throw new Error("job_removed");
423
+ const record = await this.refresh(names, found.record);
424
+ if (!isTerminal(record.phase))
425
+ throw new Error("job_not_terminal");
426
+ const phase = record.phase;
427
+ let result = record.result ?? resultFor(phase);
428
+ if (phase === "failed" && !result.diagnostic) {
429
+ const diagnostic = await this.readWorkerDiagnostic(names);
430
+ if (diagnostic)
431
+ result = { ...result, diagnostic };
432
+ }
433
+ if (phase === "succeeded" && !result.resultArtifact) {
434
+ result = { ...result, resultArtifact: await this.readResultArtifact(names, record.runId) };
435
+ }
436
+ if (JSON.stringify(result) !== JSON.stringify(record.result)) {
437
+ const stored = result;
438
+ const updated = await this.updateRecord(names, (current) => current.phase === phase ? { phase, result: stored } : undefined);
439
+ if (updated.phase === "removed")
440
+ throw new Error("job_removed");
441
+ }
442
+ return structuredClone(result);
443
+ }
444
+ async materializeWorkspace(handle, destination) {
445
+ const names = this.namesFor(handle);
446
+ const found = names && (await this.readRecord(names));
447
+ if (!names || !found)
448
+ throw new Error("job_not_found");
449
+ const record = await this.refresh(names, found.record);
450
+ if (record.phase !== "succeeded")
451
+ throw new Error("job_not_succeeded");
452
+ const expected = resolve(this.workspaceRoot, record.runId, "workspace");
453
+ if (resolve(destination) !== expected || !expected.startsWith(`${this.workspaceRoot}${sep}`)) {
454
+ throw new Error("kubernetes_workspace_destination_invalid");
455
+ }
456
+ const maxBytes = record.diskMb * 1024 * 1024;
457
+ await replaceDirectoryFromStaging(expected, maxBytes, "kubernetes_workspace_destination_invalid", (staging) => this.extractWorkspace(names, staging, maxBytes, this.transferBudgetMs(record)));
458
+ }
459
+ async stop(handle, _reason) {
460
+ const names = this.namesFor(handle);
461
+ const found = names && (await this.readRecord(names));
462
+ if (!names || !found || isTerminal(found.record.phase))
463
+ return;
464
+ await this.api.deletePod(this.namespace, names.pod, STOP_GRACE_SECONDS);
465
+ await this.updateRecord(names, () => ({ phase: "stopped", result: resultFor("stopped") }));
466
+ }
467
+ async remove(handle) {
468
+ const names = this.namesFor(handle);
469
+ const found = names && (await this.readRecord(names));
470
+ if (!names || !found || found.record.phase === "removed")
471
+ return;
472
+ const record = await this.refresh(names, found.record);
473
+ if (!isTerminal(record.phase))
474
+ throw new Error("job_not_terminal");
475
+ await this.api.deletePod(this.namespace, names.pod, 0);
476
+ await this.api.deleteNetworkPolicy(this.namespace, names.policy);
477
+ await this.api.deleteSecret(this.namespace, names.secret);
478
+ // The record stays as a tombstone so the run is never relaunched.
479
+ await this.updateRecord(names, () => ({ phase: "removed" }));
480
+ }
481
+ // -------------------------------------------------------------------------
482
+ // Launch
483
+ /** Memoized: the preflight (or, without one, a single proxy-witness read) runs once per launcher. */
484
+ runPreflight() {
485
+ this.preflightResult ??= (async () => {
486
+ try {
487
+ const provided = (await this.options.preflight?.())?.proxyIp;
488
+ if (provided !== undefined) {
489
+ if (isIP(provided) === 0)
490
+ throw new Error("kubernetes_proxy_unavailable");
491
+ return { proxyIp: provided };
492
+ }
493
+ const witness = await readProxyWitness(this.api, this.namespace, this.options.config.proxyService);
494
+ return { proxyIp: witness.clusterIp };
495
+ }
496
+ catch (error) {
497
+ throw errorWithCode(KUBERNETES_ISOLATION_ERROR, error);
498
+ }
499
+ })();
500
+ return this.preflightResult;
501
+ }
502
+ /**
503
+ * Waits until the run's NetworkPolicy is enforced on this pod: the keeper (same network namespace
504
+ * as the worker) must, on ENFORCEMENT_BLOCKED_STREAK consecutive probes 500 ms apart, reach the
505
+ * proxy on CODING_PROXY_PORT and fail to reach it on CODING_PROXY_DENY_PORT. Anything else resets
506
+ * the streak; the wall-clock bound still applies.
507
+ *
508
+ * "Fail to reach" means the connect *timed out* — the packet was dropped. A refusal (RST) is not
509
+ * a denial: it proves the SYN reached the destination host, so the deny port is merely unserved
510
+ * and witnesses nothing. That is its own verdict, below.
511
+ *
512
+ * The verdict at the bound is taken from the *last* probe, not from whether any probe was ever
513
+ * unavailable: an early blip while the pod's networking came up must not send an operator looking
514
+ * at the proxy when the real problem is an unenforced policy. The three non-proven outcomes stay
515
+ * distinct because they send an operator to three different places: the CNI, the proxy pod, and
516
+ * the deny listener.
517
+ *
518
+ * `stage` only changes which error codes a bound produces (see `EnforcementStage`); the probe
519
+ * machinery itself — the script, the streak, the poll interval, the wall-clock bound — is identical
520
+ * for the initial proof and for `provision`'s later re-confirmation immediately before the marker
521
+ * write. That re-confirmation deliberately requires the same full `ENFORCEMENT_BLOCKED_STREAK`
522
+ * consecutive proven probes, not a single one-shot probe: seeding (the window it's guarding) can run
523
+ * for real hours, so the few hundred extra milliseconds a streak costs in the common, still-enforced
524
+ * case is negligible, while a single probe could pass on a lucky sample the same way one dropped SYN
525
+ * must not open the gate at the initial proof either.
526
+ */
527
+ async waitForPolicyEnforcement(names, proxyIp, stage = "initial") {
528
+ const command = ["node", "-e", enforcementProbeScript(proxyIp)];
529
+ const started = this.now();
530
+ let blocked = 0;
531
+ for (;;) {
532
+ // `exitCode` is this iteration's probe, and the bound below is only ever reached from here —
533
+ // so the verdict is always the *last* probe's, never a remembered earlier one.
534
+ const exitCode = await this.api.exec(this.namespace, names.pod, KEEPER_CONTAINER, command, {
535
+ timeoutMs: ENFORCEMENT_EXEC_TIMEOUT_MS,
536
+ });
537
+ blocked = exitCode === 0 ? blocked + 1 : 0;
538
+ if (blocked >= ENFORCEMENT_BLOCKED_STREAK)
539
+ return;
540
+ if (this.now() - started >= this.enforcementTimeoutMs) {
541
+ throw enforcementVerdict(exitCode, proxyIp, stage);
542
+ }
543
+ await this.sleep(ENFORCEMENT_POLL_MS);
544
+ }
545
+ }
546
+ existingLaunch(record, specHash, handle) {
547
+ if (record.specHash !== specHash)
548
+ throw new Error("job_spec_conflict");
549
+ // A still-`provisioning` record is not re-provisioned: another replica may legitimately be mid-launch.
550
+ // If that launch crashed instead, the run is left to the deadline — its pod never opens the gate and
551
+ // is bounded by timeoutSec + POD_DEADLINE_GRACE_SECONDS — and observation then records it timed out or lost.
552
+ return structuredClone(handle);
553
+ }
554
+ async provision(spec, names, record) {
555
+ const { runtimeClassName, proxyService, platform } = this.options.config;
556
+ if (!runtimeClassName) {
557
+ this.warn("kubernetes_runtime_class_unset: no runtime class configured; coding pods run without gVisor (development clusters only)");
558
+ }
559
+ const capability = await this.options.resolveCapability(spec.runId);
560
+ if (!CAPABILITY.test(capability))
561
+ throw new Error("kubernetes_capability_invalid");
562
+ // Re-read (not just the ClusterIP): the gate below reads "deny port unreachable" as evidence,
563
+ // which is only meaningful against a Service that actually exposes it with a ready backend.
564
+ const witness = await readProxyWitness(this.api, this.namespace, proxyService);
565
+ const proxyIp = witness.clusterIp;
566
+ const pod = buildRunPod(spec, { namespace: this.namespace, proxyIp, runtimeClassName, platform });
567
+ const policy = buildRunNetworkPolicy(spec, this.namespace);
568
+ await this.createIfMissing(() => this.api.createSecret(this.namespace, buildCapabilitySecret(spec, this.namespace, capability)));
569
+ await this.createIfMissing(() => this.api.createNetworkPolicy(this.namespace, policy));
570
+ await this.createIfMissing(() => this.api.createPod(this.namespace, pod));
571
+ await this.waitForKeeper(names);
572
+ const [actualPod, actualPolicy] = await Promise.all([
573
+ this.api.readPod(this.namespace, names.pod),
574
+ this.api.readNetworkPolicy(this.namespace, names.policy),
575
+ ]);
576
+ if (!actualPod || !actualPolicy)
577
+ throw new Error(KUBERNETES_ISOLATION_ERROR);
578
+ assertRunPodMatches(actualPod, pod, platform);
579
+ assertRunNetworkPolicyMatches(actualPolicy, policy);
580
+ await this.waitForPolicyEnforcement(names, proxyIp);
581
+ const budget = this.transferBudgetMs(record);
582
+ await this.seedDirectory(names, this.runWorkspace(spec.runId), WORKSPACE_STORAGE, budget);
583
+ await this.seedInput(names, spec.inputArtifact, budget);
584
+ // Never open the gate for a run that was stopped (or otherwise finished) while it was being seeded.
585
+ const current = await this.readRecord(names);
586
+ if (!current || isTerminal(current.record.phase))
587
+ throw new Error("kubernetes_launch_superseded");
588
+ // Re-confirm enforcement one last time, with nothing else awaited between this and the marker
589
+ // write below: seeding above ran for real wall-clock time (bounded only by the run's own
590
+ // timeoutSec), and nothing since the initial proof re-checked anything. A NetworkPolicy landing
591
+ // in that window — GitOps drift, a stale re-apply, an unrelated future feature, anything else
592
+ // with NetworkPolicy write access in this namespace — would otherwise silently reopen this pod's
593
+ // egress at the exact moment the untrusted worker is released into it, and this launch would have
594
+ // no way to notice. Re-attesting the run's own NetworkPolicy object cannot catch this (policies
595
+ // are additive: a second policy under a different name is invisible to a comparison against the
596
+ // object this launch built); only re-probing the pod's actual, observed egress can.
597
+ await this.waitForPolicyEnforcement(names, proxyIp, "pre_marker");
598
+ const marker = await this.api.exec(this.namespace, names.pod, KEEPER_CONTAINER, ["node", "-e", `require("node:fs").writeFileSync(${JSON.stringify(KEEPER_SEEDED_MARKER)}, "")`], { timeoutMs: SHORT_EXEC_TIMEOUT_MS });
599
+ if (marker !== 0)
600
+ throw new Error("kubernetes_seed_failed");
601
+ const updated = await this.updateRecord(names, () => ({ phase: "active" }));
602
+ // Another replica may have stopped the run mid-launch; don't leave its pod running.
603
+ if (updated.phase !== "active")
604
+ await this.api.deletePod(this.namespace, names.pod, 0).catch(() => undefined);
605
+ }
606
+ async createIfMissing(create) {
607
+ try {
608
+ await create();
609
+ }
610
+ catch (error) {
611
+ if (!(error instanceof KubernetesAlreadyExistsError))
612
+ throw error;
613
+ }
614
+ }
615
+ async waitForKeeper(names) {
616
+ const started = this.now();
617
+ for (;;) {
618
+ const pod = await this.api.readPod(this.namespace, names.pod);
619
+ if (!pod || pod.status?.phase === "Failed")
620
+ throw new Error("kubernetes_pod_start_failed");
621
+ const statuses = pod.status?.containerStatuses ?? [];
622
+ const initStatuses = pod.status?.initContainerStatuses ?? [];
623
+ const keeper = statuses.find((status) => status.name === KEEPER_CONTAINER);
624
+ if ([...initStatuses, ...statuses].some((status) => FATAL_WAITING_REASONS.has(status.state?.waiting?.reason ?? "")) ||
625
+ // A failed init container (kubectl's Init:Error) or a keeper that already exited can never become ready.
626
+ initStatuses.some((status) => (status.state?.terminated?.exitCode ?? 0) !== 0) ||
627
+ keeper?.state?.terminated !== undefined) {
628
+ throw new Error("kubernetes_pod_start_failed");
629
+ }
630
+ if (keeper?.ready === true)
631
+ return;
632
+ if (this.now() - started >= this.readyTimeoutMs)
633
+ throw new Error("kubernetes_pod_start_timeout");
634
+ await this.sleep(READY_POLL_MS);
635
+ }
636
+ }
637
+ runWorkspace(runId) {
638
+ const workspace = resolve(this.workspaceRoot, runId, "workspace");
639
+ if (!workspace.startsWith(`${this.workspaceRoot}${sep}`))
640
+ throw new Error("kubernetes_workspace_path_invalid");
641
+ return workspace;
642
+ }
643
+ /** Streams a trusted local directory into `destination` inside the keeper. */
644
+ async seedDirectory(names, directory, destination, timeoutMs) {
645
+ const metadata = await lstat(directory);
646
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
647
+ throw new Error("kubernetes_seed_failed");
648
+ const archive = this.createArchive(directory);
649
+ const archived = archive.done;
650
+ archived.catch(() => undefined);
651
+ try {
652
+ const extracted = await this.api.exec(this.namespace, names.pod, KEEPER_CONTAINER, ["tar", "-C", destination, "--no-same-owner", "--no-same-permissions", "-xf", "-"], { stdin: archive.stream, timeoutMs });
653
+ // On a failed extract, the archive is destroyed (in the catch) before anything waits on it,
654
+ // so an archive nobody drains can't hang the launch.
655
+ if (extracted !== 0)
656
+ throw new Error("kubernetes_seed_failed");
657
+ if ((await archived) !== 0)
658
+ throw new Error("kubernetes_seed_failed");
659
+ }
660
+ catch (error) {
661
+ archive.stream.destroy();
662
+ if (error instanceof Error && error.message === "kubernetes_seed_failed")
663
+ throw error;
664
+ throw errorWithCode("kubernetes_seed_failed", error);
665
+ }
666
+ }
667
+ async seedInput(names, inputArtifact, timeoutMs) {
668
+ const staging = await mkdtemp(join(tmpdir(), "wardby-k8s-input-"));
669
+ try {
670
+ const source = await lstat(inputArtifact);
671
+ if (!source.isFile() || source.size > MAX_CODING_ARTIFACT_BYTES) {
672
+ throw new Error("kubernetes_input_artifact_invalid");
673
+ }
674
+ const copy = join(staging, "input.json");
675
+ await copyFile(inputArtifact, copy);
676
+ // Re-check the copy: the source could have changed between lstat and copy.
677
+ const copied = await lstat(copy);
678
+ if (!copied.isFile() || copied.size > MAX_CODING_ARTIFACT_BYTES) {
679
+ throw new Error("kubernetes_input_artifact_invalid");
680
+ }
681
+ await this.seedDirectory(names, staging, INPUT_STORAGE, timeoutMs);
682
+ }
683
+ finally {
684
+ await rm(staging, { recursive: true, force: true });
685
+ }
686
+ }
687
+ async cleanupRun(names, podGraceSeconds) {
688
+ await this.api.deletePod(this.namespace, names.pod, podGraceSeconds).catch(() => undefined);
689
+ await this.api.deleteNetworkPolicy(this.namespace, names.policy).catch(() => undefined);
690
+ await this.api.deleteSecret(this.namespace, names.secret).catch(() => undefined);
691
+ }
692
+ // -------------------------------------------------------------------------
693
+ // Observation and collection
694
+ /** Observes a non-terminal run against its pod and persists any transition; returns the stored record. */
695
+ async refresh(names, record) {
696
+ if (isTerminal(record.phase))
697
+ return record;
698
+ const pod = await this.api.readPod(this.namespace, names.pod);
699
+ const observed = observePod(pod, record, this.now(), this.readyTimeoutMs);
700
+ if (!observed)
701
+ return record;
702
+ if (observed.deleteGraceSeconds !== undefined) {
703
+ await this.api.deletePod(this.namespace, names.pod, observed.deleteGraceSeconds);
704
+ }
705
+ return this.updateRecord(names, () => ({ phase: observed.phase, result: observed.result }));
706
+ }
707
+ /** Reads only a fixed diagnostic code from a failed worker's bounded log tail; never keeps anything else. */
708
+ async readWorkerDiagnostic(names) {
709
+ let raw;
710
+ try {
711
+ raw = await this.api.readLogTail(this.namespace, names.pod, WORKER_CONTAINER, DIAGNOSTIC_TAIL_LINES, DIAGNOSTIC_LIMIT_BYTES);
712
+ }
713
+ catch {
714
+ // Diagnostics are optional (the pod may already be gone after a timeout).
715
+ return undefined;
716
+ }
717
+ for (const line of raw.split("\n").reverse()) {
718
+ try {
719
+ const value = JSON.parse(line);
720
+ if (typeof value?.error === "string" && SAFE_WORKER_DIAGNOSTIC.test(value.error))
721
+ return value.error;
722
+ }
723
+ catch {
724
+ // Worker output is untrusted; only one fixed JSON shape is recognized.
725
+ }
726
+ }
727
+ return undefined;
728
+ }
729
+ async readResultArtifact(names, runId) {
730
+ const collector = new BoundedCollector(MAX_CODING_ARTIFACT_BYTES);
731
+ let raw;
732
+ try {
733
+ const exitCode = await this.api.exec(this.namespace, names.pod, KEEPER_CONTAINER, ["head", "-c", String(MAX_CODING_ARTIFACT_BYTES + 1), RESULT_PATH], { stdout: collector, timeoutMs: SHORT_EXEC_TIMEOUT_MS });
734
+ if (!collector.writableEnded)
735
+ collector.end();
736
+ await finished(collector);
737
+ if (exitCode !== 0 || collector.exceeded)
738
+ throw new Error("kubernetes_result_artifact_invalid");
739
+ raw = collector.text();
740
+ }
741
+ catch (error) {
742
+ collector.destroy();
743
+ if (error instanceof Error && error.message === "kubernetes_result_artifact_invalid")
744
+ throw error;
745
+ throw errorWithCode("kubernetes_result_artifact_invalid", error);
746
+ }
747
+ let output;
748
+ try {
749
+ output = parseCodingAgentOutputJson(raw);
750
+ }
751
+ catch (error) {
752
+ throw errorWithCode("kubernetes_result_artifact_invalid", error);
753
+ }
754
+ if (output.runId !== runId)
755
+ throw new Error("kubernetes_result_run_mismatch");
756
+ return JSON.stringify(output);
757
+ }
758
+ /**
759
+ * Streams the keeper's workspace through the strict extractor into `staging`.
760
+ * The transfer is bounded by `timeoutMs` (see transferBudgetMs): on expiry the
761
+ * extraction is aborted. On any failure the output stream this method created
762
+ * is destroyed, since the exec's own timeout does not end it.
763
+ */
764
+ async extractWorkspace(names, staging, maxBytes, timeoutMs) {
765
+ const output = new PassThrough();
766
+ // Errors are surfaced through the exec/extraction promises; never let a late destroy() crash the process.
767
+ output.on("error", () => undefined);
768
+ const controller = new AbortController();
769
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
770
+ timer.unref();
771
+ const extraction = safeExtract(output, staging, { maxBytes, maxEntries: MAX_WORKSPACE_ENTRIES }, {
772
+ signal: controller.signal,
773
+ });
774
+ const execution = this.api
775
+ .exec(this.namespace, names.pod, KEEPER_CONTAINER, ["tar", "-C", WORKSPACE_STORAGE, "-cf", "-", "."], {
776
+ stdout: output,
777
+ timeoutMs,
778
+ })
779
+ .then((exitCode) => {
780
+ if (exitCode !== 0)
781
+ throw new Error("kubernetes_workspace_archive_failed");
782
+ if (!output.writableEnded)
783
+ output.end();
784
+ });
785
+ // Unblock the extractor as soon as the exec fails, rather than waiting for the deadline.
786
+ execution.catch((error) => output.destroy(error instanceof Error ? error : new Error(String(error))));
787
+ try {
788
+ await Promise.all([extraction, execution]);
789
+ }
790
+ catch (error) {
791
+ controller.abort();
792
+ output.destroy();
793
+ throw error;
794
+ }
795
+ finally {
796
+ clearTimeout(timer);
797
+ }
798
+ }
799
+ /**
800
+ * Time allowed for one workspace/input transfer: the run's full timeout
801
+ * (deadlineAt - createdAt), measured from when the transfer starts. The
802
+ * remaining run deadline is not used because collection legitimately
803
+ * happens after a run has finished close to (or past) its deadline.
804
+ */
805
+ transferBudgetMs(record) {
806
+ return Math.max(1000, record.deadlineAt - record.createdAt);
807
+ }
808
+ // -------------------------------------------------------------------------
809
+ // Records
810
+ namesFor(handle) {
811
+ if (handle.backend !== BACKEND || typeof handle.id !== "string")
812
+ return undefined;
813
+ const [namespace, token, ...rest] = handle.id.split("/");
814
+ if (rest.length > 0 || namespace !== this.namespace || !token || !TOKEN.test(token))
815
+ return undefined;
816
+ return kubernetesRunNamesForToken(token);
817
+ }
818
+ recordConfigMap(names, record, resourceVersion) {
819
+ return {
820
+ apiVersion: "v1",
821
+ kind: "ConfigMap",
822
+ metadata: {
823
+ name: names.record,
824
+ namespace: this.namespace,
825
+ labels: runLabels(record.runId),
826
+ annotations: { "wardby.io/run-id": record.runId },
827
+ ...(resourceVersion ? { resourceVersion } : {}),
828
+ },
829
+ data: { [RECORD_KEY]: JSON.stringify(record) },
830
+ };
831
+ }
832
+ async readRecord(names) {
833
+ const configMap = await this.api.readConfigMap(this.namespace, names.record);
834
+ if (!configMap)
835
+ return undefined;
836
+ const record = parseRecord(configMap);
837
+ // The record must belong to the run its name was derived from.
838
+ if (kubernetesRunNames(record.runId).token !== names.token)
839
+ throw new Error("kubernetes_record_invalid");
840
+ return { record, resourceVersion: configMap.metadata?.resourceVersion };
841
+ }
842
+ /**
843
+ * Applies a transition with optimistic concurrency, retrying on conflict.
844
+ * `transition` sees the freshly read record and returns the new phase/result
845
+ * (or `undefined` for no change). A terminal record never moves to another
846
+ * phase except `removed`; it may only have its result filled in.
847
+ */
848
+ async updateRecord(names, transition) {
849
+ for (let attempt = 0; attempt < MAX_RECORD_ATTEMPTS; attempt += 1) {
850
+ const found = await this.readRecord(names);
851
+ if (!found)
852
+ throw new Error("job_not_found");
853
+ const current = found.record;
854
+ const next = transition(current);
855
+ if (!next)
856
+ return current;
857
+ const regresses = isTerminal(current.phase) && next.phase !== current.phase && next.phase !== "removed";
858
+ if (regresses || current.phase === "removed")
859
+ return current;
860
+ const updated = { ...current, phase: next.phase };
861
+ if (next.result)
862
+ updated.result = next.result;
863
+ try {
864
+ await this.api.replaceConfigMap(this.namespace, names.record, this.recordConfigMap(names, updated, found.resourceVersion));
865
+ return updated;
866
+ }
867
+ catch (error) {
868
+ if (!(error instanceof KubernetesConflictError))
869
+ throw error;
870
+ }
871
+ }
872
+ throw new Error("kubernetes_record_conflict");
873
+ }
874
+ }