@animalabs/agent-framework 0.21.1 → 0.22.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 (271) hide show
  1. package/README.md +43 -0
  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 +102 -42
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/py-runner.d.ts +22 -1
  7. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.js +64 -9
  9. package/dist/src/code-execution/py-runner.js.map +1 -1
  10. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  11. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  12. package/dist/src/code-execution/runtime-py.js +215 -38
  13. package/dist/src/code-execution/runtime-py.js.map +1 -1
  14. package/dist/src/framework.d.ts +250 -39
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +1602 -371
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts +21 -0
  19. package/dist/src/gate/event-gate.d.ts.map +1 -1
  20. package/dist/src/gate/event-gate.js +40 -7
  21. package/dist/src/gate/event-gate.js.map +1 -1
  22. package/dist/src/index.d.ts +5 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +26 -3
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  30. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  31. package/dist/src/mcpl/conversation-router.js +17 -0
  32. package/dist/src/mcpl/conversation-router.js.map +1 -1
  33. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  34. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  35. package/dist/src/mcpl/feature-set-manager.js +31 -7
  36. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  37. package/dist/src/mcpl/index.d.ts +2 -0
  38. package/dist/src/mcpl/index.d.ts.map +1 -1
  39. package/dist/src/mcpl/index.js +1 -0
  40. package/dist/src/mcpl/index.js.map +1 -1
  41. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  42. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  43. package/dist/src/mcpl/prose-stream-router.js +85 -22
  44. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  45. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  46. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-coalescer.js +46 -13
  48. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  49. package/dist/src/mcpl/push-handler.d.ts +5 -3
  50. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-handler.js +41 -11
  52. package/dist/src/mcpl/push-handler.js.map +1 -1
  53. package/dist/src/mcpl/server-connection.d.ts +4 -0
  54. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  55. package/dist/src/mcpl/server-connection.js +45 -8
  56. package/dist/src/mcpl/server-connection.js.map +1 -1
  57. package/dist/src/mcpl/server-registry.d.ts +5 -2
  58. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-registry.js +25 -7
  60. package/dist/src/mcpl/server-registry.js.map +1 -1
  61. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  62. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  63. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  64. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  65. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  66. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  67. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  68. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  69. package/dist/src/mcpl/types.d.ts +15 -5
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js.map +1 -1
  72. package/dist/src/mcpl/visible-content.d.ts +39 -0
  73. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  74. package/dist/src/mcpl/visible-content.js +54 -0
  75. package/dist/src/mcpl/visible-content.js.map +1 -0
  76. package/dist/src/module-registry.d.ts +81 -3
  77. package/dist/src/module-registry.d.ts.map +1 -1
  78. package/dist/src/module-registry.js +257 -16
  79. package/dist/src/module-registry.js.map +1 -1
  80. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  81. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  82. package/dist/src/modules/workspace/branch-intent.js +83 -0
  83. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  84. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  85. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  86. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  87. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  88. package/dist/src/modules/workspace/effects.d.ts +84 -0
  89. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  90. package/dist/src/modules/workspace/effects.js +368 -0
  91. package/dist/src/modules/workspace/effects.js.map +1 -0
  92. package/dist/src/modules/workspace/index.d.ts +155 -17
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +1059 -424
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/observe.d.ts +278 -0
  97. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  98. package/dist/src/modules/workspace/observe.js +805 -0
  99. package/dist/src/modules/workspace/observe.js.map +1 -0
  100. package/dist/src/modules/workspace/reconcile.d.ts +224 -0
  101. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  102. package/dist/src/modules/workspace/reconcile.js +901 -0
  103. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  104. package/dist/src/modules/workspace/sync.d.ts +6 -59
  105. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/sync.js +6 -257
  107. package/dist/src/modules/workspace/sync.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +77 -10
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/record-journal.d.ts +127 -0
  112. package/dist/src/record-journal.d.ts.map +1 -0
  113. package/dist/src/record-journal.js +202 -0
  114. package/dist/src/record-journal.js.map +1 -0
  115. package/dist/src/shutdown.d.ts +13 -0
  116. package/dist/src/shutdown.d.ts.map +1 -0
  117. package/dist/src/shutdown.js +46 -0
  118. package/dist/src/shutdown.js.map +1 -0
  119. package/dist/src/silent-heartbeat.d.ts +74 -0
  120. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  121. package/dist/src/silent-heartbeat.js +77 -0
  122. package/dist/src/silent-heartbeat.js.map +1 -0
  123. package/dist/src/tool-presentation.d.ts +50 -0
  124. package/dist/src/tool-presentation.d.ts.map +1 -0
  125. package/dist/src/tool-presentation.js +235 -0
  126. package/dist/src/tool-presentation.js.map +1 -0
  127. package/dist/src/tool-result-guard.d.ts +125 -6
  128. package/dist/src/tool-result-guard.d.ts.map +1 -1
  129. package/dist/src/tool-result-guard.js +310 -35
  130. package/dist/src/tool-result-guard.js.map +1 -1
  131. package/dist/src/tool-result-history.d.ts +13 -0
  132. package/dist/src/tool-result-history.d.ts.map +1 -1
  133. package/dist/src/tool-result-history.js +21 -1
  134. package/dist/src/tool-result-history.js.map +1 -1
  135. package/dist/src/types/agent.d.ts +18 -2
  136. package/dist/src/types/agent.d.ts.map +1 -1
  137. package/dist/src/types/events.d.ts +4 -0
  138. package/dist/src/types/events.d.ts.map +1 -1
  139. package/dist/src/types/framework.d.ts +12 -0
  140. package/dist/src/types/framework.d.ts.map +1 -1
  141. package/dist/src/types/module.d.ts +14 -0
  142. package/dist/src/types/module.d.ts.map +1 -1
  143. package/dist/src/types/trace.d.ts +73 -0
  144. package/dist/src/types/trace.d.ts.map +1 -1
  145. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  146. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  147. package/dist/test/batched-wake-locus.test.js +166 -0
  148. package/dist/test/batched-wake-locus.test.js.map +1 -0
  149. package/dist/test/code-execution.test.js +386 -2
  150. package/dist/test/code-execution.test.js.map +1 -1
  151. package/dist/test/component-defaults.test.d.ts +2 -0
  152. package/dist/test/component-defaults.test.d.ts.map +1 -0
  153. package/dist/test/component-defaults.test.js +54 -0
  154. package/dist/test/component-defaults.test.js.map +1 -0
  155. package/dist/test/conversation-routing.test.js +57 -1
  156. package/dist/test/conversation-routing.test.js.map +1 -1
  157. package/dist/test/ephemeral-settle.test.js +3 -0
  158. package/dist/test/ephemeral-settle.test.js.map +1 -1
  159. package/dist/test/event-gate.test.js +118 -0
  160. package/dist/test/event-gate.test.js.map +1 -1
  161. package/dist/test/explicit-prose-routing.test.js +977 -0
  162. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  163. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  164. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  165. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  166. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  167. package/dist/test/mcpl-coalescing.test.js +19 -6
  168. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  169. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  170. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  171. package/dist/test/mcpl-config-diagnostics.test.js +449 -0
  172. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  173. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  174. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  175. package/dist/test/mcpl-empty-content.test.js +368 -0
  176. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  177. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  178. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  179. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  180. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  181. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  182. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  183. package/dist/test/prose-stream-router.test.js +41 -0
  184. package/dist/test/prose-stream-router.test.js.map +1 -1
  185. package/dist/test/record-journal.test.d.ts +6 -0
  186. package/dist/test/record-journal.test.d.ts.map +1 -0
  187. package/dist/test/record-journal.test.js +269 -0
  188. package/dist/test/record-journal.test.js.map +1 -0
  189. package/dist/test/save-recent-image.test.js +102 -0
  190. package/dist/test/save-recent-image.test.js.map +1 -1
  191. package/dist/test/shutdown-errors.test.d.ts +2 -0
  192. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  193. package/dist/test/shutdown-errors.test.js +662 -0
  194. package/dist/test/shutdown-errors.test.js.map +1 -0
  195. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  196. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  197. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  198. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  199. package/dist/test/silent-heartbeat.test.js +49 -38
  200. package/dist/test/silent-heartbeat.test.js.map +1 -1
  201. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  202. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  203. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  204. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  205. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  206. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  207. package/dist/test/tool-batch-hook.test.js +275 -0
  208. package/dist/test/tool-batch-hook.test.js.map +1 -0
  209. package/dist/test/tool-presentation.test.d.ts +2 -0
  210. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  211. package/dist/test/tool-presentation.test.js +268 -0
  212. package/dist/test/tool-presentation.test.js.map +1 -0
  213. package/dist/test/tool-result-guard.test.js +2330 -18
  214. package/dist/test/tool-result-guard.test.js.map +1 -1
  215. package/dist/test/tool-result-history.test.js +2 -1
  216. package/dist/test/tool-result-history.test.js.map +1 -1
  217. package/dist/test/turn-trigger-provenance.test.js +35 -0
  218. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  219. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  220. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  221. package/dist/test/unconvertible-tool-result.test.js +358 -0
  222. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  223. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  224. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  225. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  226. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  227. package/dist/test/workspace-three-way.test.d.ts +12 -0
  228. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  229. package/dist/test/workspace-three-way.test.js +3079 -0
  230. package/dist/test/workspace-three-way.test.js.map +1 -0
  231. package/dist/tsconfig.tsbuildinfo +1 -1
  232. package/package.json +2 -2
  233. package/src/agent.ts +120 -44
  234. package/src/code-execution/py-runner.ts +66 -9
  235. package/src/code-execution/runtime-py.ts +215 -38
  236. package/src/framework.ts +1661 -363
  237. package/src/gate/event-gate.ts +59 -7
  238. package/src/index.ts +11 -0
  239. package/src/mcpl/channel-registry.ts +24 -3
  240. package/src/mcpl/conversation-router.ts +34 -1
  241. package/src/mcpl/feature-set-manager.ts +36 -11
  242. package/src/mcpl/index.ts +2 -0
  243. package/src/mcpl/prose-stream-router.ts +79 -23
  244. package/src/mcpl/push-coalescer.ts +62 -13
  245. package/src/mcpl/push-handler.ts +41 -12
  246. package/src/mcpl/server-connection.ts +41 -10
  247. package/src/mcpl/server-registry.ts +24 -7
  248. package/src/mcpl/tool-lifecycle.ts +77 -2
  249. package/src/mcpl/tool-pattern-check.ts +106 -0
  250. package/src/mcpl/types.ts +15 -5
  251. package/src/mcpl/visible-content.ts +57 -0
  252. package/src/module-registry.ts +252 -16
  253. package/src/modules/workspace/branch-intent.ts +123 -0
  254. package/src/modules/workspace/disk-agreement.ts +325 -0
  255. package/src/modules/workspace/effects.ts +362 -0
  256. package/src/modules/workspace/index.ts +1058 -446
  257. package/src/modules/workspace/observe.ts +839 -0
  258. package/src/modules/workspace/reconcile.ts +1136 -0
  259. package/src/modules/workspace/sync.ts +6 -336
  260. package/src/modules/workspace/types.ts +75 -10
  261. package/src/record-journal.ts +246 -0
  262. package/src/shutdown.ts +49 -0
  263. package/src/silent-heartbeat.ts +141 -0
  264. package/src/tool-presentation.ts +188 -0
  265. package/src/tool-result-guard.ts +335 -33
  266. package/src/tool-result-history.ts +25 -1
  267. package/src/types/agent.ts +18 -2
  268. package/src/types/events.ts +4 -0
  269. package/src/types/framework.ts +12 -0
  270. package/src/types/module.ts +15 -0
  271. package/src/types/trace.ts +80 -0
