@animalabs/agent-framework 0.22.3 → 0.23.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 +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -1
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A refusal the Discord module gives the agent as worded: Discord refused the
3
+ * request, its target can't take it, or a lookup before the tool's change (a
4
+ * send, an edit, a deletion, a reaction or a thread) stopped on an unexpected
5
+ * error, so the tool did nothing, and the message says why and what to do
6
+ * instead. A thread a send asked for and didn't get isn't one: the send
7
+ * returns why as words, since its message is out.
8
+ * Anything else a tool throws is an unexpected error, which the module words
9
+ * itself (DiscordModule.handleToolCall).
10
+ */
11
+ export class DiscordRefusal extends Error {
12
+ override readonly name = 'DiscordRefusal';
13
+ }
@@ -375,6 +375,10 @@ export interface DiscordEditEvent {
375
375
  type: 'discord:edit';
376
376
  discordMessageId: string;
377
377
  newContent: string;
378
+ /** The message's attachments as the edit left them, when the client knows
379
+ * them: a stored row whose text comes first drops the blocks of those it
380
+ * no longer has. */
381
+ attachments?: DiscordAttachment[];
378
382
  [key: string]: unknown;
379
383
  }
380
384
 
@@ -412,10 +416,12 @@ export interface DiscordClientInterface {
412
416
 
413
417
  /**
414
418
  * Send a message to a channel, and start the thread `createThread` asks
415
- * for when the channel can hold threads; in one that can't, such as a DM,
416
- * no thread is started and nothing says so. When this resolves, the
417
- * message was sent. `threadFailure`, when there is one, is why the thread
418
- * it tried to start wasn't.
419
+ * for when the channel can hold threads. When this resolves, the message
420
+ * was sent. `threadFailure`, when there is one, is why the thread it tried
421
+ * to start wasn't, which the send reports as its error; `note`, when there
422
+ * is one, is something the agent should know of a send that succeeded,
423
+ * such as a thread its channel can't hold. DiscordJsClient words both for
424
+ * the agent.
419
425
  */
420
426
  sendMessage(
421
427
  channelId: string,
@@ -424,10 +430,10 @@ export interface DiscordClientInterface {
424
430
  replyTo?: string;
425
431
  createThread?: { name: string };
426
432
  }
427
- ): Promise<{ messageId: string; threadFailure?: string }>;
433
+ ): Promise<{ messageId: string; threadFailure?: string; note?: string }>;
428
434
 
429
- /** Send a DM to a user */
430
- sendDM(userId: string, content: string): Promise<{ messageId: string }>;
435
+ /** Send a DM to a user. `channelId`, when the client gives it, is the direct message channel's id, which history reads. */
436
+ sendDM(userId: string, content: string): Promise<{ messageId: string; channelId?: string }>;
431
437
 
432
438
  /** Fetch a message's text content */
433
439
  fetchMessage(channelId: string, messageId: string): Promise<string>;
