@b00dea/cockpit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/LICENSE +21 -0
  3. package/README.md +22 -2
  4. package/bin/cockpit.mjs +33 -0
  5. package/cockpit.schema.json +2574 -0
  6. package/dist/autonomy.d.ts +44 -0
  7. package/dist/autonomy.js +156 -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 +62 -0
  11. package/dist/browser/commands.js +349 -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 +117 -0
  17. package/dist/browser/runtime.js +183 -0
  18. package/dist/browser/surfaces.d.ts +141 -0
  19. package/dist/browser/surfaces.js +556 -0
  20. package/dist/browser/ui-gate.d.ts +15 -0
  21. package/dist/browser/ui-gate.js +18 -0
  22. package/dist/cli-guard.d.ts +5 -0
  23. package/dist/cli-guard.js +71 -0
  24. package/dist/cli-main.d.ts +139 -0
  25. package/dist/cli-main.js +1405 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.js +16 -0
  28. package/dist/command-table.d.ts +113 -0
  29. package/dist/command-table.js +512 -0
  30. package/dist/config/load.d.ts +171 -0
  31. package/dist/config/load.js +345 -0
  32. package/dist/config/sibling-toml.d.ts +48 -0
  33. package/dist/config/sibling-toml.js +160 -0
  34. package/dist/config/surface-tools-toml.d.ts +9 -0
  35. package/dist/config/surface-tools-toml.js +37 -0
  36. package/dist/config/verified-write.d.ts +43 -0
  37. package/dist/config/verified-write.js +89 -0
  38. package/dist/diagnostics.d.ts +26 -0
  39. package/dist/diagnostics.js +335 -0
  40. package/dist/emit.d.ts +22 -0
  41. package/dist/emit.js +43 -0
  42. package/dist/entrypoint-kinds.d.ts +3 -0
  43. package/dist/entrypoint-kinds.js +11 -0
  44. package/dist/environment/application/destructive.d.ts +6 -0
  45. package/dist/environment/application/destructive.js +425 -0
  46. package/dist/environment/application/doctor.d.ts +89 -0
  47. package/dist/environment/application/doctor.js +235 -0
  48. package/dist/environment/application/guard.d.ts +43 -0
  49. package/dist/environment/application/guard.js +622 -0
  50. package/dist/environment/application/health.d.ts +134 -0
  51. package/dist/environment/application/health.js +379 -0
  52. package/dist/environment/application/lifecycle-context.d.ts +228 -0
  53. package/dist/environment/application/lifecycle-context.js +154 -0
  54. package/dist/environment/application/lifecycle-state.d.ts +25 -0
  55. package/dist/environment/application/lifecycle-state.js +201 -0
  56. package/dist/environment/application/lifecycle.d.ts +17 -0
  57. package/dist/environment/application/lifecycle.js +729 -0
  58. package/dist/environment/application/logs.d.ts +45 -0
  59. package/dist/environment/application/logs.js +127 -0
  60. package/dist/environment/application/setup.d.ts +129 -0
  61. package/dist/environment/application/setup.js +741 -0
  62. package/dist/environment/application/shared.d.ts +11 -0
  63. package/dist/environment/application/shared.js +61 -0
  64. package/dist/environment/claims.d.ts +389 -0
  65. package/dist/environment/claims.js +1709 -0
  66. package/dist/environment/codex-hook-trust.d.ts +11 -0
  67. package/dist/environment/codex-hook-trust.js +185 -0
  68. package/dist/environment/compose-environment.d.ts +1 -0
  69. package/dist/environment/compose-environment.js +6 -0
  70. package/dist/environment/compose-ownership.d.ts +21 -0
  71. package/dist/environment/compose-ownership.js +117 -0
  72. package/dist/environment/compose-topology.d.ts +117 -0
  73. package/dist/environment/compose-topology.js +413 -0
  74. package/dist/environment/contract.d.ts +489 -0
  75. package/dist/environment/contract.js +761 -0
  76. package/dist/environment/declaration.d.ts +14 -0
  77. package/dist/environment/declaration.js +39 -0
  78. package/dist/environment/destructive-composition.d.ts +29 -0
  79. package/dist/environment/destructive-composition.js +333 -0
  80. package/dist/environment/docker-daemon.d.ts +30 -0
  81. package/dist/environment/docker-daemon.js +78 -0
  82. package/dist/environment/docker-endpoint.d.ts +10 -0
  83. package/dist/environment/docker-endpoint.js +19 -0
  84. package/dist/environment/env-schema-observation.d.ts +46 -0
  85. package/dist/environment/env-schema-observation.js +241 -0
  86. package/dist/environment/evidence.d.ts +136 -0
  87. package/dist/environment/evidence.js +437 -0
  88. package/dist/environment/executable.d.ts +26 -0
  89. package/dist/environment/executable.js +167 -0
  90. package/dist/environment/generated-application/authorities.d.ts +5 -0
  91. package/dist/environment/generated-application/authorities.js +61 -0
  92. package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
  93. package/dist/environment/generated-application/bootstrap-skill.js +328 -0
  94. package/dist/environment/generated-application/check.d.ts +13 -0
  95. package/dist/environment/generated-application/check.js +189 -0
  96. package/dist/environment/generated-application/custom-surface.d.ts +26 -0
  97. package/dist/environment/generated-application/custom-surface.js +289 -0
  98. package/dist/environment/generated-application/prepare.d.ts +29 -0
  99. package/dist/environment/generated-application/prepare.js +276 -0
  100. package/dist/environment/generated-application/publication.d.ts +33 -0
  101. package/dist/environment/generated-application/publication.js +547 -0
  102. package/dist/environment/generated-application/shared.d.ts +484 -0
  103. package/dist/environment/generated-application/shared.js +349 -0
  104. package/dist/environment/generated-application/surface-observation.d.ts +4 -0
  105. package/dist/environment/generated-application/surface-observation.js +69 -0
  106. package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
  107. package/dist/environment/generated-application/surface-outputs.js +389 -0
  108. package/dist/environment/generated-application/surfaces.d.ts +3 -0
  109. package/dist/environment/generated-application/surfaces.js +699 -0
  110. package/dist/environment/generated-application/sync.d.ts +6 -0
  111. package/dist/environment/generated-application/sync.js +109 -0
  112. package/dist/environment/git-observation.d.ts +183 -0
  113. package/dist/environment/git-observation.js +544 -0
  114. package/dist/environment/health-composition.d.ts +11 -0
  115. package/dist/environment/health-composition.js +628 -0
  116. package/dist/environment/host-observation.d.ts +56 -0
  117. package/dist/environment/host-observation.js +52 -0
  118. package/dist/environment/identity.d.ts +48 -0
  119. package/dist/environment/identity.js +85 -0
  120. package/dist/environment/init-application.d.ts +308 -0
  121. package/dist/environment/init-application.js +697 -0
  122. package/dist/environment/install-policy-observation.d.ts +71 -0
  123. package/dist/environment/install-policy-observation.js +284 -0
  124. package/dist/environment/install-policy.d.ts +48 -0
  125. package/dist/environment/install-policy.js +96 -0
  126. package/dist/environment/instance.d.ts +33 -0
  127. package/dist/environment/instance.js +122 -0
  128. package/dist/environment/instruction-pointer.d.ts +19 -0
  129. package/dist/environment/instruction-pointer.js +144 -0
  130. package/dist/environment/lifecycle-composition.d.ts +29 -0
  131. package/dist/environment/lifecycle-composition.js +675 -0
  132. package/dist/environment/lifecycle-stage.d.ts +115 -0
  133. package/dist/environment/lifecycle-stage.js +398 -0
  134. package/dist/environment/lifecycle.d.ts +75 -0
  135. package/dist/environment/lifecycle.js +354 -0
  136. package/dist/environment/logs.d.ts +132 -0
  137. package/dist/environment/logs.js +519 -0
  138. package/dist/environment/observations.d.ts +46 -0
  139. package/dist/environment/observations.js +344 -0
  140. package/dist/environment/operator-application.d.ts +50 -0
  141. package/dist/environment/operator-application.js +155 -0
  142. package/dist/environment/ports.d.ts +182 -0
  143. package/dist/environment/ports.js +1205 -0
  144. package/dist/environment/posture-observation.d.ts +68 -0
  145. package/dist/environment/posture-observation.js +253 -0
  146. package/dist/environment/read-composition.d.ts +27 -0
  147. package/dist/environment/read-composition.js +352 -0
  148. package/dist/environment/readiness-composition.d.ts +38 -0
  149. package/dist/environment/readiness-composition.js +130 -0
  150. package/dist/environment/reclaim.d.ts +333 -0
  151. package/dist/environment/reclaim.js +1835 -0
  152. package/dist/environment/repository-identity.d.ts +13 -0
  153. package/dist/environment/repository-identity.js +108 -0
  154. package/dist/environment/runtime-measurement-record.d.ts +38 -0
  155. package/dist/environment/runtime-measurement-record.js +111 -0
  156. package/dist/environment/runtime-measurement.d.ts +27 -0
  157. package/dist/environment/runtime-measurement.js +224 -0
  158. package/dist/environment/runtime-probe-residue.d.ts +43 -0
  159. package/dist/environment/runtime-probe-residue.js +148 -0
  160. package/dist/environment/runtime-probe.d.ts +72 -0
  161. package/dist/environment/runtime-probe.js +725 -0
  162. package/dist/environment/runtime.d.ts +505 -0
  163. package/dist/environment/runtime.js +2335 -0
  164. package/dist/environment/seed.d.ts +16 -0
  165. package/dist/environment/seed.js +43 -0
  166. package/dist/environment/selftest-application.d.ts +86 -0
  167. package/dist/environment/selftest-application.js +713 -0
  168. package/dist/environment/sensitive.d.ts +3 -0
  169. package/dist/environment/sensitive.js +48 -0
  170. package/dist/environment/setup-observation.d.ts +62 -0
  171. package/dist/environment/setup-observation.js +1473 -0
  172. package/dist/environment/setup.d.ts +43 -0
  173. package/dist/environment/setup.js +176 -0
  174. package/dist/environment/sibling-instructions.d.ts +33 -0
  175. package/dist/environment/sibling-instructions.js +224 -0
  176. package/dist/environment/sibling-registration.d.ts +45 -0
  177. package/dist/environment/sibling-registration.js +538 -0
  178. package/dist/environment/state.d.ts +371 -0
  179. package/dist/environment/state.js +2013 -0
  180. package/dist/environment/templates.d.ts +140 -0
  181. package/dist/environment/templates.js +685 -0
  182. package/dist/environment/tool-registration.d.ts +9 -0
  183. package/dist/environment/tool-registration.js +96 -0
  184. package/dist/environment/upgrade-application.d.ts +19 -0
  185. package/dist/environment/upgrade-application.js +175 -0
  186. package/dist/environment-exec/execute.d.ts +102 -0
  187. package/dist/environment-exec/execute.js +1175 -0
  188. package/dist/environment-exec/libexec-program.d.ts +6 -0
  189. package/dist/environment-exec/libexec-program.js +22 -0
  190. package/dist/environment-exec/verified-digest.d.ts +4 -0
  191. package/dist/environment-exec/verified-digest.js +16 -0
  192. package/dist/exact-keys.d.ts +7 -0
  193. package/dist/exact-keys.js +15 -0
  194. package/dist/fix-catalogue.d.ts +32 -0
  195. package/dist/fix-catalogue.js +60 -0
  196. package/dist/freshness.d.ts +29 -0
  197. package/dist/freshness.js +75 -0
  198. package/dist/generated-environment/adopt.d.ts +16 -0
  199. package/dist/generated-environment/adopt.js +275 -0
  200. package/dist/generated-environment/command.d.ts +47 -0
  201. package/dist/generated-environment/command.js +75 -0
  202. package/dist/generated-environment/transaction.d.ts +186 -0
  203. package/dist/generated-environment/transaction.js +1770 -0
  204. package/dist/grading/evaluate.d.ts +71 -0
  205. package/dist/grading/evaluate.js +403 -0
  206. package/dist/grading/grade.d.ts +33 -0
  207. package/dist/grading/grade.js +57 -0
  208. package/dist/guards/evaluator.d.ts +113 -0
  209. package/dist/guards/evaluator.js +1226 -0
  210. package/dist/guards/harness-adapter.d.ts +17 -0
  211. package/dist/guards/harness-adapter.js +57 -0
  212. package/dist/guards/host.d.ts +8 -0
  213. package/dist/guards/host.js +10 -0
  214. package/dist/guards/invocation-parser.d.ts +66 -0
  215. package/dist/guards/invocation-parser.js +595 -0
  216. package/dist/guards/local-identity.d.ts +40 -0
  217. package/dist/guards/local-identity.js +123 -0
  218. package/dist/guards/policy-pin.d.ts +13 -0
  219. package/dist/guards/policy-pin.js +175 -0
  220. package/dist/guards/protected-locations.d.ts +62 -0
  221. package/dist/guards/protected-locations.js +461 -0
  222. package/dist/guards/read-only.d.ts +37 -0
  223. package/dist/guards/read-only.js +315 -0
  224. package/dist/init/command.d.ts +40 -0
  225. package/dist/init/command.js +310 -0
  226. package/dist/init/derive.d.ts +51 -0
  227. package/dist/init/derive.js +715 -0
  228. package/dist/init/draft.d.ts +138 -0
  229. package/dist/init/draft.js +1205 -0
  230. package/dist/init/render.d.ts +35 -0
  231. package/dist/init/render.js +82 -0
  232. package/dist/init/scaffold.d.ts +53 -0
  233. package/dist/init/scaffold.js +121 -0
  234. package/dist/invocation.d.ts +13 -0
  235. package/dist/invocation.js +20 -0
  236. package/dist/package-metadata.d.ts +9 -0
  237. package/dist/package-metadata.js +19 -0
  238. package/dist/readiness.d.ts +53 -0
  239. package/dist/readiness.js +88 -0
  240. package/dist/references/named.d.ts +24 -0
  241. package/dist/references/named.js +27 -0
  242. package/dist/refusal.d.ts +15 -0
  243. package/dist/refusal.js +7 -0
  244. package/dist/registry-derivation.d.ts +31 -0
  245. package/dist/registry-derivation.js +32 -0
  246. package/dist/registry-resolve/command.d.ts +47 -0
  247. package/dist/registry-resolve/command.js +226 -0
  248. package/dist/registry-resolve/registry.d.ts +68 -0
  249. package/dist/registry-resolve/registry.js +292 -0
  250. package/dist/registry-resolve/render-shell.d.ts +18 -0
  251. package/dist/registry-resolve/render-shell.js +36 -0
  252. package/dist/repair.d.ts +29 -0
  253. package/dist/repair.js +8 -0
  254. package/dist/schema-json.d.ts +1 -0
  255. package/dist/schema-json.js +177 -0
  256. package/dist/schema.d.ts +1322 -0
  257. package/dist/schema.js +934 -0
  258. package/dist/setup/command.d.ts +4 -0
  259. package/dist/setup/command.js +4 -0
  260. package/dist/sha256.d.ts +1 -0
  261. package/dist/sha256.js +4 -0
  262. package/dist/surfaces/adoption.d.ts +41 -0
  263. package/dist/surfaces/adoption.js +217 -0
  264. package/dist/surfaces/consumer-instructions.d.ts +20 -0
  265. package/dist/surfaces/consumer-instructions.js +43 -0
  266. package/dist/surfaces/contract.d.ts +42 -0
  267. package/dist/surfaces/contract.js +169 -0
  268. package/dist/surfaces/core-declarations.d.ts +24 -0
  269. package/dist/surfaces/core-declarations.js +79 -0
  270. package/dist/surfaces/development-markers.d.ts +1 -0
  271. package/dist/surfaces/development-markers.js +18 -0
  272. package/dist/surfaces/generator-adapter.d.ts +71 -0
  273. package/dist/surfaces/generator-adapter.js +809 -0
  274. package/dist/surfaces/harness-registry.d.ts +180 -0
  275. package/dist/surfaces/harness-registry.js +117 -0
  276. package/dist/surfaces/mcp.d.ts +95 -0
  277. package/dist/surfaces/mcp.js +186 -0
  278. package/dist/surfaces/merge.d.ts +30 -0
  279. package/dist/surfaces/merge.js +105 -0
  280. package/dist/surfaces/ownership.d.ts +190 -0
  281. package/dist/surfaces/ownership.js +795 -0
  282. package/dist/surfaces/projection.d.ts +136 -0
  283. package/dist/surfaces/projection.js +681 -0
  284. package/dist/surfaces/render.d.ts +118 -0
  285. package/dist/surfaces/render.js +633 -0
  286. package/dist/surfaces/restrictions.d.ts +130 -0
  287. package/dist/surfaces/restrictions.js +447 -0
  288. package/dist/surfaces/selftest.d.ts +162 -0
  289. package/dist/surfaces/selftest.js +393 -0
  290. package/dist/surfaces/skill-contract.d.ts +85 -0
  291. package/dist/surfaces/skill-contract.js +449 -0
  292. package/dist/surfaces/sync.d.ts +57 -0
  293. package/dist/surfaces/sync.js +164 -0
  294. package/dist/team-metadata.d.ts +41 -0
  295. package/dist/team-metadata.js +512 -0
  296. package/dist/testing/gates.d.ts +91 -0
  297. package/dist/testing/gates.js +182 -0
  298. package/dist/testing/hooks.d.ts +59 -0
  299. package/dist/testing/hooks.js +139 -0
  300. package/dist/testing/verdict.d.ts +29 -0
  301. package/dist/testing/verdict.js +62 -0
  302. package/dist/work-orders/application.d.ts +99 -0
  303. package/dist/work-orders/application.js +383 -0
  304. package/dist/work-orders/composition.d.ts +124 -0
  305. package/dist/work-orders/composition.js +1010 -0
  306. package/dist/work-orders/contract.d.ts +61 -0
  307. package/dist/work-orders/contract.js +122 -0
  308. package/dist/work-orders/store.d.ts +158 -0
  309. package/dist/work-orders/store.js +197 -0
  310. package/docs/README.md +65 -0
  311. package/docs/architecture.md +136 -0
  312. package/docs/capability-matrix.md +50 -0
  313. package/docs/cli-reference.md +599 -0
  314. package/docs/command-list.md +51 -0
  315. package/docs/compose-patterns.md +99 -0
  316. package/docs/concepts.md +171 -0
  317. package/docs/configuration-essentials.md +172 -0
  318. package/docs/configuration-reference.md +621 -0
  319. package/docs/environment.md +760 -0
  320. package/docs/extending.md +43 -0
  321. package/docs/faq.md +111 -0
  322. package/docs/getting-started.md +608 -0
  323. package/docs/harness-surfaces.md +433 -0
  324. package/docs/joining-a-team.md +76 -0
  325. package/docs/release-policy.md +49 -0
  326. package/docs/safety-and-autonomy.md +419 -0
  327. package/docs/troubleshooting.md +361 -0
  328. package/docs/tutorial.md +170 -0
  329. package/docs/work-and-browser.md +254 -0
  330. package/libexec/browser-operation.cjs +186 -0
  331. package/libexec/fence-marker.cjs +101 -0
  332. package/libexec/fence-marker.d.cts +24 -0
  333. package/libexec/fenced-group-runner.cjs +77 -0
  334. package/libexec/fenced-supervisor.cjs +137 -0
  335. package/libexec/generated-filesystem.cjs +731 -0
  336. package/libexec/install-filesystem.cjs +225 -0
  337. package/libexec/rulesync-generator.NOTICE.md +1112 -0
  338. package/libexec/rulesync-generator.cjs +743 -0
  339. package/libexec/surface-generator-worker.cjs +266 -0
  340. package/libexec/surface-output-reader.cjs +99 -0
  341. package/libexec/surface-scratch-cleanup.cjs +14 -0
  342. package/libexec/validate-harness-evidence.d.mts +183 -0
  343. package/libexec/validate-harness-evidence.mjs +1312 -0
  344. package/package.json +74 -4
  345. package/surfaces/README.md +33 -0
  346. package/surfaces/agents/evaluator.agent.md +76 -0
  347. package/surfaces/guards/bash-guard.sh +48 -0
  348. package/surfaces/guards/claude.adapter.json +25 -0
  349. package/surfaces/guards/codex.adapter.json +24 -0
  350. package/surfaces/guards/opencode.adapter.json +25 -0
  351. package/surfaces/references/browser.md +63 -0
  352. package/surfaces/references/test-data-and-personas.md +84 -0
  353. package/surfaces/references/testing-tiers.md +11 -0
  354. package/surfaces/skills/cockpit-browser.skill.md +54 -0
  355. package/surfaces/skills/cockpit-explain.skill.md +32 -0
  356. package/surfaces/skills/cockpit-fix.skill.md +37 -0
  357. package/surfaces/skills/cockpit-setup.skill.md +39 -0
  358. package/surfaces/skills/cockpit-work.skill.md +34 -0
  359. package/surfaces/skills/collection.json +5 -0
  360. package/surfaces/skills/procedures/explain.md +61 -0
  361. package/surfaces/skills/procedures/fix.md +81 -0
  362. package/surfaces/skills/procedures/setup.md +379 -0
  363. package/surfaces/skills/procedures/work.md +105 -0
