@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,839 @@
1
+ /**
2
+ * How the workspace observes disk for reconciliation, under the mount's
3
+ * boundary.
4
+ *
5
+ * Observation honors the boundary the way openContainedFile does: the lexical
6
+ * path is lstat'd first, a symlink is refused unless the mount follows
7
+ * symlinks, and a followed one must resolve inside the canonical mount root.
8
+ * Anything that isn't a regular file inside the mount is `unobserved` — it
9
+ * proves nothing either way, and reconciliation leaves the path alone.
10
+ *
11
+ * Reads go through one descriptor that is fstat'd, located inside the mount
12
+ * and then read, so what is read and the fingerprint recorded with its hash
13
+ * describe the same file, inside the mount. A hard link is a name inside the
14
+ * mount like any other, and reading through it shows what that path holds;
15
+ * only a write in place reaches a file's other names, so effects.ts refuses
16
+ * that instead.
17
+ *
18
+ * Directories are checked (CheckedDir): located inside the mount, with their
19
+ * identity, and what they showed — a listing, an absence — is accepted only
20
+ * if each is still the directory its logical path names afterwards. A walk records which
21
+ * directories it listed completely, and only those prove a file absent: an
22
+ * unreadable directory, an ignored subtree, the file cap, a symlink the walk
23
+ * doesn't descend, or a directory that changed while it was listed leave
24
+ * everything beneath them unproven. Outside a walk, absence is proven the
25
+ * same way: by a directory inside the mount, still at its path, that lacks
26
+ * the entry (confirmAbsent). A missing parent alone proves nothing, since a
27
+ * dangling symlink or a swapped directory looks just like one.
28
+ *
29
+ * Each check is made where the operation happens, against what is there at
30
+ * that moment, so the boundary holds against what is in the mount, including
31
+ * what appears while an operation runs. Node has no openat or fdopendir, so
32
+ * a directory is checked by its canonical path and identity, not held open:
33
+ * a parent replaced concurrently in the window between a check and its use
34
+ * (or replaced and restored around it) can still redirect a listing or
35
+ * lookup there. File contents are bound regardless: a file is read only
36
+ * through a descriptor bound to its path first.
37
+ */
38
+
39
+ import { createHash } from 'node:crypto';
40
+ import { constants as fsConstants } from 'node:fs';
41
+ import type { BigIntStats } from 'node:fs';
42
+ import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises';
43
+ import type { FileHandle } from 'node:fs/promises';
44
+ import { dirname, join, sep } from 'node:path';
45
+ import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
46
+
47
+ export interface MountView {
48
+ /** Mount root as configured. */
49
+ root: string;
50
+ /**
51
+ * The root directory disk last agreed under, once the mount has evidence.
52
+ * A root that resolves to any other directory is unavailable.
53
+ */
54
+ rootIdentity?: RootIdentity;
55
+ followSymlinks: boolean;
56
+ /** Text above this size is not ingested. */
57
+ maxFileSize: number;
58
+ ignore: string[];
59
+ }
60
+
61
+ export type StatFingerprint = Omit<Fingerprint, 'hashedAt'>;
62
+
63
+ /**
64
+ * Opened with every read or write of a workspace path, where the platform has
65
+ * it: for a regular file it changes nothing, and a FIFO or other special file
66
+ * swapped in at the path neither blocks the open nor passes the regular-file
67
+ * check that follows.
68
+ */
69
+ export const NONBLOCK = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0;
70
+
71
+ export type Observation =
72
+ | { kind: 'absent' }
73
+ | { kind: 'file'; size: number; mtimeMs: number; fp: StatFingerprint }
74
+ | { kind: 'unobserved'; reason: string };
75
+
76
+ export interface ReadResult {
77
+ kind: 'read';
78
+ size: number;
79
+ mtimeMs: number;
80
+ fp: Fingerprint;
81
+ hash: string;
82
+ /** The whole file, when it is at most the requested limit. */
83
+ bytes: Buffer | null;
84
+ /** First bytes, for binary detection and MIME sniffing. */
85
+ head: Buffer;
86
+ }
87
+
88
+ /** The first bytes of a file and its size: nothing else was read or hashed. */
89
+ export interface HeadResult {
90
+ kind: 'head';
91
+ size: number;
92
+ mtimeMs: number;
93
+ head: Buffer;
94
+ }
95
+
96
+ /** Bytes hashed per read when streaming a file larger than the read limit. */
97
+ const HASH_CHUNK = 1 << 20;
98
+ /** Bytes kept for binary detection and MIME sniffing. */
99
+ export const HEAD_BYTES = 8192;
100
+ /** Timestamps this close to the hashing time can't vouch for the bytes. */
101
+ export const RACY_WINDOW_MS = 2000;
102
+
103
+ export const OUTSIDE_PARENT = 'a parent directory resolves outside the mount';
104
+ export const NOT_FOLLOWED = 'a symlink, which this mount does not follow';
105
+ export const ROOT_UNAVAILABLE = 'the mount root is unavailable';
106
+ export const ROOT_REPLACED =
107
+ 'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' +
108
+ 'reconnect it; if it was replaced on purpose, sync the mount with acceptRoot to take it as it is now';
109
+ const CHANGED = 'it changed while it was observed';
110
+ const CHANGED_WALK = 'the directory changed while the walk ran';
111
+ const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
112
+
113
+ export function errno(err: unknown): string | undefined {
114
+ return (err as { code?: string } | null)?.code;
115
+ }
116
+
117
+ export function contained(rootReal: string, candidate: string): boolean {
118
+ return candidate === rootReal || candidate.startsWith(rootReal.endsWith(sep) ? rootReal : rootReal + sep);
119
+ }
120
+
121
+ /** The mount-relative parent of a path ('' for the root). */
122
+ export function parentOf(relativePath: string): string {
123
+ const slash = relativePath.lastIndexOf('/');
124
+ return slash < 0 ? '' : relativePath.slice(0, slash);
125
+ }
126
+
127
+ /** The last component of a mount-relative path. */
128
+ export function nameOf(relativePath: string): string {
129
+ return relativePath.slice(relativePath.lastIndexOf('/') + 1);
130
+ }
131
+
132
+ function lexicalOf(view: MountView, rel: string): string {
133
+ return rel === '' ? view.root : join(view.root, rel);
134
+ }
135
+
136
+ // ===========================================================================
137
+ // Checked directories
138
+ // ===========================================================================
139
+
140
+ export type DirCheck =
141
+ | { kind: 'checked'; dir: CheckedDir }
142
+ /**
143
+ * No directory there, proven: nothing can exist beneath the path. `by` is
144
+ * the directory whose entries prove it — the one that lacks it, or that
145
+ * holds the non-directory in its place.
146
+ */
147
+ | { kind: 'absent'; by: string }
148
+ /** Unproven either way; `outside` when it resolves outside the mount. */
149
+ | { kind: 'unobserved'; reason: string; outside?: true };
150
+
151
+ /** Errors meaning a directory can't be synced on this filesystem, not that syncing it failed. */
152
+ const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
153
+
154
+ /**
155
+ * A directory inside the mount, as it was checked: its canonical path and
156
+ * its identity. Its entries are named through that path, and what it showed
157
+ * is accepted only while it is still the directory its logical path names
158
+ * (see the module doc for what that does and doesn't bind).
159
+ */
160
+ export class CheckedDir {
161
+ constructor(
162
+ /** Mount-relative path ('' for the root). */
163
+ readonly rel: string,
164
+ /** Its canonical location when checked. */
165
+ readonly real: string,
166
+ private readonly lexical: string,
167
+ private readonly dev: bigint,
168
+ private readonly ino: bigint,
169
+ ) {}
170
+
171
+ /** Which directory it is. */
172
+ identity(): RootIdentity {
173
+ return { dev: String(this.dev), ino: String(this.ino) };
174
+ }
175
+
176
+ /** A path that reaches `name` in this directory. */
177
+ at(name: string): string {
178
+ return join(this.real, name);
179
+ }
180
+
181
+ /** Whether it is still the directory its logical path names: same location, same directory. */
182
+ async stillHere(): Promise<boolean> {
183
+ if ((await realpath(this.lexical).catch(() => null)) !== this.real) return false;
184
+ const info = await lstat(this.real, { bigint: true }).catch(() => null);
185
+ return info !== null && info.isDirectory() && info.dev === this.dev && info.ino === this.ino;
186
+ }
187
+
188
+ /**
189
+ * Make its entries durable: this very directory, never one that replaced
190
+ * it at its path. Throws if it can't; a filesystem that can't sync
191
+ * directories (or Windows, where a directory can't be opened for it) is
192
+ * not a failure.
193
+ */
194
+ async sync(): Promise<void> {
195
+ if (process.platform === 'win32') return;
196
+ const handle = await open(this.real, 'r');
197
+ try {
198
+ const info = await handle.stat({ bigint: true });
199
+ if (!info.isDirectory() || info.dev !== this.dev || info.ino !== this.ino) throw new Error(CHANGED);
200
+ try {
201
+ await handle.sync();
202
+ } catch (err) {
203
+ if (!DIRECTORY_SYNC_UNSUPPORTED.has(errno(err) ?? '')) throw err;
204
+ }
205
+ } finally {
206
+ await handle.close();
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Check the directory at a canonical location, if one is there. */
212
+ async function checkedAt(view: MountView, rel: string, real: string): Promise<CheckedDir | null> {
213
+ const info = await lstat(real, { bigint: true }).catch(() => null);
214
+ if (!info?.isDirectory()) return null;
215
+ return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino);
216
+ }
217
+
218
+ /**
219
+ * Where a mount directory is now: checked; missing — nothing there, or not a
220
+ * directory, either way for confirmAbsent to prove; or not inside the mount.
221
+ */
222
+ async function locateDir(view: MountView, rootReal: string, rel: string): Promise<DirCheck | { kind: 'missing' }> {
223
+ const lexical = lexicalOf(view, rel);
224
+ let real: string;
225
+ try {
226
+ real = await realpath(lexical);
227
+ } catch (err) {
228
+ const code = errno(err);
229
+ if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
230
+ return { kind: 'unobserved', reason: `cannot resolve the directory (${code ?? String(err)})` };
231
+ }
232
+ if (rel === '') {
233
+ // The root is whatever the configured path names, and it must still be
234
+ // the root the pass was given.
235
+ if (real !== rootReal) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE };
236
+ } else if (!contained(rootReal, real)) {
237
+ return { kind: 'unobserved', reason: OUTSIDE_PARENT, outside: true };
238
+ }
239
+ const checked = await checkedAt(view, rel, real);
240
+ if (checked) {
241
+ // A root that isn't the directory disk last agreed under proves nothing
242
+ // about the files that agreed: an empty mountpoint where a drive was
243
+ // would otherwise read as every file deleted.
244
+ if (rel === '' && view.rootIdentity && !sameRootIdentity(checked.identity(), view.rootIdentity)) {
245
+ return { kind: 'unobserved', reason: ROOT_REPLACED };
246
+ }
247
+ return { kind: 'checked', dir: checked };
248
+ }
249
+ return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
250
+ }
251
+
252
+ /**
253
+ * The mount root as it is now: its identity if it is available (a directory,
254
+ * the one the pass was given, and the one disk last agreed under), else why not.
255
+ */
256
+ export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string }> {
257
+ const found = await checkRoot(view, rootReal);
258
+ return 'dir' in found ? { identity: found.dir.identity() } : found;
259
+ }
260
+
261
+ /** The mount root as locateRoot finds it, checked, so a caller can ask later whether it's still there. */
262
+ export async function checkRoot(view: MountView, rootReal: string): Promise<{ dir: CheckedDir } | { reason: string }> {
263
+ const found = await locateDir(view, rootReal, '');
264
+ if (found.kind === 'checked') return { dir: found.dir };
265
+ return { reason: found.kind === 'unobserved' ? found.reason : ROOT_UNAVAILABLE };
266
+ }
267
+
268
+ /**
269
+ * Check the directory at a mount-relative path. With `create`, missing
270
+ * directories are made one at a time beneath the deepest one that exists;
271
+ * the mount root itself is never created (a missing root is an unavailable
272
+ * mount). Without it, a missing directory is `absent` only where that can be
273
+ * proven (confirmAbsent).
274
+ */
275
+ export async function checkDir(view: MountView, rootReal: string, rel: string, opts: { create?: boolean } = {}): Promise<DirCheck> {
276
+ const found = await locateDir(view, rootReal, rel);
277
+ if (found.kind !== 'missing') return found;
278
+ if (opts.create) return createDir(view, rootReal, rel);
279
+ const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
280
+ return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
281
+ }
282
+
283
+ async function createDir(view: MountView, rootReal: string, rel: string): Promise<DirCheck> {
284
+ const parts = rel.split('/');
285
+ let base: CheckedDir | null = null;
286
+ let depth = parts.length - 1;
287
+ for (; depth >= 0; depth--) {
288
+ const found = await locateDir(view, rootReal, parts.slice(0, depth).join('/'));
289
+ if (found.kind === 'checked') {
290
+ base = found.dir;
291
+ break;
292
+ }
293
+ if (found.kind !== 'missing') return found;
294
+ }
295
+ if (base === null) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE };
296
+ for (let i = depth; i < parts.length; i++) {
297
+ const childRel = parts.slice(0, i + 1).join('/');
298
+ try {
299
+ await mkdir(base.at(parts[i]!));
300
+ } catch (err) {
301
+ const code = errno(err);
302
+ if (code !== 'EEXIST') return { kind: 'unobserved', reason: `cannot create the directory ${childRel} (${code ?? String(err)})` };
303
+ }
304
+ // Made, or there already (made meanwhile, or not a directory): it must
305
+ // be a real directory now, never a symlink or file in its place.
306
+ const checked = await checkedAt(view, childRel, base.at(parts[i]!));
307
+ if (!checked) return { kind: 'unobserved', reason: 'a parent path is not a directory' };
308
+ base = checked;
309
+ }
310
+ return { kind: 'checked', dir: base };
311
+ }
312
+
313
+ /**
314
+ * Prove that nothing exists at a mount-relative path (`entry`), or that no
315
+ * directory does, so nothing can exist beneath it (`beneath`). Walking down
316
+ * from the root through checked directories: some directory lacks the next
317
+ * component; or a component is a non-directory, still the same one, beneath
318
+ * which nothing can exist. Either proves it only while every directory the
319
+ * walk went through, the root included, is still the one its path names
320
+ * afterwards: a root or parent replaced during the walk would otherwise lend
321
+ * its replacement's absence to the original's path. Symlinked directories
322
+ * inside the mount are followed as parents; any other symlink proves
323
+ * nothing. Returns the directory whose entries prove it, or why absence
324
+ * couldn't be shown.
325
+ */
326
+ export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | string> {
327
+ const root = await locateDir(view, rootReal, '');
328
+ if (root.kind !== 'checked') return ROOT_UNAVAILABLE;
329
+ let cur: CheckedDir = root.dir;
330
+ /** Every directory the walk went through, the root first. */
331
+ const walked: CheckedDir[] = [cur];
332
+ const proven = async (): Promise<{ by: string } | string> => {
333
+ for (const dir of walked) if (!(await dir.stillHere())) return CHANGED;
334
+ return { by: cur.rel };
335
+ };
336
+ const parts = rel.split('/');
337
+ for (let i = 0; i < parts.length; i++) {
338
+ const name = parts[i]!;
339
+ const last = i === parts.length - 1;
340
+ const childRel = parts.slice(0, i + 1).join('/');
341
+ let info;
342
+ try {
343
+ info = await lstat(cur.at(name), { bigint: true });
344
+ } catch (err) {
345
+ const code = errno(err);
346
+ if (code === 'ENOTDIR') return CHANGED;
347
+ if (code !== 'ENOENT') return `cannot stat (${code ?? String(err)})`;
348
+ // Absent from a directory that, like each above it, is still the one its path names.
349
+ return proven();
350
+ }
351
+ if (info.isDirectory()) {
352
+ if (last) return CHANGED; // present after all
353
+ cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino);
354
+ walked.push(cur);
355
+ continue;
356
+ }
357
+ if (info.isSymbolicLink()) {
358
+ if (last && target === 'entry') return CHANGED; // present: the link itself
359
+ const located = await locateDir(view, rootReal, childRel);
360
+ if (located.kind !== 'checked') return BENEATH_SYMLINK;
361
+ if (last) return CHANGED; // a directory there after all
362
+ cur = located.dir;
363
+ walked.push(cur);
364
+ continue;
365
+ }
366
+ if (last && target === 'entry') return CHANGED; // present after all
367
+ // A non-directory: nothing can exist beneath it, while it is still this
368
+ // same one, in the directory still at its path.
369
+ const again = await lstat(cur.at(name), { bigint: true }).catch(() => null);
370
+ if (again === null || again.isDirectory() || again.isSymbolicLink() || again.dev !== info.dev || again.ino !== info.ino) return CHANGED;
371
+ return proven();
372
+ }
373
+ return CHANGED;
374
+ }
375
+
376
+ async function absence(view: MountView, rootReal: string, rel: string): Promise<Exclude<Observation, { kind: 'file' }>> {
377
+ const proof = await confirmAbsent(view, rootReal, rel);
378
+ return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent' };
379
+ }
380
+
381
+ // ===========================================================================
382
+ // Fingerprints
383
+ // ===========================================================================
384
+
385
+ function statFingerprint(st: { size: bigint; mtimeNs: bigint; ctimeNs: bigint; ino: bigint; dev: bigint }): StatFingerprint {
386
+ return {
387
+ size: Number(st.size),
388
+ mtimeNs: st.mtimeNs.toString(),
389
+ ctimeNs: st.ctimeNs.toString(),
390
+ ino: st.ino.toString(),
391
+ dev: st.dev.toString(),
392
+ };
393
+ }
394
+
395
+ /** Same file, same bytes, as far as stat can tell. */
396
+ export function sameFingerprint(a: StatFingerprint, b: StatFingerprint): boolean {
397
+ return a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino && a.dev === b.dev;
398
+ }
399
+
400
+ /**
401
+ * Whether a recorded fingerprint vouches that the current file still holds
402
+ * the bytes hashed with it: the filesystem maintains ctime (so no user-space
403
+ * tool can restore the old stamps after a write), every field matches, and
404
+ * the file's timestamps were already more than the racy window old when it
405
+ * was hashed (a write in the same timestamp tick can't hide behind them).
406
+ * Device and inode are part of it, so a stat that reached another file — a
407
+ * path whose parent was swapped, say — can never vouch for P's bytes.
408
+ */
409
+ export function fingerprintVouches(recorded: Fingerprint | undefined, current: StatFingerprint, trusted: boolean): boolean {
410
+ if (!trusted || !recorded || !sameFingerprint(recorded, current)) return false;
411
+ const newest = Math.max(Number(BigInt(current.mtimeNs) / 1_000_000n), Number(BigInt(current.ctimeNs) / 1_000_000n));
412
+ return recorded.hashedAt - newest > RACY_WINDOW_MS;
413
+ }
414
+
415
+ /**
416
+ * Linux filesystems known to maintain ctime on every content write (statfs
417
+ * f_type). Elsewhere a fingerprint can't vouch, and every observation rehashes.
418
+ */
419
+ const CTIME_TRUSTED_FS_TYPES = new Set<number>([
420
+ 0xef53, // ext2/ext3/ext4
421
+ 0x58465342, // xfs
422
+ 0x9123683e, // btrfs
423
+ 0x01021994, // tmpfs
424
+ 0xf2f52010, // f2fs
425
+ 0x2fc12fc1, // zfs
426
+ 0x794c7630, // overlayfs
427
+ ]);
428
+
429
+ /** Whether a statfs f_type is one of those filesystems. */
430
+ export function fsTypeTrustsCtime(type: number | bigint): boolean {
431
+ return CTIME_TRUSTED_FS_TYPES.has(Number(type) >>> 0);
432
+ }
433
+
434
+ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
435
+ if (process.platform !== 'linux') return false;
436
+ try {
437
+ return fsTypeTrustsCtime((await statfs(root)).type);
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+
443
+ // ===========================================================================
444
+ // Paths and files
445
+ // ===========================================================================
446
+
447
+ /**
448
+ * Where a path's parent directory canonically lies: inside the mount, gone
449
+ * (absence is then proven separately), or somewhere a no-follow open would
450
+ * still reach through — O_NOFOLLOW guards only the final component.
451
+ */
452
+ async function parentPlacement(rootReal: string, lexical: string): Promise<'inside' | 'gone' | { reason: string }> {
453
+ try {
454
+ const parent = await realpath(dirname(lexical));
455
+ return contained(rootReal, parent) ? 'inside' : { reason: OUTSIDE_PARENT };
456
+ } catch (err) {
457
+ const code = errno(err);
458
+ if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
459
+ return { reason: `cannot resolve the parent directory (${code ?? String(err)})` };
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Observe a mount-relative path without reading it. A `file` here is only a
465
+ * stat — anything decided on its bytes is read through a located descriptor,
466
+ * or vouched for by a fingerprint of the very inode P was recorded from — so
467
+ * only `absent` needs proof beyond the path lookup, and gets it.
468
+ */
469
+ export async function observePath(view: MountView, rootReal: string, relativePath: string): Promise<Observation> {
470
+ const lexical = join(view.root, relativePath);
471
+ const placement = await parentPlacement(rootReal, lexical);
472
+ if (placement === 'gone') return absence(view, rootReal, relativePath);
473
+ if (placement !== 'inside') return { kind: 'unobserved', reason: placement.reason };
474
+ let info;
475
+ try {
476
+ info = await lstat(lexical, { bigint: true });
477
+ } catch (err) {
478
+ const code = errno(err);
479
+ if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
480
+ return { kind: 'unobserved', reason: `cannot stat (${code ?? String(err)})` };
481
+ }
482
+ if (info.isSymbolicLink()) {
483
+ if (!view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
484
+ let target;
485
+ try {
486
+ target = await stat(lexical, { bigint: true });
487
+ } catch (err) {
488
+ return { kind: 'unobserved', reason: `a symlink whose target cannot be read (${errno(err) ?? String(err)})` };
489
+ }
490
+ let real: string;
491
+ try {
492
+ real = await realpath(lexical);
493
+ } catch (err) {
494
+ return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
495
+ }
496
+ if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
497
+ if (!target.isFile()) return { kind: 'unobserved', reason: 'not a regular file' };
498
+ info = target;
499
+ } else if (info.isDirectory()) {
500
+ return { kind: 'unobserved', reason: 'a directory' };
501
+ } else if (!info.isFile()) {
502
+ return { kind: 'unobserved', reason: 'not a regular file' };
503
+ }
504
+ return { kind: 'file', size: Number(info.size), mtimeMs: Number(info.mtimeMs), fp: statFingerprint(info) };
505
+ }
506
+
507
+ /**
508
+ * Open a mount-relative file for reading, located: a regular file that
509
+ * canonically lies inside the mount — whatever the symlink policy, since a
510
+ * symlinked parent escapes O_NOFOLLOW — and is the file the canonical path
511
+ * names now. The handle is the caller's to close.
512
+ */
513
+ async function openContained(
514
+ view: MountView,
515
+ rootReal: string,
516
+ relativePath: string,
517
+ ): Promise<{ handle: FileHandle; info: BigIntStats } | Exclude<Observation, { kind: 'file' }>> {
518
+ const lexical = join(view.root, relativePath);
519
+ const noFollow = !view.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number';
520
+ if (!view.followSymlinks && !noFollow) {
521
+ // No O_NOFOLLOW (Windows): refuse a symlink by lstat before opening.
522
+ const seen = await observePath(view, rootReal, relativePath);
523
+ if (seen.kind !== 'file') return seen;
524
+ }
525
+ let handle;
526
+ try {
527
+ // O_NONBLOCK: a FIFO swapped in since any lstat opens at once instead of
528
+ // waiting for a writer, and the regular-file check below refuses it.
529
+ handle = await open(lexical, fsConstants.O_RDONLY | NONBLOCK | (noFollow ? fsConstants.O_NOFOLLOW : 0));
530
+ } catch (err) {
531
+ const code = errno(err);
532
+ if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
533
+ if (code === 'ELOOP' && !view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
534
+ if (code === 'EISDIR') return { kind: 'unobserved', reason: 'a directory' };
535
+ return { kind: 'unobserved', reason: `cannot open (${code ?? String(err)})` };
536
+ }
537
+ let keep = false;
538
+ try {
539
+ const info = await handle.stat({ bigint: true });
540
+ if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? 'a directory' : 'not a regular file' };
541
+ const real = await realpath(lexical).catch(() => null);
542
+ if (real === null) return { kind: 'unobserved', reason: 'the file changed while it was read' };
543
+ if (!contained(rootReal, real)) {
544
+ return { kind: 'unobserved', reason: view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT };
545
+ }
546
+ const named = await stat(real, { bigint: true }).catch(() => null);
547
+ if (named === null || named.dev !== info.dev || named.ino !== info.ino) {
548
+ return { kind: 'unobserved', reason: 'the file changed while it was read' };
549
+ }
550
+ if (!view.followSymlinks) {
551
+ // Where no symlink is followed, the path itself must be the file the
552
+ // descriptor holds: without O_NOFOLLOW, a link swapped in after the
553
+ // lstat above is followed by the open.
554
+ const entry = await lstat(lexical, { bigint: true }).catch(() => null);
555
+ if (entry?.isSymbolicLink()) return { kind: 'unobserved', reason: NOT_FOLLOWED };
556
+ if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) {
557
+ return { kind: 'unobserved', reason: 'the file changed while it was read' };
558
+ }
559
+ }
560
+ keep = true;
561
+ return { handle, info };
562
+ } catch (err) {
563
+ const code = errno(err);
564
+ if (code === 'ENOENT') return absence(view, rootReal, relativePath);
565
+ return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
566
+ } finally {
567
+ if (!keep) await handle.close();
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Read and hash a mount-relative file through one located descriptor. The
573
+ * whole file is returned when it is at most `limit` bytes; larger files are
574
+ * hashed in chunks and only their head is kept.
575
+ */
576
+ export async function readPath(
577
+ view: MountView,
578
+ rootReal: string,
579
+ relativePath: string,
580
+ limit: number,
581
+ ): Promise<ReadResult | Exclude<Observation, { kind: 'file' }>> {
582
+ const opened = await openContained(view, rootReal, relativePath);
583
+ if (!('handle' in opened)) return opened;
584
+ const { handle, info } = opened;
585
+ try {
586
+ const hashedAt = Date.now();
587
+ const size = Number(info.size);
588
+ const fp: Fingerprint = { ...statFingerprint(info), hashedAt };
589
+ if (size <= limit) {
590
+ const bytes = await handle.readFile();
591
+ return {
592
+ kind: 'read',
593
+ size: bytes.byteLength,
594
+ mtimeMs: Number(info.mtimeMs),
595
+ fp: { ...fp, size: bytes.byteLength },
596
+ hash: createHash('sha256').update(bytes).digest('hex'),
597
+ bytes,
598
+ head: bytes.subarray(0, HEAD_BYTES),
599
+ };
600
+ }
601
+ const hash = createHash('sha256');
602
+ const chunk = Buffer.alloc(HASH_CHUNK);
603
+ let head: Buffer | null = null;
604
+ let offset = 0;
605
+ for (;;) {
606
+ const { bytesRead } = await handle.read(chunk, 0, HASH_CHUNK, offset);
607
+ if (bytesRead === 0) break;
608
+ if (head === null) head = Buffer.from(chunk.subarray(0, Math.min(bytesRead, HEAD_BYTES)));
609
+ hash.update(chunk.subarray(0, bytesRead));
610
+ offset += bytesRead;
611
+ }
612
+ return {
613
+ kind: 'read',
614
+ size: offset,
615
+ mtimeMs: Number(info.mtimeMs),
616
+ fp: { ...fp, size: offset },
617
+ hash: hash.digest('hex'),
618
+ bytes: null,
619
+ head: head ?? Buffer.alloc(0),
620
+ };
621
+ } catch (err) {
622
+ const code = errno(err);
623
+ if (code === 'ENOENT') return absence(view, rootReal, relativePath);
624
+ return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
625
+ } finally {
626
+ await handle.close();
627
+ }
628
+ }
629
+
630
+ /**
631
+ * The first bytes of a mount-relative file and its size, through the same
632
+ * located descriptor as readPath — for listing a file nothing tracks, which
633
+ * needs a MIME type, not a hash.
634
+ */
635
+ export async function readHead(
636
+ view: MountView,
637
+ rootReal: string,
638
+ relativePath: string,
639
+ ): Promise<HeadResult | Exclude<Observation, { kind: 'file' }>> {
640
+ const opened = await openContained(view, rootReal, relativePath);
641
+ if (!('handle' in opened)) return opened;
642
+ const { handle, info } = opened;
643
+ try {
644
+ const head = Buffer.alloc(HEAD_BYTES);
645
+ const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
646
+ return { kind: 'head', size: Number(info.size), mtimeMs: Number(info.mtimeMs), head: head.subarray(0, bytesRead) };
647
+ } catch (err) {
648
+ return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
649
+ } finally {
650
+ await handle.close();
651
+ }
652
+ }
653
+
654
+ /** Null bytes in the first 8 KB: the store holds text only from disk. */
655
+ export function looksBinary(head: Buffer): boolean {
656
+ return head.subarray(0, HEAD_BYTES).includes(0);
657
+ }
658
+
659
+ /** Image MIME type from magic bytes, for listing disk-only files. */
660
+ export function sniffImageMime(head: Buffer): string | undefined {
661
+ if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';
662
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
663
+ if (head.length >= 6 && (head.subarray(0, 6).toString('ascii') === 'GIF87a' || head.subarray(0, 6).toString('ascii') === 'GIF89a')) return 'image/gif';
664
+ if (head.length >= 12 && head.subarray(0, 4).toString('ascii') === 'RIFF' && head.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
665
+ return undefined;
666
+ }
667
+
668
+ // ===========================================================================
669
+ // Walks
670
+ // ===========================================================================
671
+
672
+ export interface Walk {
673
+ /** Regular files seen. */
674
+ files: Set<string>;
675
+ /** Directories seen. */
676
+ dirs: Set<string>;
677
+ /** Names seen that are neither (symlinks, sockets…), or ignored: present, unproven. */
678
+ others: Map<string, string>;
679
+ /** Directories listed completely. */
680
+ complete: Set<string>;
681
+ /** Directories that don't exist (everything beneath them is absent). */
682
+ missing: Set<string>;
683
+ /**
684
+ * Where the walk didn't reach, and why. `error` and `cap` regions may hide
685
+ * files the listing would show; `ignored` and `symlink` ones matter only
686
+ * where tracked entries lie beneath them.
687
+ */
688
+ incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink' }>;
689
+ }
690
+
691
+ /** Simple ignore patterns, as the watcher and the old walk read them. */
692
+ export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean {
693
+ for (const pattern of patterns) {
694
+ if (pattern === name) return true;
695
+ if (pattern.endsWith('/**')) {
696
+ const prefix = pattern.slice(0, -3);
697
+ if (relativePath === prefix || relativePath.startsWith(prefix + '/')) return true;
698
+ }
699
+ if (!pattern.includes('/') && relativePath === pattern) return true;
700
+ if (pattern.startsWith('*.') && name.endsWith(pattern.slice(1))) return true;
701
+ }
702
+ return false;
703
+ }
704
+
705
+ /**
706
+ * A subdirectory a checked directory's listing showed, checked in turn: still a
707
+ * real directory beneath that same directory. One that became a symlink or a
708
+ * file since is never followed, and proves nothing.
709
+ */
710
+ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir, rel: string): Promise<DirCheck> {
711
+ const real = parent.at(nameOf(rel));
712
+ let info;
713
+ try {
714
+ info = await lstat(real, { bigint: true });
715
+ } catch (err) {
716
+ if (errno(err) !== 'ENOENT') return { kind: 'unobserved', reason: `cannot stat (${errno(err) ?? String(err)})` };
717
+ const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
718
+ return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
719
+ }
720
+ if (!info.isDirectory()) return { kind: 'unobserved', reason: CHANGED_WALK };
721
+ return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino) };
722
+ }
723
+
724
+ /**
725
+ * List a directory scope ('' for the mount root), descending when recursive,
726
+ * up to `cap` files in total. A scope that resolves outside the mount is not
727
+ * listed at all; beneath it, the walk only descends real directories, never
728
+ * symlinks. Each directory's listing counts only if, once listed, it is still
729
+ * the directory at its path: a directory moved or replaced meanwhile is an
730
+ * incomplete region, contributing no names and proving nothing absent.
731
+ */
732
+ export async function walkScope(view: MountView, rootReal: string, scope: string, recursive: boolean, cap: number): Promise<Walk> {
733
+ const walk: Walk = { files: new Set(), dirs: new Set(), others: new Map(), complete: new Set(), missing: new Set(), incomplete: [] };
734
+ const top = await checkDir(view, rootReal, scope);
735
+ if (top.kind === 'absent') {
736
+ walk.missing.add(scope);
737
+ return walk;
738
+ }
739
+ if (top.kind === 'unobserved') {
740
+ walk.incomplete.push({ path: scope, reason: top.outside ? 'the directory resolves outside the mount' : top.reason, kind: 'error' });
741
+ return walk;
742
+ }
743
+ let capped = false;
744
+
745
+ const visit = async (dir: CheckedDir): Promise<void> => {
746
+ const rel = dir.rel;
747
+ if (capped) {
748
+ walk.incomplete.push({ path: rel, reason: `not visited: the file cap (${cap}) was reached`, kind: 'cap' });
749
+ return;
750
+ }
751
+ let entries;
752
+ try {
753
+ entries = await readdir(dir.real, { withFileTypes: true });
754
+ } catch (err) {
755
+ const code = errno(err);
756
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
757
+ // Gone since it was checked: absent beneath only if that can be shown.
758
+ const proof = rel === '' ? ROOT_UNAVAILABLE : await confirmAbsent(view, rootReal, rel, 'beneath');
759
+ if (typeof proof === 'string') walk.incomplete.push({ path: rel, reason: proof, kind: 'error' });
760
+ else walk.missing.add(rel);
761
+ return;
762
+ }
763
+ walk.incomplete.push({ path: rel, reason: `cannot list (${code ?? String(err)})`, kind: 'error' });
764
+ return;
765
+ }
766
+ const files: string[] = [];
767
+ const subdirs: string[] = [];
768
+ const others: Array<[string, string]> = [];
769
+ const regions: Walk['incomplete'] = [];
770
+ let reachedCap = false;
771
+ for (const entry of entries) {
772
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
773
+ if (isIgnored(childRel, entry.name, view.ignore)) {
774
+ others.push([childRel, 'ignored by the mount']);
775
+ regions.push({ path: childRel, reason: 'ignored by the mount', kind: 'ignored' });
776
+ continue;
777
+ }
778
+ if (entry.isDirectory()) {
779
+ subdirs.push(childRel);
780
+ } else if (entry.isFile()) {
781
+ if (walk.files.size + files.length >= cap) {
782
+ capped = true;
783
+ reachedCap = true;
784
+ regions.push({ path: rel, reason: `the file cap (${cap}) was reached`, kind: 'cap' });
785
+ break;
786
+ }
787
+ files.push(childRel);
788
+ } else {
789
+ const symlink = entry.isSymbolicLink();
790
+ others.push([childRel, symlink ? 'a symlink' : 'not a regular file']);
791
+ if (symlink) regions.push({ path: childRel, reason: 'a symlink, not walked', kind: 'symlink' });
792
+ }
793
+ }
794
+ // A listing of a directory that was moved or replaced meanwhile says
795
+ // nothing about this path: neither its names nor its absences count.
796
+ if (!(await dir.stillHere())) {
797
+ walk.incomplete.push({ path: rel, reason: CHANGED_WALK, kind: 'error' });
798
+ return;
799
+ }
800
+ for (const f of files) walk.files.add(f);
801
+ for (const d of subdirs) walk.dirs.add(d);
802
+ for (const [p, why] of others) walk.others.set(p, why);
803
+ walk.incomplete.push(...regions);
804
+ if (reachedCap) return;
805
+ walk.complete.add(rel);
806
+ if (!recursive) return;
807
+ for (const sub of subdirs) {
808
+ const child = await checkChild(view, rootReal, dir, sub);
809
+ if (child.kind === 'checked') await visit(child.dir);
810
+ else if (child.kind === 'absent') walk.missing.add(sub);
811
+ else walk.incomplete.push({ path: sub, reason: child.reason, kind: 'error' });
812
+ }
813
+ };
814
+
815
+ await visit(top.dir);
816
+ return walk;
817
+ }
818
+
819
+ /**
820
+ * Whether the walk proves nothing exists at `path`: some ancestor directory
821
+ * is missing, or a completely listed ancestor holds no entry that could lead
822
+ * to it. Ancestors outside the walked scope are passed through, so a walk of
823
+ * `a/b` decides paths beneath `a/b` and nothing else.
824
+ */
825
+ export function provenAbsent(walk: Walk, path: string): boolean {
826
+ const parts = path.split('/');
827
+ let dir = '';
828
+ for (let i = 0; i < parts.length; i++) {
829
+ if (walk.missing.has(dir)) return true;
830
+ const child = dir ? `${dir}/${parts[i]}` : parts[i]!;
831
+ if (walk.complete.has(dir)) {
832
+ if (walk.others.has(child)) return false; // present, but unproven
833
+ if (i === parts.length - 1) return !walk.files.has(child) && !walk.dirs.has(child);
834
+ if (!walk.dirs.has(child)) return true; // no directory there: nothing beneath can exist
835
+ }
836
+ dir = child;
837
+ }
838
+ return false;
839
+ }