@animalabs/agent-framework 0.27.0 → 0.29.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 (344) hide show
  1. package/README.md +145 -41
  2. package/dist/src/agent.d.ts +29 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +73 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +3 -12
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/api/server.d.ts.map +1 -1
  10. package/dist/src/api/server.js +11 -5
  11. package/dist/src/api/server.js.map +1 -1
  12. package/dist/src/auto-continue.d.ts +5 -2
  13. package/dist/src/auto-continue.d.ts.map +1 -1
  14. package/dist/src/auto-continue.js +9 -5
  15. package/dist/src/auto-continue.js.map +1 -1
  16. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  17. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/runner.js +7 -5
  19. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +3 -2
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/cut-off-round.d.ts +24 -0
  24. package/dist/src/cut-off-round.d.ts.map +1 -0
  25. package/dist/src/cut-off-round.js +87 -0
  26. package/dist/src/cut-off-round.js.map +1 -0
  27. package/dist/src/framework.d.ts +468 -125
  28. package/dist/src/framework.d.ts.map +1 -1
  29. package/dist/src/framework.js +3651 -1111
  30. package/dist/src/framework.js.map +1 -1
  31. package/dist/src/gate/event-gate.d.ts +133 -13
  32. package/dist/src/gate/event-gate.d.ts.map +1 -1
  33. package/dist/src/gate/event-gate.js +654 -59
  34. package/dist/src/gate/event-gate.js.map +1 -1
  35. package/dist/src/gate/index.d.ts +3 -2
  36. package/dist/src/gate/index.d.ts.map +1 -1
  37. package/dist/src/gate/index.js +2 -1
  38. package/dist/src/gate/index.js.map +1 -1
  39. package/dist/src/gate/reply-judge.d.ts +116 -0
  40. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  41. package/dist/src/gate/reply-judge.js +467 -0
  42. package/dist/src/gate/reply-judge.js.map +1 -0
  43. package/dist/src/gate/types.d.ts +173 -2
  44. package/dist/src/gate/types.d.ts.map +1 -1
  45. package/dist/src/heartbeat/heartbeat.d.ts +202 -0
  46. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  47. package/dist/src/heartbeat/heartbeat.js +778 -0
  48. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  50. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  51. package/dist/src/heartbeat/reminder-time.js +107 -0
  52. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  53. package/dist/src/held-writes.d.ts +3 -3
  54. package/dist/src/held-writes.js +3 -3
  55. package/dist/src/host-presentation.d.ts +170 -0
  56. package/dist/src/host-presentation.d.ts.map +1 -0
  57. package/dist/src/host-presentation.js +561 -0
  58. package/dist/src/host-presentation.js.map +1 -0
  59. package/dist/src/index.d.ts +5 -1
  60. package/dist/src/index.d.ts.map +1 -1
  61. package/dist/src/index.js +3 -1
  62. package/dist/src/index.js.map +1 -1
  63. package/dist/src/line-reader.d.ts +4 -0
  64. package/dist/src/line-reader.d.ts.map +1 -0
  65. package/dist/src/line-reader.js +59 -0
  66. package/dist/src/line-reader.js.map +1 -0
  67. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  68. package/dist/src/mcpl/capability-mask.js +3 -2
  69. package/dist/src/mcpl/capability-mask.js.map +1 -1
  70. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  71. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  72. package/dist/src/mcpl/channel-registry.js +133 -54
  73. package/dist/src/mcpl/channel-registry.js.map +1 -1
  74. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  75. package/dist/src/mcpl/conversation-router.js +2 -1
  76. package/dist/src/mcpl/conversation-router.js.map +1 -1
  77. package/dist/src/mcpl/convert-block.d.ts +1 -0
  78. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  79. package/dist/src/mcpl/convert-block.js +16 -4
  80. package/dist/src/mcpl/convert-block.js.map +1 -1
  81. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  82. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  83. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  84. package/dist/src/mcpl/inbound-source.d.ts +29 -11
  85. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  86. package/dist/src/mcpl/inbound-source.js +43 -70
  87. package/dist/src/mcpl/inbound-source.js.map +1 -1
  88. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  89. package/dist/src/mcpl/modern-connection.js +11 -7
  90. package/dist/src/mcpl/modern-connection.js.map +1 -1
  91. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  92. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  93. package/dist/src/mcpl/protocol-family.js +25 -3
  94. package/dist/src/mcpl/protocol-family.js.map +1 -1
  95. package/dist/src/mcpl/push-handler.js +2 -2
  96. package/dist/src/mcpl/push-handler.js.map +1 -1
  97. package/dist/src/mcpl/references.d.ts +12 -40
  98. package/dist/src/mcpl/references.d.ts.map +1 -1
  99. package/dist/src/mcpl/references.js +29 -23
  100. package/dist/src/mcpl/references.js.map +1 -1
  101. package/dist/src/mcpl/server-connection.d.ts +18 -8
  102. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  103. package/dist/src/mcpl/server-connection.js +70 -30
  104. package/dist/src/mcpl/server-connection.js.map +1 -1
  105. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  106. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  107. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  108. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  109. package/dist/src/mcpl/tags.d.ts +9 -2
  110. package/dist/src/mcpl/tags.d.ts.map +1 -1
  111. package/dist/src/mcpl/tags.js +12 -2
  112. package/dist/src/mcpl/tags.js.map +1 -1
  113. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  114. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  115. package/dist/src/mcpl/tool-presence.js +150 -80
  116. package/dist/src/mcpl/tool-presence.js.map +1 -1
  117. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  118. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  119. package/dist/src/mcpl/tool-signature.js +60 -30
  120. package/dist/src/mcpl/tool-signature.js.map +1 -1
  121. package/dist/src/mcpl/toolset-host.d.ts +88 -55
  122. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  123. package/dist/src/mcpl/toolset-host.js +220 -111
  124. package/dist/src/mcpl/toolset-host.js.map +1 -1
  125. package/dist/src/mcpl/toolsets-module.d.ts +27 -35
  126. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  127. package/dist/src/mcpl/toolsets-module.js +313 -91
  128. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  129. package/dist/src/mcpl/transport.d.ts +1 -1
  130. package/dist/src/mcpl/transport.d.ts.map +1 -1
  131. package/dist/src/mcpl/transport.js +16 -16
  132. package/dist/src/mcpl/transport.js.map +1 -1
  133. package/dist/src/mcpl/types.d.ts +31 -13
  134. package/dist/src/mcpl/types.d.ts.map +1 -1
  135. package/dist/src/mcpl/types.js.map +1 -1
  136. package/dist/src/module-registry.d.ts +10 -8
  137. package/dist/src/module-registry.d.ts.map +1 -1
  138. package/dist/src/module-registry.js +15 -9
  139. package/dist/src/module-registry.js.map +1 -1
  140. package/dist/src/modules/api/index.d.ts.map +1 -1
  141. package/dist/src/modules/api/index.js +4 -2
  142. package/dist/src/modules/api/index.js.map +1 -1
  143. package/dist/src/modules/discord/index.d.ts.map +1 -1
  144. package/dist/src/modules/discord/index.js +33 -25
  145. package/dist/src/modules/discord/index.js.map +1 -1
  146. package/dist/src/modules/history/index.d.ts.map +1 -1
  147. package/dist/src/modules/history/index.js +30 -17
  148. package/dist/src/modules/history/index.js.map +1 -1
  149. package/dist/src/modules/sender-name.d.ts +7 -0
  150. package/dist/src/modules/sender-name.d.ts.map +1 -0
  151. package/dist/src/modules/sender-name.js +10 -0
  152. package/dist/src/modules/sender-name.js.map +1 -0
  153. package/dist/src/modules/workspace/answers.d.ts +26 -0
  154. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  155. package/dist/src/modules/workspace/answers.js +138 -0
  156. package/dist/src/modules/workspace/answers.js.map +1 -0
  157. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  158. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  159. package/dist/src/modules/workspace/branch-intent.js +6 -2
  160. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  161. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +59 -24
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  166. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  168. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  169. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  170. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/edit-text.js +4 -2
  172. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  173. package/dist/src/modules/workspace/effects.d.ts +4 -2
  174. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/effects.js +67 -50
  176. package/dist/src/modules/workspace/effects.js.map +1 -1
  177. package/dist/src/modules/workspace/index.d.ts +63 -51
  178. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/index.js +980 -547
  180. package/dist/src/modules/workspace/index.js.map +1 -1
  181. package/dist/src/modules/workspace/observe.d.ts +125 -82
  182. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/observe.js +465 -241
  184. package/dist/src/modules/workspace/observe.js.map +1 -1
  185. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/read-page.js +7 -1
  187. package/dist/src/modules/workspace/read-page.js.map +1 -1
  188. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  189. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/reconcile.js +156 -50
  191. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  192. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  193. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/result-text.js.map +1 -1
  195. package/dist/src/modules/workspace/sync.d.ts +9 -0
  196. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/sync.js +14 -0
  198. package/dist/src/modules/workspace/sync.js.map +1 -1
  199. package/dist/src/modules/workspace/types.d.ts +96 -43
  200. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/types.js.map +1 -1
  202. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  203. package/dist/src/modules/workspace/watcher.js +8 -3
  204. package/dist/src/modules/workspace/watcher.js.map +1 -1
  205. package/dist/src/notice-tags.d.ts +79 -0
  206. package/dist/src/notice-tags.d.ts.map +1 -0
  207. package/dist/src/notice-tags.js +83 -0
  208. package/dist/src/notice-tags.js.map +1 -0
  209. package/dist/src/prose-drafts.d.ts +4 -3
  210. package/dist/src/prose-drafts.d.ts.map +1 -1
  211. package/dist/src/prose-drafts.js +3 -2
  212. package/dist/src/prose-drafts.js.map +1 -1
  213. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  214. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  215. package/dist/src/reasoning-loss-notice.js +52 -0
  216. package/dist/src/reasoning-loss-notice.js.map +1 -0
  217. package/dist/src/reasoning-loss.d.ts +24 -0
  218. package/dist/src/reasoning-loss.d.ts.map +1 -0
  219. package/dist/src/reasoning-loss.js +102 -0
  220. package/dist/src/reasoning-loss.js.map +1 -0
  221. package/dist/src/send-tools.d.ts +3 -2
  222. package/dist/src/send-tools.d.ts.map +1 -1
  223. package/dist/src/send-tools.js +3 -2
  224. package/dist/src/send-tools.js.map +1 -1
  225. package/dist/src/sent-rows.js +1 -1
  226. package/dist/src/silent-heartbeat.d.ts +1 -53
  227. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  228. package/dist/src/silent-heartbeat.js +56 -3
  229. package/dist/src/silent-heartbeat.js.map +1 -1
  230. package/dist/src/supplied-text.d.ts +35 -0
  231. package/dist/src/supplied-text.d.ts.map +1 -0
  232. package/dist/src/supplied-text.js +141 -0
  233. package/dist/src/supplied-text.js.map +1 -0
  234. package/dist/src/thinking-drops.d.ts +4 -11
  235. package/dist/src/thinking-drops.d.ts.map +1 -1
  236. package/dist/src/thinking-drops.js +7 -14
  237. package/dist/src/thinking-drops.js.map +1 -1
  238. package/dist/src/tool-args.d.ts +12 -0
  239. package/dist/src/tool-args.d.ts.map +1 -1
  240. package/dist/src/tool-args.js +39 -4
  241. package/dist/src/tool-args.js.map +1 -1
  242. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  243. package/dist/src/tool-image-ledger.js +2 -1
  244. package/dist/src/tool-image-ledger.js.map +1 -1
  245. package/dist/src/tool-presentation.d.ts.map +1 -1
  246. package/dist/src/tool-presentation.js +5 -4
  247. package/dist/src/tool-presentation.js.map +1 -1
  248. package/dist/src/tool-result-guard.d.ts +8 -4
  249. package/dist/src/tool-result-guard.d.ts.map +1 -1
  250. package/dist/src/tool-result-guard.js +23 -11
  251. package/dist/src/tool-result-guard.js.map +1 -1
  252. package/dist/src/tune-out/coordinator.d.ts +3 -2
  253. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  254. package/dist/src/tune-out/coordinator.js +24 -26
  255. package/dist/src/tune-out/coordinator.js.map +1 -1
  256. package/dist/src/tune-out/tools.d.ts +4 -1
  257. package/dist/src/tune-out/tools.d.ts.map +1 -1
  258. package/dist/src/types/agent.d.ts +63 -18
  259. package/dist/src/types/agent.d.ts.map +1 -1
  260. package/dist/src/types/events.d.ts +18 -8
  261. package/dist/src/types/events.d.ts.map +1 -1
  262. package/dist/src/types/framework.d.ts +58 -0
  263. package/dist/src/types/framework.d.ts.map +1 -1
  264. package/dist/src/types/module.d.ts +27 -13
  265. package/dist/src/types/module.d.ts.map +1 -1
  266. package/dist/src/types/trace.d.ts +34 -1
  267. package/dist/src/types/trace.d.ts.map +1 -1
  268. package/package.json +3 -3
  269. package/src/agent.ts +76 -12
  270. package/src/api/mcp-server-core.ts +3 -14
  271. package/src/api/server.ts +11 -5
  272. package/src/auto-continue.ts +12 -5
  273. package/src/code-execution/isolate/runner.ts +7 -5
  274. package/src/code-execution/isolate/scripts.ts +3 -2
  275. package/src/cut-off-round.ts +98 -0
  276. package/src/framework.ts +3772 -1138
  277. package/src/gate/event-gate.ts +705 -60
  278. package/src/gate/index.ts +9 -1
  279. package/src/gate/reply-judge.ts +512 -0
  280. package/src/gate/types.ts +176 -3
  281. package/src/heartbeat/heartbeat.ts +843 -0
  282. package/src/heartbeat/reminder-time.ts +107 -0
  283. package/src/held-writes.ts +3 -3
  284. package/src/host-presentation.ts +605 -0
  285. package/src/index.ts +5 -1
  286. package/src/line-reader.ts +58 -0
  287. package/src/mcpl/capability-mask.ts +3 -2
  288. package/src/mcpl/channel-registry.ts +139 -53
  289. package/src/mcpl/conversation-router.ts +2 -1
  290. package/src/mcpl/convert-block.ts +17 -4
  291. package/src/mcpl/hook-orchestrator.ts +4 -3
  292. package/src/mcpl/inbound-source.ts +45 -70
  293. package/src/mcpl/modern-connection.ts +11 -7
  294. package/src/mcpl/protocol-family.ts +24 -3
  295. package/src/mcpl/push-handler.ts +2 -2
  296. package/src/mcpl/references.ts +31 -23
  297. package/src/mcpl/server-connection.ts +76 -34
  298. package/src/mcpl/skip-reply-wake.ts +35 -0
  299. package/src/mcpl/tags.ts +13 -2
  300. package/src/mcpl/tool-presence.ts +149 -82
  301. package/src/mcpl/tool-signature.ts +64 -32
  302. package/src/mcpl/toolset-host.ts +212 -110
  303. package/src/mcpl/toolsets-module.ts +277 -91
  304. package/src/mcpl/transport.ts +18 -14
  305. package/src/mcpl/types.ts +32 -13
  306. package/src/module-registry.ts +14 -9
  307. package/src/modules/api/index.ts +4 -2
  308. package/src/modules/discord/index.ts +37 -25
  309. package/src/modules/history/index.ts +30 -18
  310. package/src/modules/sender-name.ts +9 -0
  311. package/src/modules/workspace/answers.ts +151 -0
  312. package/src/modules/workspace/branch-intent.ts +10 -5
  313. package/src/modules/workspace/direct-write.ts +59 -23
  314. package/src/modules/workspace/disk-agreement.ts +52 -21
  315. package/src/modules/workspace/edit-text.ts +7 -5
  316. package/src/modules/workspace/effects.ts +69 -50
  317. package/src/modules/workspace/index.ts +966 -556
  318. package/src/modules/workspace/observe.ts +497 -242
  319. package/src/modules/workspace/read-page.ts +7 -1
  320. package/src/modules/workspace/reconcile.ts +184 -49
  321. package/src/modules/workspace/result-text.ts +2 -0
  322. package/src/modules/workspace/sync.ts +16 -0
  323. package/src/modules/workspace/types.ts +96 -43
  324. package/src/modules/workspace/watcher.ts +8 -3
  325. package/src/notice-tags.ts +84 -0
  326. package/src/prose-drafts.ts +4 -3
  327. package/src/reasoning-loss-notice.ts +64 -0
  328. package/src/reasoning-loss.ts +120 -0
  329. package/src/send-tools.ts +3 -2
  330. package/src/sent-rows.ts +1 -1
  331. package/src/silent-heartbeat.ts +4 -3
  332. package/src/supplied-text.ts +154 -0
  333. package/src/thinking-drops.ts +10 -16
  334. package/src/tool-args.ts +38 -4
  335. package/src/tool-image-ledger.ts +2 -1
  336. package/src/tool-presentation.ts +5 -4
  337. package/src/tool-result-guard.ts +26 -11
  338. package/src/tune-out/coordinator.ts +25 -27
  339. package/src/tune-out/tools.ts +4 -1
  340. package/src/types/agent.ts +63 -18
  341. package/src/types/events.ts +18 -8
  342. package/src/types/framework.ts +59 -0
  343. package/src/types/module.ts +28 -13
  344. package/src/types/trace.ts +36 -1
