@animalabs/agent-framework 0.27.0 → 0.28.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 (234) hide show
  1. package/README.md +56 -15
  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 +69 -11
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +4 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +3 -2
  10. package/dist/src/auto-continue.d.ts.map +1 -1
  11. package/dist/src/auto-continue.js +4 -3
  12. package/dist/src/auto-continue.js.map +1 -1
  13. package/dist/src/cut-off-round.d.ts +24 -0
  14. package/dist/src/cut-off-round.d.ts.map +1 -0
  15. package/dist/src/cut-off-round.js +87 -0
  16. package/dist/src/cut-off-round.js.map +1 -0
  17. package/dist/src/framework.d.ts +304 -89
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +2577 -678
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +99 -3
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +538 -13
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -1
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -0
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/reply-judge.d.ts +116 -0
  30. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  31. package/dist/src/gate/reply-judge.js +467 -0
  32. package/dist/src/gate/reply-judge.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +161 -0
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  36. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  37. package/dist/src/heartbeat/heartbeat.js +752 -0
  38. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  39. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  40. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  41. package/dist/src/heartbeat/reminder-time.js +107 -0
  42. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  43. package/dist/src/index.d.ts +4 -0
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +2 -0
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  48. package/dist/src/mcpl/capability-mask.js +3 -2
  49. package/dist/src/mcpl/capability-mask.js.map +1 -1
  50. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  51. package/dist/src/mcpl/channel-registry.js +2 -1
  52. package/dist/src/mcpl/channel-registry.js.map +1 -1
  53. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  54. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  55. package/dist/src/mcpl/inbound-source.js +40 -69
  56. package/dist/src/mcpl/inbound-source.js.map +1 -1
  57. package/dist/src/mcpl/server-connection.d.ts +18 -8
  58. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-connection.js +50 -10
  60. package/dist/src/mcpl/server-connection.js.map +1 -1
  61. package/dist/src/mcpl/tags.d.ts +9 -2
  62. package/dist/src/mcpl/tags.d.ts.map +1 -1
  63. package/dist/src/mcpl/tags.js +12 -2
  64. package/dist/src/mcpl/tags.js.map +1 -1
  65. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  66. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  67. package/dist/src/mcpl/tool-presence.js +134 -65
  68. package/dist/src/mcpl/tool-presence.js.map +1 -1
  69. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  70. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  71. package/dist/src/mcpl/tool-signature.js +47 -18
  72. package/dist/src/mcpl/tool-signature.js.map +1 -1
  73. package/dist/src/mcpl/toolset-host.d.ts +88 -37
  74. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  75. package/dist/src/mcpl/toolset-host.js +207 -108
  76. package/dist/src/mcpl/toolset-host.js.map +1 -1
  77. package/dist/src/mcpl/toolsets-module.d.ts +14 -11
  78. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  79. package/dist/src/mcpl/toolsets-module.js +141 -49
  80. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  81. package/dist/src/mcpl/types.d.ts +3 -1
  82. package/dist/src/mcpl/types.d.ts.map +1 -1
  83. package/dist/src/mcpl/types.js.map +1 -1
  84. package/dist/src/module-registry.d.ts +4 -1
  85. package/dist/src/module-registry.d.ts.map +1 -1
  86. package/dist/src/module-registry.js +9 -2
  87. package/dist/src/module-registry.js.map +1 -1
  88. package/dist/src/modules/history/index.d.ts.map +1 -1
  89. package/dist/src/modules/history/index.js +22 -11
  90. package/dist/src/modules/history/index.js.map +1 -1
  91. package/dist/src/modules/workspace/answers.d.ts +22 -0
  92. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/answers.js +123 -0
  94. package/dist/src/modules/workspace/answers.js.map +1 -0
  95. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  96. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  97. package/dist/src/modules/workspace/branch-intent.js +6 -2
  98. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  99. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  100. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  101. package/dist/src/modules/workspace/direct-write.js +57 -24
  102. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  103. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  104. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  106. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  107. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  108. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/edit-text.js +4 -2
  110. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/effects.js +20 -13
  113. package/dist/src/modules/workspace/effects.js.map +1 -1
  114. package/dist/src/modules/workspace/index.d.ts +62 -51
  115. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  116. package/dist/src/modules/workspace/index.js +934 -531
  117. package/dist/src/modules/workspace/index.js.map +1 -1
  118. package/dist/src/modules/workspace/observe.d.ts +75 -63
  119. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/observe.js +366 -180
  121. package/dist/src/modules/workspace/observe.js.map +1 -1
  122. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  123. package/dist/src/modules/workspace/read-page.js +7 -1
  124. package/dist/src/modules/workspace/read-page.js.map +1 -1
  125. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/reconcile.js +26 -16
  127. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  128. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  129. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/result-text.js.map +1 -1
  131. package/dist/src/modules/workspace/sync.d.ts +9 -0
  132. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  133. package/dist/src/modules/workspace/sync.js +14 -0
  134. package/dist/src/modules/workspace/sync.js.map +1 -1
  135. package/dist/src/modules/workspace/types.d.ts +96 -43
  136. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  137. package/dist/src/modules/workspace/types.js.map +1 -1
  138. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/watcher.js +8 -3
  140. package/dist/src/modules/workspace/watcher.js.map +1 -1
  141. package/dist/src/prose-drafts.d.ts +4 -3
  142. package/dist/src/prose-drafts.d.ts.map +1 -1
  143. package/dist/src/prose-drafts.js +3 -2
  144. package/dist/src/prose-drafts.js.map +1 -1
  145. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  146. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  147. package/dist/src/reasoning-loss-notice.js +50 -0
  148. package/dist/src/reasoning-loss-notice.js.map +1 -0
  149. package/dist/src/reasoning-loss.d.ts +24 -0
  150. package/dist/src/reasoning-loss.d.ts.map +1 -0
  151. package/dist/src/reasoning-loss.js +102 -0
  152. package/dist/src/reasoning-loss.js.map +1 -0
  153. package/dist/src/send-tools.d.ts +3 -2
  154. package/dist/src/send-tools.d.ts.map +1 -1
  155. package/dist/src/send-tools.js +3 -2
  156. package/dist/src/send-tools.js.map +1 -1
  157. package/dist/src/sent-rows.js +1 -1
  158. package/dist/src/thinking-drops.d.ts +4 -11
  159. package/dist/src/thinking-drops.d.ts.map +1 -1
  160. package/dist/src/thinking-drops.js +7 -14
  161. package/dist/src/thinking-drops.js.map +1 -1
  162. package/dist/src/tool-args.d.ts +12 -0
  163. package/dist/src/tool-args.d.ts.map +1 -1
  164. package/dist/src/tool-args.js +35 -1
  165. package/dist/src/tool-args.js.map +1 -1
  166. package/dist/src/tool-result-guard.d.ts +8 -4
  167. package/dist/src/tool-result-guard.d.ts.map +1 -1
  168. package/dist/src/tool-result-guard.js +23 -11
  169. package/dist/src/tool-result-guard.js.map +1 -1
  170. package/dist/src/tune-out/tools.d.ts +4 -1
  171. package/dist/src/tune-out/tools.d.ts.map +1 -1
  172. package/dist/src/types/agent.d.ts +40 -9
  173. package/dist/src/types/agent.d.ts.map +1 -1
  174. package/dist/src/types/events.d.ts +18 -8
  175. package/dist/src/types/events.d.ts.map +1 -1
  176. package/dist/src/types/framework.d.ts +15 -0
  177. package/dist/src/types/framework.d.ts.map +1 -1
  178. package/dist/src/types/module.d.ts +5 -2
  179. package/dist/src/types/module.d.ts.map +1 -1
  180. package/dist/src/types/trace.d.ts +34 -1
  181. package/dist/src/types/trace.d.ts.map +1 -1
  182. package/package.json +3 -3
  183. package/src/agent.ts +72 -11
  184. package/src/api/server.ts +4 -2
  185. package/src/auto-continue.ts +4 -3
  186. package/src/cut-off-round.ts +98 -0
  187. package/src/framework.ts +2694 -700
  188. package/src/gate/event-gate.ts +579 -15
  189. package/src/gate/index.ts +8 -0
  190. package/src/gate/reply-judge.ts +512 -0
  191. package/src/gate/types.ts +164 -1
  192. package/src/heartbeat/heartbeat.ts +804 -0
  193. package/src/heartbeat/reminder-time.ts +107 -0
  194. package/src/index.ts +4 -0
  195. package/src/mcpl/capability-mask.ts +3 -2
  196. package/src/mcpl/channel-registry.ts +2 -1
  197. package/src/mcpl/inbound-source.ts +42 -69
  198. package/src/mcpl/server-connection.ts +56 -16
  199. package/src/mcpl/tags.ts +13 -2
  200. package/src/mcpl/tool-presence.ts +133 -67
  201. package/src/mcpl/tool-signature.ts +51 -20
  202. package/src/mcpl/toolset-host.ts +197 -107
  203. package/src/mcpl/toolsets-module.ts +143 -49
  204. package/src/mcpl/types.ts +3 -1
  205. package/src/module-registry.ts +8 -2
  206. package/src/modules/history/index.ts +22 -12
  207. package/src/modules/workspace/answers.ts +133 -0
  208. package/src/modules/workspace/branch-intent.ts +10 -5
  209. package/src/modules/workspace/direct-write.ts +58 -23
  210. package/src/modules/workspace/disk-agreement.ts +52 -21
  211. package/src/modules/workspace/edit-text.ts +7 -5
  212. package/src/modules/workspace/effects.ts +21 -12
  213. package/src/modules/workspace/index.ts +927 -541
  214. package/src/modules/workspace/observe.ts +381 -177
  215. package/src/modules/workspace/read-page.ts +7 -1
  216. package/src/modules/workspace/reconcile.ts +27 -15
  217. package/src/modules/workspace/result-text.ts +2 -0
  218. package/src/modules/workspace/sync.ts +16 -0
  219. package/src/modules/workspace/types.ts +96 -43
  220. package/src/modules/workspace/watcher.ts +8 -3
  221. package/src/prose-drafts.ts +4 -3
  222. package/src/reasoning-loss-notice.ts +62 -0
  223. package/src/reasoning-loss.ts +120 -0
  224. package/src/send-tools.ts +3 -2
  225. package/src/sent-rows.ts +1 -1
  226. package/src/thinking-drops.ts +10 -16
  227. package/src/tool-args.ts +34 -1
  228. package/src/tool-result-guard.ts +26 -11
  229. package/src/tune-out/tools.ts +4 -1
  230. package/src/types/agent.ts +40 -9
  231. package/src/types/events.ts +18 -8
  232. package/src/types/framework.ts +15 -0
  233. package/src/types/module.ts +5 -2
  234. package/src/types/trace.ts +36 -1
