@b00dea/cockpit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (426) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/LICENSE +21 -0
  3. package/README.md +25 -2
  4. package/bin/cockpit.mjs +33 -0
  5. package/cockpit.schema.json +2644 -0
  6. package/dist/autonomy.d.ts +45 -0
  7. package/dist/autonomy.js +173 -0
  8. package/dist/bounded-read.d.ts +14 -0
  9. package/dist/bounded-read.js +42 -0
  10. package/dist/browser/commands.d.ts +117 -0
  11. package/dist/browser/commands.js +453 -0
  12. package/dist/browser/package-paths.d.ts +1 -0
  13. package/dist/browser/package-paths.js +6 -0
  14. package/dist/browser/proxy.d.ts +22 -0
  15. package/dist/browser/proxy.js +37 -0
  16. package/dist/browser/runtime.d.ts +118 -0
  17. package/dist/browser/runtime.js +186 -0
  18. package/dist/browser/surfaces.d.ts +141 -0
  19. package/dist/browser/surfaces.js +561 -0
  20. package/dist/browser/ui-gate.d.ts +15 -0
  21. package/dist/browser/ui-gate.js +18 -0
  22. package/dist/canonical-json.d.ts +2 -0
  23. package/dist/canonical-json.js +5 -0
  24. package/dist/cli-guard.d.ts +5 -0
  25. package/dist/cli-guard.js +71 -0
  26. package/dist/cli-main.d.ts +176 -0
  27. package/dist/cli-main.js +1584 -0
  28. package/dist/cli.d.ts +3 -0
  29. package/dist/cli.js +16 -0
  30. package/dist/command-table.d.ts +121 -0
  31. package/dist/command-table.js +664 -0
  32. package/dist/config/load.d.ts +183 -0
  33. package/dist/config/load.js +367 -0
  34. package/dist/config/sibling-toml.d.ts +48 -0
  35. package/dist/config/sibling-toml.js +160 -0
  36. package/dist/config/surface-tools-toml.d.ts +9 -0
  37. package/dist/config/surface-tools-toml.js +37 -0
  38. package/dist/config/unconfirmed-toml.d.ts +9 -0
  39. package/dist/config/unconfirmed-toml.js +29 -0
  40. package/dist/config/verified-write.d.ts +43 -0
  41. package/dist/config/verified-write.js +89 -0
  42. package/dist/diagnostics.d.ts +26 -0
  43. package/dist/diagnostics.js +344 -0
  44. package/dist/docs/check.d.ts +16 -0
  45. package/dist/docs/check.js +197 -0
  46. package/dist/docs/decision-record.d.ts +34 -0
  47. package/dist/docs/decision-record.js +135 -0
  48. package/dist/docs/decisions.d.ts +16 -0
  49. package/dist/docs/decisions.js +103 -0
  50. package/dist/docs/markdown.d.ts +36 -0
  51. package/dist/docs/markdown.js +88 -0
  52. package/dist/docs/references.d.ts +32 -0
  53. package/dist/docs/references.js +148 -0
  54. package/dist/emit.d.ts +22 -0
  55. package/dist/emit.js +43 -0
  56. package/dist/entrypoint-kinds.d.ts +3 -0
  57. package/dist/entrypoint-kinds.js +11 -0
  58. package/dist/environment/application/confirm.d.ts +35 -0
  59. package/dist/environment/application/confirm.js +122 -0
  60. package/dist/environment/application/destructive.d.ts +6 -0
  61. package/dist/environment/application/destructive.js +429 -0
  62. package/dist/environment/application/doctor.d.ts +94 -0
  63. package/dist/environment/application/doctor.js +247 -0
  64. package/dist/environment/application/guard.d.ts +49 -0
  65. package/dist/environment/application/guard.js +677 -0
  66. package/dist/environment/application/health.d.ts +143 -0
  67. package/dist/environment/application/health.js +385 -0
  68. package/dist/environment/application/lifecycle-context.d.ts +235 -0
  69. package/dist/environment/application/lifecycle-context.js +154 -0
  70. package/dist/environment/application/lifecycle-state.d.ts +25 -0
  71. package/dist/environment/application/lifecycle-state.js +202 -0
  72. package/dist/environment/application/lifecycle.d.ts +18 -0
  73. package/dist/environment/application/lifecycle.js +737 -0
  74. package/dist/environment/application/logs.d.ts +45 -0
  75. package/dist/environment/application/logs.js +127 -0
  76. package/dist/environment/application/services.d.ts +60 -0
  77. package/dist/environment/application/services.js +89 -0
  78. package/dist/environment/application/setup.d.ts +129 -0
  79. package/dist/environment/application/setup.js +741 -0
  80. package/dist/environment/application/shared.d.ts +11 -0
  81. package/dist/environment/application/shared.js +61 -0
  82. package/dist/environment/application/siblings.d.ts +43 -0
  83. package/dist/environment/application/siblings.js +269 -0
  84. package/dist/environment/claims.d.ts +389 -0
  85. package/dist/environment/claims.js +1701 -0
  86. package/dist/environment/codex-hook-trust.d.ts +11 -0
  87. package/dist/environment/codex-hook-trust.js +185 -0
  88. package/dist/environment/compose-environment.d.ts +1 -0
  89. package/dist/environment/compose-environment.js +6 -0
  90. package/dist/environment/compose-ownership.d.ts +21 -0
  91. package/dist/environment/compose-ownership.js +117 -0
  92. package/dist/environment/compose-topology.d.ts +125 -0
  93. package/dist/environment/compose-topology.js +490 -0
  94. package/dist/environment/contract.d.ts +522 -0
  95. package/dist/environment/contract.js +794 -0
  96. package/dist/environment/declaration.d.ts +14 -0
  97. package/dist/environment/declaration.js +39 -0
  98. package/dist/environment/destructive-composition.d.ts +29 -0
  99. package/dist/environment/destructive-composition.js +333 -0
  100. package/dist/environment/docker-daemon.d.ts +30 -0
  101. package/dist/environment/docker-daemon.js +78 -0
  102. package/dist/environment/docker-endpoint.d.ts +10 -0
  103. package/dist/environment/docker-endpoint.js +19 -0
  104. package/dist/environment/env-schema-observation.d.ts +46 -0
  105. package/dist/environment/env-schema-observation.js +241 -0
  106. package/dist/environment/evidence.d.ts +136 -0
  107. package/dist/environment/evidence.js +437 -0
  108. package/dist/environment/executable.d.ts +38 -0
  109. package/dist/environment/executable.js +190 -0
  110. package/dist/environment/generated-application/authorities.d.ts +5 -0
  111. package/dist/environment/generated-application/authorities.js +61 -0
  112. package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
  113. package/dist/environment/generated-application/bootstrap-skill.js +328 -0
  114. package/dist/environment/generated-application/check.d.ts +13 -0
  115. package/dist/environment/generated-application/check.js +191 -0
  116. package/dist/environment/generated-application/custom-surface.d.ts +26 -0
  117. package/dist/environment/generated-application/custom-surface.js +289 -0
  118. package/dist/environment/generated-application/prepare.d.ts +29 -0
  119. package/dist/environment/generated-application/prepare.js +278 -0
  120. package/dist/environment/generated-application/publication.d.ts +33 -0
  121. package/dist/environment/generated-application/publication.js +547 -0
  122. package/dist/environment/generated-application/shared.d.ts +492 -0
  123. package/dist/environment/generated-application/shared.js +354 -0
  124. package/dist/environment/generated-application/surface-observation.d.ts +4 -0
  125. package/dist/environment/generated-application/surface-observation.js +69 -0
  126. package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
  127. package/dist/environment/generated-application/surface-outputs.js +400 -0
  128. package/dist/environment/generated-application/surfaces.d.ts +3 -0
  129. package/dist/environment/generated-application/surfaces.js +704 -0
  130. package/dist/environment/generated-application/sync.d.ts +6 -0
  131. package/dist/environment/generated-application/sync.js +124 -0
  132. package/dist/environment/git-observation.d.ts +202 -0
  133. package/dist/environment/git-observation.js +553 -0
  134. package/dist/environment/health-composition.d.ts +12 -0
  135. package/dist/environment/health-composition.js +645 -0
  136. package/dist/environment/host-observation.d.ts +56 -0
  137. package/dist/environment/host-observation.js +52 -0
  138. package/dist/environment/identity.d.ts +48 -0
  139. package/dist/environment/identity.js +85 -0
  140. package/dist/environment/init-application.d.ts +333 -0
  141. package/dist/environment/init-application.js +726 -0
  142. package/dist/environment/install-policy-observation.d.ts +71 -0
  143. package/dist/environment/install-policy-observation.js +284 -0
  144. package/dist/environment/install-policy.d.ts +48 -0
  145. package/dist/environment/install-policy.js +96 -0
  146. package/dist/environment/instance.d.ts +33 -0
  147. package/dist/environment/instance.js +122 -0
  148. package/dist/environment/instruction-pointer.d.ts +19 -0
  149. package/dist/environment/instruction-pointer.js +144 -0
  150. package/dist/environment/lifecycle-composition.d.ts +31 -0
  151. package/dist/environment/lifecycle-composition.js +742 -0
  152. package/dist/environment/lifecycle-stage.d.ts +120 -0
  153. package/dist/environment/lifecycle-stage.js +408 -0
  154. package/dist/environment/lifecycle.d.ts +75 -0
  155. package/dist/environment/lifecycle.js +354 -0
  156. package/dist/environment/logs.d.ts +132 -0
  157. package/dist/environment/logs.js +519 -0
  158. package/dist/environment/map-limit.d.ts +2 -0
  159. package/dist/environment/map-limit.js +13 -0
  160. package/dist/environment/mcp-observation.d.ts +9 -0
  161. package/dist/environment/mcp-observation.js +46 -0
  162. package/dist/environment/observations.d.ts +46 -0
  163. package/dist/environment/observations.js +346 -0
  164. package/dist/environment/operator-application.d.ts +79 -0
  165. package/dist/environment/operator-application.js +197 -0
  166. package/dist/environment/ports.d.ts +182 -0
  167. package/dist/environment/ports.js +1205 -0
  168. package/dist/environment/posture-observation.d.ts +68 -0
  169. package/dist/environment/posture-observation.js +253 -0
  170. package/dist/environment/presence.d.ts +4 -0
  171. package/dist/environment/presence.js +26 -0
  172. package/dist/environment/read-composition.d.ts +32 -0
  173. package/dist/environment/read-composition.js +397 -0
  174. package/dist/environment/readiness-composition.d.ts +57 -0
  175. package/dist/environment/readiness-composition.js +151 -0
  176. package/dist/environment/reclaim.d.ts +370 -0
  177. package/dist/environment/reclaim.js +1940 -0
  178. package/dist/environment/repository-identity.d.ts +13 -0
  179. package/dist/environment/repository-identity.js +108 -0
  180. package/dist/environment/runtime-measurement-record.d.ts +38 -0
  181. package/dist/environment/runtime-measurement-record.js +111 -0
  182. package/dist/environment/runtime-measurement.d.ts +27 -0
  183. package/dist/environment/runtime-measurement.js +224 -0
  184. package/dist/environment/runtime-probe-residue.d.ts +43 -0
  185. package/dist/environment/runtime-probe-residue.js +148 -0
  186. package/dist/environment/runtime-probe.d.ts +72 -0
  187. package/dist/environment/runtime-probe.js +725 -0
  188. package/dist/environment/runtime.d.ts +551 -0
  189. package/dist/environment/runtime.js +2405 -0
  190. package/dist/environment/seed.d.ts +16 -0
  191. package/dist/environment/seed.js +43 -0
  192. package/dist/environment/selftest-application.d.ts +86 -0
  193. package/dist/environment/selftest-application.js +713 -0
  194. package/dist/environment/sensitive.d.ts +3 -0
  195. package/dist/environment/sensitive.js +48 -0
  196. package/dist/environment/served-checkout-grants.d.ts +53 -0
  197. package/dist/environment/served-checkout-grants.js +182 -0
  198. package/dist/environment/setup-observation.d.ts +62 -0
  199. package/dist/environment/setup-observation.js +1473 -0
  200. package/dist/environment/setup.d.ts +43 -0
  201. package/dist/environment/setup.js +176 -0
  202. package/dist/environment/sibling-checkouts.d.ts +119 -0
  203. package/dist/environment/sibling-checkouts.js +254 -0
  204. package/dist/environment/sibling-instructions.d.ts +33 -0
  205. package/dist/environment/sibling-instructions.js +224 -0
  206. package/dist/environment/sibling-observation.d.ts +27 -0
  207. package/dist/environment/sibling-observation.js +45 -0
  208. package/dist/environment/sibling-pull.d.ts +23 -0
  209. package/dist/environment/sibling-pull.js +95 -0
  210. package/dist/environment/sibling-registration.d.ts +45 -0
  211. package/dist/environment/sibling-registration.js +538 -0
  212. package/dist/environment/stack-inputs.d.ts +18 -0
  213. package/dist/environment/stack-inputs.js +210 -0
  214. package/dist/environment/stack-inventory-prune.d.ts +31 -0
  215. package/dist/environment/stack-inventory-prune.js +129 -0
  216. package/dist/environment/stack-inventory-report.d.ts +44 -0
  217. package/dist/environment/stack-inventory-report.js +78 -0
  218. package/dist/environment/stack-inventory.d.ts +94 -0
  219. package/dist/environment/stack-inventory.js +418 -0
  220. package/dist/environment/state.d.ts +410 -0
  221. package/dist/environment/state.js +2153 -0
  222. package/dist/environment/templates.d.ts +140 -0
  223. package/dist/environment/templates.js +685 -0
  224. package/dist/environment/tool-registration.d.ts +9 -0
  225. package/dist/environment/tool-registration.js +92 -0
  226. package/dist/environment/upgrade-application.d.ts +19 -0
  227. package/dist/environment/upgrade-application.js +175 -0
  228. package/dist/environment-exec/execute.d.ts +105 -0
  229. package/dist/environment-exec/execute.js +1179 -0
  230. package/dist/environment-exec/libexec-program.d.ts +6 -0
  231. package/dist/environment-exec/libexec-program.js +22 -0
  232. package/dist/environment-exec/verified-digest.d.ts +4 -0
  233. package/dist/environment-exec/verified-digest.js +16 -0
  234. package/dist/exact-keys.d.ts +7 -0
  235. package/dist/exact-keys.js +15 -0
  236. package/dist/fix-catalogue.d.ts +32 -0
  237. package/dist/fix-catalogue.js +60 -0
  238. package/dist/freshness.d.ts +29 -0
  239. package/dist/freshness.js +75 -0
  240. package/dist/generated-environment/adopt.d.ts +16 -0
  241. package/dist/generated-environment/adopt.js +271 -0
  242. package/dist/generated-environment/command.d.ts +47 -0
  243. package/dist/generated-environment/command.js +75 -0
  244. package/dist/generated-environment/transaction.d.ts +186 -0
  245. package/dist/generated-environment/transaction.js +1770 -0
  246. package/dist/grading/evaluate.d.ts +71 -0
  247. package/dist/grading/evaluate.js +403 -0
  248. package/dist/grading/grade.d.ts +33 -0
  249. package/dist/grading/grade.js +57 -0
  250. package/dist/guards/answer-confirmation.d.ts +41 -0
  251. package/dist/guards/answer-confirmation.js +109 -0
  252. package/dist/guards/evaluator.d.ts +130 -0
  253. package/dist/guards/evaluator.js +1258 -0
  254. package/dist/guards/harness-adapter.d.ts +17 -0
  255. package/dist/guards/harness-adapter.js +57 -0
  256. package/dist/guards/host.d.ts +8 -0
  257. package/dist/guards/host.js +10 -0
  258. package/dist/guards/invocation-parser.d.ts +66 -0
  259. package/dist/guards/invocation-parser.js +595 -0
  260. package/dist/guards/local-identity.d.ts +44 -0
  261. package/dist/guards/local-identity.js +129 -0
  262. package/dist/guards/policy-change.d.ts +43 -0
  263. package/dist/guards/policy-change.js +159 -0
  264. package/dist/guards/policy-pin.d.ts +13 -0
  265. package/dist/guards/policy-pin.js +177 -0
  266. package/dist/guards/protected-locations.d.ts +62 -0
  267. package/dist/guards/protected-locations.js +461 -0
  268. package/dist/guards/read-only.d.ts +37 -0
  269. package/dist/guards/read-only.js +315 -0
  270. package/dist/init/command.d.ts +41 -0
  271. package/dist/init/command.js +330 -0
  272. package/dist/init/derive.d.ts +53 -0
  273. package/dist/init/derive.js +819 -0
  274. package/dist/init/draft.d.ts +140 -0
  275. package/dist/init/draft.js +1228 -0
  276. package/dist/init/render.d.ts +47 -0
  277. package/dist/init/render.js +104 -0
  278. package/dist/init/scaffold.d.ts +53 -0
  279. package/dist/init/scaffold.js +121 -0
  280. package/dist/invocation.d.ts +13 -0
  281. package/dist/invocation.js +20 -0
  282. package/dist/package-metadata.d.ts +9 -0
  283. package/dist/package-metadata.js +19 -0
  284. package/dist/readiness.d.ts +53 -0
  285. package/dist/readiness.js +88 -0
  286. package/dist/references/named.d.ts +24 -0
  287. package/dist/references/named.js +27 -0
  288. package/dist/refusal.d.ts +15 -0
  289. package/dist/refusal.js +7 -0
  290. package/dist/registry-derivation.d.ts +31 -0
  291. package/dist/registry-derivation.js +32 -0
  292. package/dist/registry-resolve/command.d.ts +47 -0
  293. package/dist/registry-resolve/command.js +226 -0
  294. package/dist/registry-resolve/registry.d.ts +68 -0
  295. package/dist/registry-resolve/registry.js +292 -0
  296. package/dist/registry-resolve/render-shell.d.ts +18 -0
  297. package/dist/registry-resolve/render-shell.js +36 -0
  298. package/dist/repair.d.ts +29 -0
  299. package/dist/repair.js +8 -0
  300. package/dist/repository-path.d.ts +9 -0
  301. package/dist/repository-path.js +16 -0
  302. package/dist/schema-json.d.ts +1 -0
  303. package/dist/schema-json.js +177 -0
  304. package/dist/schema.d.ts +1289 -0
  305. package/dist/schema.js +985 -0
  306. package/dist/setup/command.d.ts +4 -0
  307. package/dist/setup/command.js +4 -0
  308. package/dist/sha256.d.ts +1 -0
  309. package/dist/sha256.js +4 -0
  310. package/dist/surfaces/adoption.d.ts +41 -0
  311. package/dist/surfaces/adoption.js +217 -0
  312. package/dist/surfaces/consumer-instructions.d.ts +20 -0
  313. package/dist/surfaces/consumer-instructions.js +43 -0
  314. package/dist/surfaces/contract.d.ts +42 -0
  315. package/dist/surfaces/contract.js +169 -0
  316. package/dist/surfaces/core-declarations.d.ts +24 -0
  317. package/dist/surfaces/core-declarations.js +79 -0
  318. package/dist/surfaces/development-markers.d.ts +1 -0
  319. package/dist/surfaces/development-markers.js +18 -0
  320. package/dist/surfaces/generator-adapter.d.ts +71 -0
  321. package/dist/surfaces/generator-adapter.js +809 -0
  322. package/dist/surfaces/harness-registry.d.ts +260 -0
  323. package/dist/surfaces/harness-registry.js +146 -0
  324. package/dist/surfaces/mcp.d.ts +145 -0
  325. package/dist/surfaces/mcp.js +349 -0
  326. package/dist/surfaces/merge.d.ts +30 -0
  327. package/dist/surfaces/merge.js +105 -0
  328. package/dist/surfaces/ownership.d.ts +190 -0
  329. package/dist/surfaces/ownership.js +795 -0
  330. package/dist/surfaces/projection.d.ts +136 -0
  331. package/dist/surfaces/projection.js +716 -0
  332. package/dist/surfaces/render.d.ts +79 -0
  333. package/dist/surfaces/render.js +636 -0
  334. package/dist/surfaces/restrictions.d.ts +130 -0
  335. package/dist/surfaces/restrictions.js +447 -0
  336. package/dist/surfaces/selftest.d.ts +162 -0
  337. package/dist/surfaces/selftest.js +393 -0
  338. package/dist/surfaces/skill-contract.d.ts +85 -0
  339. package/dist/surfaces/skill-contract.js +449 -0
  340. package/dist/surfaces/sync.d.ts +57 -0
  341. package/dist/surfaces/sync.js +164 -0
  342. package/dist/team-metadata.d.ts +41 -0
  343. package/dist/team-metadata.js +513 -0
  344. package/dist/testing/gates.d.ts +91 -0
  345. package/dist/testing/gates.js +182 -0
  346. package/dist/testing/hooks.d.ts +59 -0
  347. package/dist/testing/hooks.js +139 -0
  348. package/dist/testing/test-protection.d.ts +34 -0
  349. package/dist/testing/test-protection.js +137 -0
  350. package/dist/testing/verdict.d.ts +29 -0
  351. package/dist/testing/verdict.js +62 -0
  352. package/dist/testing/weakening.d.ts +15 -0
  353. package/dist/testing/weakening.js +30 -0
  354. package/dist/work-orders/application-closure.d.ts +7 -0
  355. package/dist/work-orders/application-closure.js +76 -0
  356. package/dist/work-orders/application-context.d.ts +162 -0
  357. package/dist/work-orders/application-context.js +137 -0
  358. package/dist/work-orders/application-decisions.d.ts +27 -0
  359. package/dist/work-orders/application-decisions.js +121 -0
  360. package/dist/work-orders/application-grading.d.ts +11 -0
  361. package/dist/work-orders/application-grading.js +212 -0
  362. package/dist/work-orders/application.d.ts +6 -0
  363. package/dist/work-orders/application.js +223 -0
  364. package/dist/work-orders/composition.d.ts +101 -0
  365. package/dist/work-orders/composition.js +1202 -0
  366. package/dist/work-orders/contract.d.ts +77 -0
  367. package/dist/work-orders/contract.js +140 -0
  368. package/dist/work-orders/decisions.d.ts +23 -0
  369. package/dist/work-orders/decisions.js +55 -0
  370. package/dist/work-orders/store.d.ts +207 -0
  371. package/dist/work-orders/store.js +291 -0
  372. package/docs/README.md +68 -0
  373. package/docs/architecture.md +136 -0
  374. package/docs/capability-matrix.md +58 -0
  375. package/docs/cli-reference.md +807 -0
  376. package/docs/command-list.md +62 -0
  377. package/docs/compose-patterns.md +99 -0
  378. package/docs/concepts.md +173 -0
  379. package/docs/configuration-essentials.md +177 -0
  380. package/docs/configuration-reference.md +636 -0
  381. package/docs/docs-check.md +76 -0
  382. package/docs/environment.md +948 -0
  383. package/docs/extending.md +43 -0
  384. package/docs/faq.md +113 -0
  385. package/docs/getting-started.md +692 -0
  386. package/docs/harness-surfaces.md +500 -0
  387. package/docs/joining-a-team.md +76 -0
  388. package/docs/release-policy.md +49 -0
  389. package/docs/safety-and-autonomy.md +463 -0
  390. package/docs/troubleshooting.md +382 -0
  391. package/docs/tutorial.md +171 -0
  392. package/docs/work-and-browser.md +420 -0
  393. package/libexec/browser-operation.cjs +270 -0
  394. package/libexec/fence-marker.cjs +101 -0
  395. package/libexec/fence-marker.d.cts +24 -0
  396. package/libexec/fenced-group-runner.cjs +77 -0
  397. package/libexec/fenced-supervisor.cjs +137 -0
  398. package/libexec/generated-filesystem.cjs +731 -0
  399. package/libexec/install-filesystem.cjs +225 -0
  400. package/libexec/rulesync-generator.NOTICE.md +1112 -0
  401. package/libexec/rulesync-generator.cjs +743 -0
  402. package/libexec/surface-generator-worker.cjs +266 -0
  403. package/libexec/surface-output-reader.cjs +99 -0
  404. package/libexec/surface-scratch-cleanup.cjs +14 -0
  405. package/libexec/validate-harness-evidence.d.mts +183 -0
  406. package/libexec/validate-harness-evidence.mjs +1312 -0
  407. package/package.json +74 -4
  408. package/surfaces/README.md +33 -0
  409. package/surfaces/agents/evaluator.agent.md +76 -0
  410. package/surfaces/guards/bash-guard.sh +48 -0
  411. package/surfaces/guards/claude.adapter.json +25 -0
  412. package/surfaces/guards/codex.adapter.json +24 -0
  413. package/surfaces/guards/opencode.adapter.json +25 -0
  414. package/surfaces/references/browser.md +63 -0
  415. package/surfaces/references/test-data-and-personas.md +84 -0
  416. package/surfaces/references/testing-tiers.md +11 -0
  417. package/surfaces/skills/cockpit-browser.skill.md +59 -0
  418. package/surfaces/skills/cockpit-explain.skill.md +32 -0
  419. package/surfaces/skills/cockpit-fix.skill.md +37 -0
  420. package/surfaces/skills/cockpit-setup.skill.md +39 -0
  421. package/surfaces/skills/cockpit-work.skill.md +34 -0
  422. package/surfaces/skills/collection.json +5 -0
  423. package/surfaces/skills/procedures/explain.md +66 -0
  424. package/surfaces/skills/procedures/fix.md +95 -0
  425. package/surfaces/skills/procedures/setup.md +452 -0
  426. package/surfaces/skills/procedures/work.md +134 -0