@@ -1,6 +1,7 @@
1
1
  import type { ToolResult } from '../../types/events.js';
2
2
  import { answerText, fieldLine, sectionLead, sectionText, textAnswer } from './result-text.js';
3
3
  import { type FileText, MAX_LINE_CHARS, numberedLine, safeCut } from './text.js';
4
+ import { didNothing } from './answers.js';
4
5
 
5
6
  /**
6
7
  * One page of a read, held to the result limit as its text measures it: a
@@ -184,7 +185,12 @@ export function characterPage(path: string, file: FileText, offsetChars: number,
184
185
  const text = file.raw;
185
186
  const start = Math.min(offsetChars, text.length);
186
187
  if (splitsPair(text, start)) {
187
- return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
188
+ return {
189
+ success: false,
190
+ isError: true,
191
+ error: didNothing('read', `"offsetChars" ${start} falls between the two halves of a surrogate pair, one character in UTF-16. `
192
+ + `Give ${start - 1} or ${start + 1}; a page's nextOffsetChars never splits one.`),
193
+ };
188
194
  }
189
195
  let end = start + Math.min(limitChars, text.length - start);
190
196
  if (splitsPair(text, end)) end++;
@@ -53,9 +53,27 @@ import {
53
53
  sameRootIdentity,
54
54
  } from './disk-agreement.js';
55
55
  import type { BranchIntent, BranchIntents, ConflictKind, ConflictRecord, DiskCounterpart } from './branch-intent.js';
56
+ import { storedBytes, type StoredBytes } from './sync.js';
56
57
  import {
58
+ BENEATH_SYMLINK,
59
+ type Cause,
60
+ CHANGED,
61
+ CHANGED_WALK,
62
+ DIRECTORY,
63
+ EDITOR_FILE_REASON,
64
+ FILE_CHANGED,
57
65
  IGNORED,
66
+ LEAVES_MOUNT,
67
+ LINK_NOT_WALKED,
68
+ LINK_TO_DIRECTORY,
58
69
  type MountView,
70
+ NOT_A_DIRECTORY,
71
+ type NotShown,
72
+ NOT_FOLLOWED,
73
+ NOT_REGULAR,
74
+ OUTSIDE_PARENT,
75
+ RESOLVES_OUTSIDE,
76
+ ROOT_UNAVAILABLE,
59
77
  OTHER_MOUNT,
60
78
  ROOT_REPLACED,
61
79
  type Walk,
@@ -74,7 +92,8 @@ import {
74
92
  walkScope,
75
93
  withinIncludes,
76
94
  } from './observe.js';
77
- import { EffectFailed, type Expect, chainOf, chmodContained, narrowedTo, syncDirectories, unlinkContained, writeContained } from './effects.js';
95
+ import { EffectFailed, type Expect, callFailed, chainOf, chmodContained, narrowedTo, syncDirectories, unlinkContained, writeContained } from './effects.js';
96
+ import { OUTSIDE_STEP, RESOLVED_OUTSIDE_STEP, UNFOLLOWED_STEP, errnoClause, errnoWords } from './answers.js';
78
97
 
79
98
  export type EntryState =
80
99
  | 'synced'
@@ -103,6 +122,7 @@ export interface PathReport {
103
122
  mimeType?: string;
104
123
  conflict?: ConflictReport;
105
124
  note?: string;
125
+ noteCause?: Cause;
106
126
  /**
107
127
  * For a conflict, what this pass found on disk at the path: grep's account
108
128
  * of a conflict's disk side needs it. Listings don't show it.
@@ -147,6 +167,8 @@ export interface PassResult {
147
167
  * still the selected branch, synchronously, before it reads.
148
168
  */
