@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,901 @@
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
+ import { candidateOf, sameCandidate, sameRootIdentity, } from './disk-agreement.js';
41
+ import { ROOT_REPLACED, fingerprintVouches, locateRoot, looksBinary, observePath, provenAbsent, readHead, readPath, sniffImageMime, walkScope, } from './observe.js';
42
+ import { EffectFailed, chainOf, syncDirectories, unlinkContained, writeContained } from './effects.js';
43
+ const ABSENT = { kind: 'absent' };
44
+ export const WALK_CAP = 5000;
45
+ function diskCandidate(d) {
46
+ if (d.kind === 'absent')
47
+ return ABSENT;
48
+ if (d.kind === 'file')
49
+ return { kind: 'content', hash: d.hash };
50
+ return null;
51
+ }
52
+ function agreedFromDisk(d) {
53
+ if (d.kind !== 'file')
54
+ return { kind: 'absent' };
55
+ return { kind: 'content', hash: d.hash, size: d.size, ...(d.fp ? { fp: d.fp } : {}) };
56
+ }
57
+ function inScope(path, scope) {
58
+ if (scope.kind === 'paths')
59
+ return scope.paths.includes(path);
60
+ const dir = scope.dir;
61
+ if (dir === '')
62
+ return scope.recursive || !path.includes('/');
63
+ if (!path.startsWith(dir + '/'))
64
+ return false;
65
+ return scope.recursive || !path.slice(dir.length + 1).includes('/');
66
+ }
67
+ /**
68
+ * Learn D for a path. Content is read only when a decision needs it; a
69
+ * recorded fingerprint can vouch for unchanged bytes where the filesystem
70
+ * maintains ctime.
71
+ */
72
+ async function learnDisk(mount, path, tracked, opts) {
73
+ const seen = await observePath(mount.view, mount.rootReal, path);
74
+ if (seen.kind !== 'file')
75
+ return seen;
76
+ const p = tracked.p;
77
+ const trusted = (fp) => mount.ctimeTrustedDev !== null && fp.dev === mount.ctimeTrustedDev;
78
+ if (!opts.rehash && p?.kind === 'content' && fingerprintVouches(p.fp, seen.fp, trusted(seen.fp))) {
79
+ return { kind: 'file', hash: p.hash, size: seen.size, mtimeMs: seen.mtimeMs, fp: p.fp ?? null, ingestible: null };
80
+ }
81
+ const nothingTracked = p === undefined && !tracked.hasStore && !tracked.hasIntent;
82
+ if (nothingTracked && seen.size > mount.view.maxFileSize) {
83
+ // A new oversize file only needs listing: its size, and its head for the
84
+ // MIME type. Hashing it would read all of it, on every listing.
85
+ const head = await readHead(mount.view, mount.rootReal, path);
86
+ if (head.kind !== 'head')
87
+ return head;
88
+ return { kind: 'unhashed', size: head.size, mtimeMs: head.mtimeMs, mimeType: sniffImageMime(head.head) };
89
+ }
90
+ const read = await readPath(mount.view, mount.rootReal, path, mount.view.maxFileSize);
91
+ if (read.kind !== 'read')
92
+ return read;
93
+ const ingestible = read.bytes !== null && !looksBinary(read.head) ? read.bytes : null;
94
+ return {
95
+ kind: 'file',
96
+ hash: read.hash,
97
+ size: read.size,
98
+ mtimeMs: read.mtimeMs,
99
+ // A fingerprint is recorded only where it can later vouch for the bytes.
100
+ fp: trusted(read.fp) ? read.fp : null,
101
+ ingestible,
102
+ ...(ingestible ? {} : { mimeType: sniffImageMime(read.head) }),
103
+ };
104
+ }
105
+ /** States in which the rule keeps a change of the workspace's own over disk. */
106
+ const WORKSPACE_KEPT = new Set([
107
+ 'workspace-draft',
108
+ 'workspace-deleted',
109
+ 'conflict',
110
+ 'disk-missing-provenance-unknown',
111
+ ]);
112
+ const UNCONFIRMED_NOTE = 'disk holds what a materialize that has not completed put here; materialize again to complete it';
113
+ function keepStoreSide(hasStore, bi) {
114
+ if (hasStore)
115
+ return 'workspace-draft';
116
+ return bi?.tombstone ? 'workspace-deleted' : 'not-in-branch';
117
+ }
118
+ /**
119
+ * Settle an adoption intent on the branch it was recorded on, by the tree
120
+ * alone: S at the adopted content means the commit landed and only its
121
+ * completion was lost; anything else means it didn't, and the rule runs again
122
+ * from the prior evidence (redoing the adoption if disk still holds it).
123
+ *
124
+ * Content is proof only while nothing else has written the path since, so
125
+ * every workspace mutation settles a pending intent first (the module's
126
+ * mutation boundary): a later write of the same content can't pass for the
127
+ * old adoption.
128
+ */
129
+ function settleAdoption(p, s) {
130
+ const sc = s ? { kind: 'content', hash: s.hash } : ABSENT;
131
+ if (sameCandidate(sc, p.expect)) {
132
+ return p.expect.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: p.expect.hash, size: s.size };
133
+ }
134
+ return p.prior ?? 'forget';
135
+ }
136
+ /**
137
+ * The synchronous precondition of every workspace mutation: evidence owed a
138
+ * barrier is made durable first, and an adoption left pending on the
139
+ * selected branch is settled by the tree before the mutation changes it, and
140
+ * durably, so the settlement can't be lost while the new write survives.
141
+ * Afterwards the write can never pass for the adoption's outcome. Other
142
+ * pending intents never consult the tree, so a write can't confuse them.
143
+ * Holds whether or not an earlier observation could settle it.
144
+ */
145
+ export function settleAdoptionBeforeMutation(store, agreement, mount, path) {
146
+ // Evidence still owed a barrier (from a failed one, or read back at open)
147
+ // is made durable before any new tree mutation is permitted at all.
148
+ if (agreement.needsBarrier)
149
+ agreement.barrier();
150
+ const p = agreement.get(mount.name, path);
151
+ if (p?.kind !== 'pending' || p.effect !== 'adopt' || p.branchId !== store.currentBranch().id)
152
+ return;
153
+ const entry = store.treeGet(mount.treeStateId, path);
154
+ const settled = settleAdoption(p, entry ? { hash: entry.blobHash, size: entry.size } : null);
155
+ if (settled === 'forget')
156
+ agreement.forget(mount.name, path);
157
+ else
158
+ agreement.set(mount.name, path, settled, { afterCommittedState: true });
159
+ agreement.barrier();
160
+ }
161
+ /**
162
+ * Resolve a pending intent against what was observed. Returns the evidence
163
+ * the rule should use, and whether that resolution should be recorded.
164
+ */
165
+ function resolvePending(p, dc, s, d, branchId) {
166
+ const prior = p.prior;
167
+ const priorCandidate = prior ? candidateOf(prior) : null;
168
+ if (p.effect === 'disk') {
169
+ if (sameCandidate(dc, p.expect)) {
170
+ // Our own effect shows on disk, but nothing proves it durable: decided
171
+ // on, never recorded. Only a push whose barriers succeed completes it.
172
+ const shown = p.expect.kind === 'absent' ? { kind: 'absent' } : agreedFromDisk(d);
173
+ return { p: shown, unconfirmed: true };
174
+ }
175
+ if (priorCandidate && sameCandidate(dc, priorCandidate))
176
+ return { p: prior, record: prior };
177
+ // A write whose prior was unknown left no file: it never created one (a
178
+ // partial write leaves a file behind), so P is as unknown as before.
179
+ if (!prior && dc.kind === 'absent' && p.expect.kind === 'content')
180
+ return { p: undefined, record: 'forget' };
181
+ const interrupted = { kind: 'interrupted', candidates: [...(priorCandidate ? [priorCandidate] : []), p.expect] };
182
+ return { p: interrupted, record: interrupted };
183
+ }
184
+ // An adoption: its meaning depends on the branch it was recorded on.
185
+ if (p.branchId === branchId) {
186
+ const landed = sameCandidate(s ? { kind: 'content', hash: s.hash } : ABSENT, p.expect);
187
+ // Landed, with disk at the adopted content too: its fingerprint joins the evidence.
188
+ if (landed && p.expect.kind === 'content' && sameCandidate(dc, p.expect)) {
189
+ const agreed = agreedFromDisk(d);
190
+ return { p: agreed, record: agreed };
191
+ }
192
+ const settled = settleAdoption(p, s);
193
+ return settled === 'forget' ? { p: undefined, record: 'forget' } : { p: settled, record: settled };
194
+ }
195
+ // Another branch, whose S says nothing of whether the adoption happened
196
+ // over there. Disk still holding the prior is D = P: the store has that
197
+ // content. Disk holding the adopted content, or anything newer, may hold
198
+ // the only copy of it, so this branch lists it as a conflict and nothing
199
+ // pushes over it; P stays pending for the intent's own branch to resolve.
200
+ if (priorCandidate && sameCandidate(dc, priorCandidate))
201
+ return { p };
202
+ return { p, uncaptured: true };
203
+ }
204
+ /**
205
+ * An explicit path sync: the store takes disk's state, whatever the evidence
206
+ * — a pending push's included. That chooses disk; it doesn't certify the push.
207
+ */
208
+ function adoptDisk(d, s) {
209
+ if (d.kind === 'absent') {
210
+ return { state: 'synced', ...(s ? { adopt: 'remove', op: 'deleted' } : {}), p: { kind: 'absent' }, intent: {} };
211
+ }
212
+ // Disk agreeing with the store is disk's state already, whatever the file
213
+ // is: a binary or oversize file the workspace holds stays held.
214
+ if (s && s.hash === d.hash)
215
+ return { state: 'synced', p: agreedFromDisk(d), intent: {} };
216
+ if (d.ingestible)
217
+ return { state: 'synced', adopt: 'ingest', op: s ? 'modified' : 'created', p: agreedFromDisk(d), intent: {} };
218
+ return { state: 'disk-only', ...(s ? { adopt: 'drop', op: 'deleted' } : {}), p: 'forget', intent: {} };
219
+ }
220
+ /** The rule (see module doc). Pure. */
221
+ function decide(d, s, pIn, bi, branchId, adopt) {
222
+ // A branch intent that can't be read may have held a tombstone or a
223
+ // conflict: nothing is decided over it, until a path sync replaces it.
224
+ if (bi?.unreadable !== undefined && !adopt)
225
+ return { state: 'unverified', note: `its branch intent can't be read: ${bi.unreadable}` };
226
+ if (d.kind === 'unobserved') {
227
+ // Disk proves nothing, but an adoption on this branch settles by the tree.
228
+ if (pIn?.kind === 'pending' && pIn.effect === 'adopt' && pIn.branchId === branchId && !adopt) {
229
+ return { state: 'unverified', note: d.reason, p: settleAdoption(pIn, s) };
230
+ }
231
+ return { state: 'unverified', note: d.reason };
232
+ }
233
+ if (d.kind === 'unhashed') {
234
+ // Observed this way only when nothing tracks the path, which is then a
235
+ // disk-only file whatever else holds. Were anything to track it, its
236
+ // unhashed bytes could prove nothing about it.
237
+ if (pIn === undefined && s === null && bi === null)
238
+ return { state: 'disk-only' };
239
+ return { state: 'unverified', note: 'not hashed' };
240
+ }
241
+ const dc = diskCandidate(d);
242
+ const sc = s ? { kind: 'content', hash: s.hash } : ABSENT;
243
+ if (adopt) {
244
+ // What a path sync gives up is exactly what a sync without a path would
245
+ // have kept of the workspace's own (a draft, a deletion, a conflict's
246
+ // workspace side), and the verdict says which.
247
+ const taken = adoptDisk(d, s);
248
+ // A give-up is an adoption, or the intent it clears: a deletion or a
249
+ // conflict given up for a disk copy the workspace can't hold changes no
250
+ // tree entry, and is listed all the same.
251
+ if (taken.adopt || (bi && taken.intent !== undefined)) {
252
+ const kept = decide(d, s, pIn, bi, branchId, false);
253
+ if (WORKSPACE_KEPT.has(kept.state))
254
+ taken.discards = kept.state;
255
+ if (taken.adopt && kept.prior)
256
+ taken.prior = kept.prior;
257
+ }
258
+ return taken;
259
+ }
260
+ // A pending intent resolves first; its resolution is recorded unless the
261
+ // verdict below records newer evidence.
262
+ let p = pIn;
263
+ let resolution;
264
+ let uncaptured = false;
265
+ if (p?.kind === 'pending') {
266
+ const resolved = resolvePending(p, dc, s, d, branchId);
267
+ if (resolved.unconfirmed) {
268
+ // Disk shows our pending push's outcome (P as it intended): the state
269
+ // follows from that, and nothing is recorded — no completion, no
270
+ // agreement, no branch-intent change — until a push confirms it.
271
+ const state = sameCandidate(dc, sc) ? 'synced' : bi?.conflict ? 'conflict' : keepStoreSide(s !== null, bi);
272
+ return { state, note: UNCONFIRMED_NOTE, unconfirmed: true };
273
+ }
274
+ p = resolved.p;
275
+ resolution = resolved.record;
276
+ uncaptured = resolved.uncaptured === true;
277
+ }
278
+ const verdict = (v) => (resolution && v.p === undefined ? { ...v, p: resolution } : v);
279
+ // Agreement settles everything, a recorded conflict included.
280
+ if (sameCandidate(dc, sc)) {
281
+ const settles = bi?.tombstone || bi?.conflict || bi?.origin ? { intent: {} } : {};
282
+ // Another branch's pending adoption is still pending here (resolvePending
283
+ // leaves it): that branch resolves it, by its own tree. Recording this
284
+ // branch's agreement over it would lose the adoption there.
285
+ if (p?.kind === 'pending')
286
+ return verdict({ state: 'synced', ...settles });
287
+ if (dc.kind === 'absent') {
288
+ return verdict({ state: 'synced', ...(p && p.kind !== 'absent' ? { p: { kind: 'absent' } } : {}), ...settles });
289
+ }
290
+ const agreed = agreedFromDisk(d);
291
+ const recorded = p?.kind === 'content' ? p : null;
292
+ // Re-record only when P says something else, or a fresh fingerprint can
293
+ // replace one that no longer vouches. A fresh one exists only where it's
294
+ // trusted, so both are judged as trusted here.
295
+ const stale = !recorded || recorded.hash !== agreed.hash || recorded.size !== agreed.size ||
296
+ (agreed.fp !== undefined && fingerprintVouches(agreed.fp, agreed.fp, true) &&
297
+ !fingerprintVouches(recorded.fp, agreed.fp, true));
298
+ return verdict({ state: 'synced', ...(stale ? { p: agreed } : {}), ...settles });
299
+ }
300
+ // A recorded conflict stays until it is resolved explicitly or disk and store converge.
301
+ if (bi?.conflict)
302
+ return verdict({ state: 'conflict' });
303
+ if (p?.kind === 'interrupted' || uncaptured)
304
+ return verdict({ state: 'conflict', conflict: 'interrupted' });
305
+ if (p?.kind === 'pending') {
306
+ // Unresolved from another branch, with disk still holding its prior.
307
+ return verdict({ state: keepStoreSide(s !== null, bi) });
308
+ }
309
+ if (p !== undefined) {
310
+ const pc = candidateOf(p);
311
+ if (sameCandidate(dc, pc))
312
+ return verdict({ state: keepStoreSide(s !== null, bi) });
313
+ if (sameCandidate(sc, pc)) {
314
+ // Only disk changed since it last agreed: adopt it. P is global, so
315
+ // S = P means the workspace holds what disk last agreed with, whichever
316
+ // branch that agreement came from, and a branch's intent doesn't
317
+ // outrank it. A known P of absent means disk did lose the file, which
318
+ // settles a tombstone here: a file disk shows now is a new one, and
319
+ // adopting it gives up nothing of the workspace's own. A tombstone
320
+ // guards a deletion only while P is unknown (below).
321
+ const settles = bi ? { intent: {} } : {};
322
+ // The adoption's intent carries this resolved evidence as its prior.
323
+ const prior = p.kind === 'absent' || p.kind === 'content' ? { prior: p } : {};
324
+ if (d.kind === 'absent')
325
+ return { state: 'synced', adopt: 'remove', op: 'deleted', p: { kind: 'absent' }, ...prior, ...settles };
326
+ if (d.ingestible) {
327
+ return { state: 'synced', adopt: 'ingest', op: s ? 'modified' : 'created', p: agreedFromDisk(d), ...prior, ...settles };
328
+ }
329
+ return { state: 'disk-only', ...(s ? { adopt: 'drop', op: 'deleted' } : {}), p: 'forget', ...settles };
330
+ }
331
+ const kind = d.kind === 'absent'
332
+ ? 'deleted-on-disk'
333
+ : !s && bi?.tombstone ? 'changed-after-workspace-delete' : 'both-changed';
334
+ return verdict({ state: 'conflict', conflict: kind });
335
+ }
336
+ // No evidence: intent and provenance decide.
337
+ if (!s && bi?.tombstone) {
338
+ if (d.kind === 'file' && d.hash === bi.tombstone.hash)
339
+ return { state: 'workspace-deleted' };
340
+ return { state: 'conflict', conflict: 'changed-after-workspace-delete' };
341
+ }
342
+ if (s) {
343
+ if (d.kind === 'absent')
344
+ return { state: bi?.origin === 'store' ? 'workspace-draft' : 'disk-missing-provenance-unknown' };
345
+ return { state: 'conflict', conflict: bi?.origin === 'store' ? 'store-origin-collision' : 'unknown-provenance' };
346
+ }
347
+ // A new disk file.
348
+ if (d.kind === 'file' && d.ingestible)
349
+ return { state: 'synced', adopt: 'ingest', op: 'created', p: agreedFromDisk(d) };
350
+ return { state: 'disk-only' };
351
+ }
352
+ function counterpartOf(store, d) {
353
+ if (d.kind !== 'file')
354
+ return null;
355
+ const stored = d.ingestible ? store.storeBlob(d.ingestible, 'text/plain') : undefined;
356
+ return { hash: d.hash, size: d.size, mtimeMs: d.mtimeMs, ...(stored ? { stored } : {}) };
357
+ }
358
+ function conflictReport(record, d) {
359
+ const current = diskCandidate(d);
360
+ const recorded = record.disk ? { kind: 'content', hash: record.disk.hash } : ABSENT;
361
+ return {
362
+ kind: record.kind,
363
+ diskCopy: record.disk === null ? 'absent' : record.disk.stored ? 'stored' : 'referenced',
364
+ diskChangedSinceRecorded: current !== null && !sameCandidate(current, recorded),
365
+ };
366
+ }
367
+ /** How often a pass gathers again when the branch changes under it. */
368
+ const GATHER_ATTEMPTS = 3;
369
+ async function gather(store, agreement, mount, scope, opts) {
370
+ // A missing mount root is an unavailable mount (an unmounted drive, a root
371
+ // being replaced), not proof that every file in it was deleted, and so is a
372
+ // root that isn't the directory disk last agreed under: the empty
373
+ // directory a watcher makes where a drive's mountpoint was. The root is
374
+ // the operator's choice and may itself be a symlink to a directory, so it
375
+ // is followed, as `rootReal` is: `followSymlinks` governs links within it.
376
+ const root = await locateRoot(mount.view, mount.rootReal);
377
+ const rootUnavailable = 'reason' in root ? root.reason : null;
378
+ // The branch is labelled where the tracked candidates are read, with no
379
+ // await between: the decision is checked against this label.
380
+ const branchId = store.currentBranch().id;
381
+ const gathered = {
382
+ branchId,
383
+ rootIdentity: 'identity' in root ? root.identity : null,
384
+ walk: null,
385
+ facts: new Map(),
386
+ dirs: [],
387
+ incomplete: [],
388
+ };
389
+ if (rootUnavailable !== null) {
390
+ // Said whether or not anything is tracked: an unavailable mount must not
391
+ // read as a verified empty directory.
392
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: rootUnavailable });
393
+ }
394
+ // Which paths to decide: what disk shows, and what the store, P and intent
395
+ // track. The tracked ones are read synchronously, on this branch.
396
+ const candidates = new Set();
397
+ let walk = null;
398
+ if (scope.kind === 'paths') {
399
+ for (const p of scope.paths)
400
+ candidates.add(p);
401
+ }
402
+ else {
403
+ const prefix = scope.dir ? scope.dir + '/' : undefined;
404
+ for (const e of store.treeList(mount.treeStateId, prefix))
405
+ if (inScope(e.path, scope))
406
+ candidates.add(e.path);
407
+ for (const [p] of agreement.paths(mount.name, scope.dir))
408
+ if (inScope(p, scope))
409
+ candidates.add(p);
410
+ for (const [p] of mount.intents.list(scope.dir))
411
+ if (inScope(p, scope))
412
+ candidates.add(p);
413
+ if (rootUnavailable === null) {
414
+ walk = await walkScope(mount.view, mount.rootReal, scope.dir, scope.recursive, opts.cap ?? WALK_CAP);
415
+ for (const f of walk.files)
416
+ if (inScope(f, scope))
417
+ candidates.add(f);
418
+ // Ignored names are listed only when something tracks them (above).
419
+ for (const [p, why] of walk.others)
420
+ if (why !== 'ignored by the mount' && inScope(p, scope))
421
+ candidates.add(p);
422
+ for (const d of walk.dirs)
423
+ if (inScope(d, { kind: 'dir', dir: scope.dir, recursive: false }))
424
+ gathered.dirs.push(d);
425
+ }
426
+ }
427
+ gathered.walk = walk;
428
+ // Observe. Tracked facts are read again, synchronously, when deciding.
429
+ for (const path of candidates) {
430
+ if (rootUnavailable !== null) {
431
+ gathered.facts.set(path, { kind: 'unobserved', reason: rootUnavailable });
432
+ continue;
433
+ }
434
+ if (walk && provenAbsent(walk, path)) {
435
+ gathered.facts.set(path, { kind: 'absent' });
436
+ continue;
437
+ }
438
+ if (walk && !walk.files.has(path) && !walk.others.has(path)) {
439
+ // Beneath a region the walk didn't reach: unproven either way.
440
+ const region = walk.incomplete.find((r) => r.path === '' || path === r.path || path.startsWith(r.path + '/'));
441
+ gathered.facts.set(path, { kind: 'unobserved', reason: region ? region.reason : 'not reached by the walk' });
442
+ continue;
443
+ }
444
+ if (walk?.others.get(path) === 'ignored by the mount') {
445
+ gathered.facts.set(path, { kind: 'unobserved', reason: 'ignored by the mount' });
446
+ continue;
447
+ }
448
+ const p = agreement.get(mount.name, path);
449
+ const hasStore = store.treeGet(mount.treeStateId, path) !== null;
450
+ const hasIntent = mount.intents.get(path) !== null;
451
+ gathered.facts.set(path, await learnDisk(mount, path, { p, hasStore, hasIntent }, opts));
452
+ }
453
+ // Everything above was observed by path beneath the root's path. A root
454
+ // replaced meanwhile lends its replacement's files, and their absence, to
455
+ // the original's paths, so if the root isn't the one the pass began under
456
+ // now, nothing observed counts and nothing is decided from it.
457
+ if (gathered.rootIdentity !== null) {
458
+ const after = await locateRoot(mount.view, mount.rootReal);
459
+ if (!('identity' in after) || !sameRootIdentity(after.identity, gathered.rootIdentity)) {
460
+ const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
461
+ for (const path of gathered.facts.keys())
462
+ gathered.facts.set(path, { kind: 'unobserved', reason });
463
+ gathered.rootIdentity = null;
464
+ gathered.walk = null;
465
+ gathered.dirs = [];
466
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason });
467
+ }
468
+ }
469
+ return gathered;
470
+ }
471
+ /**
472
+ * One disk→store pass over a scope: the watcher's paths, a lazy read, a
473
+ * listing's directory, a full scan, or a path sync (opts.adopt).
474
+ */
475
+ export async function reconcilePass(store, agreement, mount, scope, opts = {}) {
476
+ agreement.ensureReconciled();
477
+ // Evidence a failed barrier left unsynced is settled before anything is
478
+ // decided from it; if the barrier fails again, so does the pass.
479
+ if (agreement.needsBarrier)
480
+ agreement.barrier();
481
+ const result = { ops: [], discarded: [], newConflicts: [], reports: new Map(), incomplete: [], dirs: [] };
482
+ // Enumerate on one branch and observe disk (async). The decision below must
483
+ // describe the same branch the candidates came from: a branch switch during
484
+ // observation would otherwise yield a "complete" listing that omits the new
485
+ // branch's drafts. So a changed branch gathers again; a branch that keeps
486
+ // changing leaves the pass explicitly incomplete, deciding nothing.
487
+ let gathered = null;
488
+ for (let attempt = 0; attempt < GATHER_ATTEMPTS; attempt++) {
489
+ const next = await gather(store, agreement, mount, scope, opts);
490
+ if (store.currentBranch().id === next.branchId) {
491
+ gathered = next;
492
+ break;
493
+ }
494
+ }
495
+ if (gathered === null) {
496
+ result.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: 'the selected branch kept changing while the scan ran' });
497
+ return result;
498
+ }
499
+ if (opts.branchId !== undefined && gathered.branchId !== opts.branchId) {
500
+ result.incomplete.push({
501
+ path: scope.kind === 'dir' ? scope.dir : scope.paths.join(', '),
502
+ reason: 'the selected branch changed before this sync ran; nothing was taken from disk — sync again on this branch to take it',
503
+ });
504
+ return result;
505
+ }
506
+ const { walk, facts } = gathered;
507
+ result.dirs.push(...gathered.dirs);
508
+ result.incomplete.push(...gathered.incomplete);
509
+ // Decide and apply — synchronously, so nothing interleaves.
510
+ const branchId = store.currentBranch().id;
511
+ result.branchId = branchId;
512
+ const planned = [];
513
+ for (const [path, d] of facts) {
514
+ const entry = store.treeGet(mount.treeStateId, path);
515
+ const s = entry ? { hash: entry.blobHash, size: entry.size } : null;
516
+ const p = agreement.get(mount.name, path);
517
+ const bi = mount.intents.get(path);
518
+ const verdict = decide(d, s, p, bi, branchId, opts.adopt?.(path) === true);
519
+ planned.push({ path, verdict, d, s, p, bi });
520
+ }
521
+ // The evidence about to be recorded describes the root it was gathered
522
+ // under, which is recorded first (once per mount), so no crash can leave
523
+ // evidence without its root.
524
+ if (gathered.rootIdentity && planned.some((x) => x.verdict.adopt !== undefined || x.verdict.p !== undefined)) {
525
+ agreement.recordRootIdentity(mount.name, gathered.rootIdentity);
526
+ }
527
+ // 1. Intents for adoptions, durable before any tree commit.
528
+ const adoptions = planned.filter((x) => x.verdict.adopt === 'ingest' || x.verdict.adopt === 'remove');
529
+ adoptions.forEach((x, i) => {
530
+ const prior = x.verdict.prior ?? (x.p && (x.p.kind === 'absent' || x.p.kind === 'content') ? x.p : null);
531
+ agreement.intend(mount.name, x.path, {
532
+ effect: 'adopt',
533
+ prior,
534
+ expect: x.verdict.adopt === 'remove' ? ABSENT : { kind: 'content', hash: x.d.hash },
535
+ branchId,
536
+ }, { durable: i === adoptions.length - 1 });
537
+ });
538
+ // 2. Forgetting P for a dropped entry is conservative, so it goes first.
539
+ for (const x of planned)
540
+ if (x.verdict.adopt === 'drop')
541
+ agreement.forget(mount.name, x.path);
542
+ // 3. Tree commits and branch intent.
543
+ let committed = false;
544
+ for (const x of planned) {
545
+ const v = x.verdict;
546
+ if (v.adopt === 'ingest' && x.d.kind === 'file' && x.d.ingestible) {
547
+ const blobHash = store.storeBlob(x.d.ingestible, 'text/plain');
548
+ store.treeSet(mount.treeStateId, x.path, { blobHash, size: x.d.ingestible.byteLength, mode: 0o644 });
549
+ committed = true;
550
+ }
551
+ else if ((v.adopt === 'remove' || v.adopt === 'drop') && x.s) {
552
+ store.treeRemove(mount.treeStateId, x.path);
553
+ committed = true;
554
+ }
555
+ if (v.conflict) {
556
+ const record = { kind: v.conflict, at: Date.now(), disk: counterpartOf(store, x.d) };
557
+ mount.intents.update(x.path, (cur) => ({ ...cur, conflict: record }));
558
+ result.newConflicts.push({ path: x.path, op: x.d.kind === 'absent' ? 'deleted' : 'modified' });
559
+ committed = true;
560
+ }
561
+ else if (v.intent !== undefined) {
562
+ mount.intents.put(x.path, v.intent);
563
+ committed = true;
564
+ }
565
+ if (v.op && v.adopt)
566
+ result.ops.push({ path: x.path, op: v.op });
567
+ if (v.discards)
568
+ result.discarded.push({ path: x.path, was: v.discards, ...(v.op && v.adopt ? { op: v.op } : {}) });
569
+ }
570
+ // 4. Completions and agreements, after the commits they assert are synced —
571
+ // this pass's, and any earlier tool write's an agreement rests on.
572
+ let first = true;
573
+ let recorded = false;
574
+ for (const x of planned) {
575
+ const next = x.verdict.p;
576
+ if (x.verdict.adopt === 'drop')
577
+ recorded = true; // its forget, in step 2
578
+ if (next === undefined)
579
+ continue;
580
+ recorded = true;
581
+ if (next === 'forget') {
582
+ agreement.forget(mount.name, x.path); // no-op when step 2 already did
583
+ continue;
584
+ }
585
+ agreement.set(mount.name, x.path, next, { afterCommittedState: first });
586
+ first = false;
587
+ }
588
+ // 5. Durable before returning. An agreement advance (D = S) has no pending
589
+ // intent to recover from, so losing its append would leave an older P that
590
+ // another branch would misread as a disk edit.
591
+ if (recorded || committed || agreement.needsBarrier)
592
+ agreement.barrier();
593
+ agreement.maybeCheckpoint();
594
+ // Reports.
595
+ for (const x of planned) {
596
+ const after = mount.intents.get(x.path);
597
+ const entry = store.treeGet(mount.treeStateId, x.path);
598
+ const report = { path: x.path, state: x.verdict.state };
599
+ if (entry)
600
+ report.size = entry.size;
601
+ else if (x.d.kind === 'file' || x.d.kind === 'unhashed')
602
+ report.size = x.d.size;
603
+ if (x.verdict.state === 'disk-only' && (x.d.kind === 'file' || x.d.kind === 'unhashed') && x.d.mimeType)
604
+ report.mimeType = x.d.mimeType;
605
+ if (x.verdict.state === 'conflict' && after?.conflict) {
606
+ report.conflict = conflictReport(after.conflict, x.d);
607
+ if (x.d.kind === 'file' || x.d.kind === 'absent')
608
+ report.diskNow = x.d.kind;
609
+ }
610
+ if (x.verdict.note)
611
+ report.note = x.verdict.note;
612
+ if (!(x.verdict.state === 'synced' && !entry))
613
+ result.reports.set(x.path, report);
614
+ }
615
+ if (walk) {
616
+ const tracked = [...result.reports.values()].filter((r) => r.state === 'unverified').map((r) => r.path);
617
+ for (const region of walk.incomplete) {
618
+ const hides = tracked.some((p) => p === region.path || p.startsWith(region.path + '/'));
619
+ if (region.kind === 'error' || region.kind === 'cap' || hides)
620
+ result.incomplete.push({ path: region.path, reason: region.reason });
621
+ }
622
+ }
623
+ return result;
624
+ }
625
+ /**
626
+ * Make disk hold the store's version of each path, under the same rule:
627
+ * drafts are written, agreement is left alone, conflicts and unverifiable
628
+ * disk copies are refused unless `force`, and a workspace deletion reaches
629
+ * disk only with `applyDeletions` (with `force` too when disk changed since).
630
+ * A path nothing has evidence about is written only when `named`.
631
+ * A path whose earlier push shows on disk unconfirmed is pushed again, so its
632
+ * completion rests on barriers that succeeded.
633
+ *
634
+ * Every write or unlink is announced by a durable intent first, and checked
635
+ * at the point of effect (effects.ts): the boundary always, and — unless
636
+ * `force` — that disk still holds what was decided on. Then every directory
637
+ * whose entries the push created or removed is synced, from each written
638
+ * file's parent up to the mount root and each unlinked file's parent, once
639
+ * per push and after all of its effects, and only then are completions
640
+ * recorded. An effect that failed without touching its path puts the path's
641
+ * evidence back exactly as it was before the intent; one that touched it, or
642
+ * whose barrier failed, leaves the intent pending.
643
+ */
644
+ export async function pushPaths(store, agreement, mount, paths, opts = {}) {
645
+ agreement.ensureReconciled();
646
+ if (agreement.needsBarrier)
647
+ agreement.barrier(); // as a pass does: nothing decided from unsynced evidence
648
+ const result = { written: [], unchanged: [], deleted: [], skipped: [], pendingDeletions: [] };
649
+ if (mount.readOnly)
650
+ return result;
651
+ // A root that isn't available takes no writes, forced or not: restoring the
652
+ // store's files into an empty mountpoint would fill the disk the drive was
653
+ // unmounted from. The push plans nothing, so it reports no branch.
654
+ const root = await locateRoot(mount.view, mount.rootReal);
655
+ if ('reason' in root) {
656
+ for (const path of paths)
657
+ result.skipped.push({ path, reason: root.reason });
658
+ return result;
659
+ }
660
+ const plans = [];
661
+ // Observe disk.
662
+ const facts = new Map();
663
+ for (const path of paths) {
664
+ const p = agreement.get(mount.name, path);
665
+ facts.set(path, await learnDisk(mount, path, { p, hasStore: true, hasIntent: true }, {}));
666
+ }
667
+ // Each path was observed by path beneath the root's path. Under a root
668
+ // replaced meanwhile they describe the replacement, so, as a pass does,
669
+ // nothing is decided or recorded from them.
670
+ const after = await locateRoot(mount.view, mount.rootReal);
671
+ if (!('identity' in after) || !sameRootIdentity(after.identity, root.identity)) {
672
+ const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
673
+ for (const path of paths)
674
+ result.skipped.push({ path, reason });
675
+ return result;
676
+ }
677
+ // Decide synchronously what to push.
678
+ const branchId = store.currentBranch().id;
679
+ if (opts.branchId !== undefined && opts.branchId !== branchId) {
680
+ for (const path of paths) {
681
+ result.skipped.push({ path, reason: 'the selected branch changed after these paths were chosen; nothing was written — materialize again' });
682
+ }
683
+ return result;
684
+ }
685
+ result.branchId = branchId;
686
+ result.sequence = store.currentSequence();
687
+ let recorded = false;
688
+ for (const [path, d] of facts) {
689
+ const entry = store.treeGet(mount.treeStateId, path);
690
+ const s = entry ? { hash: entry.blobHash, size: entry.size } : null;
691
+ const p = agreement.get(mount.name, path);
692
+ const bi = mount.intents.get(path);
693
+ const v = decide(d, s, p, bi, branchId, false);
694
+ // What the effect may find: what this observation saw, unless forced.
695
+ const expect = opts.force || d.kind === 'unobserved' || d.kind === 'unhashed'
696
+ ? { kind: 'any' }
697
+ : d.kind === 'absent' ? { kind: 'absent' } : { kind: 'content', hash: d.hash };
698
+ if (v.adopt) {
699
+ // Disk changed since it last agreed and the workspace copy didn't. Pushing
700
+ // would silently revert the disk change; its evidence stays as it is, so
701
+ // a later sync adopts it.
702
+ if (!s)
703
+ continue; // nothing in the workspace to push
704
+ if (!opts.force) {
705
+ result.skipped.push({
706
+ path,
707
+ reason: 'stale copy: disk changed since it last agreed with the workspace, which did not — ' +
708
+ 'sync this path to adopt the disk version, or pass force to overwrite it',
709
+ });
710
+ continue;
711
+ }
712
+ const priorP = p && (p.kind === 'absent' || p.kind === 'content') ? p : null;
713
+ const blob = store.getBlob(s.hash);
714
+ if (blob)
715
+ plans.push({ path, kind: 'write', blob, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect });
716
+ else
717
+ result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
718
+ continue;
719
+ }
720
+ // Whatever the verdict learned about disk (an agreement, a resolved
721
+ // intent) is recorded before anything is pushed over it, and the root it
722
+ // was learned under before that (once per mount), as a pass does.
723
+ if (v.p !== undefined)
724
+ agreement.recordRootIdentity(mount.name, root.identity);
725
+ if (v.p === 'forget')
726
+ agreement.forget(mount.name, path);
727
+ else if (v.p !== undefined)
728
+ agreement.set(mount.name, path, v.p, { afterCommittedState: true });
729
+ if (v.p !== undefined)
730
+ recorded = true;
731
+ // The prior a new intent carries is the last confirmed evidence: for a
732
+ // path whose earlier push is unconfirmed, that push's own prior, never
733
+ // the bytes it wrote.
734
+ const known = v.unconfirmed ? p.prior ?? undefined : v.p === 'forget' ? undefined : v.p !== undefined ? v.p : p;
735
+ const prior = known && (known.kind === 'absent' || known.kind === 'content') ? known : null;
736
+ if (v.state === 'synced' && !v.unconfirmed) {
737
+ if (v.intent !== undefined) {
738
+ mount.intents.put(path, v.intent);
739
+ recorded = true;
740
+ }
741
+ if (s)
742
+ result.unchanged.push(path);
743
+ continue;
744
+ }
745
+ if (v.state === 'unverified') {
746
+ if (!opts.force) {
747
+ result.skipped.push({ path, reason: `cannot verify disk copy (${v.note ?? 'not observed'}) — fix it and materialize again, or pass force to overwrite it` });
748
+ continue;
749
+ }
750
+ }
751
+ // A file merely absent from this branch is left alone, an unconfirmed
752
+ // push of another branch's bytes included: nothing replays or unlinks it.
753
+ if (v.state === 'not-in-branch' || v.state === 'disk-only')
754
+ continue;
755
+ const kind = bi?.conflict?.kind ?? v.conflict ?? 'both-changed';
756
+ // Without evidence there was no agreement for disk to change since, and
757
+ // nothing tells a newer disk copy from an older one: only a push of the
758
+ // named path overwrites it, forced.
759
+ const noEvidence = kind === 'unknown-provenance' || kind === 'store-origin-collision';
760
+ if (v.state === 'conflict' && (!opts.force || (noEvidence && !opts.named))) {
761
+ if (v.conflict) {
762
+ mount.intents.update(path, (cur) => ({ ...cur, conflict: { kind: v.conflict, at: Date.now(), disk: counterpartOf(store, d) } }));
763
+ recorded = true;
764
+ }
765
+ result.skipped.push({
766
+ path,
767
+ reason: noEvidence
768
+ ? `disk holds a different copy, and nothing records which is newer (conflict: ${kind}) — ` +
769
+ 'sync this path to adopt the disk version, or materialize this path with force to overwrite it'
770
+ : kind === 'interrupted'
771
+ ? 'disk holds bytes that match neither what was being written there nor what disk last agreed with ' +
772
+ '(conflict: interrupted), as a write that failed or was cut off partway leaves — check the file, then ' +
773
+ 'pass force to write the workspace copy over it, or sync this path to adopt the disk copy as it is'
774
+ : `stale copy: disk changed since it last agreed with the workspace (conflict: ${kind}) — ` +
775
+ 'sync this path to adopt the disk version, or pass force to overwrite it',
776
+ });
777
+ continue;
778
+ }
779
+ // An entry from before the evidence that disk lacks: nothing records
780
+ // whether disk deleted it or never had it, and most such entries came
781
+ // from disk. Writing it back could undo a deliberate deletion, so only a
782
+ // push of the named path writes it.
783
+ if (v.state === 'disk-missing-provenance-unknown' && !opts.named) {
784
+ result.skipped.push({
785
+ path,
786
+ reason: 'disk has no copy, and nothing records whether it was deleted there or never written — ' +
787
+ 'sync this path to drop it from the workspace, or materialize this path to write it',
788
+ });
789
+ continue;
790
+ }
791
+ if (!s) {
792
+ // Only a deliberate workspace deletion ever unlinks; a file that is
793
+ // merely absent from this branch is never deleted from disk.
794
+ if (!bi?.tombstone)
795
+ continue;
796
+ if (v.unconfirmed && d.kind === 'absent') {
797
+ // Our own unlink already reached disk, unconfirmed: confirm it — its
798
+ // directory synced, nothing removed — applyDeletions or not. Only
799
+ // ever a confirmation, force or not: a file that appears meanwhile
800
+ // refuses it.
801
+ plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect: { kind: 'absent' } });
802
+ continue;
803
+ }
804
+ if (!opts.applyDeletions) {
805
+ result.pendingDeletions.push(path);
806
+ continue;
807
+ }
808
+ if (d.kind === 'absent')
809
+ continue;
810
+ plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect });
811
+ continue;
812
+ }
813
+ const blob = store.getBlob(s.hash);
814
+ if (!blob) {
815
+ result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
816
+ continue;
817
+ }
818
+ plans.push({ path, kind: 'write', blob, hash: s.hash, prior, before: agreement.get(mount.name, path), expect });
819
+ }
820
+ // 1. Durable intents before any disk effect, the root they write under first.
821
+ if (plans.length > 0)
822
+ agreement.recordRootIdentity(mount.name, root.identity);
823
+ plans.forEach((step, i) => {
824
+ agreement.intend(mount.name, step.path, {
825
+ effect: 'disk',
826
+ prior: step.prior,
827
+ expect: step.kind === 'write' ? { kind: 'content', hash: step.hash } : ABSENT,
828
+ branchId,
829
+ }, { durable: i === plans.length - 1 });
830
+ });
831
+ // 2. Effects, each bound at its point of use.
832
+ const effected = [];
833
+ for (const step of plans) {
834
+ opts.beforeEffect?.(step.path);
835
+ try {
836
+ if (step.kind === 'write') {
837
+ await writeContained(mount.view, mount.rootReal, step.path, step.blob, step.expect);
838
+ effected.push({ step, dirs: chainOf(step.path) });
839
+ }
840
+ else {
841
+ // Its directory is synced whether this unlink removed the entry or
842
+ // found it gone: either way the completion claims a durable absence.
843
+ effected.push({ step, dirs: [await unlinkContained(mount.view, mount.rootReal, step.path, step.expect)] });
844
+ }
845
+ }
846
+ catch (err) {
847
+ const failure = err instanceof EffectFailed ? err : new EffectFailed(err instanceof Error ? err.message : String(err), true);
848
+ if (!failure.touched) {
849
+ // Disk at this path is as planning saw it: its evidence goes back to
850
+ // exactly what it was, whatever kind that was.
851
+ agreement.restore(mount.name, step.path, step.before);
852
+ recorded = true;
853
+ result.skipped.push({ path: step.path, reason: `not ${step.kind === 'write' ? 'written' : 'unlinked'}: ${failure.message}` });
854
+ }
855
+ else {
856
+ // No completion: the intent stays pending, and the next observation
857
+ // resolves it from whatever disk then holds.
858
+ result.skipped.push({ path: step.path, reason: `${failure.message}; the outcome is checked at the next observation` });
859
+ }
860
+ }
861
+ }
862
+ // 3. Barriers: every directory whose entries the push changed, synced
863
+ // once, after all of its effects. A directory created by an earlier,
864
+ // failed attempt is synced too: the whole chain, whoever made it.
865
+ const unsynced = await syncDirectories(mount.view, mount.rootReal, effected.flatMap((x) => x.dirs));
866
+ // 4. Completions. P is physical and global. Branch intent belongs to the
867
+ // branch this push planned on: it is settled only while that branch is
868
+ // still selected, and otherwise by convergence when the branch returns.
869
+ for (const { step, dirs } of effected) {
870
+ const failed = dirs.find((dir) => unsynced.has(dir));
871
+ if (failed !== undefined) {
872
+ result.skipped.push({
873
+ path: step.path,
874
+ reason: `could not make the ${step.kind === 'write' ? 'write' : 'unlink'} durable (directory fsync of ${failed === '' ? 'the mount root' : failed}): ` +
875
+ `${unsynced.get(failed)}; the outcome is checked at the next observation`,
876
+ });
877
+ continue;
878
+ }
879
+ const value = step.kind === 'write' ? { kind: 'content', hash: step.hash, size: step.blob.byteLength } : { kind: 'absent' };
880
+ agreement.set(mount.name, step.path, value);
881
+ recorded = true;
882
+ const planned = store.currentBranch().id === branchId;
883
+ if (step.kind === 'write') {
884
+ if (planned)
885
+ mount.intents.update(step.path, (cur) => { const next = { ...cur }; delete next.conflict; delete next.origin; return next; });
886
+ result.written.push(step.path);
887
+ }
888
+ else {
889
+ if (planned)
890
+ mount.intents.put(step.path, null);
891
+ result.deleted.push(step.path);
892
+ }
893
+ }
894
+ // Durable before returning, like a pass: agreements recorded here have no
895
+ // pending intent behind them, and completions shouldn't wait for one.
896
+ if (recorded || agreement.needsBarrier)
897
+ agreement.barrier();
898
+ agreement.maybeCheckpoint();
899
+ return result;
900
+ }
901
+ //# sourceMappingURL=reconcile.js.map