@animalabs/agent-framework 0.21.0 → 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 +3 -3
  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,3079 @@
1
+ /**
2
+ * Truthful workspace listings under the three-way rule (shelf-383): disk (D),
3
+ * the workspace store (S) and physical disk-agreement evidence (P), with
4
+ * branch-local intent (tombstones, store origin, sticky conflicts).
5
+ *
6
+ * Each describe block covers one entry of the shelf's "done when" list. Faults
7
+ * are real: a child process (fixtures/workspace-fault.mjs) runs an operation
8
+ * and dies by SIGKILL between intent, effect and completion, and a torn tail
9
+ * is a truncated records.log.
10
+ */
11
+ import { describe, test } from 'node:test';
12
+ import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../src/module-registry.js';
13
+ import assert from 'node:assert/strict';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { chmodSync, constants as fsConstants, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
16
+ import { open, statfs } from 'node:fs/promises';
17
+ import { createHash } from 'node:crypto';
18
+ import { createRequire, syncBuiltinESMExports } from 'node:module';
19
+ import { tmpdir } from 'node:os';
20
+ import { basename, dirname, join } from 'node:path';
21
+ import { JsStore } from '@animalabs/chronicle';
22
+ import { WorkspaceModule } from '../src/modules/workspace/index.js';
23
+ import { DiskAgreement } from '../src/modules/workspace/disk-agreement.js';
24
+ import { confirmAbsent, fingerprintVouches, filesystemTrustsCtime, fsTypeTrustsCtime, observePath, readPath } from '../src/modules/workspace/observe.js';
25
+ import { EffectFailed, syncDirectories, unlinkContained, writeContained } from '../src/modules/workspace/effects.js';
26
+ import { pushPaths, settleAdoptionBeforeMutation } from '../src/modules/workspace/reconcile.js';
27
+ const FAULT_FIXTURE = join(import.meta.dirname, 'fixtures/workspace-fault.mjs');
28
+ const TREE = 'workspace/work/tree';
29
+ const ONE_PX_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
30
+ const IS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
31
+ class Env {
32
+ root = mkdtempSync(join(tmpdir(), 'af-ws-3way-'));
33
+ dir = join(this.root, 'work');
34
+ storePath = join(this.root, 'ws.chronicle');
35
+ store;
36
+ events = [];
37
+ live = [];
38
+ constructor(t) {
39
+ mkdirSync(this.dir, { recursive: true });
40
+ this.store = JsStore.openOrCreate({ path: this.storePath });
41
+ t.after(async () => {
42
+ for (const m of this.live)
43
+ await m.stop().catch(() => { });
44
+ if (!this.store.isClosed())
45
+ this.store.close();
46
+ rmSync(this.root, { recursive: true, force: true });
47
+ });
48
+ }
49
+ async open(mount = {}, config = {}) {
50
+ const m = new WorkspaceModule({
51
+ mounts: [{ name: 'work', path: this.dir, mode: 'read-write', watch: 'never', ...mount }],
52
+ ...config,
53
+ });
54
+ m.initStore(this.store);
55
+ await m.start({
56
+ isRestart: false,
57
+ getState: () => null,
58
+ setState: () => { },
59
+ pushEvent: (e) => this.events.push(e),
60
+ });
61
+ this.live.push(m);
62
+ return m;
63
+ }
64
+ /** Start a module as a restart that finds `state` saved as its module state. */
65
+ async openRestarted(state, mount = {}) {
66
+ const m = new WorkspaceModule({ mounts: [{ name: 'work', path: this.dir, mode: 'read-write', watch: 'never', ...mount }] });
67
+ m.initStore(this.store);
68
+ await m.start({
69
+ isRestart: true,
70
+ getState: () => state,
71
+ setState: () => { },
72
+ pushEvent: (e) => this.events.push(e),
73
+ });
74
+ this.live.push(m);
75
+ return m;
76
+ }
77
+ /** Stop a module and close the store, as a process exit would. */
78
+ async shutdown(m) {
79
+ await m.stop();
80
+ this.live = this.live.filter((x) => x !== m);
81
+ this.store.close();
82
+ }
83
+ /** A fresh process: reopen the store and start a new module on it. */
84
+ async restart(m, mount = {}) {
85
+ await this.shutdown(m);
86
+ this.store = JsStore.openOrCreate({ path: this.storePath });
87
+ return this.open(mount);
88
+ }
89
+ /** Run one operation in a child that dies by SIGKILL at `killAt`, then reopen. */
90
+ async fault(m, op, killAt) {
91
+ await this.shutdown(m);
92
+ const child = spawnSync(process.execPath, [FAULT_FIXTURE, this.storePath, this.dir, op, killAt], { encoding: 'utf-8' });
93
+ assert.equal(child.signal, 'SIGKILL', `the child died at ${killAt} (status ${child.status}, stderr: ${child.stderr})`);
94
+ this.store = JsStore.openOrCreate({ path: this.storePath });
95
+ return this.open();
96
+ }
97
+ disk(path) {
98
+ return join(this.dir, path);
99
+ }
100
+ readDisk(path) {
101
+ return readFileSync(this.disk(path), 'utf8');
102
+ }
103
+ writeDisk(path, content) {
104
+ mkdirSync(dirname(this.disk(path)), { recursive: true });
105
+ writeFileSync(this.disk(path), content);
106
+ }
107
+ rmDisk(path) {
108
+ rmSync(this.disk(path));
109
+ }
110
+ /** A workspace entry written straight into the tree: no evidence, no intent (a pre-383 store). */
111
+ legacyEntry(path, content) {
112
+ const bytes = Buffer.from(content);
113
+ const blobHash = this.store.storeBlob(bytes, 'text/plain');
114
+ this.store.treeSet(TREE, path, { blobHash, size: bytes.byteLength, mode: 0o644 });
115
+ }
116
+ eventsOf(type) {
117
+ return this.events.filter((e) => e.type === type);
118
+ }
119
+ }
120
+ async function call(m, name, input) {
121
+ const res = await m.handleToolCall({ id: 't', name, input });
122
+ assert.equal(res.success, true, `${name} ${JSON.stringify(input)} failed: ${res.error}`);
123
+ return res.data;
124
+ }
125
+ async function refused(m, name, input) {
126
+ const res = await m.handleToolCall({ id: 't', name, input });
127
+ assert.equal(res.success, false, `${name} ${JSON.stringify(input)} unexpectedly succeeded`);
128
+ return String(res.error);
129
+ }
130
+ async function listing(m, path = 'work') {
131
+ const data = await call(m, 'ls', { path, recursive: true });
132
+ return new Map(data.entries.map((e) => [e.path, e]));
133
+ }
134
+ async function entryOf(m, path) {
135
+ return (await listing(m)).get(path);
136
+ }
137
+ async function stateOf(m, path) {
138
+ return (await entryOf(m, path))?.state ?? 'not listed';
139
+ }
140
+ /** The workspace's text for a path, unformatted. */
141
+ async function contentOf(m, path) {
142
+ const data = await call(m, 'read', { path: `work/${path}`, offsetChars: 0, limitChars: 1_000_000 });
143
+ return data.content;
144
+ }
145
+ /** Written through the workspace and materialized: S = D = P. */
146
+ async function seedSynced(env, m, path, content) {
147
+ await call(m, 'write', { path: `work/${path}`, content });
148
+ await call(m, 'materialize', { path: `work/${path}` });
149
+ assert.equal(env.readDisk(path), content);
150
+ }
151
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
152
+ /** The hash evidence and disk facts carry for this text. */
153
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
154
+ /** Run `fn` with FileHandle.readFile switching branches as `onRead` says. */
155
+ async function withReadHook(onRead, fn) {
156
+ const probe = await open(join(tmpdir(), `read-hook-${process.pid}`), 'w');
157
+ const proto = Object.getPrototypeOf(probe);
158
+ await probe.close();
159
+ const readFile = proto.readFile;
160
+ let calls = 0;
161
+ proto.readFile = async function (...args) {
162
+ onRead(++calls);
163
+ return readFile.apply(this, args);
164
+ };
165
+ try {
166
+ return await fn();
167
+ }
168
+ finally {
169
+ proto.readFile = readFile;
170
+ }
171
+ }
172
+ /** Run `fn` with node:fs/promises' `name` replaced by `make(original)`, as the code under test sees it. */
173
+ async function withFsPromises(name, make, fn) {
174
+ const fsp = createRequire(import.meta.url)('node:fs/promises');
175
+ const original = fsp[name];
176
+ fsp[name] = make(original);
177
+ syncBuiltinESMExports();
178
+ try {
179
+ return await fn();
180
+ }
181
+ finally {
182
+ fsp[name] = original;
183
+ syncBuiltinESMExports();
184
+ }
185
+ }
186
+ /** A file or directory's identity, as a FileHandle's stat reports it. */
187
+ function idOf(path) {
188
+ const s = statSync(path);
189
+ return `${s.dev}:${s.ino}`;
190
+ }
191
+ /**
192
+ * Run `fn` recording what each FileHandle sync reaches (by identity), failing
193
+ * those `fail` picks with an injected EIO.
194
+ */
195
+ async function withSyncs(env, fail, fn) {
196
+ const probe = await open(join(env.root, 'sync-probe'), 'w');
197
+ const proto = Object.getPrototypeOf(probe);
198
+ await probe.close();
199
+ const sync = proto.sync;
200
+ const synced = [];
201
+ proto.sync = async function () {
202
+ const info = await this.stat();
203
+ const id = `${info.dev}:${info.ino}`;
204
+ if (fail(id, info.isDirectory()))
205
+ throw Object.assign(new Error('injected EIO'), { code: 'EIO' });
206
+ synced.push(id);
207
+ return sync.call(this);
208
+ };
209
+ try {
210
+ return { result: await fn(), synced };
211
+ }
212
+ finally {
213
+ proto.sync = sync;
214
+ }
215
+ }
216
+ /** Swap a mount directory for a symlink to `target`, keeping the original as `saved`. */
217
+ function swapForSymlink(env, dir, target, saved = 'saved') {
218
+ renameSync(env.disk(dir), env.disk(saved));
219
+ symlinkSync(target, env.disk(dir));
220
+ }
221
+ // ---------------------------------------------------------------------------
222
+ describe('each three-way case', () => {
223
+ test('a disk-only change is ingested', async (t) => {
224
+ const env = new Env(t);
225
+ const m = await env.open();
226
+ await seedSynced(env, m, 'a.txt', 'v1');
227
+ env.writeDisk('a.txt', 'v1, edited in the shell');
228
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
229
+ assert.equal(await contentOf(m, 'a.txt'), 'v1, edited in the shell');
230
+ });
231
+ test('a store-only draft is kept through scans, watcher events, restarts and reattach', async (t) => {
232
+ const env = new Env(t);
233
+ let m = await env.open();
234
+ await seedSynced(env, m, 'a.txt', 'v1');
235
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2, a draft' });
236
+ const kept = async (when) => {
237
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft', when);
238
+ assert.equal(await contentOf(m, 'a.txt'), 'v2, a draft', `${when}: the draft survives`);
239
+ assert.equal(env.readDisk('a.txt'), 'v1', `${when}: disk is untouched`);
240
+ };
241
+ await kept('after ls');
242
+ await call(m, 'sync', {});
243
+ await kept('after a full sync');
244
+ await call(m, 'glob', { pattern: '**' });
245
+ await call(m, 'grep', { pattern: 'v' });
246
+ await kept('after glob and grep');
247
+ await m.handleFsChanges('work', [{ path: 'a.txt', op: 'modified' }]);
248
+ await kept('after a watcher event');
249
+ m = await env.restart(m);
250
+ await kept('after a restart');
251
+ await m.initialScan('work');
252
+ await kept('after a reattach scan');
253
+ });
254
+ test('agreement advances the baseline', async (t) => {
255
+ const env = new Env(t);
256
+ const m = await env.open();
257
+ await seedSynced(env, m, 'a.txt', 'v1');
258
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
259
+ env.writeDisk('a.txt', 'v2'); // disk reaches the draft by another route
260
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
261
+ // Had P stayed at v1, this edit would be "all three differ" — a conflict.
262
+ env.writeDisk('a.txt', 'v3');
263
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'disk changed since it agreed at v2: adopted');
264
+ assert.equal(await contentOf(m, 'a.txt'), 'v3');
265
+ });
266
+ test('both changed is a conflict, kept, and resolvable either way', async (t) => {
267
+ const env = new Env(t);
268
+ const m = await env.open();
269
+ for (const f of ['take-disk.txt', 'take-workspace.txt']) {
270
+ await seedSynced(env, m, f, 'v1');
271
+ env.writeDisk(f, 'v3, from the shell');
272
+ await call(m, 'write', { path: `work/${f}`, content: 'v2, from the agent' });
273
+ }
274
+ const entries = await listing(m);
275
+ for (const f of ['take-disk.txt', 'take-workspace.txt']) {
276
+ const e = entries.get(f);
277
+ assert.equal(e.state, 'conflict', f);
278
+ assert.equal(e.conflict?.kind, 'both-changed', f);
279
+ assert.equal(e.conflict?.diskCopy, 'stored', `${f}: the text disk version is kept in the store`);
280
+ }
281
+ await call(m, 'sync', {});
282
+ const bulk = await call(m, 'materialize', {});
283
+ assert.equal(bulk.materialized.length, 0, 'neither side overwrites the other unasked');
284
+ assert.equal(env.readDisk('take-disk.txt'), 'v3, from the shell');
285
+ assert.equal(await contentOf(m, 'take-workspace.txt'), 'v2, from the agent');
286
+ await call(m, 'sync', { path: 'work/take-disk.txt' });
287
+ assert.equal(await stateOf(m, 'take-disk.txt'), 'synced', 'a path sync resolves toward disk');
288
+ assert.equal(await contentOf(m, 'take-disk.txt'), 'v3, from the shell');
289
+ await call(m, 'materialize', { path: 'work/take-workspace.txt', force: true });
290
+ assert.equal(await stateOf(m, 'take-workspace.txt'), 'synced', 'a forced materialize resolves toward the workspace');
291
+ assert.equal(env.readDisk('take-workspace.txt'), 'v2, from the agent');
292
+ });
293
+ });
294
+ describe('branches', () => {
295
+ test('a switch to an earlier state while disk holds a later agreed version keeps the selected state as a draft', async (t) => {
296
+ const env = new Env(t);
297
+ const m = await env.open();
298
+ await seedSynced(env, m, 'a.txt', 'v1');
299
+ const main = env.store.currentBranch().name;
300
+ env.store.createBranch('earlier', main);
301
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
302
+ await call(m, 'materialize', {});
303
+ assert.equal(env.readDisk('a.txt'), 'v2');
304
+ env.store.switchBranch('earlier');
305
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft', 'a rewind is a store-side difference, not a disk edit');
306
+ await call(m, 'sync', {});
307
+ assert.equal(await contentOf(m, 'a.txt'), 'v1', 'no scan ingests the later version into the earlier state');
308
+ assert.equal(env.readDisk('a.txt'), 'v2', 'and nothing rewrote disk');
309
+ env.store.switchBranch(main);
310
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
311
+ });
312
+ test("a file only in another branch's history is listed, neither ingested nor unlinked, and a path sync adopts it", async (t) => {
313
+ const env = new Env(t);
314
+ const m = await env.open();
315
+ await seedSynced(env, m, 'a.txt', 'shared');
316
+ const main = env.store.currentBranch().name;
317
+ env.store.createBranch('without-b', main);
318
+ await seedSynced(env, m, 'b.txt', 'only on main');
319
+ env.store.switchBranch('without-b');
320
+ assert.equal(await stateOf(m, 'b.txt'), 'not-in-branch');
321
+ assert.match(await refused(m, 'read', { path: 'work/b.txt' }), /not in this branch/);
322
+ await call(m, 'sync', {});
323
+ assert.equal(await stateOf(m, 'b.txt'), 'not-in-branch', 'a full sync does not ingest it');
324
+ await call(m, 'materialize', { force: true, applyDeletions: true });
325
+ assert.equal(env.readDisk('b.txt'), 'only on main', 'not even force with applyDeletions unlinks it');
326
+ await call(m, 'sync', { path: 'work/b.txt' });
327
+ assert.equal(await stateOf(m, 'b.txt'), 'synced');
328
+ assert.equal(await contentOf(m, 'b.txt'), 'only on main');
329
+ });
330
+ });
331
+ describe('a deletion another branch put on disk', () => {
332
+ for (const how of ['a sync', 'a path sync']) {
333
+ test(`settles this branch's tombstone, and ${how} adopts a new disk file there as new, giving up nothing`, async (t) => {
334
+ // P is global: this branch's S (absent) equals P (absent, from the other
335
+ // branch's unlink), so the new file is only a disk change. The rule
336
+ // decides by P whenever it's known; a tombstone guards only without it.
337
+ const env = new Env(t);
338
+ const m = await env.open();
339
+ await seedSynced(env, m, 'a.txt', 'v1');
340
+ const main = env.store.currentBranch().name;
341
+ env.store.createBranch('other', main);
342
+ await call(m, 'delete', { path: 'work/a.txt' }); // main's own deletion, never pushed
343
+ env.store.switchBranch('other');
344
+ await call(m, 'delete', { path: 'work/a.txt' });
345
+ await call(m, 'materialize', { applyDeletions: true }); // other removes it from disk: P is absent
346
+ assert.equal(existsSync(env.disk('a.txt')), false);
347
+ env.writeDisk('a.txt', 'new, from the shell');
348
+ env.store.switchBranch(main);
349
+ const data = await call(m, 'sync', how === 'a sync' ? {} : { path: 'work/a.txt' });
350
+ assert.deepEqual(data.results, [{ mount: 'work', synced: ['a.txt'], conflicts: [] }], 'taken in, with nothing discarded');
351
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
352
+ assert.equal(await contentOf(m, 'a.txt'), 'new, from the shell');
353
+ });
354
+ }
355
+ });
356
+ describe('workspace deletes', () => {
357
+ test('a delete without autoMaterialize is never resurrected, reaches disk only with applyDeletions', async (t) => {
358
+ const env = new Env(t);
359
+ let m = await env.open();
360
+ await seedSynced(env, m, 'a.txt', 'v1');
361
+ await call(m, 'delete', { path: 'work/a.txt' });
362
+ assert.equal(env.readDisk('a.txt'), 'v1', 'disk keeps its copy');
363
+ const stillDeleted = async (when) => {
364
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-deleted', when);
365
+ assert.match(await refused(m, 'read', { path: 'work/a.txt' }), /deleted in the workspace/, `${when}: a lazy read does not restore it`);
366
+ };
367
+ await stillDeleted('after ls');
368
+ await call(m, 'sync', {});
369
+ await stillDeleted('after a full sync');
370
+ await m.handleFsChanges('work', [{ path: 'a.txt', op: 'modified' }]);
371
+ await stillDeleted('after a watcher event');
372
+ m = await env.restart(m);
373
+ await m.initialScan('work');
374
+ await stillDeleted('after a restart and its scan');
375
+ const plain = await call(m, 'materialize', {});
376
+ assert.deepEqual(plain.workspaceDeletionsLeftOnDisk, [{ mount: 'work', path: 'a.txt' }]);
377
+ assert.equal(env.readDisk('a.txt'), 'v1', 'materialize never deletes by default');
378
+ const applied = await call(m, 'materialize', { applyDeletions: true });
379
+ assert.deepEqual(applied.deleted, [{ mount: 'work', path: 'a.txt' }]);
380
+ assert.throws(() => env.readDisk('a.txt'), /ENOENT/);
381
+ assert.equal(await stateOf(m, 'a.txt'), 'not listed');
382
+ });
383
+ test('a path sync cancels a workspace delete', async (t) => {
384
+ const env = new Env(t);
385
+ const m = await env.open();
386
+ await seedSynced(env, m, 'a.txt', 'v1');
387
+ await call(m, 'delete', { path: 'work/a.txt' });
388
+ await call(m, 'sync', { path: 'work/a.txt' });
389
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
390
+ assert.equal(await contentOf(m, 'a.txt'), 'v1');
391
+ });
392
+ test("a deletion whose record can't be read is unverified, never resurrected, until a path sync replaces it", async (t) => {
393
+ const env = new Env(t);
394
+ let m = await env.open();
395
+ env.legacyEntry('legacy.txt', 'old');
396
+ env.writeDisk('legacy.txt', 'old');
397
+ await call(m, 'delete', { path: 'work/legacy.txt' }); // the tombstone is all that keeps it deleted: no evidence
398
+ const record = env.store.treeGet('workspace/work/intent', 'legacy.txt');
399
+ assert.ok(record, 'the tombstone is recorded');
400
+ await env.shutdown(m);
401
+ rmSync(join(env.storePath, 'blobs', record.blobHash.slice(0, 2), record.blobHash)); // and its blob is lost
402
+ env.store = JsStore.openOrCreate({ path: env.storePath });
403
+ m = await env.open();
404
+ const e = await entryOf(m, 'legacy.txt');
405
+ assert.equal(e?.state, 'unverified');
406
+ assert.match(String(e?.note), /branch intent can't be read/);
407
+ await call(m, 'sync', {});
408
+ assert.equal(env.store.treeGet(TREE, 'legacy.txt'), null, 'not taken back in from disk');
409
+ await call(m, 'sync', { path: 'work/legacy.txt' }); // the operator's word: take disk's state
410
+ assert.equal(await stateOf(m, 'legacy.txt'), 'synced');
411
+ assert.equal(await contentOf(m, 'legacy.txt'), 'old');
412
+ });
413
+ test('a legacy entry with no baseline, deleted in the workspace, stays deleted', async (t) => {
414
+ const env = new Env(t);
415
+ const m = await env.open();
416
+ env.writeDisk('legacy.txt', 'old');
417
+ env.legacyEntry('legacy.txt', 'old');
418
+ await call(m, 'delete', { path: 'work/legacy.txt' });
419
+ assert.equal(await stateOf(m, 'legacy.txt'), 'workspace-deleted', 'the tombstone carries the deleted hash');
420
+ await call(m, 'sync', {});
421
+ await m.initialScan('work');
422
+ assert.equal(await stateOf(m, 'legacy.txt'), 'workspace-deleted');
423
+ const applied = await call(m, 'materialize', { applyDeletions: true });
424
+ assert.deepEqual(applied.deleted, [{ mount: 'work', path: 'legacy.txt' }]);
425
+ });
426
+ test('autoMaterialize unlinks under an intent, and refuses when disk changed since', async (t) => {
427
+ const env = new Env(t);
428
+ const m = await env.open({ autoMaterialize: true });
429
+ await call(m, 'write', { path: 'work/a.txt', content: 'v1' });
430
+ await call(m, 'write', { path: 'work/b.txt', content: 'v1' });
431
+ assert.equal(env.readDisk('a.txt'), 'v1');
432
+ await call(m, 'delete', { path: 'work/a.txt' });
433
+ assert.throws(() => env.readDisk('a.txt'), /ENOENT/, 'unlinked');
434
+ env.writeDisk('b.txt', 'edited in the shell');
435
+ const error = await refused(m, 'delete', { path: 'work/b.txt' });
436
+ assert.match(error, /did not unlink/);
437
+ assert.equal(env.readDisk('b.txt'), 'edited in the shell', 'the shell edit survives');
438
+ });
439
+ });
440
+ describe('a path sync', () => {
441
+ test('takes disk\'s state for a whole directory, and lists each workspace change it discarded', async (t) => {
442
+ const env = new Env(t);
443
+ const m = await env.open();
444
+ await seedSynced(env, m, 'src/existing.txt', 'v1');
445
+ await call(m, 'write', { path: 'work/src/existing.txt', content: 'a draft' });
446
+ await call(m, 'write', { path: 'work/src/new.txt', content: 'never materialized' });
447
+ await seedSynced(env, m, 'src/gone.txt', 'v1');
448
+ await call(m, 'delete', { path: 'work/src/gone.txt' });
449
+ await seedSynced(env, m, 'src/shell.txt', 'v1');
450
+ env.writeDisk('src/shell.txt', 'edited in the shell');
451
+ const data = await call(m, 'sync', { path: 'work/src' });
452
+ const result = data.results[0];
453
+ assert.deepEqual([...result.synced].sort(), ['src/existing.txt', 'src/gone.txt', 'src/new.txt', 'src/shell.txt']);
454
+ assert.deepEqual([...(result.discarded ?? [])].sort((a, b) => a.path.localeCompare(b.path)), [
455
+ { path: 'src/existing.txt', was: 'workspace-draft', op: 'modified' },
456
+ { path: 'src/gone.txt', was: 'workspace-deleted', op: 'created' },
457
+ { path: 'src/new.txt', was: 'workspace-draft', op: 'deleted' },
458
+ ], 'a shell edit taken in discards nothing; the rest were the workspace\'s own changes');
459
+ assert.equal(await contentOf(m, 'src/existing.txt'), 'v1');
460
+ assert.equal(await stateOf(m, 'src/new.txt'), 'not listed', 'disk has no such file, so neither does the workspace');
461
+ });
462
+ test('asked on one branch, adopts nothing on another selected before its turn', async (t) => {
463
+ const env = new Env(t);
464
+ const m = await env.open();
465
+ await seedSynced(env, m, 'a.txt', 'v1');
466
+ const main = env.store.currentBranch().name;
467
+ env.store.createBranch('other', main);
468
+ env.store.switchBranch('other');
469
+ await call(m, 'write', { path: 'work/a.txt', content: "other's draft" });
470
+ env.store.switchBranch(main);
471
+ env.writeDisk('a.txt', 'from the shell');
472
+ // Asked on main, queued behind a turn the mount is busy with.
473
+ const mount = m.mounts.get('work');
474
+ let release;
475
+ let busy;
476
+ await new Promise((started) => {
477
+ busy = m.withMount(mount, () => new Promise((r) => { release = r; started(); }));
478
+ });
479
+ const syncing = call(m, 'sync', { path: 'work/a.txt' });
480
+ env.store.switchBranch('other');
481
+ release();
482
+ await busy;
483
+ const data = await syncing;
484
+ assert.match(JSON.stringify(data.incomplete), /the selected branch changed before this sync ran/);
485
+ assert.deepEqual(data.results, []);
486
+ assert.equal(await contentOf(m, 'a.txt'), "other's draft", "other's draft is untouched");
487
+ env.store.switchBranch(main);
488
+ assert.equal(await contentOf(m, 'a.txt'), 'v1', 'and main took nothing either');
489
+ });
490
+ test("lists a deletion it gives up for a disk copy the workspace can't hold, though no workspace entry changes", async (t) => {
491
+ const env = new Env(t);
492
+ const m = await env.open();
493
+ await seedSynced(env, m, 'a.txt', 'text');
494
+ await call(m, 'delete', { path: 'work/a.txt' });
495
+ env.writeDisk('a.txt', ONE_PX_PNG); // binary: never stored
496
+ const data = await call(m, 'sync', { path: 'work/a.txt' });
497
+ assert.deepEqual(data.results, [{ mount: 'work', synced: [], conflicts: [], discarded: [{ path: 'a.txt', was: 'conflict' }] }], 'the workspace deletion (a conflict once disk changed) is given up, with no op: the workspace copy was already gone');
498
+ assert.equal(await stateOf(m, 'a.txt'), 'disk-only');
499
+ });
500
+ });
501
+ describe('disk counterparts that are not stored', () => {
502
+ test('a binary counterpart keeps the draft and a disk reference that reports a later change', async (t) => {
503
+ const env = new Env(t);
504
+ const m = await env.open();
505
+ await seedSynced(env, m, 'a.dat', 'text v1');
506
+ env.writeDisk('a.dat', Buffer.from([1, 0, 2, 0, 3]));
507
+ await call(m, 'write', { path: 'work/a.dat', content: 'text v2' });
508
+ let e = await entryOf(m, 'a.dat');
509
+ assert.equal(e?.state, 'conflict');
510
+ assert.equal(e?.conflict?.diskCopy, 'referenced', 'binary: referenced, not stored');
511
+ assert.equal(e?.conflict?.diskChangedSinceRecorded, false);
512
+ env.writeDisk('a.dat', Buffer.from([9, 0, 9, 0]));
513
+ e = await entryOf(m, 'a.dat');
514
+ assert.equal(e?.state, 'conflict');
515
+ assert.equal(e?.conflict?.diskChangedSinceRecorded, true, 'the reference says disk changed since');
516
+ assert.equal(await contentOf(m, 'a.dat'), 'text v2', 'the draft is kept');
517
+ await call(m, 'materialize', {});
518
+ assert.deepEqual([...readFileSync(env.disk('a.dat'))], [9, 0, 9, 0], 'and nothing overwrote the disk file');
519
+ });
520
+ test('an oversize counterpart is referenced too', async (t) => {
521
+ const env = new Env(t);
522
+ const m = await env.open({ maxFileSize: 64 });
523
+ await seedSynced(env, m, 'a.txt', 'small');
524
+ env.writeDisk('a.txt', 'x'.repeat(200));
525
+ await call(m, 'write', { path: 'work/a.txt', content: 'small v2' });
526
+ const e = await entryOf(m, 'a.txt');
527
+ assert.equal(e?.state, 'conflict');
528
+ assert.equal(e?.conflict?.diskCopy, 'referenced');
529
+ });
530
+ });
531
+ describe('disk-only files', () => {
532
+ test('a shell-written PNG is listed disk-only with size and type, and read_image opens it', async (t) => {
533
+ const env = new Env(t);
534
+ const m = await env.open();
535
+ env.writeDisk('shot.png', ONE_PX_PNG);
536
+ const data = await call(m, 'ls', { path: 'work' });
537
+ const png = data.entries.find((e) => e.name === 'shot.png');
538
+ assert.deepEqual(png, { name: 'shot.png', type: 'file', state: 'disk-only', size: ONE_PX_PNG.length, mimeType: 'image/png' });
539
+ const glob = await call(m, 'glob', { pattern: '*.png' });
540
+ assert.deepEqual(glob.matches, [{ path: 'work/shot.png', state: 'disk-only', size: ONE_PX_PNG.length, mimeType: 'image/png' }]);
541
+ const res = await m.handleToolCall({ id: 't', name: 'read_image', input: { path: 'work/shot.png' } });
542
+ assert.equal(res.success, true, `read_image: ${res.error}`);
543
+ });
544
+ test('an untracked file over the size limit is listed from its head, never read whole or taken as evidence', async (t) => {
545
+ const env = new Env(t);
546
+ const m = await env.open({ maxFileSize: 1024 });
547
+ const big = Buffer.concat([ONE_PX_PNG, Buffer.alloc(256 * 1024, 1)]);
548
+ env.writeDisk('big.png', big);
549
+ const probe = await open(join(env.root, 'read-probe'), 'w');
550
+ const proto = Object.getPrototypeOf(probe);
551
+ await probe.close();
552
+ const read = proto.read;
553
+ let bytes = 0;
554
+ proto.read = async function (...args) {
555
+ const result = await read.apply(this, args);
556
+ bytes += result.bytesRead;
557
+ return result;
558
+ };
559
+ let entry;
560
+ try {
561
+ entry = (await call(m, 'ls', { path: 'work' })).entries.find((e) => e.name === 'big.png');
562
+ }
563
+ finally {
564
+ proto.read = read;
565
+ }
566
+ assert.deepEqual(entry, { name: 'big.png', type: 'file', state: 'disk-only', size: big.length, mimeType: 'image/png' });
567
+ assert.ok(bytes <= 8192, `the listing read ${bytes} bytes of a ${big.length}-byte file`);
568
+ assert.equal(m.agreement.get('work', 'big.png'), undefined, 'no evidence recorded');
569
+ });
570
+ });
571
+ describe('shell deletions', () => {
572
+ const mechanisms = [
573
+ ['a watcher event', async (_env, m) => { await m.handleFsChanges('work', [{ path: 'gone.txt', op: 'deleted' }]); return m; }],
574
+ ['a full sync', async (_env, m) => { await call(m, 'sync', {}); return m; }],
575
+ ['sync without a path, on the mount', async (_env, m) => { await call(m, 'sync', { mount: 'work' }); return m; }],
576
+ ['a listing', async (_env, m) => { await call(m, 'ls', { path: 'work' }); return m; }],
577
+ ['the post-restart scan', async (env, m) => { const r = await env.restart(m); await r.initialScan('work'); return r; }],
578
+ ['a reattach', async (_env, m) => { await m.initialScan('work'); return m; }],
579
+ ];
580
+ for (const [name, run] of mechanisms) {
581
+ test(`a shell rm of a synced file is removed by ${name}`, async (t) => {
582
+ const env = new Env(t);
583
+ let m = await env.open();
584
+ await seedSynced(env, m, 'gone.txt', 'v1');
585
+ env.rmDisk('gone.txt');
586
+ m = await run(env, m);
587
+ assert.equal(env.store.treeGet(TREE, 'gone.txt'), null, 'the tree entry is removed');
588
+ assert.match(await refused(m, 'read', { path: 'work/gone.txt' }), /File not found/);
589
+ assert.ok(env.eventsOf('workspace:deleted').some((e) => e.paths.includes('work/gone.txt')), 'a deleted event was pushed');
590
+ });
591
+ }
592
+ test('a shell rm of a file with a newer unmaterialized edit is kept as a conflict', async (t) => {
593
+ const env = new Env(t);
594
+ const m = await env.open();
595
+ await seedSynced(env, m, 'a.txt', 'v1');
596
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2, not materialized' });
597
+ env.rmDisk('a.txt');
598
+ await m.handleFsChanges('work', [{ path: 'a.txt', op: 'deleted' }]);
599
+ const deleted = env.eventsOf('workspace:deleted').at(-1);
600
+ assert.deepEqual(deleted?.conflicts, ['work/a.txt'], 'the event names the conflict');
601
+ const e = await entryOf(m, 'a.txt');
602
+ assert.equal(e?.state, 'conflict');
603
+ assert.equal(e?.conflict?.kind, 'deleted-on-disk');
604
+ assert.equal(e?.conflict?.diskCopy, 'absent');
605
+ await call(m, 'sync', {});
606
+ assert.equal(await contentOf(m, 'a.txt'), 'v2, not materialized', 'the edit survives a full sync');
607
+ assert.equal((await call(m, 'materialize', {})).materialized.length, 0, 'refused without force');
608
+ await call(m, 'materialize', { force: true });
609
+ assert.equal(env.readDisk('a.txt'), 'v2, not materialized');
610
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
611
+ });
612
+ });
613
+ describe('provenance', () => {
614
+ test('store-origin drafts are distinguished from legacy unknowns', async (t) => {
615
+ const env = new Env(t);
616
+ const m = await env.open();
617
+ await call(m, 'write', { path: 'work/draft.txt', content: 'never materialized' });
618
+ env.legacyEntry('legacy-missing.txt', 'store only');
619
+ env.legacyEntry('legacy-same.txt', 'same');
620
+ env.writeDisk('legacy-same.txt', 'same');
621
+ env.legacyEntry('legacy-diff.txt', 'store side');
622
+ env.writeDisk('legacy-diff.txt', 'disk side');
623
+ await call(m, 'write', { path: 'work/collide.txt', content: 'from the agent' });
624
+ env.writeDisk('collide.txt', 'from the shell');
625
+ const entries = await listing(m);
626
+ assert.equal(entries.get('draft.txt')?.state, 'workspace-draft');
627
+ assert.equal(entries.get('legacy-missing.txt')?.state, 'disk-missing-provenance-unknown');
628
+ assert.equal(entries.get('legacy-same.txt')?.state, 'synced');
629
+ assert.equal(entries.get('legacy-diff.txt')?.state, 'conflict');
630
+ assert.equal(entries.get('legacy-diff.txt')?.conflict?.kind, 'unknown-provenance');
631
+ assert.equal(entries.get('collide.txt')?.state, 'conflict');
632
+ assert.equal(entries.get('collide.txt')?.conflict?.kind, 'store-origin-collision');
633
+ assert.equal(await contentOf(m, 'legacy-diff.txt'), 'store side', 'a legacy mismatch keeps the store side');
634
+ const materialized = await call(m, 'materialize', {});
635
+ const written = materialized.materialized.map((w) => w.path).sort();
636
+ assert.deepEqual(written, ['draft.txt'], 'drafts are written; what has no evidence is left for a named call');
637
+ assert.equal(existsSync(env.disk('legacy-missing.txt')), false, 'a shell rm of a pre-evidence file is not undone');
638
+ // With no evidence there was no agreement for disk to have changed since,
639
+ // and nothing says whether a missing copy was deleted or never written.
640
+ const reasons = materialized.skipped.map((s) => s.reason).sort();
641
+ assert.deepEqual(reasons, [
642
+ 'collide.txt: disk holds a different copy, and nothing records which is newer (conflict: store-origin-collision) — ' +
643
+ 'sync this path to adopt the disk version, or materialize this path with force to overwrite it',
644
+ 'legacy-diff.txt: disk holds a different copy, and nothing records which is newer (conflict: unknown-provenance) — ' +
645
+ 'sync this path to adopt the disk version, or materialize this path with force to overwrite it',
646
+ 'legacy-missing.txt: disk has no copy, and nothing records whether it was deleted there or never written — ' +
647
+ 'sync this path to drop it from the workspace, or materialize this path to write it',
648
+ ]);
649
+ // Both remedies do what they say.
650
+ const named = await call(m, 'materialize', { path: 'work/legacy-missing.txt' });
651
+ assert.deepEqual(named.materialized.map((w) => w.path), ['legacy-missing.txt']);
652
+ assert.equal(env.readDisk('legacy-missing.txt'), 'store only');
653
+ env.legacyEntry('legacy-gone.txt', 'deleted in a shell');
654
+ const synced = await call(m, 'sync', { path: 'work/legacy-gone.txt' });
655
+ assert.deepEqual(synced.results[0].discarded, [{ path: 'legacy-gone.txt', was: 'disk-missing-provenance-unknown', op: 'deleted' }]);
656
+ assert.equal(env.store.treeGet(TREE, 'legacy-gone.txt'), null, 'the path sync dropped it from the workspace');
657
+ });
658
+ });
659
+ describe('restarts and faults', () => {
660
+ test('baselines survive a restart', async (t) => {
661
+ const env = new Env(t);
662
+ let m = await env.open();
663
+ await seedSynced(env, m, 'a.txt', 'v1');
664
+ m = await env.restart(m);
665
+ env.writeDisk('a.txt', 'v3, edited after the restart');
666
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'P survived: a disk-only change, not unknown provenance');
667
+ assert.equal(await contentOf(m, 'a.txt'), 'v3, edited after the restart');
668
+ });
669
+ for (const killAt of ['after-intent', 'after-effect']) {
670
+ test(`a materialize killed ${killAt}, then branch selection`, async (t) => {
671
+ const env = new Env(t);
672
+ let m = await env.open();
673
+ await seedSynced(env, m, 'a.txt', 'v1');
674
+ const main = env.store.currentBranch().name;
675
+ env.store.createBranch('before-v2', main);
676
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
677
+ m = await env.fault(m, 'materialize', killAt);
678
+ assert.equal(env.readDisk('a.txt'), killAt === 'after-intent' ? 'v1' : 'v2');
679
+ env.store.switchBranch('before-v2');
680
+ assert.equal(await stateOf(m, 'a.txt'), killAt === 'after-intent' ? 'synced' : 'workspace-draft', 'the earlier branch reads disk by the resolved intent, never as a disk edit');
681
+ env.store.switchBranch(main);
682
+ if (killAt === 'after-intent') {
683
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft', 'the write never happened: still owed');
684
+ await call(m, 'materialize', {});
685
+ assert.equal(env.readDisk('a.txt'), 'v2');
686
+ }
687
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
688
+ if (killAt === 'after-effect') {
689
+ // Disk shows the write, but nothing proves the dead process made it
690
+ // durable: listings record nothing, and the next push completes it.
691
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending');
692
+ await call(m, 'materialize', {});
693
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'content');
694
+ }
695
+ });
696
+ test(`a shell-edit ingest killed ${killAt}, then branch selection`, async (t) => {
697
+ const env = new Env(t);
698
+ let m = await env.open();
699
+ await seedSynced(env, m, 'a.txt', 'v1');
700
+ const main = env.store.currentBranch().name;
701
+ env.store.createBranch('before-edit', main);
702
+ env.writeDisk('a.txt', 'v3, from the shell');
703
+ m = await env.fault(m, 'sync', killAt);
704
+ env.store.switchBranch('before-edit');
705
+ const e = await entryOf(m, 'a.txt');
706
+ assert.equal(e?.state, 'conflict', 'disk may hold the only copy of the edit: listed, not pushed over');
707
+ assert.equal(e?.conflict?.kind, 'interrupted');
708
+ await call(m, 'materialize', { force: false });
709
+ assert.equal(env.readDisk('a.txt'), 'v3, from the shell');
710
+ env.store.switchBranch(main);
711
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'on its own branch the adoption completes or is redone');
712
+ assert.equal(await contentOf(m, 'a.txt'), 'v3, from the shell');
713
+ });
714
+ test(`an autoMaterialize write of a new file killed ${killAt}, then a branch without it`, async (t) => {
715
+ const env = new Env(t);
716
+ let m = await env.open();
717
+ await seedSynced(env, m, 'other.txt', 'x');
718
+ const main = env.store.currentBranch().name;
719
+ env.store.createBranch('without-new', main);
720
+ m = await env.fault(m, 'write', killAt);
721
+ env.store.switchBranch('without-new');
722
+ assert.equal(await stateOf(m, 'new.txt'), killAt === 'after-intent' ? 'not listed' : 'not-in-branch', 'never ingested into a branch that lacks it');
723
+ await call(m, 'sync', {});
724
+ assert.equal(env.store.treeGet(TREE, 'new.txt'), null);
725
+ env.store.switchBranch(main);
726
+ assert.equal(await stateOf(m, 'new.txt'), killAt === 'after-intent' ? 'workspace-draft' : 'synced');
727
+ assert.equal(await contentOf(m, 'new.txt'), 'new file');
728
+ });
729
+ }
730
+ /** S = P = D = A, with an adoption of the shell's B left pending (killed after its intent). */
731
+ async function pendingAdoption(env) {
732
+ let m = await env.open();
733
+ await seedSynced(env, m, 'a.txt', 'A');
734
+ env.writeDisk('a.txt', 'B');
735
+ m = await env.fault(m, 'sync', 'after-intent');
736
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending');
737
+ return m;
738
+ }
739
+ test("this branch agreeing with disk leaves another branch's pending adoption for that branch to resolve", async (t) => {
740
+ const env = new Env(t);
741
+ let m = await env.open();
742
+ await seedSynced(env, m, 'a.txt', 'A');
743
+ const main = env.store.currentBranch().name;
744
+ env.store.createBranch('other', main);
745
+ env.writeDisk('a.txt', 'B');
746
+ m = await env.fault(m, 'sync', 'after-intent'); // main's adoption of B, pending
747
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending');
748
+ env.store.switchBranch('other');
749
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
750
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'other agrees with disk');
751
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending', "and leaves main's adoption pending");
752
+ env.store.switchBranch(main);
753
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'main resolves its own adoption');
754
+ assert.equal(await contentOf(m, 'a.txt'), 'B');
755
+ });
756
+ test('an adoption intended over a pending one carries the evidence that settled it, so a crash before it lands redoes it', async (t) => {
757
+ const env = new Env(t);
758
+ let m = await pendingAdoption(env); // S = A, an adoption of B pending
759
+ env.writeDisk('a.txt', 'C');
760
+ m = await env.fault(m, 'sync', 'after-intent'); // settles B's (S is A) and intends C
761
+ const p = m.agreement.get('work', 'a.txt');
762
+ assert.equal(p.kind, 'pending');
763
+ assert.deepEqual(p.prior && { kind: p.prior.kind, hash: p.prior.hash }, { kind: 'content', hash: sha('A') }, "its prior is A, as the settled adoption left it, not the pending record's");
764
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'from A, the adoption of C is redone');
765
+ assert.equal(await contentOf(m, 'a.txt'), 'C');
766
+ });
767
+ test('a write of the pending adoption\'s content is not taken for the old adoption landing', async (t) => {
768
+ const env = new Env(t);
769
+ const m = await pendingAdoption(env);
770
+ env.writeDisk('a.txt', 'C'); // disk moves on
771
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
772
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft', 'the write is a draft over disk C');
773
+ assert.equal(await contentOf(m, 'a.txt'), 'B', 'and it survives the scan');
774
+ assert.equal(env.readDisk('a.txt'), 'C');
775
+ });
776
+ test('a path sync keeps a stored binary file that disk agrees with', async (t) => {
777
+ const env = new Env(t);
778
+ const m = await env.open();
779
+ const res = await m.writeBinary('work/img/shot.png', ONE_PX_PNG, 'image/png');
780
+ assert.equal(res.success, true, String(res.error));
781
+ await call(m, 'materialize', { path: 'work/img/shot.png' });
782
+ assert.deepEqual(readFileSync(env.disk('img/shot.png')), ONE_PX_PNG);
783
+ const stored = env.store.treeGet(TREE, 'img/shot.png');
784
+ assert.notEqual(stored, null);
785
+ const synced = await call(m, 'sync', { path: 'work/img' });
786
+ assert.deepEqual(synced.results ?? [], [], 'nothing to take from disk');
787
+ assert.deepEqual(env.store.treeGet(TREE, 'img/shot.png'), stored, 'still held, unchanged');
788
+ assert.equal(await stateOf(m, 'img/shot.png'), 'synced');
789
+ });
790
+ test('writeBinary settles the pending adoption first too', async (t) => {
791
+ const env = new Env(t);
792
+ const m = await pendingAdoption(env);
793
+ env.writeDisk('a.txt', 'C');
794
+ const res = await m.writeBinary('work/a.txt', Buffer.from('B'), 'text/plain');
795
+ assert.equal(res.success, true, String(res.error));
796
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft');
797
+ assert.equal(await contentOf(m, 'a.txt'), 'B');
798
+ });
799
+ test('an edit sees the disk version the settled adoption takes in, not stale text', async (t) => {
800
+ const env = new Env(t);
801
+ const m = await pendingAdoption(env);
802
+ env.writeDisk('a.txt', 'C');
803
+ const error = await refused(m, 'edit', { path: 'work/a.txt', oldString: 'A', newString: 'B' });
804
+ assert.match(error, /String not found/, 'the entry is disk\'s C by then, so an edit of A has nothing to replace');
805
+ assert.equal(await contentOf(m, 'a.txt'), 'C');
806
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
807
+ });
808
+ test('a delete settles the pending adoption first, and deletes what the workspace then holds', async (t) => {
809
+ const env = new Env(t);
810
+ const m = await pendingAdoption(env);
811
+ env.writeDisk('a.txt', 'C');
812
+ await call(m, 'delete', { path: 'work/a.txt' });
813
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-deleted');
814
+ assert.equal(env.readDisk('a.txt'), 'C');
815
+ });
816
+ test('a write commits only once a pending adoption on its branch is settled, even if the scan could not settle it', async (t) => {
817
+ const env = new Env(t);
818
+ const m = await pendingAdoption(env); // pending on main
819
+ const main = env.store.currentBranch().name;
820
+ env.store.createBranch('other', main);
821
+ env.writeDisk('a.txt', 'C');
822
+ env.store.switchBranch('other'); // the write starts on another branch
823
+ // Each of the settle pass's three gathers sees the branch change under it,
824
+ // so it decides nothing and returns incomplete; the write commits on main.
825
+ await withReadHook((n) => env.store.switchBranch(n % 2 === 1 ? main : 'other'), () => call(m, 'write', { path: 'work/a.txt', content: 'B' }));
826
+ assert.equal(env.store.currentBranch().name, main);
827
+ assert.notEqual(m.agreement.get('work', 'a.txt').kind, 'pending', 'settled at the commit');
828
+ const e = await entryOf(m, 'a.txt');
829
+ assert.equal(e?.state, 'conflict', 'B (the write) and C (disk) both changed since A');
830
+ assert.equal(await contentOf(m, 'a.txt'), 'B', 'the write survives');
831
+ });
832
+ test('a failed barrier at the mutation precondition stays owed, and the next mutation discharges it first', async (t) => {
833
+ const env = new Env(t);
834
+ const m = await pendingAdoption(env);
835
+ const agreement = m.agreement;
836
+ const mount = { name: 'work', treeStateId: TREE };
837
+ const set = DiskAgreement.prototype.set;
838
+ const storeSync = env.store.sync;
839
+ let armed = false;
840
+ let syncs = 0;
841
+ DiskAgreement.prototype.set = function (...args) {
842
+ set.apply(this, args);
843
+ armed = true; // the next sync is the barrier after the settlement
844
+ };
845
+ env.store.sync = function () {
846
+ syncs++;
847
+ if (armed) {
848
+ armed = false;
849
+ throw Object.assign(new Error('injected EIO at the precondition barrier'), { code: 'EIO' });
850
+ }
851
+ storeSync.call(env.store);
852
+ };
853
+ try {
854
+ assert.throws(() => settleAdoptionBeforeMutation(env.store, agreement, mount, 'a.txt'), /precondition barrier/);
855
+ DiskAgreement.prototype.set = set;
856
+ assert.equal(agreement.get('work', 'a.txt')?.kind, 'content', 'settled in memory');
857
+ assert.equal(agreement.needsBarrier, true, 'and still owed a barrier');
858
+ syncs = 0;
859
+ settleAdoptionBeforeMutation(env.store, agreement, mount, 'a.txt'); // P no longer looks pending
860
+ assert.ok(syncs >= 1, 'the retry discharges the barrier before permitting the mutation');
861
+ assert.equal(agreement.needsBarrier, false);
862
+ }
863
+ finally {
864
+ DiskAgreement.prototype.set = set;
865
+ env.store.sync = storeSync;
866
+ }
867
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
868
+ });
869
+ test('evidence loaded at open owes a barrier: reading records back proves nothing about their durability', (t) => {
870
+ const env = new Env(t);
871
+ const writer = new DiskAgreement(env.store);
872
+ writer.open([{ name: 'work', root: '/srv/work' }]);
873
+ writer.set('work', 'a.txt', { kind: 'content', hash: 'h', size: 1 }); // no barrier after it
874
+ const reader = new DiskAgreement(env.store);
875
+ reader.open([{ name: 'work', root: '/srv/work' }]);
876
+ assert.deepEqual(reader.get('work', 'a.txt'), { kind: 'content', hash: 'h', size: 1 });
877
+ assert.equal(reader.needsBarrier, true);
878
+ reader.barrier();
879
+ assert.equal(reader.needsBarrier, false);
880
+ });
881
+ test('evidence is read only after an ambiguous append is reconciled', (t) => {
882
+ const env = new Env(t);
883
+ let failSync = false;
884
+ const flaky = new Proxy(env.store, {
885
+ get(target, prop) {
886
+ if (prop === 'sync') {
887
+ return () => {
888
+ if (failSync) {
889
+ failSync = false;
890
+ throw Object.assign(new Error('injected EIO after the append'), { code: 'EIO' });
891
+ }
892
+ target.sync();
893
+ };
894
+ }
895
+ const value = target[prop];
896
+ return typeof value === 'function' ? value.bind(target) : value;
897
+ },
898
+ });
899
+ const agreement = new DiskAgreement(flaky);
900
+ agreement.open([{ name: 'work', root: '/srv/work' }]);
901
+ failSync = true;
902
+ assert.throws(() => agreement.intend('work', 'a.txt', {
903
+ effect: 'adopt', prior: null, expect: { kind: 'content', hash: 'h' }, branchId: 'b',
904
+ }, { durable: true }), /injected EIO/);
905
+ assert.equal(agreement.get('work', 'a.txt')?.kind, 'pending', 'the append that landed is read back, not the stale view');
906
+ assert.equal(agreement.needsBarrier, true);
907
+ });
908
+ test('an append that fails inside the store leaves no decision to stale evidence: passes refuse until a reopen, which finds the evidence', async (t) => {
909
+ const env = new Env(t);
910
+ let m = await env.open();
911
+ await call(m, 'write', { path: 'work/a.txt', content: 'A' });
912
+ await call(m, 'materialize', { path: 'work/a.txt' });
913
+ const main = env.store.currentBranch().name;
914
+ env.store.createBranch('draft-c', main);
915
+ env.store.switchBranch('draft-c');
916
+ await call(m, 'write', { path: 'work/a.txt', content: 'C' });
917
+ env.store.switchBranch(main);
918
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
919
+ env.writeDisk('a.txt', 'B');
920
+ // The pass that agrees at B appends that evidence, and the append throws
921
+ // after it landed, as Chronicle's does when the log's periodic sync fails.
922
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
923
+ const store = env.store;
924
+ const append = store.appendJson;
925
+ let fired = false;
926
+ store.appendJson = function (type, payload) {
927
+ const record = append.call(this, type, payload);
928
+ if (!fired && type === 'workspace/disk-agreement' && payload.t === 'set' && payload.value?.hash === sha('B')) {
929
+ fired = true;
930
+ throw new Error('injected: the append landed, then the store failed');
931
+ }
932
+ return record;
933
+ };
934
+ try {
935
+ assert.match(await refused(m, 'ls', { path: 'work', recursive: true }), /injected: the append landed/);
936
+ }
937
+ finally {
938
+ store.appendJson = append;
939
+ }
940
+ assert.equal(fired, true);
941
+ // Its evidence would still say A, so the draft branch's listing would read
942
+ // C against A on disk B as both-changed and record a sticky conflict. It
943
+ // refuses instead, and records nothing.
944
+ env.store.switchBranch('draft-c');
945
+ assert.match(await refused(m, 'ls', { path: 'work', recursive: true }), /evidence is unavailable until the store is reopened/);
946
+ assert.match(await refused(m, 'materialize', { path: 'work/a.txt' }), /evidence is unavailable until the store is reopened/);
947
+ const intents = m.intents;
948
+ assert.equal(intents.get('work').get('a.txt')?.conflict, undefined, 'no conflict was recorded');
949
+ // Reopened, the store has the evidence at B, and the draft is a draft.
950
+ m = await env.restart(m);
951
+ env.store.switchBranch('draft-c');
952
+ const entry = (await call(m, 'ls', { path: 'work', recursive: true })).entries.find((e) => e.path === 'a.txt');
953
+ assert.equal(entry.state, 'workspace-draft');
954
+ assert.equal(readFileSync(join(env.dir, 'a.txt'), 'utf8'), 'B', 'disk is untouched');
955
+ });
956
+ test('a checkpoint check after an ambiguous append reconciles first, like every entry point', (t) => {
957
+ const env = new Env(t);
958
+ let failSync = false;
959
+ const flaky = new Proxy(env.store, {
960
+ get(target, prop) {
961
+ if (prop === 'sync') {
962
+ return () => {
963
+ if (failSync) {
964
+ failSync = false;
965
+ throw Object.assign(new Error('injected EIO after the append'), { code: 'EIO' });
966
+ }
967
+ target.sync();
968
+ };
969
+ }
970
+ const value = target[prop];
971
+ return typeof value === 'function' ? value.bind(target) : value;
972
+ },
973
+ });
974
+ const agreement = new DiskAgreement(flaky);
975
+ agreement.open([{ name: 'work', root: '/srv/work' }]);
976
+ failSync = true;
977
+ assert.throws(() => agreement.intend('work', 'a.txt', {
978
+ effect: 'adopt', prior: null, expect: { kind: 'content', hash: 'h' }, branchId: 'b',
979
+ }, { durable: true }), /injected EIO/);
980
+ const journal = agreement.journal;
981
+ assert.equal(journal.needsReconcile, true);
982
+ agreement.maybeCheckpoint();
983
+ assert.equal(journal.needsReconcile, false, 'read back before deciding whether to checkpoint');
984
+ });
985
+ test('with disk unreadable at the write, the pending adoption settles by the tree', { skip: IS_ROOT ? 'root reads every file' : false }, async (t) => {
986
+ const env = new Env(t);
987
+ const m = await pendingAdoption(env);
988
+ env.writeDisk('a.txt', 'C');
989
+ chmodSync(env.disk('a.txt'), 0o000);
990
+ try {
991
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
992
+ }
993
+ finally {
994
+ chmodSync(env.disk('a.txt'), 0o644);
995
+ }
996
+ const e = await entryOf(m, 'a.txt');
997
+ assert.equal(e?.state, 'conflict', 'disk C and the write B both changed since A: kept apart');
998
+ assert.equal(await contentOf(m, 'a.txt'), 'B');
999
+ });
1000
+ test('a failed agreement barrier stays owed: the next pass settles it before deciding', async (t) => {
1001
+ const env = new Env(t);
1002
+ const m = await env.open();
1003
+ await seedSynced(env, m, 'a.txt', 'A');
1004
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
1005
+ env.writeDisk('a.txt', 'B'); // D = S: an agreement, with no intent behind it
1006
+ const set = DiskAgreement.prototype.set;
1007
+ const storeSync = env.store.sync;
1008
+ let armed = false;
1009
+ let syncs = 0;
1010
+ DiskAgreement.prototype.set = function (...args) {
1011
+ set.apply(this, args);
1012
+ armed = true; // the next sync is the barrier that should make it durable
1013
+ };
1014
+ env.store.sync = function () {
1015
+ syncs++;
1016
+ if (armed) {
1017
+ armed = false;
1018
+ throw Object.assign(new Error('injected EIO at the barrier'), { code: 'EIO' });
1019
+ }
1020
+ storeSync.call(env.store);
1021
+ };
1022
+ try {
1023
+ assert.match(await refused(m, 'ls', { path: 'work' }), /injected EIO at the barrier/);
1024
+ assert.equal(m.agreement.needsBarrier, true, 'the obligation survives the failed pass');
1025
+ DiskAgreement.prototype.set = set;
1026
+ syncs = 0;
1027
+ await call(m, 'ls', { path: 'work' }); // nothing new to record
1028
+ assert.ok(syncs >= 1, 'the next pass still syncs');
1029
+ assert.equal(m.agreement.needsBarrier, false);
1030
+ }
1031
+ finally {
1032
+ DiskAgreement.prototype.set = set;
1033
+ env.store.sync = storeSync;
1034
+ }
1035
+ });
1036
+ for (const trigger of ['a scan', 'a materialize']) {
1037
+ test(`an agreement advance is durable before ${trigger} returns`, async (t) => {
1038
+ const env = new Env(t);
1039
+ const m = await env.open();
1040
+ await seedSynced(env, m, 'a.txt', 'A');
1041
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
1042
+ env.writeDisk('a.txt', 'B'); // D = S: agreement, with no intent behind it
1043
+ const events = [];
1044
+ const set = DiskAgreement.prototype.set;
1045
+ const storeSync = env.store.sync;
1046
+ DiskAgreement.prototype.set = function (...args) {
1047
+ set.apply(this, args);
1048
+ events.push(`set ${args[1]}`);
1049
+ };
1050
+ env.store.sync = function () {
1051
+ events.push('sync');
1052
+ storeSync.call(env.store);
1053
+ };
1054
+ try {
1055
+ if (trigger === 'a scan')
1056
+ await call(m, 'ls', { path: 'work' });
1057
+ else
1058
+ await call(m, 'materialize', {});
1059
+ }
1060
+ finally {
1061
+ DiskAgreement.prototype.set = set;
1062
+ env.store.sync = storeSync;
1063
+ }
1064
+ const last = events.lastIndexOf('set a.txt');
1065
+ assert.ok(last >= 0, `the agreement was recorded (${events.join(', ')})`);
1066
+ assert.ok(events.indexOf('sync', last + 1) > last, `a barrier follows it (${events.join(', ')})`);
1067
+ });
1068
+ }
1069
+ test('a push completed after a branch switch settles only the branch it planned on', async (t) => {
1070
+ const env = new Env(t);
1071
+ const m = await env.open();
1072
+ await call(m, 'write', { path: 'work/a.txt', content: 'A' }); // a draft disk never had
1073
+ const main = env.store.currentBranch().name;
1074
+ env.store.createBranch('other', main);
1075
+ env.store.switchBranch('other');
1076
+ await call(m, 'write', { path: 'work/a.txt', content: 'B' });
1077
+ env.writeDisk('a.txt', 'from the shell');
1078
+ assert.equal(await stateOf(m, 'a.txt'), 'conflict'); // other's own intent: a recorded conflict
1079
+ env.rmDisk('a.txt');
1080
+ const intents = m.intents.get('work');
1081
+ const otherBefore = intents.get('a.txt');
1082
+ env.store.switchBranch(main);
1083
+ const mount = m.mounts.get('work');
1084
+ const runtime = await m.runtime(mount);
1085
+ const pushed = await pushPaths(env.store, m.agreement, runtime, ['a.txt'], {
1086
+ beforeEffect: () => queueMicrotask(() => env.store.switchBranch('other')),
1087
+ });
1088
+ assert.equal(env.store.currentBranch().name, 'other', 'the switch landed while the push wrote');
1089
+ assert.deepEqual(pushed.written, ['a.txt']);
1090
+ assert.equal(env.readDisk('a.txt'), 'A');
1091
+ assert.deepEqual(intents.get('a.txt'), otherBefore, "the other branch's intent is untouched");
1092
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'content', 'the physical evidence is updated');
1093
+ env.store.switchBranch(main);
1094
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'its own branch settles by convergence');
1095
+ assert.equal(intents.get('a.txt'), null);
1096
+ });
1097
+ test("a push whose root is replaced while it observes records nothing it saw there", async (t) => {
1098
+ const env = new Env(t);
1099
+ const m = await env.open();
1100
+ await call(m, 'write', { path: 'work/a.txt', content: 'A' });
1101
+ await call(m, 'materialize', { path: 'work/a.txt' });
1102
+ await call(m, 'write', { path: 'work/a.txt', content: 'C' });
1103
+ const agreement = m.agreement;
1104
+ const agreed = agreement.get('work', 'a.txt');
1105
+ const rootReal = realpathSync(env.dir);
1106
+ const away = `${env.dir}-away`;
1107
+ const mount = m.mounts.get('work');
1108
+ const runtime = await m.runtime(mount);
1109
+ let replaced = false;
1110
+ // Once the push has checked the root, it's replaced by one holding the
1111
+ // workspace's draft, which the push then observes.
1112
+ const pushed = await withFsPromises('lstat', (lstat) => async (path, ...rest) => {
1113
+ if (!replaced && (String(path) === join(env.dir, 'a.txt') || String(path) === join(rootReal, 'a.txt'))) {
1114
+ replaced = true;
1115
+ renameSync(env.dir, away);
1116
+ mkdirSync(env.dir);
1117
+ writeFileSync(join(env.dir, 'a.txt'), 'C');
1118
+ }
1119
+ return lstat(path, ...rest);
1120
+ }, () => pushPaths(env.store, agreement, runtime, ['a.txt'], {}));
1121
+ assert.equal(replaced, true);
1122
+ assert.deepEqual(pushed.written, []);
1123
+ assert.match(JSON.stringify(pushed.skipped), /not the directory disk last agreed with/);
1124
+ assert.deepEqual(agreement.get('work', 'a.txt'), agreed, "the replacement's copy isn't recorded as agreed");
1125
+ assert.equal(readFileSync(join(away, 'a.txt'), 'utf8'), 'A');
1126
+ });
1127
+ test('a push whose write is refused untouched puts back the evidence its own planning settled, not the stale intent', async (t) => {
1128
+ if (IS_ROOT)
1129
+ return t.skip('root writes through file permissions');
1130
+ const env = new Env(t);
1131
+ const m = await env.open();
1132
+ await call(m, 'write', { path: 'work/a.txt', content: 'A' });
1133
+ await call(m, 'materialize', { path: 'work/a.txt' });
1134
+ await call(m, 'write', { path: 'work/a.txt', content: 'C' });
1135
+ const agreement = m.agreement;
1136
+ const agreed = agreement.get('work', 'a.txt');
1137
+ // An earlier push of C announced its write and stopped before the effect:
1138
+ // disk still holds A, which this push's planning resolves the intent to.
1139
+ agreement.intend('work', 'a.txt', {
1140
+ effect: 'disk', prior: agreed, expect: { kind: 'content', hash: sha('C') }, branchId: env.store.currentBranch().id,
1141
+ }, { durable: true });
1142
+ assert.equal(agreement.get('work', 'a.txt').kind, 'pending');
1143
+ const mount = m.mounts.get('work');
1144
+ const runtime = await m.runtime(mount);
1145
+ let pushed;
1146
+ try {
1147
+ pushed = await pushPaths(env.store, agreement, runtime, ['a.txt'], {
1148
+ beforeEffect: () => chmodSync(env.disk('a.txt'), 0o444),
1149
+ });
1150
+ }
1151
+ finally {
1152
+ chmodSync(env.disk('a.txt'), 0o644);
1153
+ }
1154
+ assert.deepEqual(pushed.written, []);
1155
+ assert.match(JSON.stringify(pushed.skipped), /not written/);
1156
+ const now = agreement.get('work', 'a.txt');
1157
+ assert.equal(now.kind, 'content', `the resolved evidence, not the stale intent: ${JSON.stringify(now)}`);
1158
+ assert.equal(now.hash, agreed.hash);
1159
+ assert.equal(env.readDisk('a.txt'), 'A');
1160
+ });
1161
+ test('a failed durability barrier is reported, and its intent stays pending', async (t) => {
1162
+ const env = new Env(t);
1163
+ const m = await env.open();
1164
+ await call(m, 'write', { path: 'work/a.txt', content: 'v1' });
1165
+ const probe = await open(join(env.root, 'probe'), 'w');
1166
+ const proto = Object.getPrototypeOf(probe);
1167
+ await probe.close();
1168
+ const sync = proto.sync;
1169
+ let calls = 0;
1170
+ proto.sync = async function () {
1171
+ calls++;
1172
+ throw Object.assign(new Error('injected EIO'), { code: 'EIO' });
1173
+ };
1174
+ let res;
1175
+ try {
1176
+ res = await call(m, 'materialize', {});
1177
+ }
1178
+ finally {
1179
+ proto.sync = sync;
1180
+ }
1181
+ assert.equal(calls, 1, 'the file barrier failed; nothing after it ran');
1182
+ assert.deepEqual(res.materialized, []);
1183
+ assert.match(res.skipped[0].reason, /a\.txt: could not make the write durable \(file fsync\): injected EIO/);
1184
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending', 'no completion was recorded');
1185
+ const entry = await entryOf(m, 'a.txt');
1186
+ assert.equal(entry?.state, 'synced', 'the next observation decides from disk');
1187
+ assert.match(entry?.note ?? '', /materialize that has not completed/);
1188
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending', 'and records nothing: only a push completes it');
1189
+ });
1190
+ test('a write into new directories syncs every directory up to the mount root before it completes', async (t) => {
1191
+ const env = new Env(t);
1192
+ const m = await env.open();
1193
+ await call(m, 'write', { path: 'work/a/b/c.txt', content: 'deep' });
1194
+ const { result, synced } = await withSyncs(env, () => false, () => call(m, 'materialize', {}));
1195
+ assert.deepEqual(result.materialized, [{ mount: 'work', path: 'a/b/c.txt' }]);
1196
+ for (const dir of ['a/b', 'a', ''])
1197
+ assert.ok(synced.includes(idOf(env.disk(dir))), `${dir || 'the mount root'} was synced`);
1198
+ assert.ok(synced.includes(idOf(env.disk('a/b/c.txt'))), 'and the file');
1199
+ });
1200
+ test('a retry after a failed directory barrier syncs the whole chain, including directories the failed attempt made', async (t) => {
1201
+ const env = new Env(t);
1202
+ const m = await env.open();
1203
+ await call(m, 'write', { path: 'work/a/b/c.txt', content: 'deep' });
1204
+ const failing = await withSyncs(env, (id) => existsSync(env.disk('a')) && id === idOf(env.disk('a')), () => call(m, 'materialize', {}));
1205
+ assert.deepEqual(failing.result.materialized, []);
1206
+ assert.match(failing.result.skipped[0].reason, /a\/b\/c\.txt: could not make the write durable \(directory fsync of a\): injected EIO/);
1207
+ assert.equal(m.agreement.get('work', 'a/b/c.txt').kind, 'pending');
1208
+ const retry = await withSyncs(env, () => false, () => call(m, 'materialize', {}));
1209
+ assert.deepEqual(retry.result.materialized, [{ mount: 'work', path: 'a/b/c.txt' }]);
1210
+ for (const dir of ['a/b', 'a', ''])
1211
+ assert.ok(retry.synced.includes(idOf(env.disk(dir))), `${dir || 'the mount root'} was synced again`);
1212
+ assert.equal(m.agreement.get('work', 'a/b/c.txt').kind, 'content', 'completed by the push that confirmed it');
1213
+ });
1214
+ describe('only a push completes a push', () => {
1215
+ /** A push of a.txt whose directory barrier failed: the file holds v2, its intent pending. */
1216
+ async function unconfirmedPush(env, m, path) {
1217
+ const res = await withSyncs(env, (_id, isDirectory) => isDirectory, () => call(m, 'materialize', {}));
1218
+ assert.match(res.result.skipped[0].reason, /could not make the write durable \(directory fsync of the mount root\)/);
1219
+ const pending = m.agreement.get('work', path);
1220
+ assert.equal(pending.kind, 'pending');
1221
+ return pending;
1222
+ }
1223
+ test('a listing does not complete it, so losing its unbarriered write keeps the draft', async (t) => {
1224
+ const env = new Env(t);
1225
+ const m = await env.open();
1226
+ await seedSynced(env, m, 'a.txt', 'v1');
1227
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
1228
+ const pending = await unconfirmedPush(env, m, 'a.txt');
1229
+ const entry = await entryOf(m, 'a.txt');
1230
+ assert.equal(entry?.state, 'synced');
1231
+ assert.match(entry?.note ?? '', /materialize that has not completed/);
1232
+ assert.deepEqual(m.agreement.get('work', 'a.txt'), pending, 'exactly as the push left it');
1233
+ env.writeDisk('a.txt', 'v1'); // the write never reached stable storage
1234
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft');
1235
+ assert.equal(await contentOf(m, 'a.txt'), 'v2', 'the draft is kept, not replaced by the older disk version');
1236
+ });
1237
+ test("a new file's store origin survives a listing, so losing the file leaves a draft", async (t) => {
1238
+ const env = new Env(t);
1239
+ const m = await env.open();
1240
+ await call(m, 'write', { path: 'work/new.txt', content: 'new' });
1241
+ const pending = await unconfirmedPush(env, m, 'new.txt');
1242
+ assert.equal(await stateOf(m, 'new.txt'), 'synced');
1243
+ assert.deepEqual(m.agreement.get('work', 'new.txt'), pending);
1244
+ assert.equal(m.intents.get('work').get('new.txt')?.origin, 'store');
1245
+ env.rmDisk('new.txt');
1246
+ assert.equal(await stateOf(m, 'new.txt'), 'workspace-draft');
1247
+ assert.equal(await contentOf(m, 'new.txt'), 'new');
1248
+ });
1249
+ test('the next materialize redoes it, barriers and all, and completes it', async (t) => {
1250
+ const env = new Env(t);
1251
+ const m = await env.open();
1252
+ await seedSynced(env, m, 'a.txt', 'v1');
1253
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
1254
+ await unconfirmedPush(env, m, 'a.txt');
1255
+ const status = await call(m, 'status', {});
1256
+ assert.equal(status.work.pendingChanges, 1, 'still owed');
1257
+ const retry = await withSyncs(env, () => false, () => call(m, 'materialize', {}));
1258
+ assert.deepEqual(retry.result.materialized, [{ mount: 'work', path: 'a.txt' }]);
1259
+ assert.ok(retry.synced.includes(idOf(env.disk('a.txt'))), 'the file was written and synced again');
1260
+ assert.ok(retry.synced.includes(idOf(env.dir)), 'and its directory');
1261
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'content');
1262
+ assert.equal((await entryOf(m, 'a.txt'))?.note, undefined);
1263
+ assert.equal((await call(m, 'status', {})).work.pendingChanges, 0);
1264
+ });
1265
+ test('a sync of the path chooses disk instead, replacing the pending push', async (t) => {
1266
+ const env = new Env(t);
1267
+ const m = await env.open();
1268
+ await seedSynced(env, m, 'a.txt', 'v1');
1269
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
1270
+ await unconfirmedPush(env, m, 'a.txt');
1271
+ env.writeDisk('a.txt', 'v3, from the shell');
1272
+ await call(m, 'sync', { path: 'work/a.txt' });
1273
+ const p = m.agreement.get('work', 'a.txt');
1274
+ assert.equal(p.kind, 'content', 'an adoption, not a completion');
1275
+ assert.equal(await contentOf(m, 'a.txt'), 'v3, from the shell');
1276
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
1277
+ });
1278
+ test('a branch without the file neither replays its bytes nor unlinks it, and keeps it pending', async (t) => {
1279
+ const env = new Env(t);
1280
+ const m = await env.open();
1281
+ const main = env.store.currentBranch().name;
1282
+ env.store.createBranch('before-new', main);
1283
+ await call(m, 'write', { path: 'work/new.txt', content: 'new' });
1284
+ const pending = await unconfirmedPush(env, m, 'new.txt');
1285
+ env.store.switchBranch('before-new');
1286
+ assert.equal(await stateOf(m, 'new.txt'), 'not-in-branch');
1287
+ for (const input of [{}, { applyDeletions: true }, { force: true }]) {
1288
+ const res = await call(m, 'materialize', input);
1289
+ assert.deepEqual(res.materialized, []);
1290
+ assert.equal(res.deleted, undefined);
1291
+ }
1292
+ assert.equal(env.readDisk('new.txt'), 'new');
1293
+ assert.deepEqual(m.agreement.get('work', 'new.txt'), pending);
1294
+ env.store.switchBranch(main);
1295
+ const res = await call(m, 'materialize', {});
1296
+ assert.deepEqual(res.materialized, [{ mount: 'work', path: 'new.txt' }]);
1297
+ assert.equal(m.agreement.get('work', 'new.txt').kind, 'content');
1298
+ });
1299
+ test('confirming an unlink never removes a file that appeared since, force or not', async (t) => {
1300
+ const env = new Env(t);
1301
+ const m = await env.open();
1302
+ await seedSynced(env, m, 'gone.txt', 'v1');
1303
+ await call(m, 'delete', { path: 'work/gone.txt' });
1304
+ await withSyncs(env, (_id, isDirectory) => isDirectory, () => call(m, 'materialize', { applyDeletions: true }));
1305
+ const pending = m.agreement.get('work', 'gone.txt');
1306
+ assert.equal(pending.kind, 'pending');
1307
+ const mount = m.mounts.get('work');
1308
+ const pushed = await pushPaths(env.store, m.agreement, await m.runtime(mount), ['gone.txt'], {
1309
+ force: true,
1310
+ beforeEffect: () => writeFileSync(env.disk('gone.txt'), 'a new file from the shell'),
1311
+ });
1312
+ assert.deepEqual(pushed.deleted, []);
1313
+ assert.match(pushed.skipped[0].reason, /^not unlinked: a file appeared at this path since it was checked$/);
1314
+ assert.equal(env.readDisk('gone.txt'), 'a new file from the shell');
1315
+ assert.deepEqual(m.agreement.get('work', 'gone.txt'), pending, 'still owed, as before');
1316
+ });
1317
+ test('an unlink whose directory is gone is confirmed by syncing the directory that lacks it', async (t) => {
1318
+ const env = new Env(t);
1319
+ const m = await env.open();
1320
+ await seedSynced(env, m, 'sub/gone.txt', 'v1');
1321
+ await call(m, 'delete', { path: 'work/sub/gone.txt' });
1322
+ await withSyncs(env, (_id, isDirectory) => isDirectory, () => call(m, 'materialize', { applyDeletions: true }));
1323
+ assert.equal(m.agreement.get('work', 'sub/gone.txt').kind, 'pending');
1324
+ rmSync(env.disk('sub'), { recursive: true });
1325
+ const retry = await withSyncs(env, () => false, () => call(m, 'materialize', {}));
1326
+ assert.deepEqual(retry.result.deleted, [{ mount: 'work', path: 'sub/gone.txt' }]);
1327
+ assert.ok(retry.synced.includes(idOf(env.dir)), "the root, where the directory's removal is recorded, was synced");
1328
+ assert.deepEqual(m.agreement.get('work', 'sub/gone.txt'), { kind: 'absent' });
1329
+ });
1330
+ test('an unlink whose barrier failed is confirmed by the next materialize, not by a listing', async (t) => {
1331
+ const env = new Env(t);
1332
+ const m = await env.open();
1333
+ await seedSynced(env, m, 'gone.txt', 'v1');
1334
+ await call(m, 'delete', { path: 'work/gone.txt' });
1335
+ const res = await withSyncs(env, (_id, isDirectory) => isDirectory, () => call(m, 'materialize', { applyDeletions: true }));
1336
+ assert.match(res.result.skipped[0].reason, /could not make the unlink durable \(directory fsync of the mount root\)/);
1337
+ assert.throws(() => env.readDisk('gone.txt'), /ENOENT/);
1338
+ assert.equal(m.agreement.get('work', 'gone.txt').kind, 'pending');
1339
+ await listing(m);
1340
+ assert.equal(m.agreement.get('work', 'gone.txt').kind, 'pending', 'a listing records nothing');
1341
+ let status = (await call(m, 'status', {})).work;
1342
+ assert.deepEqual([status.pendingChanges, status.pendingWorkspaceDeletions], [1, 1], 'still owed, and said so');
1343
+ const retry = await withSyncs(env, () => false, () => call(m, 'materialize', {}));
1344
+ assert.deepEqual(retry.result.deleted, [{ mount: 'work', path: 'gone.txt' }], 'confirmed without applyDeletions: it already reached disk');
1345
+ assert.ok(retry.synced.includes(idOf(env.dir)));
1346
+ assert.deepEqual(m.agreement.get('work', 'gone.txt'), { kind: 'absent' });
1347
+ status = (await call(m, 'status', {})).work;
1348
+ assert.deepEqual([status.pendingChanges, status.pendingWorkspaceDeletions], [0, 0]);
1349
+ });
1350
+ });
1351
+ test('a directory that cannot be synced on its filesystem does not fail the write', async (t) => {
1352
+ const env = new Env(t);
1353
+ const m = await env.open();
1354
+ await call(m, 'write', { path: 'work/a.txt', content: 'v1' });
1355
+ const probe = await open(join(env.root, 'probe'), 'w');
1356
+ const proto = Object.getPrototypeOf(probe);
1357
+ await probe.close();
1358
+ const sync = proto.sync;
1359
+ let calls = 0;
1360
+ proto.sync = async function () {
1361
+ calls++;
1362
+ if (calls === 2)
1363
+ throw Object.assign(new Error('fsync of a directory: EINVAL'), { code: 'EINVAL' });
1364
+ return sync.call(this);
1365
+ };
1366
+ let res;
1367
+ try {
1368
+ res = await call(m, 'materialize', {});
1369
+ }
1370
+ finally {
1371
+ proto.sync = sync;
1372
+ }
1373
+ assert.deepEqual(res.materialized, [{ mount: 'work', path: 'a.txt' }]);
1374
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'content');
1375
+ });
1376
+ test('a torn record tail after a materialize, then branch selection', async (t) => {
1377
+ const env = new Env(t);
1378
+ let m = await env.open();
1379
+ await seedSynced(env, m, 'a.txt', 'v1');
1380
+ const main = env.store.currentBranch().name;
1381
+ env.store.createBranch('before-v2', main);
1382
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
1383
+ await call(m, 'materialize', {});
1384
+ await env.shutdown(m);
1385
+ const log = join(env.storePath, 'records.log');
1386
+ truncateSync(log, statSync(log).size - 5); // tears the completion
1387
+ env.store = JsStore.openOrCreate({ path: env.storePath });
1388
+ m = await env.open();
1389
+ env.store.switchBranch('before-v2');
1390
+ assert.equal(await stateOf(m, 'a.txt'), 'workspace-draft', 'the intent is decided by disk on any branch');
1391
+ env.store.switchBranch(main);
1392
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
1393
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'pending', 'and completed only by a push');
1394
+ await call(m, 'materialize', {});
1395
+ assert.equal(m.agreement.get('work', 'a.txt').kind, 'content');
1396
+ });
1397
+ test("#169's saved baselines aren't evidence: an empty mountpoint at the next start deletes nothing, and agreement records what disk still holds", async (t) => {
1398
+ // A store that ran #169: entries with no disk-agreement evidence, and the
1399
+ // stop-time baselines it saved in module state, which name no root.
1400
+ const env = new Env(t);
1401
+ await env.shutdown(await env.open()); // the mount's tree state exists, as #169 left it
1402
+ env.store = JsStore.openOrCreate({ path: env.storePath });
1403
+ env.legacyEntry('a.txt', 'alpha');
1404
+ env.legacyEntry('b.txt', 'beta');
1405
+ const saved = { mounts: { work: { lastMaterializedSeq: 1, materializedHashes: { 'a.txt': sha('alpha'), 'b.txt': sha('beta') }, refusedPaths: [] } } };
1406
+ // The drive isn't mounted yet: its mountpoint is an empty directory.
1407
+ const m = await env.openRestarted(saved);
1408
+ const listed = await listing(m);
1409
+ for (const path of ['a.txt', 'b.txt']) {
1410
+ assert.equal(listed.get(path)?.state, 'disk-missing-provenance-unknown', `${path} is kept, not adopted as a deletion`);
1411
+ }
1412
+ assert.equal(await contentOf(m, 'a.txt'), 'alpha');
1413
+ assert.equal(await contentOf(m, 'b.txt'), 'beta');
1414
+ // The drive comes back: what disk agrees with is recorded, root first.
1415
+ env.writeDisk('a.txt', 'alpha');
1416
+ env.writeDisk('b.txt', 'beta, edited in the shell');
1417
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
1418
+ assert.equal((await entryOf(m, 'b.txt'))?.conflict?.kind, 'unknown-provenance', 'a differing copy is a conflict, never adopted from a baseline');
1419
+ assert.equal(await contentOf(m, 'b.txt'), 'beta');
1420
+ });
1421
+ test('evidence checkpoints and replays; a moved mount root discards it', (t) => {
1422
+ const env = new Env(t);
1423
+ const agreement = new DiskAgreement(env.store);
1424
+ agreement.open([{ name: 'work', root: '/srv/work' }]);
1425
+ for (let i = 0; i < 1100; i++)
1426
+ agreement.set('work', `f${i}.txt`, { kind: 'content', hash: `h${i}`, size: i });
1427
+ agreement.maybeCheckpoint();
1428
+ agreement.set('work', 'after.txt', { kind: 'absent' });
1429
+ const replayed = new DiskAgreement(env.store);
1430
+ replayed.open([{ name: 'work', root: '/srv/work' }]);
1431
+ assert.deepEqual(replayed.get('work', 'f1099.txt'), { kind: 'content', hash: 'h1099', size: 1099 });
1432
+ assert.deepEqual(replayed.get('work', 'after.txt'), { kind: 'absent' });
1433
+ const respelled = new DiskAgreement(env.store);
1434
+ respelled.open([{ name: 'work', root: '/srv/work/' }]);
1435
+ assert.deepEqual(respelled.get('work', 'f1.txt'), { kind: 'content', hash: 'h1', size: 1 }, 'the same path spelled with a trailing slash keeps its evidence');
1436
+ const moved = new DiskAgreement(env.store);
1437
+ moved.open([{ name: 'work', root: '/srv/elsewhere' }]);
1438
+ assert.equal(moved.get('work', 'f1.txt'), undefined, 'evidence about another directory says nothing here');
1439
+ });
1440
+ });
1441
+ describe('fingerprints', () => {
1442
+ test('a recorded fingerprint vouches only for an unchanged, non-racy file on a ctime-keeping filesystem', () => {
1443
+ const fp = { size: 4, mtimeNs: '1000000000000', ctimeNs: '1000000000000', ino: '7', dev: '9' };
1444
+ const old = { ...fp, hashedAt: 1_000_000 + 5_000 };
1445
+ assert.equal(fingerprintVouches(old, fp, true), true);
1446
+ assert.equal(fingerprintVouches(old, fp, false), false, 'an unlisted filesystem type always rehashes');
1447
+ assert.equal(fingerprintVouches({ ...fp, hashedAt: 1_000_000 + 1_000 }, fp, true), false, 'hashed inside the racy window');
1448
+ assert.equal(fingerprintVouches(old, { ...fp, ctimeNs: '1000000000001' }, true), false, 'any field differing');
1449
+ assert.equal(fingerprintVouches(undefined, fp, true), false);
1450
+ });
1451
+ test('the ctime trust gate takes exactly the listed filesystems, and only on Linux', async (t) => {
1452
+ for (const trusted of [0xef53, 0x58465342, 0x9123683e, 0x01021994, 0xf2f52010, 0x2fc12fc1, 0x794c7630]) {
1453
+ assert.equal(fsTypeTrustsCtime(trusted), true, trusted.toString(16));
1454
+ }
1455
+ // NFS, FUSE, SMB2 and 9p maintain no ctime it can trust.
1456
+ for (const untrusted of [0x6969, 0x65735546, 0xfe534d42, 0x01021997]) {
1457
+ assert.equal(fsTypeTrustsCtime(untrusted), false, untrusted.toString(16));
1458
+ }
1459
+ assert.equal(fsTypeTrustsCtime(BigInt(0xef53)), true, 'a bigint f_type reads the same');
1460
+ const env = new Env(t);
1461
+ const expected = process.platform === 'linux' && fsTypeTrustsCtime((await statfs(env.dir)).type);
1462
+ assert.equal(await filesystemTrustsCtime(env.dir), expected);
1463
+ });
1464
+ // The racy window itself is pinned by fingerprintVouches' unit assertions
1465
+ // above: reaching it here would take a write that leaves mtime and ctime
1466
+ // unchanged, which no restore can do, since ctime can't be set.
1467
+ test('no restored timestamp hides a change: utimes, cp -p, and a write right after a hash', async (t) => {
1468
+ const env = new Env(t);
1469
+ const m = await env.open();
1470
+ await seedSynced(env, m, 'f.txt', 'aaaa');
1471
+ await sleep(2100);
1472
+ await call(m, 'ls', { path: 'work' }); // records a fingerprint old enough to vouch
1473
+ const before = statSync(env.disk('f.txt'));
1474
+ env.writeDisk('f.txt', 'bbbb'); // same size
1475
+ utimesSync(env.disk('f.txt'), before.atime, before.mtime);
1476
+ assert.equal(await contentOf(m, 'f.txt'), 'aaaa', 'the read serves the store');
1477
+ assert.equal(await stateOf(m, 'f.txt'), 'synced');
1478
+ assert.equal(await contentOf(m, 'f.txt'), 'bbbb', 'utimes restoring mtime: rehashed and adopted');
1479
+ await sleep(2100);
1480
+ await call(m, 'ls', { path: 'work' });
1481
+ env.writeDisk(join('..', 'src.txt'), 'cccc');
1482
+ utimesSync(join(env.root, 'src.txt'), before.atime, before.mtime);
1483
+ const cp = spawnSync('cp', ['-p', join(env.root, 'src.txt'), env.disk('f.txt')]);
1484
+ assert.equal(cp.status, 0, `cp -p: ${cp.stderr}`);
1485
+ assert.equal(await stateOf(m, 'f.txt'), 'synced');
1486
+ assert.equal(await contentOf(m, 'f.txt'), 'cccc', 'cp -p: rehashed and adopted');
1487
+ env.writeDisk('f.txt', 'dddd'); // just after the last hash
1488
+ assert.equal(await stateOf(m, 'f.txt'), 'synced');
1489
+ assert.equal(await contentOf(m, 'f.txt'), 'dddd');
1490
+ });
1491
+ });
1492
+ describe('trust and identity at the point of use', () => {
1493
+ /** A view of `dir` as the effects and reads take one. */
1494
+ const viewOf = (dir, extra = {}) => ({ root: dir, followSymlinks: false, maxFileSize: 1 << 20, ignore: [], ...extra });
1495
+ test('a fingerprint vouches only for a file on the filesystem whose ctime was checked', async (t) => {
1496
+ const env = new Env(t);
1497
+ const m = await env.open();
1498
+ await seedSynced(env, m, 'f.txt', 'aaaa');
1499
+ env.writeDisk('f.txt', 'bbbb'); // same size: only a read tells
1500
+ const st = statSync(env.disk('f.txt'), { bigint: true });
1501
+ const fp = { size: 4, mtimeNs: st.mtimeNs.toString(), ctimeNs: st.ctimeNs.toString(), ino: st.ino.toString(), dev: st.dev.toString() };
1502
+ const newest = Number((st.mtimeNs > st.ctimeNs ? st.mtimeNs : st.ctimeNs) / 1000000n);
1503
+ // Evidence whose fingerprint matches the file as it is now, with the old
1504
+ // bytes' hash: only a fingerprint that vouches would keep it.
1505
+ m.agreement.set('work', 'f.txt', { kind: 'content', hash: sha('aaaa'), size: 4, fp: { ...fp, hashedAt: newest + 5_000 } });
1506
+ const rootReal = realpathSync(env.dir);
1507
+ m.ctimeTrust.set(rootReal, st.dev.toString()); // the file's own filesystem was checked
1508
+ assert.equal(await stateOf(m, 'f.txt'), 'synced');
1509
+ assert.equal(await contentOf(m, 'f.txt'), 'aaaa', 'vouched for, so not read');
1510
+ m.ctimeTrust.set(rootReal, (st.dev + 1n).toString()); // another device's filesystem was
1511
+ assert.equal(await stateOf(m, 'f.txt'), 'synced');
1512
+ assert.equal(await contentOf(m, 'f.txt'), 'bbbb', 'a file on a filesystem never checked is read, and the change adopted');
1513
+ });
1514
+ test('a recheck of an unchanged file records nothing', async (t) => {
1515
+ const env = new Env(t);
1516
+ const m = await env.open();
1517
+ await seedSynced(env, m, 'f.txt', 'aaaa');
1518
+ m.ctimeTrust.set(realpathSync(env.dir), statSync(env.disk('f.txt'), { bigint: true }).dev.toString());
1519
+ const old = new Date(Date.now() - 60_000);
1520
+ utimesSync(env.disk('f.txt'), old, old);
1521
+ await sleep(2100); // past the racy window of the ctime that utimes set
1522
+ await call(m, 'sync', {}); // a full recheck rehashes, and records the fingerprint the stamps changed
1523
+ const agreement = m.agreement;
1524
+ const set = agreement.set.bind(agreement);
1525
+ let appended = 0;
1526
+ agreement.set = (...args) => { appended++; return set(...args); };
1527
+ try {
1528
+ await call(m, 'sync', {});
1529
+ await call(m, 'sync', {});
1530
+ }
1531
+ finally {
1532
+ agreement.set = set;
1533
+ }
1534
+ assert.equal(appended, 0, 'the same file, the same stamps: nothing to re-record');
1535
+ });
1536
+ test('an unlink never removes a file put at its path after the one it hashed', async (t) => {
1537
+ const env = new Env(t);
1538
+ env.writeDisk('a.txt', 'old');
1539
+ const target = env.disk('a.txt');
1540
+ const rootReal = realpathSync(env.dir);
1541
+ let replaced = false;
1542
+ const err = await withFsPromises('open', (original) => async (...args) => {
1543
+ const handle = await original(...args);
1544
+ if (!replaced && args[0] === join(rootReal, 'a.txt')) {
1545
+ replaced = true; // after the hash's descriptor opened: a new file takes the name
1546
+ writeFileSync(join(env.root, 'swap'), 'new');
1547
+ renameSync(join(env.root, 'swap'), target);
1548
+ }
1549
+ return handle;
1550
+ }, () => unlinkContained(viewOf(env.dir), rootReal, 'a.txt', { kind: 'content', hash: sha('old') }).then(() => null, (e) => e));
1551
+ assert.ok(replaced, 'the swap ran');
1552
+ assert.ok(err instanceof EffectFailed, `refused: ${err}`);
1553
+ assert.equal(readFileSync(target, 'utf8'), 'new', 'the file now at the path is untouched');
1554
+ });
1555
+ test('an unlink refused outright touched nothing, so its evidence goes back at once', { skip: IS_ROOT ? 'root unlinks anywhere' : false }, async (t) => {
1556
+ const env = new Env(t);
1557
+ const m = await env.open();
1558
+ await seedSynced(env, m, 'locked/a.txt', 'v1');
1559
+ await call(m, 'delete', { path: 'work/locked/a.txt' });
1560
+ chmodSync(env.disk('locked'), 0o555);
1561
+ let data;
1562
+ try {
1563
+ data = await call(m, 'materialize', { applyDeletions: true });
1564
+ }
1565
+ finally {
1566
+ chmodSync(env.disk('locked'), 0o755);
1567
+ }
1568
+ assert.match(JSON.stringify(data), /not unlinked: could not unlink the file/, `reported as untouched: ${JSON.stringify(data)}`);
1569
+ assert.equal(m.agreement.get('work', 'locked/a.txt').kind, 'content', 'the evidence is as it was, not pending');
1570
+ assert.equal(env.readDisk('locked/a.txt'), 'v1');
1571
+ });
1572
+ test('a FIFO swapped in at a path neither blocks a read nor a write', { skip: process.platform === 'win32' ? 'no FIFOs' : false }, async (t) => {
1573
+ const env = new Env(t);
1574
+ const rootReal = realpathSync(env.dir);
1575
+ const swapIn = (path) => {
1576
+ rmSync(path);
1577
+ assert.equal(spawnSync('mkfifo', [path]).status, 0, 'mkfifo');
1578
+ };
1579
+ const bounded = (what, run) => Promise.race([
1580
+ run,
1581
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} blocked on the FIFO`)), 3000)),
1582
+ ]);
1583
+ for (const how of ['read', 'write']) {
1584
+ env.writeDisk(`${how}.txt`, 'regular');
1585
+ const path = join(rootReal, `${how}.txt`);
1586
+ // The read opens the path under the view's root as given; where the
1587
+ // temp directory is reached through a symlink (macOS's /var), that
1588
+ // isn't the real path.
1589
+ const named = join(env.dir, `${how}.txt`);
1590
+ let swapped = false;
1591
+ const outcome = await withFsPromises('open', (original) => async (...args) => {
1592
+ if (!swapped && (args[0] === path || args[0] === named)) {
1593
+ swapped = true; // between any lstat and this open
1594
+ swapIn(path);
1595
+ }
1596
+ return original(...args);
1597
+ }, () => bounded(how, how === 'read'
1598
+ ? readPath(viewOf(env.dir), rootReal, 'read.txt', 1 << 20)
1599
+ : writeContained(viewOf(env.dir), rootReal, 'write.txt', Buffer.from('x'), { kind: 'any' }).then(() => 'written', (e) => e)));
1600
+ assert.ok(swapped, `${how}: the swap ran`);
1601
+ if (how === 'read')
1602
+ assert.deepEqual(outcome, { kind: 'unobserved', reason: 'not a regular file' });
1603
+ else
1604
+ assert.ok(outcome instanceof EffectFailed && /not a regular file/.test(outcome.message), `write refused: ${outcome}`);
1605
+ }
1606
+ });
1607
+ /** A root identified as it is now, and a way to replace it: renamed away, with an empty `sub` in its place. */
1608
+ const replaceable = (env) => {
1609
+ mkdirSync(env.disk('sub'), { recursive: true });
1610
+ const st = statSync(env.dir, { bigint: true });
1611
+ const view = viewOf(env.dir, { rootIdentity: { dev: st.dev.toString(), ino: st.ino.toString() } });
1612
+ const away = `${env.dir}-away`;
1613
+ const replace = () => {
1614
+ renameSync(env.dir, away);
1615
+ mkdirSync(join(env.dir, 'sub'), { recursive: true });
1616
+ };
1617
+ return { view, rootReal: realpathSync(env.dir), away, replace };
1618
+ };
1619
+ const isSub = (env, rootReal, path) => String(path) === join(env.dir, 'sub') || String(path) === join(rootReal, 'sub');
1620
+ test('a write whose root is replaced after it was checked, before its parent is found, writes nothing there', async (t) => {
1621
+ const env = new Env(t);
1622
+ const { view, rootReal, replace } = replaceable(env);
1623
+ let replaced = false;
1624
+ const err = await withFsPromises('realpath', (realpath) => async (path, ...rest) => {
1625
+ if (!replaced && isSub(env, rootReal, path)) {
1626
+ replaced = true;
1627
+ replace();
1628
+ }
1629
+ return realpath(path, ...rest);
1630
+ }, () => writeContained(view, rootReal, 'sub/a.txt', Buffer.from('content'), { kind: 'any' }).then(() => null, (e) => e));
1631
+ assert.equal(replaced, true);
1632
+ assert.ok(err instanceof EffectFailed && /not the directory disk last agreed with/.test(err.message), `refused, naming the root: ${err}`);
1633
+ const there = join(env.dir, 'sub/a.txt');
1634
+ assert.equal(existsSync(there) ? readFileSync(there, 'utf8') : '', '', 'no content reached the replacement');
1635
+ });
1636
+ test("an edit made while a forced write is checked isn't overwritten", async (t) => {
1637
+ const env = new Env(t);
1638
+ env.writeDisk('a.txt', 'before');
1639
+ const rootReal = realpathSync(env.dir);
1640
+ const target = join(rootReal, 'a.txt');
1641
+ let edited = false;
1642
+ const err = await withFsPromises('realpath', (realpath) => async (path, ...rest) => {
1643
+ if (!edited && String(path) === target) {
1644
+ edited = true;
1645
+ writeFileSync(target, 'before, and an edit');
1646
+ }
1647
+ return realpath(path, ...rest);
1648
+ }, () => writeContained(viewOf(env.dir), rootReal, 'a.txt', Buffer.from('overwrite'), { kind: 'any' }).then(() => null, (e) => e));
1649
+ assert.equal(edited, true);
1650
+ assert.ok(err instanceof EffectFailed && !err.touched, `refused untouched: ${err}`);
1651
+ assert.equal(readFileSync(target, 'utf8'), 'before, and an edit');
1652
+ });
1653
+ test("an edit made after a file was hashed for its unlink isn't deleted", async (t) => {
1654
+ const env = new Env(t);
1655
+ mkdirSync(env.disk('sub'), { recursive: true });
1656
+ env.writeDisk('sub/a.txt', 'old');
1657
+ const rootReal = realpathSync(env.dir);
1658
+ const target = join(rootReal, 'sub/a.txt');
1659
+ let subChecks = 0;
1660
+ let edited = false;
1661
+ // The parent is resolved once to find it and again to check it is still
1662
+ // there, which comes after the hash and before the unlink: the edit lands
1663
+ // in between.
1664
+ const err = await withFsPromises('realpath', (realpath) => async (path, ...rest) => {
1665
+ if (isSub(env, rootReal, path) && ++subChecks === 2) {
1666
+ edited = true;
1667
+ writeFileSync(target, 'old, edited');
1668
+ }
1669
+ return realpath(path, ...rest);
1670
+ }, () => unlinkContained(viewOf(env.dir), rootReal, 'sub/a.txt', { kind: 'content', hash: createHash('sha256').update('old').digest('hex') })
1671
+ .then(() => null, (e) => e));
1672
+ assert.equal(edited, true);
1673
+ assert.ok(err instanceof EffectFailed && !err.touched, `refused untouched: ${err}`);
1674
+ assert.equal(readFileSync(target, 'utf8'), 'old, edited');
1675
+ });
1676
+ test('a directory sync under a root replaced before the directory was found reports it, not a success', async (t) => {
1677
+ const env = new Env(t);
1678
+ const { view, rootReal, replace } = replaceable(env);
1679
+ let replaced = false;
1680
+ const failed = await withFsPromises('realpath', (realpath) => async (path, ...rest) => {
1681
+ if (!replaced && isSub(env, rootReal, path)) {
1682
+ replaced = true;
1683
+ replace();
1684
+ }
1685
+ return realpath(path, ...rest);
1686
+ }, () => syncDirectories(view, rootReal, ['sub']));
1687
+ assert.equal(replaced, true);
1688
+ assert.match(failed.get('sub') ?? '', /not the directory disk last agreed with/);
1689
+ });
1690
+ test("an effect under a replaced root acts on nothing, whatever its caller checked", async (t) => {
1691
+ const env = new Env(t);
1692
+ const rootReal = realpathSync(env.dir);
1693
+ const st = statSync(env.dir, { bigint: true });
1694
+ const view = viewOf(env.dir, { rootIdentity: { dev: st.dev.toString(), ino: st.ino.toString() } });
1695
+ renameSync(env.dir, join(env.root, 'unmounted'));
1696
+ mkdirSync(join(env.dir, 'sub'), { recursive: true }); // another directory where the drive was, with a same-named subdirectory
1697
+ const err = await writeContained(view, rootReal, 'sub/a.txt', Buffer.from('x'), { kind: 'absent' }).then(() => null, (e) => e);
1698
+ assert.ok(err instanceof EffectFailed, `refused: ${err}`);
1699
+ assert.equal(existsSync(join(env.dir, 'sub', 'a.txt')), false, 'nothing written in the replacement');
1700
+ });
1701
+ });
1702
+ describe('incomplete walks', () => {
1703
+ test('a permission-denied subtree deletes nothing beneath it', { skip: IS_ROOT ? 'root reads every directory' : false }, async (t) => {
1704
+ const env = new Env(t);
1705
+ const m = await env.open();
1706
+ await seedSynced(env, m, 'locked/a.txt', 'v1');
1707
+ chmodSync(env.disk('locked'), 0o000);
1708
+ try {
1709
+ const data = await call(m, 'ls', { path: 'work', recursive: true });
1710
+ const entry = data.entries.find((e) => e.path === 'locked/a.txt');
1711
+ assert.equal(entry?.state, 'unverified');
1712
+ assert.ok(data.incomplete.some((r) => r.path === 'locked' && /cannot list/.test(r.reason)));
1713
+ await call(m, 'sync', {});
1714
+ assert.notEqual(env.store.treeGet(TREE, 'locked/a.txt'), null, 'nothing unvisited is deleted');
1715
+ }
1716
+ finally {
1717
+ chmodSync(env.disk('locked'), 0o755);
1718
+ }
1719
+ });
1720
+ test('an ignored subtree deletes nothing beneath it, and untracked ignored files are not listed', async (t) => {
1721
+ const env = new Env(t);
1722
+ const m = await env.open({ ignore: ['cache'] });
1723
+ await seedSynced(env, m, 'cache/x.txt', 'tracked');
1724
+ env.rmDisk('cache/x.txt');
1725
+ env.writeDisk('cache/y.txt', 'untracked');
1726
+ const entries = await listing(m);
1727
+ assert.equal(entries.get('cache/x.txt')?.state, 'unverified');
1728
+ assert.equal(entries.has('cache/y.txt'), false);
1729
+ await call(m, 'sync', {});
1730
+ assert.notEqual(env.store.treeGet(TREE, 'cache/x.txt'), null);
1731
+ });
1732
+ test('a capped walk deletes nothing it did not reach', async (t) => {
1733
+ const env = new Env(t);
1734
+ const m = await env.open();
1735
+ for (const f of ['a.txt', 'b.txt', 'c.txt', 'd.txt'])
1736
+ await seedSynced(env, m, f, f);
1737
+ env.rmDisk('c.txt');
1738
+ const mount = m.mounts.get('work');
1739
+ const pass = await m.withMount(mount, () => m.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }, { cap: 2 }));
1740
+ assert.equal(pass.reports.get('c.txt')?.state, 'unverified');
1741
+ assert.ok(pass.incomplete.some((r) => /file cap/.test(r.reason)));
1742
+ assert.notEqual(env.store.treeGet(TREE, 'c.txt'), null);
1743
+ });
1744
+ });
1745
+ describe('the mount boundary', () => {
1746
+ test('a root replaced during an absence proof proves nothing: the replacement lacks what the original still holds', async (t) => {
1747
+ const env = new Env(t);
1748
+ mkdirSync(env.disk('sub'), { recursive: true });
1749
+ env.writeDisk('sub/a.txt', 'A');
1750
+ const st = statSync(env.dir, { bigint: true });
1751
+ const view = { root: env.dir, rootIdentity: { dev: String(st.dev), ino: String(st.ino) }, followSymlinks: false, maxFileSize: 1e6, ignore: [] };
1752
+ const away = `${env.dir}-away`;
1753
+ // The code checks the root by its resolved path (on macOS the temp
1754
+ // directory is under /var, a symlink to /private/var).
1755
+ const rootReal = realpathSync(env.dir);
1756
+ let swapped = false;
1757
+ // Once the root has been checked, it's renamed away and an empty one put
1758
+ // in its place, with the same subdirectory.
1759
+ const proof = await withFsPromises('lstat', (lstat) => async (path, ...rest) => {
1760
+ const info = await lstat(path, ...rest);
1761
+ if (!swapped && String(path) === rootReal) {
1762
+ swapped = true;
1763
+ renameSync(env.dir, away);
1764
+ mkdirSync(join(env.dir, 'sub'), { recursive: true });
1765
+ }
1766
+ return info;
1767
+ }, () => confirmAbsent(view, rootReal, 'sub/a.txt'));
1768
+ assert.equal(swapped, true);
1769
+ assert.equal(typeof proof, 'string', `no absence is proven: ${JSON.stringify(proof)}`);
1770
+ assert.equal(readFileSync(join(away, 'sub/a.txt'), 'utf8'), 'A', 'the original still holds the file');
1771
+ });
1772
+ test('a root replaced after a walk listed it decides nothing: no deletion is taken from the replacement', async (t) => {
1773
+ const env = new Env(t);
1774
+ const m = await env.open();
1775
+ await call(m, 'write', { path: 'work/a/f.txt', content: 'F' });
1776
+ await call(m, 'materialize', { path: 'work/a/f.txt' });
1777
+ const rootReal = realpathSync(env.dir);
1778
+ const away = `${env.dir}-away`;
1779
+ let replaced = false;
1780
+ // Once the walk has listed the root, the root is replaced by one whose
1781
+ // `a` is empty: the walk then lists the replacement's `a`.
1782
+ const result = await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
1783
+ const entries = await readdir(path, ...rest);
1784
+ if (!replaced && String(path) === rootReal) {
1785
+ replaced = true;
1786
+ renameSync(env.dir, away);
1787
+ mkdirSync(join(env.dir, 'a'), { recursive: true });
1788
+ }
1789
+ return entries;
1790
+ }, () => call(m, 'sync', {}));
1791
+ assert.equal(replaced, true);
1792
+ assert.notEqual(env.store.treeGet('workspace/work/tree', 'a/f.txt'), null, 'the workspace keeps the file');
1793
+ assert.notEqual(m.agreement.get('work', 'a/f.txt')?.kind, 'absent');
1794
+ assert.match(JSON.stringify(result), /not the directory disk last agreed with/);
1795
+ assert.equal(readFileSync(join(away, 'a/f.txt'), 'utf8'), 'F');
1796
+ });
1797
+ test('a root replaced during a pass takes no deletion: the entry and its evidence stay', async (t) => {
1798
+ const env = new Env(t);
1799
+ const m = await env.open();
1800
+ await call(m, 'write', { path: 'work/sub/a.txt', content: 'A' });
1801
+ await call(m, 'materialize', { path: 'work/sub/a.txt' });
1802
+ const file = env.disk('sub/a.txt');
1803
+ const away = `${env.dir}-away`;
1804
+ // The pass observes paths under the root's resolved path (on macOS the
1805
+ // temp directory is under /var, a symlink to /private/var).
1806
+ const rootReal = realpathSync(env.dir);
1807
+ const fileReal = join(rootReal, 'sub/a.txt');
1808
+ unlinkSync(file);
1809
+ let restored = false;
1810
+ let swapped = false;
1811
+ // The pass finds the file gone, then it's put back, and the root is
1812
+ // replaced by one whose subdirectory lacks it before absence is proven.
1813
+ await withFsPromises('lstat', (lstat) => async (path, ...rest) => {
1814
+ let info;
1815
+ try {
1816
+ info = await lstat(path, ...rest);
1817
+ }
1818
+ catch (err) {
1819
+ if (!restored && String(path) === fileReal && err.code === 'ENOENT') {
1820
+ writeFileSync(file, 'A');
1821
+ restored = true;
1822
+ }
1823
+ throw err;
1824
+ }
1825
+ if (restored && !swapped && String(path) === rootReal) {
1826
+ renameSync(env.dir, away);
1827
+ mkdirSync(join(env.dir, 'sub'), { recursive: true });
1828
+ swapped = true;
1829
+ }
1830
+ return info;
1831
+ }, () => {
1832
+ // The pass a watcher's change to that path runs.
1833
+ const internals = m;
1834
+ return internals.passUnlocked(internals.mounts.get('work'), { kind: 'paths', paths: ['sub/a.txt'] });
1835
+ });
1836
+ assert.equal(restored, true);
1837
+ assert.equal(swapped, true);
1838
+ assert.notEqual(env.store.treeGet('workspace/work/tree', 'sub/a.txt'), null, 'the workspace keeps the file');
1839
+ assert.notEqual(m.agreement.get('work', 'sub/a.txt')?.kind, 'absent');
1840
+ assert.equal(readFileSync(join(away, 'sub/a.txt'), 'utf8'), 'A');
1841
+ });
1842
+ test('not even a forced materialize writes through a symlink or a symlinked directory out of the mount', async (t) => {
1843
+ const env = new Env(t);
1844
+ const m = await env.open();
1845
+ const outside = join(env.root, 'outside.txt');
1846
+ writeFileSync(outside, 'outside, untouched');
1847
+ mkdirSync(join(env.root, 'outdir'));
1848
+ await call(m, 'write', { path: 'work/link.txt', content: 'from the workspace' });
1849
+ await call(m, 'write', { path: 'work/linkdir/inner.txt', content: 'from the workspace' });
1850
+ symlinkSync(outside, env.disk('link.txt'));
1851
+ symlinkSync(join(env.root, 'outdir'), env.disk('linkdir'));
1852
+ const res = await call(m, 'materialize', { force: true });
1853
+ const reasons = (res.skipped ?? []).map((s) => s.reason).join('\n');
1854
+ assert.match(reasons, /link\.txt: not written: a symlink, which this mount does not follow/);
1855
+ assert.match(reasons, /linkdir\/inner\.txt: not written: a parent directory resolves outside the mount/);
1856
+ assert.equal(readFileSync(outside, 'utf8'), 'outside, untouched');
1857
+ assert.throws(() => readFileSync(join(env.root, 'outdir', 'inner.txt')), /ENOENT/);
1858
+ });
1859
+ test('a file with other hard links is never written in place, forced or not: its other names keep their content', async (t) => {
1860
+ const env = new Env(t);
1861
+ const m = await env.open();
1862
+ const outside = join(env.root, 'outside');
1863
+ mkdirSync(outside);
1864
+ writeFileSync(join(outside, 'secret.txt'), 'outside original');
1865
+ linkSync(join(outside, 'secret.txt'), env.disk('h.txt'));
1866
+ assert.equal(await stateOf(m, 'h.txt'), 'synced', 'a listing takes it in: reading through the name in the mount');
1867
+ await call(m, 'write', { path: 'work/h.txt', content: 'written by the workspace' });
1868
+ const before = m.agreement.get('work', 'h.txt');
1869
+ for (const force of [false, true]) {
1870
+ const res = await call(m, 'materialize', { force });
1871
+ assert.deepEqual(res.materialized, [], `force: ${force}`);
1872
+ const reasons = (res.skipped ?? []).map((s) => s.reason).join('\n');
1873
+ assert.match(reasons, /h\.txt: not written: a file with other hard links/, `force: ${force}`);
1874
+ assert.equal(readFileSync(join(outside, 'secret.txt'), 'utf8'), 'outside original', `force: ${force}`);
1875
+ assert.deepEqual(m.agreement.get('work', 'h.txt'), before, 'its evidence is exactly as before the intent');
1876
+ }
1877
+ assert.equal(await stateOf(m, 'h.txt'), 'workspace-draft', 'the draft is kept, still owed');
1878
+ assert.equal(await contentOf(m, 'h.txt'), 'written by the workspace');
1879
+ });
1880
+ test('an autoMaterialize write to a file linked elsewhere after it was taken in is refused, and the write says why', async (t) => {
1881
+ const env = new Env(t);
1882
+ const m = await env.open({ autoMaterialize: true });
1883
+ env.writeDisk('h.txt', 'shared original');
1884
+ assert.equal(await stateOf(m, 'h.txt'), 'synced');
1885
+ const outside = join(env.root, 'outside.txt');
1886
+ linkSync(env.disk('h.txt'), outside);
1887
+ const error = await refused(m, 'write', { path: 'work/h.txt', content: 'written by the workspace' });
1888
+ assert.match(error, /not written: a file with other hard links/);
1889
+ assert.equal(readFileSync(outside, 'utf8'), 'shared original');
1890
+ assert.equal(await contentOf(m, 'h.txt'), 'written by the workspace', 'the workspace keeps the write');
1891
+ });
1892
+ /** A push of `paths` whose `beforeEffect` runs between planning and the effect. */
1893
+ async function pushWith(env, m, paths, opts) {
1894
+ const mount = m.mounts.get('work');
1895
+ return pushPaths(env.store, m.agreement, await m.runtime(mount), paths, opts);
1896
+ }
1897
+ for (const force of [false, true]) {
1898
+ test(`a parent swapped out of the mount after planning gets nothing written outside it (force: ${force})`, async (t) => {
1899
+ const env = new Env(t);
1900
+ const m = await env.open();
1901
+ await seedSynced(env, m, 'sub/a.txt', 'original');
1902
+ await call(m, 'write', { path: 'work/sub/a.txt', content: 'draft' });
1903
+ const outside = join(env.root, 'outside');
1904
+ mkdirSync(outside);
1905
+ writeFileSync(join(outside, 'a.txt'), 'outside, untouched');
1906
+ const before = m.agreement.get('work', 'sub/a.txt');
1907
+ const pushed = await pushWith(env, m, ['sub/a.txt'], { force, beforeEffect: () => swapForSymlink(env, 'sub', outside) });
1908
+ assert.equal(readFileSync(join(outside, 'a.txt'), 'utf8'), 'outside, untouched');
1909
+ assert.deepEqual(pushed.written, []);
1910
+ assert.match(pushed.skipped[0].reason, /^not written: a parent directory resolves outside the mount$/);
1911
+ assert.deepEqual(m.agreement.get('work', 'sub/a.txt'), before, 'its evidence is exactly as before the intent');
1912
+ assert.equal(env.readDisk('saved/a.txt'), 'original');
1913
+ });
1914
+ }
1915
+ test('a workspace deletion is never applied through a parent swapped out of the mount after planning', async (t) => {
1916
+ const env = new Env(t);
1917
+ const m = await env.open();
1918
+ await seedSynced(env, m, 'sub/gone.txt', 'v1');
1919
+ await call(m, 'delete', { path: 'work/sub/gone.txt' });
1920
+ const outside = join(env.root, 'outside');
1921
+ mkdirSync(outside);
1922
+ writeFileSync(join(outside, 'gone.txt'), 'outside, untouched');
1923
+ const pushed = await pushWith(env, m, ['sub/gone.txt'], { applyDeletions: true, beforeEffect: () => swapForSymlink(env, 'sub', outside) });
1924
+ assert.equal(readFileSync(join(outside, 'gone.txt'), 'utf8'), 'outside, untouched');
1925
+ assert.deepEqual(pushed.deleted, []);
1926
+ assert.match(pushed.skipped[0].reason, /^not unlinked: a parent directory resolves outside the mount$/);
1927
+ });
1928
+ test('a disk edit made after planning is not overwritten without force, and reads as a conflict', async (t) => {
1929
+ const env = new Env(t);
1930
+ const m = await env.open();
1931
+ await seedSynced(env, m, 'a.txt', 'v1');
1932
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
1933
+ const before = m.agreement.get('work', 'a.txt');
1934
+ const pushed = await pushWith(env, m, ['a.txt'], { beforeEffect: () => writeFileSync(env.disk('a.txt'), 'edited meanwhile') });
1935
+ assert.equal(env.readDisk('a.txt'), 'edited meanwhile');
1936
+ assert.deepEqual(pushed.written, []);
1937
+ assert.match(pushed.skipped[0].reason, /^not written: disk changed since it was checked$/);
1938
+ assert.deepEqual(m.agreement.get('work', 'a.txt'), before);
1939
+ const entry = await entryOf(m, 'a.txt');
1940
+ assert.equal(entry?.state, 'conflict');
1941
+ assert.equal(entry?.conflict?.kind, 'both-changed', 'both sides changed since they last agreed');
1942
+ const forced = await pushWith(env, m, ['a.txt'], { force: true, beforeEffect: () => writeFileSync(env.disk('a.txt'), 'edited again') });
1943
+ assert.deepEqual(forced.written, ['a.txt'], 'force overrides freshness');
1944
+ assert.equal(env.readDisk('a.txt'), 'v2');
1945
+ });
1946
+ test('a file that appears after planning saw none is not overwritten', async (t) => {
1947
+ const env = new Env(t);
1948
+ const m = await env.open();
1949
+ await call(m, 'write', { path: 'work/new.txt', content: 'from the workspace' });
1950
+ const pushed = await pushWith(env, m, ['new.txt'], { beforeEffect: () => writeFileSync(env.disk('new.txt'), 'from the shell') });
1951
+ assert.equal(env.readDisk('new.txt'), 'from the shell');
1952
+ assert.match(pushed.skipped[0].reason, /^not written: something appeared at this path since it was checked$/);
1953
+ assert.equal(m.agreement.get('work', 'new.txt'), undefined, 'still unknown, as before the intent');
1954
+ assert.equal(await stateOf(m, 'new.txt'), 'conflict');
1955
+ });
1956
+ test('a hard link made after planning is caught where the write happens', async (t) => {
1957
+ const env = new Env(t);
1958
+ const m = await env.open();
1959
+ await seedSynced(env, m, 'a.txt', 'v1');
1960
+ await call(m, 'write', { path: 'work/a.txt', content: 'the workspace draft' });
1961
+ const outside = join(env.root, 'outside.txt');
1962
+ const before = m.agreement.get('work', 'a.txt');
1963
+ const pushed = await pushWith(env, m, ['a.txt'], { force: true, beforeEffect: () => linkSync(env.disk('a.txt'), outside) });
1964
+ assert.deepEqual(pushed.written, []);
1965
+ assert.match(pushed.skipped[0].reason, /^not written: a file with other hard links/);
1966
+ assert.equal(readFileSync(outside, 'utf8'), 'v1', 'the other name keeps its content');
1967
+ assert.deepEqual(m.agreement.get('work', 'a.txt'), before);
1968
+ });
1969
+ test('even a forced write never lands in a file renamed away after it was opened', async (t) => {
1970
+ const env = new Env(t);
1971
+ const m = await env.open();
1972
+ await seedSynced(env, m, 'a.txt', 'v1');
1973
+ await call(m, 'write', { path: 'work/a.txt', content: 'the workspace draft' });
1974
+ let swapped = false;
1975
+ const pushed = await withFsPromises('open', (open) => async (path, flags, ...rest) => {
1976
+ const handle = await open(path, flags, ...rest);
1977
+ if (!swapped && basename(String(path)) === 'a.txt' && typeof flags === 'number' && (flags & fsConstants.O_WRONLY) !== 0) {
1978
+ swapped = true; // renamed away once opened, and a new file put at its path
1979
+ renameSync(env.disk('a.txt'), env.disk('backup.txt'));
1980
+ writeFileSync(env.disk('a.txt'), 'a new disk edit');
1981
+ }
1982
+ return handle;
1983
+ }, () => pushWith(env, m, ['a.txt'], { force: true }));
1984
+ assert.equal(swapped, true);
1985
+ assert.deepEqual(pushed.written, []);
1986
+ assert.match(pushed.skipped[0].reason, /disk changed since it was checked/);
1987
+ assert.equal(env.readDisk('backup.txt'), 'v1', 'the renamed file is untouched');
1988
+ assert.equal(env.readDisk('a.txt'), 'a new disk edit', 'and so is the file now at the path');
1989
+ });
1990
+ test("a forced push of a stale path whose workspace copy is missing from the store says so", async (t) => {
1991
+ const env = new Env(t);
1992
+ let m = await env.open();
1993
+ await seedSynced(env, m, 'a.txt', 'v1');
1994
+ env.writeDisk('a.txt', 'v2, from the shell'); // S = P, D changed: a forced push would overwrite it
1995
+ await env.shutdown(m);
1996
+ rmSync(join(env.storePath, 'blobs', sha('v1').slice(0, 2), sha('v1'))); // the store lost the workspace copy
1997
+ env.store = JsStore.openOrCreate({ path: env.storePath });
1998
+ m = await env.open();
1999
+ const data = await call(m, 'materialize', { path: 'work/a.txt', force: true });
2000
+ assert.match(JSON.stringify(data), /the workspace copy is missing from the store/, `reported, not dropped: ${JSON.stringify(data)}`);
2001
+ assert.equal(env.readDisk('a.txt'), 'v2, from the shell');
2002
+ });
2003
+ test("an interrupted path's refusal says what disk may hold, and that force writes the workspace copy", async (t) => {
2004
+ const env = new Env(t);
2005
+ let m = await env.open();
2006
+ await seedSynced(env, m, 'a.txt', 'v1');
2007
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
2008
+ m = await env.fault(m, 'materialize', 'after-intent');
2009
+ env.writeDisk('a.txt', 'v2, cut off'); // neither what was being written nor what disk agreed with
2010
+ assert.equal((await entryOf(m, 'a.txt'))?.conflict?.kind, 'interrupted');
2011
+ const data = await call(m, 'materialize', {});
2012
+ const skip = JSON.stringify(data);
2013
+ assert.match(skip, /match neither what was being written there nor what disk last agreed with/);
2014
+ assert.match(skip, /pass force to write the workspace copy over it/);
2015
+ assert.equal(env.readDisk('a.txt'), 'v2, cut off');
2016
+ await call(m, 'materialize', { force: true });
2017
+ assert.equal(env.readDisk('a.txt'), 'v2');
2018
+ });
2019
+ test('a refused write puts back whatever evidence the path had, an interrupted one included', async (t) => {
2020
+ const env = new Env(t);
2021
+ const m = await env.open();
2022
+ await seedSynced(env, m, 'sub/a.txt', 'original');
2023
+ await call(m, 'write', { path: 'work/sub/a.txt', content: 'draft' });
2024
+ const interrupted = { kind: 'interrupted', candidates: [{ kind: 'absent' }, { kind: 'content', hash: 'h' }] };
2025
+ m.agreement.set('work', 'sub/a.txt', interrupted);
2026
+ mkdirSync(join(env.root, 'outside'));
2027
+ await pushWith(env, m, ['sub/a.txt'], { force: true, beforeEffect: () => swapForSymlink(env, 'sub', join(env.root, 'outside')) });
2028
+ assert.deepEqual(m.agreement.get('work', 'sub/a.txt'), interrupted);
2029
+ });
2030
+ test('a directory swapped out of the mount after its parent was listed is never walked', async (t) => {
2031
+ const env = new Env(t);
2032
+ const m = await env.open();
2033
+ env.writeDisk('sub/a.txt', 'original');
2034
+ await call(m, 'sync', {});
2035
+ const outside = join(env.root, 'outside');
2036
+ mkdirSync(outside);
2037
+ writeFileSync(join(outside, 'secret.txt'), 'outside');
2038
+ let swapped = false;
2039
+ const data = await withFsPromises('readdir', (readdir) => async (...args) => {
2040
+ const listed = await readdir(...args);
2041
+ if (!swapped) {
2042
+ swapped = true; // after the root's listing, before its subdirectory's
2043
+ swapForSymlink(env, 'sub', outside);
2044
+ }
2045
+ return listed;
2046
+ }, () => call(m, 'ls', { path: 'work', recursive: true }));
2047
+ const states = new Map(data.entries.map((e) => [e.path, e.state]));
2048
+ assert.equal(states.has('sub/secret.txt'), false, 'no outside name is listed');
2049
+ assert.equal(states.get('sub/a.txt'), 'unverified');
2050
+ assert.deepEqual(data.incomplete, [{ path: 'sub', reason: 'the directory changed while the walk ran' }]);
2051
+ assert.notEqual(env.store.treeGet(TREE, 'sub/a.txt'), null, 'the agreed entry stays');
2052
+ });
2053
+ test("a directory that changes between being held and being listed contributes nothing", async (t) => {
2054
+ const env = new Env(t);
2055
+ const m = await env.open();
2056
+ env.writeDisk('sub/a.txt', 'original');
2057
+ await call(m, 'sync', {});
2058
+ const outside = join(env.root, 'outside');
2059
+ mkdirSync(outside);
2060
+ writeFileSync(join(outside, 'secret.txt'), 'outside');
2061
+ let swapped = false;
2062
+ const data = await withFsPromises('readdir', (readdir) => async (path, ...rest) => {
2063
+ if (!swapped && basename(String(path)) === 'sub') {
2064
+ swapped = true; // the subdirectory's own listing reaches outside
2065
+ swapForSymlink(env, 'sub', outside);
2066
+ }
2067
+ return readdir(path, ...rest);
2068
+ }, () => call(m, 'ls', { path: 'work', recursive: true }));
2069
+ const states = new Map(data.entries.map((e) => [e.path, e.state]));
2070
+ assert.equal(swapped, true);
2071
+ assert.equal(states.has('sub/secret.txt'), false, 'no outside name is listed');
2072
+ assert.equal(states.get('sub/a.txt'), 'unverified');
2073
+ assert.notEqual(env.store.treeGet(TREE, 'sub/a.txt'), null, 'nothing is removed on its absence');
2074
+ });
2075
+ test('absence is proven by the directory that lacks the entry, not by its name', async (t) => {
2076
+ const env = new Env(t);
2077
+ const m = await env.open();
2078
+ mkdirSync(env.disk('sub'));
2079
+ const mount = m.mounts.get('work');
2080
+ const runtime = await m.runtime(mount);
2081
+ env.writeDisk('replacement/a.txt', 'present in the replacement');
2082
+ let misses = 0;
2083
+ const seen = await withFsPromises('lstat', (lstat) => async (path, ...rest) => {
2084
+ try {
2085
+ return await lstat(path, ...rest);
2086
+ }
2087
+ catch (err) {
2088
+ // By name: the lookups may go through the lexical or the canonical path.
2089
+ if (basename(String(path)) === 'a.txt' && ++misses === 2) {
2090
+ // The empty directory that lacked it is replaced by one that has it.
2091
+ renameSync(env.disk('sub'), env.disk('old'));
2092
+ renameSync(env.disk('replacement'), env.disk('sub'));
2093
+ }
2094
+ throw err;
2095
+ }
2096
+ }, () => observePath(runtime.view, runtime.rootReal, 'sub/a.txt'));
2097
+ assert.equal(misses, 2);
2098
+ assert.equal(seen.kind, 'unobserved', 'not absent: the directory that lacked it is gone');
2099
+ assert.equal(env.readDisk('sub/a.txt'), 'present in the replacement');
2100
+ });
2101
+ test('a file standing where a directory was proves nothing once a directory is back', async (t) => {
2102
+ const env = new Env(t);
2103
+ const m = await env.open();
2104
+ env.writeDisk('sub/a.txt', 'A');
2105
+ await call(m, 'sync', {});
2106
+ renameSync(env.disk('sub'), env.disk('saved'));
2107
+ writeFileSync(env.disk('sub'), 'a file, for a moment');
2108
+ let swapped = false;
2109
+ const data = await withFsPromises('lstat', (lstat) => async (path, ...rest) => {
2110
+ const info = await lstat(path, ...rest);
2111
+ // By name: the lookups may go through the lexical or the canonical path.
2112
+ if (!swapped && basename(String(path)) === 'sub' && info.isFile()) {
2113
+ swapped = true;
2114
+ rmSync(env.disk('sub'));
2115
+ renameSync(env.disk('saved'), env.disk('sub'));
2116
+ }
2117
+ return info;
2118
+ }, () => call(m, 'ls', { path: 'work/sub', recursive: true }));
2119
+ assert.equal(swapped, true);
2120
+ assert.equal(data.entries.find((e) => e.path === 'sub/a.txt')?.state, 'unverified');
2121
+ assert.notEqual(env.store.treeGet(TREE, 'sub/a.txt'), null, 'nothing removed while it was present');
2122
+ });
2123
+ test('a path beneath a dangling symlink is not proven absent', async (t) => {
2124
+ const env = new Env(t);
2125
+ const m = await env.open();
2126
+ env.writeDisk('sub/a.txt', 'original');
2127
+ await call(m, 'sync', {});
2128
+ swapForSymlink(env, 'sub', join(env.root, 'nowhere'));
2129
+ await m.handleFsChanges('work', [{ path: 'sub/a.txt', op: 'deleted' }]);
2130
+ assert.notEqual(env.store.treeGet(TREE, 'sub/a.txt'), null, 'a watcher event deleted nothing');
2131
+ renameSync(env.disk('saved'), env.disk('elsewhere'));
2132
+ rmSync(env.disk('sub'));
2133
+ await m.handleFsChanges('work', [{ path: 'sub/a.txt', op: 'deleted' }]);
2134
+ assert.equal(env.store.treeGet(TREE, 'sub/a.txt'), null, 'a real deletion is still adopted');
2135
+ });
2136
+ for (const followSymlinks of [false, true]) {
2137
+ test(`a symlinked parent never lets a read, listing or grep reach outside the mount (followSymlinks: ${followSymlinks})`, async (t) => {
2138
+ const env = new Env(t);
2139
+ const m = await env.open({ followSymlinks });
2140
+ const outside = join(env.root, 'outside');
2141
+ mkdirSync(outside);
2142
+ writeFileSync(join(outside, 'secret.txt'), 'OUTSIDE_SENTINEL');
2143
+ symlinkSync(outside, env.disk('escape'));
2144
+ const error = await refused(m, 'read', { path: 'work/escape/secret.txt' });
2145
+ assert.match(error, /outside the mount/);
2146
+ assert.doesNotMatch(error, /OUTSIDE_SENTINEL/);
2147
+ const scoped = await call(m, 'ls', { path: 'work/escape', recursive: true });
2148
+ assert.deepEqual(scoped.entries, []);
2149
+ assert.deepEqual(scoped.incomplete, [{ path: 'escape', reason: 'the directory resolves outside the mount' }]);
2150
+ const whole = await listing(m);
2151
+ assert.equal([...whole.keys()].some((p) => p.startsWith('escape/')), false);
2152
+ assert.deepEqual((await call(m, 'grep', { pattern: 'OUTSIDE_SENTINEL' })).results, []);
2153
+ assert.deepEqual((await call(m, 'grep', { pattern: 'OUTSIDE_SENTINEL', path: 'work/escape/secret.txt' })).results, []);
2154
+ assert.equal(env.store.treeGet(TREE, 'escape/secret.txt'), null, 'nothing from outside was ingested');
2155
+ });
2156
+ }
2157
+ test('an unavailable root with nothing tracked says so instead of listing a verified empty directory', async (t) => {
2158
+ const env = new Env(t);
2159
+ const m = await env.open({ path: join(env.root, 'never-created'), mode: 'read-only' });
2160
+ const data = await call(m, 'ls', { path: 'work' });
2161
+ assert.deepEqual(data.entries, []);
2162
+ assert.deepEqual(data.incomplete, [{ path: '', reason: 'the mount root is unavailable' }]);
2163
+ });
2164
+ for (const followSymlinks of [false, true]) {
2165
+ test(`a mount whose configured root is a symlink to a directory works (followSymlinks: ${followSymlinks})`, async (t) => {
2166
+ const env = new Env(t);
2167
+ const linkedRoot = join(env.root, 'linked-root');
2168
+ symlinkSync(env.dir, linkedRoot);
2169
+ env.writeDisk('from-disk.txt', 'written through the real directory');
2170
+ const m = await env.open({ path: linkedRoot, followSymlinks });
2171
+ const data = await call(m, 'ls', { path: 'work', recursive: true });
2172
+ assert.equal(data.incomplete, undefined, 'the root is available');
2173
+ assert.deepEqual(data.entries.map((e) => [e.path, e.state]), [['from-disk.txt', 'synced']]);
2174
+ assert.equal(await contentOf(m, 'from-disk.txt'), 'written through the real directory');
2175
+ await seedSynced(env, m, 'from-workspace.txt', 'materialized through the link');
2176
+ env.rmDisk('from-disk.txt');
2177
+ await call(m, 'sync', {});
2178
+ assert.equal(env.store.treeGet(TREE, 'from-disk.txt'), null, 'a shell deletion is still adopted');
2179
+ });
2180
+ }
2181
+ test('a missing mount root is unavailable, not a deletion of everything in it', async (t) => {
2182
+ const env = new Env(t);
2183
+ const m = await env.open();
2184
+ await seedSynced(env, m, 'a.txt', 'v1');
2185
+ renameSync(env.dir, `${env.dir}.away`);
2186
+ try {
2187
+ assert.equal(await stateOf(m, 'a.txt'), 'unverified');
2188
+ await call(m, 'sync', {});
2189
+ assert.notEqual(env.store.treeGet(TREE, 'a.txt'), null, 'nothing was removed');
2190
+ }
2191
+ finally {
2192
+ renameSync(`${env.dir}.away`, env.dir);
2193
+ }
2194
+ assert.equal(await stateOf(m, 'a.txt'), 'synced');
2195
+ });
2196
+ });
2197
+ describe('grep', () => {
2198
+ test('hits are labelled by version, and disk-only files are reported as skipped', async (t) => {
2199
+ const env = new Env(t);
2200
+ const m = await env.open();
2201
+ await seedSynced(env, m, 'synced.txt', 'needle one');
2202
+ await seedSynced(env, m, 'draft.txt', 'nothing');
2203
+ await call(m, 'write', { path: 'work/draft.txt', content: 'needle two' });
2204
+ await seedSynced(env, m, 'conflict.txt', 'nothing');
2205
+ env.writeDisk('conflict.txt', 'needle on disk');
2206
+ await call(m, 'write', { path: 'work/conflict.txt', content: 'needle in the workspace' });
2207
+ await seedSynced(env, m, 'binary-conflict.dat', 'nothing');
2208
+ env.writeDisk('binary-conflict.dat', Buffer.from([0x6e, 0, 0x65, 0]));
2209
+ await call(m, 'write', { path: 'work/binary-conflict.dat', content: 'needle beside a binary disk version' });
2210
+ env.writeDisk('shot.png', ONE_PX_PNG);
2211
+ const data = await call(m, 'grep', { pattern: 'needle' });
2212
+ const hits = data.results
2213
+ .map((r) => `${r.file} ${r.state} ${r.version}: ${r.matches[0].text}`);
2214
+ assert.deepEqual(hits, [
2215
+ 'work/binary-conflict.dat conflict workspace: needle beside a binary disk version',
2216
+ 'work/conflict.txt conflict workspace: needle in the workspace',
2217
+ 'work/conflict.txt conflict conflicting-disk: needle on disk',
2218
+ 'work/draft.txt workspace-draft workspace: needle two',
2219
+ 'work/synced.txt synced workspace: needle one',
2220
+ ]);
2221
+ const skipped = data.skipped;
2222
+ assert.deepEqual(skipped.map((s) => s.file), ['work/binary-conflict.dat', 'work/shot.png'], "a conflict's binary disk version is no more searched than a disk-only file, and says so");
2223
+ assert.match(skipped[0].reason, /^conflict: the disk version is binary or over the size limit/);
2224
+ const one = await call(m, 'grep', { pattern: 'needle', path: 'work/draft.txt' });
2225
+ assert.deepEqual(one.results.map((r) => r.file), ['work/draft.txt']);
2226
+ });
2227
+ test("once disk changes again, a conflict's stored disk version is labelled as recorded, and what disk holds now as not searched", async (t) => {
2228
+ const env = new Env(t);
2229
+ const m = await env.open();
2230
+ await seedSynced(env, m, 'a.txt', 'v1');
2231
+ env.writeDisk('a.txt', 'disk says ALPHA');
2232
+ await call(m, 'write', { path: 'work/a.txt', content: 'workspace says GAMMA' });
2233
+ const recorded = await entryOf(m, 'a.txt');
2234
+ assert.equal(recorded?.conflict?.kind, 'both-changed');
2235
+ assert.equal(recorded?.conflict?.diskCopy, 'stored');
2236
+ const hitsOf = (data) => data.results.map((r) => `${r.file} ${r.state} ${r.version}: ${r.matches[0].text}`);
2237
+ const current = await call(m, 'grep', { pattern: 'ALPHA' });
2238
+ assert.deepEqual(hitsOf(current), ['work/a.txt conflict conflicting-disk: disk says ALPHA'], 'while disk still holds it');
2239
+ assert.equal(current.skipped, undefined);
2240
+ env.writeDisk('a.txt', 'disk now says BETA');
2241
+ assert.equal((await entryOf(m, 'a.txt'))?.conflict?.diskChangedSinceRecorded, true);
2242
+ const alpha = await call(m, 'grep', { pattern: 'ALPHA' });
2243
+ assert.deepEqual(hitsOf(alpha), ['work/a.txt conflict recorded-disk: disk says ALPHA'], 'no longer what disk says');
2244
+ const beta = await call(m, 'grep', { pattern: 'BETA' });
2245
+ assert.deepEqual(hitsOf(beta), []);
2246
+ for (const data of [alpha, beta]) {
2247
+ assert.deepEqual(data.skipped?.map((s) => s.file), ['work/a.txt'], 'what disk holds now is listed as not searched');
2248
+ assert.match(data.skipped[0].reason, /^conflict: disk changed since the conflict was recorded/);
2249
+ }
2250
+ env.rmDisk('a.txt');
2251
+ const gone = await call(m, 'grep', { pattern: 'ALPHA' });
2252
+ assert.deepEqual(hitsOf(gone), ['work/a.txt conflict recorded-disk: disk says ALPHA'], 'disk now has no file at all');
2253
+ assert.equal(gone.skipped, undefined, 'and no disk file went unsearched');
2254
+ assert.equal(await contentOf(m, 'a.txt'), 'workspace says GAMMA', 'the draft is kept');
2255
+ });
2256
+ });
2257
+ describe('grep, when the branch or the store changes under it', () => {
2258
+ test("a pass whose branch is no longer selected isn't read with another branch's content: grep runs it again, then gives up", async (t) => {
2259
+ const env = new Env(t);
2260
+ const m = await env.open();
2261
+ await seedSynced(env, m, 'a.txt', 'needle on main');
2262
+ const main = env.store.currentBranch().name;
2263
+ env.store.createBranch('other', main);
2264
+ env.store.switchBranch('other');
2265
+ await call(m, 'write', { path: 'work/a.txt', content: 'needle on other' });
2266
+ env.store.switchBranch(main);
2267
+ // Every pass is followed by a switch to the other branch before grep
2268
+ // reads the tree that goes with it.
2269
+ const internals = m;
2270
+ const real = internals.passUnlocked.bind(m);
2271
+ internals.passUnlocked = async (...args) => {
2272
+ const result = await real(...args);
2273
+ env.store.switchBranch(env.store.currentBranch().name === main ? 'other' : main);
2274
+ return result;
2275
+ };
2276
+ const data = await call(m, 'grep', { pattern: 'needle' });
2277
+ assert.deepEqual(data.results, [], JSON.stringify(data.results));
2278
+ assert.match(JSON.stringify(data.incomplete), /the selected branch kept changing while grep ran/);
2279
+ });
2280
+ test("a version missing from the store is listed as not searched, not taken for no match", async (t) => {
2281
+ const env = new Env(t);
2282
+ const m = await env.open();
2283
+ await seedSynced(env, m, 'conflict.txt', 'nothing');
2284
+ env.writeDisk('conflict.txt', 'needle on disk');
2285
+ await call(m, 'write', { path: 'work/conflict.txt', content: 'needle in the workspace' });
2286
+ const listed = (await call(m, 'ls', { path: 'work' })).entries.find((e) => e.name === 'conflict.txt');
2287
+ assert.equal(listed?.conflict?.diskCopy, 'stored');
2288
+ // Both stored versions go missing.
2289
+ const store = env.store;
2290
+ const getBlob = store.getBlob.bind(env.store);
2291
+ const gone = new Set([sha('needle on disk'), sha('needle in the workspace')]);
2292
+ store.getBlob = (hash) => (gone.has(hash) ? null : getBlob(hash));
2293
+ const data = await call(m, 'grep', { pattern: 'needle' });
2294
+ assert.deepEqual(data.results, []);
2295
+ const reasons = (data.skipped ?? []).map((x) => x.reason).join('\n');
2296
+ assert.match(reasons, /the workspace copy is missing from the store/);
2297
+ assert.match(reasons, /the disk version recorded with the conflict is missing from the store/);
2298
+ });
2299
+ });
2300
+ describe('on-agent-action', () => {
2301
+ test('a completed tool batch brings disk changes in before the next inference', async (t) => {
2302
+ const env = new Env(t);
2303
+ const m = await env.open({ watch: 'on-agent-action' });
2304
+ await seedSynced(env, m, 'edited.txt', 'v1');
2305
+ await seedSynced(env, m, 'removed.txt', 'v1');
2306
+ env.writeDisk('edited.txt', 'v2, by a shell command');
2307
+ env.rmDisk('removed.txt');
2308
+ env.writeDisk('created.txt', 'new');
2309
+ await m.onToolBatchComplete('agent');
2310
+ const blob = (p) => {
2311
+ const e = env.store.treeGet(TREE, p);
2312
+ return e ? env.store.getBlob(e.blobHash)?.toString('utf8') : null;
2313
+ };
2314
+ assert.equal(blob('edited.txt'), 'v2, by a shell command');
2315
+ assert.equal(blob('removed.txt'), null);
2316
+ assert.equal(blob('created.txt'), 'new');
2317
+ const status = await call(m, 'status', {});
2318
+ assert.equal(status.work.lastAgentActionScan.complete, true);
2319
+ });
2320
+ test('a scan that finishes without seeing everything says where, on status and as an event', { skip: IS_ROOT ? 'root reads every directory' : false }, async (t) => {
2321
+ const env = new Env(t);
2322
+ const m = await env.open({ watch: 'on-agent-action' });
2323
+ await seedSynced(env, m, 'locked/a.txt', 'v1');
2324
+ chmodSync(env.disk('locked'), 0o000);
2325
+ try {
2326
+ await m.onToolBatchComplete('agent');
2327
+ }
2328
+ finally {
2329
+ chmodSync(env.disk('locked'), 0o755);
2330
+ }
2331
+ const scan = (await call(m, 'status', {})).work.lastAgentActionScan;
2332
+ assert.equal(scan.complete, true, 'it finished');
2333
+ assert.equal(scan.withinDeadline, true);
2334
+ assert.deepEqual(scan.incomplete, [{ path: 'locked', reason: 'cannot list (EACCES)' }]);
2335
+ const event = env.events.find((e) => e.type === 'workspace:agent-action-scan-incomplete');
2336
+ assert.equal(event?.mount, 'work');
2337
+ assert.deepEqual(event?.incomplete, [{ path: 'locked', reason: 'cannot list (EACCES)' }]);
2338
+ });
2339
+ test('a scan that outlasts its deadline is reported incomplete, and still finishes', async (t) => {
2340
+ const env = new Env(t);
2341
+ const m = await env.open({ watch: 'on-agent-action' }, { agentActionScanDeadlineMs: 50 });
2342
+ env.writeDisk('late.txt', 'arrives late');
2343
+ const mount = m.mounts.get('work');
2344
+ const held = m.withMount(mount, () => sleep(400)); // another pass holds the mount
2345
+ await m.onToolBatchComplete('agent');
2346
+ let status = await call(m, 'status', {});
2347
+ assert.equal(status.work.lastAgentActionScan.complete, false);
2348
+ assert.equal(status.work.lastAgentActionScan.withinDeadline, false);
2349
+ assert.match(status.work.lastAgentActionScan.reason, /deadline exceeded \(50 ms\); the scan continues/);
2350
+ assert.ok(env.events.some((e) => e.type === 'workspace:agent-action-scan-incomplete'));
2351
+ assert.equal(env.store.treeGet(TREE, 'late.txt'), null, 'nothing was claimed before the scan ran');
2352
+ await held;
2353
+ await m.mountTurns.get('work');
2354
+ assert.notEqual(env.store.treeGet(TREE, 'late.txt'), null, 'the scan finished afterwards');
2355
+ status = await call(m, 'status', {});
2356
+ assert.deepEqual({ complete: status.work.lastAgentActionScan.complete, withinDeadline: status.work.lastAgentActionScan.withinDeadline, reason: status.work.lastAgentActionScan.reason }, { complete: true, withinDeadline: false, reason: 'finished after the deadline' });
2357
+ });
2358
+ test('batches that come while a scan waits share it: one runs, at most one waits', async (t) => {
2359
+ const env = new Env(t);
2360
+ const m = await env.open({ watch: 'on-agent-action' }, { agentActionScanDeadlineMs: 5_000 });
2361
+ const mod = m;
2362
+ const mount = mod.mounts.get('work');
2363
+ let passes = 0;
2364
+ const pass = mod.passUnlocked.bind(mod);
2365
+ mod.passUnlocked = (...args) => { passes++; return pass(...args); };
2366
+ const held = mod.withMount(mount, () => sleep(200)); // another pass holds the mount
2367
+ env.writeDisk('a.txt', 'from the batches');
2368
+ await Promise.all([m.onToolBatchComplete('agent'), m.onToolBatchComplete('agent'), m.onToolBatchComplete('agent')]);
2369
+ await held;
2370
+ assert.equal(passes, 1, 'three batches, one scan');
2371
+ assert.notEqual(env.store.treeGet(TREE, 'a.txt'), null);
2372
+ assert.equal((await call(m, 'status', {})).work.lastAgentActionScan.withinDeadline, true);
2373
+ });
2374
+ test("while a scan runs past its deadline, a later batch isn't held, and its scan comes after", async (t) => {
2375
+ const env = new Env(t);
2376
+ const m = await env.open({ watch: 'on-agent-action' }, { agentActionScanDeadlineMs: 50 });
2377
+ const mod = m;
2378
+ const mount = mod.mounts.get('work');
2379
+ let passes = 0;
2380
+ const pass = mod.passUnlocked.bind(mod);
2381
+ mod.passUnlocked = (...args) => { passes++; return pass(...args); };
2382
+ const held = mod.withMount(mount, () => sleep(400));
2383
+ await m.onToolBatchComplete('agent'); // held to its deadline
2384
+ env.writeDisk('later.txt', 'from the second batch');
2385
+ const started = Date.now();
2386
+ await m.onToolBatchComplete('agent');
2387
+ assert.ok(Date.now() - started < 40, 'the second batch was not held');
2388
+ let scan = (await call(m, 'status', {})).work.lastAgentActionScan;
2389
+ assert.match(scan.reason, /an earlier scan is still running past its deadline/);
2390
+ await held;
2391
+ await mod.mountTurns.get('work');
2392
+ assert.equal(passes, 1, 'the second batch shared the scan still waiting');
2393
+ assert.notEqual(env.store.treeGet(TREE, 'later.txt'), null, "the second batch's change was scanned");
2394
+ // The late scan is over (its bookkeeping settles a few ticks after the
2395
+ // mount's turn), so the next batch is held for its own scan again, which
2396
+ // may itself miss 50 ms on a loaded machine: that's its own miss.
2397
+ await new Promise((resolve) => setImmediate(resolve));
2398
+ await m.onToolBatchComplete('agent');
2399
+ scan = (await call(m, 'status', {})).work.lastAgentActionScan;
2400
+ assert.doesNotMatch(scan.reason ?? '', /an earlier scan is still running/);
2401
+ });
2402
+ test("a scan queued behind a late one puts its own outcome on status, which the late one's doesn't overwrite", async (t) => {
2403
+ const env = new Env(t);
2404
+ const m = await env.open({ watch: 'on-agent-action' }, { agentActionScanDeadlineMs: 50 });
2405
+ const mod = m;
2406
+ // Stand-ins for the two scans' passes: the first runs, then waits to be
2407
+ // released, and each says a different region couldn't be observed.
2408
+ const pass = mod.passUnlocked.bind(mod);
2409
+ const gates = [0, 1].map(() => { let open; const shut = new Promise((resolve) => { open = resolve; }); return { open, shut }; });
2410
+ let passes = 0;
2411
+ mod.passUnlocked = async (...args) => {
2412
+ const n = ++passes;
2413
+ const result = await pass(...args);
2414
+ await gates[n - 1].shut;
2415
+ return { ...result, incomplete: [{ path: n === 1 ? 'FIRST' : 'SECOND', reason: 'stand-in' }] };
2416
+ };
2417
+ const events = [];
2418
+ mod.ctx = { ...mod.ctx, pushEvent: (e) => { events.push(e); } };
2419
+ // A failed assertion mustn't leave a scan waiting on its gate: the
2420
+ // module's stop at cleanup would wait for it.
2421
+ try {
2422
+ await m.onToolBatchComplete('agent'); // its scan starts, and runs past the deadline
2423
+ await m.onToolBatchComplete('agent'); // not held: its own scan is queued behind the first
2424
+ const status = async () => (await call(m, 'status', {})).work.lastAgentActionScan;
2425
+ assert.match((await status()).reason, /an earlier scan is still running past its deadline/);
2426
+ gates[0].open();
2427
+ for (let i = 0; i < 100 && passes < 2; i++)
2428
+ await sleep(10);
2429
+ await new Promise((resolve) => setImmediate(resolve));
2430
+ // The first scan is done and the second runs: the first's outcome is not
2431
+ // the second batch's.
2432
+ assert.match((await status()).reason, /an earlier scan is still running past its deadline/, "the older scan doesn't speak for the newer batch");
2433
+ gates[1].open();
2434
+ await mod.mountTurns.get('work');
2435
+ await new Promise((resolve) => setImmediate(resolve));
2436
+ assert.equal(passes, 2, 'the second batch had a scan of its own');
2437
+ const last = await status();
2438
+ assert.equal(last.complete, true);
2439
+ assert.equal(last.withinDeadline, false, 'its batch was never held for it');
2440
+ assert.deepEqual(last.incomplete?.map((r) => r.path), ['SECOND'], JSON.stringify(last));
2441
+ const regions = events.flatMap((e) => (e.incomplete ?? []).map((r) => r.path));
2442
+ assert.deepEqual(regions.sort(), ['FIRST', 'SECOND'], 'each scan still reports its regions as an event');
2443
+ }
2444
+ finally {
2445
+ for (const gate of gates)
2446
+ gate.open();
2447
+ }
2448
+ });
2449
+ test("a batch that comes while a late scan's successor still runs isn't held either", async (t) => {
2450
+ const env = new Env(t);
2451
+ const m = await env.open({ watch: 'on-agent-action' }, { agentActionScanDeadlineMs: 300 });
2452
+ const mod = m;
2453
+ // Each scan's pass runs, then waits to be released.
2454
+ const pass = mod.passUnlocked.bind(mod);
2455
+ const gates = [0, 1, 2].map(() => { let open; const shut = new Promise((resolve) => { open = resolve; }); return { open, shut }; });
2456
+ let passes = 0;
2457
+ mod.passUnlocked = async (...args) => {
2458
+ const n = ++passes;
2459
+ const result = await pass(...args);
2460
+ await gates[n - 1].shut;
2461
+ return result;
2462
+ };
2463
+ try {
2464
+ await m.onToolBatchComplete('agent'); // its scan runs past the deadline
2465
+ await m.onToolBatchComplete('agent'); // not held: its scan is queued behind the first, late from the start
2466
+ gates[0].open(); // the first scan finishes, and the second starts
2467
+ for (let i = 0; i < 100 && passes < 2; i++)
2468
+ await sleep(10);
2469
+ await new Promise((resolve) => setImmediate(resolve));
2470
+ assert.equal(passes, 2, 'the second scan runs');
2471
+ const started = Date.now();
2472
+ await m.onToolBatchComplete('agent'); // while the late second scan still runs
2473
+ const held = Date.now() - started;
2474
+ assert.ok(held < 150, `the third batch was not held (${held} ms, deadline 300 ms)`);
2475
+ assert.match((await call(m, 'status', {})).work.lastAgentActionScan.reason, /an earlier scan is still running past its deadline/);
2476
+ }
2477
+ finally {
2478
+ for (const gate of gates)
2479
+ gate.open();
2480
+ }
2481
+ });
2482
+ test("a scan deadline within 1 s of the framework's hook bound is lowered below it, with a warning", async (t) => {
2483
+ const env = new Env(t);
2484
+ const m = await env.open({ watch: 'on-agent-action' }, { agentActionScanDeadlineMs: 60_000 });
2485
+ const warnings = [];
2486
+ const original = console.warn;
2487
+ console.warn = (...args) => { warnings.push(args.map(String).join(' ')); };
2488
+ try {
2489
+ assert.equal(m.agentActionScanDeadlineMs(), TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000);
2490
+ assert.equal(m.agentActionScanDeadlineMs(), TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000);
2491
+ }
2492
+ finally {
2493
+ console.warn = original;
2494
+ }
2495
+ assert.equal(warnings.filter((w) => w.includes('agentActionScanDeadlineMs 60000')).length, 1, 'warned once');
2496
+ });
2497
+ test('a branch switch while a pass reads disk makes it gather again, on the branch it then decides on', async (t) => {
2498
+ const env = new Env(t);
2499
+ const m = await env.open();
2500
+ await seedSynced(env, m, 'a.txt', 'A');
2501
+ const main = env.store.currentBranch().name;
2502
+ env.store.createBranch('other', main);
2503
+ env.store.switchBranch('other');
2504
+ await call(m, 'write', { path: 'work/only-other.txt', content: 'a draft on other' });
2505
+ env.store.switchBranch(main);
2506
+ // The listing that lived through the switch is the one judged: a.txt's
2507
+ // fingerprint is too fresh to vouch, so the pass reads it.
2508
+ const data = await withReadHook((n) => { if (n === 1)
2509
+ env.store.switchBranch('other'); }, () => call(m, 'ls', { path: 'work', recursive: true }));
2510
+ assert.equal(env.store.currentBranch().name, 'other');
2511
+ assert.equal(data.incomplete, undefined);
2512
+ const entries = new Map(data.entries.map((e) => [e.path, e.state]));
2513
+ assert.equal(entries.get('only-other.txt'), 'workspace-draft', "the new branch's draft is decided, not omitted");
2514
+ assert.equal(entries.get('a.txt'), 'synced');
2515
+ });
2516
+ test('a branch that keeps changing under a pass leaves it explicitly incomplete', async (t) => {
2517
+ const env = new Env(t);
2518
+ const m = await env.open();
2519
+ await seedSynced(env, m, 'a.txt', 'A');
2520
+ const main = env.store.currentBranch().name;
2521
+ env.store.createBranch('other', main);
2522
+ const data = await withReadHook((n) => env.store.switchBranch(n % 2 === 1 ? 'other' : main), () => call(m, 'ls', { path: 'work', recursive: true }));
2523
+ assert.deepEqual(data.entries, []);
2524
+ assert.deepEqual(data.incomplete, [{ path: '', reason: 'the selected branch kept changing while the scan ran' }]);
2525
+ });
2526
+ test('a branch switched away during the root check and back during a read is decided on the branch enumerated', async (t) => {
2527
+ const env = new Env(t);
2528
+ const m = await env.open();
2529
+ await seedSynced(env, m, 'a.txt', 'A');
2530
+ const main = env.store.currentBranch().name;
2531
+ env.store.createBranch('other', main);
2532
+ await call(m, 'write', { path: 'work/only-main.txt', content: 'a draft on main' });
2533
+ // The root check locates the root and reads its identity (lstat of its real path).
2534
+ const fsp = createRequire(import.meta.url)('node:fs/promises');
2535
+ const lstat = fsp.lstat;
2536
+ const rootReal = realpathSync(env.dir);
2537
+ let first = true;
2538
+ fsp.lstat = async function (path, ...rest) {
2539
+ const result = await lstat(path, ...rest);
2540
+ if (first && path === rootReal) {
2541
+ first = false;
2542
+ env.store.switchBranch('other'); // A -> B before the candidates are read
2543
+ }
2544
+ return result;
2545
+ };
2546
+ syncBuiltinESMExports();
2547
+ try {
2548
+ const data = await withReadHook((n) => { if (n === 1)
2549
+ env.store.switchBranch(main); }, // B -> A during a read
2550
+ () => call(m, 'ls', { path: 'work', recursive: true }));
2551
+ assert.equal(first, false, 'the branch switched during the root check');
2552
+ assert.equal(env.store.currentBranch().name, main);
2553
+ const states = new Map(data.entries.map((e) => [e.path, e.state]));
2554
+ assert.equal(states.get('only-main.txt'), 'workspace-draft', "main's draft is in main's listing");
2555
+ }
2556
+ finally {
2557
+ fsp.lstat = lstat;
2558
+ syncBuiltinESMExports();
2559
+ }
2560
+ });
2561
+ test('a push whose selection was made on another branch writes nothing', async (t) => {
2562
+ const env = new Env(t);
2563
+ const m = await env.open();
2564
+ await call(m, 'write', { path: 'work/a.txt', content: 'A' });
2565
+ const mount = m.mounts.get('work');
2566
+ const pushed = await pushPaths(env.store, m.agreement, await m.runtime(mount), ['a.txt'], { branchId: 'not-this-branch' });
2567
+ assert.deepEqual(pushed.written, []);
2568
+ assert.match(pushed.skipped[0].reason, /selected branch changed/);
2569
+ assert.throws(() => env.readDisk('a.txt'), /ENOENT/);
2570
+ });
2571
+ test('a branch change while the scan waits applies to the branch current when it decides', async (t) => {
2572
+ const env = new Env(t);
2573
+ const m = await env.open({ watch: 'on-agent-action' });
2574
+ await seedSynced(env, m, 'a.txt', 'v1');
2575
+ const main = env.store.currentBranch().name;
2576
+ env.store.createBranch('other', main);
2577
+ env.writeDisk('new.txt', 'made during the batch');
2578
+ const mount = m.mounts.get('work');
2579
+ const held = m.withMount(mount, () => sleep(100));
2580
+ const scan = m.onToolBatchComplete('agent');
2581
+ env.store.switchBranch('other'); // the selection changes before the scan decides
2582
+ await held;
2583
+ await scan;
2584
+ assert.notEqual(env.store.treeGet(TREE, 'new.txt'), null, 'adopted on the branch current at decision time');
2585
+ env.store.switchBranch(main);
2586
+ assert.equal(env.store.treeGet(TREE, 'new.txt'), null, 'the old selection was not written with the observation');
2587
+ assert.equal(await stateOf(m, 'new.txt'), 'not-in-branch');
2588
+ });
2589
+ });
2590
+ describe('materialize, status and shutdown', () => {
2591
+ test("a materialize between initStore and start keeps its pin: the saved one, older, isn't applied over it", async (t) => {
2592
+ const env = new Env(t);
2593
+ const first = await env.open();
2594
+ await call(first, 'write', { path: 'work/a.txt', content: 'A' });
2595
+ await call(first, 'materialize', { path: 'work/a.txt' });
2596
+ let saved;
2597
+ first.ctx.setState = (state) => { saved = state; };
2598
+ await env.shutdown(first);
2599
+ env.store = JsStore.openOrCreate({ path: env.storePath });
2600
+ // The next process materializes before its start, then starts as a restart.
2601
+ const m = new WorkspaceModule({ mounts: [{ name: 'work', path: env.dir, mode: 'read-write', watch: 'never' }] });
2602
+ m.initStore(env.store);
2603
+ await call(m, 'write', { path: 'work/b.txt', content: 'B' });
2604
+ await call(m, 'materialize', { path: 'work/b.txt' });
2605
+ const mount = m.mounts.get('work');
2606
+ const pinned = { seq: mount.lastMaterializedSeq, branch: mount.lastMaterializedBranchId };
2607
+ assert.notEqual(pinned.seq, saved.mounts.work.lastMaterializedSeq);
2608
+ await m.start({ isRestart: true, getState: () => saved, setState: () => { }, pushEvent: () => { } });
2609
+ env.live.push(m);
2610
+ assert.deepEqual({ seq: mount.lastMaterializedSeq, branch: mount.lastMaterializedBranchId }, pinned);
2611
+ });
2612
+ test('the module takes no work after it has stopped and saved its pins, until it starts again', async (t) => {
2613
+ const env = new Env(t);
2614
+ const m = await env.open();
2615
+ await call(m, 'write', { path: 'work/a.txt', content: 'A' });
2616
+ let saved;
2617
+ m.ctx.setState = (state) => { saved = state; };
2618
+ await m.stop();
2619
+ // After stop has saved the pins and watermarks, nothing more changes them.
2620
+ const after = await m.handleToolCall({ id: 't', name: 'materialize', input: { path: 'work/a.txt' } });
2621
+ assert.equal(after.success, false);
2622
+ assert.match(String(after.error), /has stopped/);
2623
+ assert.equal(existsSync(env.disk('a.txt')), false, 'nothing was written after the stop');
2624
+ assert.equal((await m.writeBinary('work/late.bin', Buffer.from('x'), 'application/octet-stream')).success, false);
2625
+ assert.deepEqual(await m.readBinary('work/a.txt'), { error: 'The workspace has stopped: it takes no more work until it starts again' });
2626
+ assert.deepEqual(await m.materializeMount('work'), []);
2627
+ assert.equal(existsSync(env.disk('a.txt')), false);
2628
+ assert.ok(saved, 'stop saved its state');
2629
+ // Started again, it takes work again.
2630
+ await m.start({ isRestart: true, getState: () => saved, setState: () => { }, pushEvent: () => { } });
2631
+ await call(m, 'materialize', { path: 'work/a.txt' });
2632
+ assert.equal(env.readDisk('a.txt'), 'A');
2633
+ });
2634
+ test('a materialize queued behind a pass is guarded on the branch it then selects on', async (t) => {
2635
+ const env = new Env(t);
2636
+ const m = await env.open();
2637
+ await seedSynced(env, m, 'base.txt', 'base');
2638
+ const forkAt = env.store.currentSequence();
2639
+ await seedSynced(env, m, 'newer.txt', 'newer on main'); // pinned past the fork
2640
+ const main = env.store.currentBranch().name;
2641
+ env.store.createBranchAt('divergent', main, forkAt);
2642
+ env.store.switchBranch('divergent');
2643
+ await call(m, 'write', { path: 'work/div.txt', content: 'divergent' });
2644
+ env.store.switchBranch(main);
2645
+ const mount = m.mounts.get('work');
2646
+ const held = m.withMount(mount, () => sleep(100));
2647
+ const queued = m.handleToolCall({ id: 't', name: 'materialize', input: {} });
2648
+ env.store.switchBranch('divergent'); // while it waits for its turn
2649
+ await held;
2650
+ const res = await queued;
2651
+ assert.equal(res.success, false, 'refused on the branch it would have written');
2652
+ assert.match(String(res.error), /diverged/);
2653
+ assert.equal(existsSync(env.disk('div.txt')), false, 'nothing written');
2654
+ });
2655
+ test('a materialize whose push is refused on a branch change leaves the pin, so the next one there is still guarded', async (t) => {
2656
+ const env = new Env(t);
2657
+ const m = await env.open();
2658
+ await seedSynced(env, m, 'base.txt', 'base');
2659
+ const forkAt = env.store.currentSequence();
2660
+ await seedSynced(env, m, 'newer.txt', 'newer on main'); // pinned past the fork
2661
+ const main = env.store.currentBranch();
2662
+ env.store.createBranchAt('divergent', main.name, forkAt);
2663
+ env.store.switchBranch('divergent');
2664
+ await call(m, 'write', { path: 'work/div.txt', content: 'divergent' });
2665
+ env.store.switchBranch(main.name);
2666
+ await call(m, 'write', { path: 'work/base.txt', content: 'base v2' }); // owed on main
2667
+ const mount = m.mounts.get('work');
2668
+ const before = { pin: mount.lastMaterializedBranchId, seq: mount.lastMaterializedSeq };
2669
+ assert.equal(before.pin, main.id);
2670
+ // The selection is made on main; the branch changes while the push reads disk.
2671
+ let switched = false;
2672
+ const data = await withFsPromises('lstat', (lstat) => async (...args) => {
2673
+ if (!switched && String(args[0]).endsWith('base.txt')) {
2674
+ switched = true;
2675
+ env.store.switchBranch('divergent');
2676
+ }
2677
+ return lstat(...args);
2678
+ }, () => call(m, 'materialize', {}));
2679
+ assert.equal(switched, true, 'the branch changed while the push read disk');
2680
+ assert.deepEqual(data.materialized, []);
2681
+ assert.match(JSON.stringify(data.skipped), /selected branch changed/);
2682
+ assert.deepEqual({ pin: mount.lastMaterializedBranchId, seq: mount.lastMaterializedSeq }, before, 'pin and watermark as they were');
2683
+ assert.equal(env.readDisk('base.txt'), 'base');
2684
+ // So the next materialize, on the divergent branch, is still refused.
2685
+ assert.match(await refused(m, 'materialize', {}), /diverged/);
2686
+ assert.equal(existsSync(env.disk('div.txt')), false, 'nothing of the divergent branch written');
2687
+ });
2688
+ test('a first materialize refused on a branch change leaves an unpinned mount unpinned, though entries were in place', async (t) => {
2689
+ const env = new Env(t);
2690
+ const m = await env.open({ watch: 'on-agent-action' });
2691
+ env.writeDisk('a.txt', 'A');
2692
+ await m.onToolBatchComplete('agent'); // adopted: in place on main, never materialized
2693
+ const mount = m.mounts.get('work');
2694
+ assert.equal(mount.lastMaterializedBranchId, null, 'never pinned');
2695
+ const main = env.store.currentBranch().name;
2696
+ env.store.createBranch('other', main);
2697
+ await call(m, 'write', { path: 'work/b.txt', content: 'B' }); // owed on main
2698
+ const seq = mount.lastMaterializedSeq;
2699
+ let switched = false;
2700
+ const data = await withFsPromises('lstat', (lstat) => async (...args) => {
2701
+ if (!switched && String(args[0]).endsWith('b.txt')) {
2702
+ switched = true;
2703
+ env.store.switchBranch('other');
2704
+ }
2705
+ return lstat(...args);
2706
+ }, () => call(m, 'materialize', {}));
2707
+ assert.equal(switched, true, 'the branch changed while the push read disk');
2708
+ assert.deepEqual(data.materialized, []);
2709
+ assert.equal(mount.lastMaterializedBranchId, null, 'still unpinned');
2710
+ assert.equal(mount.lastMaterializedSeq, seq, 'watermark as it was');
2711
+ });
2712
+ test('status counts a workspace deletion as pending until a push applies it', async (t) => {
2713
+ const env = new Env(t);
2714
+ const m = await env.open();
2715
+ await seedSynced(env, m, 'gone.txt', 'v1');
2716
+ await call(m, 'delete', { path: 'work/gone.txt' });
2717
+ await call(m, 'materialize', {}); // left on disk: applyDeletions wasn't given
2718
+ let status = (await call(m, 'status', {})).work;
2719
+ assert.deepEqual([status.pendingChanges, status.pendingWorkspaceDeletions], [1, 1]);
2720
+ await call(m, 'materialize', { applyDeletions: true });
2721
+ status = (await call(m, 'status', {})).work;
2722
+ assert.deepEqual([status.pendingChanges, status.pendingWorkspaceDeletions], [0, 0]);
2723
+ });
2724
+ test("status counts a push that didn't reach disk as pending, past the watermark", async (t) => {
2725
+ const env = new Env(t);
2726
+ const m = await env.open();
2727
+ await seedSynced(env, m, 'a.txt', 'v1');
2728
+ await call(m, 'write', { path: 'work/a.txt', content: 'v2' });
2729
+ const failed = await withSyncs(env, () => true, () => call(m, 'materialize', {}));
2730
+ assert.deepEqual(failed.result.materialized, []);
2731
+ assert.equal((await call(m, 'status', {})).work.pendingChanges, 1, 'the edit is still owed');
2732
+ await call(m, 'materialize', {});
2733
+ assert.equal((await call(m, 'status', {})).work.pendingChanges, 0);
2734
+ });
2735
+ test('stop waits for work queued behind what was in flight when it began', async (t) => {
2736
+ const env = new Env(t);
2737
+ const m = await env.open();
2738
+ const mount = m.mounts.get('work');
2739
+ void m.withMount(mount, () => sleep(100));
2740
+ const stopping = m.stop();
2741
+ await sleep(10);
2742
+ let finished = false;
2743
+ // Appended after stop began, as a watcher callback or a late scan would be.
2744
+ void m.withMount(mount, async () => {
2745
+ await sleep(100);
2746
+ finished = true;
2747
+ });
2748
+ await stopping;
2749
+ assert.equal(finished, true, 'nothing still runs once stop returns');
2750
+ });
2751
+ });
2752
+ describe('a named mount, and a path the workspace and disk hold differently', () => {
2753
+ test("status reports the mount it's asked about, and a materialize of a read-only mount says it wrote nothing there", async (t) => {
2754
+ const env = new Env(t);
2755
+ const second = join(env.root, 'second');
2756
+ mkdirSync(second);
2757
+ const m = await env.open({}, { mounts: [
2758
+ { name: 'work', path: env.dir, mode: 'read-write', watch: 'never' },
2759
+ { name: 'second', path: second, mode: 'read-only', watch: 'never' },
2760
+ ] });
2761
+ const status = await call(m, 'status', { mount: 'work' });
2762
+ assert.deepEqual(Object.keys(status.mounts ?? status), ['work'], JSON.stringify(status));
2763
+ assert.match(await refused(m, 'status', { mount: 'nowhere' }), /Unknown mount: nowhere/);
2764
+ const done = await call(m, 'materialize', { mount: 'second' });
2765
+ assert.equal(done.count, 0);
2766
+ assert.match(JSON.stringify(done.skipped), /a read-only mount: materialize writes nothing to it/);
2767
+ });
2768
+ test('a path sync takes in a file disk has where the workspace holds a directory, and drops what was beneath', async (t) => {
2769
+ const env = new Env(t);
2770
+ const m = await env.open();
2771
+ await seedSynced(env, m, 'd/x.txt', 'x');
2772
+ rmSync(env.disk('d'), { recursive: true });
2773
+ env.writeDisk('d', 'now a file');
2774
+ const data = await call(m, 'sync', { path: 'work/d' });
2775
+ assert.equal(env.store.treeGet('workspace/work/tree', 'd/x.txt'), null, 'what was beneath is gone');
2776
+ const entry = env.store.treeGet('workspace/work/tree', 'd');
2777
+ assert.ok(entry, `the file is taken in: ${JSON.stringify(data)}`);
2778
+ assert.equal(env.store.getBlob(entry.blobHash)?.toString('utf8'), 'now a file');
2779
+ });
2780
+ test("a path sync where disk has a directory over a workspace file takes in what's beneath, and says the file wasn't checked", async (t) => {
2781
+ const env = new Env(t);
2782
+ const m = await env.open();
2783
+ await seedSynced(env, m, 'f', 'a file');
2784
+ rmSync(env.disk('f'));
2785
+ mkdirSync(env.disk('f'));
2786
+ env.writeDisk('f/y.txt', 'y');
2787
+ const data = await call(m, 'sync', { path: 'work/f' });
2788
+ assert.ok(env.store.treeGet('workspace/work/tree', 'f/y.txt'), 'what disk has beneath is taken in');
2789
+ assert.match(JSON.stringify(data.skipped ?? []), /"path":"f".*disk could not be checked: a directory/);
2790
+ });
2791
+ });
2792
+ describe("Mythos's shape", () => {
2793
+ test('one new PNG and three deleted logs in a 350-entry directory', async (t) => {
2794
+ const env = new Env(t);
2795
+ const m = await env.open();
2796
+ const name = (i) => `files/tmp/log-${String(i).padStart(3, '0')}.txt`;
2797
+ for (let i = 0; i < 350; i++)
2798
+ env.writeDisk(name(i), `log ${i}`);
2799
+ await call(m, 'sync', {});
2800
+ env.writeDisk('files/tmp/screenshot.png', ONE_PX_PNG);
2801
+ for (const i of [7, 123, 300])
2802
+ env.rmDisk(name(i));
2803
+ const data = await call(m, 'ls', { path: 'work/files/tmp' });
2804
+ const entries = data.entries;
2805
+ assert.equal(entries.length, 348);
2806
+ assert.deepEqual(entries.find((e) => e.name === 'screenshot.png'), { name: 'screenshot.png', type: 'file', state: 'disk-only', size: ONE_PX_PNG.length, mimeType: 'image/png' });
2807
+ for (const i of [7, 123, 300]) {
2808
+ assert.equal(entries.some((e) => e.name === `log-${String(i).padStart(3, '0')}.txt`), false, `log ${i} is gone`);
2809
+ }
2810
+ assert.equal(entries.filter((e) => e.state === 'synced').length, 347);
2811
+ });
2812
+ });
2813
+ // A field report from a host on main: after a restart lost the watermark, a
2814
+ // bare `materialize` pushing one new file wrote the whole tree, a stale entry
2815
+ // over its newer disk copy included.
2816
+ describe('a bare materialize after a restart', () => {
2817
+ /** The paths each push of `m` was asked to take up, sorted. */
2818
+ function recordPushes(m) {
2819
+ const seen = [];
2820
+ const push = m.pushUnlocked.bind(m);
2821
+ m.pushUnlocked = (mount, paths, opts) => {
2822
+ seen.push([...paths].sort());
2823
+ return push(mount, paths, opts);
2824
+ };
2825
+ return seen;
2826
+ }
2827
+ test('refuses a path with no evidence whose disk copy differs, and writes a new file', async (t) => {
2828
+ const env = new Env(t);
2829
+ let m = await env.open();
2830
+ await seedSynced(env, m, 'old.txt', 'materialized before the restart');
2831
+ await call(m, 'materialize', {});
2832
+ // A tree entry from before the evidence (no P, no #169 baseline), stale:
2833
+ // its disk copy was appended in a shell, and no pass has observed it.
2834
+ env.legacyEntry('notes.txt', 'line 1\n');
2835
+ env.writeDisk('notes.txt', 'line 1\nline 2, appended in a shell\n');
2836
+ m = await env.restart(m);
2837
+ assert.equal((await call(m, 'status', {})).work.lastMaterializedSeq, 0, 'the restart lost the watermark');
2838
+ await call(m, 'write', { path: 'work/new.txt', content: 'one small new file' });
2839
+ const refusal = {
2840
+ mount: 'work',
2841
+ reason: 'notes.txt: disk holds a different copy, and nothing records which is newer (conflict: unknown-provenance) — ' +
2842
+ 'sync this path to adopt the disk version, or materialize this path with force to overwrite it',
2843
+ };
2844
+ const res = await call(m, 'materialize', {});
2845
+ assert.deepEqual(res.materialized.map((w) => w.path), ['new.txt']);
2846
+ assert.equal(env.readDisk('new.txt'), 'one small new file');
2847
+ assert.equal(env.readDisk('notes.txt'), 'line 1\nline 2, appended in a shell\n', 'the disk copy is untouched');
2848
+ assert.deepEqual(res.skipped, [refusal]);
2849
+ assert.equal((await entryOf(m, 'notes.txt'))?.conflict?.kind, 'unknown-provenance');
2850
+ // Nothing tells that disk copy from an older one, so force alone doesn't overwrite it either.
2851
+ const forced = await call(m, 'materialize', { force: true });
2852
+ assert.equal(forced.count, 0);
2853
+ assert.deepEqual(forced.skipped, [refusal]);
2854
+ assert.equal(env.readDisk('notes.txt'), 'line 1\nline 2, appended in a shell\n');
2855
+ await call(m, 'materialize', { path: 'work/notes.txt', force: true });
2856
+ assert.equal(env.readDisk('notes.txt'), 'line 1\n', 'naming the path with force does');
2857
+ });
2858
+ test('a whole-mount restore after a branch switch still overwrites a path with no evidence', async (t) => {
2859
+ const env = new Env(t);
2860
+ const m = await env.open();
2861
+ env.legacyEntry('settings.json', '{"restored": true}');
2862
+ env.writeDisk('settings.json', '{"restored": false}');
2863
+ // materializeMount is the deliberate restore that follows an undo or branch switch: its scope is named.
2864
+ assert.deepEqual(await m.materializeMount('work'), ['settings.json']);
2865
+ assert.equal(env.readDisk('settings.json'), '{"restored": true}');
2866
+ });
2867
+ test('takes up only what the evidence says disk owes, and never reverts a newer disk copy', async (t) => {
2868
+ const env = new Env(t);
2869
+ let m = await env.open();
2870
+ for (const name of ['a.txt', 'b.txt', 'c.txt'])
2871
+ await seedSynced(env, m, name, `${name} v1`);
2872
+ await call(m, 'materialize', {});
2873
+ // Disk moves on after it agreed; the tree hasn't seen it.
2874
+ env.writeDisk('b.txt', 'b.txt v1, appended in a shell');
2875
+ m = await env.restart(m);
2876
+ await call(m, 'write', { path: 'work/new.txt', content: 'new' });
2877
+ const pushes = recordPushes(m);
2878
+ const forced = await call(m, 'materialize', { force: true });
2879
+ assert.deepEqual(pushes, [['new.txt']], 'only the new file is taken up, even with force');
2880
+ assert.deepEqual(forced.materialized.map((w) => w.path), ['new.txt']);
2881
+ assert.equal(env.readDisk('b.txt'), 'b.txt v1, appended in a shell', 'force without a path does not revert it');
2882
+ const bare = await call(m, 'materialize', {});
2883
+ assert.deepEqual(pushes[1], [], 'nothing is owed');
2884
+ assert.equal(bare.count, 0);
2885
+ assert.equal(bare.skipped, undefined);
2886
+ await call(m, 'materialize', { path: 'work/b.txt', force: true });
2887
+ assert.equal(env.readDisk('b.txt'), 'b.txt v1', 'naming the path with force does');
2888
+ });
2889
+ test('pins the branch from the evidence when nothing is owed', async (t) => {
2890
+ const env = new Env(t);
2891
+ let m = await env.open();
2892
+ await seedSynced(env, m, 'a.txt', 'a');
2893
+ const forkAt = env.store.currentSequence();
2894
+ await seedSynced(env, m, 'b.txt', 'b');
2895
+ m = await env.restart(m);
2896
+ assert.equal((await call(m, 'status', {})).work.lastMaterializedBranch, null, 'the restart lost the pin');
2897
+ const first = await call(m, 'materialize', {});
2898
+ assert.equal(first.count, 0, 'nothing is owed');
2899
+ const main = env.store.currentBranch();
2900
+ assert.equal((await call(m, 'status', {})).work.lastMaterializedBranch, main.id, 'disk holds this branch: pinned');
2901
+ // A branch that diverged before b.txt is refused, not let through.
2902
+ env.store.createBranchAt('child', main.name, forkAt);
2903
+ env.store.switchBranch('child');
2904
+ await call(m, 'write', { path: 'work/a.txt', content: 'a from child' });
2905
+ assert.match(await refused(m, 'materialize', {}), /diverged/);
2906
+ assert.equal(env.readDisk('a.txt'), 'a');
2907
+ });
2908
+ test('status counts what disk owes by the evidence, not every tree change since the watermark', async (t) => {
2909
+ const env = new Env(t);
2910
+ const m = await env.open();
2911
+ await seedSynced(env, m, 'a.txt', 'v1');
2912
+ await call(m, 'materialize', {});
2913
+ env.writeDisk('a.txt', 'v2 from a shell');
2914
+ await call(m, 'sync', {});
2915
+ assert.equal(await contentOf(m, 'a.txt'), 'v2 from a shell', 'disk adopted: a tree change disk already holds');
2916
+ // An entry from before the evidence is unchecked, not unpushed.
2917
+ env.legacyEntry('legacy.txt', 'never checked');
2918
+ assert.equal((await call(m, 'status', {})).work.pendingChanges, 0);
2919
+ await call(m, 'write', { path: 'work/a.txt', content: 'v3' });
2920
+ assert.equal((await call(m, 'status', {})).work.pendingChanges, 1);
2921
+ });
2922
+ });
2923
+ describe('the mount root', () => {
2924
+ /** What a drive's unmount looks like from the mount: the root goes, and the watcher makes an empty one. */
2925
+ function unmount(env) {
2926
+ const away = `${env.dir}-away`;
2927
+ renameSync(env.dir, away); // kept, so the new root can't reuse its inode
2928
+ mkdirSync(env.dir);
2929
+ return away;
2930
+ }
2931
+ function remount(env, away) {
2932
+ rmSync(env.dir, { recursive: true });
2933
+ renameSync(away, env.dir);
2934
+ }
2935
+ const identityOf = (path) => {
2936
+ const s = statSync(path, { bigint: true });
2937
+ return { dev: String(s.dev), ino: String(s.ino) };
2938
+ };
2939
+ test('the root disk agreed under is recorded with the first evidence', async (t) => {
2940
+ const env = new Env(t);
2941
+ const m = await env.open();
2942
+ assert.equal(m.agreement.rootIdentity('work'), undefined, 'nothing recorded before any evidence');
2943
+ await seedSynced(env, m, 'a.txt', 'A');
2944
+ assert.deepEqual(m.agreement.rootIdentity('work'), identityOf(env.dir));
2945
+ await seedSynced(env, m, 'b.txt', 'B');
2946
+ await call(m, 'sync', {});
2947
+ const rootEntries = m.agreement.journal.load().entries.filter((e) => e.entry.t === 'root');
2948
+ assert.equal(rootEntries.length, 1, 'recorded once, not with every piece of evidence');
2949
+ });
2950
+ test('a root replaced by an empty directory is unavailable, and the tree keeps every entry', async (t) => {
2951
+ const env = new Env(t);
2952
+ const m = await env.open();
2953
+ await seedSynced(env, m, 'a.txt', 'A');
2954
+ await seedSynced(env, m, 'sub/b.txt', 'B');
2955
+ const away = unmount(env);
2956
+ await m.initialScan('work'); // the reattach
2957
+ assert.notEqual(env.store.treeGet(TREE, 'a.txt'), null, 'a.txt is still in the tree');
2958
+ assert.notEqual(env.store.treeGet(TREE, 'sub/b.txt'), null, 'sub/b.txt is still in the tree');
2959
+ assert.equal(env.eventsOf('workspace:deleted').length, 0, 'no deletion was announced');
2960
+ const synced = await call(m, 'sync', {});
2961
+ assert.equal(synced.totalSynced, 0, 'a plain sync adopts nothing either');
2962
+ assert.match(synced.incomplete?.[0]?.reason ?? '', /not the directory disk last agreed with/);
2963
+ assert.match(synced.incomplete[0].reason, /acceptRoot/, 'the reason names the remedy');
2964
+ assert.notEqual(env.store.treeGet(TREE, 'a.txt'), null, 'still in the tree after the sync');
2965
+ remount(env, away);
2966
+ await m.initialScan('work');
2967
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'back, and agreed as before');
2968
+ assert.equal(await stateOf(m, 'sub/b.txt'), 'synced');
2969
+ });
2970
+ test('the recorded root survives a restart', async (t) => {
2971
+ const env = new Env(t);
2972
+ let m = await env.open();
2973
+ await seedSynced(env, m, 'a.txt', 'A');
2974
+ await env.shutdown(m);
2975
+ unmount(env); // a host that starts while the drive is away
2976
+ env.store = JsStore.openOrCreate({ path: env.storePath });
2977
+ m = await env.open();
2978
+ await m.initialScan('work');
2979
+ assert.notEqual(env.store.treeGet(TREE, 'a.txt'), null, 'still in the tree');
2980
+ assert.equal(env.eventsOf('workspace:deleted').length, 0, 'no deletion was announced');
2981
+ });
2982
+ test('a replaced root takes no writes, forced or not', async (t) => {
2983
+ const env = new Env(t);
2984
+ const m = await env.open();
2985
+ await seedSynced(env, m, 'a.txt', 'A');
2986
+ await call(m, 'write', { path: 'work/c.txt', content: 'C, a draft' });
2987
+ await call(m, 'write', { path: 'work/sub/d.txt', content: 'D, a draft' });
2988
+ unmount(env);
2989
+ mkdirSync(env.disk('sub')); // a mountpoint with leftovers of its own: a directory a write could reach
2990
+ for (const input of [{}, { force: true }, { path: 'work/c.txt', force: true }, { path: 'work/sub/d.txt', force: true }]) {
2991
+ const res = await m.handleToolCall({ id: 't', name: 'materialize', input });
2992
+ assert.equal(existsSync(env.disk('c.txt')), false, `${JSON.stringify(input)} wrote nothing`);
2993
+ assert.equal(existsSync(env.disk('sub/d.txt')), false, `${JSON.stringify(input)} wrote nothing beneath the root`);
2994
+ assert.equal(existsSync(env.disk('a.txt')), false, `${JSON.stringify(input)} restored nothing`);
2995
+ assert.match(JSON.stringify(res), /not the directory disk last agreed with/, `${JSON.stringify(input)} says why`);
2996
+ }
2997
+ });
2998
+ for (const op of ['materialize', 'sync']) {
2999
+ test(`the root is recorded before the first evidence, so a ${op} killed after its intent leaves both`, async (t) => {
3000
+ const env = new Env(t);
3001
+ if (op === 'sync')
3002
+ env.writeDisk('a.txt', 'A, a shell file to adopt');
3003
+ let m = await env.open();
3004
+ if (op === 'materialize')
3005
+ await call(m, 'write', { path: 'work/a.txt', content: 'A, a draft' });
3006
+ assert.equal(m.agreement.paths('work').length, 0, 'no evidence before the operation');
3007
+ m = await env.fault(m, op, 'after-intent');
3008
+ assert.equal(m.agreement.get('work', 'a.txt')?.kind, 'pending', 'the intent is durable');
3009
+ assert.deepEqual(m.agreement.rootIdentity('work'), identityOf(env.dir), 'and so is the root it was made under');
3010
+ });
3011
+ }
3012
+ test('a push that only learns an agreement records the root too', async (t) => {
3013
+ const env = new Env(t);
3014
+ const m = await env.open();
3015
+ env.legacyEntry('a.txt', 'A'); // in the tree, and the same on disk, with no evidence yet
3016
+ env.writeDisk('a.txt', 'A');
3017
+ await call(m, 'materialize', { path: 'work/a.txt' });
3018
+ assert.equal(m.agreement.get('work', 'a.txt')?.kind, 'content', 'the push learned the agreement');
3019
+ assert.equal(statSync(env.disk('a.txt')).size, 1);
3020
+ assert.deepEqual(m.agreement.rootIdentity('work'), identityOf(env.dir), 'and recorded the root it was learned under');
3021
+ });
3022
+ test("a pass records the root with the evidence it gathers", async (t) => {
3023
+ const env = new Env(t);
3024
+ env.writeDisk('a.txt', 'A'); // on disk before the workspace knows anything
3025
+ const m = await env.open();
3026
+ await call(m, 'sync', {});
3027
+ assert.deepEqual(m.agreement.rootIdentity('work'), identityOf(env.dir));
3028
+ unmount(env);
3029
+ await m.initialScan('work');
3030
+ assert.notEqual(env.store.treeGet(TREE, 'a.txt'), null, 'still in the tree');
3031
+ });
3032
+ test('a checkpoint keeps the recorded root', async (t) => {
3033
+ const env = new Env(t);
3034
+ const agreement = new DiskAgreement(env.store);
3035
+ agreement.open([{ name: 'work', root: env.dir }]);
3036
+ agreement.recordRootIdentity('work', { dev: '1', ino: '2' });
3037
+ for (let i = 0; i < 1100; i++)
3038
+ agreement.set('work', `f${i}.txt`, { kind: 'absent' });
3039
+ agreement.maybeCheckpoint();
3040
+ agreement.barrier();
3041
+ const reopened = new DiskAgreement(env.store);
3042
+ reopened.open([{ name: 'work', root: env.dir }]);
3043
+ assert.deepEqual(reopened.rootIdentity('work'), { dev: '1', ino: '2' });
3044
+ assert.equal(reopened.paths('work').length, 1100);
3045
+ });
3046
+ test('a mount configured at another path starts with no recorded root', async (t) => {
3047
+ const env = new Env(t);
3048
+ let m = await env.open();
3049
+ await seedSynced(env, m, 'a.txt', 'A');
3050
+ const elsewhere = join(env.root, 'elsewhere');
3051
+ mkdirSync(elsewhere);
3052
+ m = await env.restart(m, { path: elsewhere });
3053
+ assert.equal(m.agreement.rootIdentity('work'), undefined, 'evidence about another directory is not carried over');
3054
+ });
3055
+ test('acceptRoot takes a replaced root, with its evidence set aside', async (t) => {
3056
+ const env = new Env(t);
3057
+ const m = await env.open();
3058
+ await seedSynced(env, m, 'a.txt', 'A');
3059
+ await seedSynced(env, m, 'sub/b.txt', 'B');
3060
+ unmount(env);
3061
+ env.writeDisk('a.txt', 'A'); // the new root holds the same a.txt, no sub/b.txt, and a new c.txt
3062
+ env.writeDisk('c.txt', 'C');
3063
+ const synced = await call(m, 'sync', { mount: 'work', acceptRoot: true });
3064
+ assert.deepEqual(synced.rootsAccepted, ['work']);
3065
+ assert.deepEqual(m.agreement.rootIdentity('work'), identityOf(env.dir), 'the new root is recorded');
3066
+ assert.equal(await stateOf(m, 'a.txt'), 'synced', 'agreed again');
3067
+ assert.equal(await contentOf(m, 'c.txt'), 'C', "the new root's own file is taken in");
3068
+ assert.notEqual(env.store.treeGet(TREE, 'sub/b.txt'), null, 'a file the new root lacks stays in the workspace');
3069
+ assert.equal(env.eventsOf('workspace:deleted').length, 0, 'nothing was taken as deleted');
3070
+ const again = await call(m, 'sync', { mount: 'work', acceptRoot: true });
3071
+ assert.equal(again.rootsAccepted, undefined, 'the recorded root is not accepted twice');
3072
+ });
3073
+ test('acceptRoot takes a whole mount, not a path', async (t) => {
3074
+ const env = new Env(t);
3075
+ const m = await env.open();
3076
+ assert.match(await refused(m, 'sync', { path: 'work/a.txt', acceptRoot: true }), /whole mount/);
3077
+ });
3078
+ });
3079
+ //# sourceMappingURL=workspace-three-way.test.js.map