149
169
  branchId?: string;
170
+ /** Why it decided nothing, where it didn't. */
171
+ undecided?: string;
150
172
  }
151
173
 
152
174
  export interface MountRuntime {
@@ -222,7 +244,7 @@ export const WALK_CAP = 5000;
222
244
  /** What a pass learned about D for one path. */
223
245
  type DiskFact =
224
246
  | { kind: 'absent' }
225
- | { kind: 'unobserved'; reason: string }
247
+ | ({ kind: 'unobserved' } & NotShown)
226
248
  | {
227
249
  kind: 'file';
228
250
  hash: string;
@@ -376,6 +398,7 @@ interface Verdict {
376
398
  conflict?: ConflictKind;
377
399
  op?: TreeOp;
378
400
  note?: string;
401
+ noteCause?: Cause;
379
402
  /**
380
403
  * Disk shows what a pending push of ours intended, unconfirmed: the state
381
404
  * is decided from it, and nothing at all is recorded (see module doc).
@@ -464,21 +487,22 @@ function settleAdoption(p: Pending, s: StoreSide | null): Agreed | 'forget' {
464
487
  /**
465
488
  * What's in the way, and the way out, when disk's shape keeps a push from a
466
489
  * path: no push writes a file over a directory or unlinks one, unlinks
467
- * anything but a regular file or a followed symlink to one, or writes below
468
- * a file or a symlink the walk won't take (`writeContained`,
469
- * `unlinkContained`), forced or not. Null for any other reason, which is
470
- * passed on as it is.
490
+ * anything but a regular file or a followed symlink to one, writes below a
491
+ * file or a symlink the walk won't take, or writes or unlinks through a
492
+ * symlink the mount doesn't follow or one that leads out of the mount
493
+ * (`writeContained`, `unlinkContained`), forced or not. Null for any other
494
+ * reason.
471
495
  */
472
496
  function inTheWay(reason: string | undefined, deletion: boolean): string | null {
473
497
  // Observation's words for what a deletion never unlinks, which the unlink
474
498
  // refuses in the same words.
475
- if (deletion && (reason === 'not a regular file' || reason === 'a symlink to a directory, not walked')) {
476
- const what = reason === 'not a regular file'
499
+ if (deletion && (reason === NOT_REGULAR || reason === LINK_TO_DIRECTORY)) {
500
+ const what = reason === NOT_REGULAR
477
501
  ? 'a pipe, socket or device at this path, or a symlink to one'
478
502
  : 'a symlink to a directory at this path';
479
503
  return `disk has ${what}, and no push unlinks it, forced or not — ask whoever runs this host if it should go`;
480
504
  }
481
- if (reason === 'a directory') {
505
+ if (reason === DIRECTORY) {
482
506
  return deletion
483
507
  ? 'disk has a directory at this path, and no push unlinks one, forced or not — if it should go, move or remove it on disk'
484
508
  : 'disk has a directory at this path, and no push writes a file over one, forced or not — ' +
@@ -486,13 +510,99 @@ function inTheWay(reason: string | undefined, deletion: boolean): string | null
486
510
  }
487
511
  // A deletion below a file finds nothing there to unlink, so only a write
488
512
  // meets this one.
489
- if (reason === 'a parent path is not a directory') {
513
+ if (reason === NOT_A_DIRECTORY) {
490
514
  return 'disk has a file or a symlink where this path needs a directory, and no push writes below one, forced or not — ' +
491
515
  'move or remove it on disk and materialize again, or write the file under another path';
492
516
  }
517
+ const link = reason === NOT_FOLLOWED ? 'disk has a symlink at this path, which this mount does not follow'
518
+ : reason === LEAVES_MOUNT ? 'this path leads out of the mount through a symlink on it'
519
+ : null;
520
+ if (link !== null) {
521
+ return deletion
522
+ ? `${link}, and no push unlinks ${reason === NOT_FOLLOWED ? 'one' : 'through one'}, forced or not — ask whoever runs this host if it should go`
523
+ : `${link}, and no push writes through one, forced or not — write the file under another path, ` +
524
+ `or whoever runs this host can remove the link, and ${THEN.write}`;
525
+ }
526
+ if (deletion && reason === BENEATH_SYMLINK) {
527
+ return 'a symlink on this path does not lead to a directory inside the mount, so nothing beneath it is in this mount, and no push unlinks beneath one, '
528
+ + `forced or not — whoever runs this host can remove the link or make it lead to a directory inside the mount, and ${THEN.unlink}`;
529
+ }
530
+ if (reason === OUTSIDE_PARENT) {
531
+ return `a directory on this path leads out of the mount, and no push ${deletion ? 'unlinks' : 'writes'} beneath one, forced or not — ` +
532
+ (deletion ? 'ask whoever runs this host if what is there should go' : 'write the file under another path');
533
+ }
534
+ if (reason === NOT_REGULAR || reason === LINK_TO_DIRECTORY) {
535
+ const what = reason === NOT_REGULAR ? 'a pipe, socket or device at this path, or a symlink to one' : 'a symlink to a directory at this path';
536
+ return `disk has ${what}, and no push writes a file over it, forced or not — write the file under another path, ` +
537
+ `or whoever runs this host can remove it, and ${THEN.write}`;
538
+ }
493
539
  return null;
494
540
  }
495
541
 
542
+ export type Meets = 'write' | 'unlink' | 'sync' | 'list' | 'read';
543
+
544
+ const AGAIN: Record<Meets, string> = {
545
+ write: 'materializing again',
546
+ unlink: 'materializing again with applyDeletions',
547
+ sync: 'syncing this path again',
548
+ list: 'listing it',
549
+ read: 'calling again',
550
+ };
551
+
552
+ const THEN: Record<Meets, string> = {
553
+ write: 'materializing again then gets past it',
554
+ unlink: 'materializing again with applyDeletions then gets past it',
555
+ sync: 'a sync of this path then takes it in',
556
+ list: 'the workspace then reaches it',
557
+ read: 'a read then reaches it',
558
+ };
559
+
560
+ const CHANGED_WHILE: ReadonlySet<string> = new Set([CHANGED, CHANGED_WALK, FILE_CHANGED]);
561
+
562
+ const WALKED_PAST: ReadonlySet<string> = new Set([IGNORED, EDITOR_FILE_REASON, LINK_NOT_WALKED]);
563
+
564
+ export function explained(reason: string, cause: Cause, meets: Meets): string {
565
+ if (meets === 'write' || meets === 'unlink') {
566
+ const shaped = inTheWay(reason, meets === 'unlink');
567
+ if (shaped !== null) return shaped;
568
+ }
569
+ return withWayOn(reason, cause, meets);
570
+ }
571
+
572
+ function withWayOn(reason: string, cause: Cause, meets: Meets): string {
573
+ const on = wayOn(reason, cause, meets);
574
+ return on === null ? reason : `${reason} — ${on}`;
575
+ }
576
+
577
+ export function wayOn(reason: string, cause: Cause, meets: Meets): string | null {
578
+ const lasting = `${AGAIN[meets]} won't change that; `;
579
+ if (reason === ROOT_UNAVAILABLE) {
580
+ if (cause.errno === 'ENOENT' || cause.errno === 'ENOTDIR') return `${lasting}whoever runs this host can put the mount's directory back at its path, and ${THEN[meets]}`;
581
+ if (cause.errno === undefined) return `${AGAIN[meets]} checks it afresh; if it's still unavailable, whoever runs this host can check the mount's directory and its path`;
582
+ }
583
+ if (cause.errno !== undefined) return errnoClause(cause.errno, AGAIN[meets], THEN[meets]);
584
+ if (cause.again === true || CHANGED_WHILE.has(reason)) return `${AGAIN[meets]} checks it afresh`;
585
+ switch (reason) {
586
+ case NOT_FOLLOWED:
587
+ return lasting + UNFOLLOWED_STEP;
588
+ case LEAVES_MOUNT:
589
+ return lasting + OUTSIDE_STEP;
590
+ case BENEATH_SYMLINK:
591
+ return `${lasting}nothing beneath it is in this mount, and whoever runs this host can make it lead to a directory inside the mount, or, where it leads to a directory outside, mount that directory`;
592
+ case OUTSIDE_PARENT:
593
+ case RESOLVES_OUTSIDE:
594
+ return lasting + RESOLVED_OUTSIDE_STEP;
595
+ case NOT_REGULAR:
596
+ return `${lasting}whoever runs this host can put a regular file there, and ${THEN[meets]}`;
597
+ case DIRECTORY:
598
+ return `${lasting}ls of this path lists what's in it`;
599
+ case LINK_TO_DIRECTORY:
600
+ return `${lasting}ls of the directory it leads to lists what's in it`;
601
+ default:
602
+ return meets === 'sync' && WALKED_PAST.has(reason) ? 'a sync of this path checks it' : null;
603
+ }
604
+ }
605
+
496
606
  /**
497
607
  * The forced way out the refusal of a disk copy that couldn't be verified
498
608
  * names, if any. A forced push skips the verification, so it gets past the
@@ -506,10 +616,18 @@ function inTheWay(reason: string | undefined, deletion: boolean): string | null
506
616
  * to pass.
507
617
  */
508
618
  function forcedWay(note: string | undefined, deletion: boolean, forced: string): string {
509
- if (note === 'cannot open (EACCES)') return `, or, if disk lets the workspace ${deletion ? 'unlink' : 'write'} the file, ${forced}`;
619
+ if (note === `cannot open (${errnoWords('EACCES')})`) return `, or, if disk lets the workspace ${deletion ? 'unlink' : 'write'} the file, ${forced}`;
510
620
  return '';
511
621
  }
512
622
 
623
+ const UNSETTLED = "whether disk holds the change isn't known until it's checked: a materialize of this path checks it and says how it ended";
624
+
625
+ function unverifiedCopy(v: Verdict, meets: 'write' | 'unlink', forced: string): string {
626
+ const note = v.note ?? 'not observed';
627
+ const on = wayOn(note, v.noteCause ?? {}, meets);
628
+ return `cannot verify disk copy (${note})${on !== null ? ` — ${on}` : ''}${forcedWay(v.note, meets === 'unlink', forced)}`;
629
+ }
630
+
513
631
  /**
514
632
  * The synchronous precondition of every workspace mutation: evidence owed a
515
633
  * barrier is made durable first, and an adoption left pending on the
@@ -635,6 +753,10 @@ function adoptDisk(d: Extract<DiskFact, { kind: 'absent' | 'file' }>, s: StoreSi
635
753
  return { state: 'disk-only', ...(s ? { adopt: 'drop' as const, op: 'deleted' as const } : {}), p: 'forget', intent: {} };
636
754
  }
637
755
 
756
+ function causeOf({ errno, ownStep }: NotShown): Cause {
757
+ return { ...(errno !== undefined ? { errno } : {}), ...(ownStep ? { ownStep } : {}) };
758
+ }
759
+
638
760
  /** The rule (see module doc). Pure. */
639
761
  function decide(
640
762
  d: DiskFact,
@@ -650,9 +772,9 @@ function decide(
650
772
  if (d.kind === 'unobserved') {
651
773
  // Disk proves nothing, but an adoption on this branch settles by the tree.
652
774
  if (pIn?.kind === 'pending' && pIn.effect === 'adopt' && pIn.branchId === branchId && !adopt) {
653
- return { state: 'unverified', note: d.reason, p: settleAdoption(pIn, s) };
775
+ return { state: 'unverified', note: d.reason, noteCause: causeOf(d), p: settleAdoption(pIn, s) };
654
776
  }
655
- return { state: 'unverified', note: d.reason };
777
+ return { state: 'unverified', note: d.reason, noteCause: causeOf(d) };
656
778
  }
657
779
  if (d.kind === 'unhashed') {
658
780
  // Observed this way only when nothing tracks the path, which is then a
@@ -838,6 +960,7 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
838
960
  // is followed, as `rootReal` is: `followSymlinks` governs links within it.
839
961
  const root = await locateRoot(mount.view, mount.rootReal);
840
962
  const rootUnavailable = 'reason' in root ? root.reason : null;
963
+ const rootCause = 'reason' in root && root.errno !== undefined ? { errno: root.errno } : {};
841
964
 
842
965
  // The branch is labelled where the tracked candidates are read, with no
843
966
  // await between: the decision is checked against this label.
@@ -853,7 +976,7 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
853
976
  if (rootUnavailable !== null) {
854
977
  // Said whether or not anything is tracked: an unavailable mount must not
855
978
  // read as a verified empty directory.
856
- gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: rootUnavailable });
979
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: explained(rootUnavailable, rootCause, 'list') });
857
980
  }
858
981
 
859
982
  // Which paths to decide: what disk shows, and what the store, P and intent
@@ -892,7 +1015,7 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
892
1015
  // Observe. Tracked facts are read again, synchronously, when deciding.
893
1016
  for (const path of candidates) {
894
1017
  if (rootUnavailable !== null) {
895
- gathered.facts.set(path, { kind: 'unobserved', reason: rootUnavailable });
1018
+ gathered.facts.set(path, { kind: 'unobserved', reason: rootUnavailable, ...rootCause });
896
1019
  continue;
897
1020
  }
898
1021
  // What the mount passes over by design stays unobserved by a pass that
@@ -914,8 +1037,8 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
914
1037
  byDesign = OTHER_MOUNT;
915
1038
  } else if (!opts.named) {
916
1039
  const slash = path.lastIndexOf('/');
917
- const above = slash < 0 ? undefined : passedOverAt(path.slice(0, slash), mount.view.ignore, mount.view.others);
918
- const own = above === undefined && !walk ? passedOverAt(path, mount.view.ignore, mount.view.others) : undefined;
1040
+ const above = slash < 0 ? undefined : passedOverAt(path.slice(0, slash), mount.view.ignore, mount.view.others, true);
1041
+ const own = above === undefined && !walk ? passedOverAt(path, mount.view.ignore, mount.view.others, false) : undefined;
919
1042
  byDesign = above ?? own;
920
1043
  } else if (scope.kind === 'dir') {
921
1044
  byDesign = passedOverBetween(scope.dir, path, ignoreBeneath(scope.dir, mount.view.ignore), mount.view.others);
@@ -942,7 +1065,9 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
942
1065
  if (r.kind === 'symlink' && r.path === path) continue;
943
1066
  if (region === undefined || r.path.length > region.path.length) region = r;
944
1067
  }
945
- gathered.facts.set(path, { kind: 'unobserved', reason: region ? region.reason : 'not reached by the walk' });
1068
+ gathered.facts.set(path, region
1069
+ ? { kind: 'unobserved', reason: region.reason, ...(region.errno !== undefined ? { errno: region.errno } : {}) }
1070
+ : { kind: 'unobserved', reason: 'not reached by the walk' });
946
1071
  continue;
947
1072
  }
948
1073
  const p = agreement.get(mount.name, path);
@@ -959,11 +1084,12 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
959
1084
  const after = await locateRoot(mount.view, mount.rootReal);
960
1085
  if (!('identity' in after) || !sameRootIdentity(after.identity, gathered.rootIdentity)) {
961
1086
  const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
962
- for (const path of gathered.facts.keys()) gathered.facts.set(path, { kind: 'unobserved', reason });
1087
+ const errno = 'errno' in after ? after.errno : undefined;
1088
+ for (const path of gathered.facts.keys()) gathered.facts.set(path, { kind: 'unobserved', reason, ...(errno !== undefined ? { errno } : {}) });
963
1089
  gathered.rootIdentity = null;
964
1090
  gathered.walk = null;
965
1091
  gathered.dirs = [];
966
- gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason });
1092
+ gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: explained(reason, errno !== undefined ? { errno } : {}, 'list') });
967
1093
  }
