@animalabs/agent-framework 0.21.1 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/README.md +54 -4
  2. package/dist/src/agent.d.ts +17 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +105 -43
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +41 -4
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +22 -1
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +64 -9
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +215 -38
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/framework.d.ts +569 -59
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3091 -542
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +21 -0
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +40 -7
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/index.d.ts +7 -2
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +26 -3
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  33. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  34. package/dist/src/mcpl/conversation-router.js +17 -0
  35. package/dist/src/mcpl/conversation-router.js.map +1 -1
  36. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  37. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  38. package/dist/src/mcpl/feature-set-manager.js +31 -7
  39. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  40. package/dist/src/mcpl/index.d.ts +4 -0
  41. package/dist/src/mcpl/index.d.ts.map +1 -1
  42. package/dist/src/mcpl/index.js +1 -0
  43. package/dist/src/mcpl/index.js.map +1 -1
  44. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  45. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  46. package/dist/src/mcpl/prose-stream-router.js +85 -22
  47. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  48. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  49. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  50. package/dist/src/mcpl/push-coalescer.js +46 -13
  51. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  52. package/dist/src/mcpl/push-handler.d.ts +5 -3
  53. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  54. package/dist/src/mcpl/push-handler.js +41 -11
  55. package/dist/src/mcpl/push-handler.js.map +1 -1
  56. package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
  57. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  58. package/dist/src/mcpl/reference-fetcher.js +20 -9
  59. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  60. package/dist/src/mcpl/references.d.ts +29 -6
  61. package/dist/src/mcpl/references.d.ts.map +1 -1
  62. package/dist/src/mcpl/references.js +57 -16
  63. package/dist/src/mcpl/references.js.map +1 -1
  64. package/dist/src/mcpl/server-connection.d.ts +32 -1
  65. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  66. package/dist/src/mcpl/server-connection.js +76 -11
  67. package/dist/src/mcpl/server-connection.js.map +1 -1
  68. package/dist/src/mcpl/server-registry.d.ts +5 -2
  69. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  70. package/dist/src/mcpl/server-registry.js +25 -7
  71. package/dist/src/mcpl/server-registry.js.map +1 -1
  72. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  73. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  74. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  75. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  76. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  77. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  78. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  79. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  80. package/dist/src/mcpl/types.d.ts +15 -5
  81. package/dist/src/mcpl/types.d.ts.map +1 -1
  82. package/dist/src/mcpl/types.js.map +1 -1
  83. package/dist/src/mcpl/visible-content.d.ts +39 -0
  84. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  85. package/dist/src/mcpl/visible-content.js +54 -0
  86. package/dist/src/mcpl/visible-content.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +90 -6
  88. package/dist/src/module-registry.d.ts.map +1 -1
  89. package/dist/src/module-registry.js +260 -18
  90. package/dist/src/module-registry.js.map +1 -1
  91. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  92. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/branch-intent.js +83 -0
  94. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  95. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  96. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  97. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  98. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  99. package/dist/src/modules/workspace/effects.d.ts +84 -0
  100. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  101. package/dist/src/modules/workspace/effects.js +368 -0
  102. package/dist/src/modules/workspace/effects.js.map +1 -0
  103. package/dist/src/modules/workspace/index.d.ts +155 -17
  104. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/index.js +1101 -424
  106. package/dist/src/modules/workspace/index.js.map +1 -1
  107. package/dist/src/modules/workspace/observe.d.ts +288 -0
  108. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  109. package/dist/src/modules/workspace/observe.js +825 -0
  110. package/dist/src/modules/workspace/observe.js.map +1 -0
  111. package/dist/src/modules/workspace/reconcile.d.ts +238 -0
  112. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/reconcile.js +909 -0
  114. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +6 -59
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  117. package/dist/src/modules/workspace/sync.js +6 -257
  118. package/dist/src/modules/workspace/sync.js.map +1 -1
  119. package/dist/src/modules/workspace/types.d.ts +77 -10
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  121. package/dist/src/modules/workspace/types.js.map +1 -1
  122. package/dist/src/operator-log.d.ts +41 -0
  123. package/dist/src/operator-log.d.ts.map +1 -1
  124. package/dist/src/operator-log.js.map +1 -1
  125. package/dist/src/provider-waits.d.ts +216 -0
  126. package/dist/src/provider-waits.d.ts.map +1 -0
  127. package/dist/src/provider-waits.js +482 -0
  128. package/dist/src/provider-waits.js.map +1 -0
  129. package/dist/src/record-journal.d.ts +127 -0
  130. package/dist/src/record-journal.d.ts.map +1 -0
  131. package/dist/src/record-journal.js +202 -0
  132. package/dist/src/record-journal.js.map +1 -0
  133. package/dist/src/refusal-reactions.d.ts +14 -12
  134. package/dist/src/refusal-reactions.d.ts.map +1 -1
  135. package/dist/src/refusal-reactions.js +14 -12
  136. package/dist/src/refusal-reactions.js.map +1 -1
  137. package/dist/src/shutdown.d.ts +13 -0
  138. package/dist/src/shutdown.d.ts.map +1 -0
  139. package/dist/src/shutdown.js +46 -0
  140. package/dist/src/shutdown.js.map +1 -0
  141. package/dist/src/silent-heartbeat.d.ts +74 -0
  142. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  143. package/dist/src/silent-heartbeat.js +77 -0
  144. package/dist/src/silent-heartbeat.js.map +1 -0
  145. package/dist/src/tool-presentation.d.ts +50 -0
  146. package/dist/src/tool-presentation.d.ts.map +1 -0
  147. package/dist/src/tool-presentation.js +235 -0
  148. package/dist/src/tool-presentation.js.map +1 -0
  149. package/dist/src/tool-result-guard.d.ts +142 -6
  150. package/dist/src/tool-result-guard.d.ts.map +1 -1
  151. package/dist/src/tool-result-guard.js +357 -36
  152. package/dist/src/tool-result-guard.js.map +1 -1
  153. package/dist/src/tool-result-history.d.ts +13 -0
  154. package/dist/src/tool-result-history.d.ts.map +1 -1
  155. package/dist/src/tool-result-history.js +21 -1
  156. package/dist/src/tool-result-history.js.map +1 -1
  157. package/dist/src/types/agent.d.ts +18 -2
  158. package/dist/src/types/agent.d.ts.map +1 -1
  159. package/dist/src/types/events.d.ts +16 -0
  160. package/dist/src/types/events.d.ts.map +1 -1
  161. package/dist/src/types/framework.d.ts +12 -0
  162. package/dist/src/types/framework.d.ts.map +1 -1
  163. package/dist/src/types/module.d.ts +26 -4
  164. package/dist/src/types/module.d.ts.map +1 -1
  165. package/dist/src/types/trace.d.ts +87 -3
  166. package/dist/src/types/trace.d.ts.map +1 -1
  167. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  168. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  169. package/dist/test/batched-wake-locus.test.js +166 -0
  170. package/dist/test/batched-wake-locus.test.js.map +1 -0
  171. package/dist/test/code-execution.test.js +386 -2
  172. package/dist/test/code-execution.test.js.map +1 -1
  173. package/dist/test/component-defaults.test.d.ts +2 -0
  174. package/dist/test/component-defaults.test.d.ts.map +1 -0
  175. package/dist/test/component-defaults.test.js +54 -0
  176. package/dist/test/component-defaults.test.js.map +1 -0
  177. package/dist/test/conversation-routing.test.js +57 -1
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +3 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/ephemeral-settle.test.js +3 -0
  182. package/dist/test/ephemeral-settle.test.js.map +1 -1
  183. package/dist/test/event-gate.test.js +118 -0
  184. package/dist/test/event-gate.test.js.map +1 -1
  185. package/dist/test/explicit-prose-routing.test.js +975 -0
  186. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  187. package/dist/test/framework.test.js +160 -1
  188. package/dist/test/framework.test.js.map +1 -1
  189. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  190. package/dist/test/helpers/coalescing-fixture.js +3 -2
  191. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  192. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  193. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  194. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  195. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  196. package/dist/test/inference-exhaustion-rewind.test.js +142 -14
  197. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  198. package/dist/test/inference-failure-bounds.test.d.ts +2 -0
  199. package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
  200. package/dist/test/inference-failure-bounds.test.js +1201 -0
  201. package/dist/test/inference-failure-bounds.test.js.map +1 -0
  202. package/dist/test/late-events-after-stop.test.d.ts +2 -0
  203. package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
  204. package/dist/test/late-events-after-stop.test.js +122 -0
  205. package/dist/test/late-events-after-stop.test.js.map +1 -0
  206. package/dist/test/mcpl-awareness-barrier.test.js +141 -0
  207. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  208. package/dist/test/mcpl-coalescing.test.js +97 -22
  209. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  210. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  211. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  212. package/dist/test/mcpl-config-diagnostics.test.js +452 -0
  213. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  214. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  215. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  216. package/dist/test/mcpl-empty-content.test.js +368 -0
  217. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  218. package/dist/test/mcpl-manifest-changed.test.js +2 -1
  219. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  220. package/dist/test/mcpl-reference-fetcher.test.js +232 -4
  221. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  222. package/dist/test/mcpl-reference-fields.test.js +69 -2
  223. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  224. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  225. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  226. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  227. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  228. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  229. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  230. package/dist/test/mcpl-ws-transport.test.js +103 -0
  231. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  232. package/dist/test/ops-alert.test.js +2 -1
  233. package/dist/test/ops-alert.test.js.map +1 -1
  234. package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
  235. package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
  236. package/dist/test/placed-reaction-baseline.test.js +312 -0
  237. package/dist/test/placed-reaction-baseline.test.js.map +1 -0
  238. package/dist/test/prose-stream-router.test.js +41 -0
  239. package/dist/test/prose-stream-router.test.js.map +1 -1
  240. package/dist/test/provider-acceleration-cooldown.test.js +5 -1
  241. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  242. package/dist/test/provider-waits.test.d.ts +2 -0
  243. package/dist/test/provider-waits.test.d.ts.map +1 -0
  244. package/dist/test/provider-waits.test.js +2196 -0
  245. package/dist/test/provider-waits.test.js.map +1 -0
  246. package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
  247. package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
  248. package/dist/test/reaction-baseline-platform.test.js +124 -0
  249. package/dist/test/reaction-baseline-platform.test.js.map +1 -0
  250. package/dist/test/record-journal.test.d.ts +6 -0
  251. package/dist/test/record-journal.test.d.ts.map +1 -0
  252. package/dist/test/record-journal.test.js +269 -0
  253. package/dist/test/record-journal.test.js.map +1 -0
  254. package/dist/test/refusal-reaction-baseline.test.js +10 -3
  255. package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
  256. package/dist/test/save-recent-image.test.js +240 -2
  257. package/dist/test/save-recent-image.test.js.map +1 -1
  258. package/dist/test/shutdown-errors.test.d.ts +2 -0
  259. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  260. package/dist/test/shutdown-errors.test.js +662 -0
  261. package/dist/test/shutdown-errors.test.js.map +1 -0
  262. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  263. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  264. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  265. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  266. package/dist/test/silent-heartbeat.test.js +49 -38
  267. package/dist/test/silent-heartbeat.test.js.map +1 -1
  268. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  269. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  270. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  271. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  272. package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
  273. package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
  274. package/dist/test/surgery-marks-receipt.test.js +540 -0
  275. package/dist/test/surgery-marks-receipt.test.js.map +1 -0
  276. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  277. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  278. package/dist/test/tool-batch-hook.test.js +275 -0
  279. package/dist/test/tool-batch-hook.test.js.map +1 -0
  280. package/dist/test/tool-presentation.test.d.ts +2 -0
  281. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  282. package/dist/test/tool-presentation.test.js +268 -0
  283. package/dist/test/tool-presentation.test.js.map +1 -0
  284. package/dist/test/tool-result-guard.test.js +2545 -19
  285. package/dist/test/tool-result-guard.test.js.map +1 -1
  286. package/dist/test/tool-result-history.test.js +2 -1
  287. package/dist/test/tool-result-history.test.js.map +1 -1
  288. package/dist/test/turn-trigger-provenance.test.js +35 -0
  289. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  290. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  291. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  292. package/dist/test/unconvertible-tool-result.test.js +358 -0
  293. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  294. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  295. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  296. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  297. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  298. package/dist/test/workspace-three-way.test.d.ts +12 -0
  299. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  300. package/dist/test/workspace-three-way.test.js +3194 -0
  301. package/dist/test/workspace-three-way.test.js.map +1 -0
  302. package/dist/tsconfig.tsbuildinfo +1 -1
  303. package/package.json +2 -2
  304. package/src/agent.ts +124 -45
  305. package/src/api/server.ts +43 -4
  306. package/src/code-execution/py-runner.ts +66 -9
  307. package/src/code-execution/runtime-py.ts +215 -38
  308. package/src/framework.ts +3191 -544
  309. package/src/gate/event-gate.ts +59 -7
  310. package/src/index.ts +18 -2
  311. package/src/mcpl/channel-registry.ts +24 -3
  312. package/src/mcpl/conversation-router.ts +34 -1
  313. package/src/mcpl/feature-set-manager.ts +36 -11
  314. package/src/mcpl/index.ts +4 -0
  315. package/src/mcpl/prose-stream-router.ts +79 -23
  316. package/src/mcpl/push-coalescer.ts +62 -13
  317. package/src/mcpl/push-handler.ts +41 -12
  318. package/src/mcpl/reference-fetcher.ts +18 -8
  319. package/src/mcpl/references.ts +57 -13
  320. package/src/mcpl/server-connection.ts +95 -13
  321. package/src/mcpl/server-registry.ts +24 -7
  322. package/src/mcpl/tool-lifecycle.ts +77 -2
  323. package/src/mcpl/tool-pattern-check.ts +106 -0
  324. package/src/mcpl/types.ts +15 -5
  325. package/src/mcpl/visible-content.ts +57 -0
  326. package/src/module-registry.ts +269 -25
  327. package/src/modules/workspace/branch-intent.ts +123 -0
  328. package/src/modules/workspace/disk-agreement.ts +325 -0
  329. package/src/modules/workspace/effects.ts +362 -0
  330. package/src/modules/workspace/index.ts +1098 -446
  331. package/src/modules/workspace/observe.ts +865 -0
  332. package/src/modules/workspace/reconcile.ts +1157 -0
  333. package/src/modules/workspace/sync.ts +6 -336
  334. package/src/modules/workspace/types.ts +75 -10
  335. package/src/operator-log.ts +30 -0
  336. package/src/provider-waits.ts +523 -0
  337. package/src/record-journal.ts +246 -0
  338. package/src/refusal-reactions.ts +14 -12
  339. package/src/shutdown.ts +49 -0
  340. package/src/silent-heartbeat.ts +141 -0
  341. package/src/tool-presentation.ts +188 -0
  342. package/src/tool-result-guard.ts +384 -34
  343. package/src/tool-result-history.ts +25 -1
  344. package/src/types/agent.ts +18 -2
  345. package/src/types/events.ts +16 -0
  346. package/src/types/framework.ts +12 -0
  347. package/src/types/module.ts +27 -4
  348. package/src/types/trace.ts +94 -3
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Physical disk-agreement evidence (P): for each mount and path, what disk held
3
+ * when it last agreed with the workspace store, or what the module last wrote
4
+ * there or adopted from it.
5
+ *
6
+ * P describes disk, and disk does not rewind with a branch switch, so P lives
7
+ * in global chronicle records (RecordJournal) rather than branch state: after
8
+ * an undo selects an older tree, P still says what disk holds, and a
9
+ * difference reads as a store-side change, never as a disk edit. Branch-local
10
+ * intent — tombstones, store origin, conflicts — lives in BranchIntents and
11
+ * rewinds with the tree.
12
+ *
13
+ * P is evidence about one directory, so each mount also records the identity
14
+ * (device and inode) of the root that evidence was gathered under. A root
15
+ * that isn't that directory any more (an unmounted drive whose mountpoint is
16
+ * now empty, a replaced directory) is an unavailable mount, never a disk on
17
+ * which every file was deleted; only an explicit acceptance takes it, and
18
+ * that starts its evidence afresh.
19
+ *
20
+ * Every transition is write-ahead (see reconcile.ts): an `intent` entry
21
+ * (`pending`, with the prior value and the expected one) is durable before the
22
+ * disk effect or tree adoption it announces, and the completion `set` is
23
+ * appended only after that effect is durable. One tree adoption has no
24
+ * intent: a drop, which removes the store's copy of a disk version the
25
+ * workspace can't hold, forgets P first, so a crash before the removal leaves
26
+ * P unknown, never a wrong adoption. A pending adoption resolves at its next
27
+ * observation, by the tree. A pending disk effect resolves there only when
28
+ * disk shows it didn't happen (the prior) or can't tell (`interrupted`, a
29
+ * sticky conflict resolved explicitly, or by disk and the store coming to
30
+ * agree); one that shows on disk is completed only by a push whose barriers
31
+ * succeed, since its completion releases a draft's protection.
32
+ */
33
+
34
+ import { resolve } from 'node:path';
35
+ import type { JsStore } from '@animalabs/chronicle';
36
+ import { RecordJournal } from '../../record-journal.js';
37
+
38
+ /** Everything `stat` says about a file that can vouch for unchanged bytes. */
39
+ export interface Fingerprint {
40
+ size: number;
41
+ mtimeNs: string;
42
+ ctimeNs: string;
43
+ ino: string;
44
+ dev: string;
45
+ /** When the bytes were hashed (ms). Timestamps within 2 s of it can't vouch. */
46
+ hashedAt: number;
47
+ }
48
+
49
+ export type Agreed =
50
+ | { kind: 'absent' }
51
+ | { kind: 'content'; hash: string; size: number; fp?: Fingerprint };
52
+
53
+ export type Candidate = { kind: 'absent' } | { kind: 'content'; hash: string };
54
+
55
+ export interface Pending {
56
+ kind: 'pending';
57
+ /** What the module is about to do: change disk, or adopt disk into the tree. */
58
+ effect: 'disk' | 'adopt';
59
+ /** P before the intent (null: unknown). */
60
+ prior: Agreed | null;
61
+ expect: Candidate;
62
+ /** Branch the intent was recorded on (an adoption's meaning is branch-relative). */
63
+ branchId: string;
64
+ }
65
+
66
+ export interface Interrupted {
67
+ kind: 'interrupted';
68
+ /** The values disk could have held after the interrupted effect. */
69
+ candidates: Candidate[];
70
+ }
71
+
72
+ export type Physical = Agreed | Pending | Interrupted;
73
+
74
+ /** Which directory a mount root is: its device and inode, as decimal strings. */
75
+ export interface RootIdentity {
76
+ dev: string;
77
+ ino: string;
78
+ }
79
+
80
+ /** Whether two configured roots name the same path, whatever their spelling
81
+ * (a trailing slash, a relative form). A symlinked alias is another path. */
82
+ function sameRootPath(a: string, b: string): boolean {
83
+ return resolve(a) === resolve(b);
84
+ }
85
+
86
+ export function sameRootIdentity(a: RootIdentity, b: RootIdentity): boolean {
87
+ return a.dev === b.dev && a.ino === b.ino;
88
+ }
89
+
90
+ type JournalEntry =
91
+ | { t: 'mount'; mount: string; root: string }
92
+ /** The root's identity; with `reset`, an accepted new root, whose evidence starts empty. */
93
+ | { t: 'root'; mount: string; identity: RootIdentity; reset?: true }
94
+ | { t: 'set'; mount: string; path: string; value: Physical }
95
+ | { t: 'forget'; mount: string; path: string };
96
+
97
+ interface MountEvidence {
98
+ root: string;
99
+ identity?: RootIdentity;
100
+ paths: Map<string, Physical>;
101
+ }
102
+
103
+ interface Snapshot {
104
+ mounts: Record<string, { root: string; identity?: RootIdentity; paths: Record<string, Physical> }>;
105
+ }
106
+
107
+ export const DISK_AGREEMENT_RECORD_TYPE = 'workspace/disk-agreement';
108
+
109
+ /** Checkpoint once the tail outgrows this many entries, or the live paths, whichever is larger. */
110
+ const MIN_CHECKPOINT_TAIL = 1000;
111
+
112
+ export function candidateOf(value: Agreed): Candidate {
113
+ return value.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: value.hash };
114
+ }
115
+
116
+ export function sameCandidate(a: Candidate, b: Candidate): boolean {
117
+ return a.kind === b.kind && (a.kind === 'absent' || a.hash === (b as { hash: string }).hash);
118
+ }
119
+
120
+ export class DiskAgreement {
121
+ private readonly store: JsStore;
122
+ private readonly journal: RecordJournal<JournalEntry, Snapshot>;
123
+ private readonly mounts = new Map<string, MountEvidence>();
124
+ private configured: Array<{ name: string; root: string }> = [];
125
+ /**
126
+ * An append (or a rebuild) not yet made durable. Cleared only by a
127
+ * barrier that succeeds, so a failed barrier stays owed: a later pass with
128
+ * nothing new to record still settles it before deciding anything.
129
+ */
130
+ private unsynced = false;
131
+
132
+ constructor(store: JsStore) {
133
+ this.store = store;
134
+ this.journal = new RecordJournal<JournalEntry, Snapshot>(store, { type: DISK_AGREEMENT_RECORD_TYPE });
135
+ }
136
+
137
+ /**
138
+ * Rebuild P from the journal for the configured mounts. A mount whose
139
+ * recorded root differs from its configured one starts empty: evidence
140
+ * about another directory says nothing about this one.
141
+ */
142
+ open(mounts: Array<{ name: string; root: string }>): void {
143
+ this.configured = mounts;
144
+ this.rebuild();
145
+ }
146
+
147
+ /**
148
+ * Re-read the journal when an earlier write left it unreconciled, and throw
149
+ * once an append failed inside the store. P may then lack what that append
150
+ * recorded (the store may hold it where this process can't read it back),
151
+ * and a decision from stale P can record what reopening contradicts, such
152
+ * as a conflict that stays after the right evidence is back. So until the
153
+ * store is reopened, no evidence is read, written or checkpointed, and
154
+ * every pass and tool call that needs it fails.
155
+ */
156
+ ensureReconciled(): void {
157
+ if (this.journal.needsReopen) {
158
+ throw new Error(
159
+ 'Disk-agreement evidence is unavailable until the store is reopened, as at the next start: ' +
160
+ 'an append to its journal failed inside the store, and the evidence may lack what it recorded',
161
+ );
162
+ }
163
+ if (this.journal.needsReconcile) this.rebuild();
164
+ }
165
+
166
+ /** Whether evidence recorded so far still awaits a barrier. */
167
+ get needsBarrier(): boolean {
168
+ return this.unsynced;
169
+ }
170
+
171
+ /**
172
+ * Replay the journal into memory. Records that read back are not proven
173
+ * durable — a previous owner may have appended them and died before any
174
+ * barrier — so evidence rebuilt here owes one, whether at open or after an
175
+ * ambiguous append.
176
+ */
177
+ private rebuild(): void {
178
+ this.unsynced = true;
179
+ const { snapshot, entries } = this.journal.load();
180
+ const replayed = new Map<string, MountEvidence>();
181
+ for (const [name, mount] of Object.entries(snapshot?.mounts ?? {})) {
182
+ replayed.set(name, {
183
+ root: mount.root,
184
+ ...(mount.identity ? { identity: mount.identity } : {}),
185
+ paths: new Map(Object.entries(mount.paths)),
186
+ });
187
+ }
188
+ for (const { entry } of entries) {
189
+ if (entry.t === 'mount') {
190
+ const existing = replayed.get(entry.mount);
191
+ if (!existing || !sameRootPath(existing.root, entry.root)) replayed.set(entry.mount, { root: entry.root, paths: new Map() });
192
+ continue;
193
+ }
194
+ const mount = replayed.get(entry.mount);
195
+ if (!mount) continue;
196
+ if (entry.t === 'root') applyRoot(mount, entry);
197
+ else if (entry.t === 'set') mount.paths.set(entry.path, entry.value);
198
+ else mount.paths.delete(entry.path);
199
+ }
200
+ this.mounts.clear();
201
+ for (const { name, root } of this.configured) {
202
+ const found = replayed.get(name);
203
+ if (found && sameRootPath(found.root, root)) {
204
+ this.mounts.set(name, found);
205
+ } else {
206
+ this.mounts.set(name, { root, paths: new Map() });
207
+ this.journal.append({ t: 'mount', mount: name, root });
208
+ }
209
+ }
210
+ }
211
+
212
+ /** The identity of the root this mount's evidence was gathered under, once any was. */
213
+ rootIdentity(mount: string): RootIdentity | undefined {
214
+ this.ensureReconciled();
215
+ return this.mounts.get(mount)?.identity;
216
+ }
217
+
218
+ /**
219
+ * Record the root's identity just before the first evidence a mount gains:
220
+ * the caller saw it as the root while gathering that evidence. A no-op once
221
+ * one is recorded; changing it is acceptRoot's alone.
222
+ */
223
+ recordRootIdentity(mount: string, identity: RootIdentity): void {
224
+ if (this.rootIdentity(mount) !== undefined) return;
225
+ this.write({ t: 'root', mount, identity }, {});
226
+ }
227
+
228
+ /**
229
+ * Take the root as it is now, at the operator's word: record its identity
230
+ * and forget every path's evidence, which described another directory.
231
+ */
232
+ acceptRoot(mount: string, identity: RootIdentity): void {
233
+ this.write({ t: 'root', mount, identity, reset: true }, {});
234
+ }
235
+
236
+ get(mount: string, path: string): Physical | undefined {
237
+ this.ensureReconciled(); // an ambiguous append may have landed: read it back first
238
+ return this.mounts.get(mount)?.paths.get(path);
239
+ }
240
+
241
+ /** Every path with evidence under a prefix ('' for the whole mount). */
242
+ paths(mount: string, prefix = ''): Array<[string, Physical]> {
243
+ this.ensureReconciled();
244
+ const paths = this.mounts.get(mount)?.paths;
245
+ if (!paths) return [];
246
+ const out: Array<[string, Physical]> = [];
247
+ for (const [path, value] of paths) {
248
+ if (prefix === '' || path === prefix || path.startsWith(prefix + '/')) out.push([path, value]);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * Record an intent. `durable` makes it (and every append before it) stable
255
+ * before the caller performs the effect; batches pass it on their last one.
256
+ */
257
+ intend(mount: string, path: string, intent: Omit<Pending, 'kind'>, opts: { durable?: boolean } = {}): void {
258
+ this.write({ t: 'set', mount, path, value: { kind: 'pending', ...intent } }, { durable: opts.durable });
259
+ }
260
+
261
+ /**
262
+ * Record agreed evidence. `afterCommittedState` when it asserts a tree
263
+ * commit made just before (the commit is synced first).
264
+ */
265
+ set(mount: string, path: string, value: Agreed | Interrupted, opts: { afterCommittedState?: boolean } = {}): void {
266
+ this.write({ t: 'set', mount, path, value }, { afterCommittedState: opts.afterCommittedState });
267
+ }
268
+
269
+ /** Forget a path: P becomes unknown, the most conservative evidence. */
270
+ forget(mount: string, path: string): void {
271
+ if (this.get(mount, path) === undefined) return;
272
+ this.write({ t: 'forget', mount, path }, {});
273
+ }
274
+
275
+ /**
276
+ * Put back exactly the evidence a path had before an intent whose effect
277
+ * never touched it — whatever kind it was (agreed, pending, interrupted, or
278
+ * unknown) — so the intent leaves no trace in what P says.
279
+ */
280
+ restore(mount: string, path: string, value: Physical | undefined): void {
281
+ if (value === undefined) this.forget(mount, path);
282
+ else this.write({ t: 'set', mount, path, value }, {});
283
+ }
284
+
285
+ private write(entry: JournalEntry, opts: { durable?: boolean; afterCommittedState?: boolean }): void {
286
+ this.ensureReconciled();
287
+ this.unsynced = true; // until proven durable: a failed append may still have landed
288
+ this.journal.append(entry, opts);
289
+ if (opts.durable) this.unsynced = false; // synced after this append, and so everything before it
290
+ const mount = this.mounts.get((entry as { mount: string }).mount);
291
+ if (!mount) return;
292
+ if (entry.t === 'root') applyRoot(mount, entry);
293
+ else if (entry.t === 'set') mount.paths.set(entry.path, entry.value);
294
+ else if (entry.t === 'forget') mount.paths.delete(entry.path);
295
+ }
296
+
297
+ /** Checkpoint when the tail has outgrown the live evidence. */
298
+ maybeCheckpoint(): void {
299
+ // Like every entry point: an ambiguous write is read back first.
300
+ this.ensureReconciled();
301
+ let live = 0;
302
+ for (const mount of this.mounts.values()) live += mount.paths.size;
303
+ if (this.journal.entriesSinceCheckpoint < Math.max(MIN_CHECKPOINT_TAIL, live)) return;
304
+ const snapshot: Snapshot = { mounts: {} };
305
+ for (const [name, mount] of this.mounts) {
306
+ snapshot.mounts[name] = {
307
+ root: mount.root,
308
+ ...(mount.identity ? { identity: mount.identity } : {}),
309
+ paths: Object.fromEntries(mount.paths),
310
+ };
311
+ }
312
+ this.journal.checkpoint(snapshot);
313
+ }
314
+
315
+ /** Make every append so far stable. Throws, and stays owed, if the sync fails. */
316
+ barrier(): void {
317
+ this.store.sync();
318
+ this.unsynced = false;
319
+ }
320
+ }
321
+
322
+ function applyRoot(mount: MountEvidence, entry: Extract<JournalEntry, { t: 'root' }>): void {
323
+ mount.identity = entry.identity;
324
+ if (entry.reset) mount.paths.clear();
325
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * The workspace's disk effects — a materialize's writes and unlinks, and the
3
+ * barriers that make them durable — under the mount boundary.
4
+ *
5
+ * Each effect is bound at its point of use, not at planning time: the parent
6
+ * directory is checked (located inside the mount, see observe.ts), the file is
7
+ * reached through it, and before anything is changed the effect checks that
8
+ * it acts on what was decided on — a regular file inside the mount, at this
9
+ * path, still holding the bytes planning saw (unless `force`, which overrides
10
+ * that freshness but never the boundary). Content is written only through a
11
+ * descriptor whose own location was confirmed inside the mount first, and
12
+ * only into a file with no other hard links: a write in place changes the
13
+ * file under every name it has, and the others may lie outside the mount (a
14
+ * package store's, say), so such a file is refused. Unlinking one name
15
+ * changes no other, so an unlink needs no such check.
16
+ *
17
+ * What an effect reports matters to the caller's evidence: a failure that
18
+ * never touched the path's entry leaves disk as planning saw it, so the
19
+ * path's evidence can be put back as it was; once the entry was touched
20
+ * (created, truncated, written or unlinked) the outcome is for the next
21
+ * observation to resolve. Directories created for a write don't touch the
22
+ * path's entry; they stay, empty, if the write is refused.
23
+ *
24
+ * Node has no openat, so a create, mkdir or unlink still resolves its path by
25
+ * name after the parent was checked: a parent replaced concurrently in that
26
+ * window can redirect that one operation, leaving an empty file or directory
27
+ * outside the mount or unlinking a same-named file there. Nor is there an
28
+ * unlink bound to an inode: an unlink checks the entry's identity again just
29
+ * before it, and a file replaced at its path after that check is the one
30
+ * unlinked. Content writes stay bound to the descriptor verified before
31
+ * modification: inside the mount, with no other hard links (a link another
32
+ * process makes to it after that check shares the write). The hash, which
33
+ * reads the whole file, comes before the checks of where the file is, and
34
+ * the root is checked again beside the parent, since the parent is found by
35
+ * path beneath the root's. A last look at the file (its descriptor's stat
36
+ * before a write, its entry's lstat before an unlink) refuses one written
37
+ * since it was first seen, so only an edit made in the moment between that
38
+ * look and the effect is overwritten or removed.
39
+ */
40
+
41
+ import { createHash } from 'node:crypto';
42
+ import { constants as fsConstants } from 'node:fs';
43
+ import { lstat, open, realpath, stat, unlink } from 'node:fs/promises';
44
+ import type { FileHandle } from 'node:fs/promises';
45
+ import {
46
+ type CheckedDir,
47
+ type MountView,
48
+ NONBLOCK,
49
+ NOT_FOLLOWED,
50
+ OUTSIDE_PARENT,
51
+ ROOT_REPLACED,
52
+ contained,
53
+ errno,
54
+ checkDir,
55
+ checkRoot,
56
+ nameOf,
57
+ parentOf,
58
+ } from './observe.js';
59
+
60
+ /** What a write or unlink may find at its path when it acts. */
61
+ export type Expect =
62
+ | { kind: 'absent' }
63
+ /** The bytes planning decided on, by hash. */
64
+ | { kind: 'content'; hash: string }
65
+ /** Anything (force): freshness is overridden, the boundary is not. */
66
+ | { kind: 'any' };
67
+
68
+ /** Errors with which an unlink refuses outright, leaving the entry as it was. */
69
+ const UNLINK_REFUSALS = new Set(['EACCES', 'EPERM', 'EROFS', 'EBUSY']);
70
+
71
+ export class EffectFailed extends Error {
72
+ /** Whether the path's entry was touched before the failure. */
73
+ readonly touched: boolean;
74
+
75
+ constructor(message: string, touched: boolean) {
76
+ super(message);
77
+ this.touched = touched;
78
+ }
79
+ }
80
+
81
+ const CHANGED = 'disk changed since it was checked';
82
+ const MOVED = 'the directory changed since it was checked';
83
+ const HARD_LINKED = 'a file with other hard links, which writing it in place would change too — force does not override '
84
+ + 'this; to write this path alone, give it a copy of its own first (copy it to a new name, then move that over it) '
85
+ + 'and materialize again';
86
+
87
+ function message(err: unknown): string {
88
+ return err instanceof Error ? err.message : String(err);
89
+ }
90
+
91
+ function noFollowFlag(view: MountView): number {
92
+ return !view.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
93
+ }
94
+
95
+ /**
96
+ * The parent of a path, checked for an effect; proven absent; or why neither;
97
+ * with the root. The root comes first: still a directory, and still the one
98
+ * disk last agreed under, as any observation of it checks. An effect under a
99
+ * replaced root acts on nothing, whatever its caller checked before, and the
100
+ * parent is found by path beneath the root's path, so whoever acts checks the
101
+ * root again with the parent (`moved`): a root replaced after it was
102
+ * checked would otherwise lend the effect its replacement's directory.
103
+ */
104
+ async function parentFor(view: MountView, rootReal: string, rel: string, create: boolean) {
105
+ const root = await checkRoot(view, rootReal);
106
+ if ('reason' in root) throw new EffectFailed(root.reason, false);
107
+ const checked = await checkDir(view, rootReal, parentOf(rel), { create });
108
+ if (checked.kind === 'unobserved') throw new EffectFailed(checked.outside ? OUTSIDE_PARENT : checked.reason, false);
109
+ return { ...checked, root: root.dir };
110
+ }
111
+
112
+ /**
113
+ * Why the parent, or the root it was found beneath, is no longer the
114
+ * directory its path names; null while each still is. The root is asked
115
+ * first, so a replaced root reads the same wherever it's found.
116
+ */
117
+ async function moved(root: CheckedDir, parent: CheckedDir): Promise<string | null> {
118
+ if (!(await root.stillHere())) return ROOT_REPLACED;
119
+ return (await parent.stillHere()) ? null : MOVED;
120
+ }
121
+
122
+ /** A file as a stat describes it: which file, and the size and stamps a write changes. */
123
+ type Stamped = { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint };
124
+
125
+ /**
126
+ * Whether the file at `target` is still the one `before` described: the same
127
+ * file, with the same size and stamps, so nothing wrote it since. Read just
128
+ * before an effect, after the slower checks, so an edit made while the file
129
+ * was hashed is never overwritten or deleted unseen; what remains is the
130
+ * moment between this and the effect, which Node can't close (see the header).
131
+ */
132
+ function unchanged(before: Stamped, now: Stamped | null): boolean {
133
+ return now !== null && now.dev === before.dev && now.ino === before.ino && now.size === before.size
134
+ && now.mtimeNs === before.mtimeNs && now.ctimeNs === before.ctimeNs;
135
+ }
136
+
137
+ /** The hash of what `target` holds, read through a descriptor that must be the file `id` names. */
138
+ async function hashAt(target: string, id: { dev: bigint; ino: bigint }, flags: number): Promise<string> {
139
+ let handle: FileHandle;
140
+ try {
141
+ handle = await open(target, fsConstants.O_RDONLY | NONBLOCK | flags);
142
+ } catch (err) {
143
+ throw new EffectFailed(`cannot verify the disk copy (${errno(err) ?? message(err)})`, false);
144
+ }
145
+ try {
146
+ const info = await handle.stat({ bigint: true });
147
+ if (info.dev !== id.dev || info.ino !== id.ino) throw new EffectFailed(CHANGED, false);
148
+ const hash = createHash('sha256');
149
+ const chunk = Buffer.alloc(1 << 20);
150
+ for (let offset = 0; ;) {
151
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, offset);
152
+ if (bytesRead === 0) break;
153
+ hash.update(chunk.subarray(0, bytesRead));
154
+ offset += bytesRead;
155
+ }
156
+ return hash.digest('hex');
157
+ } finally {
158
+ await handle.close();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Write `bytes` at a mount-relative path, creating missing parent
164
+ * directories, and make the file durable (its directories are synced by the
165
+ * caller, after all of a push's effects: syncDirectories).
166
+ */
167
+ export async function writeContained(view: MountView, rootReal: string, rel: string, bytes: Buffer, expect: Expect): Promise<void> {
168
+ const checked = await parentFor(view, rootReal, rel, true);
169
+ if (checked.kind !== 'checked') throw new EffectFailed('a parent path is not a directory', false);
170
+ const parent = checked.dir;
171
+ const root = checked.root;
172
+ const target = parent.at(nameOf(rel));
173
+ const noFollow = noFollowFlag(view);
174
+ if (!view.followSymlinks && !noFollow) {
175
+ // No O_NOFOLLOW (Windows): refuse a symlink by lstat before opening.
176
+ if ((await lstat(target).catch(() => null))?.isSymbolicLink()) throw new EffectFailed(NOT_FOLLOWED, false);
177
+ }
178
+
179
+ let touched = false;
180
+ const create = async (): Promise<FileHandle> => {
181
+ try {
182
+ // Exclusive: never follows a symlink, never takes over a file that
183
+ // appeared since planning saw none.
184
+ const handle = await open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644);
185
+ touched = true;
186
+ return handle;
187
+ } catch (err) {
188
+ // O_EXCL refuses any entry: a file, a directory or a link.
189
+ if (errno(err) === 'EEXIST') throw new EffectFailed('something appeared at this path since it was checked', false);
190
+ throw new EffectFailed(`cannot create the file (${errno(err) ?? message(err)})`, false);
191
+ }
192
+ };
193
+ let handle: FileHandle;
194
+ if (expect.kind === 'absent') {
195
+ handle = await create();
196
+ } else {
197
+ try {
198
+ // Opened without truncating: nothing changes until it is located.
199
+ handle = await open(target, fsConstants.O_WRONLY | NONBLOCK | noFollow);
200
+ } catch (err) {
201
+ const code = errno(err);
202
+ if (code === 'ENOENT' && expect.kind === 'any') handle = await create();
203
+ else if (code === 'ENOENT') throw new EffectFailed('the file was removed since it was checked', false);
204
+ else if (code === 'ELOOP' && !view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
205
+ else if (code === 'EISDIR') throw new EffectFailed('a directory', false);
206
+ else if (code === 'ENXIO') throw new EffectFailed('not a regular file', false); // a FIFO with no reader
207
+ else throw new EffectFailed(`cannot open the file (${code ?? message(err)})`, false);
208
+ }
209
+ }
210
+
211
+ let stage = 'write the file';
212
+ try {
213
+ // Before any byte: a regular file, and — unless forced — still holding
214
+ // what was decided on. The hash reads the whole file, so it comes first,
215
+ // and the checks of where the file is follow it.
216
+ const info = await handle.stat({ bigint: true });
217
+ if (!info.isFile()) throw new EffectFailed('not a regular file', touched);
218
+ if (expect.kind === 'content' && (await hashAt(target, info, noFollow)) !== expect.hash) throw new EffectFailed(CHANGED, touched);
219
+ // Then: canonically inside the mount, the one this path names (and not a
220
+ // symlink, where the mount follows none and the open couldn't refuse
221
+ // one), with no other names, in the directory still at its path beneath
222
+ // the root still at its own.
223
+ const real = await realpath(target).catch(() => null);
224
+ if (real === null) throw new EffectFailed(CHANGED, touched);
225
+ if (!contained(rootReal, real)) throw new EffectFailed(view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT, touched);
226
+ const named = await stat(real, { bigint: true }).catch(() => null);
227
+ if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
228
+ if (!view.followSymlinks) {
229
+ const entry = await lstat(target, { bigint: true }).catch(() => null);
230
+ if (entry?.isSymbolicLink()) throw new EffectFailed(NOT_FOLLOWED, touched);
231
+ if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
232
+ }
233
+ if (info.nlink > 1n) throw new EffectFailed(HARD_LINKED, touched);
234
+ const gone = await moved(root, parent);
235
+ if (gone !== null) throw new EffectFailed(gone, touched);
236
+ // Nothing wrote it while those ran.
237
+ if (!unchanged(info, await handle.stat({ bigint: true }).catch(() => null))) throw new EffectFailed(CHANGED, touched);
238
+
239
+ touched = true;
240
+ await handle.truncate(0);
241
+ await handle.writeFile(bytes);
242
+ stage = 'make the write durable (file fsync)';
243
+ await handle.sync();
244
+ } catch (err) {
245
+ if (err instanceof EffectFailed) throw err;
246
+ throw new EffectFailed(`could not ${stage}: ${message(err)}`, touched);
247
+ } finally {
248
+ await handle.close();
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Unlink a mount-relative file, or confirm it gone (`absent` expected: a
254
+ * file found there is never removed). Returns the mount directory whose
255
+ * entries make the absence durable, for the caller's barrier: the file's
256
+ * parent, or — when the parent itself is gone — the directory proven to lack
257
+ * it, since that removal may not be durable either.
258
+ */
259
+ export async function unlinkContained(view: MountView, rootReal: string, rel: string, expect: Expect): Promise<string> {
260
+ const checked = await parentFor(view, rootReal, rel, false);
261
+ if (checked.kind === 'absent') {
262
+ // No directory, so no file: proven beneath a root still at its path.
263
+ if (!(await checked.root.stillHere())) throw new EffectFailed(ROOT_REPLACED, false);
264
+ return checked.by;
265
+ }
266
+ const parent = checked.dir;
267
+ const root = checked.root;
268
+ const target = parent.at(nameOf(rel));
269
+ let info;
270
+ try {
271
+ info = await lstat(target, { bigint: true });
272
+ } catch (err) {
273
+ if (errno(err) === 'ENOENT') {
274
+ const gone = await moved(root, parent);
275
+ if (gone !== null) throw new EffectFailed(gone, false);
276
+ return parent.rel;
277
+ }
278
+ throw new EffectFailed(`cannot stat (${errno(err) ?? message(err)})`, false);
279
+ }
280
+ // Confirming an unlink that already happened never removes a file that
281
+ // appeared since.
282
+ if (expect.kind === 'absent') {
283
+ const kind = info.isDirectory() ? 'a directory' : info.isSymbolicLink() ? 'a symlink' : 'a file';
284
+ throw new EffectFailed(`${kind} appeared at this path since it was checked`, false);
285
+ }
286
+ let file = info;
287
+ if (info.isSymbolicLink()) {
288
+ // The link itself is what is removed; a followed one must still lie inside.
289
+ if (!view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
290
+ const real = await realpath(target).catch(() => null);
291
+ if (real === null || !contained(rootReal, real)) throw new EffectFailed('a symlink that leaves the mount', false);
292
+ const through = await stat(real, { bigint: true }).catch(() => null);
293
+ if (through === null) throw new EffectFailed(CHANGED, false);
294
+ file = through;
295
+ } else if (info.isDirectory()) {
296
+ throw new EffectFailed('a directory', false);
297
+ }
298
+ if (expect.kind === 'content' && (await hashAt(target, file, noFollowFlag(view))) !== expect.hash) throw new EffectFailed(CHANGED, false);
299
+ const gone = await moved(root, parent);
300
+ if (gone !== null) throw new EffectFailed(gone, false);
301
+ // The entry checked is still the one at the path, unwritten since it was
302
+ // hashed, as close to the unlink as Node allows (see the header): a file
303
+ // replaced or edited while it was hashed isn't removed.
304
+ const again = await lstat(target, { bigint: true }).catch(() => null);
305
+ const named = again?.isSymbolicLink() ? await stat(target, { bigint: true }).catch(() => null) : again;
306
+ if (!unchanged(file, named)) throw new EffectFailed(CHANGED, false);
307
+ try {
308
+ await unlink(target);
309
+ } catch (err) {
310
+ const code = errno(err);
311
+ // An unlink is all or nothing: one refused outright changed nothing, so
312
+ // the path's evidence can go back as it was. Anything else may have
313
+ // landed (a network filesystem's EIO, say): the next observation resolves it.
314
+ if (code !== 'ENOENT') throw new EffectFailed(`could not unlink the file: ${message(err)}`, !UNLINK_REFUSALS.has(code ?? ''));
315
+ }
316
+ return parent.rel;
317
+ }
318
+
319
+ /**
320
+ * Sync each mount directory (mount-relative), deepest first, so the entries
321
+ * a push created or removed in them are durable. Each is checked first and the
322
+ * directory it holds is the one synced — never a replacement at its path.
323
+ * Returns the ones that failed, with why. Where directories can't be synced
324
+ * at all (Windows, or a filesystem answering EINVAL/ENOTSUP), there is
325
+ * nothing to wait for.
326
+ */
327
+ export async function syncDirectories(view: MountView, rootReal: string, rels: Iterable<string>): Promise<Map<string, string>> {
328
+ const failed = new Map<string, string>();
329
+ const depth = (rel: string): number => (rel === '' ? 0 : rel.split('/').length);
330
+ const sorted = [...new Set(rels)].sort((a, b) => depth(b) - depth(a));
331
+ // Each directory is found by path beneath the root's path: under a root
332
+ // that isn't the one disk agreed under, or that is replaced while they're
333
+ // synced, none of them is the mount's.
334
+ const root = await checkRoot(view, rootReal);
335
+ if ('reason' in root) {
336
+ for (const rel of sorted) failed.set(rel, root.reason);
337
+ return failed;
338
+ }
339
+ for (const rel of sorted) {
340
+ const checked = await checkDir(view, rootReal, rel);
341
+ if (checked.kind !== 'checked') {
342
+ failed.set(rel, checked.kind === 'absent' ? 'the directory is gone' : checked.reason);
343
+ continue;
344
+ }
345
+ try {
346
+ await checked.dir.sync();
347
+ if (!(await root.dir.stillHere())) failed.set(rel, ROOT_REPLACED);
348
+ } catch (err) {
349
+ failed.set(rel, message(err));
350
+ }
351
+ }
352
+ return failed;
353
+ }
354
+
355
+ /** The directories whose entries make a written file reachable: its parent up to the mount root. */
356
+ export function chainOf(rel: string): string[] {
357
+ const chain: string[] = [];
358
+ for (let dir = parentOf(rel); ; dir = parentOf(dir)) {
359
+ chain.push(dir);
360
+ if (dir === '') return chain;
361
+ }
362
+ }