@@ -0,0 +1,1136 @@
1
+ /**
2
+ * The three-way rule over disk (D), the workspace store (S) and physical
3
+ * evidence (P), with branch-local intent (tombstones, store origin, sticky
4
+ * conflicts). Under it a scan or read never discards a pending store edit,
5
+ * and a branch switch is never mistaken for a disk edit:
6
+ *
7
+ * - D = S: agreement; P := D.
8
+ * - P known: D = P keeps the store side (a draft, a workspace deletion, or a
9
+ * file this branch doesn't have); S = P adopts disk (ingest, or remove S,
10
+ * or — for a binary/oversize disk version — drop S so the path reads
11
+ * disk-only); all three differing is a conflict.
12
+ * - P unknown: a tombstone, then store origin, then legacy entries decide;
13
+ * otherwise a new disk file is ingested or listed disk-only.
14
+ * - P pending (an intent whose effect may or may not have landed) resolves
15
+ * first; P interrupted is a sticky conflict until it's resolved explicitly
16
+ * or disk and the store agree. Another branch's pending adoption is left
17
+ * for its own branch to resolve, even where this branch agrees with disk.
18
+ *
19
+ * Only a push completes a push. A pending disk intent is the module's own
20
+ * write or unlink, and its completion releases the protection a draft has
21
+ * (P := what was written), so it is recorded only by a push whose barriers
22
+ * succeeded. An observation that finds the intended outcome on disk decides
23
+ * the path's state from it but records nothing for it — no completion, no
24
+ * agreement, no branch-intent change — and the path stays owed until a push
25
+ * redoes it. Otherwise a crash that loses an unbarriered write would leave
26
+ * P saying the draft reached disk, and the rule would adopt the older disk
27
+ * version over it. (An explicit path sync still chooses disk, replacing
28
+ * whatever the path's evidence was; that adopts disk, it doesn't certify the
29
+ * push.)
30
+ *
31
+ * Each pass observes disk first (the only async step), then reads S, P and
32
+ * intent and applies every decision in one synchronous step, in the agreed
33
+ * order: intents (durable) → tree commits and intent records → completions
34
+ * (after `store.sync()`). The caller serializes passes per mount. A drop (S
35
+ * removed for a disk copy the workspace can't hold) is the one tree adoption
36
+ * without an intent: it forgets P first, which is conservative, since a crash
37
+ * before the removal leaves P unknown with S present, a conflict, and never
38
+ * a wrong adoption.
39
+ */
40
+
41
+ import type { JsStore } from '@animalabs/chronicle';
42
+ import {
43
+ type Agreed,
44
+ type Candidate,
45
+ type DiskAgreement,
46
+ type Fingerprint,
47
+ type Interrupted,
48
+ type Pending,
49
+ type Physical,
50
+ type RootIdentity,
51
+ candidateOf,
52
+ sameCandidate,
53
+ sameRootIdentity,
54
+ } from './disk-agreement.js';
55
+ import type { BranchIntent, BranchIntents, ConflictKind, ConflictRecord, DiskCounterpart } from './branch-intent.js';
56
+ import {
57
+ type MountView,
58
+ ROOT_REPLACED,
59
+ type Walk,
60
+ fingerprintVouches,
61
+ locateRoot,
62
+ looksBinary,
63
+ observePath,
64
+ provenAbsent,
65
+ readHead,
66
+ readPath,
67
+ sniffImageMime,
68
+ walkScope,
69
+ } from './observe.js';
70
+ import { EffectFailed, type Expect, chainOf, syncDirectories, unlinkContained, writeContained } from './effects.js';
71
+
72
+ export type EntryState =
73
+ | 'synced'
74
+ | 'workspace-draft'
75
+ | 'workspace-deleted'
76
+ | 'not-in-branch'
77
+ | 'disk-only'
78
+ | 'conflict'
79
+ | 'disk-missing-provenance-unknown'
80
+ /** Tracked, but disk wasn't observed this pass (unreadable, ignored, unwalked). */
81
+ | 'unverified';
82
+
83
+ export interface ConflictReport {
84
+ kind: ConflictKind;
85
+ /** Where the disk side is kept: in the store, by reference to the disk file, or absent. */
86
+ diskCopy: 'stored' | 'referenced' | 'absent';
87
+ /** The disk file no longer matches what was recorded with the conflict. */
88
+ diskChangedSinceRecorded: boolean;
89
+ }
90
+
91
+ export interface PathReport {
92
+ path: string;
93
+ state: EntryState;
94
+ /** Store size for entries in the store; disk size otherwise. */
95
+ size?: number;
96
+ mimeType?: string;
97
+ conflict?: ConflictReport;
98
+ note?: string;
99
+ /**
100
+ * For a conflict, what this pass found on disk at the path: grep's account
101
+ * of a conflict's disk side needs it. Listings don't show it.
102
+ */
103
+ diskNow?: 'file' | 'absent';
104
+ }
105
+
106
+ export type TreeOp = 'created' | 'modified' | 'deleted';
107
+
108
+ export interface PassResult {
109
+ /** Tree changes adopted from disk. */
110
+ ops: Array<{ path: string; op: TreeOp }>;
111
+ /** What a path sync gave up of the workspace's own; `op` is what it did to the
112
+ * workspace copy, absent when it changed none (a deletion or conflict given
113
+ * up for a disk copy the workspace can't hold). */
114
+ discarded: Array<{ path: string; was: EntryState; op?: TreeOp }>;
115
+ /** Conflicts recorded by this pass, with the disk change behind each. */
116
+ newConflicts: Array<{ path: string; op: TreeOp }>;
117
+ /** Every path the pass decided, by path. */
118
+ reports: Map<string, PathReport>;
119
+ /** Regions the pass couldn't observe that matter to its scope. */
120
+ incomplete: Array<{ path: string; reason: string }>;
121
+ /** For a directory scope: the subdirectories disk shows directly beneath it. */
122
+ dirs: string[];
123
+ /**
124
+ * The branch the pass decided on, absent when it decided nothing. A caller
125
+ * that reads the tree or branch intent to go with the reports checks it is
126
+ * still the selected branch, synchronously, before it reads.
127
+ */
128
+ branchId?: string;
129
+ }
130
+
131
+ export interface MountRuntime {
132
+ name: string;
133
+ view: MountView;
134
+ /** Canonical mount root, for symlink containment. */
135
+ rootReal: string;
136
+ /**
137
+ * The device of the mount root's filesystem when that filesystem maintains
138
+ * ctime, else null. A fingerprint vouches, and is recorded, only for a file
139
+ * on that device: a file on another filesystem mounted beneath the root
140
+ * (which wasn't checked) is rehashed at every observation.
141
+ */
142
+ ctimeTrustedDev: string | null;
143
+ treeStateId: string;
144
+ intents: BranchIntents;
145
+ readOnly: boolean;
146
+ }
147
+
148
+ export type Scope =
149
+ | { kind: 'paths'; paths: string[] }
150
+ | { kind: 'dir'; dir: string; recursive: boolean };
151
+
152
+ export interface PassOptions {
153
+ /** Rehash every file instead of trusting fingerprints (an explicit sync). */
154
+ rehash?: boolean;
155
+ /** Paths whose disk state is adopted explicitly (a path sync). */
156
+ adopt?: (path: string) => boolean;
157
+ /** Walk cap for directory scopes. */
158
+ cap?: number;
159
+ /**
160
+ * The branch the caller asked on (a path sync). If another is selected when
161
+ * the pass gathers, it decides nothing: an adoption asked for on one branch
162
+ * would give up another branch's drafts.
163
+ */
164
+ branchId?: string;
165
+ }
166
+
167
+ const ABSENT: Candidate = { kind: 'absent' };
168
+ export const WALK_CAP = 5000;
169
+
170
+ /** What a pass learned about D for one path. */
171
+ type DiskFact =
172
+ | { kind: 'absent' }
173
+ | { kind: 'unobserved'; reason: string }
174
+ | {
175
+ kind: 'file';
176
+ hash: string;
177
+ size: number;
178
+ mtimeMs: number;
179
+ fp: Fingerprint | null;
180
+ /** Text within limits, with its bytes: what disk→store sync may store. */
181
+ ingestible: Buffer | null;
182
+ mimeType?: string;
183
+ }
184
+ /**
185
+ * A file nothing tracks, too large to ingest: listed from its size and head
186
+ * alone. Its bytes were never hashed, so it can't be compared with anything
187
+ * — it is never evidence of agreement, only of a disk-only file.
188
+ */
189
+ | { kind: 'unhashed'; size: number; mtimeMs: number; mimeType?: string };
190
+
191
+ function diskCandidate(d: DiskFact): Candidate | null {
192
+ if (d.kind === 'absent') return ABSENT;
193
+ if (d.kind === 'file') return { kind: 'content', hash: d.hash };
194
+ return null;
195
+ }
196
+
197
+ function agreedFromDisk(d: DiskFact): Agreed {
198
+ if (d.kind !== 'file') return { kind: 'absent' };
199
+ return { kind: 'content', hash: d.hash, size: d.size, ...(d.fp ? { fp: d.fp } : {}) };
200
+ }
201
+
202
+ function inScope(path: string, scope: Scope): boolean {
203
+ if (scope.kind === 'paths') return scope.paths.includes(path);
204
+ const dir = scope.dir;
205
+ if (dir === '') return scope.recursive || !path.includes('/');
206
+ if (!path.startsWith(dir + '/')) return false;
207
+ return scope.recursive || !path.slice(dir.length + 1).includes('/');
208
+ }
209
+
210
+ /**
211
+ * Learn D for a path. Content is read only when a decision needs it; a
212
+ * recorded fingerprint can vouch for unchanged bytes where the filesystem
213
+ * maintains ctime.
214
+ */
215
+ async function learnDisk(
216
+ mount: MountRuntime,
217
+ path: string,
218
+ tracked: { p: Physical | undefined; hasStore: boolean; hasIntent: boolean },
219
+ opts: PassOptions,
220
+ ): Promise<DiskFact> {
221
+ const seen = await observePath(mount.view, mount.rootReal, path);
222
+ if (seen.kind !== 'file') return seen;
223
+ const p = tracked.p;
224
+ const trusted = (fp: { dev: string }) => mount.ctimeTrustedDev !== null && fp.dev === mount.ctimeTrustedDev;
225
+ if (!opts.rehash && p?.kind === 'content' && fingerprintVouches(p.fp, seen.fp, trusted(seen.fp))) {
226
+ return { kind: 'file', hash: p.hash, size: seen.size, mtimeMs: seen.mtimeMs, fp: p.fp ?? null, ingestible: null };
227
+ }
228
+ const nothingTracked = p === undefined && !tracked.hasStore && !tracked.hasIntent;
229
+ if (nothingTracked && seen.size > mount.view.maxFileSize) {
230
+ // A new oversize file only needs listing: its size, and its head for the
231
+ // MIME type. Hashing it would read all of it, on every listing.
232
+ const head = await readHead(mount.view, mount.rootReal, path);
233
+ if (head.kind !== 'head') return head;
234
+ return { kind: 'unhashed', size: head.size, mtimeMs: head.mtimeMs, mimeType: sniffImageMime(head.head) };
235
+ }
236
+ const read = await readPath(mount.view, mount.rootReal, path, mount.view.maxFileSize);
237
+ if (read.kind !== 'read') return read;
238
+ const ingestible = read.bytes !== null && !looksBinary(read.head) ? read.bytes : null;
239
+ return {
240
+ kind: 'file',
241
+ hash: read.hash,
242
+ size: read.size,
243
+ mtimeMs: read.mtimeMs,
244
+ // A fingerprint is recorded only where it can later vouch for the bytes.
245
+ fp: trusted(read.fp) ? read.fp : null,
246
+ ingestible,
247
+ ...(ingestible ? {} : { mimeType: sniffImageMime(read.head) }),
248
+ };
249
+ }
250
+
251
+ /** The rule's verdict for one path, before effects. */
252
+ interface Verdict {
253
+ state: EntryState;
254
+ /** Adopt disk into the tree: set S to D's bytes, or remove S. */
255
+ adopt?: 'ingest' | 'remove' | 'drop';
256
+ /** New P (undefined: unchanged; 'forget': unknown). */
257
+ p?: Agreed | Interrupted | 'forget';
258
+ /** New branch intent (undefined: unchanged). */
259
+ intent?: BranchIntent;
260
+ /** A conflict to record now. */
261
+ conflict?: ConflictKind;
262
+ op?: TreeOp;
263
+ note?: string;
264
+ /**
265
+ * Disk shows what a pending push of ours intended, unconfirmed: the state
266
+ * is decided from it, and nothing at all is recorded (see module doc).
267
+ */
268
+ unconfirmed?: true;
269
+ /** A path sync's adoption gives up this workspace state (see decide). */
270
+ discards?: EntryState;
271
+ /**
272
+ * The last confirmed evidence decide resolved for the path, which an
273
+ * adoption's intent carries as its prior: a settled same-branch adoption's
274
+ * outcome, where the recorded P is still that adoption's pending record.
275
+ */
276
+ prior?: Agreed;
277
+ }
278
+
279
+ /** States in which the rule keeps a change of the workspace's own over disk. */
280
+ const WORKSPACE_KEPT: ReadonlySet<EntryState> = new Set<EntryState>([
281
+ 'workspace-draft',
282
+ 'workspace-deleted',
283
+ 'conflict',
284
+ 'disk-missing-provenance-unknown',
285
+ ]);
286
+
287
+ const UNCONFIRMED_NOTE = 'disk holds what a materialize that has not completed put here; materialize again to complete it';
288
+
289
+ function keepStoreSide(hasStore: boolean, bi: BranchIntent | null): EntryState {
290
+ if (hasStore) return 'workspace-draft';
291
+ return bi?.tombstone ? 'workspace-deleted' : 'not-in-branch';
292
+ }
293
+
294
+ /**
295
+ * Settle an adoption intent on the branch it was recorded on, by the tree
296
+ * alone: S at the adopted content means the commit landed and only its
297
+ * completion was lost; anything else means it didn't, and the rule runs again
298
+ * from the prior evidence (redoing the adoption if disk still holds it).
299
+ *
300
+ * Content is proof only while nothing else has written the path since, so
301
+ * every workspace mutation settles a pending intent first (the module's
302
+ * mutation boundary): a later write of the same content can't pass for the
303
+ * old adoption.
304
+ */
305
+ function settleAdoption(p: Pending, s: { hash: string; size: number } | null): Agreed | 'forget' {
306
+ const sc: Candidate = s ? { kind: 'content', hash: s.hash } : ABSENT;
307
+ if (sameCandidate(sc, p.expect)) {
308
+ return p.expect.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: p.expect.hash, size: s!.size };
309
+ }
310
+ return p.prior ?? 'forget';
311
+ }
312
+
313
+ /**
314
+ * The synchronous precondition of every workspace mutation: evidence owed a
315
+ * barrier is made durable first, and an adoption left pending on the
316
+ * selected branch is settled by the tree before the mutation changes it, and
317
+ * durably, so the settlement can't be lost while the new write survives.
318
+ * Afterwards the write can never pass for the adoption's outcome. Other
319
+ * pending intents never consult the tree, so a write can't confuse them.
320
+ * Holds whether or not an earlier observation could settle it.
321
+ */
322
+ export function settleAdoptionBeforeMutation(
323
+ store: JsStore,
324
+ agreement: DiskAgreement,
325
+ mount: { name: string; treeStateId: string },
326
+ path: string,
327
+ ): void {
328
+ // Evidence still owed a barrier (from a failed one, or read back at open)
329
+ // is made durable before any new tree mutation is permitted at all.
330
+ if (agreement.needsBarrier) agreement.barrier();
331
+ const p = agreement.get(mount.name, path);
332
+ if (p?.kind !== 'pending' || p.effect !== 'adopt' || p.branchId !== store.currentBranch().id) return;
333
+ const entry = store.treeGet(mount.treeStateId, path);
334
+ const settled = settleAdoption(p, entry ? { hash: entry.blobHash, size: entry.size } : null);
335
+ if (settled === 'forget') agreement.forget(mount.name, path);
336
+ else agreement.set(mount.name, path, settled, { afterCommittedState: true });
337
+ agreement.barrier();
338
+ }
339
+
340
+ /**
341
+ * Resolve a pending intent against what was observed. Returns the evidence
342
+ * the rule should use, and whether that resolution should be recorded.
343
+ */
344
+ function resolvePending(
345
+ p: Pending,
346
+ dc: Candidate,
347
+ s: { hash: string; size: number } | null,
348
+ d: DiskFact,
349
+ branchId: string,
350
+ ): { p: Physical | undefined; record?: Agreed | Interrupted | 'forget'; uncaptured?: true; unconfirmed?: true } {
351
+ const prior = p.prior;
352
+ const priorCandidate = prior ? candidateOf(prior) : null;
353
+ if (p.effect === 'disk') {
354
+ if (sameCandidate(dc, p.expect)) {
355
+ // Our own effect shows on disk, but nothing proves it durable: decided
356
+ // on, never recorded. Only a push whose barriers succeed completes it.
357
+ const shown = p.expect.kind === 'absent' ? ({ kind: 'absent' } as Agreed) : agreedFromDisk(d);
358
+ return { p: shown, unconfirmed: true };
359
+ }
360
+ if (priorCandidate && sameCandidate(dc, priorCandidate)) return { p: prior!, record: prior! };
361
+ // A write whose prior was unknown left no file: it never created one (a
362
+ // partial write leaves a file behind), so P is as unknown as before.
363
+ if (!prior && dc.kind === 'absent' && p.expect.kind === 'content') return { p: undefined, record: 'forget' };
364
+ const interrupted: Interrupted = { kind: 'interrupted', candidates: [...(priorCandidate ? [priorCandidate] : []), p.expect] };
365
+ return { p: interrupted, record: interrupted };
366
+ }
367
+ // An adoption: its meaning depends on the branch it was recorded on.
368
+ if (p.branchId === branchId) {
369
+ const landed = sameCandidate(s ? { kind: 'content', hash: s.hash } : ABSENT, p.expect);
370
+ // Landed, with disk at the adopted content too: its fingerprint joins the evidence.
371
+ if (landed && p.expect.kind === 'content' && sameCandidate(dc, p.expect)) {
372
+ const agreed = agreedFromDisk(d);
373
+ return { p: agreed, record: agreed };
374
+ }
375
+ const settled = settleAdoption(p, s);
376
+ return settled === 'forget' ? { p: undefined, record: 'forget' } : { p: settled, record: settled };
377
+ }
378
+ // Another branch, whose S says nothing of whether the adoption happened
379
+ // over there. Disk still holding the prior is D = P: the store has that
380
+ // content. Disk holding the adopted content, or anything newer, may hold
381
+ // the only copy of it, so this branch lists it as a conflict and nothing
382
+ // pushes over it; P stays pending for the intent's own branch to resolve.
383
+ if (priorCandidate && sameCandidate(dc, priorCandidate)) return { p };
384
+ return { p, uncaptured: true };
385
+ }
386
+
387
+ /**
388
+ * An explicit path sync: the store takes disk's state, whatever the evidence
389
+ * — a pending push's included. That chooses disk; it doesn't certify the push.
390
+ */
391
+ function adoptDisk(d: Extract<DiskFact, { kind: 'absent' | 'file' }>, s: { hash: string; size: number } | null): Verdict {
392
+ if (d.kind === 'absent') {
393
+ return { state: 'synced', ...(s ? { adopt: 'remove' as const, op: 'deleted' as const } : {}), p: { kind: 'absent' }, intent: {} };
394
+ }
395
+ // Disk agreeing with the store is disk's state already, whatever the file
396
+ // is: a binary or oversize file the workspace holds stays held.
397
+ if (s && s.hash === d.hash) return { state: 'synced', p: agreedFromDisk(d), intent: {} };
398
+ if (d.ingestible) return { state: 'synced', adopt: 'ingest', op: s ? 'modified' : 'created', p: agreedFromDisk(d), intent: {} };
399
+ return { state: 'disk-only', ...(s ? { adopt: 'drop' as const, op: 'deleted' as const } : {}), p: 'forget', intent: {} };
400
+ }
401
+
402
+ /** The rule (see module doc). Pure. */
403
+ function decide(
404
+ d: DiskFact,
405
+ s: { hash: string; size: number } | null,
406
+ pIn: Physical | undefined,
407
+ bi: BranchIntent | null,
408
+ branchId: string,
409
+ adopt: boolean,
410
+ ): Verdict {
411
+ // A branch intent that can't be read may have held a tombstone or a
412
+ // conflict: nothing is decided over it, until a path sync replaces it.
413
+ if (bi?.unreadable !== undefined && !adopt) return { state: 'unverified', note: `its branch intent can't be read: ${bi.unreadable}` };
414
+ if (d.kind === 'unobserved') {
415
+ // Disk proves nothing, but an adoption on this branch settles by the tree.
416
+ if (pIn?.kind === 'pending' && pIn.effect === 'adopt' && pIn.branchId === branchId && !adopt) {
417
+ return { state: 'unverified', note: d.reason, p: settleAdoption(pIn, s) };
418
+ }
419
+ return { state: 'unverified', note: d.reason };
420
+ }
421
+ if (d.kind === 'unhashed') {
422
+ // Observed this way only when nothing tracks the path, which is then a
423
+ // disk-only file whatever else holds. Were anything to track it, its
424
+ // unhashed bytes could prove nothing about it.
425
+ if (pIn === undefined && s === null && bi === null) return { state: 'disk-only' };
426
+ return { state: 'unverified', note: 'not hashed' };
427
+ }
428
+ const dc = diskCandidate(d)!;
429
+ const sc: Candidate = s ? { kind: 'content', hash: s.hash } : ABSENT;
430
+
431
+ if (adopt) {
432
+ // What a path sync gives up is exactly what a sync without a path would
433
+ // have kept of the workspace's own (a draft, a deletion, a conflict's
434
+ // workspace side), and the verdict says which.
435
+ const taken = adoptDisk(d, s);
436
+ // A give-up is an adoption, or the intent it clears: a deletion or a
437
+ // conflict given up for a disk copy the workspace can't hold changes no
438
+ // tree entry, and is listed all the same.
439
+ if (taken.adopt || (bi && taken.intent !== undefined)) {
440
+ const kept = decide(d, s, pIn, bi, branchId, false);
441
+ if (WORKSPACE_KEPT.has(kept.state)) taken.discards = kept.state;
442
+ if (taken.adopt && kept.prior) taken.prior = kept.prior;
443
+ }
444
+ return taken;
445
+ }
446
+
447
+ // A pending intent resolves first; its resolution is recorded unless the
448
+ // verdict below records newer evidence.
449
+ let p = pIn;
450
+ let resolution: Agreed | Interrupted | 'forget' | undefined;
451
+ let uncaptured = false;
452
+ if (p?.kind === 'pending') {
453
+ const resolved = resolvePending(p, dc, s, d, branchId);
454
+ if (resolved.unconfirmed) {
455
+ // Disk shows our pending push's outcome (P as it intended): the state
456
+ // follows from that, and nothing is recorded — no completion, no
457
+ // agreement, no branch-intent change — until a push confirms it.
458
+ const state: EntryState = sameCandidate(dc, sc) ? 'synced' : bi?.conflict ? 'conflict' : keepStoreSide(s !== null, bi);
459
+ return { state, note: UNCONFIRMED_NOTE, unconfirmed: true };
460
+ }
461
+ p = resolved.p;
462
+ resolution = resolved.record;
463
+ uncaptured = resolved.uncaptured === true;
464
+ }
465
+ const verdict = (v: Verdict): Verdict => (resolution && v.p === undefined ? { ...v, p: resolution } : v);
466
+
467
+ // Agreement settles everything, a recorded conflict included.
468
+ if (sameCandidate(dc, sc)) {
469
+ const settles = bi?.tombstone || bi?.conflict || bi?.origin ? { intent: {} } : {};
470
+ // Another branch's pending adoption is still pending here (resolvePending
471
+ // leaves it): that branch resolves it, by its own tree. Recording this
472
+ // branch's agreement over it would lose the adoption there.
473
+ if (p?.kind === 'pending') return verdict({ state: 'synced', ...settles });
474
+ if (dc.kind === 'absent') {
475
+ return verdict({ state: 'synced', ...(p && p.kind !== 'absent' ? { p: { kind: 'absent' } as Agreed } : {}), ...settles });
476
+ }
477
+ const agreed = agreedFromDisk(d) as Extract<Agreed, { kind: 'content' }>;
478
+ const recorded = p?.kind === 'content' ? p : null;
479
+ // Re-record only when P says something else, or a fresh fingerprint can
480
+ // replace one that no longer vouches. A fresh one exists only where it's
481
+ // trusted, so both are judged as trusted here.
482
+ const stale = !recorded || recorded.hash !== agreed.hash || recorded.size !== agreed.size ||
483
+ (agreed.fp !== undefined && fingerprintVouches(agreed.fp, agreed.fp, true) &&
484
+ !fingerprintVouches(recorded.fp, agreed.fp, true));
485
+ return verdict({ state: 'synced', ...(stale ? { p: agreed } : {}), ...settles });
486
+ }
487
+
488
+ // A recorded conflict stays until it is resolved explicitly or disk and store converge.
489
+ if (bi?.conflict) return verdict({ state: 'conflict' });
490
+
491
+ if (p?.kind === 'interrupted' || uncaptured) return verdict({ state: 'conflict', conflict: 'interrupted' });
492
+
493
+ if (p?.kind === 'pending') {
494
+ // Unresolved from another branch, with disk still holding its prior.
495
+ return verdict({ state: keepStoreSide(s !== null, bi) });
496
+ }
497
+
498
+ if (p !== undefined) {
499
+ const pc = candidateOf(p);
500
+ if (sameCandidate(dc, pc)) return verdict({ state: keepStoreSide(s !== null, bi) });
501
+ if (sameCandidate(sc, pc)) {
502
+ // Only disk changed since it last agreed: adopt it. P is global, so
503
+ // S = P means the workspace holds what disk last agreed with, whichever
504
+ // branch that agreement came from, and a branch's intent doesn't
505
+ // outrank it. A known P of absent means disk did lose the file, which
506
+ // settles a tombstone here: a file disk shows now is a new one, and
507
+ // adopting it gives up nothing of the workspace's own. A tombstone
508
+ // guards a deletion only while P is unknown (below).
509
+ const settles = bi ? { intent: {} } : {};
510
+ // The adoption's intent carries this resolved evidence as its prior.
511
+ const prior = p.kind === 'absent' || p.kind === 'content' ? { prior: p } : {};
512
+ if (d.kind === 'absent') return { state: 'synced', adopt: 'remove', op: 'deleted', p: { kind: 'absent' }, ...prior, ...settles };
513
+ if (d.ingestible) {
514
+ return { state: 'synced', adopt: 'ingest', op: s ? 'modified' : 'created', p: agreedFromDisk(d), ...prior, ...settles };
515
+ }
516
+ return { state: 'disk-only', ...(s ? { adopt: 'drop' as const, op: 'deleted' as const } : {}), p: 'forget', ...settles };
517
+ }
518
+ const kind: ConflictKind = d.kind === 'absent'
519
+ ? 'deleted-on-disk'
520
+ : !s && bi?.tombstone ? 'changed-after-workspace-delete' : 'both-changed';
521
+ return verdict({ state: 'conflict', conflict: kind });
522
+ }
523
+
524
+ // No evidence: intent and provenance decide.
525
+ if (!s && bi?.tombstone) {
526
+ if (d.kind === 'file' && d.hash === bi.tombstone.hash) return { state: 'workspace-deleted' };
527
+ return { state: 'conflict', conflict: 'changed-after-workspace-delete' };
528
+ }
529
+ if (s) {
530
+ if (d.kind === 'absent') return { state: bi?.origin === 'store' ? 'workspace-draft' : 'disk-missing-provenance-unknown' };
531
+ return { state: 'conflict', conflict: bi?.origin === 'store' ? 'store-origin-collision' : 'unknown-provenance' };
532
+ }
533
+ // A new disk file.
534
+ if (d.kind === 'file' && d.ingestible) return { state: 'synced', adopt: 'ingest', op: 'created', p: agreedFromDisk(d) };
535
+ return { state: 'disk-only' };
536
+ }
537
+
538
+ function counterpartOf(store: JsStore, d: DiskFact): DiskCounterpart | null {
539
+ if (d.kind !== 'file') return null;
540
+ const stored = d.ingestible ? store.storeBlob(d.ingestible, 'text/plain') : undefined;
541
+ return { hash: d.hash, size: d.size, mtimeMs: d.mtimeMs, ...(stored ? { stored } : {}) };
542
+ }
543
+
544
+ function conflictReport(record: ConflictRecord, d: DiskFact): ConflictReport {
545
+ const current = diskCandidate(d);
546
+ const recorded: Candidate = record.disk ? { kind: 'content', hash: record.disk.hash } : ABSENT;
547
+ return {
548
+ kind: record.kind,
549
+ diskCopy: record.disk === null ? 'absent' : record.disk.stored ? 'stored' : 'referenced',
550
+ diskChangedSinceRecorded: current !== null && !sameCandidate(current, recorded),
551
+ };
552
+ }
553
+
554
+ /** A pass's view of its scope: candidates enumerated on one branch, and what disk showed for each. */
555
+ interface Gathered {
556
+ branchId: string;
557
+ /** The root the facts were gathered under, when it was available. */
558
+ rootIdentity: RootIdentity | null;
559
+ walk: Walk | null;
560
+ facts: Map<string, DiskFact>;
561
+ dirs: string[];
562
+ incomplete: Array<{ path: string; reason: string }>;
563
+ }
564
+
565
+ /** How often a pass gathers again when the branch changes under it. */
566
+ const GATHER_ATTEMPTS = 3;
567
+
568
+ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRuntime, scope: Scope, opts: PassOptions): Promise<Gathered> {
569
+ // A missing mount root is an unavailable mount (an unmounted drive, a root
570
+ // being replaced), not proof that every file in it was deleted, and so is a
571
+ // root that isn't the directory disk last agreed under: the empty
572
+ // directory a watcher makes where a drive's mountpoint was. The root is
573
+ // the operator's choice and may itself be a symlink to a directory, so it
574
+ // is followed, as `rootReal` is: `followSymlinks` governs links within it.
575
+ const root = await locateRoot(mount.view, mount.rootReal);
576
+ const rootUnavailable = 'reason' in root ? root.reason : null;
577
+
578
+ // The branch is labelled where the tracked candidates are read, with no
579
+ // await between: the decision is checked against this label.
580
+ const branchId = store.currentBranch().id;
581
+ const gathered: Gathered = {
582
+ branchId,
583
+ rootIdentity: 'identity' in root ? root.identity : null,
584
+ walk: null,
585
+ facts: new Map(),
586
+ dirs: [],
587
+ incomplete: [],
588
+ };
589
+ if (rootUnavailable !== null) {
590
+ // Said whether or not anything is tracked: an unavailable mount must not
591
+ // read as a verified empty directory.
592
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: rootUnavailable });
593
+ }
594
+
595
+ // Which paths to decide: what disk shows, and what the store, P and intent
596
+ // track. The tracked ones are read synchronously, on this branch.
597
+ const candidates = new Set<string>();
598
+ let walk: Walk | null = null;
599
+ if (scope.kind === 'paths') {
600
+ for (const p of scope.paths) candidates.add(p);
601
+ } else {
602
+ const prefix = scope.dir ? scope.dir + '/' : undefined;
603
+ for (const e of store.treeList(mount.treeStateId, prefix)) if (inScope(e.path, scope)) candidates.add(e.path);
604
+ for (const [p] of agreement.paths(mount.name, scope.dir)) if (inScope(p, scope)) candidates.add(p);
605
+ for (const [p] of mount.intents.list(scope.dir)) if (inScope(p, scope)) candidates.add(p);
606
+ if (rootUnavailable === null) {
607
+ walk = await walkScope(mount.view, mount.rootReal, scope.dir, scope.recursive, opts.cap ?? WALK_CAP);
608
+ for (const f of walk.files) if (inScope(f, scope)) candidates.add(f);
609
+ // Ignored names are listed only when something tracks them (above).
610
+ for (const [p, why] of walk.others) if (why !== 'ignored by the mount' && inScope(p, scope)) candidates.add(p);
611
+ for (const d of walk.dirs) if (inScope(d, { kind: 'dir', dir: scope.dir, recursive: false })) gathered.dirs.push(d);
612
+ }
613
+ }
614
+ gathered.walk = walk;
615
+
616
+ // Observe. Tracked facts are read again, synchronously, when deciding.
617
+ for (const path of candidates) {
618
+ if (rootUnavailable !== null) {
619
+ gathered.facts.set(path, { kind: 'unobserved', reason: rootUnavailable });
620
+ continue;
621
+ }
622
+ if (walk && provenAbsent(walk, path)) {
623
+ gathered.facts.set(path, { kind: 'absent' });
624
+ continue;
625
+ }
626
+ if (walk && !walk.files.has(path) && !walk.others.has(path)) {
627
+ // Beneath a region the walk didn't reach: unproven either way.
628
+ const region = walk.incomplete.find((r) => r.path === '' || path === r.path || path.startsWith(r.path + '/'));
629
+ gathered.facts.set(path, { kind: 'unobserved', reason: region ? region.reason : 'not reached by the walk' });
630
+ continue;
631
+ }
632
+ if (walk?.others.get(path) === 'ignored by the mount') {
633
+ gathered.facts.set(path, { kind: 'unobserved', reason: 'ignored by the mount' });
634
+ continue;
635
+ }
636
+ const p = agreement.get(mount.name, path);
637
+ const hasStore = store.treeGet(mount.treeStateId, path) !== null;
638
+ const hasIntent = mount.intents.get(path) !== null;
639
+ gathered.facts.set(path, await learnDisk(mount, path, { p, hasStore, hasIntent }, opts));
640
+ }
641
+
642
+ // Everything above was observed by path beneath the root's path. A root
643
+ // replaced meanwhile lends its replacement's files, and their absence, to
644
+ // the original's paths, so if the root isn't the one the pass began under
645
+ // now, nothing observed counts and nothing is decided from it.
646
+ if (gathered.rootIdentity !== null) {
647
+ const after = await locateRoot(mount.view, mount.rootReal);
648
+ if (!('identity' in after) || !sameRootIdentity(after.identity, gathered.rootIdentity)) {
649
+ const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
650
+ for (const path of gathered.facts.keys()) gathered.facts.set(path, { kind: 'unobserved', reason });
651
+ gathered.rootIdentity = null;
652
+ gathered.walk = null;
653
+ gathered.dirs = [];
654
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason });
655
+ }
656
+ }
657
+ return gathered;
658
+ }
659
+
660
+ /**
661
+ * One disk→store pass over a scope: the watcher's paths, a lazy read, a
662
+ * listing's directory, a full scan, or a path sync (opts.adopt).
663
+ */
664
+ export async function reconcilePass(
665
+ store: JsStore,
666
+ agreement: DiskAgreement,
667
+ mount: MountRuntime,
668
+ scope: Scope,
669
+ opts: PassOptions = {},
670
+ ): Promise<PassResult> {
671
+ agreement.ensureReconciled();
672
+ // Evidence a failed barrier left unsynced is settled before anything is
673
+ // decided from it; if the barrier fails again, so does the pass.
674
+ if (agreement.needsBarrier) agreement.barrier();
675
+ const result: PassResult = { ops: [], discarded: [], newConflicts: [], reports: new Map(), incomplete: [], dirs: [] };
676
+
677
+ // Enumerate on one branch and observe disk (async). The decision below must
678
+ // describe the same branch the candidates came from: a branch switch during
679
+ // observation would otherwise yield a "complete" listing that omits the new
680
+ // branch's drafts. So a changed branch gathers again; a branch that keeps
681
+ // changing leaves the pass explicitly incomplete, deciding nothing.
682
+ let gathered: Gathered | null = null;
683
+ for (let attempt = 0; attempt < GATHER_ATTEMPTS; attempt++) {
684
+ const next = await gather(store, agreement, mount, scope, opts);
685
+ if (store.currentBranch().id === next.branchId) {
686
+ gathered = next;
687
+ break;
688
+ }
689
+ }
690
+ if (gathered === null) {
691
+ result.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: 'the selected branch kept changing while the scan ran' });
692
+ return result;
693
+ }
694
+ if (opts.branchId !== undefined && gathered.branchId !== opts.branchId) {
695
+ result.incomplete.push({
696
+ path: scope.kind === 'dir' ? scope.dir : scope.paths.join(', '),
697
+ reason: 'the selected branch changed before this sync ran; nothing was taken from disk — sync again on this branch to take it',
698
+ });
699
+ return result;
700
+ }
701
+ const { walk, facts } = gathered;
702
+ result.dirs.push(...gathered.dirs);
703
+ result.incomplete.push(...gathered.incomplete);
704
+
705
+ // Decide and apply — synchronously, so nothing interleaves.
706
+ const branchId = store.currentBranch().id;
707
+ result.branchId = branchId;
708
+ type Planned = { path: string; verdict: Verdict; d: DiskFact; s: { hash: string; size: number } | null; p: Physical | undefined; bi: BranchIntent | null };
709
+ const planned: Planned[] = [];
710
+ for (const [path, d] of facts) {
711
+ const entry = store.treeGet(mount.treeStateId, path);
712
+ const s = entry ? { hash: entry.blobHash, size: entry.size } : null;
713
+ const p = agreement.get(mount.name, path);
714
+ const bi = mount.intents.get(path);
715
+ const verdict = decide(d, s, p, bi, branchId, opts.adopt?.(path) === true);
716
+ planned.push({ path, verdict, d, s, p, bi });
717
+ }
718
+
719
+ // The evidence about to be recorded describes the root it was gathered
720
+ // under, which is recorded first (once per mount), so no crash can leave
721
+ // evidence without its root.
722
+ if (gathered.rootIdentity && planned.some((x) => x.verdict.adopt !== undefined || x.verdict.p !== undefined)) {
723
+ agreement.recordRootIdentity(mount.name, gathered.rootIdentity);
724
+ }
725
+
726
+ // 1. Intents for adoptions, durable before any tree commit.
727
+ const adoptions = planned.filter((x) => x.verdict.adopt === 'ingest' || x.verdict.adopt === 'remove');
728
+ adoptions.forEach((x, i) => {
729
+ const prior = x.verdict.prior ?? (x.p && (x.p.kind === 'absent' || x.p.kind === 'content') ? x.p : null);
730
+ agreement.intend(mount.name, x.path, {
731
+ effect: 'adopt',
732
+ prior,
733
+ expect: x.verdict.adopt === 'remove' ? ABSENT : { kind: 'content', hash: (x.d as { hash: string }).hash },
734
+ branchId,
735
+ }, { durable: i === adoptions.length - 1 });
736
+ });
737
+
738
+ // 2. Forgetting P for a dropped entry is conservative, so it goes first.
739
+ for (const x of planned) if (x.verdict.adopt === 'drop') agreement.forget(mount.name, x.path);
740
+
741
+ // 3. Tree commits and branch intent.
742
+ let committed = false;
743
+ for (const x of planned) {
744
+ const v = x.verdict;
745
+ if (v.adopt === 'ingest' && x.d.kind === 'file' && x.d.ingestible) {
746
+ const blobHash = store.storeBlob(x.d.ingestible, 'text/plain');
747
+ store.treeSet(mount.treeStateId, x.path, { blobHash, size: x.d.ingestible.byteLength, mode: 0o644 });
748
+ committed = true;
749
+ } else if ((v.adopt === 'remove' || v.adopt === 'drop') && x.s) {
750
+ store.treeRemove(mount.treeStateId, x.path);
751
+ committed = true;
752
+ }
753
+ if (v.conflict) {
754
+ const record: ConflictRecord = { kind: v.conflict, at: Date.now(), disk: counterpartOf(store, x.d) };
755
+ mount.intents.update(x.path, (cur) => ({ ...cur, conflict: record }));
756
+ result.newConflicts.push({ path: x.path, op: x.d.kind === 'absent' ? 'deleted' : 'modified' });
757
+ committed = true;
758
+ } else if (v.intent !== undefined) {
759
+ mount.intents.put(x.path, v.intent);
760
+ committed = true;
761
+ }
762
+ if (v.op && v.adopt) result.ops.push({ path: x.path, op: v.op });
763
+ if (v.discards) result.discarded.push({ path: x.path, was: v.discards, ...(v.op && v.adopt ? { op: v.op } : {}) });
764
+ }
765
+
766
+ // 4. Completions and agreements, after the commits they assert are synced —
767
+ // this pass's, and any earlier tool write's an agreement rests on.
768
+ let first = true;
769
+ let recorded = false;
770
+ for (const x of planned) {
771
+ const next = x.verdict.p;
772
+ if (x.verdict.adopt === 'drop') recorded = true; // its forget, in step 2
773
+ if (next === undefined) continue;
774
+ recorded = true;
775
+ if (next === 'forget') {
776
+ agreement.forget(mount.name, x.path); // no-op when step 2 already did
777
+ continue;
778
+ }
779
+ agreement.set(mount.name, x.path, next, { afterCommittedState: first });
780
+ first = false;
781
+ }
782
+ // 5. Durable before returning. An agreement advance (D = S) has no pending
783
+ // intent to recover from, so losing its append would leave an older P that
784
+ // another branch would misread as a disk edit.
785
+ if (recorded || committed || agreement.needsBarrier) agreement.barrier();
786
+ agreement.maybeCheckpoint();
787
+
788
+ // Reports.
789
+ for (const x of planned) {
790
+ const after = mount.intents.get(x.path);
791
+ const entry = store.treeGet(mount.treeStateId, x.path);
792
+ const report: PathReport = { path: x.path, state: x.verdict.state };
793
+ if (entry) report.size = entry.size;
794
+ else if (x.d.kind === 'file' || x.d.kind === 'unhashed') report.size = x.d.size;
795
+ if (x.verdict.state === 'disk-only' && (x.d.kind === 'file' || x.d.kind === 'unhashed') && x.d.mimeType) report.mimeType = x.d.mimeType;
796
+ if (x.verdict.state === 'conflict' && after?.conflict) {
797
+ report.conflict = conflictReport(after.conflict, x.d);
798
+ if (x.d.kind === 'file' || x.d.kind === 'absent') report.diskNow = x.d.kind;
799
+ }
800
+ if (x.verdict.note) report.note = x.verdict.note;
801
+ if (!(x.verdict.state === 'synced' && !entry)) result.reports.set(x.path, report);
802
+ }
803
+ if (walk) {
804
+ const tracked = [...result.reports.values()].filter((r) => r.state === 'unverified').map((r) => r.path);
805
+ for (const region of walk.incomplete) {
806
+ const hides = tracked.some((p) => p === region.path || p.startsWith(region.path + '/'));
807
+ if (region.kind === 'error' || region.kind === 'cap' || hides) result.incomplete.push({ path: region.path, reason: region.reason });
808
+ }
809
+ }
810
+ return result;
811
+ }
812
+
813
+ // ===========================================================================
814
+ // Store → disk
815
+ // ===========================================================================
816
+
817
+ export interface PushOptions {
818
+ /**
819
+ * Overwrite (or unlink) a disk copy in conflict, or one that can't be
820
+ * verified. A disk copy nothing has evidence about is overwritten only when
821
+ * `named` too: a bare push can't tell it from a newer copy.
822
+ */
823
+ force?: boolean;
824
+ /**
825
+ * The caller named these paths (a path given to materialize), rather than
826
+ * the evidence selecting them. Only such a push writes a path nothing has
827
+ * evidence about: over a differing disk copy with `force`, or where disk
828
+ * has no copy at all.
829
+ */
830
+ named?: boolean;
831
+ /** Unlink workspace-deleted files whose disk copy is one the store holds. */
832
+ applyDeletions?: boolean;
833
+ /** Called before each disk write/unlink, so the caller can suppress the watcher echo. */
834
+ beforeEffect?: (path: string) => void;
835
+ /**
836
+ * The branch the caller chose `paths` on. If another is selected by the
837
+ * time the push decides, it writes nothing: a selection made on one branch
838
+ * pushed with another branch's content would be neither branch's materialize.
839
+ */
840
+ branchId?: string;
841
+ }
842
+
843
+ export interface PushResult {
844
+ written: string[];
845
+ unchanged: string[];
846
+ deleted: string[];
847
+ /** Refused paths, each with its reason. */
848
+ skipped: Array<{ path: string; reason: string }>;
849
+ /** Workspace deletions left on disk because applyDeletions wasn't given. */
850
+ pendingDeletions: string[];
851
+ /** The branch and sequence the push planned on: what disk now reflects.
852
+ * Absent when it planned nothing (a read-only mount, or the selected
853
+ * branch changed after its paths were chosen): disk then still reflects
854
+ * whatever it did before. */
855
+ branchId?: string;
856
+ sequence?: number;
857
+ }
858
+
859
+ /**
860
+ * Make disk hold the store's version of each path, under the same rule:
861
+ * drafts are written, agreement is left alone, conflicts and unverifiable
862
+ * disk copies are refused unless `force`, and a workspace deletion reaches
863
+ * disk only with `applyDeletions` (with `force` too when disk changed since).
864
+ * A path nothing has evidence about is written only when `named`.
865
+ * A path whose earlier push shows on disk unconfirmed is pushed again, so its
866
+ * completion rests on barriers that succeeded.
867
+ *
868
+ * Every write or unlink is announced by a durable intent first, and checked
869
+ * at the point of effect (effects.ts): the boundary always, and — unless
870
+ * `force` — that disk still holds what was decided on. Then every directory
871
+ * whose entries the push created or removed is synced, from each written
872
+ * file's parent up to the mount root and each unlinked file's parent, once
873
+ * per push and after all of its effects, and only then are completions
874
+ * recorded. An effect that failed without touching its path puts the path's
875
+ * evidence back exactly as it was before the intent; one that touched it, or
876
+ * whose barrier failed, leaves the intent pending.
877
+ */
878
+ export async function pushPaths(
879
+ store: JsStore,
880
+ agreement: DiskAgreement,
881
+ mount: MountRuntime,
882
+ paths: string[],
883
+ opts: PushOptions = {},
884
+ ): Promise<PushResult> {
885
+ agreement.ensureReconciled();
886
+ if (agreement.needsBarrier) agreement.barrier(); // as a pass does: nothing decided from unsynced evidence
887
+ const result: PushResult = { written: [], unchanged: [], deleted: [], skipped: [], pendingDeletions: [] };
888
+ if (mount.readOnly) return result;
889
+ // A root that isn't available takes no writes, forced or not: restoring the
890
+ // store's files into an empty mountpoint would fill the disk the drive was
891
+ // unmounted from. The push plans nothing, so it reports no branch.
892
+ const root = await locateRoot(mount.view, mount.rootReal);
893
+ if ('reason' in root) {
894
+ for (const path of paths) result.skipped.push({ path, reason: root.reason });
895
+ return result;
896
+ }
897
+
898
+ type Plan = {
899
+ path: string;
900
+ /**
901
+ * The path's evidence just before this push's intent, after what planning
902
+ * itself recorded (an agreement, a resolved intent), put back if the
903
+ * effect never touches the path.
904
+ */
905
+ before: Physical | undefined;
906
+ prior: Agreed | null;
907
+ /** What the effect may find at the path. */
908
+ expect: Expect;
909
+ } & ({ kind: 'write'; blob: Buffer; hash: string } | { kind: 'unlink' });
910
+ const plans: Plan[] = [];
911
+
912
+ // Observe disk.
913
+ const facts = new Map<string, DiskFact>();
914
+ for (const path of paths) {
915
+ const p = agreement.get(mount.name, path);
916
+ facts.set(path, await learnDisk(mount, path, { p, hasStore: true, hasIntent: true }, {}));
917
+ }
918
+ // Each path was observed by path beneath the root's path. Under a root
919
+ // replaced meanwhile they describe the replacement, so, as a pass does,
920
+ // nothing is decided or recorded from them.
921
+ const after = await locateRoot(mount.view, mount.rootReal);
922
+ if (!('identity' in after) || !sameRootIdentity(after.identity, root.identity)) {
923
+ const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
924
+ for (const path of paths) result.skipped.push({ path, reason });
925
+ return result;
926
+ }
927
+
928
+ // Decide synchronously what to push.
929
+ const branchId = store.currentBranch().id;
930
+ if (opts.branchId !== undefined && opts.branchId !== branchId) {
931
+ for (const path of paths) {
932
+ result.skipped.push({ path, reason: 'the selected branch changed after these paths were chosen; nothing was written — materialize again' });
933
+ }
934
+ return result;
935
+ }
936
+ result.branchId = branchId;
937
+ result.sequence = store.currentSequence();
938
+ let recorded = false;
939
+ for (const [path, d] of facts) {
940
+ const entry = store.treeGet(mount.treeStateId, path);
941
+ const s = entry ? { hash: entry.blobHash, size: entry.size } : null;
942
+ const p = agreement.get(mount.name, path);
943
+ const bi = mount.intents.get(path);
944
+ const v = decide(d, s, p, bi, branchId, false);
945
+ // What the effect may find: what this observation saw, unless forced.
946
+ const expect: Expect = opts.force || d.kind === 'unobserved' || d.kind === 'unhashed'
947
+ ? { kind: 'any' }
948
+ : d.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: d.hash };
949
+ if (v.adopt) {
950
+ // Disk changed since it last agreed and the workspace copy didn't. Pushing
951
+ // would silently revert the disk change; its evidence stays as it is, so
952
+ // a later sync adopts it.
953
+ if (!s) continue; // nothing in the workspace to push
954
+ if (!opts.force) {
955
+ result.skipped.push({
956
+ path,
957
+ reason: 'stale copy: disk changed since it last agreed with the workspace, which did not — ' +
958
+ 'sync this path to adopt the disk version, or pass force to overwrite it',
959
+ });
960
+ continue;
961
+ }
962
+ const priorP = p && (p.kind === 'absent' || p.kind === 'content') ? p : null;
963
+ const blob = store.getBlob(s.hash);
964
+ if (blob) plans.push({ path, kind: 'write', blob, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect });
965
+ else result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
966
+ continue;
967
+ }
968
+ // Whatever the verdict learned about disk (an agreement, a resolved
969
+ // intent) is recorded before anything is pushed over it, and the root it
970
+ // was learned under before that (once per mount), as a pass does.
971
+ if (v.p !== undefined) agreement.recordRootIdentity(mount.name, root.identity);
972
+ if (v.p === 'forget') agreement.forget(mount.name, path);
973
+ else if (v.p !== undefined) agreement.set(mount.name, path, v.p, { afterCommittedState: true });
974
+ if (v.p !== undefined) recorded = true;
975
+ // The prior a new intent carries is the last confirmed evidence: for a
976
+ // path whose earlier push is unconfirmed, that push's own prior, never
977
+ // the bytes it wrote.
978
+ const known = v.unconfirmed ? (p as Pending).prior ?? undefined : v.p === 'forget' ? undefined : v.p !== undefined ? v.p : p;
979
+ const prior = known && (known.kind === 'absent' || known.kind === 'content') ? known : null;
980
+ if (v.state === 'synced' && !v.unconfirmed) {
981
+ if (v.intent !== undefined) {
982
+ mount.intents.put(path, v.intent);
983
+ recorded = true;
984
+ }
985
+ if (s) result.unchanged.push(path);
986
+ continue;
987
+ }
988
+ if (v.state === 'unverified') {
989
+ if (!opts.force) {
990
+ result.skipped.push({ path, reason: `cannot verify disk copy (${v.note ?? 'not observed'}) — fix it and materialize again, or pass force to overwrite it` });
991
+ continue;
992
+ }
993
+ }
994
+ // A file merely absent from this branch is left alone, an unconfirmed
995
+ // push of another branch's bytes included: nothing replays or unlinks it.
996
+ if (v.state === 'not-in-branch' || v.state === 'disk-only') continue;
997
+ const kind = bi?.conflict?.kind ?? v.conflict ?? 'both-changed';
998
+ // Without evidence there was no agreement for disk to change since, and
999
+ // nothing tells a newer disk copy from an older one: only a push of the
1000
+ // named path overwrites it, forced.
1001
+ const noEvidence = kind === 'unknown-provenance' || kind === 'store-origin-collision';
1002
+ if (v.state === 'conflict' && (!opts.force || (noEvidence && !opts.named))) {
1003
+ if (v.conflict) {
1004
+ mount.intents.update(path, (cur) => ({ ...cur, conflict: { kind: v.conflict!, at: Date.now(), disk: counterpartOf(store, d) } }));
1005
+ recorded = true;
1006
+ }
1007
+ result.skipped.push({
1008
+ path,
1009
+ reason: noEvidence
1010
+ ? `disk holds a different copy, and nothing records which is newer (conflict: ${kind}) — ` +
1011
+ 'sync this path to adopt the disk version, or materialize this path with force to overwrite it'
1012
+ : kind === 'interrupted'
1013
+ ? 'disk holds bytes that match neither what was being written there nor what disk last agreed with ' +
1014
+ '(conflict: interrupted), as a write that failed or was cut off partway leaves — check the file, then ' +
1015
+ 'pass force to write the workspace copy over it, or sync this path to adopt the disk copy as it is'
1016
+ : `stale copy: disk changed since it last agreed with the workspace (conflict: ${kind}) — ` +
1017
+ 'sync this path to adopt the disk version, or pass force to overwrite it',
1018
+ });
1019
+ continue;
1020
+ }
1021
+ // An entry from before the evidence that disk lacks: nothing records
1022
+ // whether disk deleted it or never had it, and most such entries came
1023
+ // from disk. Writing it back could undo a deliberate deletion, so only a
1024
+ // push of the named path writes it.
1025
+ if (v.state === 'disk-missing-provenance-unknown' && !opts.named) {
1026
+ result.skipped.push({
1027
+ path,
1028
+ reason: 'disk has no copy, and nothing records whether it was deleted there or never written — ' +
1029
+ 'sync this path to drop it from the workspace, or materialize this path to write it',
1030
+ });
1031
+ continue;
1032
+ }
1033
+ if (!s) {
1034
+ // Only a deliberate workspace deletion ever unlinks; a file that is
1035
+ // merely absent from this branch is never deleted from disk.
1036
+ if (!bi?.tombstone) continue;
1037
+ if (v.unconfirmed && d.kind === 'absent') {
1038
+ // Our own unlink already reached disk, unconfirmed: confirm it — its
1039
+ // directory synced, nothing removed — applyDeletions or not. Only
1040
+ // ever a confirmation, force or not: a file that appears meanwhile
1041
+ // refuses it.
1042
+ plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect: { kind: 'absent' } });
1043
+ continue;
1044
+ }
1045
+ if (!opts.applyDeletions) {
1046
+ result.pendingDeletions.push(path);
1047
+ continue;
1048
+ }
1049
+ if (d.kind === 'absent') continue;
1050
+ plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect });
1051
+ continue;
1052
+ }
1053
+ const blob = store.getBlob(s.hash);
1054
+ if (!blob) {
1055
+ result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
1056
+ continue;
1057
+ }
1058
+ plans.push({ path, kind: 'write', blob, hash: s.hash, prior, before: agreement.get(mount.name, path), expect });
1059
+ }
1060
+
1061
+ // 1. Durable intents before any disk effect, the root they write under first.
1062
+ if (plans.length > 0) agreement.recordRootIdentity(mount.name, root.identity);
1063
+ plans.forEach((step, i) => {
1064
+ agreement.intend(mount.name, step.path, {
1065
+ effect: 'disk',
1066
+ prior: step.prior,
1067
+ expect: step.kind === 'write' ? { kind: 'content', hash: step.hash } : ABSENT,
1068
+ branchId,
1069
+ }, { durable: i === plans.length - 1 });
1070
+ });
1071
+
1072
+ // 2. Effects, each bound at its point of use.
1073
+ const effected: Array<{ step: Plan; dirs: string[] }> = [];
1074
+ for (const step of plans) {
1075
+ opts.beforeEffect?.(step.path);
1076
+ try {
1077
+ if (step.kind === 'write') {
1078
+ await writeContained(mount.view, mount.rootReal, step.path, step.blob, step.expect);
1079
+ effected.push({ step, dirs: chainOf(step.path) });
1080
+ } else {
1081
+ // Its directory is synced whether this unlink removed the entry or
1082
+ // found it gone: either way the completion claims a durable absence.
1083
+ effected.push({ step, dirs: [await unlinkContained(mount.view, mount.rootReal, step.path, step.expect)] });
1084
+ }
1085
+ } catch (err) {
1086
+ const failure = err instanceof EffectFailed ? err : new EffectFailed(err instanceof Error ? err.message : String(err), true);
1087
+ if (!failure.touched) {
1088
+ // Disk at this path is as planning saw it: its evidence goes back to
1089
+ // exactly what it was, whatever kind that was.
1090
+ agreement.restore(mount.name, step.path, step.before);
1091
+ recorded = true;
1092
+ result.skipped.push({ path: step.path, reason: `not ${step.kind === 'write' ? 'written' : 'unlinked'}: ${failure.message}` });
1093
+ } else {
1094
+ // No completion: the intent stays pending, and the next observation
1095
+ // resolves it from whatever disk then holds.
1096
+ result.skipped.push({ path: step.path, reason: `${failure.message}; the outcome is checked at the next observation` });
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // 3. Barriers: every directory whose entries the push changed, synced
1102
+ // once, after all of its effects. A directory created by an earlier,
1103
+ // failed attempt is synced too: the whole chain, whoever made it.
1104
+ const unsynced = await syncDirectories(mount.view, mount.rootReal, effected.flatMap((x) => x.dirs));
1105
+
1106
+ // 4. Completions. P is physical and global. Branch intent belongs to the
1107
+ // branch this push planned on: it is settled only while that branch is
1108
+ // still selected, and otherwise by convergence when the branch returns.
1109
+ for (const { step, dirs } of effected) {
1110
+ const failed = dirs.find((dir) => unsynced.has(dir));
1111
+ if (failed !== undefined) {
1112
+ result.skipped.push({
1113
+ path: step.path,
1114
+ reason: `could not make the ${step.kind === 'write' ? 'write' : 'unlink'} durable (directory fsync of ${failed === '' ? 'the mount root' : failed}): ` +
1115
+ `${unsynced.get(failed)}; the outcome is checked at the next observation`,
1116
+ });
1117
+ continue;
1118
+ }
1119
+ const value: Agreed = step.kind === 'write' ? { kind: 'content', hash: step.hash, size: step.blob.byteLength } : { kind: 'absent' };
1120
+ agreement.set(mount.name, step.path, value);
1121
+ recorded = true;
1122
+ const planned = store.currentBranch().id === branchId;
1123
+ if (step.kind === 'write') {
1124
+ if (planned) mount.intents.update(step.path, (cur) => { const next = { ...cur }; delete next.conflict; delete next.origin; return next; });
1125
+ result.written.push(step.path);
1126
+ } else {
1127
+ if (planned) mount.intents.put(step.path, null);
1128
+ result.deleted.push(step.path);
1129
+ }
1130
+ }
1131
+ // Durable before returning, like a pass: agreements recorded here have no
1132
+ // pending intent behind them, and completions shouldn't wait for one.
1133
+ if (recorded || agreement.needsBarrier) agreement.barrier();
1134
+ agreement.maybeCheckpoint();
1135
+ return result;
1136
+ }