968
1094
  }
969
1095
  return gathered;
@@ -1000,14 +1126,13 @@ export async function reconcilePass(
1000
1126
  }
1001
1127
  }
1002
1128
  if (gathered === null) {
1003
- result.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: 'the selected branch kept changing while the scan ran' });
1129
+ result.undecided = 'the selected branch kept changing while the scan ran';
1130
+ result.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: explained(result.undecided, { again: true }, 'list') });
1004
1131
  return result;
1005
1132
  }
1006
1133
  if (opts.branchId !== undefined && gathered.branchId !== opts.branchId) {
1007
- result.incomplete.push({
1008
- path: scope.kind === 'dir' ? scope.dir : scope.paths.join(', '),
1009
- reason: 'the selected branch changed before this sync ran; nothing was taken from disk — sync again on this branch to take it',
1010
- });
1134
+ result.undecided = 'the selected branch changed before this sync ran; nothing was taken from disk — sync again on this branch to take it';
1135
+ result.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : scope.paths.join(', '), reason: result.undecided });
1011
1136
  return result;
1012
1137
  }
1013
1138
  const { walk, facts } = gathered;
@@ -1028,9 +1153,13 @@ export async function reconcilePass(
1028
1153
  }
1029
1154
 
1030
1155
  // The evidence about to be recorded describes the root it was gathered