@@ -0,0 +1,1709 @@
1
+ import { z } from 'zod';
2
+ import { lstat, opendir, readFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, normalize } from 'node:path';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { platform } from 'node:os';
6
+ import { STRUCTURED_INPUT_ENTRY_LIMIT } from '../schema.js';
7
+ import { hasErrorCode, RefusalError, ENVIRONMENT_CODES, errorCause, isCanonicalTimestamp, snapshotJson, snapshotExecutionPlan, snapshotEnvironmentObservation, } from './contract.js';
8
+ import { authenticatedExecutionOutcome, consumeFencedExecutionTermination } from '../environment-exec/execute.js';
9
+ import { resolveExecutablePath } from './executable.js';
10
+ import { abortStateTransaction, authenticateStateNamespace, inspectStateLock, parseCoordinationStateEntry, readState, SERIALIZED_RECORD_BYTE_LIMIT, snapshotStateNamespace, transactState, withStateNamespaceInventory, } from './state.js';
11
+ const OWNER = 'environment-claims';
12
+ export const observationDeclarations = Object.freeze([
13
+ { id: 'claim-state', owner: OWNER, postures: ['environment'], stages: [{ stage: 'bootstrap', disposition: 'not-applicable', reason: 'claims do not exist before the first seed' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.CLAIM_STATE_UNHEALTHY, isIssued: isClaimObservation, issuedAt: observationIssuedAt },
14
+ ]);
15
+ const issuedObservations = new WeakMap();
16
+ function issueObservation(value) {
17
+ const snapshot = snapshotEnvironmentObservation(value);
18
+ if (!snapshot || snapshot.owner !== OWNER)
19
+ throw new RefusalError('claim observation is malformed');
20
+ issuedObservations.set(snapshot, Date.now());
21
+ return snapshot;
22
+ }
23
+ function isClaimObservation(value) {
24
+ return value !== null && typeof value === 'object' && issuedObservations.has(value);
25
+ }
26
+ function observationIssuedAt(value) {
27
+ return value !== null && typeof value === 'object' ? issuedObservations.get(value) : undefined;
28
+ }
29
+ export async function observeClaimState(input) {
30
+ const namespace = snapshotStateNamespace(input.namespace);
31
+ if (!namespace || !/^stack:[a-f0-9]{64}$/.test(input.stackId) || !isCanonicalTimestamp(input.observedAt)) {
32
+ throw new RefusalError('claim observation input is malformed');
33
+ }
34
+ const inventory = await readActiveStackInventory(namespace);
35
+ const refusal = inventory.kind === 'refusal' ? inventory.diagnostics[0] : undefined;
36
+ return issueObservation({
37
+ code: ENVIRONMENT_CODES.CLAIM_STATE_UNHEALTHY,
38
+ owner: OWNER,
39
+ stackId: input.stackId,
40
+ source: namespace.paths.coordination(input.stackId).path,
41
+ observed: refusal
42
+ ? { state: 'failed', reason: refusal.message, observedAt: input.observedAt }
43
+ : { state: 'available', value: true, observedAt: input.observedAt },
44
+ expectation: { kind: 'equal', value: true },
45
+ applicability: { state: 'required', posture: 'environment' },
46
+ freshness: { state: 'current' },
47
+ remedy: 'repair or release malformed coordination state',
48
+ });
49
+ }
50
+ const holderIdentity = /^holder:[a-f0-9]{64}:session$/;
51
+ const checkoutIdentity = /^checkout:[a-f0-9]{64}$/;
52
+ const stackIdentity = /^stack:[a-f0-9]{64}$/;
53
+ const sha256Identity = /^[a-f0-9]{64}$/;
54
+ const ClaimHolderSchema = z.object({
55
+ holderId: z.string().regex(holderIdentity),
56
+ checkoutId: z.string().regex(checkoutIdentity),
57
+ claimedAt: z.string().refine(isCanonicalTimestamp),
58
+ pid: z.number().int().positive().max(2_147_483_647).optional(),
59
+ processStart: z.string().refine(isProcessStart).optional(),
60
+ }).strict().refine((holder) => holder.processStart === undefined || holder.pid !== undefined);
61
+ const PortLeaseRecordSchema = z.object({
62
+ offset: z.number().int().nonnegative().safe(),
63
+ ports: z.record(z.string().regex(/^[A-Z][A-Z0-9_]*$/), z.number().int().min(1).max(65535))
64
+ .refine((ports) => Object.keys(ports).length > 0)
65
+ .refine((ports) => new Set(Object.values(ports)).size === Object.keys(ports).length),
66
+ released: z.boolean(),
67
+ declaration: z.object({
68
+ step: z.number().int().positive().safe(),
69
+ count: z.number().int().positive().safe(),
70
+ bases: z.record(z.string().regex(/^[A-Z][A-Z0-9_]*$/), z.number().int().min(1).max(65535))
71
+ .refine((bases) => Object.keys(bases).length > 0)
72
+ .refine((bases) => new Set(Object.values(bases)).size === Object.keys(bases).length),
73
+ }).strict(),
74
+ }).strict().superRefine((lease, context) => {
75
+ const portKeys = Object.keys(lease.ports).sort();
76
+ const baseKeys = Object.keys(lease.declaration.bases).sort();
77
+ const candidate = lease.offset % lease.declaration.step === 0
78
+ && lease.offset / lease.declaration.step < lease.declaration.count;
79
+ const derived = baseKeys.every((key) => lease.ports[key] === lease.declaration.bases[key] + lease.offset);
80
+ if (!candidate || portKeys.join('\0') !== baseKeys.join('\0') || !derived) {
81
+ context.addIssue({ code: 'custom', message: 'lease does not match its declaration' });
82
+ }
83
+ });
84
+ const DrainAttemptSchema = z.object({
85
+ attemptId: z.string().regex(sha256Identity),
86
+ reservationId: z.string().regex(sha256Identity),
87
+ purpose: z.enum(['sleep', 'down', 'reset']),
88
+ targetIds: z.array(z.string().min(1)).min(1).max(STRUCTURED_INPUT_ENTRY_LIMIT),
89
+ generation: z.string().regex(sha256Identity),
90
+ actionIndex: z.number().int().nonnegative().safe(),
91
+ currentTarget: z.string().min(1),
92
+ currentStage: z.enum(['runtime-stop', 'reclaim']),
93
+ committedRevision: z.number().int().positive().safe(),
94
+ committedAt: z.string().refine(isCanonicalTimestamp),
95
+ validThrough: z.string().refine(isCanonicalTimestamp),
96
+ status: z.enum(['active', 'failed']),
97
+ failedRevision: z.number().int().positive().safe().nullable(),
98
+ failedAt: z.string().refine(isCanonicalTimestamp).nullable(),
99
+ }).strict().superRefine((attempt, context) => {
100
+ if (new Set(attempt.targetIds).size !== attempt.targetIds.length
101
+ || attempt.targetIds.some((target, index) => Buffer.byteLength(target, 'utf8') > 512
102
+ || target.includes('\0') || index > 0 && attempt.targetIds[index - 1] >= target)
103
+ || Buffer.byteLength(attempt.currentTarget, 'utf8') > 512 || attempt.currentTarget.includes('\0')
104
+ || Date.parse(attempt.validThrough) <= Date.parse(attempt.committedAt)
105
+ || attempt.status === 'active' && (attempt.failedRevision !== null || attempt.failedAt !== null)
106
+ || attempt.status === 'failed' && (attempt.failedRevision !== attempt.committedRevision + 1
107
+ || attempt.failedAt === null || Date.parse(attempt.failedAt) < Date.parse(attempt.committedAt))) {
108
+ context.addIssue({ code: 'custom', message: 'drain attempt targets are not canonical' });
109
+ }
110
+ });
111
+ function canonicalJsonValue(value) {
112
+ if (Array.isArray(value))
113
+ return value.map(canonicalJsonValue);
114
+ if (value !== null && typeof value === 'object') {
115
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
116
+ .map(([key, item]) => [key, canonicalJsonValue(item)]));
117
+ }
118
+ return value;
119
+ }
120
+ function isCanonicalJson(body) {
121
+ try {
122
+ return JSON.stringify(canonicalJsonValue(JSON.parse(body))) === body;
123
+ }
124
+ catch {
125
+ // Unparseable JSON is simply not canonical.
126
+ return false;
127
+ }
128
+ }
129
+ const DrainTargetCommitmentSchema = z.object({
130
+ format: z.literal('cockpit-reclaim-target/v1'),
131
+ sha256: z.string().regex(sha256Identity),
132
+ body: z.string().min(2).max(32_768),
133
+ }).strict().superRefine((commitment, context) => {
134
+ if (Buffer.byteLength(commitment.body, 'utf8') > 32_768
135
+ || createHash('sha256').update(commitment.body).digest('hex') !== commitment.sha256
136
+ || !isCanonicalJson(commitment.body)) {
137
+ context.addIssue({ code: 'custom', message: 'drain target commitment is malformed' });
138
+ }
139
+ });
140
+ const DrainActionSchema = z.discriminatedUnion('stage', [
141
+ z.object({
142
+ stage: z.literal('runtime-stop'),
143
+ targetId: z.string().min(1),
144
+ validForMs: z.number().int().min(1).max(300_000).safe(),
145
+ }).strict(),
146
+ z.object({
147
+ stage: z.literal('reclaim'),
148
+ targetId: z.string().min(1),
149
+ validForMs: z.number().int().min(1).max(300_000).safe(),
150
+ targetCommitment: DrainTargetCommitmentSchema,
151
+ }).strict(),
152
+ ]);
153
+ const DrainReservationSchema = z.object({
154
+ purpose: z.enum(['sleep', 'down', 'reset']),
155
+ stackId: z.string().regex(stackIdentity),
156
+ holderId: z.string().regex(holderIdentity),
157
+ requesterId: z.string().regex(holderIdentity),
158
+ configSha256: z.string().regex(sha256Identity),
159
+ planDigest: z.string().regex(sha256Identity),
160
+ operationBasis: z.string().regex(sha256Identity),
161
+ generation: z.string().regex(sha256Identity),
162
+ targetIds: z.array(z.string().min(1)).min(1).max(STRUCTURED_INPUT_ENTRY_LIMIT),
163
+ actions: z.array(DrainActionSchema).min(1).max(STRUCTURED_INPUT_ENTRY_LIMIT),
164
+ expectedCoordinationRevision: z.number().int().nonnegative().safe(),
165
+ coordinationRevision: z.number().int().positive().safe(),
166
+ completedActionCount: z.number().int().nonnegative().safe(),
167
+ progressRevision: z.number().int().positive().safe(),
168
+ reservedAt: z.string().refine(isCanonicalTimestamp),
169
+ reservationId: z.string().regex(sha256Identity),
170
+ attempt: DrainAttemptSchema.nullable().optional().default(null),
171
+ }).strict().superRefine((value, context) => {
172
+ if (value.coordinationRevision !== value.expectedCoordinationRevision + 1
173
+ || value.attempt !== null && (value.attempt.reservationId !== value.reservationId
174
+ || value.attempt.purpose !== value.purpose
175
+ || value.attempt.generation !== value.generation
176
+ || !sameTargets(value.attempt.targetIds, value.targetIds)
177
+ || value.attempt.committedRevision <= value.progressRevision
178
+ || (value.attempt.committedRevision - value.progressRevision) % 2 !== 1
179
+ || Date.parse(value.attempt.committedAt) < Date.parse(value.reservedAt)
180
+ || value.attempt.actionIndex !== value.completedActionCount
181
+ || value.actions[value.completedActionCount]?.stage !== value.attempt.currentStage
182
+ || value.actions[value.completedActionCount]?.targetId !== value.attempt.currentTarget
183
+ || Date.parse(value.attempt.validThrough) - Date.parse(value.attempt.committedAt) !== value.actions[value.completedActionCount]?.validForMs) || value.generation === value.reservationId
184
+ || value.completedActionCount >= value.actions.length
185
+ || value.progressRevision < value.coordinationRevision
186
+ || new Set(value.targetIds).size !== value.targetIds.length
187
+ || value.targetIds.some((target, index) => Buffer.byteLength(target, 'utf8') > 512
188
+ || target.includes('\0') || index > 0 && value.targetIds[index - 1] >= target)
189
+ || value.actions[0]?.stage !== 'runtime-stop' || value.actions[0]?.targetId !== value.stackId
190
+ || value.actions.slice(1).some((action, index) => action.stage !== 'reclaim'
191
+ || action.targetId !== value.targetIds.filter((target) => target !== value.stackId)[index])
192
+ || value.actions.length !== value.targetIds.length
193
+ || !sameTargets([...value.actions.map(({ targetId }) => targetId)].sort(), value.targetIds)
194
+ || value.purpose === 'sleep' && value.actions.length !== 1) {
195
+ context.addIssue({ code: 'custom', message: 'drain reservation chronology is inconsistent' });
196
+ }
197
+ });
198
+ const CoordinationValueSchema = z.object({
199
+ holders: z.array(ClaimHolderSchema).max(STRUCTURED_INPUT_ENTRY_LIMIT),
200
+ lease: PortLeaseRecordSchema.nullable(),
201
+ lastReleasedHolder: z.string().regex(holderIdentity).nullable().optional().default(null),
202
+ drain: DrainReservationSchema.nullable().optional().default(null),
203
+ }).strict().superRefine((value, context) => {
204
+ const holderIds = value.holders.map(({ holderId }) => holderId);
205
+ if (new Set(holderIds).size !== holderIds.length
206
+ || value.lastReleasedHolder !== null && holderIds.includes(value.lastReleasedHolder)
207
+ || value.holders.length > 0 && (value.lease === null || value.lease.released)
208
+ || value.holders.length === 0 && value.lease !== null && !value.lease.released && value.lastReleasedHolder === null
209
+ || value.drain !== null && (value.holders.length !== 1
210
+ || value.lease === null
211
+ || value.lease.released
212
+ || value.drain.holderId !== value.drain.requesterId
213
+ || value.holders[0]?.holderId !== value.drain.holderId
214
+ || !holderIds.includes(value.drain.holderId)
215
+ || !holderIds.includes(value.drain.requesterId))) {
216
+ context.addIssue({ code: 'custom', message: 'coordination invariants are inconsistent' });
217
+ }
218
+ });
219
+ const PS_START = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) +(\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
220
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
221
+ const BOOT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
222
+ const LSTART_START = /^lstart:(.+)$/;
223
+ const PROC_START = /^proc:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]{1,20}$/;
224
+ /** A start identity as `observeProcessStart` reports it: `lstart:<timestamp>` or `proc:<boot id>:<ticks>`. */
225
+ export function isProcessStart(value) {
226
+ return typeof value === 'string' && (PROC_START.test(value) || isCanonicalTimestamp(LSTART_START.exec(value)?.[1]));
227
+ }
228
+ const startKind = (start) => start.slice(0, start.indexOf(':'));
229
+ /**
230
+ * Whether an observation proves gone the process recorded with `processStart`. A gone pid proves it
231
+ * whether or not a start was recorded, since reuse can only make a dead process look alive; a start
232
+ * identity decides only against one read the same way.
233
+ */
234
+ export function isProcessGone(processStart, observed) {
235
+ return observed.state === 'absent' || observed.state === 'present' && processStart !== undefined
236
+ && startKind(observed.start) === startKind(processStart) && observed.start !== processStart;
237
+ }
238
+ // A pid alone does not name a holder: the pid of a killed session is reused, and a reused pid read
239
+ // as the session would wedge its stack for as long as the stranger runs. So a session records its
240
+ // process's start identity, and a holder is dead when its pid is gone or now belongs to a process
241
+ // with another identity. On Linux the identity is the start in clock ticks since boot (field 22 of
242
+ // /proc/<pid>/stat) with the boot's ID: ps derives a wall-clock start from the boot time, which moves
243
+ // whenever the wall clock is stepped (NTP, a resumed VM), so a running session would read as a
244
+ // stranger and lose its hold. macOS records the wall-clock start once, and ps prints it to the
245
+ // second under a fixed locale and zone. Each identity names how it was read; anything that cannot
246
+ // be confirmed reads as unknown, and unknown is never dead.
247
+ export async function observeProcessStart(pid, host) {
248
+ if (!Number.isSafeInteger(pid) || pid < 1 || pid > 2_147_483_647)
249
+ return { state: 'unknown', reason: `process id ${pid} is invalid` };
250
+ const presence = await host.presence(pid);
251
+ if (presence === 'absent')
252
+ return { state: 'absent' };
253
+ if (presence !== 'live')
254
+ return { state: 'unknown', reason: `the presence of process ${pid} could not be confirmed` };
255
+ return host.platform === 'linux' ? observeProcStart(pid, host) : observeLstart(pid, host);
256
+ }
257
+ async function observeProcStart(pid, host) {
258
+ let stat;
259
+ try {
260
+ stat = await host.readFile(`/proc/${pid}/stat`);
261
+ }
262
+ catch (error) {
263
+ // Signal 0 just found it, so a missing entry means it exited since, unless this /proc is not
264
+ // this pid namespace's (unmounted in a chroot or sandbox); only a second signal decides.
265
+ if (hasErrorCode(error, 'ENOENT') && await host.presence(pid) === 'absent')
266
+ return { state: 'absent' };
267
+ return { state: 'unknown', reason: `/proc/${pid}/stat could not be read (${errorCause(error)})` };
268
+ }
269
+ let bootId;
270
+ try {
271
+ bootId = (await host.readFile('/proc/sys/kernel/random/boot_id')).trim();
272
+ }
273
+ catch (error) {
274
+ return { state: 'unknown', reason: `the boot ID could not be read (${errorCause(error)})` };
275
+ }
276
+ // The command name in parentheses may itself hold spaces and parentheses; the fields after the
277
+ // last `)` start at field 3, so field 22 is the twentieth.
278
+ const ticks = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
279
+ if (stat.lastIndexOf(')') === -1 || ticks === undefined || !/^[0-9]{1,20}$/.test(ticks) || !BOOT_ID.test(bootId)) {
280
+ return { state: 'unknown', reason: `/proc reported an unrecognised start for process ${pid}` };
281
+ }
282
+ return { state: 'present', start: `proc:${bootId}:${ticks}` };
283
+ }
284
+ async function observeLstart(pid, host) {
285
+ const started = await host.psStart(pid);
286
+ if (started.kind === 'failed')
287
+ return { state: 'unknown', reason: started.reason };
288
+ const match = PS_START.exec(started.text.trim());
289
+ if (!match)
290
+ return { state: 'unknown', reason: `ps reported an unrecognised start time for process ${pid}` };
291
+ const [, month, day, hours, minutes, seconds, year] = match;
292
+ const startedAt = new Date(Date.UTC(Number(year), MONTHS.indexOf(month), Number(day), Number(hours), Number(minutes), Number(seconds)));
293
+ return Number.isNaN(startedAt.getTime()) ? { state: 'unknown', reason: `ps reported an invalid start time for process ${pid}` }
294
+ : { state: 'present', start: `lstart:${startedAt.toISOString()}` };
295
+ }
296
+ /** The production `ProcessHost`: signal 0 and ps cross the executor, and /proc is read directly. */
297
+ export function processHost(execute) {
298
+ return {
299
+ platform: process.platform,
300
+ presence: async (pid) => {
301
+ const node = await resolveExecutablePath(process.execPath);
302
+ if (node.state !== 'available')
303
+ return 'unavailable';
304
+ const answer = await runBoundedProbe(node.value, `process:presence:${pid}`, ['-e', PROCESS_PRESENCE_PROBE, String(pid)], {}, execute);
305
+ return answer === 'live' || answer === 'absent' ? answer : 'unavailable';
306
+ },
307
+ psStart: async (pid) => {
308
+ let ps;
309
+ for (const candidate of ['/bin/ps', '/usr/bin/ps']) {
310
+ const resolved = await resolveExecutablePath(candidate);
311
+ if (resolved.state === 'available') {
312
+ ps = resolved.value;
313
+ break;
314
+ }
315
+ }
316
+ if (!ps)
317
+ return { kind: 'failed', reason: 'ps is not installed at /bin/ps or /usr/bin/ps' };
318
+ const text = await runBoundedProbe(ps, `process:start:${pid}`, ['-o', 'lstart=', '-p', String(pid)], { LC_ALL: 'C', TZ: 'UTC0' }, execute);
319
+ return text === undefined ? { kind: 'failed', reason: `ps did not report when process ${pid} started` } : { kind: 'complete', text };
320
+ },
321
+ readFile: (path) => readFile(path, 'utf8'),
322
+ };
323
+ }
324
+ // Reports `live` or `absent` for a pid; twice, so a transient answer reads as `unavailable`.
325
+ const PROCESS_PRESENCE_PROBE = "const p=Number(process.argv[1]);const c=()=>{try{process.kill(p,0);return'live'}catch(e){return e&&e.code==='ESRCH'?'absent':'unavailable'}};const a=c(),b=c();process.stdout.write(a===b?a:'unavailable')";
326
+ // One short read-only step through the executor; its stdout when it exits 0, otherwise undefined.
327
+ async function runBoundedProbe(executable, id, args, env, execute) {
328
+ const step = snapshotExecutionPlan({ steps: [{
329
+ id, argv: [executable.canonicalPath, ...args],
330
+ argv0: executable.invocationPath, cwd: '/', env: { ...env }, timeoutMs: 5_000,
331
+ stdoutLimitBytes: 256, stderrLimitBytes: 1_024, paths: [], ports: [],
332
+ labels: { probe: id }, expected: [],
333
+ consumedFiles: [{ path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912,
334
+ argvIndexes: [0], executable: true, executionMode: executable.executionMode }],
335
+ filesystemChecks: [], compensation: [],
336
+ }] })?.steps[0];
337
+ if (!step)
338
+ return undefined;
339
+ const outcome = authenticatedExecutionOutcome(await execute(step), step);
340
+ return outcome?.kind === 'complete' && outcome.execution.exitCode === 0 ? Buffer.from(outcome.stdout).toString('utf8') : undefined;
341
+ }
342
+ function describeHolder(holder) {
343
+ return holder.pid === undefined ? holder.holderId : `${holder.holderId} (process ${holder.pid})`;
344
+ }
345
+ // Names each other holder and how to end it. A dead session's hold is released before this is
346
+ // reached, so every holder named here is running, or could not be confirmed gone.
347
+ export function liveHolderMessage(holders) {
348
+ const pids = holders.flatMap(({ pid }) => pid === undefined ? [] : [pid]);
349
+ return `another cockpit session holds this stack (${holders.map(describeHolder).join(', ')}), and down and reset never displace a running session; `
350
+ + `let its command finish${pids.length > 0 ? ` or stop it with \`kill ${pids.join(' ')}\`` : ''}, then retry`;
351
+ }
352
+ // Every probe is a child process; a record may name thousands of holders.
353
+ const MAX_CONCURRENT_HOLDER_PROBES = 4;
354
+ /**
355
+ * Remove the holds of `cockpit session` wrappers whose process is gone. A session killed with
356
+ * SIGKILL, a crash or a closed terminal never runs its closing `sleep`, and its hold then made
357
+ * `down`, `reset` and `sleep` refuse forever while naming a session nobody could end. When every
358
+ * holder is dead, the requesting holder takes the stack over in the same transaction: the stack is
359
+ * still running, and a lease no holder retains reads as a stopped stack whose ports are free.
360
+ */
361
+ export async function releaseDeadSessionHolders(input) {
362
+ const stored = await readState(input.namespace.paths.coordination(input.stackId));
363
+ if (stored.kind === 'missing')
364
+ return { kind: 'complete', released: [] };
365
+ if (stored.kind !== 'complete')
366
+ return stateRefusal(stored);
367
+ const value = decodeCoordinationRecord(stored.value, input.stackId);
368
+ if (!value)
369
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', input.stackId);
370
+ if (isDrainActive(value))
371
+ return { kind: 'complete', released: [] };
372
+ const candidates = value.holders.filter((holder) => holder.holderId !== input.successor.holderId && holder.pid !== undefined);
373
+ // Indexed slots keep the verdicts in holder order whatever order the probes finish in.
374
+ const observed = new Array(candidates.length);
375
+ let next = 0;
376
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_HOLDER_PROBES, candidates.length) }, async () => {
377
+ while (next < candidates.length) {
378
+ const index = next++;
379
+ observed[index] = await input.observe(candidates[index].pid);
380
+ }
381
+ }));
382
+ const dead = candidates.filter((holder, index) => isProcessGone(holder.processStart, observed[index]));
383
+ if (dead.length === 0)
384
+ return { kind: 'complete', released: [] };
385
+ let committed = [];
386
+ const result = await transactClaim(input.namespace, input.stackId, input.successor.holderId, (current) => {
387
+ const latest = decodeCoordinationRecord(current, input.stackId);
388
+ if (!latest)
389
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', input.stackId));
390
+ if (isDrainActive(latest) || latest.lease === null || latest.lease.released) {
391
+ return abortStateTransaction({ kind: 'complete', released: [] });
392
+ }
393
+ // Only a holder still recorded exactly as it was observed is released; one that changed
394
+ // since is someone else's to judge.
395
+ const released = dead.filter((holder) => latest.holders.some((candidate) => candidate.holderId === holder.holderId
396
+ && candidate.claimedAt === holder.claimedAt && candidate.pid === holder.pid
397
+ && candidate.processStart === holder.processStart));
398
+ if (released.length === 0)
399
+ return abortStateTransaction({ kind: 'complete', released: [] });
400
+ committed = released;
401
+ const remaining = latest.holders.filter((holder) => !released.some(({ holderId }) => holderId === holder.holderId));
402
+ const reduced = { ...latest, holders: remaining, lastReleasedHolder: released.at(-1).holderId };
403
+ const next = remaining.length > 0 ? reduced : addCoordinationHolder(reduced, input.successor, input.now)?.value;
404
+ if (!next)
405
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination holder set cannot accept the successor holder', input.successor.holderId));
406
+ return coordinationStateRecord(input.namespace, input.stackId, current, input.now, next);
407
+ });
408
+ if ('diagnostics' in result || 'released' in result)
409
+ return result;
410
+ if (result.kind !== 'complete')
411
+ return result.kind === 'missing'
412
+ ? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'claim transaction returned no record', result.path) : stateRefusal(result);
413
+ return { kind: 'complete', released: committed };
414
+ }
415
+ function diagnostic(code, message, target, fix) {
416
+ return { severity: 'error', code, message, target, ...(fix ? { fix } : {}) };
417
+ }
418
+ function refuse(code, message, target, fix) {
419
+ return { kind: 'refusal', diagnostics: [diagnostic(code, message, target, fix)] };
420
+ }
421
+ export async function readActiveStackInventory(namespace, options = {}) {
422
+ return readCoordinationInventory(namespace, options);
423
+ }
424
+ async function readCoordinationInventory(namespace, options, budget) {
425
+ const ownedNamespace = snapshotStateNamespace(namespace);
426
+ if (!ownedNamespace)
427
+ return refuse(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'state namespace is malformed', 'state namespace');
428
+ namespace = ownedNamespace;
429
+ const directory = dirname(namespace.paths.coordination(`stack:${'0'.repeat(64)}`).path);
430
+ let inspected = false;
431
+ try {
432
+ const before = await lstat(directory, { bigint: true });
433
+ inspected = true;
434
+ if (!before.isDirectory() || before.isSymbolicLink()) {
435
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store is not a safe directory', directory);
436
+ }
437
+ const names = [];
438
+ const handle = await opendir(directory);
439
+ try {
440
+ for await (const entry of handle) {
441
+ if (names.length >= STRUCTURED_INPUT_ENTRY_LIMIT) {
442
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store exceeds its entry boundary', directory);
443
+ }
444
+ if (budget && ++budget.entries > STRUCTURED_INPUT_ENTRY_LIMIT) {
445
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination inventory exceeds its aggregate entry boundary', directory);
446
+ }
447
+ names.push(entry.name);
448
+ }
449
+ }
450
+ finally {
451
+ await handle.close().catch(() => undefined);
452
+ }
453
+ const records = [];
454
+ const observedRecords = new Map();
455
+ for (const name of names.sort()) {
456
+ if (budget) {
457
+ const path = join(directory, name);
458
+ const status = await lstat(path);
459
+ if (!status.isFile() || status.isSymbolicLink() || !Number.isSafeInteger(status.size)
460
+ || (budget.bytes += status.size) > SERIALIZED_RECORD_BYTE_LIMIT) {
461
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination inventory exceeds its aggregate byte boundary or contains an unsafe entry', path);
462
+ }
463
+ }
464
+ const entry = parseCoordinationStateEntry(name);
465
+ if (entry?.kind === 'temporary') {
466
+ const lock = await inspectStateLock(namespace, options.observeHolder);
467
+ const residue = join(directory, name);
468
+ const [currentResidue, afterLockInspection] = await Promise.all([
469
+ lstat(residue),
470
+ lstat(directory, { bigint: true }),
471
+ ]);
472
+ if (!currentResidue.isFile() || currentResidue.isSymbolicLink()
473
+ || !afterLockInspection.isDirectory() || afterLockInspection.isSymbolicLink()
474
+ || afterLockInspection.dev !== before.dev || afterLockInspection.ino !== before.ino
475
+ || afterLockInspection.mtimeNs !== before.mtimeNs || afterLockInspection.ctimeNs !== before.ctimeNs) {
476
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store changed during observation', directory);
477
+ }
478
+ if (lock.holderState === 'live') {
479
+ return refuse(ENVIRONMENT_CODES.STATE_LOCKED, 'coordination publication is in progress', residue, 'retry after the coordination transaction completes');
480
+ }
481
+ if (lock.holderState === 'unknown') {
482
+ return refuse(ENVIRONMENT_CODES.STATE_LOCKED, 'coordination publication ownership is unverified', residue, lock.recovery);
483
+ }
484
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store contains an orphaned unverified publication residue', residue, `${lock.description}; ${lock.recovery}; remove the residue only after proving that no writer owns it`);
485
+ }
486
+ if (entry?.kind === 'executor') {
487
+ const path = join(directory, name);
488
+ const marker = await lstat(path, { bigint: true });
489
+ const stored = await readState(namespace.paths.coordination(entry.stackId));
490
+ const record = stored.kind === 'complete' ? decodeCoordinationRecord(stored.value, entry.stackId) : undefined;
491
+ const after = await lstat(path, { bigint: true });
492
+ // The executor owns marker contents and repair. Inventory only admits a safe
493
+ // auxiliary file for the exact independently recorded drain attempt.
494
+ if (!marker.isFile() || marker.isSymbolicLink() || marker.nlink !== 1n
495
+ || marker.uid !== BigInt(process.getuid?.() ?? -1) || (marker.mode & 18n) !== 0n
496
+ || marker.size > BigInt(SERIALIZED_RECORD_BYTE_LIMIT)
497
+ || !after.isFile() || after.dev !== marker.dev || after.ino !== marker.ino
498
+ || after.size !== marker.size || after.mtimeNs !== marker.mtimeNs || after.ctimeNs !== marker.ctimeNs
499
+ || after.uid !== marker.uid || after.mode !== marker.mode || after.nlink !== marker.nlink
500
+ || !record?.drain?.attempt || record.drain.attempt.attemptId !== entry.attemptId
501
+ || stored.kind !== 'complete'
502
+ || exactRecordFingerprint(stored.value) !== exactRecordFingerprint(observedRecords.get(entry.stackId))) {
503
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination executor marker is unsafe or does not match its current drain attempt', path);
504
+ }
505
+ continue;
506
+ }
507
+ if (!entry || entry.kind !== 'record') {
508
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store contains an unexpected entry', join(directory, name));
509
+ }
510
+ const stackId = entry.stackId;
511
+ const result = await readState(namespace.paths.coordination(stackId));
512
+ if (result.kind !== 'complete') {
513
+ return refuse(result.kind === 'missing' ? ENVIRONMENT_CODES.STATE_RECORD_MALFORMED : result.code, result.kind === 'missing' ? 'coordination record disappeared during observation' : result.message, result.kind === 'missing' ? result.path : result.target);
514
+ }
515
+ const record = decodeCoordinationRecord(result.value, stackId);
516
+ if (!record)
517
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', result.value.subjectId);
518
+ observedRecords.set(stackId, result.value);
519
+ if (record.holders.length > 0 || record.lease !== null && !record.lease.released)
520
+ records.push({ stackId, record });
521
+ }
522
+ const after = await lstat(directory, { bigint: true });
523
+ if (!after.isDirectory() || after.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino
524
+ || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
525
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store changed during observation', directory);
526
+ }
527
+ return { kind: 'complete', value: records };
528
+ }
529
+ catch (error) {
530
+ if (hasErrorCode(error, 'ENOENT')) {
531
+ return inspected
532
+ ? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination store changed during observation', directory)
533
+ : { kind: 'complete', value: [] };
534
+ }
535
+ return refuse(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'coordination store could not be inspected', directory);
536
+ }
537
+ }
538
+ // Port leases are machine-wide, so this reads every cockpit's coordination records. `skipped` names
539
+ // each other cockpit's namespace this version cannot read; its leases are not seen, and the caller
540
+ // reports it so a person can remove it. The caller's own namespace, a contended lock and the
541
+ // shared read bounds still refuse.
542
+ export async function readRootPortInventory(namespace, options = {}) {
543
+ const result = await withStateNamespaceInventory(namespace, async (namespaces, unreadable) => {
544
+ const records = [];
545
+ const skipped = [...unreadable];
546
+ const budget = { entries: namespaces.length, bytes: 0 };
547
+ for (const candidate of namespaces) {
548
+ const inventory = await readCoordinationInventory(candidate, options, budget);
549
+ if (inventory.kind !== 'complete') {
550
+ const diagnostic = inventory.diagnostics[0];
551
+ if (candidate.cockpitRoot === namespace.cockpitRoot || diagnostic?.code === ENVIRONMENT_CODES.STATE_LOCKED
552
+ || budget.entries > STRUCTURED_INPUT_ENTRY_LIMIT || budget.bytes > SERIALIZED_RECORD_BYTE_LIMIT)
553
+ return inventory;
554
+ skipped.push({ path: candidate.cockpitRoot, reason: diagnostic?.message ?? 'coordination records are unreadable' });
555
+ continue;
556
+ }
557
+ const cockpitId = candidate.paths.coordination(`stack:${'0'.repeat(64)}`).cockpitId;
558
+ records.push(...inventory.value.map((record) => ({ cockpitId, ...record })));
559
+ }
560
+ return { kind: 'complete', value: records, skipped };
561
+ });
562
+ if (result.kind === 'complete')
563
+ return result.value;
564
+ if (result.kind === 'missing')
565
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'state namespace inventory disappeared during observation', result.path);
566
+ return refuse(result.code, result.message, result.target, result.recovery);
567
+ }
568
+ function validIdentity(value, pattern) {
569
+ return typeof value === 'string' && pattern.test(value);
570
+ }
571
+ function hasExactKeys(value, keys) {
572
+ try {
573
+ const actual = Reflect.ownKeys(Object.getOwnPropertyDescriptors(value));
574
+ return actual.length === keys.length && actual.every((key) => typeof key === 'string' && keys.includes(key));
575
+ }
576
+ catch {
577
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
578
+ return false;
579
+ }
580
+ }
581
+ export function decodeCoordinationRecord(record, stackId) {
582
+ if (record === null)
583
+ return { holders: [], lease: null, lastReleasedHolder: null, drain: null };
584
+ if (record.kind !== 'coordination' || record.subjectId !== stackId)
585
+ return undefined;
586
+ const parsed = CoordinationValueSchema.safeParse(record.value);
587
+ if (!parsed.success)
588
+ return undefined;
589
+ if (parsed.data.drain !== null && parsed.data.drain.stackId !== stackId)
590
+ return undefined;
591
+ if (parsed.data.drain !== null) {
592
+ const boundaryRevision = parsed.data.drain.attempt?.status === 'failed'
593
+ ? parsed.data.drain.attempt.failedRevision
594
+ : parsed.data.drain.attempt?.committedRevision ?? parsed.data.drain.progressRevision;
595
+ if (record.revision !== boundaryRevision)
596
+ return undefined;
597
+ }
598
+ return {
599
+ holders: parsed.data.holders
600
+ .map((holder) => ({
601
+ holderId: holder.holderId,
602
+ checkoutId: holder.checkoutId,
603
+ claimedAt: holder.claimedAt,
604
+ ...(holder.pid === undefined ? {} : { pid: holder.pid }),
605
+ ...(holder.processStart === undefined ? {} : { processStart: holder.processStart }),
606
+ }))
607
+ .sort((left, right) => left.holderId < right.holderId ? -1 : left.holderId > right.holderId ? 1 : 0),
608
+ lease: parsed.data.lease === null ? null : {
609
+ offset: parsed.data.lease.offset,
610
+ ports: { ...parsed.data.lease.ports },
611
+ released: parsed.data.lease.released,
612
+ declaration: {
613
+ step: parsed.data.lease.declaration.step,
614
+ count: parsed.data.lease.declaration.count,
615
+ bases: { ...parsed.data.lease.declaration.bases },
616
+ },
617
+ },
618
+ lastReleasedHolder: parsed.data.lastReleasedHolder,
619
+ drain: parsed.data.drain === null ? null : snapshotDrainReservation({
620
+ ...parsed.data.drain,
621
+ stackId: parsed.data.drain.stackId,
622
+ holderId: parsed.data.drain.holderId,
623
+ requesterId: parsed.data.drain.requesterId,
624
+ }),
625
+ };
626
+ }
627
+ export function addCoordinationHolder(value, holder, now) {
628
+ const existing = value.holders.find((candidate) => candidate.holderId === holder.holderId);
629
+ if (existing && existing.checkoutId !== holder.checkoutId)
630
+ return undefined;
631
+ if (existing)
632
+ return { value, change: 'unchanged' };
633
+ return {
634
+ value: {
635
+ ...value,
636
+ holders: [...value.holders, { ...holder, claimedAt: now }]
637
+ .sort((left, right) => left.holderId < right.holderId ? -1 : left.holderId > right.holderId ? 1 : 0),
638
+ lastReleasedHolder: value.lastReleasedHolder === holder.holderId ? null : value.lastReleasedHolder,
639
+ },
640
+ change: 'acquired',
641
+ };
642
+ }
643
+ export function removeExactCoordinationHolder(value, holderId) {
644
+ try {
645
+ if (!holderIdentity.test(holderId) || value.lease === null || value.lease.released
646
+ || value.drain != null)
647
+ return undefined;
648
+ const holders = value.holders.filter((holder) => holder.holderId !== holderId);
649
+ if (holders.length !== value.holders.length - 1)
650
+ return undefined;
651
+ return {
652
+ ...value,
653
+ holders,
654
+ lastReleasedHolder: holderId,
655
+ };
656
+ }
657
+ catch {
658
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
659
+ return undefined;
660
+ }
661
+ }
662
+ export function coordinationStateRecord(namespace, stackId, current, now, value) {
663
+ return {
664
+ format: 'cockpit-state/v1',
665
+ kind: 'coordination',
666
+ cockpitId: namespace.paths.coordination(stackId).cockpitId,
667
+ subjectId: stackId,
668
+ revision: current === null ? 0 : current.revision + 1,
669
+ updatedAt: now,
670
+ value: {
671
+ holders: value.holders.map((holder) => ({ ...holder })),
672
+ lease: value.lease === null ? null : {
673
+ ...value.lease,
674
+ ports: { ...value.lease.ports },
675
+ declaration: { ...value.lease.declaration, bases: { ...value.lease.declaration.bases } },
676
+ },
677
+ lastReleasedHolder: value.lastReleasedHolder,
678
+ drain: value.drain == null ? null : {
679
+ ...value.drain,
680
+ targetIds: [...value.drain.targetIds],
681
+ actions: value.drain.actions.map((action) => ({
682
+ stage: action.stage,
683
+ targetId: action.targetId,
684
+ validForMs: action.validForMs,
685
+ ...(action.stage === 'runtime-stop' ? {} : {
686
+ targetCommitment: { ...action.targetCommitment },
687
+ }),
688
+ })),
689
+ attempt: value.drain.attempt === null ? null : {
690
+ ...value.drain.attempt,
691
+ targetIds: [...value.drain.attempt.targetIds],
692
+ },
693
+ },
694
+ },
695
+ };
696
+ }
697
+ function snapshotDrainRequest(value) {
698
+ try {
699
+ if (!value || typeof value !== 'object' || Array.isArray(value))
700
+ return undefined;
701
+ const keys = ['purpose', 'stackId', 'holderId', 'requesterId', 'configSha256', 'planDigest', 'operationBasis', 'generation', 'targetIds', 'actions', 'expectedCoordinationRevision'];
702
+ const descriptors = Object.getOwnPropertyDescriptors(value);
703
+ const supplied = Reflect.ownKeys(descriptors);
704
+ if (supplied.length !== keys.length || supplied.some((key) => typeof key !== 'string')
705
+ || keys.some((key) => !supplied.includes(key) || !('value' in descriptors[key])))
706
+ return undefined;
707
+ const request = Object.fromEntries(keys.map((key) => [key, descriptors[key].value]));
708
+ const targetIds = snapshotDrainTargets(request.targetIds);
709
+ const actions = snapshotDrainActions(request.actions);
710
+ if ((request.purpose !== 'sleep' && request.purpose !== 'down' && request.purpose !== 'reset')
711
+ || !validIdentity(request.stackId, stackIdentity)
712
+ || !validIdentity(request.holderId, holderIdentity)
713
+ || !validIdentity(request.requesterId, holderIdentity)
714
+ || !sha256Identity.test(request.configSha256)
715
+ || !sha256Identity.test(request.planDigest)
716
+ || !sha256Identity.test(request.operationBasis)
717
+ || !sha256Identity.test(request.generation)
718
+ || !targetIds || !actions || !validDrainActions(request.purpose, request.stackId, targetIds, actions)
719
+ || !Number.isSafeInteger(request.expectedCoordinationRevision)
720
+ || request.expectedCoordinationRevision < 0
721
+ || request.expectedCoordinationRevision >= Number.MAX_SAFE_INTEGER)
722
+ return undefined;
723
+ return {
724
+ purpose: request.purpose, stackId: request.stackId, holderId: request.holderId,
725
+ requesterId: request.requesterId, configSha256: request.configSha256,
726
+ planDigest: request.planDigest, operationBasis: request.operationBasis,
727
+ generation: request.generation, targetIds, actions,
728
+ expectedCoordinationRevision: request.expectedCoordinationRevision,
729
+ };
730
+ }
731
+ catch {
732
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
733
+ return undefined;
734
+ }
735
+ }
736
+ function sameDrainRequest(left, right) {
737
+ return left.purpose === right.purpose && left.stackId === right.stackId
738
+ && left.holderId === right.holderId && left.requesterId === right.requesterId
739
+ && left.configSha256 === right.configSha256 && left.planDigest === right.planDigest
740
+ && left.operationBasis === right.operationBasis
741
+ && left.generation === right.generation && sameTargets(left.targetIds, right.targetIds)
742
+ && sameDrainActions(left.actions, right.actions)
743
+ && left.expectedCoordinationRevision === right.expectedCoordinationRevision;
744
+ }
745
+ function sameDrainAttemptBinding(left, right) {
746
+ return left.attemptId === right.attemptId && left.reservationId === right.reservationId
747
+ && left.purpose === right.purpose && sameTargets(left.targetIds, right.targetIds)
748
+ && left.generation === right.generation && left.actionIndex === right.actionIndex && left.currentTarget === right.currentTarget
749
+ && left.currentStage === right.currentStage && left.committedRevision === right.committedRevision
750
+ && left.committedAt === right.committedAt && left.validThrough === right.validThrough;
751
+ }
752
+ function sameDrainAttempt(left, right) {
753
+ return sameDrainAttemptBinding(left, right) && left.status === right.status
754
+ && left.failedRevision === right.failedRevision && left.failedAt === right.failedAt;
755
+ }
756
+ function snapshotDrainReservation(reservation) {
757
+ return {
758
+ ...reservation,
759
+ targetIds: [...reservation.targetIds],
760
+ actions: reservation.actions.map(snapshotDrainAction),
761
+ attempt: reservation.attempt === null ? null : {
762
+ ...reservation.attempt,
763
+ targetIds: [...reservation.attempt.targetIds],
764
+ },
765
+ };
766
+ }
767
+ function snapshotDrainAction(action) {
768
+ return action.stage === 'runtime-stop'
769
+ ? { ...action }
770
+ : { ...action, targetCommitment: { ...action.targetCommitment } };
771
+ }
772
+ function snapshotDrainTargets(value) {
773
+ try {
774
+ if (!Array.isArray(value))
775
+ return undefined;
776
+ const descriptors = Object.getOwnPropertyDescriptors(value);
777
+ const lengthValue = descriptors.length?.value;
778
+ if (typeof lengthValue !== 'number' || !Number.isSafeInteger(lengthValue)
779
+ || lengthValue === 0 || lengthValue > STRUCTURED_INPUT_ENTRY_LIMIT)
780
+ return undefined;
781
+ if (Reflect.ownKeys(descriptors).some((key) => typeof key === 'symbol' || key !== 'length' && !/^(?:0|[1-9]\d*)$/.test(key)))
782
+ return undefined;
783
+ const targets = Array.from({ length: lengthValue }, (_, index) => {
784
+ const property = descriptors[String(index)];
785
+ if (!property || !('value' in property) || typeof property.value !== 'string')
786
+ throw new RefusalError('invalid target');
787
+ return property.value;
788
+ });
789
+ if (new Set(targets).size !== targets.length
790
+ || targets.some((target, index) => target.length === 0 || Buffer.byteLength(target, 'utf8') > 512
791
+ || target.includes('\0') || index > 0 && targets[index - 1] >= target))
792
+ return undefined;
793
+ return targets;
794
+ }
795
+ catch {
796
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
797
+ return undefined;
798
+ }
799
+ }
800
+ function snapshotDataOnly(value, seen = new WeakSet(), depth = 0) {
801
+ if (value === null || typeof value === 'string' || typeof value === 'boolean'
802
+ || typeof value === 'number' && Number.isFinite(value))
803
+ return value;
804
+ if (typeof value !== 'object' || depth > 16 || seen.has(value))
805
+ return undefined;
806
+ try {
807
+ const prototype = Object.getPrototypeOf(value);
808
+ if (prototype !== Object.prototype && prototype !== null && prototype !== Array.prototype)
809
+ return undefined;
810
+ seen.add(value);
811
+ const descriptors = Object.getOwnPropertyDescriptors(value);
812
+ if (Reflect.ownKeys(descriptors).some((key) => typeof key === 'symbol'))
813
+ return undefined;
814
+ if (Array.isArray(value)) {
815
+ const lengthValue = descriptors.length?.value;
816
+ if (typeof lengthValue !== 'number' || !Number.isSafeInteger(lengthValue)
817
+ || lengthValue < 0 || lengthValue > STRUCTURED_INPUT_ENTRY_LIMIT)
818
+ return undefined;
819
+ const keys = Object.keys(descriptors).filter((key) => key !== 'length');
820
+ if (keys.length !== lengthValue)
821
+ return undefined;
822
+ const result = [];
823
+ for (let index = 0; index < lengthValue; index += 1) {
824
+ const descriptor = descriptors[String(index)];
825
+ if (!descriptor || !('value' in descriptor))
826
+ return undefined;
827
+ const item = snapshotDataOnly(descriptor.value, seen, depth + 1);
828
+ if (item === undefined && descriptor.value !== undefined)
829
+ return undefined;
830
+ result.push(item);
831
+ }
832
+ return result;
833
+ }
834
+ const result = {};
835
+ for (const [key, descriptor] of Object.entries(descriptors)) {
836
+ if (!('value' in descriptor))
837
+ return undefined;
838
+ const item = snapshotDataOnly(descriptor.value, seen, depth + 1);
839
+ if (item === undefined && descriptor.value !== undefined)
840
+ return undefined;
841
+ result[key] = item;
842
+ }
843
+ return result;
844
+ }
845
+ catch {
846
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
847
+ return undefined;
848
+ }
849
+ }
850
+ function snapshotDrainActions(value) {
851
+ try {
852
+ if (!Array.isArray(value))
853
+ return undefined;
854
+ const descriptors = Object.getOwnPropertyDescriptors(value);
855
+ const lengthValue = descriptors.length?.value;
856
+ if (typeof lengthValue !== 'number' || !Number.isSafeInteger(lengthValue)
857
+ || lengthValue === 0 || lengthValue > STRUCTURED_INPUT_ENTRY_LIMIT)
858
+ return undefined;
859
+ if (Reflect.ownKeys(descriptors).some((key) => typeof key === 'symbol' || key !== 'length' && !/^(?:0|[1-9]\d*)$/.test(key)))
860
+ return undefined;
861
+ const actions = [];
862
+ for (let index = 0; index < lengthValue; index += 1) {
863
+ const item = descriptors[String(index)];
864
+ if (!item || !('value' in item) || !item.value || typeof item.value !== 'object' || Array.isArray(item.value))
865
+ return undefined;
866
+ const snapshot = snapshotDataOnly(item.value);
867
+ const parsed = DrainActionSchema.safeParse(snapshot);
868
+ if (!parsed.success)
869
+ return undefined;
870
+ actions.push(snapshotDrainAction(parsed.data));
871
+ }
872
+ return actions;
873
+ }
874
+ catch {
875
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
876
+ return undefined;
877
+ }
878
+ }
879
+ function sameDrainActions(left, right) {
880
+ return left.length === right.length && left.every((action, index) => action.stage === right[index]?.stage
881
+ && action.targetId === right[index]?.targetId && action.validForMs === right[index]?.validForMs
882
+ && (action.stage === 'runtime-stop' || right[index]?.stage === 'reclaim'
883
+ && JSON.stringify(action.targetCommitment) === JSON.stringify(right[index].targetCommitment)));
884
+ }
885
+ // Persisting the order prevents callers and executors from choosing destructive work.
886
+ function validDrainActions(purpose, stackId, targetIds, actions) {
887
+ const reclaimTargets = targetIds.filter((target) => target !== stackId);
888
+ return actions.length === targetIds.length
889
+ && actions[0]?.stage === 'runtime-stop' && actions[0].targetId === stackId
890
+ && actions.slice(1).every((action, index) => action.stage === 'reclaim' && action.targetId === reclaimTargets[index])
891
+ && sameTargets([...actions.map(({ targetId }) => targetId)].sort(), targetIds)
892
+ && (purpose !== 'sleep' || actions.length === 1);
893
+ }
894
+ function sameTargets(left, right) {
895
+ return left.length === right.length && left.every((target, index) => target === right[index]);
896
+ }
897
+ function exactRecordFingerprint(record) {
898
+ const snapshot = snapshotDataOnly(record);
899
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)
900
+ || !hasExactKeys(snapshot, ['format', 'kind', 'cockpitId', 'subjectId', 'revision', 'updatedAt', 'value']))
901
+ return undefined;
902
+ const candidate = snapshot;
903
+ if (candidate.format !== 'cockpit-state/v1' || candidate.kind !== 'coordination'
904
+ || typeof candidate.cockpitId !== 'string' || candidate.cockpitId.length === 0
905
+ || typeof candidate.subjectId !== 'string' || candidate.subjectId.length === 0
906
+ || typeof candidate.revision !== 'number' || !Number.isInteger(candidate.revision) || candidate.revision < 0
907
+ || typeof candidate.updatedAt !== 'string' || !isCanonicalTimestamp(candidate.updatedAt)
908
+ || !z.json().safeParse(candidate.value).success)
909
+ return undefined;
910
+ return JSON.stringify(canonicalJsonValue(snapshot));
911
+ }
912
+ export function isDrainActive(value) {
913
+ return value.drain != null;
914
+ }
915
+ export async function reserveDrain(input) {
916
+ let namespace;
917
+ let request;
918
+ try {
919
+ if (!input || typeof input !== 'object' || Array.isArray(input) || !hasExactKeys(input, ['namespace', 'request']))
920
+ throw new RefusalError('invalid');
921
+ const descriptors = Object.getOwnPropertyDescriptors(input);
922
+ if (!('value' in descriptors.namespace) || !('value' in descriptors.request))
923
+ throw new RefusalError('invalid');
924
+ namespace = snapshotStateNamespace(descriptors.namespace.value);
925
+ request = snapshotDrainRequest(descriptors.request.value);
926
+ }
927
+ catch {
928
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
929
+ namespace = undefined;
930
+ request = undefined;
931
+ }
932
+ if (!namespace || !request)
933
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain reservation input is malformed', 'drain reservation');
934
+ const reservedAt = new Date().toISOString();
935
+ const reservationId = randomBytes(32).toString('hex');
936
+ const reservation = {
937
+ ...request,
938
+ coordinationRevision: request.expectedCoordinationRevision + 1,
939
+ completedActionCount: 0,
940
+ progressRevision: request.expectedCoordinationRevision + 1,
941
+ reservedAt,
942
+ reservationId,
943
+ attempt: null,
944
+ };
945
+ const result = await transactClaim(namespace, request.stackId, request.requesterId, (current) => {
946
+ const value = decodeCoordinationRecord(current, request.stackId);
947
+ if (!value || current === null || current.revision !== request.expectedCoordinationRevision) {
948
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'drain reservation requires the exact current coordination revision', request.stackId));
949
+ }
950
+ if (value.drain !== null)
951
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'the stack already has an exclusive mutation', request.stackId));
952
+ if (request.holderId !== request.requesterId
953
+ || value.holders.length !== 1
954
+ || value.holders[0]?.holderId !== request.holderId
955
+ || value.lease === null || value.lease.released) {
956
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'drain reservation requires retained holder and lease ownership', request.stackId));
957
+ }
958
+ return coordinationStateRecord(namespace, request.stackId, current, reservedAt, { ...value, drain: reservation });
959
+ });
960
+ if ('diagnostics' in result || 'reservation' in result)
961
+ return result;
962
+ if (result.kind !== 'complete') {
963
+ return result.kind === 'missing'
964
+ ? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain reservation returned no record', result.path)
965
+ : stateRefusal(result);
966
+ }
967
+ const value = decodeCoordinationRecord(result.value, request.stackId);
968
+ if (!value?.drain || !sameDrainRequest(value.drain, request) || value.drain.reservationId !== reservationId) {
969
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'published drain reservation is malformed', request.stackId);
970
+ }
971
+ return { kind: 'complete', reservation: value.drain };
972
+ }
973
+ function reduceDrainAttemptState(record, reservation, attempt, retry) {
974
+ const value = decodeCoordinationRecord(record, reservation.stackId);
975
+ const currentAction = reservation.actions[reservation.completedActionCount];
976
+ const prior = reservation.attempt;
977
+ const expectedRevision = retry ? prior?.failedRevision : reservation.progressRevision;
978
+ if (!value?.drain || !currentAction || expectedRevision === null || expectedRevision === undefined
979
+ || record.revision !== expectedRevision || attempt.status !== 'active' || attempt.failedRevision !== null || attempt.failedAt !== null
980
+ || attempt.reservationId !== reservation.reservationId || attempt.purpose !== reservation.purpose
981
+ || !sameTargets(attempt.targetIds, reservation.targetIds) || attempt.generation !== reservation.generation
982
+ || attempt.actionIndex !== reservation.completedActionCount || attempt.currentStage !== currentAction.stage
983
+ || attempt.currentTarget !== currentAction.targetId || attempt.committedRevision !== expectedRevision + 1
984
+ || Date.parse(attempt.validThrough) - Date.parse(attempt.committedAt) !== currentAction.validForMs
985
+ || !sameDrainRequest(value.drain, reservation) || value.drain.reservationId !== reservation.reservationId
986
+ || value.drain.reservedAt !== reservation.reservedAt)
987
+ return undefined;
988
+ if (retry) {
989
+ if (!prior || prior.status !== 'failed' || value.drain.attempt?.status !== 'failed'
990
+ || !sameDrainAttempt(value.drain.attempt, prior)
991
+ || prior.failedAt === null || Date.parse(attempt.committedAt) < Date.parse(prior.failedAt))
992
+ return undefined;
993
+ }
994
+ else if (prior !== null || value.drain.attempt !== null)
995
+ return undefined;
996
+ const proposed = { ...value, drain: { ...value.drain, attempt } };
997
+ return CoordinationValueSchema.safeParse(proposed).success ? proposed : undefined;
998
+ }
999
+ /**
1000
+ * Commits the next attempt of a reservation: its first try, or a retry of the action whose try was
1001
+ * published as failed. The commit admits only the exact persisted reservation at the revision it
1002
+ * names, so two holders of one reservation cannot both start an attempt.
1003
+ */
1004
+ export async function reserveDrainAttempt(input) {
1005
+ const namespace = snapshotStateNamespace(input.namespace);
1006
+ const { reservation } = input;
1007
+ const currentAction = reservation.actions[reservation.completedActionCount];
1008
+ if (!namespace || !currentAction || reservation.attempt?.status === 'active'
1009
+ || reservation.attempt?.status === 'failed' && (currentAction.targetId !== reservation.attempt.currentTarget
1010
+ || currentAction.stage !== reservation.attempt.currentStage
1011
+ || reservation.completedActionCount !== reservation.attempt.actionIndex)) {
1012
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain attempt input is malformed', 'drain attempt');
1013
+ }
1014
+ const committedAt = new Date().toISOString();
1015
+ const priorRevision = reservation.attempt?.status === 'failed'
1016
+ ? reservation.attempt.failedRevision
1017
+ : reservation.progressRevision;
1018
+ const validThrough = new Date(Date.parse(committedAt) + currentAction.validForMs).toISOString();
1019
+ const attempt = {
1020
+ attemptId: randomBytes(32).toString('hex'), reservationId: reservation.reservationId,
1021
+ purpose: reservation.purpose, targetIds: reservation.targetIds, generation: reservation.generation,
1022
+ actionIndex: reservation.completedActionCount, currentTarget: currentAction.targetId, currentStage: currentAction.stage,
1023
+ committedRevision: priorRevision + 1, committedAt, validThrough,
1024
+ status: 'active', failedRevision: null, failedAt: null,
1025
+ };
1026
+ const fence = drainExecutionFence(attempt, namespace.paths.coordination(reservation.stackId));
1027
+ if (!fence)
1028
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain attempt execution fence is malformed', 'drain attempt');
1029
+ const transaction = await transactClaim(namespace, reservation.stackId, reservation.requesterId, (current) => {
1030
+ const reduced = current === null ? undefined : reduceDrainAttemptState(current, reservation, attempt, reservation.attempt !== null);
1031
+ if (!reduced) {
1032
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'drain attempt requires an exact unstarted or failed reservation', reservation.stackId));
1033
+ }
1034
+ return coordinationStateRecord(namespace, reservation.stackId, current, committedAt, reduced);
1035
+ });
1036
+ if ('diagnostics' in transaction || 'attempt' in transaction)
1037
+ return transaction;
1038
+ if (transaction.kind !== 'complete') {
1039
+ return transaction.kind === 'missing'
1040
+ ? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain attempt returned no record', transaction.path)
1041
+ : stateRefusal(transaction);
1042
+ }
1043
+ const value = decodeCoordinationRecord(transaction.value, reservation.stackId);
1044
+ if (!value?.drain?.attempt || value.drain.attempt.attemptId !== attempt.attemptId
1045
+ || transaction.value.revision !== attempt.committedRevision) {
1046
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'published drain attempt is malformed', reservation.stackId);
1047
+ }
1048
+ return { kind: 'complete', reservation: value.drain, attempt: value.drain.attempt, fence };
1049
+ }
1050
+ function sameSnapshot(left, right) {
1051
+ const leftSnapshot = snapshotJson(left);
1052
+ const rightSnapshot = snapshotJson(right);
1053
+ return leftSnapshot !== undefined && rightSnapshot !== undefined
1054
+ && JSON.stringify(leftSnapshot) === JSON.stringify(rightSnapshot);
1055
+ }
1056
+ function planningRecordPrecedesAttempt(record, reservation) {
1057
+ const snapshot = snapshotDataOnly(record);
1058
+ if (!snapshot || reservation.attempt === null || snapshot.subjectId !== reservation.stackId)
1059
+ return undefined;
1060
+ const value = decodeCoordinationRecord(snapshot, reservation.stackId);
1061
+ if (value === undefined)
1062
+ return undefined;
1063
+ if (value.drain == null) {
1064
+ // A record from before the reservation plans only the first try of an action: once a try
1065
+ // failed, a retry must be planned from the published failure, never from the original plan.
1066
+ return reservation.expectedCoordinationRevision === snapshot.revision
1067
+ && reservation.coordinationRevision === snapshot.revision + 1
1068
+ && reservation.attempt.committedRevision === reservation.progressRevision + 1 ? snapshot : undefined;
1069
+ }
1070
+ if (reservation.attempt.committedRevision !== snapshot.revision + 1)
1071
+ return undefined;
1072
+ if (value.drain.attempt === null) {
1073
+ if (snapshot.revision !== value.drain.progressRevision
1074
+ || !sameSnapshot({ ...reservation, attempt: null }, value.drain))
1075
+ return undefined;
1076
+ return snapshot;
1077
+ }
1078
+ const failed = value.drain.attempt;
1079
+ if (failed.status !== 'failed' || failed.failedRevision !== snapshot.revision
1080
+ || failed.actionIndex !== reservation.attempt.actionIndex
1081
+ || failed.currentStage !== reservation.attempt.currentStage
1082
+ || failed.currentTarget !== reservation.attempt.currentTarget
1083
+ || failed.reservationId !== reservation.attempt.reservationId
1084
+ || failed.purpose !== reservation.attempt.purpose
1085
+ || failed.generation !== reservation.attempt.generation
1086
+ || !sameTargets(failed.targetIds, reservation.attempt.targetIds)
1087
+ || !sameSnapshot({ ...reservation, attempt: failed }, value.drain))
1088
+ return undefined;
1089
+ return snapshot;
1090
+ }
1091
+ /**
1092
+ * Binds an active reclaim attempt to the coordination record its removal was planned from. Undefined
1093
+ * when that record does not directly precede the attempt, so a plan never runs under another try.
1094
+ */
1095
+ export function reclaimDrainStep(input) {
1096
+ const { reservation } = input;
1097
+ const attempt = reservation.attempt;
1098
+ const action = attempt === null ? undefined : reservation.actions[attempt.actionIndex];
1099
+ if (!attempt || attempt.status !== 'active' || attempt.currentStage !== 'reclaim'
1100
+ || action?.stage !== 'reclaim' || action.targetId !== attempt.currentTarget)
1101
+ return undefined;
1102
+ const planningRecord = planningRecordPrecedesAttempt(input.planningRecord, reservation);
1103
+ return planningRecord === undefined ? undefined : { planningRecord, reservation, failedAt: null };
1104
+ }
1105
+ function drainExecutionFence(attempt, address) {
1106
+ const attemptId = attempt.attemptId;
1107
+ const markerPath = `${address.path}.${attemptId}.executor.json`;
1108
+ const socketRoot = join(platform() === 'darwin' ? '/private/tmp' : '/tmp', `cockpit-exec-${process.getuid?.() ?? 0}`);
1109
+ const socketPath = join(socketRoot, `${attemptId.slice(0, 24)}.sock`);
1110
+ return /^[a-f0-9]{64}$/.test(attemptId) && isAbsolute(markerPath) && normalize(markerPath) === markerPath
1111
+ && isAbsolute(socketPath) && normalize(socketPath) === socketPath && Buffer.byteLength(socketPath) <= 103
1112
+ ? { attemptId, markerPath, socketPath } : undefined;
1113
+ }
1114
+ /** The coordination value the outcome publishes over `record`, or undefined when it no longer applies. */
1115
+ export function reduceDrainOutcome(input) {
1116
+ const { outcome, record } = input;
1117
+ if (outcome.kind === 'failure')
1118
+ return reduceDrainAttemptFailure(record, outcome.reservation, outcome.failedAt);
1119
+ const attempt = outcome.reservation.attempt;
1120
+ return attempt !== null && attempt.actionIndex === outcome.reservation.actions.length - 1
1121
+ ? reduceDrain(record, outcome.reservation, outcome.fact)
1122
+ : reduceDrainAttemptSuccess(record, outcome.reservation, outcome.fact);
1123
+ }
1124
+ function outcomeObservedAt(outcome) {
1125
+ return outcome.kind === 'failure' ? outcome.failedAt : outcome.fact.observedAt;
1126
+ }
1127
+ /**
1128
+ * Publishes a settled drain outcome to the coordination record. The publication admits it only while
1129
+ * the record holds the outcome's exact active attempt, so an outcome publishes at most once.
1130
+ */
1131
+ export async function commitDrainOutcome(input) {
1132
+ const { outcome, stackId, beforePublish } = input;
1133
+ const namespace = snapshotStateNamespace(input.namespace);
1134
+ const reservation = outcome.reservation;
1135
+ const notBefore = outcomeObservedAt(outcome);
1136
+ // Ownership and the observation time come from the reservation and this clock, never the caller.
1137
+ if (!namespace || stackId !== reservation.stackId || reservation.requesterId !== reservation.holderId
1138
+ || !holderIdentity.test(reservation.requesterId) || !isCanonicalTimestamp(notBefore)) {
1139
+ return refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'drain outcome publication requires exact coordination authority and ownership', stackId);
1140
+ }
1141
+ const address = namespace.paths.coordination(stackId);
1142
+ let preparedAt;
1143
+ const publication = await transactState(namespace, address, {
1144
+ ownerId: reservation.requesterId,
1145
+ contentionTimeoutMs: 2_000,
1146
+ ...(beforePublish ? { beforePublish } : {}),
1147
+ beforeCommit: () => {
1148
+ const now = Date.now();
1149
+ return !preparedAt || now < Date.parse(notBefore) || now < Date.parse(preparedAt)
1150
+ ? abortStateTransaction(refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'drain outcome publication clock changed before commit', stackId))
1151
+ : undefined;
1152
+ },
1153
+ }, (current) => {
1154
+ const updatedAt = new Date().toISOString();
1155
+ if (!current || Date.parse(updatedAt) < Date.parse(notBefore)
1156
+ || current.kind !== 'coordination' || current.cockpitId !== address.cockpitId || current.subjectId !== stackId) {
1157
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'drain outcome publication requires an exact current record and a non-regressive observation time', stackId));
1158
+ }
1159
+ const value = reduceDrainOutcome({ outcome, record: current });
1160
+ if (!value) {
1161
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'drain outcome publication no longer matches the current coordination record', stackId));
1162
+ }
1163
+ preparedAt = updatedAt;
1164
+ return coordinationStateRecord(namespace, stackId, current, updatedAt, value);
1165
+ });
1166
+ return 'aborted' in publication ? publication.value : publication;
1167
+ }
1168
+ /**
1169
+ * Whether `record` is the one the step's outcome published: its removal progressed, or (for a step
1170
+ * that never started) its failure was recorded. Anything else is unmatched.
1171
+ */
1172
+ export function matchReclaimDrainContinuation(input) {
1173
+ const step = input.continuation;
1174
+ if (step === null)
1175
+ return 'unmatched';
1176
+ if (step.failedAt === null)
1177
+ return matchesDrainProgress(step, input.record) ? 'progressed' : 'unmatched';
1178
+ return matchesDrainFailure(step, step.failedAt, input.record) ? 'failed' : 'unmatched';
1179
+ }
1180
+ function followsPlanningRecord(step, record, notBefore) {
1181
+ const attempt = step.reservation.attempt;
1182
+ const snapshot = snapshotDataOnly(record);
1183
+ if (!attempt || !snapshot || snapshot.format !== step.planningRecord.format
1184
+ || snapshot.kind !== step.planningRecord.kind || snapshot.cockpitId !== step.planningRecord.cockpitId
1185
+ || snapshot.subjectId !== step.planningRecord.subjectId
1186
+ || snapshot.revision !== attempt.committedRevision + 1
1187
+ || !isCanonicalTimestamp(snapshot.updatedAt)
1188
+ || Date.parse(snapshot.updatedAt) < Date.parse(notBefore)
1189
+ || Date.parse(snapshot.updatedAt) > Date.now())
1190
+ return undefined;
1191
+ const predecessor = decodeCoordinationRecord(step.planningRecord, step.reservation.stackId);
1192
+ const value = decodeCoordinationRecord(snapshot, step.reservation.stackId);
1193
+ if (!predecessor || !value?.drain
1194
+ || !sameSnapshot(value.holders, predecessor.holders)
1195
+ || !sameSnapshot(value.lease, predecessor.lease)
1196
+ || value.lastReleasedHolder !== predecessor.lastReleasedHolder)
1197
+ return undefined;
1198
+ return value;
1199
+ }
1200
+ function matchesDrainProgress(step, record) {
1201
+ const attempt = step.reservation.attempt;
1202
+ const value = followsPlanningRecord(step, record, attempt.committedAt);
1203
+ return value !== undefined && sameSnapshot(value.drain, {
1204
+ ...step.reservation,
1205
+ completedActionCount: attempt.actionIndex + 1,
1206
+ progressRevision: attempt.committedRevision + 1,
1207
+ attempt: null,
1208
+ });
1209
+ }
1210
+ function matchesDrainFailure(step, failedAt, record) {
1211
+ const attempt = step.reservation.attempt;
1212
+ const value = followsPlanningRecord(step, record, failedAt);
1213
+ return value !== undefined && sameSnapshot(value.drain, {
1214
+ ...step.reservation,
1215
+ attempt: { ...attempt, status: 'failed', failedRevision: attempt.committedRevision + 1, failedAt },
1216
+ });
1217
+ }
1218
+ function reduceDrainAttemptFailure(record, reservation, failedAt) {
1219
+ if (!reservation.attempt || reservation.attempt.status !== 'active'
1220
+ || record.revision !== reservation.attempt.committedRevision
1221
+ || !isCanonicalTimestamp(failedAt)
1222
+ || Date.parse(failedAt) < Date.parse(reservation.attempt.committedAt)
1223
+ || Date.parse(failedAt) > Date.now())
1224
+ return undefined;
1225
+ const value = decodeCoordinationRecord(record, reservation.stackId);
1226
+ const attempt = value?.drain?.attempt;
1227
+ if (!value?.drain || !attempt || attempt.status !== 'active'
1228
+ || value.drain.reservationId !== reservation.reservationId
1229
+ || !sameDrainAttempt(attempt, reservation.attempt))
1230
+ return undefined;
1231
+ return {
1232
+ ...value,
1233
+ drain: {
1234
+ ...value.drain,
1235
+ attempt: { ...attempt, status: 'failed',
1236
+ failedRevision: attempt.committedRevision + 1, failedAt },
1237
+ },
1238
+ };
1239
+ }
1240
+ function drainRepair(address, record, reservation) {
1241
+ if (!reservation.attempt || reservation.attempt.status !== 'active')
1242
+ return undefined;
1243
+ const fence = drainExecutionFence(reservation.attempt, address);
1244
+ return fence && exactRecordFingerprint(record) ? { kind: 'repairable', fence, record } : undefined;
1245
+ }
1246
+ /**
1247
+ * Records an interrupted attempt as failed once the executor's termination is proven for its fence.
1248
+ * The record must still be exactly the one inspected, so a concurrent change is never overwritten.
1249
+ */
1250
+ export async function settleInterruptedDrainRepair(input) {
1251
+ const namespace = snapshotStateNamespace(input.namespace);
1252
+ const stackId = input.record.subjectId;
1253
+ const fingerprint = exactRecordFingerprint(input.record);
1254
+ const reservation = validIdentity(stackId, stackIdentity) ? decodeCoordinationRecord(input.record, stackId)?.drain : undefined;
1255
+ const fence = namespace && reservation?.attempt?.status === 'active'
1256
+ ? drainExecutionFence(reservation.attempt, namespace.paths.coordination(stackId)) : undefined;
1257
+ if (!namespace || !fingerprint || !reservation?.attempt || !fence || !consumeFencedExecutionTermination(input.evidence, fence)) {
1258
+ return refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'interrupted drain termination evidence is unavailable', 'drain repair');
1259
+ }
1260
+ const failedAt = new Date().toISOString();
1261
+ return transactClaim(namespace, stackId, reservation.holderId, current => {
1262
+ const value = decodeCoordinationRecord(current, stackId);
1263
+ const attempt = value?.drain?.attempt;
1264
+ if (!current || exactRecordFingerprint(current) !== fingerprint || !value?.drain || !attempt
1265
+ || attempt.status !== 'active' || !sameDrainAttempt(attempt, reservation.attempt)) {
1266
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'interrupted drain changed before executor termination could be recorded', stackId));
1267
+ }
1268
+ return coordinationStateRecord(namespace, stackId, current, failedAt, {
1269
+ ...value,
1270
+ drain: { ...value.drain, attempt: { ...attempt, status: 'failed',
1271
+ failedRevision: current.revision + 1, failedAt } },
1272
+ });
1273
+ });
1274
+ }
1275
+ export async function inspectDestructiveDrainRecovery(input) {
1276
+ let namespace;
1277
+ let stackId;
1278
+ let holderId;
1279
+ let purpose;
1280
+ try {
1281
+ if (!input || typeof input !== 'object' || Array.isArray(input)
1282
+ || !hasExactKeys(input, ['namespace', 'stackId', 'holderId', 'purpose']))
1283
+ throw new RefusalError('invalid');
1284
+ const descriptors = Object.getOwnPropertyDescriptors(input);
1285
+ if (!('value' in descriptors.namespace) || !('value' in descriptors.stackId)
1286
+ || !('value' in descriptors.holderId) || !('value' in descriptors.purpose))
1287
+ throw new RefusalError('invalid');
1288
+ namespace = authenticateStateNamespace(descriptors.namespace.value);
1289
+ const suppliedStackId = descriptors.stackId.value;
1290
+ const suppliedHolderId = descriptors.holderId.value;
1291
+ const suppliedPurpose = descriptors.purpose.value;
1292
+ if (typeof suppliedStackId !== 'string' || typeof suppliedHolderId !== 'string'
1293
+ || !validIdentity(suppliedStackId, stackIdentity) || !validIdentity(suppliedHolderId, holderIdentity)
1294
+ || suppliedPurpose !== 'down' && suppliedPurpose !== 'reset')
1295
+ throw new RefusalError('invalid');
1296
+ stackId = suppliedStackId;
1297
+ holderId = suppliedHolderId;
1298
+ purpose = suppliedPurpose;
1299
+ }
1300
+ catch {
1301
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1302
+ namespace = undefined;
1303
+ }
1304
+ if (!namespace || !stackId || !holderId || !purpose) {
1305
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'destructive drain recovery inspection input is malformed', 'destructive drain recovery');
1306
+ }
1307
+ const address = namespace.paths.coordination(stackId);
1308
+ const stored = await readState(address);
1309
+ if (stored.kind === 'missing') {
1310
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'destructive recovery requires current coordination state', address.path);
1311
+ }
1312
+ if (stored.kind !== 'complete')
1313
+ return stateRefusal(stored);
1314
+ const record = stored.value;
1315
+ const value = decodeCoordinationRecord(record, stackId);
1316
+ if (!value)
1317
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', address.path);
1318
+ if (value.drain == null) {
1319
+ const others = value.holders.filter((holder) => holder.holderId !== holderId);
1320
+ if (others.length > 0 && value.holders.some((holder) => holder.holderId === holderId)) {
1321
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, liveHolderMessage(others), stackId);
1322
+ }
1323
+ if (value.holders.length !== 1 || value.holders[0]?.holderId !== holderId
1324
+ || value.lease === null || value.lease.released) {
1325
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'destructive recovery requires exact retained holder and lease ownership', stackId);
1326
+ }
1327
+ return { kind: 'none' };
1328
+ }
1329
+ const reservation = value.drain;
1330
+ if (reservation.holderId !== holderId || reservation.requesterId !== holderId) {
1331
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'persisted drain belongs to another holder', stackId);
1332
+ }
1333
+ if (reservation.purpose !== purpose) {
1334
+ return refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'persisted drain requires explicit repair', stackId);
1335
+ }
1336
+ if (reservation.attempt?.status === 'active') {
1337
+ return drainRepair(address, record, reservation)
1338
+ ?? refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'the interrupted destructive executor has no repairable execution fence', stackId);
1339
+ }
1340
+ const request = snapshotDrainRequest({
1341
+ purpose: reservation.purpose,
1342
+ stackId: reservation.stackId,
1343
+ holderId: reservation.holderId,
1344
+ requesterId: reservation.requesterId,
1345
+ configSha256: reservation.configSha256,
1346
+ planDigest: reservation.planDigest,
1347
+ operationBasis: reservation.operationBasis,
1348
+ generation: reservation.generation,
1349
+ targetIds: reservation.targetIds,
1350
+ actions: reservation.actions,
1351
+ expectedCoordinationRevision: reservation.expectedCoordinationRevision,
1352
+ });
1353
+ return request ? { kind: 'recoverable', request }
1354
+ : refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'persisted destructive drain is malformed', address.path);
1355
+ }
1356
+ export async function inspectSleepDrainRecovery(input) {
1357
+ let namespace;
1358
+ let stackId;
1359
+ let holderId;
1360
+ try {
1361
+ if (!input || typeof input !== 'object' || Array.isArray(input)
1362
+ || !hasExactKeys(input, ['namespace', 'stackId', 'holderId']))
1363
+ throw new RefusalError('invalid');
1364
+ const descriptors = Object.getOwnPropertyDescriptors(input);
1365
+ if (!('value' in descriptors.namespace) || !('value' in descriptors.stackId)
1366
+ || !('value' in descriptors.holderId))
1367
+ throw new RefusalError('invalid');
1368
+ namespace = authenticateStateNamespace(descriptors.namespace.value);
1369
+ const suppliedStackId = descriptors.stackId.value;
1370
+ const suppliedHolderId = descriptors.holderId.value;
1371
+ if (typeof suppliedStackId !== 'string' || typeof suppliedHolderId !== 'string'
1372
+ || !validIdentity(suppliedStackId, stackIdentity) || !validIdentity(suppliedHolderId, holderIdentity))
1373
+ throw new RefusalError('invalid');
1374
+ stackId = suppliedStackId;
1375
+ holderId = suppliedHolderId;
1376
+ }
1377
+ catch {
1378
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1379
+ namespace = undefined;
1380
+ }
1381
+ if (!namespace || !stackId || !holderId) {
1382
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'sleep drain recovery inspection input is malformed', 'sleep drain recovery');
1383
+ }
1384
+ const address = namespace.paths.coordination(stackId);
1385
+ const stored = await readState(address);
1386
+ if (stored.kind === 'missing')
1387
+ return { kind: 'none' };
1388
+ if (stored.kind !== 'complete')
1389
+ return stateRefusal(stored);
1390
+ const record = stored.value;
1391
+ const value = decodeCoordinationRecord(record, stackId);
1392
+ if (!value)
1393
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', address.path);
1394
+ if (value.drain == null)
1395
+ return { kind: 'none' };
1396
+ const reservation = value.drain;
1397
+ if (reservation.holderId !== holderId || reservation.requesterId !== holderId) {
1398
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'persisted drain belongs to another holder', stackId);
1399
+ }
1400
+ if (reservation.purpose !== 'sleep') {
1401
+ return refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'persisted drain requires explicit repair', stackId);
1402
+ }
1403
+ if (reservation.attempt?.status === 'active') {
1404
+ return drainRepair(address, record, reservation)
1405
+ ?? refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'the interrupted sleep executor has no repairable execution fence', stackId);
1406
+ }
1407
+ const runtimeStop = reservation.actions[0];
1408
+ if (!runtimeStop || runtimeStop.stage !== 'runtime-stop' || runtimeStop.targetId !== stackId || exactRecordFingerprint(record) === undefined) {
1409
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'persisted sleep drain is malformed', address.path);
1410
+ }
1411
+ return {
1412
+ kind: 'recoverable',
1413
+ generation: reservation.generation,
1414
+ runtimeStopValidForMs: runtimeStop.validForMs,
1415
+ record,
1416
+ };
1417
+ }
1418
+ /**
1419
+ * Resumes a persisted sleep drain from fresh planning evidence. The stored record must still be the
1420
+ * one inspected and the fresh request must name its revision and the same work.
1421
+ */
1422
+ export async function recoverSleepDrainReservation(input) {
1423
+ let namespace;
1424
+ let request;
1425
+ try {
1426
+ namespace = authenticateStateNamespace(input.namespace);
1427
+ request = snapshotDrainRequest(input.request);
1428
+ }
1429
+ catch {
1430
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1431
+ namespace = undefined;
1432
+ }
1433
+ const fingerprint = exactRecordFingerprint(input.record);
1434
+ const inspected = fingerprint === undefined ? undefined : decodeCoordinationRecord(input.record, input.record.subjectId)?.drain;
1435
+ if (!namespace || !request || !inspected) {
1436
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'sleep drain recovery input is malformed', 'sleep drain recovery');
1437
+ }
1438
+ const address = namespace.paths.coordination(inspected.stackId);
1439
+ const stored = await readState(address);
1440
+ if (stored.kind !== 'complete') {
1441
+ return stored.kind === 'missing'
1442
+ ? refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'persisted sleep drain disappeared', stored.path)
1443
+ : stateRefusal(stored);
1444
+ }
1445
+ if (exactRecordFingerprint(stored.value) !== fingerprint) {
1446
+ return refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'persisted sleep drain changed before recovery', address.path);
1447
+ }
1448
+ if (request.expectedCoordinationRevision !== stored.value.revision) {
1449
+ return refuse(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, 'fresh sleep drain planning evidence does not name the current coordination revision', address.path);
1450
+ }
1451
+ const persistedRequest = {
1452
+ ...request,
1453
+ expectedCoordinationRevision: inspected.expectedCoordinationRevision,
1454
+ };
1455
+ if (request.purpose !== 'sleep' || inspected.purpose !== 'sleep' || inspected.attempt?.status === 'active'
1456
+ || !sameDrainRequest(inspected, persistedRequest)) {
1457
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'persisted sleep drain does not match fresh planning evidence', inspected.stackId);
1458
+ }
1459
+ return { kind: 'complete', reservation: inspected };
1460
+ }
1461
+ export async function recoverDrainReservation(input) {
1462
+ let namespace;
1463
+ let request;
1464
+ try {
1465
+ if (!input || typeof input !== 'object' || Array.isArray(input) || !hasExactKeys(input, ['namespace', 'request']))
1466
+ throw new RefusalError('invalid');
1467
+ const descriptors = Object.getOwnPropertyDescriptors(input);
1468
+ if (!('value' in descriptors.namespace) || !('value' in descriptors.request))
1469
+ throw new RefusalError('invalid');
1470
+ namespace = snapshotStateNamespace(descriptors.namespace.value);
1471
+ request = snapshotDrainRequest(descriptors.request.value);
1472
+ }
1473
+ catch {
1474
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1475
+ namespace = undefined;
1476
+ request = undefined;
1477
+ }
1478
+ if (!namespace || !request)
1479
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain recovery input is malformed', 'drain recovery');
1480
+ const stored = await readState(namespace.paths.coordination(request.stackId));
1481
+ if (stored.kind !== 'complete') {
1482
+ return stored.kind === 'missing'
1483
+ ? refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'drain recovery found no coordination record', stored.path)
1484
+ : stateRefusal(stored);
1485
+ }
1486
+ const value = decodeCoordinationRecord(stored.value, request.stackId);
1487
+ if (!value?.drain || !sameDrainRequest(value.drain, request)) {
1488
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'persisted drain does not match the recovery request', request.stackId);
1489
+ }
1490
+ if (value.drain.attempt?.status === 'active') {
1491
+ return refuse(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'the active destructive attempt requires explicit repair', request.stackId);
1492
+ }
1493
+ if (stored.value.revision !== (value.drain.attempt?.failedRevision ?? value.drain.progressRevision)) {
1494
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'persisted drain does not match its coordination revision', request.stackId);
1495
+ }
1496
+ return { kind: 'complete', reservation: value.drain };
1497
+ }
1498
+ function failedReclaimRecoveryTarget(action) {
1499
+ return {
1500
+ targetId: action.targetId,
1501
+ targetCommitment: { ...action.targetCommitment },
1502
+ };
1503
+ }
1504
+ /**
1505
+ * The committed targets of a recovered reservation whose reclaim try failed, read from the record
1506
+ * that published that failure. Undefined for any other reservation or record.
1507
+ */
1508
+ export function inspectFailedReclaimRecovery(input) {
1509
+ const { reservation, record } = input;
1510
+ const value = exactRecordFingerprint(record) === undefined ? undefined : decodeCoordinationRecord(record, reservation.stackId);
1511
+ if (!value?.drain || !sameSnapshot(value.drain, reservation))
1512
+ return undefined;
1513
+ const attempt = reservation.attempt;
1514
+ const action = attempt && reservation.actions[attempt.actionIndex];
1515
+ if (!attempt || attempt.status !== 'failed' || attempt.currentStage !== 'reclaim'
1516
+ || reservation.purpose !== 'down' && reservation.purpose !== 'reset'
1517
+ || record.revision !== attempt.failedRevision || !isCanonicalTimestamp(record.updatedAt)
1518
+ || Date.parse(record.updatedAt) < Date.parse(attempt.failedAt) || Date.parse(record.updatedAt) > Date.now()
1519
+ || Date.parse(reservation.reservedAt) > Date.parse(attempt.committedAt)
1520
+ || reservation.progressRevision !== attempt.committedRevision - 1
1521
+ || attempt.failedRevision !== attempt.committedRevision + 1
1522
+ || reservation.completedActionCount !== attempt.actionIndex
1523
+ || !action || action.stage !== 'reclaim' || action.targetId !== attempt.currentTarget
1524
+ || !action.targetCommitment
1525
+ || !validDrainActions(reservation.purpose, reservation.stackId, reservation.targetIds, reservation.actions))
1526
+ return undefined;
1527
+ const reclaimActions = reservation.actions.filter((candidate) => candidate.stage === 'reclaim');
1528
+ const currentIndex = attempt.actionIndex - 1;
1529
+ if (currentIndex < 0 || reclaimActions[currentIndex] !== action)
1530
+ return undefined;
1531
+ return {
1532
+ purpose: reservation.purpose,
1533
+ stackId: reservation.stackId,
1534
+ configSha256: reservation.configSha256,
1535
+ planDigest: reservation.planDigest,
1536
+ freshAfter: record.updatedAt,
1537
+ completed: reclaimActions.slice(0, currentIndex).map(failedReclaimRecoveryTarget),
1538
+ current: failedReclaimRecoveryTarget(action),
1539
+ pending: reclaimActions.slice(currentIndex + 1).map(failedReclaimRecoveryTarget),
1540
+ };
1541
+ }
1542
+ function sameDrainFact(reservation, fact) {
1543
+ return reservation.attempt !== null && fact.purpose === reservation.purpose && fact.stackId === reservation.stackId
1544
+ && fact.configSha256 === reservation.configSha256 && fact.planDigest === reservation.planDigest
1545
+ && fact.operationBasis === reservation.operationBasis
1546
+ && fact.coordinationRevision === reservation.coordinationRevision
1547
+ && fact.reservationId === reservation.reservationId && fact.reservedAt === reservation.reservedAt
1548
+ && fact.generation === reservation.attempt.generation && isCanonicalTimestamp(fact.observedAt)
1549
+ && fact.attemptId === reservation.attempt.attemptId
1550
+ && fact.attemptRevision === reservation.attempt.committedRevision
1551
+ && fact.attemptCommittedAt === reservation.attempt.committedAt
1552
+ && fact.validThrough === reservation.attempt.validThrough
1553
+ && fact.actionIndex === reservation.attempt.actionIndex
1554
+ && fact.currentTarget === reservation.attempt.currentTarget
1555
+ && fact.currentStage === reservation.attempt.currentStage
1556
+ && Date.parse(fact.observedAt) >= Date.parse(reservation.attempt.committedAt)
1557
+ && Date.parse(fact.observedAt) <= Date.parse(reservation.attempt.validThrough)
1558
+ && Date.parse(fact.observedAt) <= Date.now();
1559
+ }
1560
+ const runtimeStoppedKeys = [
1561
+ 'purpose', 'stackId', 'configSha256', 'planDigest', 'operationBasis', 'coordinationRevision',
1562
+ 'reservationId', 'reservedAt', 'generation', 'attemptId', 'attemptRevision', 'attemptCommittedAt',
1563
+ 'validThrough', 'actionIndex', 'currentTarget', 'currentStage', 'observedAt',
1564
+ ];
1565
+ function isExactDrainSuccess(reservation, fact) {
1566
+ if (!reservation.attempt)
1567
+ return false;
1568
+ if (reservation.attempt.currentStage === 'runtime-stop') {
1569
+ return hasExactKeys(fact, runtimeStoppedKeys) && sameDrainFact(reservation, fact);
1570
+ }
1571
+ if (!hasExactKeys(fact, [...runtimeStoppedKeys, 'remainingTargetIds']))
1572
+ return false;
1573
+ const reclaim = fact;
1574
+ const remaining = snapshotDrainTargetsAllowEmpty(reclaim.remainingTargetIds);
1575
+ const expected = reservation.actions.slice(reservation.attempt.actionIndex + 1).map(({ targetId }) => targetId);
1576
+ return sameDrainFact(reservation, reclaim) && remaining !== undefined && sameTargets(remaining, expected);
1577
+ }
1578
+ function snapshotDrainTargetsAllowEmpty(value) {
1579
+ if (Array.isArray(value) && value.length === 0)
1580
+ return [];
1581
+ return snapshotDrainTargets(value);
1582
+ }
1583
+ function reduceDrainAttemptSuccess(record, reservation, success) {
1584
+ if (!reservation.attempt || reservation.attempt.status !== 'active'
1585
+ || reservation.attempt.actionIndex >= reservation.actions.length - 1
1586
+ || record.revision !== reservation.attempt.committedRevision
1587
+ || !isExactDrainSuccess(reservation, success))
1588
+ return undefined;
1589
+ const value = decodeCoordinationRecord(record, reservation.stackId);
1590
+ if (!value?.drain?.attempt || !sameDrainRequest(value.drain, reservation)
1591
+ || value.drain.reservationId !== reservation.reservationId
1592
+ || !sameDrainAttempt(value.drain.attempt, reservation.attempt))
1593
+ return undefined;
1594
+ return {
1595
+ ...value,
1596
+ drain: {
1597
+ ...value.drain,
1598
+ completedActionCount: value.drain.completedActionCount + 1,
1599
+ progressRevision: value.drain.attempt.committedRevision + 1,
1600
+ attempt: null,
1601
+ },
1602
+ };
1603
+ }
1604
+ function reduceDrain(record, reservation, success) {
1605
+ if (!reservation.attempt
1606
+ || reservation.attempt.actionIndex !== reservation.actions.length - 1
1607
+ || reservation.completedActionCount !== reservation.actions.length - 1
1608
+ || !Number.isSafeInteger(record.revision) || record.revision !== reservation.attempt.committedRevision
1609
+ || !isExactDrainSuccess(reservation, success))
1610
+ return undefined;
1611
+ const value = decodeCoordinationRecord(record, reservation.stackId);
1612
+ if (!value?.drain || !sameDrainRequest(value.drain, reservation)
1613
+ || value.drain.coordinationRevision !== reservation.coordinationRevision
1614
+ || value.drain.reservationId !== reservation.reservationId
1615
+ || value.drain.reservedAt !== reservation.reservedAt
1616
+ || !value.drain.attempt || !sameDrainAttempt(value.drain.attempt, reservation.attempt)
1617
+ || value.drain.attempt.status !== 'active')
1618
+ return undefined;
1619
+ return {
1620
+ holders: [],
1621
+ lease: value.lease === null ? null : { ...value.lease, released: true },
1622
+ lastReleasedHolder: value.drain.holderId,
1623
+ drain: null,
1624
+ };
1625
+ }
1626
+ function stateRefusal(result) {
1627
+ return refuse(result.code, result.message, result.target, 'recovery' in result ? result.recovery : undefined);
1628
+ }
1629
+ async function transactClaim(namespace, stackId, ownerId, update, beforeCommit) {
1630
+ const result = await transactState(namespace, namespace.paths.coordination(stackId), { ownerId, contentionTimeoutMs: 2_000, beforeCommit }, update);
1631
+ const aborted = result;
1632
+ return aborted.aborted === true ? aborted.value : result;
1633
+ }
1634
+ export async function releaseClaim(input) {
1635
+ let request;
1636
+ try {
1637
+ const namespaceProperty = Object.getOwnPropertyDescriptor(input, 'namespace');
1638
+ if (!namespaceProperty || !('value' in namespaceProperty))
1639
+ throw new RefusalError('invalid namespace property');
1640
+ const stableNamespace = snapshotStateNamespace(namespaceProperty.value);
1641
+ if (!stableNamespace)
1642
+ throw new RefusalError('invalid namespace');
1643
+ request = {
1644
+ namespace: stableNamespace,
1645
+ stackId: input.stackId,
1646
+ requesterId: input.requesterId,
1647
+ holderId: input.holderId,
1648
+ now: input.now,
1649
+ };
1650
+ }
1651
+ catch (error) {
1652
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `claim release input could not be read safely: ${errorCause(error)}`, 'claim release input');
1653
+ }
1654
+ if (!validIdentity(request.stackId, stackIdentity)
1655
+ || !validIdentity(request.requesterId, holderIdentity)
1656
+ || !validIdentity(request.holderId, holderIdentity)
1657
+ || !isCanonicalTimestamp(request.now)) {
1658
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'claim release requires canonical stack, holder, and timestamp values', String(request.stackId ?? ''));
1659
+ }
1660
+ // A holder whose session is gone is released by `releaseDeadSessionHolders`, which proves the
1661
+ // death itself; no other holder's claim is this function's to drop.
1662
+ if (request.requesterId !== request.holderId) {
1663
+ return refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'a holder can release only its own claim', request.holderId);
1664
+ }
1665
+ const result = await transactClaim(request.namespace, request.stackId, request.requesterId, (current) => {
1666
+ const value = decodeCoordinationRecord(current, request.stackId);
1667
+ if (!value)
1668
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'coordination record is malformed', request.stackId));
1669
+ if (isDrainActive(value))
1670
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'claim release is blocked while the stack has an exclusive mutation', request.stackId));
1671
+ const holders = value.holders.filter((holder) => holder.holderId !== request.holderId);
1672
+ if (holders.length === value.holders.length) {
1673
+ if (holders.length === 0) {
1674
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'claim release requires an active non-final holder', request.stackId));
1675
+ }
1676
+ return abortStateTransaction({
1677
+ kind: 'complete', change: 'unchanged', decision: 'retain',
1678
+ remaining: holders.map((holder) => holder.holderId),
1679
+ });
1680
+ }
1681
+ if (holders.length === 0) {
1682
+ return abortStateTransaction(refuse(ENVIRONMENT_CODES.CLAIM_CO_TENANT, 'the final holder must reserve and complete a drain before releasing the stack', request.stackId));
1683
+ }
1684
+ return coordinationStateRecord(request.namespace, request.stackId, current, new Date().toISOString(), {
1685
+ holders,
1686
+ lease: value.lease,
1687
+ lastReleasedHolder: request.holderId,
1688
+ drain: value.drain,
1689
+ });
1690
+ });
1691
+ if ('diagnostics' in result || 'remaining' in result)
1692
+ return result;
1693
+ if (result.kind === 'missing') {
1694
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'claim transaction returned no record', result.path);
1695
+ }
1696
+ if (result.kind !== 'complete')
1697
+ return stateRefusal(result);
1698
+ const value = decodeCoordinationRecord(result.value, request.stackId);
1699
+ if (!value) {
1700
+ return refuse(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'published claim record is malformed', result.value.subjectId);
1701
+ }
1702
+ const remaining = value.holders.map((holder) => holder.holderId);
1703
+ return {
1704
+ kind: 'complete',
1705
+ change: 'released',
1706
+ decision: remaining.length === 0 ? 'stop' : 'retain',
1707
+ remaining,
1708
+ };
1709
+ }