@@ -0,0 +1,133 @@
1
+ import { constants } from 'node:os';
2
+ import { boundedErrorMessage } from '../../error-message.js';
3
+
4
+ export class Refusal extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = 'Refusal';
8
+ }
9
+ }
10
+
11
+ export function didNothing(tool: string, body: string, to?: string): string {
12
+ return `The ${tool} did nothing${to !== undefined ? ` to ${JSON.stringify(to)}` : ''}: ${body}`;
13
+ }
14
+
15
+ export function onlyTheHost(can: string): string {
16
+ return `Calling again won't change that; whoever runs this host can ${can}.`;
17
+ }
18
+
19
+ // Node's own message for a failed system call names the host's absolute
20
+ // path, so an agent gets the code's plain words instead.
21
+ export function errnoWords(code: string | undefined): string {
22
+ switch (code) {
23
+ case 'ENOENT': return 'nothing is there';
24
+ case 'ENOTDIR': return 'a part of the path isn\'t a directory';
25
+ case 'EACCES': return 'permission denied';
26
+ case 'EPERM': return 'not permitted';
27
+ case 'EISDIR': return 'it\'s a directory';
28
+ case 'EEXIST': return 'something is already there';
29
+ case 'ENOTEMPTY': return 'the directory isn\'t empty';
30
+ case 'EXDEV': return 'the move would cross from one filesystem to another';
31
+ case 'ELOOP': return 'too many symlinks, or a loop of them';
32
+ case 'ENAMETOOLONG': return 'a name too long for the filesystem';
33
+ case 'ENOSPC': return 'no space left on the disk';
34
+ case 'EDQUOT': return 'the disk quota is used up';
35
+ case 'EFBIG': return 'the file would be too large for the filesystem';
36
+ case 'EROFS': return 'the filesystem is read-only';
37
+ case 'EIO': return 'an input/output error on the disk';
38
+ case 'EBUSY': case 'ETXTBSY': return 'the file is busy';
39
+ case 'EMFILE': case 'ENFILE': return 'too many files are open on this host';
40
+ case 'ENOMEM': return 'this host is out of memory';
41
+ case 'EAGAIN': case 'EWOULDBLOCK': return 'the system was busy';
42
+ case 'EINTR': return 'the system call was interrupted';
43
+ case 'ETIMEDOUT': return 'the system call timed out';
44
+ case 'ESTALE': return 'a stale network file handle';
45
+ case 'EINVAL': return 'the system refused an argument as invalid';
46
+ case undefined: return 'an error the system gave no code for';
47
+ default: return `a system error (${code})`;
48
+ }
49
+ }
50
+
51
+ export function hostFix(code: string | undefined): string | undefined {
52
+ switch (code) {
53
+ case 'ENOSPC': return 'free space on its disk';
54
+ case 'EFBIG': return 'raise the limit on a file\'s size, or keep the files on a filesystem that holds larger ones';
55
+ case 'EDQUOT': return 'raise or free the disk quota';
56
+ case 'EROFS': return 'make the filesystem writable';
57
+ case 'EIO': return 'check the disk';
58
+ case 'EACCES': case 'EPERM': return 'change the permissions';
59
+ case 'EMFILE': case 'ENFILE': return 'raise the limit on open files';
60
+ case 'ELOOP': return 'fix the symlinks on that path';
61
+ case 'ENOMEM': return 'free memory';
62
+ default: return undefined;
63
+ }
64
+ }
65
+
66
+ const PASSING = new Set(['EIO', 'EMFILE', 'ENFILE', 'ENOMEM']);
67
+ const AS_DISK_IS = new Set(['EISDIR', 'ENOTDIR', 'EEXIST', 'ENOTEMPTY', 'EXDEV']);
68
+
69
+ export function systemWords(code: string | undefined, error: unknown): string {
70
+ return code !== undefined ? errnoWords(code) : boundedErrorMessage(error);
71
+ }
72
+
73
+ export function errnoWayOn(code: string | undefined, otherwise = 'Calling again may work.', hostsName = false): string {
74
+ if (code === 'ENAMETOOLONG' && hostsName) return onlyTheHost("give the mount's directory a shorter path");
75
+ const fix = hostFix(code);
76
+ if (fix !== undefined) {
77
+ return code !== undefined && PASSING.has(code) ? `Calling again may work; if it fails the same way, whoever runs this host can ${fix}.` : onlyTheHost(fix);
78
+ }
79
+ if (code === 'ENAMETOOLONG') return "Calling again won't change that; a shorter name would.";
80
+ if (code !== undefined && AS_DISK_IS.has(code)) return "Calling again won't change that while disk holds what it does there.";
81
+ return otherwise;
82
+ }
83
+
84
+ const OS_ERRNO = new Map<number, string>();
85
+ for (const [name, number] of Object.entries(constants.errno)) if (!OS_ERRNO.has(number)) OS_ERRNO.set(number, name);
86
+
87
+ // Chronicle's store errors carry the code GenericFailure, and an I/O
88
+ // failure's message is "IO error: " and Rust's std::io::Error, which ends
89
+ // with the OS error's number: an errno on POSIX, a Win32 code on Windows.
90
+ export function errnoCode(error: unknown): string | undefined {
91
+ const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
92
+ if (typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return code;
93
+ const os = error instanceof Error && process.platform !== 'win32' ? /^IO error: .*\(os error (\d+)\)$/s.exec(error.message) : null;
94
+ return os !== null ? OS_ERRNO.get(Number(os[1])) : undefined;
95
+ }
96
+
97
+ const CHANGING = new Set(['write', 'edit', 'edit_many', 'delete', 'materialize', 'sync']);
98
+
99
+ export function changesFiles(tool: string): boolean {
100
+ return CHANGING.has(tool);
101
+ }
102
+
103
+ export const ROOT_RECHECK = "Calling again may work; if it fails the same way, whoever runs this host can check the mount's directory and its path.";
104
+
105
+ export const ROOT_UNCHECKED = "Calling again won't change that while the mount's directory is gone from its path, and checks it afresh otherwise; whoever runs this host can check the directory and its path.";
106
+
107
+ export const AWAITS_REOPEN = "The workspace can't check its files against disk until its store is reopened, so calling again won't change that; whoever runs this host can reopen it with a restart.";
108
+
109
+ export interface Unexpected {
110
+ stored?: 'unknown' | 'not' | 'held';
111
+ said?: string;
112
+ reopen?: boolean;
113
+ look?: string;
114
+ }
115
+
116
+ export function unexpectedAnswer(tool: string, subject: string | undefined, error: unknown, how: Unexpected = {}): string {
117
+ const stored = how.stored ?? 'unknown';
118
+ const code = errnoCode(error);
119
+ const words = how.said ?? systemWords(code, error);
120
+ const before = stored !== 'not' ? '' : tool === 'edit_many' ? ', before any of its edits was stored' : `, before the ${tool === 'delete' ? 'deletion' : tool} was stored`;
121
+ const head = `The ${tool}${subject !== undefined ? ` of ${subject}` : ''} stopped on an error the workspace didn't expect${before}: ${words.replace(/\.$/, '')}.`;
122
+ if (stored === 'held') {
123
+ return `${head} ${subject !== undefined ? subject : 'Its files'} already held what the call gave, so ${subject !== undefined ? 'it holds' : 'they hold'} that in the workspace either way.`;
124
+ }
125
+ const changing = stored === 'unknown' && CHANGING.has(tool);
126
+ if (how.reopen === true) return [head, AWAITS_REOPEN, ...(changing ? ["Whether it changed anything isn't known until then."] : [])].join(' ');
127
+ const look = !changing ? null
128
+ : how.look ?? (subject === undefined ? 'read its files to see what they hold now' : `read ${subject} to see what the workspace holds there now`);
129
+ const on = code !== undefined
130
+ ? errnoWayOn(code, "Calling again may work; if it fails the same way, whoever runs this host can find the whole error in this host's log.")
131
+ : "Calling again may work; if it fails the same way, whoever runs this host can find the whole error in this host's log.";
132
+ return [head, ...(look !== null ? [`Whether it changed anything isn't known: ${look}.`] : []), on].join(' ');
133
+ }
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { JsStore } from '@animalabs/chronicle';
22
+ import { storedBytes } from './sync.js';
22
23
 
23
24
  export type ConflictKind =
24
25
  /** Disk, workspace and the last agreement all differ. */
@@ -56,9 +57,11 @@ export interface BranchIntent {
56
57
  origin?: 'store';
57
58
  conflict?: ConflictRecord;
58
59
  /**
59
- * Why a recorded intent can't be read (its blob missing from the store, or
60
- * not an intent): a tombstone or conflict it held is unknown, never absent.
61
- * Never written; a put replaces the damaged record.
60
+ * Why a recorded intent can't be read (its blob missing from the store,
61
+ * a blob chronicle can't read, or not an intent): a tombstone or conflict
62
+ * it held is unknown, never absent.
63
+ * Never written; a put replaces the damaged record, unless it puts the same
64
+ * contents over a blob file chronicle can't read, which chronicle keeps.
62
65
  */
63
66
  unreadable?: string;
64
67
  }
@@ -70,8 +73,10 @@ function isEmpty(intent: BranchIntent): boolean {
70
73
 
71
74
  /** A recorded intent's blob, read; a damaged one says why it can't be. */
72
75
  function readIntent(store: JsStore, blobHash: string): BranchIntent {
73
- const blob = store.getBlob(blobHash);
74
- if (!blob) return { unreadable: "its record's blob is missing from the store" };
76
+ const stored = storedBytes(store, blobHash);
77
+ if ('missing' in stored) return { unreadable: "its record's blob is missing from the store" };
78
+ if ('unreadable' in stored) return { unreadable: `its record's blob can't be read from the store (${stored.unreadable})` };
79
+ const blob = stored.bytes;
75
80
  try {
76
81
  const parsed = JSON.parse(blob.toString('utf-8')) as unknown;
77
82
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as BranchIntent;
@@ -56,19 +56,27 @@ import type { BigIntStats } from 'node:fs';
56
56
  import { lstat, open, realpath } from 'node:fs/promises';
57
57
  import { relative, sep } from 'node:path';
58
58
  import { refusedLanding } from './effects.js';
59
+ import { ROOT_RECHECK, errnoWayOn, errnoWords, onlyTheHost, systemWords } from './answers.js';
59
60
  import {
60
61
  type CheckedDir,
61
62
  type MountView,
62
63
  NONBLOCK,
64
+ LEAVES_MOUNT,
65
+ NOT_A_DIRECTORY,
63
66
  NOT_FOLLOWED,
64
67
  OUTSIDE_PARENT,
68
+ ROOT_UNAVAILABLE,
65
69
  checkDir,
66
70
  checkRoot,
67
71
  errno,
72
+ rootAbsence,
68
73
  nameOf,
69
74
  parentOf,
70
75
  } from './observe.js';
71
76
 
77
+ const DIRECTORY_THERE = 'A file can\'t be written over a directory; give another path.';
78
+ const NOT_REGULAR_THERE = onlyTheHost('put a regular file there');
79
+
72
80
  /** An open that follows no link at the path, where the platform has one. */
73
81
  const NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
74
82
 
@@ -119,7 +127,28 @@ export const sameIdentity = (a: FileIdentity, b: FileIdentity): boolean =>
119
127
  export class FileChanged extends Error {}
120
128
 
121
129
  /** Why a direct write didn't happen, in words for its answer; disk is as it was. */
122
- export class WriteRefused extends Error {}
130
+ export class WriteRefused extends Error {
131
+ constructor(message: string, readonly wayOn: string | null = null, readonly errno?: string) {
132
+ super(message);
133
+ }
134
+ }
135
+
136
+ const AFRESH = 'Calling again checks it afresh.';
137
+
138
+ const NAME_ITS_OWN = "Calling again won't change that; if you know the file it leads to, name that file by its own path.";
139
+
140
+ function rootWayOn(code: string | undefined): string {
141
+ return code === 'ENOENT' || code === 'ENOTDIR' ? onlyTheHost("put the mount's directory back at its path") : errnoWayOn(code, ROOT_RECHECK, true);
142
+ }
143
+
144
+ function refusedWayOn(reason: string, code?: string, hostsName = false): string | null {
145
+ if (reason === ROOT_UNAVAILABLE) return rootWayOn(code);
146
+ if (code !== undefined) return errnoWayOn(code, undefined, hostsName);
147
+ if (reason === NOT_FOLLOWED) return NAME_ITS_OWN;
148
+ if (reason === OUTSIDE_PARENT || reason === LEAVES_MOUNT) return "Calling again won't change that; the workspace writes only inside its mounts.";
149
+ if (reason === NOT_A_DIRECTORY) return "Calling again won't change that while something other than a directory stands where the path needs one; another path, or removing what stands there, would.";
150
+ return null;
151
+ }
123
152
 
124
153
  /** A file's new bytes staged beside it, ready to be renamed over it (stageContained). */
125
154
  export interface Staged {
@@ -170,7 +199,8 @@ async function directoryOf(view: MountView, rootReal: string, rel: string, makeD
170
199
  const checked = await checkDir(view, rootReal, parentOf(rel), { create: makeDirectory });
171
200
  if (checked.kind === 'checked') return checked.dir;
172
201
  if (checked.kind === 'absent') throw new FileChanged(rel);
173
- throw new WriteRefused(checked.outside ? OUTSIDE_PARENT : checked.reason);
202
+ const reason = checked.outside ? OUTSIDE_PARENT : checked.reason;
203
+ throw new WriteRefused(reason, refusedWayOn(reason, checked.errno));
174
204
  }
175
205
 
176
206
  /**
@@ -193,29 +223,34 @@ export async function stageContained(
193
223
  { makeDirectory = false }: { makeDirectory?: boolean } = {},
194
224
  ): Promise<Staged> {
195
225
  const root = await checkRoot(view, rootReal);
196
- if ('reason' in root) throw new WriteRefused(root.reason);
226
+ if ('reason' in root) {
227
+ const code = root.errno ?? await rootAbsence(rootReal);
228
+ throw new WriteRefused(root.reason, rootWayOn(code), code);
229
+ }
197
230
  let parent = await directoryOf(view, rootReal, rel, makeDirectory);
198
231
  let path = parent.at(nameOf(rel));
199
232
  let info = await lstat(path, { bigint: true }).catch((err: unknown) => {
200
233
  if (errno(err) === 'ENOENT') return null;
201
- throw new WriteRefused(`cannot stat the file (${errno(err) ?? String(err)})`);
234
+ throw new WriteRefused(`cannot stat the file (${systemWords(errno(err), err)})`, errnoWayOn(errno(err)), errno(err));
202
235
  });
203
236
  if (info?.isSymbolicLink()) {
204
- if (!view.followSymlinks) throw new WriteRefused(NOT_FOLLOWED);
237
+ if (!view.followSymlinks) throw new WriteRefused(NOT_FOLLOWED, NAME_ITS_OWN);
205
238
  // A followed link stands for the file it leads to, as a read takes it:
206
239
  // the write goes there, judged where it lands, and the link stays.
207
240
  const real = await realpath(path).catch((err: unknown) => {
208
- throw new WriteRefused(errno(err) === 'ENOENT' ? "a symlink whose target doesn't exist" : `cannot resolve the symlink (${errno(err) ?? String(err)})`);
241
+ throw errno(err) === 'ENOENT'
242
+ ? new WriteRefused("a symlink whose target doesn't exist", onlyTheHost('remove the link or make it lead to a file'))
243
+ : new WriteRefused(`cannot resolve the symlink (${systemWords(errno(err), err)})`, errnoWayOn(errno(err)), errno(err));
209
244
  });
210
245
  const leaves = await refusedLanding(view, rootReal, rel, real);
211
- if (leaves !== null) throw new WriteRefused(leaves);
246
+ if (leaves !== null) throw new WriteRefused(leaves, refusedWayOn(leaves));
212
247
  const realRel = relativeTo(rootReal, real);
213
248
  parent = await directoryOf(view, rootReal, realRel, false);
214
249
  path = parent.at(nameOf(realRel));
215
250
  info = await lstat(path, { bigint: true }).catch((err: unknown) => {
216
251
  // Gone since realpath found it: another writer's change.
217
252
  if (errno(err) === 'ENOENT') throw new FileChanged(rel);
218
- throw new WriteRefused(`cannot stat the file the symlink leads to (${errno(err) ?? String(err)})`);
253
+ throw new WriteRefused(`cannot stat the file the symlink leads to (${systemWords(errno(err), err)})`, errnoWayOn(errno(err)), errno(err));
219
254
  });
220
255
  if (info.isSymbolicLink()) throw new FileChanged(rel);
221
256
  } else {
@@ -223,10 +258,10 @@ export async function stageContained(
223
258
  // lands where the mount may not act: in a nested mount's directory, or
224
259
  // on a file its own include leaves out (refusedLanding).
225
260
  const leaves = await refusedLanding(view, rootReal, rel, path);
226
- if (leaves !== null) throw new WriteRefused(leaves);
261
+ if (leaves !== null) throw new WriteRefused(leaves, refusedWayOn(leaves));
227
262
  }
228
- if (info?.isDirectory()) throw new WriteRefused('a directory');
229
- if (info && !info.isFile()) throw new WriteRefused('not a regular file');
263
+ if (info?.isDirectory()) throw new WriteRefused('a directory', DIRECTORY_THERE);
264
+ if (info && !info.isFile()) throw new WriteRefused('not a regular file', NOT_REGULAR_THERE);
230
265
  // A fixed-length name, so a file whose own name is near the 255-byte limit can still be replaced.
231
266
  const temp = parent.at(`${STAGED_PREFIX}${randomUUID()}`);
232
267
  let made = false;
@@ -266,8 +301,8 @@ export async function stageContained(
266
301
  // A staged file that couldn't be made names the directory: a push's
267
302
  // write in place needs only the file, and this one needs both.
268
303
  throw new WriteRefused(made
269
- ? `cannot write the staged file beside it (${errno(err) ?? String(err)})`
270
- : `cannot make the staged file in its directory (${errno(err) ?? String(err)}): a direct write needs to write the file's directory as well as the file`);
304
+ ? `cannot write the staged file beside it (${systemWords(errno(err), err)})`
305
+ : `cannot make the staged file in its directory (${systemWords(errno(err), err)}): a direct write needs to write the file's directory as well as the file`, errnoWayOn(errno(err)), errno(err));
271
306
  }
272
307
  }
273
308
 
@@ -278,7 +313,7 @@ export async function stageContained(
278
313
  */
279
314
  export async function placedStill(staged: Staged): Promise<void> {
280
315
  if (!(await staged.root.stillHere())) throw new WriteRefused(ROOT_MOVED);
281
- if (!(await staged.parent.stillHere())) throw new WriteRefused('the directory changed since it was checked');
316
+ if (!(await staged.parent.stillHere())) throw new WriteRefused('the directory changed since it was checked', AFRESH);
282
317
  }
283
318
 
284
319
  /**
@@ -309,13 +344,13 @@ export function checkUnchanged(staged: Staged, expected: { identity: FileIdentit
309
344
  try {
310
345
  now = lstatSync(staged.path, { bigint: true });
311
346
  } catch (err) {
312
- if (errno(err) !== 'ENOENT') throw new WriteRefused(`cannot check that it hadn't changed (${errno(err) ?? String(err)})`);
347
+ if (errno(err) !== 'ENOENT') throw new WriteRefused(`cannot check that it hadn't changed (${systemWords(errno(err), err)})`, errnoWayOn(errno(err)), errno(err));
313
348
  if (expected !== null) throw new FileChanged(staged.path);
314
349
  return;
315
350
  }
316
- if (now.isSymbolicLink()) throw new WriteRefused('it became a symlink while this call was being applied');
317
- if (now.isDirectory()) throw new WriteRefused('a directory');
318
- if (!now.isFile()) throw new WriteRefused('not a regular file');
351
+ if (now.isSymbolicLink()) throw new WriteRefused('it became a symlink while this call was being applied', AFRESH);
352
+ if (now.isDirectory()) throw new WriteRefused('a directory', DIRECTORY_THERE);
353
+ if (!now.isFile()) throw new WriteRefused('not a regular file', NOT_REGULAR_THERE);
319
354
  // As a push's write, which opens the file for writing, would be refused:
320
355
  // the rename needs only the directory's permission, and would replace a
321
356
  // file its owner made read-only.
@@ -329,9 +364,9 @@ export function checkUnchanged(staged: Staged, expected: { identity: FileIdentit
329
364
  throw new FileChanged(staged.path);
330
365
  }
331
366
  if (code === 'EACCES' || code === 'EPERM') {
332
- throw new WriteRefused(`a file the host's process may not write (${code}): its mode or owner keeps that process out, and calling again is refused until the file's owner, or whoever runs the host, changes that`);
367
+ throw new WriteRefused(`a file the host's process may not write (${errnoWords(code)}): its mode or owner keeps that process out, and calling again is refused until the file's owner, or whoever runs the host, changes that`);
333
368
  }
334
- throw new WriteRefused(`cannot check that the host's process may write it (${code ?? String(err)})`);
369
+ throw new WriteRefused(`cannot check that the host's process may write it (${systemWords(code, err)})`, errnoWayOn(code), code);
335
370
  }
336
371
  if (expected === null) return;
337
372
  let bytes: Buffer;
@@ -353,7 +388,7 @@ export function checkUnchanged(staged: Staged, expected: { identity: FileIdentit
353
388
  // ELOOP: a link put at the path since the lstat; EAGAIN: a pipe, with a
354
389
  // writer and nothing in it yet. Either is another writer's change.
355
390
  if (errno(err) === 'ENOENT' || errno(err) === 'ELOOP' || errno(err) === 'EAGAIN') throw new FileChanged(staged.path);
356
- throw new WriteRefused(`cannot check that it hadn't changed (${errno(err) ?? String(err)})`);
391
+ throw new WriteRefused(`cannot check that it hadn't changed (${systemWords(errno(err), err)})`, errnoWayOn(errno(err)), errno(err));
357
392
  }
358
393
  if (!sameIdentity(identityOf(after), expected.identity) || !bytes.equals(expected.bytes)) throw new FileChanged(staged.path);
359
394
  }
@@ -384,13 +419,13 @@ export function renameInto(staged: Staged): void {
384
419
  // The staged file is still the one this call wrote, not another put at its name.
385
420
  const temp = lstatSync(staged.temp, { bigint: true });
386
421
  if (!temp.isFile() || !sameIdentity(identityOf(temp), staged.tempIdentity)) {
387
- throw new WriteRefused('another writer replaced the staged copy of the new text before it could be renamed over the file, which is as it was');
422
+ throw new WriteRefused('another writer replaced the staged copy of the new text before it could be renamed over the file, which is as it was', 'Calling again writes it afresh.');
388
423
  }
389
424
  otherLinks = otherLinksAt(staged.path);
390
425
  renameSync(staged.temp, staged.path);
391
426
  } catch (err) {
392
427
  if (err instanceof WriteRefused) throw err;
393
- throw new WriteRefused(`cannot rename the staged file over it (${errno(err) ?? (err instanceof Error ? err.message : String(err))})`);
428
+ throw new WriteRefused(`cannot rename the staged file over it (${systemWords(errno(err), err)})`, errnoWayOn(errno(err)), errno(err));
394
429
  }
395
430
  // Set once the rename has happened, so a count never stands for a split that didn't.
396
431
  staged.otherLinks = otherLinks;
@@ -10,14 +10,14 @@
10
10
  * intent — tombstones, store origin, conflicts — lives in BranchIntents and
11
11
  * rewinds with the tree.
12
12
  *
13
- * P is evidence about one directory, so each mount also records the identity
14
- * (device and inode) of the root that evidence was gathered under. A root
15
- * that isn't that directory any more (an unmounted drive whose mountpoint is
16
- * now empty, a replaced directory) is an unavailable mount, never a disk on
17
- * which every file was deleted; only an explicit acceptance takes it, and
18
- * that starts its evidence afresh. A place, where a walk stopped (`walks`),
19
- * binds no root: it only says where the next walk starts, in whatever
20
- * directory the root is by then.
13
+ * P is evidence about one directory, so each mount also records the identity of
14
+ * the root that evidence was gathered under (sameRootIdentity says when two
15
+ * name the same directory). A root that isn't that directory any more (an
16
+ * unmounted drive whose mountpoint is now empty, a replaced directory) is an
17
+ * unavailable mount, never a disk on which every file was deleted; only an
18
+ * explicit acceptance takes it, and that starts its evidence afresh. A place,
19
+ * where a walk stopped (`walks`), binds no root: it only says where the next
20
+ * walk starts, in whatever directory the root is by then.
21
21
  *
22
22
  * Every transition is write-ahead (see reconcile.ts): an `intent` entry
23
23
  * (`pending`, with the prior value and the expected one) is durable before the
@@ -33,9 +33,18 @@
33
33
  * succeed, since its completion releases a draft's protection.
34
34
  */
35
35
 
36
+ import { AsyncLocalStorage } from 'node:async_hooks';
36
37
  import { resolve } from 'node:path';
37
38
  import type { JsStore } from '@animalabs/chronicle';
38
39
  import { RecordJournal } from '../../record-journal.js';
40
+ import { Refusal, onlyTheHost } from './answers.js';
41
+
42
+ const UNTIL_REOPENED = "the workspace can't check its files against disk until its store is reopened";
43
+ const REOPEN = onlyTheHost('reopen it with a restart');
44
+
45
+ export class EvidenceUnavailable extends Refusal {}
46
+
47
+ export const callGate = new AsyncLocalStorage<{ passed: boolean }>();
39
48
 
40
49
  /** Everything `stat` says about a file that can vouch for unchanged bytes. */
41
50
  export interface Fingerprint {
@@ -94,10 +103,15 @@ export interface Interrupted {
94
103
 
95
104
  export type Physical = Agreed | Pending | Interrupted;
96
105
 
97
- /** Which directory a mount root is: its device and inode, as decimal strings. */
106
+ /**
107
+ * Which directory a mount root is: its device and inode, and its birth time
108
+ * in nanoseconds as the runtime reports it, where that is above zero, as decimal
109
+ * strings.
110
+ */
98
111
  export interface RootIdentity {
99
112
  dev: string;
100
113
  ino: string;
114
+ born?: string;
101
115
  }
102
116
 
103
117
  /** Whether two configured roots name the same path, whatever their spelling
@@ -106,8 +120,17 @@ function sameRootPath(a: string, b: string): boolean {
106
120
  return resolve(a) === resolve(b);
107
121
  }
108
122
 
123
+ /**
124
+ * Whether two identities name the same directory: the same device and inode,
125
+ * or the same inode and birth time on another device, since macOS can give a
126
+ * volume another device number after a reboot. A birth time in whole seconds
127
+ * doesn't count across devices: filesystems made in the same second can give
128
+ * their roots one inode (2 on ext4) and one birth time.
129
+ */
109
130
  export function sameRootIdentity(a: RootIdentity, b: RootIdentity): boolean {
110
- return a.dev === b.dev && a.ino === b.ino;
131
+ if (a.ino !== b.ino) return false;
132
+ if (a.dev === b.dev) return true;
133
+ return a.born !== undefined && a.born === b.born && !a.born.endsWith('000000000');
111
134
  }
112
135
 
113
136
  type JournalEntry =
@@ -219,18 +242,18 @@ export class DiskAgreement {
219
242
  */
220
243
  ensureReconciled(): void {
221
244
  if (this.journal.needsReopen) {
222
- throw new Error(
223
- 'Disk-agreement evidence is unavailable until the store is reopened, as at the next start: ' +
224
- 'an append to its journal failed inside the store, and the evidence may lack what it recorded',
225
- );
245
+ throw new EvidenceUnavailable(`${UNTIL_REOPENED}: an append to its record of what disk last agreed with failed inside the store, so that record may lack what the append held. ${REOPEN}`);
226
246
  }
227
247
  if (!this.whole) {
228
- throw new Error(
229
- 'Disk-agreement evidence is unavailable until the store is reopened, as at the next start: ' +
230
- 'reading it back from the store failed',
231
- );
248
+ throw new EvidenceUnavailable(`${UNTIL_REOPENED}: reading back its record of what disk last agreed with failed. ${REOPEN}`);
232
249
  }
233
250
  if (this.journal.needsReconcile) this.rebuild();
251
+ const gate = callGate.getStore();
252
+ if (gate) gate.passed = true;
253
+ }
254
+
255
+ get awaitsReopen(): boolean {
256
+ return this.journal.needsReopen || !this.whole;
234
257
  }
235
258
 
236
259
  /** Whether evidence recorded so far still awaits a barrier. */
@@ -314,11 +337,19 @@ export class DiskAgreement {
314
337
 
315
338
  /**
316
339
  * Record the root's identity just before the first evidence a mount gains:
317
- * the caller saw it as the root while gathering that evidence. A no-op once
318
- * one is recorded; changing it is acceptRoot's alone.
340
+ * the caller saw it as the root while gathering that evidence. Once one is
341
+ * recorded, the same directory seen on another device, or with a birth
342
+ * time the record lacks, replaces it and keeps the evidence; another
343
+ * directory is acceptRoot's alone.
319
344
  */
320
345
  recordRootIdentity(mount: string, identity: RootIdentity): void {
321
- if (this.rootIdentity(mount) !== undefined) return;
346
+ const recorded = this.rootIdentity(mount);
347
+ if (recorded !== undefined) {
348
+ if (!sameRootIdentity(recorded, identity)) return;
349
+ const renumbered = recorded.dev !== identity.dev;
350
+ const bornLearned = recorded.born === undefined && identity.born !== undefined;
351
+ if (!renumbered && !bornLearned) return;
352
+ }
322
353
  this.write({ t: 'root', mount, identity }, {});
323
354
  }
324
355
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { ToolResult } from '../../types/events.js';
15
15
  import { type Change, type FilePlan, type Leaves, type MatchProblem, SHOWN_MATCHES } from './edit-plan.js';
16
- import { answerText, fieldLine, sectionLead, sectionText, shownValue, textAnswer } from './result-text.js';
16
+ import { answerText, fieldLine, sectionLead, sectionText, shownValue, textAnswer, type DiskState } from './result-text.js';
17
17
  import { MAX_LINE_CHARS, numberedLine, safeCut } from './text.js';
18
18
 
19
19
  /** The tool a call came through, which words its refusals and receipt. */
@@ -548,8 +548,8 @@ function listedFileLine(file: ListedFile): string {
548
548
  }
549
549
 
550
550
  /** edit_many's receipt's text before its changed regions: its counts, and the files it lists. */
551
- function manyHeadText(data: { edits: number; replacements: number; files: ListedFile[]; filesNotListed?: number }): string {
552
- const header = [fieldLine('edits', data.edits), fieldLine('replacements', data.replacements)];
551
+ function manyHeadText(data: { edits: number; replacements: number; disk: DiskState; files: ListedFile[]; filesNotListed?: number }): string {
552
+ const header = [fieldLine('edits', data.edits), fieldLine('replacements', data.replacements), fieldLine('disk', data.disk)];
553
553
  if (data.filesNotListed !== undefined) header.push(fieldLine('filesNotListed', data.filesNotListed));
554
554
  return answerText(header, [sectionText('files', data.files.map(listedFileLine).join('\n'))]);
555
555
  }
@@ -571,7 +571,7 @@ function manyHeadText(data: { edits: number; replacements: number; files: Listed
571
571
  * call answers ResultTooLarge. Otherwise the model reads the text, and a
572
572
  * script gets the receipt's fields beside it.
573
573
  */
574
- export function editReceipt(tool: EditTool, files: StoredFile[], editCount: number, maxChars: number): ToolResult {
574
+ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: number, maxChars: number, disk: DiskState): ToolResult {
575
575
  const reserve = receiptReserve(tool, files, maxChars);
576
576
  const notesOf = ({ plan, notes }: StoredFile): string[] => {
577
577
  const lineEnds = lineEndNote(plan);
@@ -595,6 +595,7 @@ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: numb
595
595
  path: file.path,
596
596
  size: file.plan.bytes.length,
597
597
  replacements: file.plan.replacements,
598
+ disk,
598
599
  ...(notes.length ? { note: notes.join(' ') } : {}),
599
600
  ...(file.modeKept ? { modeKept: file.modeKept } : {}),
600
601
  };
@@ -604,9 +605,10 @@ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: numb
604
605
  return textAnswer(answerText(header, [sectionText('changed', changed)]), { ...head, changed });
605
606
  }
606
607
  const listed: ListedFile[] = [];
607
- const data: { edits: number; replacements: number; files: ListedFile[]; filesNotListed?: number; changed: string } = {
608
+ const data: { edits: number; replacements: number; disk: DiskState; files: ListedFile[]; filesNotListed?: number; changed: string } = {
608
609
  edits: editCount,
609
610
  replacements: files.reduce((sum, file) => sum + file.plan.replacements, 0),
611
+ disk,
610
612
  files: listed,
611
613
  changed: '',
612
614
  };
@@ -47,7 +47,9 @@ import type { FileHandle } from 'node:fs/promises';
47
47
  import {
48
48
  type CheckedDir,
49
49
  type MountView,
50
+ LEAVES_MOUNT,
50
51
  NONBLOCK,
52
+ NOT_A_DIRECTORY,
51
53
  NOT_FOLLOWED,
52
54
  OUTSIDE_PARENT,
53
55
  ROOT_REPLACED,
@@ -58,7 +60,6 @@ import {
58
60
  leadsInto,
59
61
  nameOf,
60
62
  coversLanding,
61
- namesNestedPlace,
62
63
  nestedAtReal,
63
64
  parentOf,
64
65
  } from './observe.js';
@@ -232,7 +233,7 @@ export async function writeContained(
232
233
  createMode?: number,
233
234
  ): Promise<{ perm: number; widenRefused: boolean; modeKept: boolean }> {
234
235
  const checked = await parentFor(view, rootReal, rel, true);
235
- if (checked.kind !== 'checked') throw new EffectFailed('a parent path is not a directory', false);
236
+ if (checked.kind !== 'checked') throw new EffectFailed(NOT_A_DIRECTORY, false);
236
237
  const parent = checked.dir;
237
238
  const root = checked.root;
238
239
  const target = parent.at(nameOf(rel));
@@ -258,8 +259,16 @@ export async function writeContained(
258
259
  touched = true;
259
260
  return handle;
260
261
  } catch (err) {
261
- // O_EXCL refuses any entry: a file, a directory or a link.
262
- if (errno(err) === 'EEXIST') throw new EffectFailed('something appeared at this path since it was checked', false);
262
+ // O_EXCL refuses any entry: a file, a directory or a link, one that
263
+ // leads nowhere included, which a followed observation reads as absent.
264
+ if (errno(err) === 'EEXIST') {
265
+ const link = (await lstat(target).catch(() => null))?.isSymbolicLink() === true;
266
+ const leads = link ? await stat(target).then(() => 'somewhere', (e: unknown) => errno(e)) : null;
267
+ throw new EffectFailed(leads === 'ENOENT' || leads === 'ENOTDIR'
268
+ ? 'a symlink that leads nowhere is at this path, and a push makes a file only where nothing is, forced or not; '
269
+ + 'whoever runs this host can remove the link or make it lead to a file, and a materialize then writes it'
270
+ : 'something appeared at this path since it was checked', false);
271
+ }
263
272
  throw new EffectFailed(`cannot create the file (${errno(err) ?? message(err)})`, false);
264
273
  }
265
274
  };
@@ -386,10 +395,10 @@ export async function writeContained(
386
395
  * it, and so does a create, before anything is made.
387
396
  */
388
397
  export async function refusedLanding(view: MountView, rootReal: string, rel: string, real: string): Promise<string | null> {
389
- const leaves = view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT;
398
+ const leaves = view.followSymlinks ? LEAVES_MOUNT : OUTSIDE_PARENT;
390
399
  if (!contained(rootReal, real)) return leaves;
391
- const holder = nestedAtReal(real, view.nested);
392
- if (holder) return namesNestedPlace(holder.rest, holder.include, false) ? leadsInto(view.name, rel, holder) : view.followSymlinks ? leaves : NOT_FOLLOWED;
400
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
401
+ if (holder) return holder.names(false) ? leadsInto(view.name, rel, holder) : view.followSymlinks ? leaves : NOT_FOLLOWED;
393
402
  if (!(await coversLanding(view, rootReal, real, rel))) return view.followSymlinks ? leaves : NOT_FOLLOWED;
394
403
  return null;
395
404
  }
@@ -566,15 +575,15 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
566
575
  // The link itself is what is removed; a followed one must still lie inside.
567
576
  if (!view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
568
577
  const real = await realpath(target).catch(() => null);
569
- if (real === null || !contained(rootReal, real)) throw new EffectFailed('a symlink that leaves the mount', false);
578
+ if (real === null || !contained(rootReal, real)) throw new EffectFailed(LEAVES_MOUNT, false);
570
579
  // One leading into a nested mount's directory is that mount's: the link
571
580
  // and the file it leads to are left as they are. Where that mount's
572
581
  // include leaves the place out, it's no mount's, and the link leaves this
573
582
  // one (namesNestedPlace).
574
- const holder = nestedAtReal(real, view.nested);
583
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
575
584
  if (holder) {
576
- const directory = holder.include !== undefined && await stat(real).then((s) => s.isDirectory(), () => false);
577
- throw new EffectFailed(namesNestedPlace(holder.rest, holder.include, directory) ? leadsInto(view.name, rel, holder) : 'a symlink that leaves the mount', false);
585
+ const directory = holder.judgedByInclude && await stat(real).then((s) => s.isDirectory(), () => false);
586
+ throw new EffectFailed(holder.names(directory) ? leadsInto(view.name, rel, holder) : LEAVES_MOUNT, false);
578
587
  }
579
588
  const through = await stat(real, { bigint: true }).catch(() => null);
580
589
  if (through === null) throw new EffectFailed(CHANGED, false);
@@ -586,7 +595,7 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
586
595
  if (!through.isFile()) throw new EffectFailed(through.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file', false);
587
596
  // One leading to a file this mount's own include leaves out leaves the
588
597
  // mount too, and that file isn't read for a hash (coversLanding).
589
- if (!(await coversLanding(view, rootReal, real, rel))) throw new EffectFailed('a symlink that leaves the mount', false);
598
+ if (!(await coversLanding(view, rootReal, real, rel))) throw new EffectFailed(LEAVES_MOUNT, false);
590
599
  file = through;
591
600
  } else if (info.isDirectory()) {
592
601
  throw new EffectFailed('a directory', false);