1031
- // under, which is recorded first (once per mount), so no crash can leave
1032
- // evidence without its root.
1033
- if (gathered.rootIdentity && planned.some((x) => x.verdict.adopt !== undefined || x.verdict.p !== undefined)) {
1156
+ // under, which is recorded first, so no crash can leave evidence without
1157
+ // its root. Once a root is recorded, every pass that found it offers it,
1158
+ // evidence or none: the same root on a new device, or with its first
1159
+ // birth time, is recorded again (recordRootIdentity writes nothing
1160
+ // otherwise), and a quiet mount's passes are where an old record learns
1161
+ // its birth time.
1162
+ if (gathered.rootIdentity && (agreement.rootIdentity(mount.name) !== undefined || planned.some((x) => x.verdict.adopt !== undefined || x.verdict.p !== undefined))) {
1034
1163
  agreement.recordRootIdentity(mount.name, gathered.rootIdentity);
1035
1164
  }
1036
1165
 
@@ -1128,6 +1257,7 @@ export async function reconcilePass(
1128
1257
  if (x.d.kind === 'file' || x.d.kind === 'absent') report.diskNow = x.d.kind;
1129
1258
  }
1130
1259
  if (x.verdict.note) report.note = x.verdict.note;
1260
+ if (x.verdict.noteCause !== undefined) report.noteCause = x.verdict.noteCause;
1131
1261
  if (!(x.verdict.state === 'synced' && !entry)) result.reports.set(x.path, report);
1132
1262
  }