@@ -0,0 +1,1940 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { lstatSync } from 'node:fs';
4
+ import { dirname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
5
+ import { admitLocalDockerEndpoint } from './docker-endpoint.js';
6
+ import { decodeCoordinationRecord, inspectFailedReclaimRecovery, matchReclaimDrainContinuation, reclaimDrainStep, } from './claims.js';
7
+ import { hasErrorCode, tryParseJson, RefusalError, ENVIRONMENT_CODES, errorCause, filesystemObjectIdentity, isCanonicalTimestamp, isFilesystemObjectIdentity, sameFilesystemObject, snapshotExecutionStep, snapshotJson, } from './contract.js';
8
+ import { resolvedDockerExecutable, resolvedExecutable } from './executable.js';
9
+ import { readState } from './state.js';
10
+ import { exactKeys } from '../exact-keys.js';
11
+ const STACK_ID = /^stack:[a-f0-9]{64}$/;
12
+ const COCKPIT_ID = /^cockpit:[a-z0-9][a-z0-9-]*:[a-f0-9]{64}$/;
13
+ const SHA256 = /^[a-f0-9]{64}$/;
14
+ const BUILD_CACHE_ID = /^[a-z0-9]{25}$/;
15
+ const RESOURCE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:@+-]{0,255}$/;
16
+ const DOCKER_SELECTOR = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
17
+ const REQUIRED_LABELS = Object.freeze({
18
+ stack: 'dev.b0dea.cockpit.stack-id',
19
+ config: 'dev.b0dea.cockpit.config-sha256',
20
+ owner: 'dev.b0dea.cockpit.owner-id',
21
+ });
22
+ // Compose labels every volume it creates with its project and the volume's key in the Compose
23
+ // file. `resettable_volumes` names that key, and Docker's volume name differs per checkout.
24
+ const COMPOSE_LABELS = Object.freeze({
25
+ project: 'com.docker.compose.project',
26
+ volume: 'com.docker.compose.volume',
27
+ });
28
+ const COMPOSE_PROJECT = /^[a-z0-9][a-z0-9_-]*$/;
29
+ const MAX_RESOURCES = 4_096;
30
+ const OUTPUT_LIMIT = 1_048_576;
31
+ // Inspections are chunked so a stack with many resources still needs few Docker clients. The chunk
32
+ // is sized from the per-call output cap: every printed object is held to a fixed budget, and a
33
+ // chunk's cap is its object count times that budget.
34
+ const INSPECTION_OBJECT_BUDGET = 16_384;
35
+ const INSPECTION_CHUNK = Math.min(64, Math.floor(OUTPUT_LIMIT / INSPECTION_OBJECT_BUDGET));
36
+ // [executable, --host, endpoint, <type>, inspect, --format, <format>, ...ids]
37
+ const INSPECTION_ARGV_PREFIX = 7;
38
+ const DAEMON_ENV = /^(?:DOCKER|BUILDX|CONTAINER|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY)(?:_|$)/i;
39
+ const SAFE_ENV = new Set(['PATH', 'TMPDIR', 'LANG', 'LC_ALL']);
40
+ // A discovery may legitimately contain thousands of resources. Each inspection launches a Docker
41
+ // client, so one shared bound covers every resource kind rather than letting each kind
42
+ // independently saturate the daemon.
43
+ const MAX_CONCURRENT_INSPECTIONS = 4;
44
+ const DISCOVERY_KINDS = ['container', 'network', 'volume', 'image', 'build-cache'];
45
+ function diagnostic(code, message, target, fix) {
46
+ return { severity: 'error', code, message, target, ...(fix ? { fix } : {}) };
47
+ }
48
+ function refuse(code, message, target, fix) {
49
+ return { kind: 'refusal', diagnostics: [diagnostic(code, message, target, fix)] };
50
+ }
51
+ function lexical(left, right) {
52
+ return left < right ? -1 : left > right ? 1 : 0;
53
+ }
54
+ function snapshotResource(value) {
55
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
56
+ return undefined;
57
+ const candidate = value;
58
+ const keys = [
59
+ 'id', 'name', 'kind', 'labels', 'referenced', 'bytes',
60
+ ...(candidate.dataBearing === undefined ? [] : ['dataBearing']),
61
+ ...(candidate.generation === undefined ? [] : ['generation']),
62
+ ];
63
+ if (!exactKeys(value, keys)
64
+ || typeof candidate.id !== 'string' || !RESOURCE_ID.test(candidate.id)
65
+ || typeof candidate.name !== 'string' || candidate.name.length === 0 || candidate.name.length > 512 || candidate.name.includes('\0')
66
+ || !['container', 'network', 'volume', 'image', 'build-cache', 'cache'].includes(candidate.kind)
67
+ || candidate.labels === null || typeof candidate.labels !== 'object' || Array.isArray(candidate.labels)
68
+ || Object.keys(candidate.labels).length > 64
69
+ || Object.entries(candidate.labels).some(([key, item]) => key.length === 0 || key.length > 256 || key.includes('\0')
70
+ || typeof item !== 'string' || item.length > 512 || item.includes('\0'))
71
+ || typeof candidate.referenced !== 'boolean'
72
+ || candidate.bytes !== null && (!Number.isSafeInteger(candidate.bytes) || candidate.bytes < 0)
73
+ || candidate.dataBearing !== undefined && typeof candidate.dataBearing !== 'boolean'
74
+ || candidate.generation !== undefined && (candidate.kind !== 'volume' || typeof candidate.generation !== 'string'
75
+ || candidate.generation.length === 0 || candidate.generation.length > 128 || candidate.generation.includes('\0')))
76
+ return undefined;
77
+ return {
78
+ id: candidate.id,
79
+ name: candidate.name,
80
+ kind: candidate.kind,
81
+ labels: { ...candidate.labels },
82
+ referenced: candidate.referenced,
83
+ bytes: candidate.bytes,
84
+ ...(candidate.dataBearing === undefined ? {} : { dataBearing: candidate.dataBearing }),
85
+ ...(candidate.generation === undefined ? {} : { generation: candidate.generation }),
86
+ };
87
+ }
88
+ function snapshotClassificationInput(value) {
89
+ const snapshot = snapshotJson(value);
90
+ if (snapshot === undefined || snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)
91
+ || !exactKeys(snapshot, ['stackId', 'cockpitId', 'configSha256', 'coordinationRecord', 'declaredCacheIds', 'discovery']))
92
+ return undefined;
93
+ const input = snapshot;
94
+ if (typeof input.stackId !== 'string' || !STACK_ID.test(input.stackId)
95
+ || typeof input.cockpitId !== 'string' || !COCKPIT_ID.test(input.cockpitId)
96
+ || typeof input.configSha256 !== 'string' || !SHA256.test(input.configSha256)
97
+ || !Array.isArray(input.declaredCacheIds) || input.declaredCacheIds.length > MAX_RESOURCES
98
+ || input.declaredCacheIds.some((id) => typeof id !== 'string' || !(BUILD_CACHE_ID.test(id) || SHA256.test(id)))
99
+ || new Set(input.declaredCacheIds).size !== input.declaredCacheIds.length
100
+ || input.discovery === null || typeof input.discovery !== 'object' || Array.isArray(input.discovery))
101
+ return undefined;
102
+ const discovery = input.discovery;
103
+ if (typeof discovery.observedAt !== 'string' || !isCanonicalTimestamp(discovery.observedAt)
104
+ || discovery.producer !== 'runtime-resource-discovery'
105
+ || !Array.isArray(discovery.resources) || discovery.resources.length > MAX_RESOURCES)
106
+ return undefined;
107
+ const resources = discovery.resources.map(snapshotResource);
108
+ if (resources.some((item) => item === undefined))
109
+ return undefined;
110
+ const ids = resources.map((item) => item.id);
111
+ if (new Set(ids).size !== ids.length)
112
+ return undefined;
113
+ const coordinationRecord = input.coordinationRecord;
114
+ if (coordinationRecord !== null) {
115
+ if (typeof coordinationRecord !== 'object' || Array.isArray(coordinationRecord))
116
+ return undefined;
117
+ const record = coordinationRecord;
118
+ if (!exactKeys(record, ['format', 'kind', 'cockpitId', 'subjectId', 'revision', 'updatedAt', 'value'])
119
+ || record.format !== 'cockpit-state/v1' || record.kind !== 'coordination'
120
+ || record.cockpitId !== input.cockpitId || record.subjectId !== input.stackId
121
+ || !Number.isSafeInteger(record.revision) || record.revision < 0
122
+ || typeof record.updatedAt !== 'string' || !isCanonicalTimestamp(record.updatedAt))
123
+ return undefined;
124
+ }
125
+ return {
126
+ stackId: input.stackId,
127
+ cockpitId: input.cockpitId,
128
+ configSha256: input.configSha256,
129
+ coordinationRecord: coordinationRecord,
130
+ declaredCacheIds: [...input.declaredCacheIds],
131
+ discovery: {
132
+ observedAt: discovery.observedAt,
133
+ producer: 'runtime-resource-discovery',
134
+ resources: resources,
135
+ },
136
+ };
137
+ }
138
+ function labelOwnership(resource, input, positiveState) {
139
+ const present = Object.values(REQUIRED_LABELS).filter((name) => Object.hasOwn(resource.labels, name));
140
+ const exactCache = input.declaredCacheIds.includes(resource.id) && resource.name === resource.id
141
+ && (resource.kind === 'build-cache' && BUILD_CACHE_ID.test(resource.id) || resource.kind === 'cache' && SHA256.test(resource.id));
142
+ if (present.length === 0) {
143
+ return exactCache
144
+ ? { ownership: 'unknown', evidence: ['exact declared cache identity; ownership labels absent'], reason: 'the exact declared cache has no current ownership evidence' }
145
+ : { ownership: 'unlabelled', evidence: ['all required ownership labels absent'], reason: 'the resource carries no ownership labels' };
146
+ }
147
+ if (present.length !== Object.keys(REQUIRED_LABELS).length) {
148
+ return { ownership: 'partial', evidence: [`${present.length} of 3 required ownership labels present`], reason: 'partial labels cannot establish ownership' };
149
+ }
150
+ const stack = resource.labels[REQUIRED_LABELS.stack];
151
+ const config = resource.labels[REQUIRED_LABELS.config];
152
+ const owner = resource.labels[REQUIRED_LABELS.owner];
153
+ if (!STACK_ID.test(stack) || !SHA256.test(config) || !COCKPIT_ID.test(owner)) {
154
+ return { ownership: 'malformed', evidence: ['required label values failed their closed grammar'], reason: 'malformed labels cannot establish ownership' };
155
+ }
156
+ if (stack !== input.stackId || owner !== input.cockpitId) {
157
+ return { ownership: 'foreign', evidence: [`stack=${stack}`, `owner=${owner}`], reason: 'the labels identify another stack or cockpit' };
158
+ }
159
+ if (!positiveState) {
160
+ return { ownership: 'unknown', evidence: ['exact labels matched; coordinated state evidence absent'], reason: 'labels alone do not grant ownership' };
161
+ }
162
+ if (config !== input.configSha256) {
163
+ return { ownership: 'stale-config', evidence: [`config=${config}`, `current=${input.configSha256}`], reason: 'this stack started the resource under an earlier configuration' };
164
+ }
165
+ return { ownership: 'current', evidence: ['exact stack, owner, and configuration labels matched coordinated state'], reason: 'positive labels and state identify the current stack' };
166
+ }
167
+ function reversibility(resource, declaredCacheIds) {
168
+ if (declaredCacheIds.includes(resource.id) && resource.name === resource.id
169
+ && (resource.kind === 'build-cache' && BUILD_CACHE_ID.test(resource.id) || resource.kind === 'cache' && SHA256.test(resource.id)))
170
+ return 'regenerable-cache';
171
+ if (resource.dataBearing === true || resource.kind === 'volume')
172
+ return 'data-bearing';
173
+ return 'runtime';
174
+ }
175
+ function shellQuote(value) {
176
+ return `'${value.replaceAll("'", "'\\''")}'`;
177
+ }
178
+ function suggestion(resource, ownership) {
179
+ if (resource.kind === 'build-cache' || resource.kind === 'cache')
180
+ return undefined;
181
+ const noun = resource.kind === 'image' ? 'image' : resource.kind;
182
+ return {
183
+ command: `docker ${noun} rm -- ${shellQuote(resource.id)}`,
184
+ risk: `${ownership} ${resource.kind} is not owned for automatic removal; verify its consumers before running this exact command`,
185
+ };
186
+ }
187
+ export function classifyReclaim(rawInput) {
188
+ const input = snapshotClassificationInput(rawInput);
189
+ if (input === undefined)
190
+ return refuse(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reclaim discovery input is malformed or exceeds its bounds', 'reclaim-discovery');
191
+ const decoded = input.coordinationRecord === null ? undefined : decodeCoordinationRecord(input.coordinationRecord, input.stackId);
192
+ if (input.coordinationRecord !== null && (decoded === undefined || input.coordinationRecord.cockpitId !== input.cockpitId)) {
193
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination evidence does not match the requested stack and cockpit', input.stackId);
194
+ }
195
+ const positiveState = decoded !== undefined;
196
+ const resources = input.discovery.resources.map((resource) => {
197
+ const evidence = labelOwnership(resource, input, positiveState);
198
+ const reverse = reversibility(resource, input.declaredCacheIds);
199
+ const exactUnknownCache = (resource.kind === 'build-cache' || resource.kind === 'cache') && evidence.ownership === 'unknown'
200
+ && reverse === 'regenerable-cache' && !resource.referenced;
201
+ // The stack and owner labels are the stack's stable identity; the configuration label only
202
+ // records which cockpit.toml started the resource. Keeping a container or network an earlier
203
+ // configuration started made `down` after an edited cockpit.toml report the stack removed
204
+ // while it stayed. Its data stays out of teardown's reach: only a declared `reset` removes it.
205
+ const action = resource.referenced ? 'report-only' : evidence.ownership === 'current'
206
+ ? reverse === 'data-bearing' ? 'reset-required'
207
+ : reverse === 'regenerable-cache' ? 'reclaim' : 'teardown'
208
+ : evidence.ownership === 'stale-config' && reverse === 'runtime' ? 'teardown'
209
+ : exactUnknownCache ? 'reclaim' : 'report-only';
210
+ return {
211
+ ...resource,
212
+ ownership: evidence.ownership,
213
+ reversibility: reverse,
214
+ action,
215
+ evidence: [...evidence.evidence],
216
+ reason: evidence.reason,
217
+ ...(action === 'report-only' ? { suggestion: suggestion(resource, evidence.ownership) } : {}),
218
+ };
219
+ }).sort((left, right) => lexical(left.id, right.id));
220
+ return { kind: 'complete', noop: resources.length === 0, resources };
221
+ }
222
+ /**
223
+ * The resources a finished `down` leaves behind although this stack's labels and coordinated state
224
+ * show they are its own, each with the reason teardown may not remove it. Volumes are left out:
225
+ * `down` keeps data by design, and only `reset` deletes it.
226
+ */
227
+ export function keptTeardownResources(input) {
228
+ const classified = classifyReclaim(reclaimInput(input));
229
+ if (classified.kind !== 'complete')
230
+ return undefined;
231
+ return classified.resources
232
+ .filter(({ kind, ownership, action }) => kind !== 'volume' && action === 'report-only'
233
+ && (ownership === 'current' || ownership === 'stale-config'))
234
+ .map(({ kind, id, name, referenced, reason }) => ({
235
+ kind, id, name,
236
+ reason: !referenced ? reason
237
+ : kind === 'network' ? 'a container outside this stack is still attached to it'
238
+ : kind === 'image' ? 'it is tagged under more than one name, so another name may still need it'
239
+ : 'something outside this stack still uses it',
240
+ }));
241
+ }
242
+ function safeEnvironment(value) {
243
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
244
+ return undefined;
245
+ const entries = Object.entries(value);
246
+ if (entries.some(([key, item]) => !key || key.includes('\0') || key.includes('=') || DAEMON_ENV.test(key)
247
+ || typeof item !== 'string' || item.includes('\0')))
248
+ return undefined;
249
+ return Object.fromEntries(entries.filter(([key]) => SAFE_ENV.has(key)).sort(([a], [b]) => lexical(a, b)));
250
+ }
251
+ const contextSelectionFormat = '{"context":{{json .Name}},"endpoint":{{json .Endpoints.docker.Host}}}';
252
+ function selectionRow(bytes) {
253
+ const text = bytes.toString('utf8');
254
+ if (!Buffer.from(text, 'utf8').equals(bytes))
255
+ return undefined;
256
+ try {
257
+ const parsed = JSON.parse(text.trim());
258
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
259
+ }
260
+ catch {
261
+ // Unparseable docker output is unproven; the caller refuses the discovery.
262
+ return undefined;
263
+ }
264
+ }
265
+ // Ownership is label-based: a resource without this stack's label is never removed, so listing the
266
+ // rest of a shared daemon only cost time. Listing and inspecting every container, image and volume
267
+ // on a daemon with a hundred unrelated containers made each `down` observation take seconds, and
268
+ // `container ls --size` alone ran past the step timeout. A stack's volumes stay unfiltered because a
269
+ // reset refuses when a declared volume name belongs to something this stack does not own, and an
270
+ // unlabelled volume of that name would otherwise be invisible; their inspection is still scoped.
271
+ // The machine-wide listing has no such name to check, so it lists cockpit-labelled volumes alone.
272
+ function runtimeCommand(kind, selection, owner) {
273
+ if (kind === 'build-cache')
274
+ return ['--host', selection.endpoint, 'builder', 'du', '--format', 'json'];
275
+ const owned = ['--filter', owner === null ? `label=${REQUIRED_LABELS.stack}` : `label=${REQUIRED_LABELS.stack}=${owner.stackId}`];
276
+ const command = kind === 'container' ? ['container', 'ls', '--all', '--no-trunc', ...owned]
277
+ : kind === 'volume' ? ['volume', 'ls', ...owner === null ? owned : []]
278
+ : kind === 'image' ? ['image', 'ls', '--all', '--no-trunc', ...owned] : [kind, 'ls', '--no-trunc', ...owned];
279
+ return ['--host', selection.endpoint, ...command, '--format', discoveryFormat(kind, owner)];
280
+ }
281
+ function discoveryFormat(kind, owner) {
282
+ const id = kind === 'volume' ? '{{json .Name}}' : '{{json .ID}}';
283
+ const name = kind === 'container' ? '{{json .Names}}' : kind === 'image' ? '{{json .Repository}}' : '{{json .Name}}';
284
+ const label = (key) => `{{json (.Label "${key}")}}`;
285
+ const labels = kind === 'image' ? '{}' : `{${(owner === null ? MACHINE_LABELS : listedLabels(kind)).map((key) => `"${key}":${label(key)}`).join(',')}}`;
286
+ // The machine-wide listing attributes each resource by its labels alone and reports whether a container runs.
287
+ if (owner === null)
288
+ return `{"id":${id},"name":${name},"state":${kind === 'container' ? '{{json .State}}' : '""'},"labels":${labels}}`;
289
+ // Without `--size` a container prints a misleading "0B (virtual ...)", so its size is unmeasured.
290
+ const bytes = kind === 'network' || kind === 'container' ? 'null' : '{{json .Size}}';
291
+ return `{"id":${id},"name":${name},"labels":${labels},"referenced":false,"bytes":${bytes}}`;
292
+ }
293
+ function listedLabels(kind) {
294
+ return [...Object.values(REQUIRED_LABELS), ...kind === 'volume' ? Object.values(COMPOSE_LABELS) : []];
295
+ }
296
+ /** The labels Docker printed, refused when any is not `allowed` or its value is too long or holds a NUL; empty values are dropped. */
297
+ function dockerLabels(value, allowed, maxLength = 512) {
298
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
299
+ return undefined;
300
+ const labels = {};
301
+ if (Object.keys(value).some((key) => !allowed.includes(key)))
302
+ return undefined;
303
+ for (const [key, labelValue] of Object.entries(value)) {
304
+ if (typeof labelValue !== 'string' || labelValue.length > maxLength || labelValue.includes('\0'))
305
+ return undefined;
306
+ if (labelValue !== '')
307
+ labels[key] = labelValue;
308
+ }
309
+ return labels;
310
+ }
311
+ function dockerBytes(value, allowVirtualSize = false) {
312
+ if (value === undefined || value === null || value === '' || value === 'N/A')
313
+ return null;
314
+ if (Number.isSafeInteger(value) && value >= 0)
315
+ return value;
316
+ if (typeof value !== 'string')
317
+ return undefined;
318
+ if (/^\d+$/.test(value)) {
319
+ const bytes = Number(value);
320
+ return Number.isSafeInteger(bytes) ? bytes : undefined;
321
+ }
322
+ const match = /^(\d+(?:\.\d+)?)\s*(B|kB|MB|GB|TB|KiB|MiB|GiB|TiB)(?: \(virtual \d+(?:\.\d+)?\s*(?:B|kB|MB|GB|TB|KiB|MiB|GiB|TiB)\))?$/.exec(value);
323
+ if (!allowVirtualSize && value.includes('(virtual '))
324
+ return undefined;
325
+ if (!match)
326
+ return undefined;
327
+ const powers = { B: 1, kB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3, TiB: 1024 ** 4 };
328
+ // Docker prints rounded decimal sizes ("1.029GB"), and scaling the decimal is inexact in floating
329
+ // point (1028999999.9999999), so round to whole bytes; the printed value is already approximate.
330
+ const bytes = Math.round(Number(match[1]) * powers[match[2]]);
331
+ return Number.isSafeInteger(bytes) ? bytes : undefined;
332
+ }
333
+ function dockerResource(value, kind) {
334
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
335
+ return undefined;
336
+ const row = value;
337
+ if (kind === 'build-cache') {
338
+ const id = row.ID;
339
+ const reclaimable = row.Reclaimable;
340
+ const bytes = dockerBytes(row.Size);
341
+ return typeof id === 'string' && (reclaimable === true || reclaimable === false || reclaimable === 'true' || reclaimable === 'false')
342
+ && bytes !== undefined
343
+ ? snapshotResource({ id, name: id, kind, labels: {}, referenced: reclaimable === false || reclaimable === 'false', bytes })
344
+ : undefined;
345
+ }
346
+ if (!exactKeys(row, ['id', 'name', 'labels', 'referenced', 'bytes']))
347
+ return undefined;
348
+ const id = row.id;
349
+ const name = row.name;
350
+ const labels = kind === 'image'
351
+ ? row.labels !== null && typeof row.labels === 'object' && !Array.isArray(row.labels) && exactKeys(row.labels, []) ? {} : undefined
352
+ : dockerLabels(row.labels, listedLabels(kind));
353
+ const bytes = dockerBytes(row.bytes, kind === 'container');
354
+ const referenced = kind === 'image' ? typeof row.referenced === 'boolean' ? row.referenced : undefined : row.referenced;
355
+ return typeof id === 'string' && typeof name === 'string' && labels !== undefined && bytes !== undefined && typeof referenced === 'boolean'
356
+ ? snapshotResource({
357
+ id, name, kind, labels, referenced, bytes,
358
+ ...(kind === 'volume' ? { dataBearing: true } : {}),
359
+ })
360
+ : undefined;
361
+ }
362
+ function inspectionSteps(authority, type, format, ids, labels) {
363
+ const steps = [];
364
+ for (let start = 0; start < ids.length; start += INSPECTION_CHUNK) {
365
+ const chunk = ids.slice(start, start + INSPECTION_CHUNK);
366
+ steps.push(exactStep(`runtime:inspect:${labels.resourceKind}:${digestValue(chunk).slice(0, 16)}`, [authority.executable.canonicalPath, '--host', authority.selection.endpoint, type, 'inspect', '--format', format, ...chunk], authority.executable, authority.cwd, authority.env, 30_000, [], { ...labels, resourceCount: String(chunk.length) }, [], chunk.length * INSPECTION_OBJECT_BUDGET));
367
+ }
368
+ return steps;
369
+ }
370
+ function inspectedIds(step) {
371
+ return step.argv.slice(INSPECTION_ARGV_PREFIX);
372
+ }
373
+ /** One parsed row per inspected id, in argument order; any missing, extra or oversized row fails the chunk. */
374
+ function inspectionRows(stdout, ids) {
375
+ const text = stdout.toString('utf8');
376
+ if (!Buffer.from(text, 'utf8').equals(stdout))
377
+ return undefined;
378
+ const lines = text.endsWith('\n') ? text.slice(0, -1).split('\n') : text.split('\n');
379
+ if (lines.length !== ids.length)
380
+ return undefined;
381
+ const rows = [];
382
+ for (const line of lines) {
383
+ if (Buffer.byteLength(line) > INSPECTION_OBJECT_BUDGET)
384
+ return undefined;
385
+ // Unparseable docker output is unproven; the caller refuses the discovery.
386
+ const row = tryParseJson(line);
387
+ if (row === null || typeof row !== 'object' || Array.isArray(row))
388
+ return undefined;
389
+ rows.push(row);
390
+ }
391
+ return rows;
392
+ }
393
+ /**
394
+ * Name an inspection chunk that the per-object budget cannot hold, so the operator learns which
395
+ * resources to look at instead of a generic unobservable precondition.
396
+ */
397
+ export function inspectionOverflow(step, outcome) {
398
+ if (!step.id.startsWith('runtime:inspect:'))
399
+ return undefined;
400
+ const ids = inspectedIds(step);
401
+ const oversized = outcome.kind === 'refusal' && outcome.code === ENVIRONMENT_CODES.EXECUTION_STDOUT_LIMIT
402
+ || outcome.kind === 'complete' && outcome.stdout.toString('utf8').split('\n').some((line) => Buffer.byteLength(line) > INSPECTION_OBJECT_BUDGET);
403
+ if (!oversized)
404
+ return undefined;
405
+ return `docker ${step.argv[3]} inspect printed more than ${INSPECTION_OBJECT_BUDGET} bytes for one of ${ids.length} resources `
406
+ + `(${ids.slice(0, 4).join(', ')}${ids.length > 4 ? ', ...' : ''}); inspect them and remove or relabel the oversized one`;
407
+ }
408
+ function imageInspectionFormat() {
409
+ const label = (key) => `{{json (or (index . "${key}") "")}}`;
410
+ return `{"id":{{json .Id}},"labels":{{with (index .Config "Labels")}}{"${REQUIRED_LABELS.stack}":${label(REQUIRED_LABELS.stack)},"${REQUIRED_LABELS.config}":${label(REQUIRED_LABELS.config)},"${REQUIRED_LABELS.owner}":${label(REQUIRED_LABELS.owner)}}{{else}}{}{{end}}}`;
411
+ }
412
+ // Members are the attached endpoint ids, so a network can be told apart from the containers that
413
+ // hold it; container ids are hex, so the space-separated list is a safe JSON string.
414
+ function networkInspectionFormat() {
415
+ return '{"id":{{json .Id}},"members":"{{range $member, $endpoint := .Containers}}{{$member}} {{end}}"}';
416
+ }
417
+ // A container that carries the network's exact ownership triple belongs to the same stack, so the
418
+ // teardown that may remove the network removes that container first (containers sort before
419
+ // networks). Counting such containers as references left every stack's own network behind after
420
+ // `down`. Any other holder, or one discovery did not see, still keeps the network.
421
+ function sameStackHolder(network, container) {
422
+ return container !== undefined && Object.values(REQUIRED_LABELS).every((name) => typeof network.labels[name] === 'string' && network.labels[name] === container.labels[name]);
423
+ }
424
+ // Discovery lists only this stack's containers, so a foreign container that holds one of its
425
+ // networks is found by asking Docker for every container attached to that network by name. The
426
+ // name, not the id, is the filter because a created but never started container records its
427
+ // networks without an id, and the filter by id misses it.
428
+ function networkHolderFormat() {
429
+ return '{"id":{{json .ID}},"networks":{{json .Networks}}}';
430
+ }
431
+ const NETWORK_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
432
+ function networkHolderSteps(authority, names, labels) {
433
+ const steps = [];
434
+ for (let start = 0; start < names.length; start += INSPECTION_CHUNK) {
435
+ const chunk = names.slice(start, start + INSPECTION_CHUNK);
436
+ steps.push(exactStep(`runtime:network-holders:${digestValue(chunk).slice(0, 16)}`, [authority.executable.canonicalPath, '--host', authority.selection.endpoint, 'container', 'ls', '--all', '--no-trunc',
437
+ ...chunk.flatMap((name) => ['--filter', `network=${name}`]), '--format', networkHolderFormat()], authority.executable, authority.cwd, authority.env, 30_000, [], { ...labels, networkCount: String(chunk.length) }, [], OUTPUT_LIMIT));
438
+ }
439
+ return steps;
440
+ }
441
+ function holderRows(stdout) {
442
+ const text = stdout.toString('utf8');
443
+ if (!Buffer.from(text, 'utf8').equals(stdout))
444
+ return undefined;
445
+ const rows = [];
446
+ for (const line of text.split('\n')) {
447
+ if (line.length === 0)
448
+ continue;
449
+ // Unparseable docker output is unproven; the caller refuses the discovery.
450
+ const row = tryParseJson(line);
451
+ if (row === null || typeof row !== 'object' || Array.isArray(row) || !exactKeys(row, ['id', 'networks']))
452
+ return undefined;
453
+ const { id, networks } = row;
454
+ if (typeof id !== 'string' || !RESOURCE_ID.test(id) || typeof networks !== 'string')
455
+ return undefined;
456
+ const names = networks.split(',').filter(Boolean);
457
+ if (names.length > MAX_RESOURCES || names.some((name) => !NETWORK_NAME.test(name)))
458
+ return undefined;
459
+ rows.push({ id, networks: names });
460
+ }
461
+ return rows.length > MAX_RESOURCES || new Set(rows.map(({ id }) => id)).size !== rows.length ? undefined : rows;
462
+ }
463
+ /** Complete, empty-stderr output of one step, or nothing. */
464
+ function cleanOutput(outcome) {
465
+ return outcome.kind === 'complete' && outcome.stderr.byteLength === 0 ? outcome : undefined;
466
+ }
467
+ function utf8(bytes) {
468
+ const text = bytes.toString('utf8');
469
+ return Buffer.from(text, 'utf8').equals(bytes) ? text : undefined;
470
+ }
471
+ function boundedExecutor(execute, concurrency) {
472
+ let active = 0;
473
+ const waiters = [];
474
+ return async (step) => {
475
+ if (active < concurrency)
476
+ active += 1;
477
+ else
478
+ await new Promise(resolveWaiter => waiters.push(resolveWaiter));
479
+ try {
480
+ return await execute(step);
481
+ }
482
+ finally {
483
+ const next = waiters.shift();
484
+ if (next)
485
+ next();
486
+ else
487
+ active -= 1;
488
+ }
489
+ };
490
+ }
491
+ async function selectDocker(input, executable, env, execute) {
492
+ // Only the selection reads DOCKER_HOST; discovery and actions address the admitted endpoint
493
+ // with --host, so every other step keeps a daemon-free environment.
494
+ const selectionEnv = input.host === undefined ? env : { ...env, DOCKER_HOST: input.host };
495
+ const step = exactStep('runtime:select:context', [executable.canonicalPath, 'context', 'inspect', input.context, '--format', contextSelectionFormat], executable, input.cwd, selectionEnv, input.timeoutMs, [], { selectionKind: 'context' });
496
+ if (step === undefined)
497
+ return undefined;
498
+ const outcome = cleanOutput(await execute(step));
499
+ const row = outcome === undefined ? undefined : selectionRow(outcome.stdout);
500
+ if (outcome === undefined || row === undefined || !exactKeys(row, ['context', 'endpoint'])
501
+ || row.context !== input.context
502
+ || typeof row.endpoint !== 'string' || admitLocalDockerEndpoint(row.endpoint) === undefined
503
+ || input.host !== undefined && row.endpoint !== input.host)
504
+ return undefined;
505
+ return { value: { context: input.context, endpoint: row.endpoint }, startedAt: outcome.receipt.startedAt, finishedAt: outcome.receipt.finishedAt };
506
+ }
507
+ /** One stack's listed resources of one kind, or nothing when any row cannot be read. */
508
+ function stackRows(text, kind) {
509
+ const resources = [];
510
+ for (const line of text.split('\n')) {
511
+ if (line.length === 0)
512
+ continue;
513
+ // Unparseable docker output is unproven; the discovery fails closed.
514
+ const parsed = tryParseJson(line);
515
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
516
+ return undefined;
517
+ const owned = dockerResource(parsed, kind);
518
+ if (owned === undefined)
519
+ return undefined;
520
+ // An image prints one row per tag. The one tag Compose gives an image it builds is this
521
+ // stack's own name for it, so that image is removable; a second tag means some other name
522
+ // was given to it, and that name may still be needed. Treating any tag as a reference
523
+ // kept every image the stack built. A container still using an image keeps it by
524
+ // Docker's own refusal to remove it.
525
+ const duplicate = resources.find(({ id }) => id === owned.id);
526
+ if (duplicate !== undefined && kind === 'image') {
527
+ if (duplicate.bytes !== owned.bytes)
528
+ return undefined;
529
+ resources.splice(resources.indexOf(duplicate), 1, { ...duplicate, referenced: true, name: [duplicate.name, owned.name].sort(lexical)[0] });
530
+ continue;
531
+ }
532
+ resources.push(owned);
533
+ }
534
+ if (resources.length > MAX_RESOURCES || new Set(resources.map(({ id }) => id)).size !== resources.length)
535
+ return undefined;
536
+ return resources.sort((a, b) => lexical(a.id, b.id));
537
+ }
538
+ /**
539
+ * Lists every kind at once on the context's daemon, reading the selection before and after so the
540
+ * listings describe one daemon. A step that fails, writes to stderr or prints a row `parse` rejects
541
+ * fails the whole listing closed.
542
+ */
543
+ async function listOnSelectedDaemon(input, executable, env, execute, owner, kinds, parse) {
544
+ const planned = await selectDocker(input, executable, env, execute);
545
+ if (planned === undefined)
546
+ return undefined;
547
+ const selection = planned.value;
548
+ const listed = await Promise.all(kinds.map(async (kind) => {
549
+ const step = exactStep(`${owner === null ? 'stacks' : 'runtime'}:discover:${kind}`, [executable.canonicalPath, ...runtimeCommand(kind, selection, owner)], executable, input.cwd, env, input.timeoutMs, [], {
550
+ ...owner === null ? {} : { stackId: owner.stackId, cockpitId: owner.cockpitId },
551
+ resourceKind: kind, dockerContext: selection.context, dockerEndpoint: selection.endpoint,
552
+ }, [], OUTPUT_LIMIT);
553
+ const outcome = step === undefined ? undefined : cleanOutput(await execute(step));
554
+ const text = outcome === undefined ? undefined : utf8(outcome.stdout);
555
+ const rows = text === undefined ? undefined : parse(text, kind);
556
+ return rows === undefined ? undefined : { value: rows, startedAt: outcome.receipt.startedAt, finishedAt: outcome.receipt.finishedAt };
557
+ }));
558
+ if (listed.some((value) => value === undefined))
559
+ return undefined;
560
+ const lists = listed;
561
+ const listedAt = lists.map(({ finishedAt }) => finishedAt).sort(lexical).at(-1);
562
+ const reselected = await selectDocker(input, executable, env, execute);
563
+ if (reselected === undefined || !sameSelection(reselected.value, selection)
564
+ || Date.parse(reselected.startedAt) < Date.parse(listedAt))
565
+ return undefined;
566
+ return { selection, lists, listedAt, reselected };
567
+ }
568
+ /** Runs one kind's inspection steps; an oversized object names its chunk instead of failing anonymously. */
569
+ async function runInspections(steps, kind, execute) {
570
+ if (steps.some((step) => step === undefined))
571
+ return undefined;
572
+ const attempts = await Promise.allSettled(steps.map(async (step) => ({ step, outcome: await execute(step) })));
573
+ if (attempts.some(({ status }) => status === 'rejected'))
574
+ return undefined;
575
+ const evidence = attempts.map((attempt) => attempt.value);
576
+ const overflow = evidence.map(({ step, outcome }) => inspectionOverflow(step, outcome)).find((message) => message !== undefined);
577
+ if (overflow !== undefined)
578
+ return refusal(ENVIRONMENT_CODES.EXECUTION_STDOUT_LIMIT, overflow, kind);
579
+ const clean = evidence.map(({ step, outcome }) => ({ step, outcome: cleanOutput(outcome) }));
580
+ return clean.every(({ outcome }) => outcome !== undefined)
581
+ ? clean : undefined;
582
+ }
583
+ async function inspectImages(runtime, images, execute) {
584
+ const evidence = await runInspections(inspectionSteps(runtime, 'image', imageInspectionFormat(), images.map(({ id }) => id), {
585
+ resourceKind: 'image-inspection', dockerContext: runtime.selection.context, dockerEndpoint: runtime.selection.endpoint,
586
+ }), 'image', execute);
587
+ if (evidence === undefined || 'diagnostics' in evidence)
588
+ return evidence;
589
+ const labels = new Map();
590
+ for (const { step, outcome } of evidence) {
591
+ const ids = inspectedIds(step);
592
+ const rows = inspectionRows(outcome.stdout, ids);
593
+ if (rows === undefined)
594
+ return undefined;
595
+ for (const [position, record] of rows.entries()) {
596
+ const resourceId = ids[position];
597
+ const observedLabels = exactKeys(record, ['id', 'labels']) ? dockerLabels(record.labels, listedLabels()) : undefined;
598
+ if (record.id !== resourceId || observedLabels === undefined || labels.has(resourceId))
599
+ return undefined;
600
+ labels.set(resourceId, observedLabels);
601
+ }
602
+ }
603
+ return { value: images.map((resource) => snapshotResource({ ...resource, labels: labels.get(resource.id) ?? resource.labels })), finishedAt: latest(evidence) };
604
+ }
605
+ async function inspectVolumes(runtime, volumes, execute) {
606
+ // Only a volume this stack labels can become a target, so only its generation is read.
607
+ const owned = volumes.filter(({ labels }) => labels[REQUIRED_LABELS.stack] === runtime.stackId);
608
+ const evidence = await runInspections(inspectionSteps(runtime, 'volume', `{"name":{{json .Name}},"generation":{{with .Labels}}{{json (or (index . "dev.b0dea.cockpit.generation-id") "")}}{{else}}""{{end}}}`, owned.map(({ id }) => id), { resourceKind: 'volume-inspection' }), 'volume', execute);
609
+ if (evidence === undefined || 'diagnostics' in evidence)
610
+ return evidence;
611
+ const generations = new Map();
612
+ for (const { step, outcome } of evidence) {
613
+ const ids = inspectedIds(step);
614
+ const rows = inspectionRows(outcome.stdout, ids);
615
+ if (rows === undefined)
616
+ return undefined;
617
+ for (const [position, record] of rows.entries()) {
618
+ if (!exactKeys(record, ['name', 'generation']))
619
+ return undefined;
620
+ const { name, generation } = record;
621
+ if (name !== ids[position] || typeof generation !== 'string'
622
+ || generation.length > 128 || generation.includes('\0') || generations.has(name))
623
+ return undefined;
624
+ if (generation !== '' && !SHA256.test(generation))
625
+ return undefined;
626
+ generations.set(name, generation);
627
+ }
628
+ }
629
+ return {
630
+ value: volumes.map((resource) => {
631
+ const generation = generations.get(resource.id);
632
+ return snapshotResource({ ...resource, ...(generation === undefined || generation === '' ? {} : { generation }) });
633
+ }),
634
+ finishedAt: latest(evidence),
635
+ };
636
+ }
637
+ async function inspectNetworks(runtime, networks, containers, execute) {
638
+ const selection = { dockerContext: runtime.selection.context, dockerEndpoint: runtime.selection.endpoint };
639
+ const names = [...new Set(networks.map(({ name }) => name))].sort(lexical);
640
+ if (names.some((name) => !NETWORK_NAME.test(name)))
641
+ return undefined;
642
+ const evidence = await runInspections([
643
+ ...inspectionSteps(runtime, 'network', networkInspectionFormat(), networks.map(({ id }) => id), { resourceKind: 'network-inspection', ...selection }),
644
+ ...networkHolderSteps(runtime, names, { resourceKind: 'network-holder-listing', ...selection }),
645
+ ], 'network', execute);
646
+ if (evidence === undefined || 'diagnostics' in evidence)
647
+ return evidence;
648
+ const containersById = new Map(containers.map((container) => [container.id, container]));
649
+ const holders = new Map();
650
+ const inspectedNetworks = new Set();
651
+ const hold = (networkId, containerId) => {
652
+ holders.set(networkId, (holders.get(networkId) ?? new Set()).add(containerId));
653
+ };
654
+ for (const { step, outcome } of evidence) {
655
+ if (step.labels.resourceKind === 'network-inspection') {
656
+ const ids = inspectedIds(step);
657
+ const rows = inspectionRows(outcome.stdout, ids);
658
+ if (rows === undefined)
659
+ return undefined;
660
+ for (const [position, record] of rows.entries()) {
661
+ const resourceId = ids[position];
662
+ if (!exactKeys(record, ['id', 'members']) || record.id !== resourceId
663
+ || typeof record.members !== 'string' || inspectedNetworks.has(resourceId))
664
+ return undefined;
665
+ const members = record.members.split(' ').filter(Boolean);
666
+ if (members.length > MAX_RESOURCES || members.some((member) => !RESOURCE_ID.test(member)))
667
+ return undefined;
668
+ inspectedNetworks.add(resourceId);
669
+ for (const member of members)
670
+ hold(resourceId, member);
671
+ }
672
+ }
673
+ else {
674
+ const rows = holderRows(outcome.stdout);
675
+ if (rows === undefined)
676
+ return undefined;
677
+ for (const row of rows) {
678
+ // A created but never started container (as an interrupted Compose recreate leaves
679
+ // its replacement) still names its networks, so it holds every discovered network
680
+ // of that name; refusing the whole discovery left `down` unable to remove the
681
+ // stack that `up` had started.
682
+ for (const name of row.networks) {
683
+ for (const network of networks)
684
+ if (network.name === name)
685
+ hold(network.id, row.id);
686
+ }
687
+ }
688
+ }
689
+ }
690
+ return {
691
+ value: networks.map((resource) => snapshotResource({
692
+ ...resource, referenced: [...holders.get(resource.id) ?? []].some((holder) => !sameStackHolder(resource, containersById.get(holder))),
693
+ })),
694
+ finishedAt: latest(evidence),
695
+ };
696
+ }
697
+ function latest(evidence) {
698
+ return evidence.map(({ outcome }) => outcome.receipt.finishedAt).sort(lexical).at(-1);
699
+ }
700
+ function sameSelection(left, right) {
701
+ return left.context === right.context && left.endpoint === right.endpoint;
702
+ }
703
+ async function discoverReclaimRound(input, executable, env, execute) {
704
+ // The selection is read before and after the listings, and again after the inspections, so the
705
+ // context's endpoint cannot have moved to another daemon while this stack was being read.
706
+ const listed = await listOnSelectedDaemon(input, executable, env, execute, { stackId: input.stackId, cockpitId: input.cockpitId }, DISCOVERY_KINDS, stackRows);
707
+ if (listed === undefined || Date.parse(listed.reselected.finishedAt) - Date.parse(listed.listedAt) > 30_000)
708
+ return undefined;
709
+ const { lists, listedAt, selection } = listed;
710
+ const runtime = {
711
+ executable, cwd: input.cwd, env, timeoutMs: input.timeoutMs,
712
+ stackId: input.stackId, cockpitId: input.cockpitId, selection,
713
+ };
714
+ const [containers, networks, volumes, images, buildCache] = lists.map(({ value }) => value);
715
+ const inspect = boundedExecutor(execute, MAX_CONCURRENT_INSPECTIONS);
716
+ const inspected = await Promise.all([
717
+ inspectNetworks(runtime, networks, containers, inspect),
718
+ inspectVolumes(runtime, volumes, inspect),
719
+ inspectImages(runtime, images, inspect),
720
+ ]);
721
+ const refused = inspected.find((value) => value !== undefined && 'diagnostics' in value);
722
+ if (refused)
723
+ return refused;
724
+ if (inspected.some((value) => value === undefined))
725
+ return undefined;
726
+ const [inspectedNetworks, inspectedVolumes, inspectedImages] = inspected;
727
+ const resources = [...containers, ...inspectedNetworks.value, ...inspectedVolumes.value, ...inspectedImages.value, ...buildCache]
728
+ .sort((left, right) => lexical(left.id, right.id));
729
+ if (resources.length > MAX_RESOURCES || new Set(resources.map(({ id }) => id)).size !== resources.length)
730
+ return undefined;
731
+ const startedAt = lists.map(({ startedAt: listStarted }) => listStarted).sort(lexical)[0];
732
+ const finishedAt = [listedAt, ...inspected.map((value) => value.finishedAt)]
733
+ .filter((value) => value !== undefined).sort(lexical).at(-1);
734
+ if (Date.parse(finishedAt) - Date.parse(startedAt) > 30_000)
735
+ return undefined;
736
+ const confirmed = await selectDocker(input, executable, env, execute);
737
+ if (confirmed === undefined || !sameSelection(confirmed.value, selection)
738
+ || Date.parse(confirmed.startedAt) < Date.parse(finishedAt))
739
+ return undefined;
740
+ return { observedAt: finishedAt, producer: 'runtime-resource-discovery', resources, startedAt, stackId: input.stackId, selection };
741
+ }
742
+ /**
743
+ * Lists and inspects this stack's runtime resources on the selected Docker context. Discovery
744
+ * lists resources and then inspects what it listed, and a listed resource can vanish in between,
745
+ * as Compose replacing one of this stack's containers does; the round then fails closed on an id
746
+ * that no longer exists, and inside a reserved drain that left the attempt for repair. One more
747
+ * complete round covers that race without admitting partial evidence.
748
+ */
749
+ export async function discoverReclaim(input, execute) {
750
+ const executable = resolvedDockerExecutable(input.executable);
751
+ const env = safeEnvironment(input.env);
752
+ if (executable === undefined || env === undefined || !DOCKER_SELECTOR.test(input.context)
753
+ || input.host !== undefined && (admitLocalDockerEndpoint(input.host) === undefined || input.context !== 'default')
754
+ || !STACK_ID.test(input.stackId) || !COCKPIT_ID.test(input.cockpitId)
755
+ || !validExecutionInput(executable, input.cwd, input.env, input.timeoutMs))
756
+ return undefined;
757
+ return await discoverReclaimRound(input, executable, env, execute)
758
+ ?? await discoverReclaimRound(input, executable, env, execute);
759
+ }
760
+ const MACHINE_KINDS = ['container', 'network', 'volume'];
761
+ const COMPOSE_WORKING_DIR = 'com.docker.compose.project.working_dir';
762
+ const MACHINE_LABELS = [...Object.values(REQUIRED_LABELS), COMPOSE_LABELS.project, COMPOSE_WORKING_DIR];
763
+ const RUNNING_STATES = new Set(['running', 'restarting', 'paused']);
764
+ // Each removal names its resources in argv, which a step bounds; a chunk also keeps the ids Docker
765
+ // echoes on stdout within the step's default output cap.
766
+ const REMOVAL_CHUNK = 128;
767
+ function machineRows(text, kind) {
768
+ const resources = [];
769
+ let unattributed = 0;
770
+ for (const line of text.split('\n')) {
771
+ if (line.length === 0)
772
+ continue;
773
+ // Unparseable docker output is unproven; the discovery fails closed.
774
+ const row = tryParseJson(line);
775
+ if (row === null || typeof row !== 'object' || Array.isArray(row) || !exactKeys(row, ['id', 'name', 'state', 'labels']))
776
+ return undefined;
777
+ const { id, name, state, labels: listed } = row;
778
+ // A working directory is a path, so its label may run far past a stack's own label bound.
779
+ const labels = listed !== null && typeof listed === 'object' && exactKeys(listed, MACHINE_LABELS) ? dockerLabels(listed, MACHINE_LABELS, 4_096) : undefined;
780
+ if (typeof id !== 'string' || !RESOURCE_ID.test(id) || typeof name !== 'string' || name.length === 0 || name.length > 512
781
+ || typeof state !== 'string' || state.length > 64 || labels === undefined)
782
+ return undefined;
783
+ const stack = labels[REQUIRED_LABELS.stack] ?? '';
784
+ const owner = labels[REQUIRED_LABELS.owner] ?? '';
785
+ if (!STACK_ID.test(stack) || !COCKPIT_ID.test(owner) || !SHA256.test(labels[REQUIRED_LABELS.config] ?? '')) {
786
+ unattributed += 1;
787
+ continue;
788
+ }
789
+ const workingDir = labels[COMPOSE_WORKING_DIR];
790
+ resources.push({
791
+ kind, id, name, cockpitId: owner, stackId: stack,
792
+ running: kind === 'container' && RUNNING_STATES.has(state),
793
+ composeProject: labels[COMPOSE_LABELS.project] ?? null,
794
+ workingDir: kind === 'container' && workingDir !== undefined && isAbsolute(workingDir) ? normalize(workingDir) : null,
795
+ });
796
+ }
797
+ return { resources, unattributed };
798
+ }
799
+ /**
800
+ * Lists every container, network and volume on the selected daemon that carries cockpit's stack
801
+ * label, whichever cockpit or checkout started it, for the machine-wide stack inventory. Ownership
802
+ * comes from the labels alone; nothing is attributed by its name. The selection is read before and
803
+ * after the listings, as `discoverReclaim` does, so the listings describe one daemon.
804
+ */
805
+ export async function discoverMachineStacks(input, execute) {
806
+ const executable = resolvedDockerExecutable(input.executable);
807
+ const env = safeEnvironment(input.env);
808
+ if (executable === undefined || env === undefined || !DOCKER_SELECTOR.test(input.context)
809
+ || input.host !== undefined && (admitLocalDockerEndpoint(input.host) === undefined || input.context !== 'default')
810
+ || !validExecutionInput(executable, input.cwd, input.env, input.timeoutMs))
811
+ return undefined;
812
+ const listed = await listOnSelectedDaemon(input, executable, env, execute, null, MACHINE_KINDS, machineRows);
813
+ if (listed === undefined)
814
+ return undefined;
815
+ const resources = listed.lists.flatMap(({ value }) => value.resources);
816
+ if (resources.length > MAX_RESOURCES)
817
+ return undefined;
818
+ return {
819
+ selection: listed.selection, observedAt: listed.listedAt,
820
+ resources: resources.sort((left, right) => lexical(`${left.kind}:${left.id}`, `${right.kind}:${right.id}`)),
821
+ unattributed: listed.lists.reduce((sum, { value }) => sum + value.unattributed, 0),
822
+ };
823
+ }
824
+ /**
825
+ * The Docker removals prune runs for one kind of one orphaned stack's resources, as the machine
826
+ * discovery attributed them: one client per chunk rather than one per resource. Undefined when the
827
+ * resources are not all of one kind and one stack, or a step cannot be built.
828
+ */
829
+ export function machineStackRemovalSteps(input, selection, resources) {
830
+ const executable = resolvedDockerExecutable(input.executable);
831
+ const env = safeEnvironment(input.env);
832
+ const [first] = resources;
833
+ if (first === undefined || executable === undefined || env === undefined || admitLocalDockerEndpoint(selection.endpoint) === undefined
834
+ || resources.some(({ kind, stackId, cockpitId, id }) => kind !== first.kind || stackId !== first.stackId || cockpitId !== first.cockpitId || !RESOURCE_ID.test(id))
835
+ || !validExecutionInput(executable, input.cwd, input.env, input.timeoutMs))
836
+ return undefined;
837
+ const command = first.kind === 'container' ? ['container', 'rm', '--force'] : [first.kind, 'rm'];
838
+ const removals = [];
839
+ for (let start = 0; start < resources.length; start += REMOVAL_CHUNK) {
840
+ const chunk = resources.slice(start, start + REMOVAL_CHUNK);
841
+ const ids = chunk.map(({ id }) => id);
842
+ const step = exactStep(`stacks:prune:${first.kind}:${digestValue(ids).slice(0, 16)}`, [executable.canonicalPath, '--host', selection.endpoint, ...command, '--', ...ids], executable, input.cwd, env, input.timeoutMs, [], { stackId: first.stackId, cockpitId: first.cockpitId, resourceKind: first.kind, resourceCount: String(ids.length) });
843
+ if (step === undefined)
844
+ return undefined;
845
+ removals.push({ step, resources: chunk });
846
+ }
847
+ return removals;
848
+ }
849
+ function refusal(code, message, target, fix) {
850
+ return { kind: 'refusal', diagnostics: [diagnostic(code, message, target, fix)] };
851
+ }
852
+ function canonicalValue(value) {
853
+ if (Array.isArray(value))
854
+ return value.map(canonicalValue);
855
+ if (value !== null && typeof value === 'object') {
856
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => lexical(left, right))
857
+ .map(([key, item]) => [key, canonicalValue(item)]));
858
+ }
859
+ return value;
860
+ }
861
+ function digestValue(value) {
862
+ return createHash('sha256').update(JSON.stringify(canonicalValue(value))).digest('hex');
863
+ }
864
+ function validExecutionInput(executable, cwd, env, timeoutMs) {
865
+ const safeEnv = safeEnvironment(env);
866
+ return resolvedExecutable(executable) !== undefined
867
+ && typeof cwd === 'string' && isAbsolute(cwd) && normalize(cwd) === cwd && !cwd.includes('\0')
868
+ && safeEnv !== undefined
869
+ && Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 30_000;
870
+ }
871
+ async function removalExecutable(value) {
872
+ const executable = resolvedExecutable(value);
873
+ if (executable === undefined || executable.invocationPath !== '/bin/rm' || executable.executionMode !== 'verified-path')
874
+ return undefined;
875
+ const canonical = await realpath('/bin/rm');
876
+ const bytes = await readFile(canonical);
877
+ return canonical === executable.canonicalPath
878
+ && createHash('sha256').update(bytes).digest('hex') === executable.sha256 ? executable : undefined;
879
+ }
880
+ // A discovery lists only one stack's resources, so a discovery of another stack would make this
881
+ // one look empty: every planned target absent and every removal confirmed. It must name this stack.
882
+ function discoveryOfStack(discovery, stackId) {
883
+ return discovery.stackId === stackId;
884
+ }
885
+ // The plan acts only on a discovery read after the approved one, so what is removed is what the
886
+ // approved digest names in the daemon's current state, not a replay of the state that was shown.
887
+ function followsConsent(consent, digest, discovery) {
888
+ return consent !== undefined && consent.digest === digest
889
+ && Date.parse(discovery.startedAt) > Date.parse(consent.observedAt);
890
+ }
891
+ // After a removal, the discovery that reconciled it may plan the next one. After a removal that
892
+ // never started, nothing proved the state since, so the discovery must follow the published failure.
893
+ function followsContinuation(discovery, continuation, failed, record) {
894
+ return failed ? Date.parse(discovery.startedAt) > Date.parse(record.updatedAt)
895
+ : Date.parse(discovery.startedAt) >= Date.parse(continuation.freshAfter);
896
+ }
897
+ function exactStep(id, argv, executable, cwd, env, timeoutMs, paths, labels, filesystemChecks = [], stdoutLimitBytes = 65_536) {
898
+ return snapshotExecutionStep({
899
+ id,
900
+ argv,
901
+ argv0: executable.invocationPath,
902
+ cwd,
903
+ env,
904
+ timeoutMs,
905
+ stdoutLimitBytes,
906
+ stderrLimitBytes: 65_536,
907
+ paths,
908
+ ports: [],
909
+ labels,
910
+ expected: [],
911
+ consumedFiles: [{
912
+ path: executable.canonicalPath,
913
+ sha256: executable.sha256,
914
+ maxBytes: 536_870_912,
915
+ argvIndexes: [0],
916
+ executable: true,
917
+ executionMode: executable.executionMode,
918
+ }],
919
+ filesystemChecks,
920
+ compensation: [],
921
+ });
922
+ }
923
+ async function inspectHostPath(declaration) {
924
+ if (!declaration || typeof declaration.root !== 'string' || typeof declaration.path !== 'string'
925
+ || typeof declaration.declaration !== 'string' || declaration.declaration.length === 0 || declaration.declaration.includes('\0')
926
+ || !isAbsolute(declaration.root) || normalize(declaration.root) !== declaration.root
927
+ || !isAbsolute(declaration.path) || normalize(declaration.path) !== declaration.path)
928
+ return undefined;
929
+ const relation = relative(declaration.root, declaration.path);
930
+ if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`) || isAbsolute(relation))
931
+ return undefined;
932
+ try {
933
+ const rootStatus = await lstat(declaration.root, { bigint: true });
934
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink() || await realpath(declaration.root) !== declaration.root)
935
+ return undefined;
936
+ const ancestors = [{
937
+ path: declaration.root, ...filesystemObjectIdentity(rootStatus),
938
+ }];
939
+ let cursor = declaration.root;
940
+ const parts = relation.split(sep);
941
+ for (let index = 0; index < parts.length; index += 1) {
942
+ cursor = resolve(cursor, parts[index]);
943
+ try {
944
+ const status = await lstat(cursor, { bigint: true });
945
+ if (status.isSymbolicLink())
946
+ return undefined;
947
+ if (index < parts.length - 1 && !status.isDirectory())
948
+ return undefined;
949
+ if (index === parts.length - 1) {
950
+ if (await realpath(cursor) !== cursor)
951
+ return undefined;
952
+ return {
953
+ state: 'present', type: status.isDirectory() ? 'directory' : status.isFile() ? 'file' : undefined,
954
+ ...filesystemObjectIdentity(status), mode: Number(status.mode),
955
+ size: Number(status.size), mtimeMs: Number(status.mtimeNs) / 1_000_000, ancestors,
956
+ };
957
+ }
958
+ ancestors.push({ path: cursor, ...filesystemObjectIdentity(status) });
959
+ }
960
+ catch (error) {
961
+ if (!hasErrorCode(error, 'ENOENT'))
962
+ return undefined;
963
+ return { state: 'absent' };
964
+ }
965
+ }
966
+ }
967
+ catch {
968
+ // A declared path that cannot be identified is refused by the caller as an unidentified target.
969
+ return undefined;
970
+ }
971
+ return undefined;
972
+ }
973
+ async function dependencyCacheAuthorities(declarations) {
974
+ if (declarations === undefined)
975
+ return [];
976
+ if (!Array.isArray(declarations) || declarations.length > MAX_RESOURCES)
977
+ return undefined;
978
+ const authorities = [];
979
+ for (const declaration of declarations) {
980
+ if (declaration === null || typeof declaration !== 'object' || !exactKeys(declaration, ['cacheId', 'root', 'path'])
981
+ || typeof declaration.cacheId !== 'string' || !SHA256.test(declaration.cacheId)
982
+ || typeof declaration.root !== 'string' || typeof declaration.path !== 'string'
983
+ || resolve(declaration.root, declaration.cacheId) !== declaration.path)
984
+ return undefined;
985
+ const evidence = await inspectHostPath({ ...declaration, declaration: declaration.cacheId });
986
+ if (evidence === undefined || evidence.state === 'present' && evidence.type !== 'directory')
987
+ return undefined;
988
+ authorities.push({ ...declaration, evidence });
989
+ }
990
+ if (new Set(authorities.map(({ cacheId }) => cacheId)).size !== authorities.length
991
+ || new Set(authorities.map(({ path }) => path)).size !== authorities.length)
992
+ return undefined;
993
+ return authorities.sort((left, right) => lexical(left.cacheId, right.cacheId));
994
+ }
995
+ function safeTarget(value) {
996
+ return {
997
+ kind: value.kind,
998
+ identity: value.identity,
999
+ declaration: value.declaration,
1000
+ ...(value.labels === undefined ? {} : { labels: { ...value.labels } }),
1001
+ };
1002
+ }
1003
+ function reclaimInput(input) {
1004
+ return {
1005
+ stackId: input.stackId,
1006
+ cockpitId: input.cockpitId,
1007
+ configSha256: input.configSha256,
1008
+ coordinationRecord: input.coordinationRecord,
1009
+ declaredCacheIds: input.declaredCacheIds,
1010
+ discovery: input.discovery,
1011
+ };
1012
+ }
1013
+ async function resetAuthorities(input) {
1014
+ if (!Array.isArray(input.resettableVolumes) || !Array.isArray(input.generatedHostPaths)
1015
+ || input.resettableVolumes.length + input.generatedHostPaths.length > MAX_RESOURCES
1016
+ || input.resettableVolumes.some((name) => typeof name !== 'string' || !/^[a-z0-9][a-z0-9_.-]*$/.test(name))
1017
+ || new Set(input.resettableVolumes).size !== input.resettableVolumes.length
1018
+ || typeof input.composeProject !== 'string' || !COMPOSE_PROJECT.test(input.composeProject))
1019
+ return undefined;
1020
+ const classified = classifyReclaim(reclaimInput(input));
1021
+ if (classified.kind !== 'complete')
1022
+ return undefined;
1023
+ const targets = [];
1024
+ for (const key of input.resettableVolumes) {
1025
+ // A volume carrying Compose's default name for this key without its labels is one Compose
1026
+ // would adopt for the stack, so it counts as a match and refuses below instead of hiding.
1027
+ const matches = classified.resources.filter((resource) => resource.kind === 'volume'
1028
+ && (resource.labels[COMPOSE_LABELS.project] === input.composeProject && resource.labels[COMPOSE_LABELS.volume] === key
1029
+ || resource.name === `${input.composeProject}_${key}`));
1030
+ // Configuration validation and the ownership render refuse a key no volume declares, so
1031
+ // no match here is a volume not created yet or already removed.
1032
+ if (matches.length === 0)
1033
+ continue;
1034
+ if (matches.length !== 1)
1035
+ return undefined;
1036
+ const match = matches[0];
1037
+ // The stack and owner labels identify the stack; the configuration label only records
1038
+ // which cockpit.toml created the volume. Compose keeps a volume across configuration
1039
+ // edits and `up` goes on using it, so admitting only the current configuration left every
1040
+ // data volume un-resettable after the first edit. The preview still commits the exact
1041
+ // volume id, labels and generation, so a volume replaced before the action is refused.
1042
+ if (match.ownership !== 'current' && match.ownership !== 'stale-config' || match.referenced
1043
+ || match.reversibility !== 'data-bearing' || typeof match.generation !== 'string')
1044
+ return undefined;
1045
+ targets.push({
1046
+ kind: 'volume', identity: match.name, declaration: key, labels: { ...match.labels },
1047
+ evidence: { resourceId: match.id, bytes: match.bytes, generation: match.generation },
1048
+ });
1049
+ }
1050
+ // Docker refuses to remove a volume a container still references, stopped or not, and the
1051
+ // runtime stop leaves the stack's containers in place. So a reset that removes volumes first
1052
+ // removes the containers the same ownership evidence names as this stack's own; containers
1053
+ // sort before volumes, so they are removed first. Without them every approved reset failed
1054
+ // with "volume is in use".
1055
+ if (targets.length > 0) {
1056
+ for (const match of classified.resources) {
1057
+ if (match.kind !== 'container' || match.ownership !== 'current' && match.ownership !== 'stale-config'
1058
+ || match.action !== 'teardown')
1059
+ continue;
1060
+ const { ownership: _ownership, reversibility: _reversibility, action: _action, evidence: _evidence, reason: _reason, suggestion: _suggestion, ...resource } = match;
1061
+ targets.push({
1062
+ kind: 'container', identity: match.id, declaration: match.name, labels: { ...match.labels },
1063
+ evidence: { resource },
1064
+ });
1065
+ }
1066
+ }
1067
+ const hostIdentities = new Set();
1068
+ for (const declaration of input.generatedHostPaths) {
1069
+ const evidence = await inspectHostPath(declaration);
1070
+ if (evidence === undefined || evidence.state === 'present' && evidence.type === undefined || hostIdentities.has(declaration.path))
1071
+ return undefined;
1072
+ hostIdentities.add(declaration.path);
1073
+ targets.push({
1074
+ kind: 'host-path', identity: declaration.path, declaration: declaration.declaration,
1075
+ root: declaration.root, evidence,
1076
+ });
1077
+ }
1078
+ return targets.sort((left, right) => lexical(left.kind, right.kind) || lexical(left.identity, right.identity));
1079
+ }
1080
+ function coordinationForDestruction(input) {
1081
+ const record = input.coordinationRecord;
1082
+ if (record === null || record.cockpitId !== input.cockpitId || record.revision !== input.expectedCoordinationRevision) {
1083
+ return { refusal: refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'coordination revision does not match the destructive request', input.stackId) };
1084
+ }
1085
+ const coordination = decodeCoordinationRecord(record, input.stackId);
1086
+ if (coordination === undefined)
1087
+ return { refusal: refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination evidence is malformed', input.stackId) };
1088
+ const otherHolders = coordination.holders.filter(({ holderId }) => holderId !== input.requesterHolderId);
1089
+ if (otherHolders.length > 0 || input.requesterHolderId !== null && !coordination.holders.some(({ holderId }) => holderId === input.requesterHolderId)) {
1090
+ return { refusal: refusal(ENVIRONMENT_CODES.RESET_CO_TENANT, 'reset requires an exact requester claim and zero co-tenants', input.stackId) };
1091
+ }
1092
+ return { revision: record.revision };
1093
+ }
1094
+ function coordinationIsFresh(record, observedAt) {
1095
+ const observed = Date.parse(observedAt);
1096
+ const now = Date.now();
1097
+ return Number.isFinite(Date.parse(record.updatedAt)) && Number.isFinite(observed)
1098
+ && now >= observed && now - observed <= 30_000;
1099
+ }
1100
+ function coordinationWorkBasis(record, stackId) {
1101
+ const value = decodeCoordinationRecord(record, stackId);
1102
+ return value === undefined ? undefined : {
1103
+ holders: value.holders,
1104
+ lease: value.lease,
1105
+ lastReleasedHolder: value.lastReleasedHolder,
1106
+ };
1107
+ }
1108
+ async function rereadCoordination(address, expected, observedAt) {
1109
+ if (address === undefined || !coordinationIsFresh(expected, observedAt))
1110
+ return undefined;
1111
+ const result = await readState(address);
1112
+ if (result.kind !== 'complete' || !coordinationIsFresh(result.value, observedAt)
1113
+ || digestValue(result.value) !== digestValue(expected))
1114
+ return undefined;
1115
+ return result.value;
1116
+ }
1117
+ export async function planReset(input) {
1118
+ try {
1119
+ const { discovery, consent, continuation, recoveredReservation, operatorApproval } = input;
1120
+ const runtimeExecutable = resolvedDockerExecutable(input.runtimeExecutable);
1121
+ const pathExecutable = await removalExecutable(input.pathExecutable);
1122
+ const env = safeEnvironment(input.env);
1123
+ if (runtimeExecutable === undefined || pathExecutable === undefined || env === undefined
1124
+ || !validExecutionInput(runtimeExecutable, input.cwd, input.env, input.timeoutMs)
1125
+ || !validExecutionInput(pathExecutable, input.cwd, input.env, input.timeoutMs)) {
1126
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset execution authority is malformed', 'reset');
1127
+ }
1128
+ if (!discoveryOfStack(discovery, input.stackId)) {
1129
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset requires a fresh runtime discovery of this stack', 'reset-discovery');
1130
+ }
1131
+ const selection = discovery.selection;
1132
+ const coordination = coordinationForDestruction(input);
1133
+ if ('refusal' in coordination)
1134
+ return coordination.refusal;
1135
+ const coldPlanningRecovery = continuation === undefined && consent === undefined && recoveredReservation !== undefined
1136
+ ? inspectFailedReclaimRecovery({ reservation: recoveredReservation, record: input.coordinationRecord }) : undefined;
1137
+ if ((recoveredReservation !== undefined) !== (operatorApproval !== undefined)
1138
+ || operatorApproval !== undefined && coldPlanningRecovery === undefined) {
1139
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'cold reset recovery requires an authenticated approval of the remaining targets', 'reset-confirmation');
1140
+ }
1141
+ const coldPlanningIdentities = coldPlanningRecovery === undefined
1142
+ ? undefined : committedTargetIdentities(coldPlanningRecovery);
1143
+ const observedAuthorities = await resetAuthorities(input);
1144
+ if (observedAuthorities === undefined)
1145
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset targets do not match current declarations and ownership evidence', 'reset-targets');
1146
+ if (continuation !== undefined && continuation.purpose !== 'reset') {
1147
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation receipt is invalid', 'reset-continuation');
1148
+ }
1149
+ const continued = continuation;
1150
+ let authorities = observedAuthorities;
1151
+ let continuedAfterFailure = false;
1152
+ if (continued !== undefined) {
1153
+ const match = matchReclaimDrainContinuation({ continuation: continued.drain, record: input.coordinationRecord });
1154
+ if (match === 'unmatched') {
1155
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation no longer matches the remaining consented target set', 'reset-continuation');
1156
+ }
1157
+ continuedAfterFailure = match === 'failed';
1158
+ const completedTargetIds = new Set(continuedAfterFailure
1159
+ ? continued.removedTargetIds
1160
+ : [...continued.removedTargetIds, continued.completedTargetId]);
1161
+ const remainingNow = continuedAfterFailure ? observedAuthorities
1162
+ : observedAuthorities.filter(({ kind, identity }) => `${kind}:${identity}` !== continued.completedTargetId);
1163
+ const remainingTargetIds = continued.targetIds.filter((targetId) => !completedTargetIds.has(targetId));
1164
+ const remainingIdentities = resetTargetIdentities(remainingTargetIds, remainingNow);
1165
+ const completedRemainAbsent = continued.targetIdentities
1166
+ .filter(({ targetId }) => completedTargetIds.has(targetId))
1167
+ .every((identity) => targetIsAbsent(identity, discovery.resources));
1168
+ if (!followsContinuation(discovery, continued, continuedAfterFailure, input.coordinationRecord)
1169
+ || !completedRemainAbsent || remainingIdentities === undefined
1170
+ || !sameTargetProjections(remainingIdentities, continued.targetIdentities, remainingTargetIds)) {
1171
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation no longer matches the remaining consented target set', 'reset-continuation');
1172
+ }
1173
+ authorities = remainingNow;
1174
+ }
1175
+ const digestTargetIds = authorities
1176
+ .filter((candidate) => candidate.kind !== 'host-path' || candidate.evidence.state === 'present')
1177
+ .map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1178
+ const digestIdentities = coldPlanningIdentities ?? resetTargetIdentities(digestTargetIds, authorities);
1179
+ const coldCurrentIdentity = coldPlanningIdentities?.find(({ targetId }) => targetId === coldPlanningRecovery?.current.targetId);
1180
+ const coldCurrentAbsent = coldCurrentIdentity !== undefined && targetIsAbsent(coldCurrentIdentity, discovery.resources);
1181
+ if (coldCurrentAbsent && (coldCurrentIdentity.medium === 'path'
1182
+ ? !input.generatedHostPaths.some(declaration => declaration.root === coldCurrentIdentity.root
1183
+ && declaration.path === coldCurrentIdentity.path && declaration.declaration === coldCurrentIdentity.declaration)
1184
+ : coldCurrentIdentity.scope === 'reset-volume' && !input.resettableVolumes.includes(coldCurrentIdentity.declaration))) {
1185
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'interrupted reset target is no longer declared', 'reset-targets');
1186
+ }
1187
+ const digest = continued?.digest ?? digestValue({
1188
+ format: 'cockpit-reset-preview/v1', stackId: input.stackId, cockpitId: input.cockpitId,
1189
+ configSha256: input.configSha256,
1190
+ coordination: coordinationWorkBasis(input.coordinationRecord, input.stackId),
1191
+ targets: digestIdentities,
1192
+ runtime: { executable: runtimeExecutable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs },
1193
+ });
1194
+ const runtimeDigest = digestValue({ executable: runtimeExecutable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs });
1195
+ if (continued !== undefined && continued.runtimeDigest !== runtimeDigest) {
1196
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset continuation does not match the original runtime authority', 'reset-continuation');
1197
+ }
1198
+ const publicTargets = authorities.map(safeTarget);
1199
+ if (input.confirmationDigest === undefined) {
1200
+ const actionable = authorities.filter((candidate) => candidate.kind !== 'host-path' || candidate.evidence.state === 'present');
1201
+ const targetIds = actionable.map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1202
+ const identities = coldPlanningIdentities ?? resetTargetIdentities(targetIds, actionable);
1203
+ if (identities === undefined) {
1204
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset target identities could not be committed exactly', 'reset-targets');
1205
+ }
1206
+ return {
1207
+ kind: 'preview', digest, coordinationRevision: coordination.revision,
1208
+ targets: publicTargets, noop: authorities.length === 0 && !coldCurrentAbsent,
1209
+ consent: { digest, observedAt: discovery.observedAt },
1210
+ envelope: reclaimTargetEnvelope('reset', input.stackId, input.configSha256, digest, targetCommitments(identities)),
1211
+ };
1212
+ }
1213
+ if (!SHA256.test(input.confirmationDigest) || input.confirmationDigest !== digest) {
1214
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'reset consent does not match the fresh complete preview', 'reset-confirmation');
1215
+ }
1216
+ const currentCoordination = input.coordinationRecord === null ? undefined
1217
+ : await rereadCoordination(input.coordinationAddress, input.coordinationRecord, input.discovery.observedAt);
1218
+ if (currentCoordination === undefined) {
1219
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'reset requires fresh coordination evidence read at the destructive boundary', input.stackId);
1220
+ }
1221
+ const boundaryCoordination = coordinationForDestruction({ ...input, coordinationRecord: currentCoordination });
1222
+ if ('refusal' in boundaryCoordination)
1223
+ return boundaryCoordination.refusal;
1224
+ const coldRecovery = coldPlanningRecovery;
1225
+ const coldIdentities = coldRecovery === undefined ? undefined : committedTargetIdentities(coldRecovery);
1226
+ const coldReclaimTargetIds = coldRecovery === undefined ? undefined
1227
+ : [...coldRecovery.completed, coldRecovery.current, ...coldRecovery.pending].map(({ targetId }) => targetId);
1228
+ const coldCompletedTargetIds = coldRecovery?.completed.map(({ targetId }) => targetId);
1229
+ const coldRemainingTargetIds = coldRecovery === undefined ? undefined
1230
+ : (coldCurrentAbsent ? coldRecovery.pending : [coldRecovery.current, ...coldRecovery.pending]).map(({ targetId }) => targetId);
1231
+ const coldObservedIdentities = coldRemainingTargetIds === undefined ? undefined
1232
+ : resetTargetIdentities(coldRemainingTargetIds, authorities);
1233
+ const coldTargetsMatch = coldRecovery !== undefined && coldIdentities !== undefined
1234
+ && coldRecovery.purpose === 'reset' && coldRecovery.stackId === input.stackId
1235
+ && coldRecovery.configSha256 === input.configSha256 && coldRecovery.planDigest === input.confirmationDigest
1236
+ && (coldCurrentAbsent || coldRecovery.current.targetId === coldRemainingTargetIds?.[0])
1237
+ && sameStrings(digestTargetIds, coldRemainingTargetIds)
1238
+ && coldCompletedTargetIds.every((targetId) => coldIdentities
1239
+ .filter((identity) => identity.targetId === targetId)
1240
+ .every((identity) => targetIsAbsent(identity, discovery.resources)))
1241
+ && coldObservedIdentities !== undefined
1242
+ && sameTargetProjections(coldObservedIdentities, coldIdentities, coldRemainingTargetIds);
1243
+ const followsPersistedFailure = coldTargetsMatch
1244
+ && Date.parse(discovery.startedAt) > Date.parse(coldRecovery.freshAfter);
1245
+ const approval = operatorApproval?.envelope;
1246
+ if (operatorApproval !== undefined && (!coldTargetsMatch || !approval
1247
+ || approval.purpose !== 'reset' || approval.stackId !== input.stackId || approval.configSha256 !== input.configSha256
1248
+ || digestValue(approval.commitments) !== digestValue(targetCommitments(coldIdentities
1249
+ .filter(({ targetId }) => coldRemainingTargetIds.includes(targetId)))))) {
1250
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'approved reset preview does not match the exact remaining persisted targets', 'reset-confirmation');
1251
+ }
1252
+ if (continued === undefined && !(coldTargetsMatch ? followsPersistedFailure : followsConsent(consent, digest, discovery))) {
1253
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset requires a fresh runtime discovery read after the approved preview', 'reset-discovery');
1254
+ }
1255
+ const actionable = authorities.filter((candidate) => candidate.kind !== 'host-path' || candidate.evidence.state === 'present');
1256
+ const targetIds = continued?.targetIds ?? coldReclaimTargetIds ?? actionable.map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1257
+ const removedTargetIds = coldTargetsMatch ? coldCompletedTargetIds : continued === undefined
1258
+ ? []
1259
+ : continuedAfterFailure ? continued.removedTargetIds
1260
+ : [...continued.removedTargetIds, continued.completedTargetId].sort(lexical);
1261
+ const targetIdentities = continued?.targetIdentities ?? (coldTargetsMatch ? coldIdentities : resetTargetIdentities(targetIds, actionable));
1262
+ if (targetIdentities === undefined) {
1263
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reset target identities could not be retained exactly', 'reset-targets');
1264
+ }
1265
+ const envelope = reclaimTargetEnvelope('reset', input.stackId, input.configSha256, digest, targetCommitments(coldTargetsMatch ? coldIdentities : targetIdentities));
1266
+ const continuationFor = (completedTargetId) => ({
1267
+ purpose: 'reset', digest, completedTargetId, runtimeDigest, targetIds, removedTargetIds, targetIdentities,
1268
+ freshAfter: new Date().toISOString(), drain: null,
1269
+ });
1270
+ if (coldTargetsMatch && coldCurrentAbsent) {
1271
+ const action = {
1272
+ stackId: input.stackId, configSha256: input.configSha256, step: null,
1273
+ coordinationRecord: currentCoordination, continuation: continuationFor(coldRecovery.current.targetId),
1274
+ observedAt: discovery.observedAt,
1275
+ remainingTargets: async (next) => {
1276
+ const current = await resetAuthorities({ ...input, discovery: next });
1277
+ return current?.filter(candidate => candidate.kind !== 'host-path' || candidate.evidence.state === 'present')
1278
+ .map(({ kind, identity }) => `${kind}:${identity}`).sort(lexical);
1279
+ },
1280
+ };
1281
+ return { kind: 'complete', action, remaining: digestTargetIds.length, noop: false, digest, envelope };
1282
+ }
1283
+ // Revalidation narrows cooperative races; portable process APIs cannot bind a daemon object or pathname descriptor through its final removal syscall.
1284
+ const target = actionable[0];
1285
+ if (target === undefined)
1286
+ return { kind: 'complete', action: null, remaining: 0, noop: true, digest, envelope };
1287
+ const step = target.kind === 'host-path'
1288
+ ? exactStep(`reset:host-path:${digestValue(target.identity).slice(0, 16)}`, [pathExecutable.canonicalPath, '-rf', '--', target.identity], pathExecutable, input.cwd, env, input.timeoutMs, [target.identity], { stackId: input.stackId, configSha256: input.configSha256, resetKind: target.kind }, [{ path: target.identity, expected: target.evidence.type }])
1289
+ : target.kind === 'container' ? exactStep(`reset:container:${digestValue(target.identity).slice(0, 16)}`, [runtimeExecutable.canonicalPath, '--host', selection.endpoint, 'container', 'rm', '--force', '--', target.identity], runtimeExecutable, input.cwd, env, input.timeoutMs, [], { stackId: input.stackId, configSha256: input.configSha256, resetKind: target.kind, container: target.declaration })
1290
+ : exactStep(`reset:volume:${digestValue(target.identity).slice(0, 16)}`, [runtimeExecutable.canonicalPath, '--host', selection.endpoint, 'volume', 'rm', '--', target.evidence.resourceId], runtimeExecutable, input.cwd, env, input.timeoutMs, [], { stackId: input.stackId, configSha256: input.configSha256, resetKind: target.kind, volume: target.identity });
1291
+ if (step === undefined)
1292
+ return refusal(ENVIRONMENT_CODES.RESET_CONFIRMATION_MISMATCH, 'exact reset action could not cross the execution boundary', target.identity);
1293
+ const action = {
1294
+ stackId: input.stackId, configSha256: input.configSha256, step,
1295
+ coordinationRecord: currentCoordination, continuation: continuationFor(`${target.kind}:${target.identity}`),
1296
+ observedAt: discovery.observedAt,
1297
+ };
1298
+ return { kind: 'complete', action, remaining: actionable.length - 1, noop: false, digest, envelope };
1299
+ }
1300
+ catch (error) {
1301
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, `reset input could not be inspected safely: ${errorCause(error)}`, 'reset');
1302
+ }
1303
+ }
1304
+ function sumBytes(resources) {
1305
+ if (resources.some(({ bytes }) => bytes === null))
1306
+ return null;
1307
+ let total = 0;
1308
+ for (const resource of resources) {
1309
+ total += resource.bytes ?? 0;
1310
+ if (!Number.isSafeInteger(total))
1311
+ return undefined;
1312
+ }
1313
+ return total;
1314
+ }
1315
+ function teardownStep(resource, input, selection, caches, pathExecutable) {
1316
+ if (resource.kind === 'cache') {
1317
+ const cache = caches.find(({ cacheId, evidence }) => cacheId === resource.id && evidence.state === 'present');
1318
+ if (cache === undefined || pathExecutable === undefined)
1319
+ return undefined;
1320
+ return exactStep(`teardown:cache:${digestValue(resource.id).slice(0, 16)}`, [pathExecutable.canonicalPath, '-rf', '--', cache.path], pathExecutable, input.cwd, input.env, input.timeoutMs, [cache.path], { stackId: input.stackId, configSha256: input.configSha256, resourceKind: resource.kind, resourceId: resource.id }, [{ path: cache.path, expected: 'directory' }]);
1321
+ }
1322
+ const command = resource.kind === 'container'
1323
+ ? ['container', 'rm', '--force', '--', resource.id]
1324
+ : resource.kind === 'network'
1325
+ ? ['network', 'rm', '--', resource.id]
1326
+ : resource.kind === 'image'
1327
+ ? ['image', 'rm', '--', resource.id]
1328
+ : resource.kind === 'build-cache'
1329
+ ? ['buildx', 'prune', '--filter', `id=${resource.id}`, '--force']
1330
+ : undefined;
1331
+ if (command === undefined)
1332
+ return undefined;
1333
+ const selected = ['--host', selection.endpoint, ...(resource.kind === 'build-cache' ? ['builder', ...command.slice(1)] : command)];
1334
+ return exactStep(`teardown:${resource.kind}:${digestValue(resource.id).slice(0, 16)}`, [input.executable.canonicalPath, ...selected], input.executable, input.cwd, input.env, input.timeoutMs, [], { stackId: input.stackId, configSha256: input.configSha256, resourceKind: resource.kind, resourceId: resource.id });
1335
+ }
1336
+ function actionableReclaimResources(resources) {
1337
+ return resources.filter((resource) => resource.action === 'teardown'
1338
+ || resource.action === 'reclaim' && (resource.kind === 'build-cache' || resource.kind === 'cache'))
1339
+ .sort((left, right) => lexical(`${left.kind}:${left.id}`, `${right.kind}:${right.id}`));
1340
+ }
1341
+ export async function planTeardown(input) {
1342
+ try {
1343
+ const { discovery, consent, continuation: continued, recoveredReservation } = input;
1344
+ const executable = resolvedDockerExecutable(input.executable);
1345
+ const env = safeEnvironment(input.env);
1346
+ const caches = await dependencyCacheAuthorities(input.dependencyCaches);
1347
+ const pathExecutable = input.dependencyCaches !== undefined && input.dependencyCaches.length > 0
1348
+ ? await removalExecutable(input.pathExecutable) : undefined;
1349
+ if (executable === undefined || env === undefined || !validExecutionInput(executable, input.cwd, input.env, input.timeoutMs)) {
1350
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown execution authority is malformed', 'teardown');
1351
+ }
1352
+ if (!discoveryOfStack(discovery, input.stackId)) {
1353
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown requires a fresh runtime discovery of this stack', 'teardown-discovery');
1354
+ }
1355
+ const selection = discovery.selection;
1356
+ if (caches === undefined || input.dependencyCaches !== undefined && input.dependencyCaches.length > 0 && pathExecutable === undefined) {
1357
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'dependency-cache reclaim authority is malformed or unconfined', 'teardown-cache');
1358
+ }
1359
+ if (input.coordinationRecord === null || input.coordinationRecord.cockpitId !== input.cockpitId) {
1360
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'teardown requires current coordinated state evidence', input.stackId);
1361
+ }
1362
+ const coordination = decodeCoordinationRecord(input.coordinationRecord, input.stackId);
1363
+ if (coordination === undefined)
1364
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'teardown coordination evidence is malformed', input.stackId);
1365
+ if (coordination.holders.length > 1 || coordination.holders.length === 1 && (coordination.lease === null || coordination.lease.released)) {
1366
+ return refusal(ENVIRONMENT_CODES.RESET_CO_TENANT, 'teardown requires no more than one retained holder with its lease', input.stackId);
1367
+ }
1368
+ const cacheResources = caches.filter(({ evidence }) => evidence.state === 'present').map(({ cacheId }) => ({
1369
+ id: cacheId, name: cacheId, kind: 'cache', labels: {}, referenced: false, bytes: null,
1370
+ }));
1371
+ const observedDiscovery = {
1372
+ observedAt: discovery.observedAt, producer: discovery.producer,
1373
+ resources: [...discovery.resources, ...cacheResources],
1374
+ };
1375
+ const classified = classifyReclaim({ ...reclaimInput(input), discovery: observedDiscovery });
1376
+ if (classified.kind !== 'complete')
1377
+ return classified;
1378
+ const coldRecovery = continued === undefined && consent === undefined && recoveredReservation !== undefined
1379
+ ? inspectFailedReclaimRecovery({ reservation: recoveredReservation, record: input.coordinationRecord }) : undefined;
1380
+ const coldIdentities = coldRecovery === undefined ? undefined : committedTargetIdentities(coldRecovery);
1381
+ if (continued !== undefined && continued.purpose !== 'down') {
1382
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation receipt is invalid', 'teardown-continuation');
1383
+ }
1384
+ let continuedAfterFailure = false;
1385
+ if (continued !== undefined) {
1386
+ const match = matchReclaimDrainContinuation({ continuation: continued.drain, record: input.coordinationRecord });
1387
+ if (match === 'unmatched') {
1388
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation no longer matches the remaining consented resources', 'teardown-continuation');
1389
+ }
1390
+ continuedAfterFailure = match === 'failed';
1391
+ const completedTargetIds = new Set(continuedAfterFailure
1392
+ ? continued.removedTargetIds
1393
+ : [...continued.removedTargetIds, continued.completedTargetId]);
1394
+ const remainingActionable = actionableReclaimResources(classified.resources);
1395
+ const remainingCaches = caches.filter(({ evidence }) => evidence.state === 'present');
1396
+ const remainingTargetIds = continued.targetIds.filter((targetId) => !completedTargetIds.has(targetId));
1397
+ const remainingIdentities = teardownTargetIdentities(remainingTargetIds, remainingActionable, remainingCaches);
1398
+ const completedRemainAbsent = continued.targetIdentities
1399
+ .filter(({ targetId }) => completedTargetIds.has(targetId))
1400
+ .every((identity) => targetIsAbsent(identity, discovery.resources));
1401
+ if (!followsContinuation(discovery, continued, continuedAfterFailure, input.coordinationRecord)
1402
+ || !completedRemainAbsent || remainingIdentities === undefined
1403
+ || !sameTargetProjections(remainingIdentities, continued.targetIdentities, remainingTargetIds)) {
1404
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation no longer matches the remaining consented resources', 'teardown-continuation');
1405
+ }
1406
+ }
1407
+ const actionable = actionableReclaimResources(classified.resources);
1408
+ const freshTargetIds = actionable.map(({ kind, id }) => `${kind}:${id}`).sort(lexical);
1409
+ const freshTargetIdentities = teardownTargetIdentities(freshTargetIds, actionable, caches);
1410
+ if (freshTargetIdentities === undefined) {
1411
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown target identities could not be committed exactly', 'teardown-targets');
1412
+ }
1413
+ const discoveryDigest = continued?.digest ?? digestValue({
1414
+ format: 'cockpit-teardown-discovery/v1', stackId: input.stackId, cockpitId: input.cockpitId,
1415
+ configSha256: input.configSha256,
1416
+ coordination: coordinationWorkBasis(input.coordinationRecord, input.stackId),
1417
+ targets: coldIdentities ?? freshTargetIdentities,
1418
+ runtime: { executable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs },
1419
+ });
1420
+ const runtimeDigest = digestValue({ executable, pathExecutable, cwd: input.cwd, env, selection, timeoutMs: input.timeoutMs });
1421
+ if (continued !== undefined && continued.runtimeDigest !== runtimeDigest) {
1422
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown continuation does not match the original runtime authority', 'teardown-continuation');
1423
+ }
1424
+ if (input.expectedDiscoveryDigest !== undefined && input.expectedDiscoveryDigest !== discoveryDigest) {
1425
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'fresh teardown discovery differs from the accepted snapshot', input.stackId);
1426
+ }
1427
+ let currentCoordination = input.coordinationRecord;
1428
+ if (input.expectedDiscoveryDigest !== undefined) {
1429
+ const current = await rereadCoordination(input.coordinationAddress, input.coordinationRecord, discovery.observedAt);
1430
+ const decodedCurrent = current === undefined ? undefined : decodeCoordinationRecord(current, input.stackId);
1431
+ if (decodedCurrent === undefined || decodedCurrent.holders.length > 1
1432
+ || decodedCurrent.holders.length === 1 && (decodedCurrent.lease === null || decodedCurrent.lease.released)) {
1433
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'teardown requires fresh retained drain coordination evidence read at the destructive boundary', input.stackId);
1434
+ }
1435
+ currentCoordination = current;
1436
+ }
1437
+ const first = actionable[0];
1438
+ const coldReclaimTargetIds = coldRecovery === undefined ? undefined
1439
+ : [...coldRecovery.completed, coldRecovery.current, ...coldRecovery.pending].map(({ targetId }) => targetId);
1440
+ const coldCompletedTargetIds = coldRecovery?.completed.map(({ targetId }) => targetId);
1441
+ const coldCurrentIdentity = coldIdentities?.find(({ targetId }) => targetId === coldRecovery?.current.targetId);
1442
+ const coldCurrentAbsent = coldCurrentIdentity !== undefined && targetIsAbsent(coldCurrentIdentity, discovery.resources);
1443
+ const coldRemainingTargetIds = coldRecovery === undefined ? undefined
1444
+ : (coldCurrentAbsent ? coldRecovery.pending : [coldRecovery.current, ...coldRecovery.pending]).map(({ targetId }) => targetId);
1445
+ const coldObservedIdentities = coldRemainingTargetIds === undefined ? undefined
1446
+ : teardownTargetIdentities(coldRemainingTargetIds, actionable, caches);
1447
+ const coldTargetsMatch = coldRecovery !== undefined && coldIdentities !== undefined
1448
+ && coldRecovery.purpose === 'down' && coldRecovery.stackId === input.stackId
1449
+ && coldRecovery.configSha256 === input.configSha256
1450
+ && coldRecovery.planDigest === discoveryDigest
1451
+ && (input.expectedDiscoveryDigest === undefined || coldRecovery.planDigest === input.expectedDiscoveryDigest)
1452
+ && (coldCurrentAbsent || coldRecovery.current.targetId === coldRemainingTargetIds?.[0])
1453
+ && sameStrings(freshTargetIds, coldRemainingTargetIds)
1454
+ && coldCompletedTargetIds.every((targetId) => coldIdentities
1455
+ .filter((identity) => identity.targetId === targetId)
1456
+ .every((identity) => targetIsAbsent(identity, discovery.resources)))
1457
+ && coldObservedIdentities !== undefined
1458
+ && sameTargetProjections(coldObservedIdentities, coldIdentities, coldRemainingTargetIds);
1459
+ const followsPersistedFailure = coldTargetsMatch
1460
+ && Date.parse(discovery.startedAt) > Date.parse(coldRecovery.freshAfter);
1461
+ if ((first !== undefined || coldCurrentAbsent) && input.expectedDiscoveryDigest !== undefined
1462
+ && continued === undefined && !(coldTargetsMatch ? followsPersistedFailure : followsConsent(consent, discoveryDigest, discovery))) {
1463
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown requires a fresh runtime discovery read after the accepted snapshot', 'teardown-discovery');
1464
+ }
1465
+ const absent = coldTargetsMatch && coldCurrentAbsent && input.expectedDiscoveryDigest !== undefined;
1466
+ const step = absent || first === undefined || input.expectedDiscoveryDigest === undefined
1467
+ ? undefined : teardownStep(first, { ...input, executable, env }, selection, caches, pathExecutable);
1468
+ if (!absent && first !== undefined && input.expectedDiscoveryDigest !== undefined && step === undefined) {
1469
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'exact teardown action could not cross the execution boundary', first.id);
1470
+ }
1471
+ const raw = observedDiscovery.resources;
1472
+ const observed = {
1473
+ orphans: sumBytes(raw.filter(({ kind }) => kind === 'container')),
1474
+ images: sumBytes(raw.filter(({ kind }) => kind === 'image')),
1475
+ buildCache: sumBytes(raw.filter(({ kind }) => kind === 'build-cache' || kind === 'cache')),
1476
+ staleNetworks: sumBytes(classified.resources.filter(({ kind, ownership }) => kind === 'network' && ownership === 'stale-config')),
1477
+ };
1478
+ const reclaimable = {
1479
+ orphans: sumBytes(actionable.filter(({ kind }) => kind === 'container')),
1480
+ images: sumBytes(actionable.filter(({ kind }) => kind === 'image')),
1481
+ buildCache: sumBytes(actionable.filter(({ kind }) => kind === 'build-cache' || kind === 'cache')),
1482
+ staleNetworks: sumBytes(actionable.filter(({ kind, ownership }) => kind === 'network' && ownership === 'stale-config')),
1483
+ };
1484
+ if ([...Object.values(observed), ...Object.values(reclaimable)].some((value) => value === undefined)) {
1485
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'reclaim byte accounting exceeds the safe integer range', 'teardown-accounting');
1486
+ }
1487
+ const targetIds = continued?.targetIds ?? coldReclaimTargetIds ?? freshTargetIds;
1488
+ const removedTargetIds = coldTargetsMatch ? coldCompletedTargetIds : continued === undefined
1489
+ ? []
1490
+ : continuedAfterFailure ? continued.removedTargetIds
1491
+ : [...continued.removedTargetIds, continued.completedTargetId];
1492
+ const targetIdentities = continued?.targetIdentities ?? (coldTargetsMatch ? coldIdentities : freshTargetIdentities);
1493
+ if (targetIdentities === undefined) {
1494
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'teardown target identities could not be retained exactly', 'teardown-targets');
1495
+ }
1496
+ const continuationFor = (completedTargetId) => ({
1497
+ purpose: 'down', digest: discoveryDigest, completedTargetId, runtimeDigest, targetIds, removedTargetIds, targetIdentities,
1498
+ freshAfter: new Date().toISOString(), drain: null,
1499
+ });
1500
+ const action = absent ? {
1501
+ stackId: input.stackId, configSha256: input.configSha256, step: null,
1502
+ coordinationRecord: currentCoordination, continuation: continuationFor(coldRecovery.current.targetId),
1503
+ observedAt: discovery.observedAt,
1504
+ remainingTargets: async (next) => {
1505
+ const nextCaches = await dependencyCacheAuthorities(input.dependencyCaches);
1506
+ if (!nextCaches)
1507
+ return undefined;
1508
+ const cacheRows = nextCaches.filter(({ evidence }) => evidence.state === 'present').map(({ cacheId }) => ({
1509
+ id: cacheId, name: cacheId, kind: 'cache', labels: {}, referenced: false, bytes: null,
1510
+ }));
1511
+ const classified = classifyReclaim({ ...reclaimInput(input),
1512
+ discovery: { observedAt: next.observedAt, producer: next.producer, resources: [...next.resources, ...cacheRows] } });
1513
+ return classified.kind === 'complete' ? actionableReclaimResources(classified.resources)
1514
+ .map(({ kind, id }) => `${kind}:${id}`).sort(lexical) : undefined;
1515
+ },
1516
+ } : step === undefined || first === undefined ? null : {
1517
+ stackId: input.stackId, configSha256: input.configSha256,
1518
+ step,
1519
+ coordinationRecord: currentCoordination, continuation: continuationFor(`${first.kind}:${first.id}`),
1520
+ observedAt: discovery.observedAt,
1521
+ };
1522
+ return {
1523
+ kind: 'complete', action,
1524
+ remaining: step === undefined ? actionable.length : actionable.length - 1,
1525
+ noop: actionable.length === 0 && !absent, discoveryDigest,
1526
+ byteAccounting: {
1527
+ observed: observed, reclaimable: reclaimable,
1528
+ action: first === undefined || step === undefined ? null : { resourceId: first.id, observedBytes: first.bytes },
1529
+ actuallyReclaimed: null,
1530
+ },
1531
+ consent: input.expectedDiscoveryDigest === undefined ? { digest: discoveryDigest, observedAt: discovery.observedAt } : null,
1532
+ envelope: reclaimTargetEnvelope('down', input.stackId, input.configSha256, discoveryDigest, targetCommitments(coldTargetsMatch ? coldIdentities : targetIdentities)),
1533
+ };
1534
+ }
1535
+ catch (error) {
1536
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, `teardown input could not be inspected safely: ${errorCause(error)}`, 'teardown');
1537
+ }
1538
+ }
1539
+ function targetCommitment(identity) {
1540
+ const body = JSON.stringify(canonicalValue(identity));
1541
+ return {
1542
+ format: 'cockpit-reclaim-target/v1', targetId: identity.targetId,
1543
+ sha256: createHash('sha256').update(body).digest('hex'), body,
1544
+ };
1545
+ }
1546
+ function targetCommitments(identities) {
1547
+ return identities.map(targetCommitment);
1548
+ }
1549
+ function reclaimTargetEnvelope(purpose, stackId, configSha256, planDigest, commitments) {
1550
+ return {
1551
+ purpose, stackId, configSha256, planDigest,
1552
+ commitments: commitments.map((commitment) => ({ ...commitment })),
1553
+ };
1554
+ }
1555
+ function snapshotCommittedTargetIdentity(value) {
1556
+ const parsed = snapshotJson(value);
1557
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1558
+ return undefined;
1559
+ const candidate = parsed;
1560
+ const validLabels = (labels) => labels !== null && typeof labels === 'object'
1561
+ && !Array.isArray(labels) && Object.keys(labels).length <= 64
1562
+ && Object.entries(labels).every(([key, item]) => key.length > 0 && key.length <= 256 && !key.includes('\0')
1563
+ && typeof item === 'string' && item.length <= 512 && !item.includes('\0'));
1564
+ if (typeof candidate.targetId !== 'string' || candidate.targetId.length === 0
1565
+ || Buffer.byteLength(candidate.targetId, 'utf8') > 512)
1566
+ return undefined;
1567
+ if (candidate.medium === 'runtime' && candidate.scope === 'reset-volume') {
1568
+ if (!exactKeys(candidate, ['medium', 'scope', 'targetId', 'kind', 'declaration', 'runtimeId', 'name', 'labels', 'generation'])
1569
+ || candidate.kind !== 'volume' || typeof candidate.declaration !== 'string'
1570
+ || typeof candidate.runtimeId !== 'string' || typeof candidate.name !== 'string'
1571
+ || typeof candidate.generation !== 'string' || !validLabels(candidate.labels)
1572
+ || candidate.targetId !== `volume:${candidate.name}`
1573
+ || digestValue(canonicalLabels(candidate.labels)) !== digestValue(candidate.labels))
1574
+ return undefined;
1575
+ return candidate;
1576
+ }
1577
+ if (candidate.medium === 'runtime' && candidate.scope === 'teardown-runtime') {
1578
+ if (!exactKeys(candidate, ['medium', 'scope', 'targetId', 'kind', 'runtimeId', 'name', 'labels', 'referenced', 'dataBearing', 'generation'])
1579
+ || !['container', 'network', 'volume', 'image', 'build-cache'].includes(candidate.kind)
1580
+ || typeof candidate.runtimeId !== 'string' || typeof candidate.name !== 'string'
1581
+ || !validLabels(candidate.labels)
1582
+ || typeof candidate.referenced !== 'boolean'
1583
+ || candidate.dataBearing !== null && typeof candidate.dataBearing !== 'boolean'
1584
+ || candidate.generation !== null && typeof candidate.generation !== 'string'
1585
+ || candidate.targetId !== `${candidate.kind}:${candidate.runtimeId}`
1586
+ || digestValue(canonicalLabels(candidate.labels)) !== digestValue(candidate.labels))
1587
+ return undefined;
1588
+ return candidate;
1589
+ }
1590
+ if (candidate.medium === 'path') {
1591
+ const ancestors = candidate.ancestors;
1592
+ if (!exactKeys(candidate, ['medium', 'targetId', 'kind', 'declaration', 'root', 'path', 'type', 'dev', 'ino', 'birthtimeNs', 'ancestors'])
1593
+ || candidate.kind !== 'cache' && candidate.kind !== 'host-path'
1594
+ || typeof candidate.declaration !== 'string' || typeof candidate.root !== 'string' || typeof candidate.path !== 'string'
1595
+ || !isAbsolute(candidate.root) || normalize(candidate.root) !== candidate.root
1596
+ || !isAbsolute(candidate.path) || normalize(candidate.path) !== candidate.path
1597
+ || relative(candidate.root, candidate.path) === '' || relative(candidate.root, candidate.path) === '..'
1598
+ || relative(candidate.root, candidate.path).startsWith(`..${sep}`) || isAbsolute(relative(candidate.root, candidate.path))
1599
+ || candidate.type !== 'directory' && candidate.type !== 'file'
1600
+ || !isFilesystemObjectIdentity(candidate)
1601
+ || !Array.isArray(ancestors) || ancestors.length < 1 || ancestors.length > 64
1602
+ || new Set(ancestors.map((ancestor) => ancestor !== null && typeof ancestor === 'object'
1603
+ ? ancestor.path : undefined)).size !== ancestors.length
1604
+ || ancestors.some((ancestor, index) => ancestor === null || typeof ancestor !== 'object' || Array.isArray(ancestor)
1605
+ || !exactKeys(ancestor, ['path', 'dev', 'ino', 'birthtimeNs'])
1606
+ || typeof ancestor.path !== 'string' || !isAbsolute(ancestor.path) || normalize(ancestor.path) !== ancestor.path
1607
+ || !isFilesystemObjectIdentity(ancestor)
1608
+ || (index === 0 ? ancestor.path !== candidate.root : dirname(ancestor.path) !== ancestors[index - 1].path))
1609
+ || dirname(candidate.path) !== ancestors.at(-1).path)
1610
+ return undefined;
1611
+ return candidate;
1612
+ }
1613
+ return undefined;
1614
+ }
1615
+ function committedTargetIdentities(recovery) {
1616
+ try {
1617
+ const targets = [...recovery.completed, recovery.current, ...recovery.pending];
1618
+ const identities = targets.map((target) => {
1619
+ const commitment = target.targetCommitment;
1620
+ if (!commitment || createHash('sha256').update(commitment.body).digest('hex') !== commitment.sha256)
1621
+ throw new RefusalError('invalid commitment');
1622
+ const parsed = snapshotCommittedTargetIdentity(JSON.parse(commitment.body));
1623
+ if (!parsed || parsed.targetId !== target.targetId
1624
+ || commitment.body !== JSON.stringify(canonicalValue(parsed)))
1625
+ throw new RefusalError('invalid identity');
1626
+ return parsed;
1627
+ });
1628
+ return identities.length === targets.length ? identities : undefined;
1629
+ }
1630
+ catch {
1631
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1632
+ return undefined;
1633
+ }
1634
+ }
1635
+ function canonicalLabels(labels) {
1636
+ const required = new Set(Object.values(REQUIRED_LABELS));
1637
+ return Object.fromEntries(Object.entries(labels)
1638
+ .filter(([key]) => required.has(key))
1639
+ .sort(([left], [right]) => lexical(left, right)));
1640
+ }
1641
+ function teardownRuntimeTargetIdentity(targetId, resource) {
1642
+ if (resource.kind === 'cache')
1643
+ return undefined;
1644
+ return {
1645
+ medium: 'runtime', scope: 'teardown-runtime', targetId, kind: resource.kind,
1646
+ runtimeId: resource.id, name: resource.name, labels: canonicalLabels(resource.labels),
1647
+ referenced: resource.referenced, dataBearing: resource.dataBearing ?? null,
1648
+ generation: resource.generation ?? null,
1649
+ };
1650
+ }
1651
+ function pathTargetIdentity(targetId, kind, declaration, root, path, evidence) {
1652
+ if (evidence.state !== 'present' || evidence.type === undefined || evidence.dev === undefined || evidence.ino === undefined
1653
+ || evidence.birthtimeNs === undefined || evidence.ancestors === undefined)
1654
+ return undefined;
1655
+ return {
1656
+ medium: 'path', targetId, kind, declaration, root, path, type: evidence.type,
1657
+ dev: evidence.dev, ino: evidence.ino, birthtimeNs: evidence.birthtimeNs,
1658
+ ancestors: evidence.ancestors.map((ancestor) => ({ ...ancestor })),
1659
+ };
1660
+ }
1661
+ async function identityBoundFilesystemCheck(expected) {
1662
+ const relation = relative(expected.root, expected.path);
1663
+ if (relation === '' || relation === '..' || relation.startsWith(`..${sep}`) || isAbsolute(relation))
1664
+ return undefined;
1665
+ const parts = relation.split(sep);
1666
+ const ancestorPaths = [expected.root];
1667
+ let cursor = expected.root;
1668
+ for (const segment of parts.slice(0, -1)) {
1669
+ cursor = resolve(cursor, segment);
1670
+ ancestorPaths.push(cursor);
1671
+ }
1672
+ try {
1673
+ if (ancestorPaths.length !== expected.ancestors.length)
1674
+ return undefined;
1675
+ const ancestors = [];
1676
+ for (const [index, path] of ancestorPaths.entries()) {
1677
+ const committed = expected.ancestors[index];
1678
+ if (committed === undefined || committed.path !== path)
1679
+ return undefined;
1680
+ const status = await lstat(path, { bigint: true });
1681
+ if (!status.isDirectory() || status.isSymbolicLink() || await realpath(path) !== path
1682
+ || !sameFilesystemObject(status, committed))
1683
+ return undefined;
1684
+ ancestors.push({ path, ...filesystemObjectIdentity(status) });
1685
+ }
1686
+ const target = await lstat(expected.path, { bigint: true });
1687
+ const type = target.isDirectory() ? 'directory' : target.isFile() ? 'file' : undefined;
1688
+ if (target.isSymbolicLink() || type !== expected.type || !sameFilesystemObject(target, expected))
1689
+ return undefined;
1690
+ return {
1691
+ path: expected.path,
1692
+ expected: expected.type,
1693
+ identity: { ...filesystemObjectIdentity(target), ancestors },
1694
+ };
1695
+ }
1696
+ catch {
1697
+ // A target that cannot be re-identified no longer matches; the caller refuses it.
1698
+ return undefined;
1699
+ }
1700
+ }
1701
+ function resetTargetIdentities(targetIds, targets) {
1702
+ const identities = targets.flatMap((target) => {
1703
+ const targetId = `${target.kind}:${target.identity}`;
1704
+ if (!targetIds.includes(targetId))
1705
+ return [];
1706
+ if (target.kind === 'host-path') {
1707
+ const identity = pathTargetIdentity(targetId, 'host-path', target.declaration, target.root, target.identity, target.evidence);
1708
+ return identity === undefined ? [] : [identity];
1709
+ }
1710
+ // A container is committed with the identity teardown commits for it, so recovery reads
1711
+ // one runtime identity shape whichever verb removed it.
1712
+ if (target.kind === 'container') {
1713
+ const identity = teardownRuntimeTargetIdentity(targetId, target.evidence.resource);
1714
+ return identity === undefined ? [] : [identity];
1715
+ }
1716
+ const evidence = target.evidence;
1717
+ return [{
1718
+ medium: 'runtime', scope: 'reset-volume',
1719
+ targetId, kind: 'volume', declaration: target.declaration,
1720
+ runtimeId: evidence.resourceId, name: target.identity,
1721
+ labels: canonicalLabels(target.labels ?? {}), generation: evidence.generation,
1722
+ }];
1723
+ }).sort((left, right) => lexical(left.targetId, right.targetId));
1724
+ return identities.length === targetIds.length
1725
+ && sameStrings(identities.map(({ targetId }) => targetId), targetIds)
1726
+ ? identities : undefined;
1727
+ }
1728
+ function teardownTargetIdentities(targetIds, resources, caches) {
1729
+ const identities = resources.flatMap((resource) => {
1730
+ const targetId = `${resource.kind}:${resource.id}`;
1731
+ if (!targetIds.includes(targetId))
1732
+ return [];
1733
+ if (resource.kind === 'cache') {
1734
+ const cache = caches.find(({ cacheId }) => cacheId === resource.id);
1735
+ if (cache === undefined)
1736
+ return [];
1737
+ const identity = pathTargetIdentity(targetId, 'cache', cache.cacheId, cache.root, cache.path, cache.evidence);
1738
+ return identity === undefined ? [] : [identity];
1739
+ }
1740
+ const identity = teardownRuntimeTargetIdentity(targetId, resource);
1741
+ return identity === undefined ? [] : [identity];
1742
+ }).sort((left, right) => lexical(left.targetId, right.targetId));
1743
+ return identities.length === targetIds.length
1744
+ && sameStrings(identities.map(({ targetId }) => targetId), targetIds)
1745
+ ? identities : undefined;
1746
+ }
1747
+ function sameStrings(left, right) {
1748
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1749
+ }
1750
+ function samePathTargetIdentity(expected) {
1751
+ try {
1752
+ for (const ancestor of expected.ancestors) {
1753
+ const current = lstatSync(ancestor.path, { bigint: true });
1754
+ if (!current.isDirectory() || current.isSymbolicLink() || !sameFilesystemObject(current, ancestor))
1755
+ return false;
1756
+ }
1757
+ const current = lstatSync(expected.path, { bigint: true });
1758
+ const type = current.isDirectory() ? 'directory' : current.isFile() ? 'file' : undefined;
1759
+ return type === expected.type && sameFilesystemObject(current, expected);
1760
+ }
1761
+ catch {
1762
+ // A path that cannot be re-identified no longer matches the recorded identity.
1763
+ return false;
1764
+ }
1765
+ }
1766
+ function pathIsAbsent(path) {
1767
+ try {
1768
+ lstatSync(path);
1769
+ return false;
1770
+ }
1771
+ catch (error) {
1772
+ return hasErrorCode(error, 'ENOENT');
1773
+ }
1774
+ }
1775
+ function selectedRuntimeResources(identity, resources) {
1776
+ return resources.filter((resource) => resource.kind === identity.kind
1777
+ && (identity.scope === 'teardown-runtime' ? resource.id === identity.runtimeId : resource.name === identity.name));
1778
+ }
1779
+ function sameTargetIdentity(identity, resources) {
1780
+ if (identity.medium === 'path')
1781
+ return samePathTargetIdentity(identity);
1782
+ const observed = selectedRuntimeResources(identity, resources);
1783
+ if (observed.length !== 1)
1784
+ return false;
1785
+ const [resource] = observed;
1786
+ const common = resource.id === identity.runtimeId
1787
+ && resource.name === identity.name
1788
+ && digestValue(canonicalLabels(resource.labels)) === digestValue(identity.labels)
1789
+ && (resource.generation ?? null) === identity.generation;
1790
+ return identity.scope === 'reset-volume' ? common
1791
+ : common && resource.referenced === identity.referenced
1792
+ && (resource.dataBearing ?? null) === identity.dataBearing;
1793
+ }
1794
+ function sameTargetProjections(observed, original, targetIds) {
1795
+ if (observed.length !== targetIds.length)
1796
+ return false;
1797
+ return targetIds.every((targetId) => {
1798
+ const current = observed.filter((identity) => identity.targetId === targetId);
1799
+ const retained = original.filter((identity) => identity.targetId === targetId);
1800
+ return current.length === 1 && retained.length === 1 && digestValue(current[0]) === digestValue(retained[0]);
1801
+ });
1802
+ }
1803
+ function targetIsAbsent(identity, resources) {
1804
+ if (identity.medium === 'path')
1805
+ return pathIsAbsent(identity.path);
1806
+ return identity.scope === 'reset-volume'
1807
+ ? resources.every((resource) => resource.id !== identity.runtimeId
1808
+ && (resource.kind !== 'volume' || resource.name !== identity.name))
1809
+ : resources.every((resource) => resource.id !== identity.runtimeId
1810
+ && (resource.kind !== identity.kind || resource.name !== identity.name));
1811
+ }
1812
+ function reclaimCompletionFact(reservation, attempt, observedAt, remainingTargetIds) {
1813
+ return {
1814
+ purpose: reservation.purpose,
1815
+ stackId: reservation.stackId,
1816
+ configSha256: reservation.configSha256,
1817
+ planDigest: reservation.planDigest,
1818
+ operationBasis: reservation.operationBasis,
1819
+ coordinationRevision: reservation.coordinationRevision,
1820
+ reservationId: reservation.reservationId,
1821
+ reservedAt: reservation.reservedAt,
1822
+ generation: reservation.generation,
1823
+ attemptId: attempt.attemptId,
1824
+ attemptRevision: attempt.committedRevision,
1825
+ attemptCommittedAt: attempt.committedAt,
1826
+ validThrough: attempt.validThrough,
1827
+ actionIndex: attempt.actionIndex,
1828
+ currentTarget: attempt.currentTarget,
1829
+ currentStage: attempt.currentStage,
1830
+ observedAt: observedAt,
1831
+ remainingTargetIds: [...remainingTargetIds],
1832
+ };
1833
+ }
1834
+ /**
1835
+ * Runs the planned removal under the reserved attempt: the attempt must name exactly this target
1836
+ * of the committed target set, and the step runs fenced to the attempt's validity window.
1837
+ */
1838
+ export async function executeReclaimAction(input) {
1839
+ const { action } = input;
1840
+ const { continuation } = action;
1841
+ const candidate = input.attempt;
1842
+ if (candidate.reservation.attempt?.attemptId !== candidate.attempt.attemptId || candidate.attempt.status !== 'active' || candidate.attempt.currentStage !== 'reclaim')
1843
+ return { kind: 'unavailable' };
1844
+ const targetId = continuation.completedTargetId;
1845
+ const reclaimTargets = candidate.reservation.actions.filter(({ stage }) => stage === 'reclaim').map(({ targetId: id }) => id);
1846
+ const reserved = candidate.reservation.actions[candidate.attempt.actionIndex];
1847
+ if (candidate.reservation.purpose !== continuation.purpose
1848
+ || candidate.reservation.stackId !== action.stackId
1849
+ || candidate.reservation.configSha256 !== action.configSha256
1850
+ || candidate.reservation.planDigest !== continuation.digest
1851
+ || candidate.attempt.reservationId !== candidate.reservation.reservationId
1852
+ || candidate.attempt.purpose !== candidate.reservation.purpose
1853
+ || candidate.attempt.generation !== candidate.reservation.generation
1854
+ || candidate.attempt.actionIndex !== continuation.removedTargetIds.length + 1
1855
+ || reserved?.stage !== 'reclaim' || reserved.targetId !== targetId
1856
+ || candidate.attempt.currentTarget !== targetId
1857
+ || !sameStrings(reclaimTargets, continuation.targetIds))
1858
+ return { kind: 'unavailable' };
1859
+ const drain = reclaimDrainStep({ planningRecord: action.coordinationRecord, reservation: candidate.reservation });
1860
+ if (!drain)
1861
+ return { kind: 'unavailable' };
1862
+ const executedWith = (receipt, fence) => ({
1863
+ kind: 'executed',
1864
+ executed: { action, reservation: candidate.reservation, attempt: candidate.attempt, drain, receipt, fence },
1865
+ });
1866
+ if (action.step === null)
1867
+ return executedWith(null, null);
1868
+ const pathIdentity = continuation.targetIdentities.find((identity) => identity.targetId === targetId && identity.medium === 'path');
1869
+ const identityCheck = pathIdentity === undefined ? undefined : await identityBoundFilesystemCheck(pathIdentity);
1870
+ if (pathIdentity !== undefined && identityCheck === undefined)
1871
+ return { kind: 'unavailable' };
1872
+ const validThroughMs = Date.parse(candidate.attempt.validThrough);
1873
+ const remainingMs = validThroughMs - Date.now();
1874
+ if (!Number.isSafeInteger(validThroughMs) || remainingMs < 1)
1875
+ return { kind: 'unavailable' };
1876
+ const { fence } = candidate;
1877
+ const step = snapshotExecutionStep({
1878
+ ...action.step,
1879
+ timeoutMs: Math.min(action.step.timeoutMs, remainingMs),
1880
+ spawnNotAfter: candidate.attempt.validThrough,
1881
+ filesystemChecks: identityCheck === undefined ? action.step.filesystemChecks : [identityCheck],
1882
+ executionFence: fence,
1883
+ });
1884
+ if (!step)
1885
+ return { kind: 'unavailable' };
1886
+ const outcome = await input.execute(step);
1887
+ if (outcome.kind === 'refusal') {
1888
+ // Only a removal that provably never started leaves the target as it was.
1889
+ if (outcome.receipt !== undefined || outcome.execution.started || outcome.execution.signal !== null
1890
+ || outcome.execution.exitCode === 0)
1891
+ return { kind: 'unavailable' };
1892
+ const failedAt = new Date().toISOString();
1893
+ return {
1894
+ kind: 'not-started', code: outcome.code, fence,
1895
+ outcome: { kind: 'failure', reservation: candidate.reservation, failedAt },
1896
+ continuation: { ...continuation, drain: { ...drain, failedAt } },
1897
+ };
1898
+ }
1899
+ if (outcome.stderr.byteLength !== 0)
1900
+ return { kind: 'unavailable' };
1901
+ return executedWith(outcome.receipt, fence);
1902
+ }
1903
+ /**
1904
+ * Settles an executed removal from a discovery read after it: every completed target must be
1905
+ * absent and every remaining committed target unchanged, or the attempt stays for repair.
1906
+ */
1907
+ export async function reconcileReclaimAction(input) {
1908
+ const { action, reservation, attempt, drain, receipt } = input.executed;
1909
+ const { discovery } = input;
1910
+ const { continuation } = action;
1911
+ const remainder = reservation.actions.slice(attempt.actionIndex + 1)
1912
+ .filter(({ stage }) => stage === 'reclaim').map(({ targetId }) => targetId);
1913
+ const completed = new Set([...continuation.removedTargetIds, continuation.completedTargetId]);
1914
+ // An already-absent target ran nothing, so the discovery must follow the one that planned it.
1915
+ const freshAfter = receipt === null ? action.observedAt : receipt.finishedAt;
1916
+ const remaining = action.remainingTargets === undefined ? remainder : await action.remainingTargets(discovery);
1917
+ if (discovery.stackId !== reservation.stackId
1918
+ || receipt !== null && (Date.parse(receipt.startedAt) < Date.parse(attempt.committedAt)
1919
+ || Date.parse(receipt.startedAt) > Date.parse(receipt.finishedAt)
1920
+ || Date.parse(receipt.finishedAt) > Date.parse(attempt.validThrough)
1921
+ || Date.parse(receipt.finishedAt) > Date.parse(discovery.startedAt))
1922
+ || receipt === null && (Date.parse(discovery.startedAt) <= Date.parse(action.observedAt)
1923
+ || Date.parse(discovery.startedAt) < Date.parse(attempt.committedAt))
1924
+ || Date.parse(discovery.startedAt) > Date.parse(discovery.observedAt)
1925
+ || Date.parse(discovery.observedAt) > Date.parse(attempt.validThrough)
1926
+ || Date.parse(discovery.observedAt) > Date.now()
1927
+ || !remaining || !sameStrings(remaining, remainder)
1928
+ || !sameStrings([...completed, ...remainder].sort(lexical), continuation.targetIds)
1929
+ || continuation.targetIdentities.length !== continuation.targetIds.length
1930
+ || !continuation.targetIdentities.every((identity) => completed.has(identity.targetId)
1931
+ ? targetIsAbsent(identity, discovery.resources)
1932
+ : remainder.includes(identity.targetId) && sameTargetIdentity(identity, discovery.resources))) {
1933
+ return refusal(ENVIRONMENT_CODES.RECLAIM_ACCOUNTING_MISMATCH, 'fresh discovery did not prove the exact reserved removal set', 'reclaim-reconciliation');
1934
+ }
1935
+ return {
1936
+ kind: 'complete',
1937
+ outcome: { kind: 'success', reservation, fact: reclaimCompletionFact(reservation, attempt, discovery.observedAt, remainder) },
1938
+ continuation: remainder.length === 0 ? null : { ...continuation, freshAfter, drain },
1939
+ };
1940
+ }