@@ -467,8 +473,10 @@ export interface DiscordClientInterface {
467
473
  /** Register message handler */
468
474
  onMessage(handler: (message: DiscordMessageData) => void): void;
469
475
 
470
- /** Register message edit handler */
471
- onMessageEdit(handler: (messageId: string, newContent: string) => void): void;
476
+ /** Register message edit handler. A client that knows the edited
477
+ * message's attachments passes them third; one that passes two arguments
478
+ * leaves a stored row's attachment blocks as they are. */
479
+ onMessageEdit(handler: (messageId: string, newContent: string, attachments?: DiscordAttachment[]) => void): void;
472
480
 
473
481
  /** Register message delete handler */
474
482
  onMessageDelete(handler: (messageId: string) => void): void;
@@ -26,6 +26,7 @@ import type { EventResponse, ProcessState } from '../../types/module.js';
26
26
  import type { AgentFramework } from '../../framework.js';
27
27
  import { formatZonedDateTime, resolveTimeZone } from '../../timezone.js';
28
28
  import { safeSlice } from '../../safe-slice.js';
29
+ import { unknownToolMessage } from '../unknown-tool.js';
29
30
 
30
31
  export interface HealthModuleConfig {
31
32
  /** IANA zone for timestamps returned by the snapshot tool. */
@@ -127,7 +128,7 @@ export class HealthModule implements Module {
127
128
 
128
129
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
129
130
  if (call.name !== 'snapshot') {
130
- return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
131
+ return { success: false, isError: true, error: unknownToolMessage(this.name, call.name, this.getTools()) };
131
132
  }
132
133
  const input = (call.input ?? {}) as SnapshotInput;
133
134
  const lookback = input.lookback ?? this.config.defaultLookback;
@@ -156,8 +157,11 @@ export class HealthModule implements Module {
156
157
  includeSubagents: boolean,
157
158
  ): Record<string, unknown> {
158
159
  if (!this.framework || !this.store) {
160
+ // The host's mistake, named for whoever runs it; the agent reads what
161
+ // it can do about it, which is nothing.
162
+ console.error('[health] snapshot asked for before the host bound the module: call bind(framework, store) before tool dispatch');
159
163
  throw new Error(
160
- 'HealthModule not bound — host must call bind(framework, store) before tool dispatch.',
164
+ "The health snapshot isn't available: this host hasn't finished setting up the health module, so nothing was read. Calling it again won't change that; ask whoever runs this host.",
161
165
  );
162
166
  }
163
167
 
@@ -30,7 +30,7 @@ export type {
30
30
  SyncInput,
31
31
  } from './workspace/index.js';
32
32
 
33
- export { DiscordModule, DiscordJsClient } from './discord/index.js';
33
+ export { DiscordModule, DiscordJsClient, DiscordRefusal } from './discord/index.js';
34
34
  export type { DiscordJsClientConfig } from './discord/index.js';
35
35
  export type {
36
36
  DiscordModuleConfig,
@@ -181,6 +181,12 @@ export class DiskAgreement {
181
181
  * place owes none (recordWalk).
182
182
  */
183
183
  private unsynced = false;
184
+ /**
185
+ * Whether memory holds a whole state: false until a rebuild first runs to
186
+ * its end. A rebuild fills a new map and swaps it in only at its end, so
187
+ * one that throws partway leaves whatever whole state came before it.
188
+ */
189
+ private whole = false;
184
190
  /** Whether this host observes file modes (not on Windows): where it doesn't, no record it writes claims one. */
185
191
  private readonly modesObserved: () => boolean;
186
192
 
@@ -206,8 +212,10 @@ export class DiskAgreement {
206
212
  * recorded (the store may hold it where this process can't read it back),
207
213
  * and a decision from stale P can record what reopening contradicts, such
208
214
  * as a conflict that stays after the right evidence is back. So until the
209
- * store is reopened, no evidence is read, written or checkpointed, and
210
- * every pass and tool call that needs it fails.
215
+ * store is reopened, no evidence is read (but for rootIdentityForRead's
216
+ * one exception), written or checkpointed, and every pass and tool call
217
+ * that needs it fails. The same holds, the exception included, when the
218
+ * open's rebuild threw: there is no whole evidence to read.
211
219
  */
212
220
  ensureReconciled(): void {
213
221
  if (this.journal.needsReopen) {
@@ -216,6 +224,12 @@ export class DiskAgreement {
216
224
  'an append to its journal failed inside the store, and the evidence may lack what it recorded',
217
225
  );
218
226
  }
227
+ 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
+ );
232
+ }
219
233
  if (this.journal.needsReconcile) this.rebuild();
220
234
  }
221
235
 
@@ -253,16 +267,24 @@ export class DiskAgreement {
253
267
  const mount = replayed.get(entry.mount);
254
268
  if (mount) apply(mount, entry);
255
269
  }
256
- this.mounts.clear();
270
+ // Filled apart and swapped in at the end, so an append that throws here
271
+ // leaves memory as it was, whole, rather than with the mounts after it
272
+ // missing. Nothing reaches that today: only an open with a moved mount
273
+ // appends here, the workspace opens each agreement once, and after the
274
+ // open a rebuild finds every mount's record and appends none.
275
+ const rebuilt = new Map<string, MountEvidence>();
257
276
  for (const { name, root } of this.configured) {
258
277
  const found = replayed.get(name);
259
278
  if (found && sameRootPath(found.root, root)) {
260
- this.mounts.set(name, found);
279
+ rebuilt.set(name, found);
261
280
  } else {
262
- this.mounts.set(name, { root, paths: new Map(), walks: new Map() });
281
+ rebuilt.set(name, { root, paths: new Map(), walks: new Map() });
263
282
  this.journal.append({ t: 'mount', mount: name, root });
264
283
  }
265
284
  }
285
+ this.mounts.clear();
286
+ for (const [name, mount] of rebuilt) this.mounts.set(name, mount);
287
+ this.whole = true;
266
288
  }
267
289
 
268
290
  /** The identity of the root this mount's evidence was gathered under, once any was. */
@@ -271,6 +293,25 @@ export class DiskAgreement {
271
293
  return this.mounts.get(mount)?.identity;
272
294
  }
273
295
 
296
+ /**
297
+ * The identity a disk read checks the mount's root against: the one in
298
+ * memory once memory is whole, in every state of the journal, and an error
299
+ * until then. It is the last identity whose append succeeded, since write()
300
+ * appends before it applies, and no reconcile can change it, since root
301
+ * entries are written without a sync; were one made durable, its failed
302
+ * sync would leave the identity from before it: an old one, which refuses
303
+ * the new root, or none, as for a mount with no recorded identity. This
304
+ * is the one exception to ensureReconciled's "no evidence is read": the
305
+ * layer stops reading so that a decision from stale evidence can't record
306
+ * what reopening contradicts, and a read records nothing. Refusing reads
307
+ * instead would stop every host read of the workspace's disk until a
308
+ * restart.
309
+ */
310
+ rootIdentityForRead(mount: string): RootIdentity | undefined {
311
+ if (!this.whole) this.ensureReconciled(); // throws: there's no whole state to read
312
+ return this.mounts.get(mount)?.identity;
313
+ }
314
+
274
315
  /**
275
316
  * Record the root's identity just before the first evidence a mount gains:
276
317
  * the caller saw it as the root while gathering that evidence. A no-op once
@@ -24,7 +24,8 @@
24
24
  * Node has no openat, so a create, mkdir or unlink still resolves its path by
25
25
  * name after the parent was checked: a parent replaced concurrently in that
26
26
  * window can redirect that one operation, leaving an empty file or directory
27
- * outside the mount or unlinking a same-named file there. Nor is there an
27
+ * outside the mount or in a nested mount's directory, or unlinking a
28
+ * same-named file there. Nor is there an
28
29
  * unlink bound to an inode: an unlink checks the entry's identity again just
29
30
  * before it, and a file replaced at its path after that check is the one
30
31
  * unlinked. Content writes stay bound to the descriptor verified before
@@ -53,7 +54,9 @@ import {
53
54
  errno,
54
55
  checkDir,
55
56
  checkRoot,
57
+ leadsInto,
56
58
  nameOf,
59
+ nestedAtReal,
57
60
  parentOf,
58
61
  } from './observe.js';
59
62
 
@@ -273,7 +276,7 @@ export async function writeContained(
273
276
  let stage = 'write the file';
274
277
  try {
275
278
  const created = touched;
276
- await checkOpened(view, rootReal, root, parent, target, handle, expect, noFollow, touched);
279
+ await checkOpened(view, rootReal, rel, root, parent, target, handle, expect, noFollow, touched);
277
280
  touched = true;
278
281
  // The mode the bytes are written at, and the one the file ends with.
279
282
  let before: number | undefined;
@@ -369,14 +372,16 @@ export async function writeContained(
369
372
  * Before an effect on an opened file: a regular file, and — unless forced —
370
373
  * still holding what was decided on. The hash reads the whole file, so it
371
374
  * comes first, and the checks of where the file is follow it: canonically
372
- * inside the mount, the one this path names (and not a symlink, where the
373
- * mount follows none and the open couldn't refuse one), with no other names,
374
- * in the directory still at its path beneath the root still at its own; and
375
- * nothing wrote it while those ran.
375
+ * inside the mount and in no nested mount's directory (nestedAtReal), the
376
+ * one this path names (and not a symlink, where the mount follows none and
377
+ * the open couldn't refuse one), with no other names, in the directory still
378
+ * at its path beneath the root still at its own; and nothing wrote it while
379
+ * those ran.
376
380
  */
377
381
  async function checkOpened(
378
382
  view: MountView,
379
383
  rootReal: string,
384
+ rel: string,
380
385
  root: CheckedDir,
381
386
  parent: CheckedDir,
382
387
  target: string,
@@ -391,6 +396,8 @@ async function checkOpened(
391
396
  const real = await realpath(target).catch(() => null);
392
397
  if (real === null) throw new EffectFailed(CHANGED, touched);
393
398
  if (!contained(rootReal, real)) throw new EffectFailed(view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT, touched);
399
+ const holder = nestedAtReal(real, view.nested);
400
+ if (holder) throw new EffectFailed(leadsInto(view.name, rel, holder), touched);
394
401
  const named = await stat(real, { bigint: true }).catch(() => null);
395
402
  if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
396
403
  if (!view.followSymlinks) {
@@ -459,7 +466,7 @@ export async function chmodContained(
459
466
  let touched = false;
460
467
  let stage = 'set the file\'s mode';
461
468
  try {
462
- await checkOpened(view, rootReal, root, parent, target, handle, expect, noFollow, false);
469
+ await checkOpened(view, rootReal, rel, root, parent, target, handle, expect, noFollow, false);
463
470
  const current = Number((await handle.stat({ bigint: true })).mode & 0o777n);
464
471
  if (from !== undefined && current !== from) throw new EffectFailed(MODE_CHANGED, false);
465
472
  const narrowing = (current & ~mode & 0o777) !== 0;
@@ -525,6 +532,10 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
525
532
  if (!view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
526
533
  const real = await realpath(target).catch(() => null);
527
534
  if (real === null || !contained(rootReal, real)) throw new EffectFailed('a symlink that leaves the mount', false);
535
+ // One leading into a nested mount's directory is that mount's: the link
536
+ // and the file it leads to are left as they are.
537
+ const holder = nestedAtReal(real, view.nested);
538
+ if (holder) throw new EffectFailed(leadsInto(view.name, rel, holder), false);
528
539
  const through = await stat(real, { bigint: true }).catch(() => null);
529
540
  if (through === null) throw new EffectFailed(CHANGED, false);
530
541
  // A followed link stands for the file it leads to, so it's removed only