1133
1263
  if (walk) {
@@ -1157,7 +1287,9 @@ export async function reconcilePass(
1157
1287
  // holder), so naming the directory as unreached would never clear.
1158
1288
  const wholeScope = scope.kind === 'dir' && scope.dir !== '' && region.path === scope.dir;
1159
1289
  const unreached = hides && region.reason !== OTHER_MOUNT;
1160
- if (region.kind === 'error' || region.kind === 'cap' || unreached || wholeScope) result.incomplete.push({ path: region.path, reason: region.reason });
1290
+ if (region.kind === 'error' || region.kind === 'cap' || unreached || wholeScope) {
1291
+ result.incomplete.push({ path: region.path, reason: explained(region.reason, region.errno !== undefined ? { errno: region.errno } : {}, 'list') });
1292
+ }
1161
1293
  // A name passed over by design that hides nothing tracked is reported nowhere else:
1162
1294
  // counted, so a sync that says 0 also says what it didn't look at
1163
1295
  // (agent-framework #276). A symlink region isn't counted: a link the
@@ -1264,7 +1396,8 @@ export async function pushPaths(
1264
1396
  // unmounted from. The push plans nothing, so it reports no branch.
1265
1397
  const root = await locateRoot(mount.view, mount.rootReal);
1266
1398
  if ('reason' in root) {
1267
- for (const path of paths) result.skipped.push({ path, reason: root.reason });
1399
+ const why = explained(root.reason, root.errno !== undefined ? { errno: root.errno } : {}, 'write');
1400
+ for (const path of paths) result.skipped.push({ path, reason: why });
1268
1401
  return result;
1269
1402
  }
1270
1403
  // Another mount's directory nested in this one is that mount's: no push
@@ -1337,7 +1470,7 @@ export async function pushPaths(
1337
1470
  // nothing is decided or recorded from them.
1338
1471
  const after = await locateRoot(mount.view, mount.rootReal);
1339
1472
  if (!('identity' in after) || !sameRootIdentity(after.identity, root.identity)) {
1340
- const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
1473
+ const reason = 'reason' in after ? explained(after.reason, after.errno !== undefined ? { errno: after.errno } : {}, 'write') : ROOT_REPLACED;
1341
1474
  for (const path of paths) result.skipped.push({ path, reason });
1342
1475
  return result;
1343
1476
  }
@@ -1358,9 +1491,6 @@ export async function pushPaths(
1358
1491
  const p = agreement.get(mount.name, path);
1359
1492
  const bi = mount.intents.get(path);
1360
1493
  const v = decide(d, s, p, bi, branchId, false);
1361
- // The ways out a refusal names, as materialize takes them: a deletion
1362
- // reaches disk only with applyDeletions.
1363
- const again = s ? 'materialize again' : 'materialize again with applyDeletions';
1364
1494
  // Where disk has deleted a file the workspace holds, a sync takes the
1365
1495
  // deletion, which removes the workspace's copy, and a forced push writes
1366
1496
  // the workspace's copy back: there is no disk copy to adopt or overwrite.
@@ -1386,12 +1516,12 @@ export async function pushPaths(
1386
1516
  continue;
1387
1517
  }
1388
1518
  const priorP = p && (p.kind === 'absent' || p.kind === 'content') ? p : null;
1389
- const blob = store.getBlob(s.hash);
1519
+ const stored = storedBytes(store, s.hash);
1390
1520
  const forcedMode = mount.modesObserved && s.mode !== undefined && s.mode !== (d.kind === 'file' ? d.mode : undefined) ? s.mode : undefined;
1391
1521
  const agreedMode = priorP?.kind === 'content' ? priorP.mode : undefined;
1392
- if (blob) {
1522
+ if ('bytes' in stored) {
1393
1523
  plans.push({
1394
- path, kind: 'write', blob, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect,
1524
+ path, kind: 'write', blob: stored.bytes, hash: s.hash, prior: priorP, before: agreement.get(mount.name, path), expect,
1395
1525
  ...(forcedMode !== undefined ? { apply: forcedMode } : {}),
1396
1526
  ...(forcedMode !== undefined && d.kind === 'file' && agreedMode !== d.mode ? { overDisk: true as const } : {}),
1397
1527
  ...(s.mode !== undefined ? { storeMode: s.mode } : {}),
@@ -1400,7 +1530,7 @@ export async function pushPaths(
1400
1530
  ...(agreedMode !== undefined ? { priorMode: agreedMode } : {}),
1401
1531
  ...(d.kind === 'file' && d.mode !== undefined ? { seenMode: d.mode } : {}),
1402
1532
  });
1403
- } else result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
1533
+ } else result.skipped.push({ path, reason: unstoredCopy(stored) });
1404
1534
  continue;
1405
1535
  }
1406
1536
  if (v.mode === 'adopt' && s && d.kind === 'file') {
@@ -1426,7 +1556,7 @@ export async function pushPaths(
1426
1556
  }
1427
1557
  // Whatever the verdict learned about disk (an agreement, a resolved
1428
1558
  // intent) is recorded before anything is pushed over it, and the root it
1429
- // was learned under before that (once per mount), as a pass does.
1559
+ // was learned under before that, as a pass does.
1430
1560
  if (v.p !== undefined) agreement.recordRootIdentity(mount.name, root.identity);
1431
1561
  if (v.p === 'forget') agreement.forget(mount.name, path);
1432
1562
  else if (v.p !== undefined) agreement.set(mount.name, path, v.p, { afterCommittedState: true });
@@ -1476,8 +1606,7 @@ export async function pushPaths(
1476
1606
  ? d.kind === 'absent' || d.kind === 'file'
1477
1607
  ? `${v.note} — ${sync}`
1478
1608
  : `${v.note}, and disk's copy can't be verified (${d.kind === 'unobserved' ? d.reason : 'not hashed'}) — once it can be, ${sync}`
1479
- : inTheWay(v.note, !s) ??
1480
- `cannot verify disk copy (${v.note ?? 'not observed'}) — fix it and ${again}${forcedWay(v.note, !s, forced)}`,
1609
+ : inTheWay(v.note, !s) ?? unverifiedCopy(v, s ? 'write' : 'unlink', forced),
1481
1610
  });
1482
1611
  continue;
1483
1612
  }
@@ -1542,7 +1671,7 @@ export async function pushPaths(
1542
1671
  // A deletion no push makes, forced or not (inTheWay), is refused for
1543
1672
  // that, as an unforced push refuses it, rather than left for
1544
1673
  // applyDeletions, which couldn't make it either.
1545
- const lasting = inTheWay(v.note, true);
1674
+ const lasting = inTheWay(v.note, true) ?? (v.noteCause?.ownStep === true ? v.note ?? null : null);
1546
1675
  if (lasting !== null) result.skipped.push({ path, reason: lasting });
1547
1676
  else result.pendingDeletions.push(path);
1548
1677
  continue;
@@ -1551,11 +1680,12 @@ export async function pushPaths(
1551
1680
  plans.push({ path, kind: 'unlink', prior, before: agreement.get(mount.name, path), expect });
1552
1681
  continue;
1553
1682
  }
1554
- const blob = store.getBlob(s.hash);
1555
- if (!blob) {
1556
- result.skipped.push({ path, reason: 'the workspace copy is missing from the store' });
1683
+ const stored = storedBytes(store, s.hash);
1684
+ if (!('bytes' in stored)) {
1685
+ result.skipped.push({ path, reason: unstoredCopy(stored) });
1557
1686
  continue;
1558
1687
  }
1688
+ const blob = stored.bytes;
1559
1689
  // The store's mode goes to disk with its bytes where the file is new,
1560
1690
  // the push is forced, or disk still has the mode they last agreed on. A
1561
1691
  // push that completes our own unconfirmed one, whose bytes landed, is a
@@ -1671,18 +1801,18 @@ export async function pushPaths(
1671
1801
  effected.push({ step, dirs: [await unlinkContained(mount.view, mount.rootReal, step.path, step.expect)] });
1672
1802
  }
1673
1803
  } catch (err) {
1674
- const failure = err instanceof EffectFailed ? err : new EffectFailed(err instanceof Error ? err.message : String(err), true);
1804
+ const failure = err instanceof EffectFailed ? err : callFailed('the push failed', err, true, 'colon');
1675
1805
  if (!failure.touched) {
1676
1806
  // Disk at this path is as planning saw it: its evidence goes back to
1677
1807
  // exactly what it was, whatever kind that was.
1678
1808
  agreement.restore(mount.name, step.path, step.before);
1679
1809
  recorded = true;
1680
- const why = inTheWay(failure.message, step.kind === 'unlink') ?? failure.message;
1810
+ const why = step.kind === 'chmod' ? withWayOn(failure.message, failure.detail, 'write') : explained(failure.message, failure.detail, step.kind);
1681
1811
  result.skipped.push({ path: step.path, reason: `not ${step.kind === 'unlink' ? 'unlinked' : step.kind === 'chmod' ? 'given the workspace\'s mode' : 'written'}: ${why}` });
1682
1812
  } else {
1683
1813
  // No completion: the intent stays pending, and the next observation
1684
1814
  // resolves it from whatever disk then holds.
1685
- result.skipped.push({ path: step.path, reason: `${failure.message}; the outcome is checked at the next observation` });
1815
+ result.skipped.push({ path: step.path, reason: `${failure.message} — ${UNSETTLED}` });
1686
1816
  }
1687
1817
  }
1688
1818
  }
@@ -1702,7 +1832,7 @@ export async function pushPaths(
1702
1832
  result.skipped.push({
1703
1833
  path: step.path,
1704
1834
  reason: `could not make the ${step.kind === 'write' ? 'write' : 'unlink'} durable (directory fsync of ${failed === '' ? 'the mount root' : failed}): ` +
1705
- `${unsynced.get(failed)}; the outcome is checked at the next observation`,
1835
+ `${unsynced.get(failed)} — ${UNSETTLED}`,
1706
1836
  });
1707
1837
  continue;
1708
1838
  }
@@ -1774,3 +1904,8 @@ export async function pushPaths(
1774
1904
  agreement.maybeCheckpoint();
1775
1905
  return result;
1776
1906
  }
1907
+
1908
+ function unstoredCopy(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
1909
+ return `${'missing' in stored ? 'the workspace copy is missing from the store' : `the workspace copy is unreadable in the store (${stored.unreadable})`}`
1910
+ + " — a read of this path takes disk's copy where it can, and otherwise says what can be done";
1911
+ }
@@ -30,6 +30,8 @@ export function shownValue(value: string | number | boolean | null): string {
30
30
  return JSON.stringify(value).replace(/[\u0085\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
31
31
  }
32
32
 
33
+ export type DiskState = 'done' | 'waits for materialize' | 'waits for materialize with applyDeletions' | 'in conflict with disk';
34
+
33
35
  /** A header line: a field's key and its value as shownValue writes it. */
34
36
  export const fieldLine = (key: string, value: string | number | boolean | null): string => `${key}: ${shownValue(value)}`;
35
37
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
+ import type { JsStore } from '@animalabs/chronicle';
9
10
 
10
11
  export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
11
12
 
@@ -18,3 +19,18 @@ export function hashContent(content: string | Buffer): string {
18
19
  hash.update(content);
19
20
  return hash.digest('hex');
20
21
  }
22
+
23
+ export type StoredBytes = { bytes: Buffer } | { missing: true } | { unreadable: string };
24
+
25
+ export function storedBytes(store: JsStore, hash: string): StoredBytes {
26
+ try {
27
+ const blob = store.getBlob(hash);
28
+ return blob ? { bytes: blob } : { missing: true };
29
+ } catch (error) {
30
+ if (store.isClosed()) throw error;
31
+ // Chronicle's getBlob throws, rather than answering null, for a blob file
32
+ // it can't read whole or whose format, checksum or hash doesn't check out,
33
+ // such as one a write cut short left truncated.
34
+ return { unreadable: error instanceof Error ? error.message : String(error) };
35
+ }
36
+ }