@animalabs/agent-framework 0.23.2 → 0.24.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.
- package/README.md +212 -42
- package/dist/src/agent.d.ts +60 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +92 -12
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +14 -12
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/engine.d.ts +177 -0
- package/dist/src/code-execution/engine.d.ts.map +1 -0
- package/dist/src/code-execution/engine.js +494 -0
- package/dist/src/code-execution/engine.js.map +1 -0
- package/dist/src/code-execution/index.d.ts +5 -10
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js +5 -9
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/isolate/calls.d.ts +114 -0
- package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/calls.js +145 -0
- package/dist/src/code-execution/isolate/calls.js.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.js +5 -0
- package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
- package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
- package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-main.js +132 -0
- package/dist/src/code-execution/isolate/child-main.js.map +1 -0
- package/dist/src/code-execution/isolate/child.d.ts +44 -0
- package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child.js +315 -0
- package/dist/src/code-execution/isolate/child.js.map +1 -0
- package/dist/src/code-execution/isolate/frames.d.ts +80 -0
- package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/frames.js +210 -0
- package/dist/src/code-execution/isolate/frames.js.map +1 -0
- package/dist/src/code-execution/isolate/output.d.ts +68 -0
- package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/output.js +143 -0
- package/dist/src/code-execution/isolate/output.js.map +1 -0
- package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
- package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/prelude.js +401 -0
- package/dist/src/code-execution/isolate/prelude.js.map +1 -0
- package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
- package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/protocol.js +150 -0
- package/dist/src/code-execution/isolate/protocol.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts +57 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/render.js +214 -0
- package/dist/src/code-execution/isolate/render.js.map +1 -0
- package/dist/src/code-execution/isolate/runner.d.ts +250 -0
- package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/runner.js +854 -0
- package/dist/src/code-execution/isolate/runner.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/scripts.js +357 -0
- package/dist/src/code-execution/isolate/scripts.js.map +1 -0
- package/dist/src/code-execution/quickjs.wasm +0 -0
- package/dist/src/code-execution/tool-definition.d.ts +93 -43
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +176 -142
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/deadline-timer.d.ts +3 -0
- package/dist/src/deadline-timer.d.ts.map +1 -1
- package/dist/src/deadline-timer.js +1 -1
- package/dist/src/deadline-timer.js.map +1 -1
- package/dist/src/framework.d.ts +1027 -440
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4093 -2238
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +28 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +88 -10
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -3
- package/dist/src/held-writes.d.ts.map +1 -1
- package/dist/src/held-writes.js +16 -3
- package/dist/src/held-writes.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +9 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +471 -75
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1673 -337
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +12 -7
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +12 -7
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +9 -5
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +50 -0
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +130 -0
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -2
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +6 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +8 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +15 -6
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +168 -53
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -5
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +4 -5
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +97 -10
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +173 -16
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +132 -14
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +227 -50
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +4 -1
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +4 -4
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +50 -21
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +158 -74
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +33 -0
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +55 -7
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +163 -34
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +22 -6
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +4 -2
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +100 -13
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +8 -13
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +27 -4
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +15 -3
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +27 -5
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/unknown-tool.d.ts +4 -1
- package/dist/src/modules/unknown-tool.d.ts.map +1 -1
- package/dist/src/modules/unknown-tool.js +4 -1
- package/dist/src/modules/unknown-tool.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +3 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
- package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-plan.js +794 -0
- package/dist/src/modules/workspace/edit-plan.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +117 -0
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-text.js +584 -0
- package/dist/src/modules/workspace/edit-text.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +5 -4
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +78 -25
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +484 -61
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2173 -645
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +213 -38
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +416 -36
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +15 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +32 -0
- package/dist/src/modules/workspace/text.d.ts.map +1 -0
- package/dist/src/modules/workspace/text.js +67 -0
- package/dist/src/modules/workspace/text.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +210 -6
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +3 -2
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/operator-log.d.ts +52 -24
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts +26 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +164 -39
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js +23 -4
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/send-tools.d.ts +11 -0
- package/dist/src/send-tools.d.ts.map +1 -0
- package/dist/src/send-tools.js +11 -0
- package/dist/src/send-tools.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +4 -3
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +5 -4
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +1 -1
- package/dist/src/tool-result-history.js +1 -1
- package/dist/src/tune-out/coordinator.d.ts +21 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +139 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +4 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +62 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +62 -58
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +40 -8
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +111 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/types.d.ts +16 -0
- package/dist/src/usage/types.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts +14 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.js +81 -41
- package/dist/src/usage/usage-tracker.js.map +1 -1
- package/package.json +6 -5
- package/src/agent.ts +93 -12
- package/src/api/server.ts +14 -12
- package/src/code-execution/engine.ts +595 -0
- package/src/code-execution/index.ts +5 -17
- package/src/code-execution/isolate/calls.ts +199 -0
- package/src/code-execution/isolate/child-entry.ts +5 -0
- package/src/code-execution/isolate/child-main.ts +132 -0
- package/src/code-execution/isolate/child.ts +327 -0
- package/src/code-execution/isolate/frames.ts +209 -0
- package/src/code-execution/isolate/output.ts +161 -0
- package/src/code-execution/isolate/prelude.ts +409 -0
- package/src/code-execution/isolate/protocol.ts +240 -0
- package/src/code-execution/isolate/render.ts +230 -0
- package/src/code-execution/isolate/runner.ts +911 -0
- package/src/code-execution/isolate/scripts.ts +479 -0
- package/src/code-execution/quickjs.wasm +0 -0
- package/src/code-execution/quickjs.wasm.LICENSES +149 -0
- package/src/code-execution/tool-definition.ts +236 -167
- package/src/deadline-timer.ts +1 -1
- package/src/framework.ts +4468 -2418
- package/src/gate/event-gate.ts +78 -11
- package/src/held-writes.ts +17 -3
- package/src/index.ts +30 -22
- package/src/mcpl/channel-registry.ts +1825 -368
- package/src/mcpl/conversation-router.ts +12 -7
- package/src/mcpl/convert-block.ts +9 -5
- package/src/mcpl/inbound-source.ts +131 -0
- package/src/mcpl/index.ts +4 -2
- package/src/mcpl/log-value.ts +9 -0
- package/src/mcpl/modern-connection.ts +162 -53
- package/src/mcpl/prose-grammar.ts +4 -5
- package/src/mcpl/protocol-family.ts +199 -21
- package/src/mcpl/push-coalescer.ts +306 -62
- package/src/mcpl/push-handler.ts +10 -2
- package/src/mcpl/reference-fetcher.ts +4 -4
- package/src/mcpl/server-connection.ts +180 -79
- package/src/mcpl/tags.ts +36 -0
- package/src/mcpl/tool-result-normalize.ts +2 -1
- package/src/mcpl/transport.ts +162 -33
- package/src/mcpl/types.ts +23 -6
- package/src/module-registry.ts +92 -13
- package/src/modules/discord/index.ts +8 -13
- package/src/modules/history/index.ts +25 -3
- package/src/modules/history/semantic.ts +29 -6
- package/src/modules/index.ts +1 -0
- package/src/modules/unknown-tool.ts +4 -1
- package/src/modules/workspace/branch-intent.ts +3 -1
- package/src/modules/workspace/edit-plan.ts +954 -0
- package/src/modules/workspace/edit-text.ts +618 -0
- package/src/modules/workspace/effects.ts +70 -19
- package/src/modules/workspace/index.ts +2247 -642
- package/src/modules/workspace/observe.ts +465 -56
- package/src/modules/workspace/reconcile.ts +14 -7
- package/src/modules/workspace/text.ts +68 -0
- package/src/modules/workspace/types.ts +211 -6
- package/src/modules/workspace/watcher.ts +3 -2
- package/src/operator-log.ts +47 -19
- package/src/recovery/discord-awareness-outbox.ts +1500 -356
- package/src/recovery/offline-branch.ts +189 -55
- package/src/recovery/recover-cli.ts +25 -4
- package/src/send-tools.ts +11 -0
- package/src/tool-result-guard.ts +6 -5
- package/src/tool-result-history.ts +1 -1
- package/src/tune-out/coordinator.ts +163 -42
- package/src/types/agent.ts +4 -0
- package/src/types/events.ts +62 -9
- package/src/types/framework.ts +62 -58
- package/src/types/module.ts +40 -8
- package/src/types/trace.ts +121 -15
- package/src/usage/types.ts +16 -0
- package/src/usage/usage-tracker.ts +86 -43
- package/dist/src/code-execution/py-runner.d.ts +0 -191
- package/dist/src/code-execution/py-runner.d.ts.map +0 -1
- package/dist/src/code-execution/py-runner.js +0 -655
- package/dist/src/code-execution/py-runner.js.map +0 -1
- package/dist/src/code-execution/runtime-py.d.ts +0 -50
- package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
- package/dist/src/code-execution/runtime-py.js +0 -677
- package/dist/src/code-execution/runtime-py.js.map +0 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
- package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
- package/dist/src/mcpl/prose-stream-router.js +0 -291
- package/dist/src/mcpl/prose-stream-router.js.map +0 -1
- package/src/code-execution/py-runner.ts +0 -791
- package/src/code-execution/runtime-py.ts +0 -677
- package/src/mcpl/prose-stream-router.ts +0 -268
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { isUtf8 } from 'node:buffer';
|
|
9
8
|
import { constants as fsConstants } from 'node:fs';
|
|
10
9
|
import type { Stats } from 'node:fs';
|
|
11
10
|
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
12
11
|
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
13
|
-
import type { JsStore } from '@animalabs/chronicle';
|
|
12
|
+
import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
|
|
14
13
|
import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
|
|
15
14
|
import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
|
|
16
15
|
import type {
|
|
@@ -23,6 +22,7 @@ import type {
|
|
|
23
22
|
ReadImageInput,
|
|
24
23
|
WriteInput,
|
|
25
24
|
EditInput,
|
|
25
|
+
EditManyInput,
|
|
26
26
|
DeleteInput,
|
|
27
27
|
LsInput,
|
|
28
28
|
GlobInput,
|
|
@@ -39,9 +39,12 @@ import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
|
39
39
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
40
40
|
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
41
41
|
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
42
|
-
import { BranchIntents } from './branch-intent.js';
|
|
43
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount,
|
|
42
|
+
import { type BranchIntent, BranchIntents } from './branch-intent.js';
|
|
43
|
+
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
|
|
44
44
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
45
|
+
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
46
|
+
import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
47
|
+
import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
|
|
45
48
|
import {
|
|
46
49
|
type MountRuntime,
|
|
47
50
|
type PassOptions,
|
|
@@ -73,6 +76,18 @@ const CATCH_UP_BOUND_MS = 30_000;
|
|
|
73
76
|
const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
|
|
74
77
|
/** The workspace's own deadline must end before the framework's hook bound. */
|
|
75
78
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
79
|
+
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
80
|
+
const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
81
|
+
/**
|
|
82
|
+
* How many files a direct mount's grep reads, and its listings look at, at
|
|
83
|
+
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
84
|
+
* 16 at a time on Node's default threadpool had a median time below the
|
|
85
|
+
* store-backed grep's over the same files at every load tried, and one
|
|
86
|
+
* reading them in turn took 2 to 2.4 times as long.
|
|
87
|
+
*/
|
|
88
|
+
const DIRECT_READS_AT_ONCE = 16;
|
|
89
|
+
/** Why sync passes over a direct mount, as its result's `skipped` says. */
|
|
90
|
+
const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
|
|
76
91
|
|
|
77
92
|
export type {
|
|
78
93
|
WorkspaceConfig,
|
|
@@ -83,6 +98,7 @@ export type {
|
|
|
83
98
|
ReadImageInput,
|
|
84
99
|
WriteInput,
|
|
85
100
|
EditInput,
|
|
101
|
+
EditManyInput,
|
|
86
102
|
DeleteInput,
|
|
87
103
|
LsInput,
|
|
88
104
|
GlobInput,
|
|
@@ -133,15 +149,28 @@ class WorkspaceImageReadError extends Error {
|
|
|
133
149
|
* link and, where it leads somewhere, its target, a symlink whose canonical
|
|
134
150
|
* target lands outside the canonical mount root, or one among the path's
|
|
135
151
|
* directories leading out of it (`escape`), a file swapped underneath the read
|
|
136
|
-
* (`changed`),
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
152
|
+
* (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
|
|
153
|
+
* and a path in another mount's directory nested in the one named
|
|
154
|
+
* (`other_mount`), whose message names the mount that holds it and the path
|
|
155
|
+
* to use there. A symlink leading into such a directory is `other_mount`
|
|
156
|
+
* only where its refusal names that mount: a directory, or a file that
|
|
157
|
+
* mount's include covers. Anywhere else, on a mount that doesn't follow
|
|
158
|
+
* symlinks, it gets the code of a link where it stands, `symlink` at the
|
|
159
|
+
* path itself and `parent_symlink` on its way, and on one that does,
|
|
160
|
+
* `escape`, as a path that leaves the mount. On a mount with an include, a
|
|
161
|
+
* link's message names its target only where a tool could name it: a
|
|
162
|
+
* directory, or a file the include covers. A link the include doesn't cover
|
|
163
|
+
* is outside the mount, and its message tells nothing of where it leads, so
|
|
164
|
+
* on a path's way its code is `parent_symlink` wherever it leads; named
|
|
165
|
+
* itself, it's refused as outside the include (`outside_include`), as any
|
|
166
|
+
* path the include doesn't cover is.
|
|
167
|
+
* A `mount_unavailable` for a root whose realpath or stat fails otherwise
|
|
168
|
+
* carries that errno, such as EACCES.
|
|
141
169
|
*/
|
|
142
170
|
export type WorkspaceReadErrorCode =
|
|
143
171
|
| 'unknown_mount'
|
|
144
172
|
| 'traversal'
|
|
173
|
+
| 'outside_include'
|
|
145
174
|
| 'other_mount'
|
|
146
175
|
| 'mount_unavailable'
|
|
147
176
|
| 'not_found'
|
|
@@ -166,6 +195,14 @@ export type WorkspaceReadStage =
|
|
|
166
195
|
| 'stat'
|
|
167
196
|
| 'read';
|
|
168
197
|
|
|
198
|
+
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
199
|
+
class OutsideIncludeError extends Error {
|
|
200
|
+
constructor(shownPath: string, mount: MountConfig) {
|
|
201
|
+
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
202
|
+
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
169
206
|
/**
|
|
170
207
|
* A file operation's path lies in another mount's directory nested in the
|
|
171
208
|
* mount it names. The message names the mount that holds it and the path to
|
|
@@ -288,27 +325,37 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean {
|
|
|
288
325
|
* link's reason, as the observations give it, after the path; or, for a
|
|
289
326
|
* directory the walk couldn't ask, a failure to read, coded `not_found` with
|
|
290
327
|
* the readlink's errno, as every failed lookup is. A link's code follows
|
|
291
|
-
* where it leads, so a caller choosing advice
|
|
328
|
+
* where its refusal says it leads (shownLeads), so a caller choosing advice
|
|
329
|
+
* by code gets advice that fits, and the code says no more than the message:
|
|
292
330
|
* `escape` for a link leading outside the mount, the code a path resolving
|
|
293
|
-
* outside it gets too; `other_mount` for one into a nested mount's directory
|
|
294
|
-
* the
|
|
295
|
-
*
|
|
331
|
+
* outside it gets too; `other_mount` for one into a nested mount's directory
|
|
332
|
+
* where the message names that mount (namesHolder), the code that mount's own
|
|
333
|
+
* paths get; and for any other, a link the mount's include doesn't cover
|
|
334
|
+
* among them, the code of a link the mount doesn't follow where it stands:
|
|
335
|
+
* `symlink` at the path itself, as any other link there gets, and
|
|
336
|
+
* `parent_symlink` among its directories, judged on the parsed path
|
|
337
|
+
* (`relativePath`), whatever spelling the caller gave. Where the link leads is
|
|
338
|
+
* read beneath `rootReal`, the root the check that found it resolved, so the
|
|
339
|
+
* link and its target are judged beneath one root, by that root's nesting
|
|
340
|
+
* (`nested`).
|
|
296
341
|
*/
|
|
297
342
|
async function linkReadError(
|
|
298
|
-
mount: { config: { name: string; path: string } },
|
|
343
|
+
mount: { config: { name: string; path: string; include?: readonly string[] } },
|
|
299
344
|
found: LinkFound,
|
|
300
345
|
mountPrefixedPath: string,
|
|
301
|
-
|
|
346
|
+
relativePath: string,
|
|
347
|
+
nested: readonly NestedMount[],
|
|
348
|
+
rootReal: string,
|
|
302
349
|
): Promise<WorkspaceReadError> {
|
|
303
350
|
if (!('link' in found)) {
|
|
304
351
|
return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
|
|
305
352
|
}
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
const code = leads
|
|
310
|
-
: leads
|
|
311
|
-
: 'parent_symlink';
|
|
353
|
+
const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
|
|
354
|
+
const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
|
|
355
|
+
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
356
|
+
const code = leads?.kind === 'outside' ? 'escape'
|
|
357
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
|
|
358
|
+
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
312
359
|
return new WorkspaceReadError(code, reason, 'readlink');
|
|
313
360
|
}
|
|
314
361
|
|
|
@@ -325,6 +372,48 @@ function nestedDir(from: string, to: string): string | undefined {
|
|
|
325
372
|
return rel.split(sep).join('/');
|
|
326
373
|
}
|
|
327
374
|
|
|
375
|
+
/** Each directory above `path`, innermost first, to the filesystem's root. */
|
|
376
|
+
function ancestorsOf(path: string): string[] {
|
|
377
|
+
const dirs: string[] = [];
|
|
378
|
+
for (let dir = dirname(path); ; dir = dirname(dir)) {
|
|
379
|
+
dirs.push(dir);
|
|
380
|
+
if (dirname(dir) === dir) return dirs;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Where a mount is nested in an outer mount, as a start places it: at its
|
|
386
|
+
* directory as configured beneath the outer mount's configured path
|
|
387
|
+
* (`outerPath`), beneath each directory above its configured path that is
|
|
388
|
+
* the outer root on disk (`above`, each with where it lies on disk), and
|
|
389
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`),
|
|
390
|
+
* each also as disk spells it beneath `spellAt`. Its real root is claimed
|
|
391
|
+
* where it doesn't hold the outer root, which it would otherwise claim
|
|
392
|
+
* whole. Undefined where it's nested nowhere.
|
|
393
|
+
*/
|
|
394
|
+
async function placementOf(
|
|
395
|
+
outerPath: string,
|
|
396
|
+
outerReal: string,
|
|
397
|
+
innerPath: string,
|
|
398
|
+
real: string,
|
|
399
|
+
above: ReadonlyArray<{ dir: string; real: string }>,
|
|
400
|
+
spellAt: string,
|
|
401
|
+
): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
|
|
402
|
+
const configured = nestedDir(outerPath, innerPath);
|
|
403
|
+
const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
|
|
404
|
+
const fromRoots: Array<{ dir: string; root: string }> = [];
|
|
405
|
+
for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
|
|
406
|
+
if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
407
|
+
}
|
|
408
|
+
if (fromRoots.length === 0) return undefined;
|
|
409
|
+
const spellings = [...fromRoots];
|
|
410
|
+
for (const found of fromRoots) {
|
|
411
|
+
const dir = await diskSpelling(spellAt, found.dir);
|
|
412
|
+
if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
413
|
+
}
|
|
414
|
+
return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
|
|
415
|
+
}
|
|
416
|
+
|
|
328
417
|
/** A mount nested in another, as the outer mount's otherMounts holds it. */
|
|
329
418
|
interface NestedMountEntry {
|
|
330
419
|
name: string;
|
|
@@ -340,6 +429,22 @@ interface NestedMountEntry {
|
|
|
340
429
|
spellings: Array<{ dir: string; root: string }>;
|
|
341
430
|
}
|
|
342
431
|
|
|
432
|
+
/**
|
|
433
|
+
* A path as parsePath reads it: its mount, and its mount-relative and
|
|
434
|
+
* mount-prefixed paths as the parse spells them; with the root the parse
|
|
435
|
+
* judged it beneath, a direct mount's as the parse resolved it and any
|
|
436
|
+
* other's as configured, and, where a direct mount's root lies elsewhere
|
|
437
|
+
* than the last start placed it, the mounts nested in it there
|
|
438
|
+
* (movedNesting), null otherwise.
|
|
439
|
+
*/
|
|
440
|
+
interface ParsedPath {
|
|
441
|
+
mount: MountState;
|
|
442
|
+
relativePath: string;
|
|
443
|
+
path: string;
|
|
444
|
+
root: string;
|
|
445
|
+
moved: NestedMount[] | null;
|
|
446
|
+
}
|
|
447
|
+
|
|
343
448
|
/** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
|
|
344
449
|
const LINKS_FOLLOWED = 40;
|
|
345
450
|
|
|
@@ -348,13 +453,14 @@ const LINKS_FOLLOWED = 40;
|
|
|
348
453
|
* it, with the links on its way and in it taken out, read without holding
|
|
349
454
|
* the process. Where nothing is there yet, a link leads where its target
|
|
350
455
|
* would be, and anything else lies beneath its nearest existing directory's
|
|
351
|
-
* real path, with the rest as given
|
|
352
|
-
* link's
|
|
353
|
-
*
|
|
456
|
+
* real path, with the rest as given, a `..` after a name that isn't there
|
|
457
|
+
* taken lexically. A link's target is read from the link's directory as it
|
|
458
|
+
* is on disk, so a `..` is taken from where the links before it lead, as the
|
|
354
459
|
* kernel takes it. Where realpath fails for any other reason, such as a
|
|
355
|
-
* loop or a lack of permission, the whole path as path.resolve reads it
|
|
460
|
+
* loop or a lack of permission, the whole path as path.resolve reads it, and
|
|
461
|
+
* `unresolved`, where given, takes the first such code.
|
|
356
462
|
*/
|
|
357
|
-
async function realBeneath(given: string, links = 0): Promise<string> {
|
|
463
|
+
async function realBeneath(given: string, links = 0, unresolved?: { code?: string }): Promise<string> {
|
|
358
464
|
// A trailing separator, as a link to a directory is often written, names
|
|
359
465
|
// the same place, but readlink would then follow the link it names.
|
|
360
466
|
const { root } = parse(given);
|
|
@@ -365,14 +471,33 @@ async function realBeneath(given: string, links = 0): Promise<string> {
|
|
|
365
471
|
} catch (err) {
|
|
366
472
|
const code = errnoOf(err);
|
|
367
473
|
const parent = dirname(absolute);
|
|
368
|
-
if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links
|
|
369
|
-
|
|
474
|
+
if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links > LINKS_FOLLOWED || parent === absolute) {
|
|
475
|
+
if (unresolved) unresolved.code ??= links > LINKS_FOLLOWED ? 'ELOOP' : code ?? 'EIO';
|
|
476
|
+
return resolve(absolute);
|
|
477
|
+
}
|
|
478
|
+
const place = await realBeneath(parent, links, unresolved);
|
|
370
479
|
const target = await readlink(absolute).catch(() => null);
|
|
371
|
-
if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
|
|
480
|
+
if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1, unresolved);
|
|
372
481
|
return join(place, basename(absolute));
|
|
373
482
|
}
|
|
374
483
|
}
|
|
375
484
|
|
|
485
|
+
/**
|
|
486
|
+
* A configured value as a refusal shows it: a number or a bigint as
|
|
487
|
+
* JavaScript writes it (JSON would say null for NaN, and throws for a
|
|
488
|
+
* bigint), anything else as JSON, or as String gives it where JSON can't,
|
|
489
|
+
* as for a list holding a bigint.
|
|
490
|
+
*/
|
|
491
|
+
function shownValue(value: unknown): string {
|
|
492
|
+
if (typeof value === 'number') return String(value);
|
|
493
|
+
if (typeof value === 'bigint') return `${value}n`;
|
|
494
|
+
try {
|
|
495
|
+
return JSON.stringify(value) ?? String(value);
|
|
496
|
+
} catch {
|
|
497
|
+
return String(value);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
376
501
|
function isContainedPath(root: string, candidate: string): boolean {
|
|
377
502
|
if (candidate === root) return true;
|
|
378
503
|
const normalizedRoot = root.endsWith(sep) ? root : root + sep;
|
|
@@ -826,6 +951,10 @@ export class WorkspaceModule implements Module {
|
|
|
826
951
|
private otherMounts = new Map<string, NestedMountEntry[]>();
|
|
827
952
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
828
953
|
private rootFoldsCase = new Map<string, boolean>();
|
|
954
|
+
/** Where the last start placed each mount's root (readNesting's `reals`). */
|
|
955
|
+
private placedRoots = new Map<string, string>();
|
|
956
|
+
/** Each directory above each mount's configured path, with where the last start found it on disk (readNesting's `above`). */
|
|
957
|
+
private placedAbove = new Map<string, Array<{ dir: string; real: string }>>();
|
|
829
958
|
/**
|
|
830
959
|
* Whether a file's mode is observed, taken in and applied (MountRuntime's
|
|
831
960
|
* `modesObserved`): not on Windows, which reports no POSIX modes. A test
|
|
@@ -837,17 +966,49 @@ export class WorkspaceModule implements Module {
|
|
|
837
966
|
* mounts, so restoration must be second-callback-safe like watcher
|
|
838
967
|
* startup already is (issue #72). Cleared after application. */
|
|
839
968
|
private savedState: WorkspaceModuleState | null = null;
|
|
969
|
+
/**
|
|
970
|
+
* The mounts configured direct that run as 'chronicle' this session, with
|
|
971
|
+
* the changes the store held there that disk lacked (chooseKinds), for
|
|
972
|
+
* ensureRunning to say once both halves of the lifecycle have run.
|
|
973
|
+
*/
|
|
974
|
+
private notDirect = new Map<string, Array<{ path: string; what: string }>>();
|
|
840
975
|
|
|
841
976
|
constructor(config: WorkspaceConfig) {
|
|
842
977
|
for (const mount of config.mounts) {
|
|
843
978
|
const cap: unknown = mount.walkCap;
|
|
844
979
|
if (cap !== undefined && !(Number.isSafeInteger(cap) && (cap as number) > 0)) {
|
|
845
|
-
// Numbers as JavaScript writes them (JSON would say null for NaN), anything else as JSON.
|
|
846
|
-
const shown = typeof cap === 'number' ? String(cap) : typeof cap === 'bigint' ? `${cap}n` : JSON.stringify(cap) ?? String(cap);
|
|
847
980
|
throw new Error(
|
|
848
|
-
`[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${
|
|
981
|
+
`[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${shownValue(cap)}`,
|
|
849
982
|
);
|
|
850
983
|
}
|
|
984
|
+
const backend: unknown = mount.backend;
|
|
985
|
+
if (backend !== undefined && backend !== 'chronicle' && backend !== 'direct') {
|
|
986
|
+
throw new Error(`[workspace] Mount "${mount.name}": backend must be 'chronicle' or 'direct', not ${shownValue(backend)}`);
|
|
987
|
+
}
|
|
988
|
+
const refused = backend === 'direct' ? directRefusals(mount) : [];
|
|
989
|
+
if (refused.length > 0) {
|
|
990
|
+
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
991
|
+
}
|
|
992
|
+
const include: unknown = mount.include;
|
|
993
|
+
if (include !== undefined) {
|
|
994
|
+
// A pattern the matcher can't read would match nothing, and take every
|
|
995
|
+
// file out of the mount unsaid (includePatternReadable).
|
|
996
|
+
if (!Array.isArray(include) || include.length === 0) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
// By index: an entry that is itself undefined, or a hole, is unreadable too.
|
|
1002
|
+
const at = include.findIndex((p) => !includePatternReadable(p));
|
|
1003
|
+
if (at >= 0) {
|
|
1004
|
+
const unreadable: unknown = include[at];
|
|
1005
|
+
throw new Error(
|
|
1006
|
+
`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
|
|
1007
|
+
+ `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
|
|
1008
|
+
+ `${shownValue(unreadable)} is none of these`,
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
851
1012
|
}
|
|
852
1013
|
// Detect overlapping mount paths: a mount nested inside another is that
|
|
853
1014
|
// mount's directory, which the outer mount passes over in every pass,
|
|
@@ -890,23 +1051,34 @@ export class WorkspaceModule implements Module {
|
|
|
890
1051
|
// before the call, knowing no mount, and a later initStore starts clean.
|
|
891
1052
|
const mounts = new Map<string, MountState>();
|
|
892
1053
|
const intents = new Map<string, BranchIntents>();
|
|
1054
|
+
// A direct mount's files never go into the store, so its states aren't
|
|
1055
|
+
// made. One that ran as 'chronicle' before has them, and they're read
|
|
1056
|
+
// below for what the store holds there that disk lacks.
|
|
1057
|
+
const made = this.config.mounts.some((m) => m.backend === 'direct') ? new Set(store.listStates().map((s) => s.id)) : null;
|
|
893
1058
|
|
|
894
1059
|
// Register tree states for each mount: the workspace tree, and the
|
|
895
1060
|
// branch-local intent that qualifies it.
|
|
896
1061
|
for (const mount of this.config.mounts) {
|
|
897
1062
|
const treeStateId = `workspace/${mount.name}/tree`;
|
|
898
1063
|
const intentTreeStateId = `workspace/${mount.name}/intent`;
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1064
|
+
const direct = mount.backend === 'direct';
|
|
1065
|
+
// A mount configured direct that ran as 'chronicle' runs direct only
|
|
1066
|
+
// once chooseKinds finds nothing held there.
|
|
1067
|
+
const ranStoreBacked = direct && made!.has(treeStateId) && made!.has(intentTreeStateId);
|
|
1068
|
+
if (!direct) {
|
|
1069
|
+
for (const id of [treeStateId, intentTreeStateId]) {
|
|
1070
|
+
ensureStateRegistered(store, {
|
|
1071
|
+
id,
|
|
1072
|
+
strategy: 'tree',
|
|
1073
|
+
deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
|
|
1074
|
+
fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
906
1077
|
}
|
|
907
1078
|
|
|
908
1079
|
mounts.set(mount.name, {
|
|
909
1080
|
config: mount,
|
|
1081
|
+
direct: direct && !ranStoreBacked,
|
|
910
1082
|
treeStateId,
|
|
911
1083
|
intentTreeStateId,
|
|
912
1084
|
lastMaterializedSeq: 0,
|
|
@@ -916,7 +1088,9 @@ export class WorkspaceModule implements Module {
|
|
|
916
1088
|
watcherReadyAt: null,
|
|
917
1089
|
watcherError: null,
|
|
918
1090
|
});
|
|
919
|
-
|
|
1091
|
+
if (!direct || ranStoreBacked) {
|
|
1092
|
+
intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
|
|
1093
|
+
}
|
|
920
1094
|
}
|
|
921
1095
|
|
|
922
1096
|
this.store = store;
|
|
@@ -924,14 +1098,53 @@ export class WorkspaceModule implements Module {
|
|
|
924
1098
|
for (const [name, intent] of intents) this.intents.set(name, intent);
|
|
925
1099
|
|
|
926
1100
|
// What disk last agreed with, per mount and path: global records, so a
|
|
927
|
-
// branch switch never rewinds it.
|
|
1101
|
+
// branch switch never rewinds it. A mount configured direct has its
|
|
1102
|
+
// evidence read only where a 'chronicle' run left its states, for what
|
|
1103
|
+
// the store holds there that disk lacks (chooseKinds); one that never ran
|
|
1104
|
+
// so has none to read, and none is begun for it.
|
|
928
1105
|
this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
|
|
929
|
-
this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
1106
|
+
this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
930
1107
|
|
|
1108
|
+
this.chooseKinds();
|
|
931
1109
|
this.applySavedState();
|
|
932
1110
|
this.ensureRunning();
|
|
933
1111
|
}
|
|
934
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Whether each mount configured direct runs direct: not while the store
|
|
1115
|
+
* holds changes there that disk lacks, on the branch selected then, so
|
|
1116
|
+
* they stay in view (MountConfig.backend); it runs as 'chronicle' instead,
|
|
1117
|
+
* and ensureRunning says so. Decided when initStore gives the store, and
|
|
1118
|
+
* again at each start, which knows how the mounts nest, so a start that
|
|
1119
|
+
* finds none held starts the mount direct. A path held more than one way
|
|
1120
|
+
* is named for the last way set here.
|
|
1121
|
+
*/
|
|
1122
|
+
private chooseKinds(): void {
|
|
1123
|
+
// Every mount is decided before any is set, so a read of the store that
|
|
1124
|
+
// throws leaves the decisions as they were.
|
|
1125
|
+
const decided = new Map<string, Array<{ path: string; what: string }>>();
|
|
1126
|
+
for (const [name, state] of this.mounts) {
|
|
1127
|
+
if (state.config.backend !== 'direct') continue;
|
|
1128
|
+
// One with no states from a 'chronicle' run holds nothing.
|
|
1129
|
+
if (!this.intents.has(name)) {
|
|
1130
|
+
decided.set(name, []);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const held = this.heldChanges(state);
|
|
1134
|
+
const what = new Map<string, string>();
|
|
1135
|
+
for (const path of held.owed) what.set(path, 'changed in the workspace');
|
|
1136
|
+
for (const path of held.unreadable) what.set(path, UNREADABLE_INTENT);
|
|
1137
|
+
for (const path of held.deletions) what.set(path, 'deleted in the workspace');
|
|
1138
|
+
for (const path of held.conflicts) what.set(path, 'in conflict');
|
|
1139
|
+
decided.set(name, [...what].sort(([a], [b]) => a.localeCompare(b)).map(([path, label]) => ({ path, what: label })));
|
|
1140
|
+
}
|
|
1141
|
+
this.notDirect = new Map();
|
|
1142
|
+
for (const [name, held] of decided) {
|
|
1143
|
+
this.mounts.get(name)!.direct = held.length === 0;
|
|
1144
|
+
if (held.length > 0) this.notDirect.set(name, held);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
935
1148
|
// ==========================================================================
|
|
936
1149
|
// Reconciliation plumbing
|
|
937
1150
|
// ==========================================================================
|
|
@@ -960,7 +1173,7 @@ export class WorkspaceModule implements Module {
|
|
|
960
1173
|
* A mount as observation reads it, with the root identity the workspace
|
|
961
1174
|
* recorded for it, if any: what a pass reads beneath (runtime), with
|
|
962
1175
|
* rootIdentity's, and what a disk read checks its root against (readRoot),
|
|
963
|
-
* with rootIdentityForRead's.
|
|
1176
|
+
* with rootIdentityForRead's. A direct mount's has none (diskView).
|
|
964
1177
|
*/
|
|
965
1178
|
private viewOf(mount: MountState, rootIdentity: MountView['rootIdentity']): MountView {
|
|
966
1179
|
return {
|
|
@@ -969,12 +1182,42 @@ export class WorkspaceModule implements Module {
|
|
|
969
1182
|
rootIdentity,
|
|
970
1183
|
followSymlinks: mount.config.followSymlinks === true,
|
|
971
1184
|
maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
972
|
-
ignore: mount
|
|
1185
|
+
ignore: this.ignoreOf(mount),
|
|
1186
|
+
...(mount.config.include ? { include: mount.config.include } : {}),
|
|
973
1187
|
others: this.othersOf(mount.config.name),
|
|
974
1188
|
nested: this.nestedOf(mount.config.name),
|
|
975
1189
|
};
|
|
976
1190
|
}
|
|
977
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* A mount's ignore list: its config's, or, for a mount configured direct
|
|
1194
|
+
* that names none, the direct kind's default (MountConfig.ignore), whichever
|
|
1195
|
+
* kind it runs as this session, as its watch mode goes by its config
|
|
1196
|
+
* (watchOf).
|
|
1197
|
+
*/
|
|
1198
|
+
private ignoreOf(mount: MountState): string[] {
|
|
1199
|
+
return mount.config.ignore ?? (mount.config.backend === 'direct' ? [...DIRECT_MOUNT_IGNORE] : []);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* A direct mount as its tools read it: its view, with no root identity,
|
|
1204
|
+
* since it promises what's at its root now and keeps no evidence to check
|
|
1205
|
+
* that against (MountConfig.backend), and its root as realpath resolves it,
|
|
1206
|
+
* or as given where that fails, as a pass's runtime takes it. The view
|
|
1207
|
+
* reads beneath that root, so a walk, a listing and grep's reads judge
|
|
1208
|
+
* and open their files beneath one root, however its configured path is
|
|
1209
|
+
* repointed meanwhile. A read of one file goes through openContainedFile
|
|
1210
|
+
* instead, which refuses it once its root is no longer the directory it
|
|
1211
|
+
* checked.
|
|
1212
|
+
*/
|
|
1213
|
+
private async diskView(mount: MountState): Promise<{ view: MountView; rootReal: string; moved: NestedMount[] | null }> {
|
|
1214
|
+
const root = resolve(mount.config.path);
|
|
1215
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
1216
|
+
const view = { ...this.viewOf(mount, undefined), root: rootReal };
|
|
1217
|
+
const moved = await this.movedNesting(mount, rootReal);
|
|
1218
|
+
return { view: moved === null ? view : { ...view, nested: moved, others: moved.map((n) => n.dir) }, rootReal, moved };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
978
1221
|
/** The view reconciliation needs of a mount, refreshed per pass. */
|
|
979
1222
|
private async runtime(mount: MountState): Promise<MountRuntime> {
|
|
980
1223
|
const root = resolve(mount.config.path);
|
|
@@ -994,7 +1237,7 @@ export class WorkspaceModule implements Module {
|
|
|
994
1237
|
intents: this.intents.get(mount.config.name)!,
|
|
995
1238
|
readOnly: mount.config.mode === 'read-only',
|
|
996
1239
|
walkCap: mount.config.walkCap ?? WALK_CAP,
|
|
997
|
-
catchUpScan: (mount.config
|
|
1240
|
+
catchUpScan: watchOf(mount.config) === 'always',
|
|
998
1241
|
modesObserved: this.modesObserved,
|
|
999
1242
|
};
|
|
1000
1243
|
}
|
|
@@ -1008,15 +1251,18 @@ export class WorkspaceModule implements Module {
|
|
|
1008
1251
|
* under that spelling beside their own. So the start stops before a segment
|
|
1009
1252
|
* spelled any other way, not there, or not a directory, and before a link,
|
|
1010
1253
|
* which a walk of the whole mount doesn't go through either. It lists only
|
|
1011
|
-
* directories it has checked lie inside the mount, and
|
|
1012
|
-
* passes over by design, which the walk then answers for.
|
|
1254
|
+
* directories it has checked lie inside the mount, and, on a 'chronicle'
|
|
1255
|
+
* mount, none it passes over by design, which the walk then answers for.
|
|
1256
|
+
* A direct mount walks a path its tools name whatever its ignore list or
|
|
1257
|
+
* an editor's file name covers there (MountConfig.ignore), so there it
|
|
1258
|
+
* lists such directories too, and only another mount's it doesn't.
|
|
1013
1259
|
*/
|
|
1014
1260
|
private async walkStart(mount: MountState, base: string, beneath: string): Promise<string> {
|
|
1015
1261
|
if (beneath === '') return base;
|
|
1016
|
-
const { view, rootReal } = await this.runtime(mount);
|
|
1262
|
+
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
1017
1263
|
let start = base;
|
|
1018
1264
|
for (const segment of beneath.split('/')) {
|
|
1019
|
-
if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined) break;
|
|
1265
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
|
|
1020
1266
|
const here = await checkDir(view, rootReal, start);
|
|
1021
1267
|
if (here.kind !== 'checked') break;
|
|
1022
1268
|
const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
|
|
@@ -1048,10 +1294,23 @@ export class WorkspaceModule implements Module {
|
|
|
1048
1294
|
/** One store→disk push; the caller holds the mount's turn. */
|
|
1049
1295
|
private async pushUnlocked(mount: MountState, paths: string[], opts: PushOptions = {}): Promise<PushResult> {
|
|
1050
1296
|
const watcher = this.watchers.get(mount.config.name);
|
|
1051
|
-
|
|
1297
|
+
// A path outside the mount's include never reaches disk, even when the
|
|
1298
|
+
// workspace's history holds it from before the include. It's skipped,
|
|
1299
|
+
// and a materialize counts it, with the reason.
|
|
1300
|
+
const inside: string[] = [];
|
|
1301
|
+
const outside: string[] = [];
|
|
1302
|
+
for (const p of paths) {
|
|
1303
|
+
// A key no spelling names, such as an earlier version's `a//b.txt`,
|
|
1304
|
+
// isn't the mount's either, and isn't the include's to report.
|
|
1305
|
+
if (!parsedSpelling(p)) continue;
|
|
1306
|
+
(insideMount(p, mount.config.include) ? inside : outside).push(p);
|
|
1307
|
+
}
|
|
1308
|
+
const pushed = await pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), inside, {
|
|
1052
1309
|
...opts,
|
|
1053
1310
|
beforeEffect: (p) => watcher?.suppress(p),
|
|
1054
1311
|
});
|
|
1312
|
+
for (const path of outside) pushed.skipped.push({ path, reason: OUTSIDE_INCLUDE });
|
|
1313
|
+
return pushed;
|
|
1055
1314
|
}
|
|
1056
1315
|
|
|
1057
1316
|
async start(ctx: ModuleContext): Promise<void> {
|
|
@@ -1062,12 +1321,14 @@ export class WorkspaceModule implements Module {
|
|
|
1062
1321
|
// before they're known. A stop begun meanwhile stands, and this start then
|
|
1063
1322
|
// sets nothing.
|
|
1064
1323
|
const stops = this.stopsBegun;
|
|
1065
|
-
const { nesting, reals } = await this.readNesting();
|
|
1324
|
+
const { nesting, reals, above } = await this.readNesting();
|
|
1066
1325
|
const rootFoldsCase = new Map<string, boolean>();
|
|
1067
1326
|
for (const mount of this.config.mounts) rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)!));
|
|
1068
1327
|
if (this.stopsBegun !== stops) return;
|
|
1069
1328
|
this.otherMounts = nesting;
|
|
1070
1329
|
this.rootFoldsCase = rootFoldsCase;
|
|
1330
|
+
this.placedRoots = reals;
|
|
1331
|
+
this.placedAbove = above;
|
|
1071
1332
|
|
|
1072
1333
|
this.ctx = ctx;
|
|
1073
1334
|
this.stopped = false;
|
|
@@ -1207,16 +1468,19 @@ export class WorkspaceModule implements Module {
|
|
|
1207
1468
|
/**
|
|
1208
1469
|
* Start chokidar watchers and emit workspace:mounted events.
|
|
1209
1470
|
* Idempotent: safe to call from both start() and initStore(), fires once
|
|
1210
|
-
* per mount regardless of which runs second.
|
|
1471
|
+
* per mount regardless of which runs second. Once both have run, it
|
|
1472
|
+
* decides again which mounts configured direct run direct, now that the
|
|
1473
|
+
* mounts' nesting is known, and says which don't, once for this start.
|
|
1211
1474
|
*/
|
|
1212
1475
|
private ensureRunning(): void {
|
|
1213
1476
|
if (!this.ctx || this.mounts.size === 0) return;
|
|
1477
|
+
this.chooseKinds();
|
|
1478
|
+
if (this.notDirect.size > 0) this.sayNotDirect();
|
|
1214
1479
|
|
|
1215
1480
|
for (const [name, mount] of this.mounts) {
|
|
1216
1481
|
if (this.watchers.has(name)) continue;
|
|
1217
1482
|
|
|
1218
|
-
|
|
1219
|
-
if (watchMode === 'always') {
|
|
1483
|
+
if (watchOf(mount.config) === 'always') {
|
|
1220
1484
|
// Chokidar is started with ignoreInitial:true, so changes made while
|
|
1221
1485
|
// the session was down would be invisible. A catch-up pass takes them
|
|
1222
1486
|
// in under the three-way rule: only real differences produce tree
|
|
@@ -1300,6 +1564,51 @@ export class WorkspaceModule implements Module {
|
|
|
1300
1564
|
}
|
|
1301
1565
|
}
|
|
1302
1566
|
|
|
1567
|
+
/**
|
|
1568
|
+
* Say, for whoever runs the host, which mounts configured direct run as
|
|
1569
|
+
* 'chronicle' this session (chooseKinds), what the workspace holds there
|
|
1570
|
+
* that keeps them so, and the two ways to start one direct: on stderr,
|
|
1571
|
+
* and as one ops alert for them all (the framework's failures log, its
|
|
1572
|
+
* `ops:alert` trace and, where one is set, its webhook, which posts one
|
|
1573
|
+
* alert of a kind per cooldown and keeps only the start and end of a long
|
|
1574
|
+
* one). So the words of fixed length, what's happening and the ways out,
|
|
1575
|
+
* come first, and the mounts and what they hold after them.
|
|
1576
|
+
*/
|
|
1577
|
+
private sayNotDirect(): void {
|
|
1578
|
+
const mounts = [...this.notDirect];
|
|
1579
|
+
const one = mounts.length === 1;
|
|
1580
|
+
// An intent the workspace can't read may hold a change or may not, so
|
|
1581
|
+
// it's counted as what it is.
|
|
1582
|
+
const counted = (paths: Array<{ what: string }>) => {
|
|
1583
|
+
const u = paths.filter((h) => h.what === UNREADABLE_INTENT).length;
|
|
1584
|
+
const c = paths.length - u;
|
|
1585
|
+
return [
|
|
1586
|
+
c > 0 ? `${c} change${c === 1 ? '' : 's'} that disk lacks` : '',
|
|
1587
|
+
u > 0 ? `${u} intent${u === 1 ? '' : 's'} it can't read` : '',
|
|
1588
|
+
].filter((part) => part !== '').join(' and ');
|
|
1589
|
+
};
|
|
1590
|
+
const each = mounts.map(([name, paths]) => `"${name}" (${counted(paths)})`);
|
|
1591
|
+
const which = one
|
|
1592
|
+
? `Mount "${mounts[0]![0]}" holds ${counted(mounts[0]![1])}`
|
|
1593
|
+
: `Mounts ${each.slice(0, -1).join(', ')} and ${each[each.length - 1]} hold`;
|
|
1594
|
+
const held = mounts.flatMap(([name, paths]) => paths.map((h) => `"${name}/${h.path}" (${h.what})`));
|
|
1595
|
+
// Materialize settles an intent the workspace can't read only where the
|
|
1596
|
+
// workspace holds a copy there, and says nothing of one it doesn't, so
|
|
1597
|
+
// the ways out say what does wherever one is held, past the ten paths
|
|
1598
|
+
// named too.
|
|
1599
|
+
const intents = mounts.some(([, paths]) => paths.some((h) => h.what === UNREADABLE_INTENT))
|
|
1600
|
+
? "Sync the path of an intent it can't read. "
|
|
1601
|
+
: '';
|
|
1602
|
+
const message = (one
|
|
1603
|
+
? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
|
|
1604
|
+
: "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
|
|
1605
|
+
"sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
|
|
1606
|
+
`follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
|
|
1607
|
+
`${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
|
|
1608
|
+
console.warn(`[workspace] ${message}`);
|
|
1609
|
+
this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1303
1612
|
private async initialScan(mountName: string): Promise<void> {
|
|
1304
1613
|
const store = this.store;
|
|
1305
1614
|
const mount = this.mounts.get(mountName);
|
|
@@ -1367,12 +1676,15 @@ export class WorkspaceModule implements Module {
|
|
|
1367
1676
|
this.ctx = null;
|
|
1368
1677
|
}
|
|
1369
1678
|
|
|
1370
|
-
/** Mount names + modes,
|
|
1371
|
-
*
|
|
1372
|
-
|
|
1679
|
+
/** Mount names + modes, and each mount's include where it sets one, for
|
|
1680
|
+
* peer callers (framework spill/journal paths) that need a mount for files
|
|
1681
|
+
* of their own, the first writable one without an include
|
|
1682
|
+
* (firstWritableMountName), without reaching into private state. */
|
|
1683
|
+
getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[] }> {
|
|
1373
1684
|
return [...this.mounts.values()].map((m) => ({
|
|
1374
1685
|
name: m.config.name,
|
|
1375
1686
|
mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
|
|
1687
|
+
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
1376
1688
|
}));
|
|
1377
1689
|
}
|
|
1378
1690
|
|
|
@@ -1381,6 +1693,19 @@ export class WorkspaceModule implements Module {
|
|
|
1381
1693
|
// ==========================================================================
|
|
1382
1694
|
|
|
1383
1695
|
getTools(): ToolDefinition[] {
|
|
1696
|
+
// Where a mount runs direct, four descriptions say what that changes.
|
|
1697
|
+
// A host with none keeps them as they were, and its residents' caches.
|
|
1698
|
+
const direct = [...this.mounts.values()].some((m) => m.direct);
|
|
1699
|
+
const lsDirect = direct
|
|
1700
|
+
? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
|
|
1701
|
+
+ 'its size and no state, or a note where the mount doesn\'t take it. A path its ignore list covers is listed when you name it.'
|
|
1702
|
+
: '';
|
|
1703
|
+
const grepDirect = direct
|
|
1704
|
+
? ' On a direct mount, grep searches disk\'s files as they are now (version disk), and lists as skipped, with why, '
|
|
1705
|
+
+ 'those it doesn\'t: binary, over the size limit, or not read, such as a link the mount doesn\'t follow.'
|
|
1706
|
+
: '';
|
|
1707
|
+
const globDirect = direct ? ' On a direct mount, a match carries its size, or a note where the mount doesn\'t take it, and no state.' : '';
|
|
1708
|
+
const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
|
|
1384
1709
|
return [
|
|
1385
1710
|
{
|
|
1386
1711
|
name: 'read',
|
|
@@ -1426,18 +1751,62 @@ export class WorkspaceModule implements Module {
|
|
|
1426
1751
|
},
|
|
1427
1752
|
{
|
|
1428
1753
|
name: 'edit',
|
|
1429
|
-
description: 'Edit a file by replacing
|
|
1754
|
+
description: 'Edit a file by replacing exact text. oldString must occur exactly once, or with replaceAll at least once, '
|
|
1755
|
+
+ "and every occurrence is replaced; occurrences that overlap count, but can't each be replaced. "
|
|
1756
|
+
+ 'A miss shows the closest region it finds, numbered, and a count that differs shows the lines that matched. '
|
|
1757
|
+
+ 'CRLF line ends are matched with LF text and kept, as is a BOM. As second chances, straight and curly quotes match as equal, '
|
|
1758
|
+
+ "and an oldString carrying the file's BOM at its start or a line's CR at its end, as a character page shows them, matches without them, "
|
|
1759
|
+
+ 'its newString read the same way. '
|
|
1760
|
+
+ "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
|
|
1761
|
+
+ 'A change disk made to the file is taken in first, and the answer says so. '
|
|
1762
|
+
+ 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
|
|
1763
|
+
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
|
|
1430
1764
|
inputSchema: {
|
|
1431
1765
|
type: 'object' as const,
|
|
1432
1766
|
properties: {
|
|
1433
1767
|
path: { type: 'string', description: 'File path (mount-prefixed)' },
|
|
1434
|
-
oldString: { type: 'string', description: '
|
|
1435
|
-
newString: { type: 'string', description: 'Replacement
|
|
1436
|
-
replaceAll: { type: 'boolean', description: 'Replace
|
|
1768
|
+
oldString: { type: 'string', description: 'Exact text to replace, including whitespace and indentation' },
|
|
1769
|
+
newString: { type: 'string', description: 'Replacement text' },
|
|
1770
|
+
replaceAll: { type: 'boolean', description: 'Replace every occurrence (default: false)' },
|
|
1437
1771
|
},
|
|
1438
1772
|
required: ['path', 'oldString', 'newString'],
|
|
1439
1773
|
},
|
|
1440
1774
|
},
|
|
1775
|
+
{
|
|
1776
|
+
name: 'edit_many',
|
|
1777
|
+
description: 'Apply several exact edits at once, in one file or across files of one mount, all or none: no edit is stored unless every edit applies. '
|
|
1778
|
+
+ "Each edit's oldString must occur in its file exactly `occurrences` times (1 when left out), and every one is replaced; "
|
|
1779
|
+
+ "occurrences that overlap count, but can't each be replaced. "
|
|
1780
|
+
+ "Each edit is matched in its file as this call reads it, never in another edit's result, so the list's order doesn't matter, "
|
|
1781
|
+
+ "and two edits' matches may touch but not overlap. Matching is edit's. "
|
|
1782
|
+
+ "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
|
|
1783
|
+
+ 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
|
|
1784
|
+
+ "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
|
|
1785
|
+
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
|
|
1786
|
+
inputSchema: {
|
|
1787
|
+
type: 'object' as const,
|
|
1788
|
+
properties: {
|
|
1789
|
+
edits: {
|
|
1790
|
+
type: 'array',
|
|
1791
|
+
description: "The edits, each matched in its file as this call reads it, never in another edit's result",
|
|
1792
|
+
items: {
|
|
1793
|
+
type: 'object',
|
|
1794
|
+
properties: {
|
|
1795
|
+
path: { type: 'string', description: 'File path (mount-prefixed); every edit names a file of the same mount' },
|
|
1796
|
+
oldString: { type: 'string', description: 'Exact text to replace, as the file holds it before this call' },
|
|
1797
|
+
newString: { type: 'string', description: 'Replacement text' },
|
|
1798
|
+
occurrences: {
|
|
1799
|
+
type: 'integer',
|
|
1800
|
+
description: 'How many times oldString occurs in the file (default: 1); every one is replaced, and any other count is refused',
|
|
1801
|
+
},
|
|
1802
|
+
},
|
|
1803
|
+
required: ['path', 'oldString', 'newString'],
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
},
|
|
1807
|
+
required: ['edits'],
|
|
1808
|
+
},
|
|
1809
|
+
},
|
|
1441
1810
|
{
|
|
1442
1811
|
name: 'delete',
|
|
1443
1812
|
description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
|
|
@@ -1461,18 +1830,18 @@ export class WorkspaceModule implements Module {
|
|
|
1461
1830
|
+ 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace, '
|
|
1462
1831
|
+ 'with applyDeletions too where the workspace deleted it; '
|
|
1463
1832
|
+ 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
|
|
1464
|
-
+ 'the listing could not see.',
|
|
1833
|
+
+ 'the listing could not see.' + lsDirect,
|
|
1465
1834
|
inputSchema: {
|
|
1466
1835
|
type: 'object' as const,
|
|
1467
1836
|
properties: {
|
|
1468
|
-
path: { type: 'string', description: 'Directory path (mount-prefixed, optional)' },
|
|
1837
|
+
path: { type: 'string', description: 'Directory path (mount-prefixed, optional; without it, the mounts are listed, each with its include where it has one)' },
|
|
1469
1838
|
recursive: { type: 'boolean', description: 'List recursively (default: false)' },
|
|
1470
1839
|
},
|
|
1471
1840
|
},
|
|
1472
1841
|
},
|
|
1473
1842
|
{
|
|
1474
1843
|
name: 'glob',
|
|
1475
|
-
description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.',
|
|
1844
|
+
description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.' + globDirect,
|
|
1476
1845
|
inputSchema: {
|
|
1477
1846
|
type: 'object' as const,
|
|
1478
1847
|
properties: {
|
|
@@ -1488,7 +1857,7 @@ export class WorkspaceModule implements Module {
|
|
|
1488
1857
|
+ 'which version matched: the workspace\'s (with its state); the disk version kept with a conflict '
|
|
1489
1858
|
+ '(conflicting-disk); or, once disk has changed since the conflict was recorded, that recorded version '
|
|
1490
1859
|
+ '(recorded-disk). Disk versions it could not search are listed as skipped: disk-only files (binary or '
|
|
1491
|
-
+ 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.',
|
|
1860
|
+
+ 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.' + grepDirect,
|
|
1492
1861
|
inputSchema: {
|
|
1493
1862
|
type: 'object' as const,
|
|
1494
1863
|
properties: {
|
|
@@ -1514,7 +1883,9 @@ export class WorkspaceModule implements Module {
|
|
|
1514
1883
|
{
|
|
1515
1884
|
name: 'materialize',
|
|
1516
1885
|
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
|
|
1517
|
-
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.'
|
|
1886
|
+
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
|
|
1887
|
+
+ 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
|
|
1888
|
+
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
|
|
1518
1889
|
inputSchema: {
|
|
1519
1890
|
type: 'object' as const,
|
|
1520
1891
|
properties: {
|
|
@@ -1546,15 +1917,17 @@ export class WorkspaceModule implements Module {
|
|
|
1546
1917
|
+ 'reported nowhere else is counted under `passedOver`, by mount and reason: another mount\'s '
|
|
1547
1918
|
+ 'directory nested in this one, entries the mount\'s ignore list covers, and entries with an '
|
|
1548
1919
|
+ 'editor\'s swap or backup file name (`.name.swp` and `.name.swx` as the whole name, names ending '
|
|
1549
|
-
+ 'in `~` or in `.subl…tmp`),
|
|
1550
|
-
+ 'with what lies beneath it)
|
|
1920
|
+
+ 'in `~` or in `.subl…tmp`), each holding nothing the workspace tracks (such a directory counts '
|
|
1921
|
+
+ 'once, with what lies beneath it); and files and links outside the mount\'s include, a link '
|
|
1922
|
+
+ 'counted as "a symlink, not walked" instead where it leads to a directory on a mount that follows '
|
|
1923
|
+
+ 'symlinks, and wherever it leads on one that doesn\'t. So a '
|
|
1551
1924
|
+ 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
|
|
1552
1925
|
+ 'sync looked everywhere and nothing had changed. A path the mount ignores, or an editor\'s file, is '
|
|
1553
1926
|
+ 'synced when you name it, and listed under `ignored`: a sync without a path passes over it. A path '
|
|
1554
1927
|
+ 'in another mount\'s directory isn\'t synced even when you name it: use the mount that holds it. Naming '
|
|
1555
1928
|
+ 'an ignored directory sets aside the patterns that cover it or a directory above it; one that '
|
|
1556
1929
|
+ 'covers only something inside it still applies, and what it covers is passed over, as are editors\' '
|
|
1557
|
-
+ 'files inside it.',
|
|
1930
|
+
+ 'files inside it.' + syncDirect,
|
|
1558
1931
|
inputSchema: {
|
|
1559
1932
|
type: 'object' as const,
|
|
1560
1933
|
properties: {
|
|
@@ -1587,7 +1960,7 @@ export class WorkspaceModule implements Module {
|
|
|
1587
1960
|
const mountName = slash < 0 ? path : path.slice(0, slash);
|
|
1588
1961
|
const mount = this.config.mounts.find(m => m.name === mountName);
|
|
1589
1962
|
if (!mount) throw new Error(`Unknown mount: "${mountName}"`);
|
|
1590
|
-
// Against the resolved root, as
|
|
1963
|
+
// Against the resolved root, as parsePathAsGiven's traversal guard judges it.
|
|
1591
1964
|
const root = resolve(mount.path);
|
|
1592
1965
|
const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1));
|
|
1593
1966
|
if (!isContainedPath(root, resolved)) throw new Error(`Path traversal detected: "${path}"`);
|
|
@@ -1645,16 +2018,18 @@ export class WorkspaceModule implements Module {
|
|
|
1645
2018
|
const input = call.input as Record<string, unknown>;
|
|
1646
2019
|
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1647
2020
|
if (generated) {
|
|
1648
|
-
if (call.callerAgentName
|
|
1649
|
-
|
|
1650
|
-
|
|
2021
|
+
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
2022
|
+
return await this.handleRead(input as unknown as ReadInput, generated.read());
|
|
2023
|
+
}
|
|
2024
|
+
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
1651
2025
|
}
|
|
1652
2026
|
|
|
1653
2027
|
switch (call.name) {
|
|
1654
2028
|
case 'read': return await this.handleRead(input as unknown as ReadInput);
|
|
1655
2029
|
case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
|
|
1656
2030
|
case 'write': return await this.handleWrite(input as unknown as WriteInput);
|
|
1657
|
-
case 'edit': return await this.handleEdit(input as unknown as EditInput);
|
|
2031
|
+
case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars);
|
|
2032
|
+
case 'edit_many': return await this.handleEditMany(input as unknown as EditManyInput, call.resultMaxChars, call.callerAgentName);
|
|
1658
2033
|
case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
|
|
1659
2034
|
case 'ls': return await this.handleLs(input as unknown as LsInput);
|
|
1660
2035
|
case 'glob': return await this.handleGlob(input as unknown as GlobInput);
|
|
@@ -1663,7 +2038,7 @@ export class WorkspaceModule implements Module {
|
|
|
1663
2038
|
case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
|
|
1664
2039
|
case 'sync': return await this.handleSync(input as unknown as SyncInput);
|
|
1665
2040
|
default:
|
|
1666
|
-
return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
|
|
2041
|
+
return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
|
|
1667
2042
|
}
|
|
1668
2043
|
} catch (err) {
|
|
1669
2044
|
return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
|
|
@@ -1742,8 +2117,11 @@ export class WorkspaceModule implements Module {
|
|
|
1742
2117
|
|
|
1743
2118
|
/**
|
|
1744
2119
|
* Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
|
|
1745
|
-
* absolute filesystem path. Returns null if the mount is unknown or
|
|
1746
|
-
*
|
|
2120
|
+
* absolute filesystem path. Returns null if the mount is unknown or
|
|
2121
|
+
* doesn't hold the path: one that leaves the mount's directory, lies in
|
|
2122
|
+
* another mount's directory nested in it, or falls outside its include, or
|
|
2123
|
+
* one that can't name a file of it, with a separator right after the
|
|
2124
|
+
* mount's name or a lone UTF-16 surrogate.
|
|
1747
2125
|
*
|
|
1748
2126
|
* **Lexical containment only.** The guard reasons about `..` segments in the
|
|
1749
2127
|
* path string; it knows nothing about what is on disk. A symlink inside the
|
|
@@ -1752,15 +2130,26 @@ export class WorkspaceModule implements Module {
|
|
|
1752
2130
|
* path for resolution-only purposes (write targets, nonexistent paths,
|
|
1753
2131
|
* display); for reading file content, use {@link readFileFromDisk}, which
|
|
1754
2132
|
* enforces the mount's `followSymlinks` policy and canonical containment.
|
|
2133
|
+
* It judges other mounts' directories by where the last start placed them,
|
|
2134
|
+
* so for a direct mount whose root has moved since, it can differ from
|
|
2135
|
+
* `readFileFromDisk`, which takes the nesting where the root lies now
|
|
2136
|
+
* (movedNesting), about which paths lie in another mount's directory.
|
|
1755
2137
|
*
|
|
1756
2138
|
* @deprecated for direct filesystem reads — call `readFileFromDisk()`.
|
|
1757
2139
|
*/
|
|
1758
2140
|
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
1759
2141
|
try {
|
|
1760
|
-
// Synchronous, so the path as given
|
|
1761
|
-
//
|
|
1762
|
-
//
|
|
1763
|
-
|
|
2142
|
+
// Synchronous, so the path as given. The nested-mount check takes it
|
|
2143
|
+
// in each spelling readNesting knows for the nested mount's directory,
|
|
2144
|
+
// or in any spelling a disk that folds names may fold to one of them,
|
|
2145
|
+
// erring toward that mount (holderOf's asGiven, beginsWithDir). The
|
|
2146
|
+
// include judges it as given, so on a disk that
|
|
2147
|
+
// folds names a covered name whose file disk spells otherwise returns
|
|
2148
|
+
// that file's path, which the tools refuse, and a covered file named in
|
|
2149
|
+
// another case is null, which the tools read. A path the mount doesn't
|
|
2150
|
+
// hold, outside its include or in another mount's directory nested in
|
|
2151
|
+
// it, is null, as for a path that leaves the mount's directory.
|
|
2152
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
|
|
1764
2153
|
return resolve(mount.config.path, relativePath);
|
|
1765
2154
|
} catch {
|
|
1766
2155
|
return null;
|
|
@@ -1783,10 +2172,13 @@ export class WorkspaceModule implements Module {
|
|
|
1783
2172
|
* (agent-framework #277).
|
|
1784
2173
|
*
|
|
1785
2174
|
* A write whose tree entry is set but that then fails returns success
|
|
1786
|
-
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
1787
|
-
*
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
2175
|
+
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2176
|
+
* the store failed after recording the entry, which skips the push where
|
|
2177
|
+
* the mount pushes, or something after the entry (recording its intent,
|
|
2178
|
+
* the event, the push) threw. The workspace's tools read the file from the
|
|
2179
|
+
* tree, so it is there for them, on the branch that was current when the
|
|
2180
|
+
* entry was set. A set that threw and couldn't be read back throws, saying
|
|
2181
|
+
* that whether the entry is set isn't known.
|
|
1790
2182
|
*/
|
|
1791
2183
|
async writeBinary(
|
|
1792
2184
|
mountPrefixedPath: string,
|
|
@@ -1844,7 +2236,12 @@ export class WorkspaceModule implements Module {
|
|
|
1844
2236
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
1845
2237
|
} catch (error) {
|
|
1846
2238
|
if (!committed) throw error;
|
|
1847
|
-
|
|
2239
|
+
// As write words it: a store that failed after recording the file
|
|
2240
|
+
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2241
|
+
failure = error instanceof StoredThenFailed
|
|
2242
|
+
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
2243
|
+
+ (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
|
|
2244
|
+
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
1848
2245
|
}
|
|
1849
2246
|
if (failure !== null) {
|
|
1850
2247
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -1861,8 +2258,10 @@ export class WorkspaceModule implements Module {
|
|
|
1861
2258
|
|
|
1862
2259
|
/**
|
|
1863
2260
|
* Read a file's raw bytes from a mount (through the Chronicle tree, synced
|
|
1864
|
-
* first like the `read` tool
|
|
1865
|
-
*
|
|
2261
|
+
* first like the `read` tool; on a direct mount, from disk as
|
|
2262
|
+
* readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
|
|
2263
|
+
* API for the framework's synthesized `read_image` tool and other peer
|
|
2264
|
+
* callers that need binary content.
|
|
1866
2265
|
*/
|
|
1867
2266
|
async readBinary(
|
|
1868
2267
|
mountPrefixedPath: string,
|
|
@@ -1881,6 +2280,8 @@ export class WorkspaceModule implements Module {
|
|
|
1881
2280
|
} catch (error) {
|
|
1882
2281
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
1883
2282
|
}
|
|
2283
|
+
// A direct mount's bytes are disk's, as read takes them, at most its size limit.
|
|
2284
|
+
if (mount.direct) return this.bytesFromDisk(mount, relativePath, mountPrefixedPath, mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
1884
2285
|
const store = this.getStore();
|
|
1885
2286
|
return this.withMount(mount, async () => {
|
|
1886
2287
|
await this.ensureSynced(mount, relativePath);
|
|
@@ -1926,8 +2327,17 @@ export class WorkspaceModule implements Module {
|
|
|
1926
2327
|
} catch (error) {
|
|
1927
2328
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
1928
2329
|
}
|
|
1929
|
-
const
|
|
1930
|
-
|
|
2330
|
+
const read = await this.bytesFromDisk(mount, relativePath, mountPrefixedPath, opts.maxBytes);
|
|
2331
|
+
return 'error' in read ? read : { data: read.data, absolutePath: resolve(mount.config.path, relativePath) };
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/** A file's bytes as readBinaryFromDisk reads them, once its path is parsed. */
|
|
2335
|
+
private async bytesFromDisk(
|
|
2336
|
+
mount: MountState,
|
|
2337
|
+
relativePath: string,
|
|
2338
|
+
mountPrefixedPath: string,
|
|
2339
|
+
maxBytes: number | undefined,
|
|
2340
|
+
): Promise<{ data: Buffer } | { error: string }> {
|
|
1931
2341
|
let tooLarge: string | undefined;
|
|
1932
2342
|
try {
|
|
1933
2343
|
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
@@ -1937,14 +2347,14 @@ export class WorkspaceModule implements Module {
|
|
|
1937
2347
|
}
|
|
1938
2348
|
});
|
|
1939
2349
|
try {
|
|
1940
|
-
if (maxBytes === undefined) return { data: await handle.readFile()
|
|
2350
|
+
if (maxBytes === undefined) return { data: await handle.readFile() };
|
|
1941
2351
|
// No more than maxBytes is loaded: a file grown past it since the
|
|
1942
2352
|
// fstat above is refused here, as one already over it was there. Its
|
|
1943
2353
|
// size is an fstat's after the read, and at least one more than the
|
|
1944
2354
|
// bytes kept, so the refusal's size is always past maxBytes.
|
|
1945
2355
|
const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
|
|
1946
2356
|
if (more) return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
|
|
1947
|
-
return { data: bytes
|
|
2357
|
+
return { data: bytes };
|
|
1948
2358
|
} finally {
|
|
1949
2359
|
await handle.close();
|
|
1950
2360
|
}
|
|
@@ -1987,9 +2397,11 @@ export class WorkspaceModule implements Module {
|
|
|
1987
2397
|
* as configured still names it as configured (resolveAbsolutePath). A
|
|
1988
2398
|
* directory not on disk keeps the spellings the roots give alone. Each
|
|
1989
2399
|
* mount's real root comes back beside the nesting (`reals`), for what
|
|
1990
|
-
* start reads there
|
|
2400
|
+
* start reads there, and each directory above each configured path with
|
|
2401
|
+
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2402
|
+
* (movedNesting).
|
|
1991
2403
|
*/
|
|
1992
|
-
private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string
|
|
2404
|
+
private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string>; above: Map<string, Array<{ dir: string; real: string }>> }> {
|
|
1993
2405
|
const reals = new Map<string, string>();
|
|
1994
2406
|
// Each directory above a mount's configured path, with where it lies on
|
|
1995
2407
|
// disk, so a path that reaches another mount's root through a link, as
|
|
@@ -1999,10 +2411,7 @@ export class WorkspaceModule implements Module {
|
|
|
1999
2411
|
const given = resolve(mount.path);
|
|
2000
2412
|
reals.set(mount.name, await realBeneath(given));
|
|
2001
2413
|
const dirs: Array<{ dir: string; real: string }> = [];
|
|
2002
|
-
for (
|
|
2003
|
-
dirs.push({ dir, real: await realBeneath(dir) });
|
|
2004
|
-
if (dirname(dir) === dir) break;
|
|
2005
|
-
}
|
|
2414
|
+
for (const dir of ancestorsOf(given)) dirs.push({ dir, real: await realBeneath(dir) });
|
|
2006
2415
|
above.set(mount.name, dirs);
|
|
2007
2416
|
}
|
|
2008
2417
|
const nesting = new Map<string, NestedMountEntry[]>();
|
|
@@ -2012,23 +2421,9 @@ export class WorkspaceModule implements Module {
|
|
|
2012
2421
|
for (const inner of this.config.mounts) {
|
|
2013
2422
|
if (outer === inner) continue;
|
|
2014
2423
|
const real = reals.get(inner.name)!;
|
|
2015
|
-
const
|
|
2016
|
-
|
|
2017
|
-
const
|
|
2018
|
-
// Its directory as configured, read against this mount's root as
|
|
2019
|
-
// configured and from each directory above it that is this mount's
|
|
2020
|
-
// root on disk, and its real root against this mount's.
|
|
2021
|
-
const throughRoot = above.get(inner.name)!.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
|
|
2022
|
-
for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
|
|
2023
|
-
if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
2024
|
-
}
|
|
2025
|
-
if (fromRoots.length === 0) continue;
|
|
2026
|
-
const spellings = [...fromRoots];
|
|
2027
|
-
for (const found of fromRoots) {
|
|
2028
|
-
const dir = await diskSpelling(outerPath, found.dir);
|
|
2029
|
-
if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
2030
|
-
}
|
|
2031
|
-
const claims = !isContainedPath(real, outerReal);
|
|
2424
|
+
const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!, outerPath);
|
|
2425
|
+
if (at === undefined) continue;
|
|
2426
|
+
const { configured, fromRoots, spellings, claims } = at;
|
|
2032
2427
|
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
|
|
2033
2428
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2034
2429
|
if (placed.length > 0) {
|
|
@@ -2039,12 +2434,118 @@ export class WorkspaceModule implements Module {
|
|
|
2039
2434
|
}
|
|
2040
2435
|
}
|
|
2041
2436
|
}
|
|
2042
|
-
return { nesting, reals };
|
|
2437
|
+
return { nesting, reals, above };
|
|
2043
2438
|
}
|
|
2044
2439
|
|
|
2045
|
-
/**
|
|
2440
|
+
/**
|
|
2441
|
+
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2442
|
+
* has one, and include (MountView's `nested`), in each spelling known.
|
|
2443
|
+
*/
|
|
2046
2444
|
private nestedOf(mountName: string): NestedMount[] {
|
|
2047
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
|
|
2445
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
|
|
2446
|
+
o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
/**
|
|
2450
|
+
* The mounts nested in `mount` as a call of it that resolved its root to
|
|
2451
|
+
* `rootReal` sees them: movedNesting where that differs from where the
|
|
2452
|
+
* last start placed it, the start's placement (nestedOf) otherwise.
|
|
2453
|
+
*/
|
|
2454
|
+
private async nestedFor(mount: MountState, rootReal: string): Promise<NestedMount[]> {
|
|
2455
|
+
return (await this.movedNesting(mount, rootReal)) ?? this.nestedOf(mount.config.name);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* The mounts nested in a direct mount whose root a check resolved to
|
|
2460
|
+
* `rootReal`, where that's elsewhere than the last start placed it, as when
|
|
2461
|
+
* a link it's configured through is repointed; null where the start's
|
|
2462
|
+
* placement holds, or where nothing is at `rootReal`: there the parse's
|
|
2463
|
+
* checks keep the start's placement, and a read says the root is gone. A
|
|
2464
|
+
* direct mount reads beneath whatever directory is at its root, so a check
|
|
2465
|
+
* judges which mount a path is by the root it resolved. A tool's parse
|
|
2466
|
+
* resolves it once, and its respelling and its checks of the path it's
|
|
2467
|
+
* given (parseFilePath's, refusesAsOutside, grep's) read disk beneath that
|
|
2468
|
+
* root and judge by the nesting there. A read and a walk each resolve it
|
|
2469
|
+
* for themselves, and judge the include and the nesting again beneath it.
|
|
2470
|
+
* So a root repointed during a call can change which answer or refusal a
|
|
2471
|
+
* path gets, but nothing a read or a walk serves is a file the include
|
|
2472
|
+
* leaves out, nothing a walk serves lies in another mount's directory as
|
|
2473
|
+
* its own nesting places it, and nothing a read serves lies in the
|
|
2474
|
+
* directory of a mount its own nesting claims by real root. Whoever writes
|
|
2475
|
+
* the disk can race a read in two ways, as a rename during a call can on
|
|
2476
|
+
* any mount. On a disk that folds names, a read judges the include by the
|
|
2477
|
+
* path as its parse spelled it, so a root repointed between the two can
|
|
2478
|
+
* have it serve a file the new root spells otherwise (sameLevels). And a
|
|
2479
|
+
* mount nested by its directories alone, since its real root holds this
|
|
2480
|
+
* one, is judged by the parse's nesting, so a root repointed between a
|
|
2481
|
+
* read's parse and its open can have it serve a path in that mount's
|
|
2482
|
+
* directory. Each mount is placed as a start places it (placementOf), from
|
|
2483
|
+
* where its real root and the directories above its configured path lie on
|
|
2484
|
+
* disk. For a mount configured at this mount's own path or beneath it,
|
|
2485
|
+
* that's where they lie now: beneath `rootReal` where they lie beneath this
|
|
2486
|
+
* mount's path, and the directories above this mount's path as resolving
|
|
2487
|
+
* that path finds them. For any other mount, it's where the last start
|
|
2488
|
+
* found them. Each carries its own include, as nestedOf gives it. It
|
|
2489
|
+
* resolves nothing but the directories on this mount's own configured path
|
|
2490
|
+
* and those beneath its root, so a share elsewhere that doesn't answer
|
|
2491
|
+
* holds only its own calls, unless such a directory is a link into it,
|
|
2492
|
+
* where that mount's files lie. What it leaves to the next start: a mount
|
|
2493
|
+
* configured elsewhere whose own root has moved since, as when a link on
|
|
2494
|
+
* its configured path is repointed, this mount's own reached through
|
|
2495
|
+
* another link among them, that mount's spellings through other links, and
|
|
2496
|
+
* the other mounts' own placements, so a direct root moved into another
|
|
2497
|
+
* mount's directory is that mount's too, as a nested mount's root moved
|
|
2498
|
+
* while running stays placed.
|
|
2499
|
+
*/
|
|
2500
|
+
private async movedNesting(mount: MountState, rootReal: string): Promise<NestedMount[] | null> {
|
|
2501
|
+
// One start's placements, whatever a start in the meantime sets.
|
|
2502
|
+
const { placedRoots, placedAbove } = this;
|
|
2503
|
+
const placed = placedRoots.get(mount.config.name);
|
|
2504
|
+
if (!mount.direct || placed === undefined || placed === rootReal) return null;
|
|
2505
|
+
const there = await stat(rootReal).then(() => true, (err: unknown) => {
|
|
2506
|
+
const code = errnoOf(err);
|
|
2507
|
+
return code !== 'ENOENT' && code !== 'ENOTDIR';
|
|
2508
|
+
});
|
|
2509
|
+
if (!there) return null;
|
|
2510
|
+
const outerPath = resolve(mount.config.path);
|
|
2511
|
+
const nested: NestedMount[] = [];
|
|
2512
|
+
// The directories above this mount's own path, as they lie on disk now,
|
|
2513
|
+
// resolved as resolving the path itself does.
|
|
2514
|
+
let ownAbove: Array<{ dir: string; real: string }> | undefined;
|
|
2515
|
+
const aboveOwn = async () => ownAbove ??= await Promise.all(ancestorsOf(outerPath).map(async (dir) => ({ dir, real: await realBeneath(dir) })));
|
|
2516
|
+
for (const inner of this.config.mounts) {
|
|
2517
|
+
if (inner.name === mount.config.name) continue;
|
|
2518
|
+
const innerPath = resolve(inner.path);
|
|
2519
|
+
const configured = nestedDir(outerPath, innerPath);
|
|
2520
|
+
let real: string | undefined;
|
|
2521
|
+
let above: ReadonlyArray<{ dir: string; real: string }>;
|
|
2522
|
+
if (configured !== undefined || innerPath === outerPath) {
|
|
2523
|
+
// A mount configured on this mount's own path, or beneath it, lies
|
|
2524
|
+
// where that path now leads: its real root, and each directory above
|
|
2525
|
+
// it, resolved now, beneath this root where they lie beneath it.
|
|
2526
|
+
const segments = configured?.split('/') ?? [];
|
|
2527
|
+
real = await realBeneath(join(rootReal, ...segments));
|
|
2528
|
+
const beneath: Array<{ dir: string; real: string }> = [];
|
|
2529
|
+
for (let i = segments.length - 1; i >= 1; i--) {
|
|
2530
|
+
beneath.push({ dir: join(outerPath, ...segments.slice(0, i)), real: await realBeneath(join(rootReal, ...segments.slice(0, i))) });
|
|
2531
|
+
}
|
|
2532
|
+
// This mount's own path, which is the root, would add only the
|
|
2533
|
+
// configured directory, which placementOf adds anyway.
|
|
2534
|
+
above = [...beneath, ...await aboveOwn()];
|
|
2535
|
+
} else {
|
|
2536
|
+
// Any other mount lies where the last start found it and the
|
|
2537
|
+
// directories above its path. One leading here only since, as
|
|
2538
|
+
// through this mount's own link, waits for the next start.
|
|
2539
|
+
real = placedRoots.get(inner.name);
|
|
2540
|
+
if (real === undefined) continue;
|
|
2541
|
+
above = placedAbove.get(inner.name) ?? [];
|
|
2542
|
+
}
|
|
2543
|
+
const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
|
|
2544
|
+
if (at === undefined) continue;
|
|
2545
|
+
const include = this.mounts.get(inner.name)?.config.include;
|
|
2546
|
+
for (const s of at.spellings) nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2547
|
+
}
|
|
2548
|
+
return nested;
|
|
2048
2549
|
}
|
|
2049
2550
|
|
|
2050
2551
|
/**
|
|
@@ -2052,17 +2553,32 @@ export class WorkspaceModule implements Module {
|
|
|
2052
2553
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2053
2554
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2054
2555
|
* outer mount isn't followed), with the path to use there; undefined when
|
|
2055
|
-
* no nested mount's directory holds it.
|
|
2556
|
+
* no nested mount's directory holds it. A path parsePath has respelled is
|
|
2557
|
+
* compared with the directory's spellings exactly. One judged as given
|
|
2558
|
+
* (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
|
|
2559
|
+
* folds names may compare it (beginsWithDir), so it can't reach that
|
|
2560
|
+
* mount's directory by spelling a segment otherwise. On a disk that tells
|
|
2561
|
+
* names apart, that takes a directory of this mount's own spelled like the
|
|
2562
|
+
* nested one in another way as that mount's too.
|
|
2056
2563
|
*/
|
|
2057
|
-
private holderOf(mountName: string, relativePath: string): { name: string; use: string } | undefined {
|
|
2564
|
+
private holderOf(mountName: string, relativePath: string, asGiven = false, moved: NestedMount[] | null = null): { name: string; use: string } | undefined {
|
|
2058
2565
|
const mount = this.mounts.get(mountName);
|
|
2059
2566
|
if (!mount) return undefined;
|
|
2060
|
-
const
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2063
|
-
|
|
2567
|
+
const root = resolve(mount.config.path);
|
|
2568
|
+
const resolved = resolve(root, relativePath);
|
|
2569
|
+
if (!isContainedPath(root, resolved)) return undefined;
|
|
2570
|
+
const segments = resolved === root ? [] : relative(root, resolved).split(sep);
|
|
2571
|
+
// A direct mount whose root a call found moved is judged by that call's
|
|
2572
|
+
// nesting (movedNesting), each directory beneath its configured path.
|
|
2573
|
+
const dirs = moved !== null
|
|
2574
|
+
? moved.map((n) => ({ name: n.name, dir: n.dir }))
|
|
2575
|
+
: (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
|
|
2576
|
+
const holder = dirs
|
|
2577
|
+
.filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
|
|
2578
|
+
.map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
|
|
2579
|
+
.sort((a, b) => b.depth - a.depth)[0];
|
|
2064
2580
|
if (!holder) return undefined;
|
|
2065
|
-
const rest =
|
|
2581
|
+
const rest = segments.slice(holder.depth).join('/');
|
|
2066
2582
|
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
|
|
2067
2583
|
}
|
|
2068
2584
|
|
|
@@ -2071,8 +2587,8 @@ export class WorkspaceModule implements Module {
|
|
|
2071
2587
|
* mount and the path to use: for a sync or a push, which of them leaves
|
|
2072
2588
|
* it; for a listing, whose the path is.
|
|
2073
2589
|
*/
|
|
2074
|
-
private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list'): string {
|
|
2075
|
-
const holder = this.holderOf(mountName, relativePath);
|
|
2590
|
+
private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
|
|
2591
|
+
const holder = this.holderOf(mountName, relativePath, false, moved);
|
|
2076
2592
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2077
2593
|
return holder
|
|
2078
2594
|
? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
|
|
@@ -2081,76 +2597,231 @@ export class WorkspaceModule implements Module {
|
|
|
2081
2597
|
|
|
2082
2598
|
/**
|
|
2083
2599
|
* parsePath for the file operations (read, write, edit, delete, and the
|
|
2084
|
-
* module's binary and disk reads and writes)
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
* it,
|
|
2600
|
+
* module's binary and disk reads and writes), refused where the mount
|
|
2601
|
+
* doesn't hold the path. A path in another mount's directory nested in
|
|
2602
|
+
* this one is that mount's: this mount's passes don't observe it and its
|
|
2603
|
+
* pushes don't write it, so the refusal names the innermost mount that
|
|
2604
|
+
* holds it and the path to use there. A path the mount's include doesn't
|
|
2605
|
+
* cover is outside the mount, whatever disk or the workspace's history has
|
|
2606
|
+
* there. A directory is checked the same way, since a path can be a
|
|
2607
|
+
* directory on one side and a file on the other.
|
|
2088
2608
|
*/
|
|
2089
2609
|
private async parseFilePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
|
|
2090
|
-
const
|
|
2091
|
-
//
|
|
2092
|
-
//
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2610
|
+
const spelled = await this.parsePath(path);
|
|
2611
|
+
// Both checks judge by the nesting the parse found, and the second reads
|
|
2612
|
+
// disk beneath the root the parse resolved.
|
|
2613
|
+
const { mount, root, moved } = spelled;
|
|
2614
|
+
const parsed = this.refuseNotHeld(spelled, path, false, moved);
|
|
2615
|
+
// A path can also reach a nested mount's directory, or a file this
|
|
2616
|
+
// mount's own include leaves out, through a symlink on its way. No push
|
|
2617
|
+
// of this mount writes it there and no read reads it, so it's refused
|
|
2618
|
+
// here, before anything is stored, and the checks where disk is acted on
|
|
2619
|
+
// refuse it again.
|
|
2620
|
+
const refusal = await this.landingRefusal(mount, parsed.relativePath, parsed.path, moved ?? this.nestedOf(mount.config.name), root);
|
|
2096
2621
|
if (refusal) throw refusal;
|
|
2097
2622
|
return parsed;
|
|
2098
2623
|
}
|
|
2099
2624
|
|
|
2100
2625
|
/**
|
|
2101
2626
|
* The refusal for a path of `mount` that reaches a nested mount's directory
|
|
2102
|
-
* through a symlink on its way (nestedAtReal), or
|
|
2103
|
-
*
|
|
2104
|
-
*
|
|
2105
|
-
*
|
|
2627
|
+
* through a symlink on its way (nestedAtReal), or a file this mount's own
|
|
2628
|
+
* include leaves out (coversLanding), or, on a mount that follows links
|
|
2629
|
+
* with an include, one realpath can't resolve, or null where it doesn't.
|
|
2630
|
+
* On a mount that doesn't follow the link, the refusal is the link's own
|
|
2631
|
+
* (linkWords), as the readers and pushes give it. On one that does, it
|
|
2632
|
+
* names the nested mount only where the place is that mount's
|
|
2633
|
+
* (namesNestedPlace); anywhere else the place is no mount's, so the path
|
|
2634
|
+
* leaves this one, in the disk readers' words, as it does where it lands
|
|
2635
|
+
* on a file this mount's include leaves out. A mount with nothing nested,
|
|
2636
|
+
* and without both an include and links followed, has nothing to find, so
|
|
2637
|
+
* it pays nothing. The caller gives the nesting and the root to judge by
|
|
2638
|
+
* (`nested`, `root`). A direct mount's file operation gives those its
|
|
2639
|
+
* parse found, so where the path lands, and any link on its way, is judged
|
|
2640
|
+
* beneath the root that parse resolved; any other caller gives the start's
|
|
2641
|
+
* nesting and the configured path.
|
|
2106
2642
|
*/
|
|
2107
|
-
private async
|
|
2108
|
-
const
|
|
2109
|
-
if (nested.length === 0) return null;
|
|
2110
|
-
const
|
|
2111
|
-
|
|
2643
|
+
private async landingRefusal(mount: MountState, relativePath: string, path: string, nested: readonly NestedMount[], root: string): Promise<Error | null> {
|
|
2644
|
+
const judged = mount.config.followSymlinks === true && mount.config.include !== undefined;
|
|
2645
|
+
if (nested.length === 0 && !judged) return null;
|
|
2646
|
+
const unresolved: { code?: string } = {};
|
|
2647
|
+
const real = await realBeneath(resolve(root, relativePath), 0, unresolved);
|
|
2648
|
+
// Where realpath can't resolve the path, as for a loop or a directory
|
|
2649
|
+
// this process can't search, where it lands can't be judged, and a
|
|
2650
|
+
// promise about content fails closed: the path is refused, by every file
|
|
2651
|
+
// operation, in the words a disk read gives a path it can't resolve,
|
|
2652
|
+
// whatever the place realpath stopped short of would say.
|
|
2653
|
+
if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2654
|
+
const holder = nestedAtReal(real, nested);
|
|
2655
|
+
if (!holder) {
|
|
2656
|
+
if (!judged) return null;
|
|
2657
|
+
const rootReal = await realBeneath(root);
|
|
2658
|
+
if (!isContainedPath(rootReal, real) || await stat(real).then((info) => info.isDirectory(), () => false)) return null;
|
|
2659
|
+
return await coversLanding({ include: mount.config.include }, rootReal, real, relativePath) ? null : new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2660
|
+
}
|
|
2112
2661
|
if (mount.config.followSymlinks !== true) {
|
|
2113
|
-
const
|
|
2114
|
-
|
|
2662
|
+
const rootReal = await realBeneath(root);
|
|
2663
|
+
const found = await linkOnTheWay({ root: rootReal, followSymlinks: false }, relativePath);
|
|
2664
|
+
if (found !== null) return await linkReadError(mount, found, path, relativePath, nested, rootReal);
|
|
2115
2665
|
}
|
|
2666
|
+
// On a mount with an include, the path to use there names each directory
|
|
2667
|
+
// on the place's way as that mount's, so a place beneath anything else
|
|
2668
|
+
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2669
|
+
// a directory not there yet is.
|
|
2670
|
+
const onItsWay = holder.include === undefined
|
|
2671
|
+
|| await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
|
|
2672
|
+
const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2673
|
+
if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2116
2674
|
return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
|
|
2117
2675
|
}
|
|
2118
2676
|
|
|
2119
|
-
/**
|
|
2120
|
-
|
|
2121
|
-
|
|
2677
|
+
/**
|
|
2678
|
+
* A parsed path, refused where the mount doesn't hold it: in a nested
|
|
2679
|
+
* mount's directory first, naming the mount that holds it, then outside
|
|
2680
|
+
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2681
|
+
* as disk spells it for parseFilePath, as given for resolveAbsolutePath,
|
|
2682
|
+
* which is synchronous and says so (`asGiven`, so the nested-mount check
|
|
2683
|
+
* folds where disk does). The include's refusal shows the path as given
|
|
2684
|
+
* (`given`), since it answers the path the caller asked about.
|
|
2685
|
+
*/
|
|
2686
|
+
private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, asGiven = false, moved: NestedMount[] | null = null): P {
|
|
2687
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
|
|
2122
2688
|
if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
|
|
2689
|
+
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2690
|
+
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2691
|
+
}
|
|
2123
2692
|
return parsed;
|
|
2124
2693
|
}
|
|
2125
2694
|
|
|
2695
|
+
/**
|
|
2696
|
+
* Whether a mount-relative path names a file, as grep decides what a path
|
|
2697
|
+
* it's given names: for a path the include covers, a file in the
|
|
2698
|
+
* workspace's history, or anything but a directory on disk. Grep takes it
|
|
2699
|
+
* as a directory only when neither side has a file there, and filters what
|
|
2700
|
+
* it finds beneath it by the mount's include. A path the include doesn't
|
|
2701
|
+
* cover names no file of the mount's, whatever disk or history holds
|
|
2702
|
+
* there, so one refusesAsOutside lets through is a directory. A direct
|
|
2703
|
+
* mount has no history: what a run of it as 'chronicle' left in the store
|
|
2704
|
+
* isn't its own, so disk alone decides. Disk is read beneath `root`: a
|
|
2705
|
+
* direct mount's is the root its parse resolved, a store-backed mount's
|
|
2706
|
+
* its configured path.
|
|
2707
|
+
*/
|
|
2708
|
+
private async namesFile(mount: MountState, relativePath: string, root: string): Promise<boolean> {
|
|
2709
|
+
return relativePath !== '' && insideMount(relativePath, mount.config.include) && (
|
|
2710
|
+
(!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) ||
|
|
2711
|
+
await lstat(join(root, relativePath)).then((s) => !s.isDirectory(), () => false)
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* Whether sync, materialize, grep, glob or ls refuses a path it's given as
|
|
2717
|
+
* outside the mount's include. A path the include doesn't cover is a scope
|
|
2718
|
+
* exactly where a listing names a directory: where disk has a directory
|
|
2719
|
+
* reached from the mount's root through directories, with no link among
|
|
2720
|
+
* them, or the workspace's history holds an entry beneath it that's the
|
|
2721
|
+
* mount's own to list (withinIncludes). The one exception is a directory
|
|
2722
|
+
* the ignore list covers, which a listing passes over and which is still a
|
|
2723
|
+
* scope when it's named, since a sync that names it takes in what's there
|
|
2724
|
+
* (#276). Anything else is refused, a file at the path included, which no
|
|
2725
|
+
* scope tool takes up. The walk descends no link it meets. Where covered
|
|
2726
|
+
* history makes a name a scope and disk has since put a link there, a
|
|
2727
|
+
* mount that doesn't follow symlinks refuses the link, as it refuses any
|
|
2728
|
+
* link on a path's way, and on a mount that does, a pass of the scope
|
|
2729
|
+
* follows the link, and its answer shows whether the link leads out of the
|
|
2730
|
+
* mount. A
|
|
2731
|
+
* path in another mount's directory nested in this one is that mount's,
|
|
2732
|
+
* whatever this mount's include says, so it isn't refused here: each tool
|
|
2733
|
+
* names the mount that holds it, as the file operations do. A direct mount
|
|
2734
|
+
* has no history, as namesFile says, and is judged beneath the root its
|
|
2735
|
+
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2736
|
+
*/
|
|
2737
|
+
private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
|
|
2738
|
+
if (this.holderOf(mount.config.name, relativePath, false, moved)) return false;
|
|
2739
|
+
if (insideMount(relativePath, mount.config.include)) return false;
|
|
2740
|
+
const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
|
|
2741
|
+
if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
|
|
2742
|
+
.some((entry) => withinIncludes(own, entry.path))) return false;
|
|
2743
|
+
// A directory, and each directory above it to the mount's root, as the
|
|
2744
|
+
// walk reaches it: through no link, since the walk descends none.
|
|
2745
|
+
const segments = relativePath.split('/');
|
|
2746
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
2747
|
+
const entry = await lstat(join(root, ...segments.slice(0, i))).catch(() => null);
|
|
2748
|
+
if (!entry?.isDirectory()) return true;
|
|
2749
|
+
}
|
|
2750
|
+
return false;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2126
2753
|
/**
|
|
2127
2754
|
* Parse a mount-prefixed path into its mount and mount-relative path, as
|
|
2128
|
-
* disk spells it (diskSpelling)
|
|
2129
|
-
*
|
|
2130
|
-
*
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
*
|
|
2137
|
-
*
|
|
2138
|
-
*
|
|
2755
|
+
* disk spells it (diskSpelling). `path` is the mount-prefixed path in that
|
|
2756
|
+
* spelling where it differs from the one spelling, and otherwise the path
|
|
2757
|
+
* as the caller gave it. The path is read in its one spelling first
|
|
2758
|
+
* (parsePathAsGiven), so the respelling only ever sees a path with no
|
|
2759
|
+
* empty, `.` or `..` segment and no separator before its first. On a disk
|
|
2760
|
+
* that folds names, a path spelled otherwise reaches disk's file, so every
|
|
2761
|
+
* tool takes it by disk's name: one name per file, and the names its
|
|
2762
|
+
* answers give are disk's, except that a path that names a workspace entry
|
|
2763
|
+
* exactly, or does once its directories take disk's spelling, keeps the
|
|
2764
|
+
* entry's spelling, and what lies past a link on the way keeps the
|
|
2765
|
+
* caller's, since the step reads nothing past a link. A path the workspace
|
|
2766
|
+
* records an intent at, such as a deletion disk hasn't been given yet,
|
|
2767
|
+
* counts as an entry here, so the calls that settle it reach it. The
|
|
2768
|
+
* workspace's entries (entriesOf) stand in for disk where it has nothing
|
|
2769
|
+
* yet, so a draft has one name too. Neither applies to a path that disk's
|
|
2770
|
+
* spelling puts in a nested mount's directory. A direct mount holds no
|
|
2771
|
+
* entries, so its paths take disk's spelling alone. Past a nested mount's
|
|
2772
|
+
* directory the path stays as given, since what lies there is that mount's
|
|
2773
|
+
* to name. Every answer shows `path`, a name the include leaves out
|
|
2774
|
+
* included, since how disk spells a name the caller gave is no content of
|
|
2775
|
+
* that file, except the include's own refusal, which shows the path
|
|
2776
|
+
* exactly as given (refuseNotHeld), since it answers the path asked about.
|
|
2139
2777
|
* A handler that must read something at the moment it's asked, as
|
|
2140
2778
|
* handleSync reads the branch a path sync adopts on, reads it before
|
|
2141
|
-
* calling this, since this awaits disk. The entries are read when the
|
|
2142
|
-
*
|
|
2779
|
+
* calling this, since this awaits disk. The entries are read when the step
|
|
2780
|
+
* needs them, some after those awaits, from the branch current then.
|
|
2143
2781
|
*/
|
|
2144
|
-
private async parsePath(path: string): Promise<
|
|
2782
|
+
private async parsePath(path: string): Promise<ParsedPath> {
|
|
2145
2783
|
const given = this.parsePathAsGiven(path);
|
|
2146
|
-
|
|
2784
|
+
// A direct mount's root is resolved once here, so the respelling and the
|
|
2785
|
+
// checks of the path that follow read disk beneath one root and judge by
|
|
2786
|
+
// the nesting there (movedNesting).
|
|
2787
|
+
const configured = resolve(given.mount.config.path);
|
|
2788
|
+
const root = given.mount.direct ? await realpath(configured).catch(() => configured) : configured;
|
|
2789
|
+
const moved = given.mount.direct ? await this.movedNesting(given.mount, root) : null;
|
|
2790
|
+
const spelled = await diskSpelling(root, given.relativePath, given.mount.direct ? undefined : this.entriesOf(given.mount));
|
|
2791
|
+
// Past a nested mount's directory the path stays as given: what lies
|
|
2792
|
+
// there is that mount's, whose own include decides what a tool may name,
|
|
2793
|
+
// so this mount doesn't respell it.
|
|
2794
|
+
// The directory itself takes disk's spelling, as a listing names it.
|
|
2795
|
+
// The respelling keeps each segment's place, so the given path's rest
|
|
2796
|
+
// follows the directory's segments.
|
|
2797
|
+
const depth = this.nestedDepth(given.mount.config.name, spelled, moved);
|
|
2798
|
+
const relativePath = depth === undefined ? spelled
|
|
2799
|
+
: [...spelled.split('/').slice(0, depth), ...given.relativePath.split('/').slice(depth)].join('/');
|
|
2147
2800
|
return {
|
|
2148
2801
|
mount: given.mount,
|
|
2149
2802
|
relativePath,
|
|
2150
2803
|
path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
|
|
2804
|
+
root,
|
|
2805
|
+
moved,
|
|
2151
2806
|
};
|
|
2152
2807
|
}
|
|
2153
2808
|
|
|
2809
|
+
/**
|
|
2810
|
+
* How many segments the innermost nested mount's directory that holds
|
|
2811
|
+
* `relativePath` has, in a spelling it's known by (othersOf), or, for a
|
|
2812
|
+
* direct mount whose root has moved, in the nesting there (`moved`);
|
|
2813
|
+
* undefined when no nested mount's directory holds it.
|
|
2814
|
+
*/
|
|
2815
|
+
private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
|
|
2816
|
+
let depth: number | undefined;
|
|
2817
|
+
for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
|
|
2818
|
+
if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
|
|
2819
|
+
const segments = dir.split('/').length;
|
|
2820
|
+
if (depth === undefined || segments > depth) depth = segments;
|
|
2821
|
+
}
|
|
2822
|
+
return depth;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2154
2825
|
/**
|
|
2155
2826
|
* A mount's entries on the current branch, and the paths it records an
|
|
2156
2827
|
* intent at there, as diskSpelling reads them when it does, whether its
|
|
@@ -2168,14 +2839,34 @@ export class WorkspaceModule implements Module {
|
|
|
2168
2839
|
// given yet or a conflict, is the workspace's own name for the file
|
|
2169
2840
|
// there, as an entry is, so the calls that settle it reach it.
|
|
2170
2841
|
has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
|
|
2171
|
-
|
|
2842
|
+
// Only an entry this mount's include covers spells a draft's path
|
|
2843
|
+
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
2844
|
+
// from an earlier version, nor one the include leaves out, which would
|
|
2845
|
+
// respell a covered draft into a name the include leaves out. An entry
|
|
2846
|
+
// in a nested mount's directory that this mount's include covers
|
|
2847
|
+
// spells one whatever that mount's include says, so on a disk that
|
|
2848
|
+
// folds names, a draft naming the directory in another spelling takes
|
|
2849
|
+
// the entry's spelling and is judged as in it.
|
|
2850
|
+
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
2172
2851
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
2173
2852
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
2174
2853
|
};
|
|
2175
2854
|
}
|
|
2176
2855
|
|
|
2177
2856
|
/**
|
|
2178
|
-
* Parse a mount-prefixed path into
|
|
2857
|
+
* Parse a mount-prefixed path into its mount and the one mount-relative
|
|
2858
|
+
* path every later step reads, as given, before disk's spelling
|
|
2859
|
+
* (parsePath): `/` between segments (`\` read as `/` where it's the
|
|
2860
|
+
* platform's separator), with no empty or `.` segment and no trailing `/`,
|
|
2861
|
+
* and '' for the mount's root. So the traversal guard here and every later
|
|
2862
|
+
* step judge one spelling of the path, which parsePath then respells name
|
|
2863
|
+
* by name as disk lists them. A `..` segment takes back the segment before
|
|
2864
|
+
* it, before anything looks the path up, so a mount's include judges the
|
|
2865
|
+
* path every tool acts on, and one with nothing left to take back climbs
|
|
2866
|
+
* above the mount's root and is refused, whatever it reaches afterwards. A
|
|
2867
|
+
* separator straight after the mount's name would make the rest an
|
|
2868
|
+
* absolute path, so that is refused too, and every reader that takes a
|
|
2869
|
+
* path refuses it here, a disk reader included.
|
|
2179
2870
|
*/
|
|
2180
2871
|
private parsePathAsGiven(path: string): { mount: MountState; relativePath: string } {
|
|
2181
2872
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
@@ -2185,25 +2876,37 @@ export class WorkspaceModule implements Module {
|
|
|
2185
2876
|
}
|
|
2186
2877
|
const slashIdx = path.indexOf('/');
|
|
2187
2878
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
2188
|
-
const
|
|
2879
|
+
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
2189
2880
|
|
|
2190
2881
|
const mount = this.mounts.get(mountName);
|
|
2191
2882
|
if (!mount) {
|
|
2192
2883
|
throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
|
|
2193
2884
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2885
|
+
const segments = pathSegments(given);
|
|
2886
|
+
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
2887
|
+
throw new Error(`Path traversal detected: "${path}" has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root.`);
|
|
2888
|
+
}
|
|
2889
|
+
const kept: string[] = [];
|
|
2890
|
+
for (const segment of segments) {
|
|
2891
|
+
if (segment !== '..') kept.push(segment);
|
|
2892
|
+
else if (kept.pop() === undefined) {
|
|
2893
|
+
throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
const relativePath = kept.join('/');
|
|
2897
|
+
|
|
2898
|
+
// Path traversal guard (CWE-22), on the path every later step reads: on
|
|
2899
|
+
// Windows a first segment such as `C:` names another drive. Containment
|
|
2900
|
+
// must compare with the PLATFORM separator (isContainedPath): resolve()
|
|
2901
|
+
// emits backslashes on Windows, so a '/'-suffixed root never prefix-matches
|
|
2902
|
+
// there and every in-mount path was rejected as traversal. Against the
|
|
2903
|
+
// resolved root, so a root configured with a trailing separator, or
|
|
2904
|
+
// relative, contains its own paths, the root included.
|
|
2201
2905
|
const root = resolve(mount.config.path);
|
|
2202
2906
|
const resolved = resolve(root, relativePath);
|
|
2203
2907
|
if (!isContainedPath(root, resolved)) {
|
|
2204
2908
|
throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
|
|
2205
2909
|
}
|
|
2206
|
-
|
|
2207
2910
|
return { mount, relativePath };
|
|
2208
2911
|
}
|
|
2209
2912
|
|
|
@@ -2272,6 +2975,8 @@ export class WorkspaceModule implements Module {
|
|
|
2272
2975
|
* which answers while the evidence awaits reopening; where even it can't
|
|
2273
2976
|
* be read, as when the evidence was never read back whole, the read is
|
|
2274
2977
|
* refused too, with an errno only where no directory is at the root's path.
|
|
2978
|
+
* A direct mount's root is checked against no identity, and its read reads
|
|
2979
|
+
* no evidence: it promises what's at its root now (diskView).
|
|
2275
2980
|
*/
|
|
2276
2981
|
private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
|
|
2277
2982
|
const name = mount.config.name;
|
|
@@ -2283,13 +2988,15 @@ export class WorkspaceModule implements Module {
|
|
|
2283
2988
|
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
2284
2989
|
}
|
|
2285
2990
|
let rootIdentity: MountView['rootIdentity'];
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2991
|
+
if (!mount.direct) {
|
|
2992
|
+
try {
|
|
2993
|
+
rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
|
|
2994
|
+
} catch (err) {
|
|
2995
|
+
// The evidence can't be read back, so neither can the check be made.
|
|
2996
|
+
// Where no directory is at the root's path, its errno still says so.
|
|
2997
|
+
throw new WorkspaceReadError('mount_unavailable',
|
|
2998
|
+
`Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
|
|
2999
|
+
}
|
|
2293
3000
|
}
|
|
2294
3001
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
2295
3002
|
if ('dir' in checked) return { real, dir: checked.dir };
|
|
@@ -2305,11 +3012,13 @@ export class WorkspaceModule implements Module {
|
|
|
2305
3012
|
* with the code and errno readRoot gives what is at its path now (its stage
|
|
2306
3013
|
* and words are this check's): gone, or not a directory, with ENOENT or
|
|
2307
3014
|
* ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
|
|
2308
|
-
* another directory, with none.
|
|
3015
|
+
* another directory, with none. A direct mount reads whatever directory is
|
|
3016
|
+
* at its root (MountConfig.backend), so for it, another directory there is
|
|
3017
|
+
* one the next read reads.
|
|
2309
3018
|
*/
|
|
2310
3019
|
private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
|
|
2311
3020
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
2312
|
-
const reason = errno
|
|
3021
|
+
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
2313
3022
|
return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
|
|
2314
3023
|
}
|
|
2315
3024
|
|
|
@@ -2324,7 +3033,8 @@ export class WorkspaceModule implements Module {
|
|
|
2324
3033
|
* among the directories on its way beneath the mount's root, wherever it
|
|
2325
3034
|
* leads (linkOnTheWay), before anything resolves it: as `escape` for a
|
|
2326
3035
|
* link leading outside the mount, as `other_mount` for one into a nested
|
|
2327
|
-
* mount's directory
|
|
3036
|
+
* mount's directory where its refusal names that mount, and as
|
|
3037
|
+
* `parent_symlink` for any other.
|
|
2328
3038
|
* 2. `lstat` the lexical path — refuse directories; refuse a final-component
|
|
2329
3039
|
* symlink when the mount does not `followSymlinks`; refuse anything else
|
|
2330
3040
|
* that isn't a regular file, such as a FIFO, without opening it.
|
|
@@ -2384,9 +3094,14 @@ export class WorkspaceModule implements Module {
|
|
|
2384
3094
|
// mount that doesn't follow symlinks goes through none among the
|
|
2385
3095
|
// directories on the way, wherever it leads. Asked again of the path's
|
|
2386
3096
|
// resolution after the open, below.
|
|
2387
|
-
|
|
3097
|
+
// The checks below judge by the root step 0 checked, and read the links
|
|
3098
|
+
// on the path's way beneath it. The lstat and the open go through the
|
|
3099
|
+
// configured path, and step 5 refuses a read whose root is no longer the
|
|
3100
|
+
// directory step 0 checked, so what's served is judged beneath one root.
|
|
3101
|
+
const linkView = { root: realMountRoot, followSymlinks: follow };
|
|
3102
|
+
const nested = await this.nestedFor(mount, realMountRoot);
|
|
2388
3103
|
const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
|
|
2389
|
-
if (onTheWay !== null) throw await linkReadError(mount, onTheWay, mountPrefixedPath,
|
|
3104
|
+
if (onTheWay !== null) throw await linkReadError(mount, onTheWay, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
2390
3105
|
|
|
2391
3106
|
let fileInfo: Awaited<ReturnType<typeof lstat>>;
|
|
2392
3107
|
try {
|
|
@@ -2473,14 +3188,23 @@ export class WorkspaceModule implements Module {
|
|
|
2473
3188
|
}
|
|
2474
3189
|
|
|
2475
3190
|
// A directory swapped for a link since the walk above is caught here, by
|
|
2476
|
-
// what the path resolved to after the open
|
|
3191
|
+
// what the path resolved to after the open, and the check below holds
|
|
3192
|
+
// the opened file to that path. A link put back after this realpath,
|
|
3193
|
+
// leading to the opened file itself, passes both: a race this doesn't
|
|
3194
|
+
// close.
|
|
2477
3195
|
const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
|
|
2478
|
-
if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath,
|
|
2479
|
-
|
|
2480
|
-
|
|
3196
|
+
if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
3197
|
+
|
|
3198
|
+
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
3199
|
+
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3200
|
+
// A place in a nested mount's directory that its include leaves out is
|
|
3201
|
+
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
3202
|
+
// does one reaching a file this mount's own include leaves out
|
|
3203
|
+
// (coversLanding).
|
|
3204
|
+
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
3205
|
+
if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
|
|
2481
3206
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
2482
3207
|
}
|
|
2483
|
-
const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
|
|
2484
3208
|
if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
|
|
2485
3209
|
|
|
2486
3210
|
let pathStat: Stats;
|
|
@@ -2532,7 +3256,9 @@ export class WorkspaceModule implements Module {
|
|
|
2532
3256
|
* - Once the workspace has recorded the mount root's identity, with the
|
|
2533
3257
|
* first evidence it gathers there, a root that is another directory is
|
|
2534
3258
|
* an unavailable mount: a replaced directory, a link swapped in for it,
|
|
2535
|
-
* or an unmounted drive's mountpoint.
|
|
3259
|
+
* or an unmounted drive's mountpoint. A direct mount's reads check no
|
|
3260
|
+
* recorded identity: they read beneath whatever directory is at its
|
|
3261
|
+
* root (MountConfig.backend). In any mount, recorded or not, a
|
|
2536
3262
|
* root that stops being the directory the read checked until the file
|
|
2537
3263
|
* has been opened and checked is unavailable too: with ENOENT or
|
|
2538
3264
|
* ENOTDIR where no directory is there, as before the read, and with no
|
|
@@ -2540,9 +3266,10 @@ export class WorkspaceModule implements Module {
|
|
|
2540
3266
|
* the descriptor that was checked.
|
|
2541
3267
|
*
|
|
2542
3268
|
* Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
|
|
2543
|
-
* mount, lexical traversal, a path
|
|
2544
|
-
*
|
|
2545
|
-
*
|
|
3269
|
+
* mount, lexical traversal, a path the mount's include doesn't cover
|
|
3270
|
+
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
3271
|
+
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
3272
|
+
* directory, policy-denied symlink, outside-mount target, and
|
|
2546
3273
|
* file-changed-during-read.
|
|
2547
3274
|
*/
|
|
2548
3275
|
async readFileFromDisk(
|
|
@@ -2560,7 +3287,8 @@ export class WorkspaceModule implements Module {
|
|
|
2560
3287
|
// A link's refusal (parseFilePath) carries its own code.
|
|
2561
3288
|
if (err instanceof WorkspaceReadError) throw err;
|
|
2562
3289
|
const message = err instanceof Error ? err.message : String(err);
|
|
2563
|
-
const code: WorkspaceReadErrorCode = err instanceof
|
|
3290
|
+
const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3291
|
+
: err instanceof NestedMountError ? 'other_mount'
|
|
2564
3292
|
: message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
|
|
2565
3293
|
throw new WorkspaceReadError(code, message, 'parse');
|
|
2566
3294
|
}
|
|
@@ -2692,9 +3420,11 @@ export class WorkspaceModule implements Module {
|
|
|
2692
3420
|
/**
|
|
2693
3421
|
* Commit a tool's write or delete to the workspace tree, with the branch
|
|
2694
3422
|
* intent it implies: store origin for a path disk never agreed with; for a
|
|
2695
|
-
* deletion, a tombstone,
|
|
3423
|
+
* deletion, a tombstone, written before the entry is removed, unless the
|
|
3424
|
+
* agreement's evidence for the path, P, holds no agreed disk copy (P
|
|
2696
3425
|
* absent, or no P and store origin), when it clears the path's intent and
|
|
2697
|
-
* pushes nothing. It pushes the change to
|
|
3426
|
+
* pushes nothing. It pushes the change to
|
|
3427
|
+
* disk when the mount opts in via
|
|
2698
3428
|
* `autoMaterialize` — required for cross-agent pipelines, since another
|
|
2699
3429
|
* agent's watcher on the same directory only sees real filesystem events.
|
|
2700
3430
|
* The push follows materialize's rule: a disk copy that changed since it
|
|
@@ -2713,85 +3443,210 @@ export class WorkspaceModule implements Module {
|
|
|
2713
3443
|
private async commitToolChange(
|
|
2714
3444
|
mount: MountState,
|
|
2715
3445
|
relativePath: string,
|
|
2716
|
-
change:
|
|
3446
|
+
change: ToolChange,
|
|
2717
3447
|
onCommitted?: () => void,
|
|
2718
3448
|
): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
|
|
3449
|
+
const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3450
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
/**
|
|
3454
|
+
* commitToolChange for several paths of one mount at once, as edit_many
|
|
3455
|
+
* commits its files: every path's precondition is enforced before any entry
|
|
3456
|
+
* is set, then a deletion's tombstone is written, then every entry is set
|
|
3457
|
+
* in one record of the store, which takes all of them or none, then each
|
|
3458
|
+
* other path's intent, by the rule commitToolChange's doc gives, which the
|
|
3459
|
+
* code below applies, with no await between, and one push takes the paths
|
|
3460
|
+
* to disk, but for a deletion that clears its path's intent. `onCommitted`
|
|
3461
|
+
* is told of each path once the entries are set, also when the store throws
|
|
3462
|
+
* after setting them, whose error then goes on in place of the push
|
|
3463
|
+
* (changesLanded).
|
|
3464
|
+
* Answers, by path, the reason for each that reached the workspace but not
|
|
3465
|
+
* disk (`refused`), and, apart from it, why one whose bytes reached disk
|
|
3466
|
+
* left the file's mode as disk had it (`modeKept`), and that a deletion
|
|
3467
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`), as
|
|
3468
|
+
* commitToolChange answers them for one path.
|
|
3469
|
+
*/
|
|
3470
|
+
private async commitToolChanges(
|
|
3471
|
+
mount: MountState,
|
|
3472
|
+
changes: Array<{ path: string; change: ToolChange }>,
|
|
3473
|
+
onCommitted?: (path: string) => void,
|
|
3474
|
+
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
|
|
2719
3475
|
const store = this.getStore();
|
|
3476
|
+
const agreement = this.agreementOrThrow();
|
|
2720
3477
|
const intents = this.intents.get(mount.config.name)!;
|
|
2721
3478
|
// The mutation's precondition, enforced here, synchronously with the
|
|
2722
3479
|
// commit: whatever settleBeforeMutation's pass could or couldn't settle.
|
|
2723
|
-
|
|
2724
|
-
const
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
3480
|
+
// Every path's comes first, so nothing that can refuse follows a set.
|
|
3481
|
+
for (const { path } of changes) {
|
|
3482
|
+
settleAdoptionBeforeMutation(store, agreement, { name: mount.config.name, treeStateId: mount.treeStateId }, path);
|
|
3483
|
+
}
|
|
3484
|
+
const entry = (change: ToolChange & { kind: 'set' }) => ({ blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
|
|
3485
|
+
// The changes the tree doesn't hold yet, which the read-back below checks,
|
|
3486
|
+
// read before any intent is written, so no read comes between a
|
|
3487
|
+
// tombstone's write and the put-back's guard.
|
|
3488
|
+
const changing = changes.filter(({ path, change }) => !holds(store.treeGet(mount.treeStateId, path), change));
|
|
3489
|
+
// Each path's intent, by the rule commitToolChange's doc gives. A
|
|
3490
|
+
// deletion's tombstone is written before its entry is removed. Removed
|
|
3491
|
+
// without it, the entry would read as a file this branch doesn't have,
|
|
3492
|
+
// which no materialize deletes and no second delete can reach
|
|
3493
|
+
// (anima-research/agent-framework#553). Every other intent is written
|
|
3494
|
+
// after the tree change: what it gives up, the tombstone a write replaces
|
|
3495
|
+
// or the origin and conflict a deletion clears, guards the path until the
|
|
3496
|
+
// change lands.
|
|
3497
|
+
const notes = new Map<string, string>();
|
|
3498
|
+
const unpushed = new Set<string>();
|
|
3499
|
+
const intentChanges = changes.map(({ path, change }): { path: string; first: boolean; next: (cur: BranchIntent) => BranchIntent } => {
|
|
3500
|
+
const evidence = agreement.get(mount.config.name, path);
|
|
3501
|
+
const cur = intents.get(path) ?? {};
|
|
3502
|
+
if (change.kind === 'remove') {
|
|
3503
|
+
// A deletion whose evidence holds no agreed disk copy (disk last
|
|
3504
|
+
// agreed it had nothing here, or has never agreed with this entry,
|
|
3505
|
+
// which the workspace created) puts the store back at the
|
|
3506
|
+
// agreement. Nothing on disk is the workspace's to unlink, so it
|
|
3507
|
+
// pushes nothing, and it clears the path's whole intent: no
|
|
3508
|
+
// tombstone, so it needs no proof of absence, which a path disk
|
|
3509
|
+
// can't show, such as one beneath a symlink the mount doesn't
|
|
3510
|
+
// follow, would never give; and no conflict, which has nothing left
|
|
3511
|
+
// to be about once the workspace has given up its side. Whatever
|
|
3512
|
+
// disk holds there is a sync's to observe, a file the workspace
|
|
3513
|
+
// never saw included. Any other deletion keeps its tombstone, which
|
|
3514
|
+
// guards it: a copy disk agreed with, a push of ours that may have
|
|
3515
|
+
// landed (pending or interrupted), or an entry from before the
|
|
3516
|
+
// evidence.
|
|
3517
|
+
if (!(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'))) {
|
|
3518
|
+
return {
|
|
3519
|
+
path,
|
|
3520
|
+
first: true,
|
|
3521
|
+
next: (current) => {
|
|
3522
|
+
const next = { ...current, tombstone: { hash: change.previousHash } };
|
|
3523
|
+
delete next.origin;
|
|
3524
|
+
return next;
|
|
3525
|
+
},
|
|
3526
|
+
};
|
|
2765
3527
|
}
|
|
3528
|
+
unpushed.add(path);
|
|
2766
3529
|
// A conflict it clears may name a file disk held there, which the
|
|
2767
|
-
// workspace never took in: the answer says that file wasn't
|
|
2768
|
-
// where the deletion alone would read as having removed
|
|
2769
|
-
// deletion doesn't look at disk, so a file nothing recorded
|
|
2770
|
-
// note.
|
|
3530
|
+
// workspace never took in: the answer says that file wasn't
|
|
3531
|
+
// unlinked, where the deletion alone would read as having removed
|
|
3532
|
+
// it. The deletion doesn't look at disk, so a file nothing recorded
|
|
3533
|
+
// gets no note.
|
|
2771
3534
|
if (cur.conflict?.disk) {
|
|
2772
|
-
|
|
2773
|
-
`that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there
|
|
3535
|
+
notes.set(path, `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${path} `
|
|
3536
|
+
+ `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`);
|
|
2774
3537
|
}
|
|
2775
|
-
return {};
|
|
2776
|
-
}
|
|
2777
|
-
|
|
3538
|
+
return { path, first: false, next: () => ({}) };
|
|
3539
|
+
}
|
|
3540
|
+
return {
|
|
3541
|
+
path,
|
|
3542
|
+
first: false,
|
|
3543
|
+
next: (current) => {
|
|
3544
|
+
const next = { ...current };
|
|
3545
|
+
delete next.tombstone;
|
|
3546
|
+
// Disk has never agreed with this path: a missing disk copy is a
|
|
3547
|
+
// draft not yet materialized, not a deletion.
|
|
3548
|
+
if (evidence === undefined) next.origin = 'store';
|
|
3549
|
+
return next;
|
|
3550
|
+
},
|
|
3551
|
+
};
|
|
3552
|
+
});
|
|
3553
|
+
// A tombstone written for a change that then doesn't land is put back:
|
|
3554
|
+
// the path's intent record as it was, a damaged one's bytes included, so
|
|
3555
|
+
// a refused deletion changes nothing. The put-back is best effort, since
|
|
3556
|
+
// the error that stopped the call is its answer. A tombstone it leaves
|
|
3557
|
+
// beside the entry guards nothing: decide gives no verdict from one while
|
|
3558
|
+
// the entry is there, status and materialize take it as no deletion, and
|
|
3559
|
+
// the path's next agreement clears it.
|
|
3560
|
+
const written: Array<{ path: string; record: JsTreeEntry | null }> = [];
|
|
3561
|
+
const putBack = () => {
|
|
3562
|
+
for (const { path, record } of written) {
|
|
3563
|
+
try {
|
|
3564
|
+
if (record) store.treeSet(intents.treeStateId, path, record);
|
|
3565
|
+
else if (store.treeGet(intents.treeStateId, path)) store.treeRemove(intents.treeStateId, path);
|
|
3566
|
+
} catch (error) {
|
|
3567
|
+
// Left beside the entry, where it guards nothing (above).
|
|
3568
|
+
console.warn(
|
|
3569
|
+
`[workspace] Mount "${mount.config.name}": a change to ${path} failed, and putting its branch intent back threw too `
|
|
3570
|
+
+ `(${error instanceof Error ? error.message : String(error)}), so a tombstone may be left beside the entry. One there `
|
|
3571
|
+
+ `deletes nothing, and the path's next agreement with disk clears it`,
|
|
3572
|
+
);
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
};
|
|
3576
|
+
try {
|
|
3577
|
+
for (const { path, first, next } of intentChanges) {
|
|
3578
|
+
if (!first) continue;
|
|
3579
|
+
written.push({ path, record: store.treeGet(intents.treeStateId, path) });
|
|
3580
|
+
intents.update(path, next);
|
|
3581
|
+
}
|
|
3582
|
+
} catch (error) {
|
|
3583
|
+
putBack();
|
|
3584
|
+
throw error;
|
|
2778
3585
|
}
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
3586
|
+
// A store can fail after its record lands: Chronicle 0.4 writes a state's
|
|
3587
|
+
// auto-snapshot after appending its update, and that failure reaches the
|
|
3588
|
+
// caller with the update recorded (anima-research/chronicle#39). So a set
|
|
3589
|
+
// that threw is read back, and where every path it changes holds what its
|
|
3590
|
+
// change leaves, the record is in: the paths' intents follow, as for any
|
|
3591
|
+
// set, and then the error goes on in place of the push. A change the tree
|
|
3592
|
+
// already holds can't show whether the record is in, so a call whose every
|
|
3593
|
+
// change the tree holds already goes on as the store's own failure.
|
|
3594
|
+
let landedThenThrew: { error: unknown } | null = null;
|
|
3595
|
+
try {
|
|
3596
|
+
if (changes.length === 1) {
|
|
3597
|
+
const [{ path, change }] = changes as [{ path: string; change: ToolChange }];
|
|
3598
|
+
if (change.kind === 'set') store.treeSet(mount.treeStateId, path, entry(change));
|
|
3599
|
+
else store.treeRemove(mount.treeStateId, path);
|
|
3600
|
+
} else {
|
|
3601
|
+
// One record for every path, so a store that fails partway sets none.
|
|
3602
|
+
store.treeBatch(mount.treeStateId, changes.map(({ path, change }) => (change.kind === 'set'
|
|
3603
|
+
? { op: 'set', path, entry: entry(change) }
|
|
3604
|
+
: { op: 'remove', path })));
|
|
3605
|
+
}
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
// A read-back that fails can't tell whether the change landed, so its
|
|
3608
|
+
// error goes on and a tombstone stays: put back where the removal did
|
|
3609
|
+
// land, it would leave #553's state, and beside an entry it guards
|
|
3610
|
+
// nothing.
|
|
3611
|
+
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3612
|
+
putBack();
|
|
3613
|
+
throw error;
|
|
3614
|
+
}
|
|
3615
|
+
landedThenThrew = { error };
|
|
3616
|
+
}
|
|
3617
|
+
// A later intent update or disk push can throw; the tree entries are
|
|
3618
|
+
// already set, on the branch current now. If the active branch changes
|
|
3619
|
+
// during the push, that branch's tree has the files and the new one may
|
|
3620
|
+
// not.
|
|
3621
|
+
for (const { path } of changes) onCommitted?.(path);
|
|
3622
|
+
for (const { path, first, next } of intentChanges) if (!first) intents.update(path, next);
|
|
3623
|
+
const pushing = changes.filter(({ path }) => !unpushed.has(path));
|
|
3624
|
+
if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3625
|
+
const refused = new Map<string, string>();
|
|
3626
|
+
const modeKept = new Map<string, string>();
|
|
3627
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) return { refused, modeKept, notes };
|
|
3628
|
+
|
|
3629
|
+
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3630
|
+
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
2783
3631
|
branchId: store.currentBranch().id,
|
|
2784
3632
|
});
|
|
2785
|
-
const
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
3633
|
+
for (const { path, change } of pushing) {
|
|
3634
|
+
const skipped = pushed.skipped.find((s) => s.path === path);
|
|
3635
|
+
if (!skipped) {
|
|
3636
|
+
const kept = pushed.modesKept.find((k) => k.path === path)?.reason;
|
|
3637
|
+
if (kept) modeKept.set(path, kept);
|
|
3638
|
+
continue;
|
|
3639
|
+
}
|
|
3640
|
+
refused.set(path, skipped.reason);
|
|
3641
|
+
this.ctx?.pushEvent({
|
|
3642
|
+
type: 'workspace:materialize-failed',
|
|
3643
|
+
mount: mount.config.name,
|
|
3644
|
+
path,
|
|
3645
|
+
op: change.kind === 'set' ? 'write' : 'delete',
|
|
3646
|
+
error: skipped.reason,
|
|
3647
|
+
} as ProcessEvent);
|
|
3648
|
+
}
|
|
3649
|
+
return { refused, modeKept, notes };
|
|
2795
3650
|
}
|
|
2796
3651
|
|
|
2797
3652
|
// ==========================================================================
|
|
@@ -2850,6 +3705,63 @@ export class WorkspaceModule implements Module {
|
|
|
2850
3705
|
}
|
|
2851
3706
|
}
|
|
2852
3707
|
|
|
3708
|
+
/**
|
|
3709
|
+
* A file's bytes for read, from where the mount keeps what it reads: the
|
|
3710
|
+
* workspace's copy, taken in from disk first where it holds none
|
|
3711
|
+
* (ensureSynced); or, on a direct mount, disk's file as it is now
|
|
3712
|
+
* (bytesOnDisk). An error says why there are none to read.
|
|
3713
|
+
*/
|
|
3714
|
+
private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3715
|
+
if (mount.direct) return this.bytesOnDisk(mount, relativePath, path);
|
|
3716
|
+
const store = this.getStore();
|
|
3717
|
+
return this.withMount(mount, async () => {
|
|
3718
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
3719
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3720
|
+
if (!entry) return { error: this.notInWorkspace(path, report) };
|
|
3721
|
+
const blob = store.getBlob(entry.blobHash);
|
|
3722
|
+
if (!blob) return { error: `Blob not found for: ${path}` };
|
|
3723
|
+
return { bytes: blob };
|
|
3724
|
+
});
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
/**
|
|
3728
|
+
* A direct mount's file for read: its bytes on disk as they are now,
|
|
3729
|
+
* through the mount's boundary as readFileFromDisk reaches them
|
|
3730
|
+
* (openContainedFile), whose refusals are the answer. What a 'chronicle'
|
|
3731
|
+
* mount never stores, it doesn't show either: a file over the mount's
|
|
3732
|
+
* size limit, and a binary one, with a NUL in its first 8 KB, which
|
|
3733
|
+
* read_image reads where it's an image.
|
|
3734
|
+
*/
|
|
3735
|
+
private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3736
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3737
|
+
const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
|
|
3738
|
+
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
3739
|
+
try {
|
|
3740
|
+
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3741
|
+
if (fileStat.size > maxSize) throw new Error(over);
|
|
3742
|
+
});
|
|
3743
|
+
} catch (err) {
|
|
3744
|
+
// A refusal that names only the path says why with its errno, as a
|
|
3745
|
+
// failed read below does.
|
|
3746
|
+
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
3747
|
+
return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
|
|
3748
|
+
}
|
|
3749
|
+
try {
|
|
3750
|
+
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
3751
|
+
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
3752
|
+
if (more) return { error: over };
|
|
3753
|
+
if (looksBinary(bytes)) {
|
|
3754
|
+
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
|
|
3755
|
+
}
|
|
3756
|
+
return { bytes };
|
|
3757
|
+
} catch (err) {
|
|
3758
|
+
const errno = errnoOf(err);
|
|
3759
|
+
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
3760
|
+
} finally {
|
|
3761
|
+
await opened.handle.close();
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
|
|
2853
3765
|
// ==========================================================================
|
|
2854
3766
|
// Tool Handlers
|
|
2855
3767
|
// ==========================================================================
|
|
@@ -2867,17 +3779,9 @@ export class WorkspaceModule implements Module {
|
|
|
2867
3779
|
if (content === undefined) {
|
|
2868
3780
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
2869
3781
|
input = { ...input, path };
|
|
2870
|
-
const
|
|
2871
|
-
const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
|
|
2872
|
-
const report = await this.ensureSynced(mount, relativePath);
|
|
2873
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2874
|
-
if (!entry) return { error: this.notInWorkspace(input.path, report) };
|
|
2875
|
-
const blob = store.getBlob(entry.blobHash);
|
|
2876
|
-
if (!blob) return { error: `Blob not found for: ${input.path}` };
|
|
2877
|
-
return { text: blob.toString('utf-8') };
|
|
2878
|
-
});
|
|
3782
|
+
const found = await this.bytesOf(mount, relativePath, path);
|
|
2879
3783
|
if ('error' in found) return { success: false, error: found.error, isError: true };
|
|
2880
|
-
content = found.
|
|
3784
|
+
content = found.bytes.toString('utf-8');
|
|
2881
3785
|
}
|
|
2882
3786
|
if (characterPaging) {
|
|
2883
3787
|
const start = Math.min(offsetChars, content.length);
|
|
@@ -2971,48 +3875,23 @@ export class WorkspaceModule implements Module {
|
|
|
2971
3875
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2972
3876
|
let treeError: WorkspaceImageReadError | null = null;
|
|
2973
3877
|
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
data: image.bytes.toString('base64'),
|
|
2987
|
-
mimeType: image.mimeType,
|
|
2988
|
-
},
|
|
2989
|
-
],
|
|
2990
|
-
};
|
|
2991
|
-
}
|
|
2992
|
-
} catch (err) {
|
|
2993
|
-
if (err instanceof WorkspaceImageReadError) {
|
|
2994
|
-
treeError = err;
|
|
2995
|
-
} else {
|
|
2996
|
-
throw err;
|
|
3878
|
+
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
3879
|
+
// mount holds none, so its image is disk's.
|
|
3880
|
+
if (!mount.direct) {
|
|
3881
|
+
try {
|
|
3882
|
+
const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
|
|
3883
|
+
if (image) return imageResult(input.path, image);
|
|
3884
|
+
} catch (err) {
|
|
3885
|
+
if (err instanceof WorkspaceImageReadError) {
|
|
3886
|
+
treeError = err;
|
|
3887
|
+
} else {
|
|
3888
|
+
throw err;
|
|
3889
|
+
}
|
|
2997
3890
|
}
|
|
2998
3891
|
}
|
|
2999
3892
|
|
|
3000
3893
|
try {
|
|
3001
|
-
|
|
3002
|
-
return {
|
|
3003
|
-
success: true,
|
|
3004
|
-
data: [
|
|
3005
|
-
{
|
|
3006
|
-
type: 'text',
|
|
3007
|
-
text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
3008
|
-
},
|
|
3009
|
-
{
|
|
3010
|
-
type: 'image',
|
|
3011
|
-
data: image.bytes.toString('base64'),
|
|
3012
|
-
mimeType: image.mimeType,
|
|
3013
|
-
},
|
|
3014
|
-
],
|
|
3015
|
-
};
|
|
3894
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
|
|
3016
3895
|
} catch (err) {
|
|
3017
3896
|
if (err instanceof WorkspaceImageReadError) {
|
|
3018
3897
|
if (err.code === 'not_found' && treeError) {
|
|
@@ -3056,168 +3935,438 @@ export class WorkspaceModule implements Module {
|
|
|
3056
3935
|
}));
|
|
3057
3936
|
} catch (error) {
|
|
3058
3937
|
if (!committed) throw error;
|
|
3059
|
-
return storedButUnfinished('write', input.path,
|
|
3938
|
+
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
3939
|
+
? { storeFailed: error.message, pushes: error.pushes }
|
|
3940
|
+
: { threw: error instanceof Error ? error.message : String(error) });
|
|
3060
3941
|
}
|
|
3061
3942
|
if (refused !== null) return storedButUnfinished('write', input.path, { refused });
|
|
3062
3943
|
|
|
3063
3944
|
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
3064
3945
|
}
|
|
3065
3946
|
|
|
3066
|
-
private async handleEdit(input: EditInput): Promise<ToolResult> {
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3947
|
+
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS): Promise<ToolResult> {
|
|
3948
|
+
return this.applyEdits('edit', [{
|
|
3949
|
+
path: input.path,
|
|
3950
|
+
oldString: input.oldString,
|
|
3951
|
+
newString: input.newString,
|
|
3952
|
+
expected: input.replaceAll ? 'all' : 1,
|
|
3953
|
+
}], maxChars);
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
private async handleEditMany(
|
|
3957
|
+
input: EditManyInput,
|
|
3958
|
+
maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
|
|
3959
|
+
callerAgentName?: string,
|
|
3960
|
+
): Promise<ToolResult> {
|
|
3961
|
+
if (input.edits.length === 0) {
|
|
3962
|
+
return { success: false, isError: true, error: 'The edit_many call did nothing: "edits" is empty. Give at least one edit.' };
|
|
3963
|
+
}
|
|
3964
|
+
return this.applyEdits('edit_many', input.edits.map((edit) => ({
|
|
3965
|
+
path: edit.path,
|
|
3966
|
+
oldString: edit.oldString,
|
|
3967
|
+
newString: edit.newString,
|
|
3968
|
+
expected: edit.occurrences ?? 1,
|
|
3969
|
+
})), maxChars, callerAgentName);
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
/**
|
|
3973
|
+
* A call's edits, all or none, applied to the stored copies of their files:
|
|
3974
|
+
* edit's one, or edit_many's in the files of one mount. Each edit is matched
|
|
3975
|
+
* in its file's text as this call reads it, never in another edit's result
|
|
3976
|
+
* (edit-plan.ts), and any problem refuses the call with none of its edits
|
|
3977
|
+
* stored: edit names its first problem, and edit_many every problem found.
|
|
3978
|
+
*
|
|
3979
|
+
* Each edit's own input is checked first, and each path's mount. A problem
|
|
3980
|
+
* there refuses edit at once, before any file's text is read, and edit_many
|
|
3981
|
+
* when its edits name more than one mount, which no one pass settles.
|
|
3982
|
+
* Otherwise edit_many goes on to settle and plan the rest, so its one
|
|
3983
|
+
* refusal names the problems it finds; the settling is what a read or a
|
|
3984
|
+
* listing of its files would take in from disk, and the refusal says what
|
|
3985
|
+
* it took in. A failure before the commit sets the entries stores none of
|
|
3986
|
+
* them, and edit_many's answer to it says so. A store that throws as it
|
|
3987
|
+
* sets them is read back (commitToolChanges), so the answer says they're
|
|
3988
|
+
* stored where its record landed, and that it can't tell where they
|
|
3989
|
+
* couldn't be read back.
|
|
3990
|
+
* Under the mount's turn, one pass settles every path with disk, by the
|
|
3991
|
+
* rule a listing uses, so the edits apply to what disk now holds. That pass
|
|
3992
|
+
* is the only step that awaits before the commit: every file is then read,
|
|
3993
|
+
* every edit planned, and every entry set (commitToolChanges) with no turn
|
|
3994
|
+
* of the event loop between, so no pass, branch change or other call moves
|
|
3995
|
+
* the tree between a file's read and its set. Disk then takes what it can
|
|
3996
|
+
* of the stored change.
|
|
3997
|
+
*
|
|
3998
|
+
* The answer is held to `maxChars`, the length the call's result may have
|
|
3999
|
+
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
4000
|
+
*/
|
|
4001
|
+
private async applyEdits(
|
|
4002
|
+
tool: EditTool,
|
|
4003
|
+
given: Array<{ path: string; oldString: string; newString: string; expected: number | 'all' }>,
|
|
4004
|
+
maxChars: number,
|
|
4005
|
+
callerAgentName?: string,
|
|
4006
|
+
): Promise<ToolResult> {
|
|
4007
|
+
const refuse = (problems: EditProblem[], notes: string[] = []): ToolResult =>
|
|
4008
|
+
({ success: false, isError: true, error: refusalText(tool, problems, notes, maxChars) });
|
|
4009
|
+
|
|
4010
|
+
// Each edit's own input, before anything is read: an empty old text
|
|
4011
|
+
// matches between every character, and UTF-8 can't store a lone surrogate.
|
|
4012
|
+
const problems: EditProblem[] = [];
|
|
4013
|
+
const specs = new Map<number, EditSpec>();
|
|
4014
|
+
for (const [index, edit] of given.entries()) {
|
|
4015
|
+
const spec: EditSpec = {
|
|
4016
|
+
index,
|
|
4017
|
+
oldText: edit.oldString.replaceAll('\r\n', '\n'),
|
|
4018
|
+
newText: edit.newString.replaceAll('\r\n', '\n'),
|
|
4019
|
+
expected: edit.expected,
|
|
4020
|
+
};
|
|
4021
|
+
const found = problems.length;
|
|
4022
|
+
if (spec.oldText === '') {
|
|
4023
|
+
problems.push({ kind: 'input', edit: index, text: '"oldString" is empty, and an empty string matches between every character. Give the text to replace.' });
|
|
4024
|
+
} else if (spec.oldText === spec.newText) {
|
|
4025
|
+
// An edit's new text takes the line ends of the text it replaces, so
|
|
4026
|
+
// one whose only change is its line ends changes nothing.
|
|
4027
|
+
problems.push({
|
|
4028
|
+
kind: 'input',
|
|
4029
|
+
edit: index,
|
|
4030
|
+
text: edit.oldString !== edit.newString
|
|
4031
|
+
? "oldString and newString are the same once their CRLF line ends are read as LF, so there's nothing to change: an edit keeps the file's line ends, and write replaces the whole file, line ends included."
|
|
4032
|
+
: "oldString and newString are the same, so there's nothing to change.",
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
for (const field of ['oldString', 'newString'] as const) {
|
|
4036
|
+
if (LONE_SURROGATE.test(edit[field])) {
|
|
4037
|
+
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
if (problems.length === found) specs.set(index, spec);
|
|
3071
4041
|
}
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
4042
|
+
// edit names its first problem, so one in its input refuses it before
|
|
4043
|
+
// its path is looked up on disk.
|
|
4044
|
+
if (tool === 'edit' && problems.length) return refuse(problems);
|
|
4045
|
+
|
|
4046
|
+
// Each path in its mount, as disk spells it, before any file's text is
|
|
4047
|
+
// read. Edits of one stored path are one file, planned once from one
|
|
4048
|
+
// text, whatever path named it, and named as disk spells it.
|
|
4049
|
+
const parsed = new Map<string, { mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>>();
|
|
4050
|
+
const targets = new Map<string, EditTarget>();
|
|
4051
|
+
const mounts = new Map<string, MountState>();
|
|
4052
|
+
for (const [index, { path }] of given.entries()) {
|
|
4053
|
+
if (!parsed.has(path)) parsed.set(path, await this.editablePath(tool, path, callerAgentName));
|
|
4054
|
+
const where = parsed.get(path)!;
|
|
4055
|
+
if ('kind' in where) {
|
|
4056
|
+
where.edits.push(index);
|
|
4057
|
+
continue;
|
|
3075
4058
|
}
|
|
4059
|
+
mounts.set(where.mount.config.name, where.mount);
|
|
4060
|
+
const key = `${where.mount.config.name}/${where.relativePath}`;
|
|
4061
|
+
let target = targets.get(key);
|
|
4062
|
+
if (!target) targets.set(key, (target = { relativePath: where.relativePath, path: where.path, edits: [], specs: [] }));
|
|
4063
|
+
target.edits.push(index);
|
|
4064
|
+
const spec = specs.get(index);
|
|
4065
|
+
if (spec) target.specs.push(spec);
|
|
3076
4066
|
}
|
|
3077
|
-
const
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
4067
|
+
for (const where of parsed.values()) if ('kind' in where) problems.push(where);
|
|
4068
|
+
if (mounts.size > 1) {
|
|
4069
|
+
problems.push({ kind: 'mounts', edits: [...targets.values()].flatMap((target) => target.edits), mounts: [...mounts.keys()] });
|
|
3081
4070
|
}
|
|
4071
|
+
const [mount] = mounts.values();
|
|
4072
|
+
if (tool === 'edit' && problems.length) return refuse(problems);
|
|
4073
|
+
if (!mount || mounts.size > 1) return refuse(problems);
|
|
3082
4074
|
|
|
3083
4075
|
const store = this.getStore();
|
|
4076
|
+
// Whether the commit set the call's entries, all of them at once.
|
|
3084
4077
|
let committed = false;
|
|
3085
|
-
|
|
4078
|
+
// What each file's settling took in, for a receipt.
|
|
4079
|
+
const applied = new Map<EditTarget, string | null>();
|
|
4080
|
+
// The conflict each file's settling found, on a mount that doesn't push:
|
|
4081
|
+
// the success answer names it, and so does the answer to a store that
|
|
4082
|
+
// failed after recording the edits, which stand as on a success.
|
|
4083
|
+
const conflicts = new Map<EditTarget, string>();
|
|
3086
4084
|
try {
|
|
3087
4085
|
return await this.withMount(mount, async (): Promise<ToolResult> => {
|
|
3088
|
-
//
|
|
3089
|
-
//
|
|
3090
|
-
|
|
3091
|
-
//
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
//
|
|
3095
|
-
//
|
|
3096
|
-
|
|
3097
|
-
const
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
const
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
:
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
//
|
|
3136
|
-
|
|
3137
|
-
const
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
4086
|
+
// As every file tool's pass does, it names the paths it acts on, so a
|
|
4087
|
+
// file the mount passes over by design is observed (see PassOptions.named).
|
|
4088
|
+
const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4089
|
+
// Nothing below awaits until the commit's push.
|
|
4090
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4091
|
+
const hints = hintBudget();
|
|
4092
|
+
// As much as the largest file the call names holds, at least, so the
|
|
4093
|
+
// budget is the same whatever order its files are planned in.
|
|
4094
|
+
let largest = maxSize;
|
|
4095
|
+
for (const target of targets.values()) largest = Math.max(largest, store.treeGet(mount.treeStateId, target.relativePath)?.size ?? 0);
|
|
4096
|
+
const counts = countBudget(largest);
|
|
4097
|
+
const notes: string[] = [];
|
|
4098
|
+
const pushes = this.pushesOnCommit(mount);
|
|
4099
|
+
const plans: Array<{ target: EditTarget; plan: FilePlan; mode: number; backOnDisk: string | null; conflictNote: string | null }> = [];
|
|
4100
|
+
for (const target of targets.values()) {
|
|
4101
|
+
const report = pass.reports.get(target.relativePath);
|
|
4102
|
+
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
4103
|
+
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
4104
|
+
// then write at the mode disk has, so it leaves nothing to say.
|
|
4105
|
+
const took = (op: TreeOp) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
4106
|
+
// Which copy the edits used, when it isn't the one the workspace
|
|
4107
|
+
// last agreed on with disk: disk's newer text, or the workspace's
|
|
4108
|
+
// copy because disk's couldn't be read or disk has none. A file the
|
|
4109
|
+
// workspace didn't hold has no older copy, so taking it in needs no
|
|
4110
|
+
// word.
|
|
4111
|
+
const subject = tool === 'edit' ? 'the edit' : editList(target.edits);
|
|
4112
|
+
const whichCopy = (verb: string): string | null => took('modified')
|
|
4113
|
+
? `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} had changed since the workspace last agreed with it, so it was taken in first, and ${subject} ${verb} that text.`
|
|
4114
|
+
: report?.state === 'unverified'
|
|
4115
|
+
? `${tool === 'edit' ? 'The path' : target.path} couldn't be checked against disk (${report.note ?? 'not observed'}), so ${subject} ${verb} the workspace's copy.`
|
|
4116
|
+
: report?.state === 'disk-missing-provenance-unknown'
|
|
4117
|
+
? `Disk has no copy of ${tool === 'edit' ? 'this file' : target.path}, and nothing records whether it was deleted there, so ${subject} ${verb} the workspace's copy.`
|
|
4118
|
+
: null;
|
|
4119
|
+
// A conflict the pass found is named when the edits are refused for
|
|
4120
|
+
// the text they found, on either kind of mount, and on a success
|
|
4121
|
+
// where the mount doesn't push, since such a mount keeps the conflict
|
|
4122
|
+
// until materialize or sync settles it. A pushing mount's push that
|
|
4123
|
+
// completed met none: unforced, it stops at a conflict, so the edits
|
|
4124
|
+
// made the two copies agree, which settles it, or there was none.
|
|
4125
|
+
// Edits stored that couldn't finish leave it to the push's reason,
|
|
4126
|
+
// which names the conflict, or to ls, which that answer points to
|
|
4127
|
+
// when the rest of the call failed; where the store failed after
|
|
4128
|
+
// recording them, the answer names it as the success does.
|
|
4129
|
+
// What a sync does follows what disk holds now, whatever kind the
|
|
4130
|
+
// conflict was recorded as: where disk has no copy, it takes the
|
|
4131
|
+
// deletion; where disk has one, it keeps disk's copy and gives up
|
|
4132
|
+
// the workspace's, though a copy the workspace can't hold, binary
|
|
4133
|
+
// or past maxFileSize, isn't taken in.
|
|
4134
|
+
const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
|
|
4135
|
+
const conflictNote = (where: string): string | null => conflict === null ? null
|
|
4136
|
+
: `${tool === 'edit' ? 'The path' : target.path} is in conflict with disk (${conflict}): ${subject} ${where} the workspace's copy. `
|
|
4137
|
+
+ (report?.diskNow === 'absent'
|
|
4138
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy, or materialize it with force to write the workspace's copy back to disk."
|
|
4139
|
+
: "Sync the path to keep disk's copy and give up the workspace's, or materialize it with force to write the workspace's over disk's.");
|
|
4140
|
+
applied.set(target, whichCopy('applied to'));
|
|
4141
|
+
// The edits make the path the workspace's own change, so a file disk
|
|
4142
|
+
// lacked goes back with this call's push, or with the next
|
|
4143
|
+
// materialize where the mount doesn't push. A push that didn't
|
|
4144
|
+
// complete answers with its reason instead.
|
|
4145
|
+
const backOnDisk = report?.state !== 'disk-missing-provenance-unknown' ? null
|
|
4146
|
+
: tool === 'edit'
|
|
4147
|
+
? `${pushes ? 'Its push put the file back on disk' : 'A materialize of the path puts it back on disk'}; if it was deleted there on purpose, delete it here too.`
|
|
4148
|
+
: `${pushes ? `This call's push put ${target.path} back on disk` : `A materialize of ${target.path} puts it back on disk`}; if it was deleted there on purpose, delete it here too.`;
|
|
4149
|
+
const lookedIn = [whichCopy('looked in'), conflictNote('looked in')].filter((note): note is string => note !== null);
|
|
4150
|
+
|
|
4151
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4152
|
+
if (!entry) {
|
|
4153
|
+
// A pass also drops a copy disk made binary or too large, which
|
|
4154
|
+
// the disk-only answer describes; any other drop is disk's deletion.
|
|
4155
|
+
const text = took('deleted') && report?.state !== 'disk-only'
|
|
4156
|
+
? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
|
|
4157
|
+
: this.notInWorkspace(target.path, report);
|
|
4158
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
|
|
4159
|
+
continue;
|
|
4160
|
+
}
|
|
4161
|
+
notes.push(...lookedIn);
|
|
4162
|
+
const blob = store.getBlob(entry.blobHash);
|
|
4163
|
+
if (!blob) {
|
|
4164
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
|
|
4165
|
+
continue;
|
|
4166
|
+
}
|
|
4167
|
+
// Decoding would turn each invalid sequence into U+FFFD, and writing
|
|
4168
|
+
// the text back would change bytes the edits never named.
|
|
4169
|
+
const file = decodeForEdit(blob);
|
|
4170
|
+
if (!file) {
|
|
4171
|
+
problems.push({
|
|
4172
|
+
kind: 'file',
|
|
4173
|
+
edits: target.edits,
|
|
4174
|
+
path: target.path,
|
|
4175
|
+
text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
|
|
4176
|
+
+ 'Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.',
|
|
4177
|
+
whole: false,
|
|
4178
|
+
});
|
|
4179
|
+
continue;
|
|
4180
|
+
}
|
|
4181
|
+
if (!target.specs.length) continue;
|
|
4182
|
+
const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
|
|
4183
|
+
if ('problems' in planned) {
|
|
4184
|
+
// One at a time: a call of many edits can have more problems
|
|
4185
|
+
// than a spread passes as arguments.
|
|
4186
|
+
for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
|
|
4187
|
+
continue;
|
|
4188
|
+
}
|
|
4189
|
+
// The planner refuses a file its counts take past the limit before
|
|
4190
|
+
// building it, from the least its bytes can be; one it built past
|
|
4191
|
+
// the limit, as its CRs or multi-byte characters can take it, is
|
|
4192
|
+
// refused here.
|
|
4193
|
+
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
4194
|
+
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
4195
|
+
problems.push({
|
|
4196
|
+
kind: 'file',
|
|
4197
|
+
edits: target.edits,
|
|
4198
|
+
path: target.path,
|
|
4199
|
+
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
|
|
4200
|
+
+ `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
|
|
4201
|
+
+ "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
|
|
4202
|
+
whole: false,
|
|
4203
|
+
});
|
|
4204
|
+
continue;
|
|
4205
|
+
}
|
|
4206
|
+
plans.push({
|
|
4207
|
+
target,
|
|
4208
|
+
plan: planned.plan,
|
|
4209
|
+
mode: entry.mode,
|
|
4210
|
+
backOnDisk,
|
|
4211
|
+
conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
|
|
4212
|
+
});
|
|
4213
|
+
const conflicted = plans.at(-1)!.conflictNote;
|
|
4214
|
+
if (conflicted !== null) conflicts.set(target, conflicted);
|
|
3175
4215
|
}
|
|
4216
|
+
if (problems.length) return refuse(problems, notes);
|
|
3176
4217
|
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
const newBuffer = Buffer.from(content, 'utf-8');
|
|
3184
|
-
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
3185
|
-
const { refused, modeKept } = await this.commitToolChange(mount, relativePath, {
|
|
3186
|
-
kind: 'set',
|
|
3187
|
-
blobHash: newBlobHash,
|
|
3188
|
-
size: newBuffer.byteLength,
|
|
3189
|
-
mode: entry.mode,
|
|
3190
|
-
}, () => { committed = true; });
|
|
3191
|
-
if (refused !== null) return storedButUnfinished('edit', input.path, { refused }, appliedNote);
|
|
3192
|
-
|
|
3193
|
-
// The edit makes the path the workspace's own change (commitToolChange),
|
|
3194
|
-
// so a file disk lacked goes back with this edit's push, or with the
|
|
3195
|
-
// next materialize where the mount doesn't push. A push that didn't
|
|
3196
|
-
// complete answers above, with its reason. One that completed met no
|
|
3197
|
-
// conflict, since this push, unforced, stops at one: the edit made the
|
|
3198
|
-
// two copies agree, which settles it, or there was none. So only a
|
|
3199
|
-
// mount that doesn't push still holds the conflict the pass found.
|
|
3200
|
-
const pushes = this.pushesOnCommit(mount);
|
|
3201
|
-
const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
|
|
3202
|
-
? `${pushes ? 'Its push put the file back on disk' : 'A materialize of the path puts it back on disk'}; if it was deleted there on purpose, delete it here too.`
|
|
3203
|
-
: null;
|
|
3204
|
-
const notes = [appliedNote, backOnDisk, pushes ? null : conflictNote('is in')].filter((n): n is string => n !== null);
|
|
4218
|
+
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4219
|
+
path: target.relativePath,
|
|
4220
|
+
change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4221
|
+
})), () => { committed = true; });
|
|
4222
|
+
if (refused.size > 0) return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
|
|
3205
4223
|
return {
|
|
3206
4224
|
success: true,
|
|
3207
|
-
data: {
|
|
3208
|
-
path:
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
},
|
|
4225
|
+
data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
|
|
4226
|
+
path: target.path,
|
|
4227
|
+
plan,
|
|
4228
|
+
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
|
|
4229
|
+
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4230
|
+
})), given.length, maxChars),
|
|
3213
4231
|
};
|
|
3214
4232
|
});
|
|
3215
4233
|
} catch (error) {
|
|
3216
|
-
|
|
3217
|
-
|
|
4234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4235
|
+
if (committed) {
|
|
4236
|
+
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
4237
|
+
? { storeFailed: message, pushes: error.pushes }
|
|
4238
|
+
: { threw: message }, maxChars);
|
|
4239
|
+
}
|
|
4240
|
+
// A set that threw and couldn't be read back may have stored the edits,
|
|
4241
|
+
// and a second call could then apply them twice.
|
|
4242
|
+
if (error instanceof CommitUnknownError) {
|
|
4243
|
+
const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
|
|
4244
|
+
return {
|
|
4245
|
+
success: false,
|
|
4246
|
+
isError: true,
|
|
4247
|
+
error: tool === 'edit_many'
|
|
4248
|
+
? `The edit_many call can't tell whether it stored its edits: the store failed as it set them (${set}), and reading them back failed too (${read}). `
|
|
4249
|
+
+ 'Read its files before sending it again: a second call can apply its edits twice.'
|
|
4250
|
+
: `Whether the edit of "${[...targets.values()][0]!.path}" is stored isn't known: the store failed as it set it (${set}), and reading it back failed too (${read}). `
|
|
4251
|
+
+ 'Read the file before sending the edit again: a second edit can apply the change twice.',
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
4254
|
+
// The commit sets every entry in one record or none, and a set that
|
|
4255
|
+
// threw was read back, so nothing is stored; or every change was one
|
|
4256
|
+
// the tree already held, which leaves the tree as it was either way.
|
|
4257
|
+
if (tool === 'edit_many') return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
|
|
4258
|
+
throw error;
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4262
|
+
/**
|
|
4263
|
+
* Where an edit's path is in its mount, or why the call can't edit it: a
|
|
4264
|
+
* path no mount holds, one outside its mount or holding a lone surrogate
|
|
4265
|
+
* (parsePath), one a nested mount holds or that leads into one through a
|
|
4266
|
+
* link (parseFilePath), a path that names its mount's root, a read-only
|
|
4267
|
+
* mount, or for edit_many a generated file. edit's
|
|
4268
|
+
* generated file is refused before it gets here, by the same rule
|
|
4269
|
+
* (handleToolCall checks a call's `path`, which edit_many's input lacks).
|
|
4270
|
+
*/
|
|
4271
|
+
private async editablePath(
|
|
4272
|
+
tool: EditTool,
|
|
4273
|
+
path: string,
|
|
4274
|
+
callerAgentName: string | undefined,
|
|
4275
|
+
): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
|
|
4276
|
+
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: true });
|
|
4277
|
+
try {
|
|
4278
|
+
// In the one spelling every file tool reads, as disk spells it, so two
|
|
4279
|
+
// spellings of a file are one entry and, in one call, one plan
|
|
4280
|
+
// (parsePathAsGiven); and a path in a mount nested inside this one is
|
|
4281
|
+
// that mount's, which every file tool refuses here (parseFilePath).
|
|
4282
|
+
const where = await this.parseFilePath(path);
|
|
4283
|
+
// One that names the mount's root, its bare name included, names no
|
|
4284
|
+
// file.
|
|
4285
|
+
if (where.relativePath === '') return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
|
|
4286
|
+
if (where.mount.config.mode === 'read-only') return unusable(`Mount "${where.mount.config.name}" is read-only`);
|
|
4287
|
+
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4288
|
+
if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
|
|
4289
|
+
return where;
|
|
4290
|
+
} catch (error) {
|
|
4291
|
+
return unusable(error instanceof Error ? error.message : String(error));
|
|
3218
4292
|
}
|
|
3219
4293
|
}
|
|
3220
4294
|
|
|
4295
|
+
/**
|
|
4296
|
+
* Why a call can't change a generated file: another agent's is that agent's,
|
|
4297
|
+
* and the agent's own is read-only, since it shows the agent's tool
|
|
4298
|
+
* presentation, which set_tool_visibility and set_tool_description change.
|
|
4299
|
+
*/
|
|
4300
|
+
private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4301
|
+
return callerAgentName !== generated.agentName
|
|
4302
|
+
? 'Generated file belongs to another agent'
|
|
4303
|
+
: 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
/**
|
|
4307
|
+
* The answer to edits stored but not finished: the commit set every file's
|
|
4308
|
+
* entry, and then disk refused some of their pushes, or the store failed
|
|
4309
|
+
* after recording them, so their push didn't run, or the rest of the call
|
|
4310
|
+
* failed. It stays an error, since part of the call failed, and leads with
|
|
4311
|
+
* what is stored, which must not be sent again: a second call can apply
|
|
4312
|
+
* those edits twice. edit's is the answer write and delete give, one path's;
|
|
4313
|
+
* edit_many's lists the files disk refused, each with its push's reason,
|
|
4314
|
+
* notes each file disk took with its mode left as disk had it, as the
|
|
4315
|
+
* success answer's `modeKept` does, within `maxChars`, and never says to
|
|
4316
|
+
* send the call again. Where the store failed after recording the edits,
|
|
4317
|
+
* which stand as on a success, it names each conflict the success answer
|
|
4318
|
+
* would (`conflicts`, found only where the mount doesn't push).
|
|
4319
|
+
*/
|
|
4320
|
+
private editsStoredButUnfinished(
|
|
4321
|
+
tool: EditTool,
|
|
4322
|
+
targets: EditTarget[],
|
|
4323
|
+
applied: Map<EditTarget, string | null>,
|
|
4324
|
+
conflicts: Map<EditTarget, string>,
|
|
4325
|
+
outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
4326
|
+
maxChars: number,
|
|
4327
|
+
): ToolResult {
|
|
4328
|
+
const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4329
|
+
if (tool === 'edit') {
|
|
4330
|
+
const [target] = targets;
|
|
4331
|
+
const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
|
|
4332
|
+
return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
|
|
4333
|
+
}
|
|
4334
|
+
const stored = "Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.";
|
|
4335
|
+
// The head is never cut, so what stands for the whole call goes in it,
|
|
4336
|
+
// and the parts are the files disk refused, each with its push's reason.
|
|
4337
|
+
let head: string;
|
|
4338
|
+
const parts: string[] = [];
|
|
4339
|
+
if ('refused' in outcome) {
|
|
4340
|
+
const files = targets.length;
|
|
4341
|
+
const refused = outcome.refused.size;
|
|
4342
|
+
head = `${stored}\n\n${files === 1
|
|
4343
|
+
? "Disk didn't take the call's one file. Its push didn't complete:"
|
|
4344
|
+
: `Disk took ${files - refused} of the call's ${files} files. ${refused === 1
|
|
4345
|
+
? "This file's push didn't complete:"
|
|
4346
|
+
: "These files' pushes didn't complete, each with its reason:"}`}`;
|
|
4347
|
+
for (const target of targets) {
|
|
4348
|
+
const reason = outcome.refused.get(target.relativePath);
|
|
4349
|
+
if (reason !== undefined) parts.push(`${target.path}: ${reason}`);
|
|
4350
|
+
}
|
|
4351
|
+
} else if ('storeFailed' in outcome) {
|
|
4352
|
+
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4353
|
+
+ (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
|
|
4354
|
+
} else {
|
|
4355
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
|
|
4356
|
+
}
|
|
4357
|
+
const kept = 'modeKept' in outcome
|
|
4358
|
+
? targets.flatMap((target) => {
|
|
4359
|
+
const reason = outcome.modeKept.get(target.relativePath);
|
|
4360
|
+
return reason === undefined ? [] : [`Disk took ${target.path}: ${reason}.`];
|
|
4361
|
+
})
|
|
4362
|
+
: [];
|
|
4363
|
+
const notes = [
|
|
4364
|
+
...kept,
|
|
4365
|
+
...targets.flatMap((target) => [applied.get(target) ?? null, conflictOf(target)]).filter((note): note is string => !!note),
|
|
4366
|
+
];
|
|
4367
|
+
return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
|
|
4368
|
+
}
|
|
4369
|
+
|
|
3221
4370
|
private async handleDelete(input: DeleteInput): Promise<ToolResult> {
|
|
3222
4371
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3223
4372
|
input = { ...input, path };
|
|
@@ -3250,70 +4399,164 @@ export class WorkspaceModule implements Module {
|
|
|
3250
4399
|
});
|
|
3251
4400
|
} catch (error) {
|
|
3252
4401
|
if (!committed) throw error;
|
|
4402
|
+
if (error instanceof StoredThenFailed) {
|
|
4403
|
+
// Its note, as the success answer's: a deletion that cleared a
|
|
4404
|
+
// conflict naming a disk file unlinked nothing.
|
|
4405
|
+
const note = error.notes.get(relativePath);
|
|
4406
|
+
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
|
|
4407
|
+
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
|
|
4408
|
+
}
|
|
3253
4409
|
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
|
|
3254
4410
|
}
|
|
3255
4411
|
}
|
|
3256
4412
|
|
|
3257
4413
|
private async handleLs(input: LsInput): Promise<ToolResult> {
|
|
3258
|
-
const store = this.getStore();
|
|
3259
|
-
|
|
3260
4414
|
if (!input.path) {
|
|
3261
|
-
// List all mounts
|
|
4415
|
+
// List all mounts, with the kind a mount configured direct runs as
|
|
4416
|
+
// this session and the ignore list it walks by, whose default no
|
|
4417
|
+
// config shows, and each with its include where it has one, so the
|
|
4418
|
+
// files a mount holds show before a path outside them is refused.
|
|
3262
4419
|
const mounts = [...this.mounts.entries()].map(([name, m]) => ({
|
|
3263
4420
|
name,
|
|
3264
4421
|
path: m.config.path,
|
|
3265
4422
|
mode: m.config.mode,
|
|
4423
|
+
...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
|
|
4424
|
+
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
3266
4425
|
}));
|
|
3267
4426
|
return { success: true, data: { mounts } };
|
|
3268
4427
|
}
|
|
3269
4428
|
|
|
3270
|
-
const { mount, relativePath, path } = await this.parsePath(input.path);
|
|
4429
|
+
const { mount, relativePath, path, moved, root } = await this.parsePath(input.path);
|
|
4430
|
+
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
4431
|
+
// is refused, as glob refuses it, and shown as given.
|
|
4432
|
+
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4433
|
+
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
4434
|
+
}
|
|
3271
4435
|
input = { ...input, path };
|
|
3272
4436
|
const recursive = input.recursive === true;
|
|
3273
4437
|
|
|
3274
4438
|
// Every listing checks the paths it shows against disk first, under the
|
|
3275
|
-
// three-way rule, whatever the mount's watch mode
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
if (recursive) {
|
|
3282
|
-
return {
|
|
3283
|
-
success: true,
|
|
3284
|
-
data: {
|
|
3285
|
-
path: input.path,
|
|
3286
|
-
entries: reports.map((r) => ({ path: r.path, ...stateView(r) })),
|
|
3287
|
-
count: reports.length,
|
|
3288
|
-
...incomplete,
|
|
3289
|
-
},
|
|
3290
|
-
};
|
|
3291
|
-
}
|
|
3292
|
-
|
|
3293
|
-
// Non-recursive: the scope's files, and the directories beneath it that
|
|
3294
|
-
// disk or the workspace has.
|
|
3295
|
-
const prefix = relativePath ? relativePath + '/' : '';
|
|
3296
|
-
const dirNames = new Set(pass.dirs.map((d) => d.slice(prefix.length)));
|
|
3297
|
-
for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
|
|
3298
|
-
const slash = entry.path.indexOf('/', prefix.length);
|
|
3299
|
-
if (slash >= 0) dirNames.add(entry.path.slice(prefix.length, slash));
|
|
3300
|
-
}
|
|
3301
|
-
const children: Array<Record<string, unknown>> = [
|
|
3302
|
-
...[...dirNames].sort().map((name) => ({ name, type: 'directory' })),
|
|
3303
|
-
...reports.map((r) => ({ name: r.path.slice(prefix.length), type: 'file', ...stateView(r) })),
|
|
3304
|
-
];
|
|
4439
|
+
// three-way rule, whatever the mount's watch mode; a direct mount's
|
|
4440
|
+
// shows disk's.
|
|
4441
|
+
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
4442
|
+
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
4443
|
+
if (recursive) {
|
|
3305
4444
|
return {
|
|
3306
4445
|
success: true,
|
|
3307
4446
|
data: {
|
|
3308
4447
|
path: input.path,
|
|
3309
|
-
entries:
|
|
3310
|
-
count:
|
|
4448
|
+
entries: listing.files,
|
|
4449
|
+
count: listing.files.length,
|
|
3311
4450
|
...incomplete,
|
|
3312
4451
|
},
|
|
3313
4452
|
};
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
// Non-recursive: the scope's files, and the directories beneath it.
|
|
4456
|
+
const prefix = relativePath ? relativePath + '/' : '';
|
|
4457
|
+
const children: Array<Record<string, unknown>> = [
|
|
4458
|
+
...listing.dirs.map((d) => d.slice(prefix.length)).sort().map((name) => ({ name, type: 'directory' })),
|
|
4459
|
+
...listing.files.map(({ path: file, ...view }) => ({ name: file.slice(prefix.length), type: 'file', ...view })),
|
|
4460
|
+
];
|
|
4461
|
+
return {
|
|
4462
|
+
success: true,
|
|
4463
|
+
data: {
|
|
4464
|
+
path: input.path,
|
|
4465
|
+
entries: children,
|
|
4466
|
+
count: children.length,
|
|
4467
|
+
...incomplete,
|
|
4468
|
+
},
|
|
4469
|
+
};
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
/**
|
|
4473
|
+
* What a listing shows of a directory scope, from where the mount keeps
|
|
4474
|
+
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
4475
|
+
* direct mount, disk (listingFromDisk). `keep` chooses the files shown.
|
|
4476
|
+
*/
|
|
4477
|
+
private listingOf(mount: MountState, dir: string, recursive: boolean, keep?: (path: string) => boolean): Promise<Listing> {
|
|
4478
|
+
return mount.direct ? this.listingFromDisk(mount, dir, recursive, keep) : this.listingFromStore(mount, dir, recursive, keep);
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
/**
|
|
4482
|
+
* A 'chronicle' mount's listing: a pass over the scope under the three-way
|
|
4483
|
+
* rule, each path it decided with its state, and the directories beneath
|
|
4484
|
+
* the scope that disk or the workspace has.
|
|
4485
|
+
*/
|
|
4486
|
+
private listingFromStore(mount: MountState, dir: string, recursive: boolean, keep?: (path: string) => boolean): Promise<Listing> {
|
|
4487
|
+
const store = this.getStore();
|
|
4488
|
+
return this.withMount(mount, async (): Promise<Listing> => {
|
|
4489
|
+
const pass = await this.passUnlocked(mount, { kind: 'dir', dir, recursive });
|
|
4490
|
+
const files = [...pass.reports.values()]
|
|
4491
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
4492
|
+
.filter((report) => keep === undefined || keep(report.path))
|
|
4493
|
+
.map((report) => ({ path: report.path, ...stateView(report) }));
|
|
4494
|
+
if (recursive) return { files, dirs: [], incomplete: pass.incomplete };
|
|
4495
|
+
// Read with the pass's reports, on the branch it decided on, before
|
|
4496
|
+
// anything else takes the mount's turn.
|
|
4497
|
+
const dirs = new Set(pass.dirs);
|
|
4498
|
+
const prefix = dir ? dir + '/' : '';
|
|
4499
|
+
// History implies a directory only where it's the mount's own to name
|
|
4500
|
+
// (withinIncludes): not history in a nested mount's directory that
|
|
4501
|
+
// that mount's include leaves out.
|
|
4502
|
+
const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
|
|
4503
|
+
for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
|
|
4504
|
+
if (!withinIncludes(own, entry.path)) continue;
|
|
4505
|
+
const slash = entry.path.indexOf('/', prefix.length);
|
|
4506
|
+
if (slash >= 0) dirs.add(entry.path.slice(0, slash));
|
|
4507
|
+
}
|
|
4508
|
+
return { files, dirs: [...dirs], incomplete: pass.incomplete };
|
|
3314
4509
|
});
|
|
3315
4510
|
}
|
|
3316
4511
|
|
|
4512
|
+
/**
|
|
4513
|
+
* A direct mount's listing: its walk of the scope (diskWalk), with each
|
|
4514
|
+
* entry it took as disk has it now, observed as a pass observes it: a
|
|
4515
|
+
* file's size, or why the mount doesn't take it (a link it doesn't follow,
|
|
4516
|
+
* one that leaves the mount, what isn't a regular file). A file gone since
|
|
4517
|
+
* the walk listed it is no longer there to show.
|
|
4518
|
+
*/
|
|
4519
|
+
private async listingFromDisk(mount: MountState, dir: string, recursive: boolean, keep?: (path: string) => boolean): Promise<Listing> {
|
|
4520
|
+
const { view, rootReal, taken, dirs, incomplete } = await this.diskWalk(mount, dir, recursive);
|
|
4521
|
+
const files: Listing['files'] = [];
|
|
4522
|
+
await inOrder(taken.filter((path) => keep === undefined || keep(path)), DIRECT_READS_AT_ONCE, (path) => observePath(view, rootReal, path), (seen, path) => {
|
|
4523
|
+
if (seen.kind === 'file') files.push({ path, size: seen.size });
|
|
4524
|
+
else if (seen.kind === 'unobserved') files.push({ path, note: seen.reason });
|
|
4525
|
+
});
|
|
4526
|
+
return { files, dirs, incomplete };
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
/**
|
|
4530
|
+
* A direct mount's walk of a scope, from its view (diskView): the entries
|
|
4531
|
+
* it took that aren't directories (files, links and the like), in path
|
|
4532
|
+
* order; the directories directly beneath a scope it doesn't descend; and
|
|
4533
|
+
* the regions it couldn't see that a pass reports beside no tracked file:
|
|
4534
|
+
* what it couldn't list, what the cap left, and a scope in another mount's
|
|
4535
|
+
* directory, said as a listing says it. A path the walk names is walked
|
|
4536
|
+
* whatever the mount's ignore list or an editor's file name covers there
|
|
4537
|
+
* (MountConfig.ignore), as a path sync's walk is.
|
|
4538
|
+
*/
|
|
4539
|
+
private async diskWalk(mount: MountState, dir: string, recursive: boolean): Promise<{
|
|
4540
|
+
view: MountView; rootReal: string; taken: string[]; dirs: string[]; incomplete: Array<{ path: string; reason: string }>;
|
|
4541
|
+
}> {
|
|
4542
|
+
const { view, rootReal, moved } = await this.diskView(mount);
|
|
4543
|
+
const walk = await walkScope(view, rootReal, dir, recursive, mount.config.walkCap ?? WALK_CAP, { named: true });
|
|
4544
|
+
const incomplete = walk.incomplete
|
|
4545
|
+
.filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
|
|
4546
|
+
.map((region) => ({
|
|
4547
|
+
path: region.path,
|
|
4548
|
+
reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved) : region.reason,
|
|
4549
|
+
}));
|
|
4550
|
+
return {
|
|
4551
|
+
view,
|
|
4552
|
+
rootReal,
|
|
4553
|
+
taken: [...walk.files, ...walk.othersTaken].sort((a, b) => a.localeCompare(b)),
|
|
4554
|
+
// A walk that doesn't descend sees the scope's own subdirectories alone.
|
|
4555
|
+
dirs: recursive ? [] : [...walk.dirs],
|
|
4556
|
+
incomplete,
|
|
4557
|
+
};
|
|
4558
|
+
}
|
|
4559
|
+
|
|
3317
4560
|
private async handleGlob(input: GlobInput): Promise<ToolResult> {
|
|
3318
4561
|
const regex = globToRegex(input.pattern);
|
|
3319
4562
|
const matches: Array<Record<string, unknown>> = [];
|
|
@@ -3322,7 +4565,16 @@ export class WorkspaceModule implements Module {
|
|
|
3322
4565
|
// Search across mounts
|
|
3323
4566
|
const mountsToSearch = input.path
|
|
3324
4567
|
? [await this.parsePath(input.path)]
|
|
3325
|
-
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
4568
|
+
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '', moved: null, root: resolve(m.config.path) }));
|
|
4569
|
+
// The path is a directory to search in. One outside the mount's include
|
|
4570
|
+
// that's no scope (refusesAsOutside) is refused, as grep refuses it,
|
|
4571
|
+
// rather than searched and reported as matching nothing.
|
|
4572
|
+
if (input.path) {
|
|
4573
|
+
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
4574
|
+
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4575
|
+
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
4576
|
+
}
|
|
4577
|
+
}
|
|
3326
4578
|
|
|
3327
4579
|
// Every match lies beneath the pattern's literal directory, so the walk
|
|
3328
4580
|
// starts there: past the walk's cap, a walk of the whole scope can stop
|
|
@@ -3330,15 +4582,10 @@ export class WorkspaceModule implements Module {
|
|
|
3330
4582
|
const patternDir = literalDirOf(input.pattern);
|
|
3331
4583
|
for (const { mount, relativePath } of mountsToSearch) {
|
|
3332
4584
|
const start = await this.walkStart(mount, relativePath, patternDir);
|
|
3333
|
-
const
|
|
3334
|
-
|
|
3335
|
-
for (const
|
|
3336
|
-
|
|
3337
|
-
if (regex.test(testPath)) {
|
|
3338
|
-
matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) });
|
|
3339
|
-
}
|
|
3340
|
-
}
|
|
3341
|
-
for (const region of pass.incomplete) {
|
|
4585
|
+
const matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
|
|
4586
|
+
const listing = await this.listingOf(mount, start, true, matched);
|
|
4587
|
+
for (const { path, ...view } of listing.files) matches.push({ path: `${mount.config.name}/${path}`, ...view });
|
|
4588
|
+
for (const region of listing.incomplete) {
|
|
3342
4589
|
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
3343
4590
|
}
|
|
3344
4591
|
}
|
|
@@ -3355,7 +4602,6 @@ export class WorkspaceModule implements Module {
|
|
|
3355
4602
|
}
|
|
3356
4603
|
|
|
3357
4604
|
private async handleGrep(input: GrepInput): Promise<ToolResult> {
|
|
3358
|
-
const store = this.getStore();
|
|
3359
4605
|
let regex: RegExp;
|
|
3360
4606
|
try {
|
|
3361
4607
|
regex = new RegExp(input.pattern);
|
|
@@ -3374,11 +4620,19 @@ export class WorkspaceModule implements Module {
|
|
|
3374
4620
|
|
|
3375
4621
|
const mountsToSearch = input.path
|
|
3376
4622
|
? [await this.parsePath(input.path)]
|
|
3377
|
-
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
4623
|
+
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '', moved: null, root: resolve(m.config.path) }));
|
|
4624
|
+
// A path outside the mount's include that's no scope is refused, as read
|
|
4625
|
+
// refuses it, rather than searched and reported as holding no match.
|
|
4626
|
+
if (input.path) {
|
|
4627
|
+
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
4628
|
+
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4629
|
+
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
3378
4632
|
|
|
3379
4633
|
type GrepMatch = { line: number; text: string; context?: string[] };
|
|
3380
|
-
const results: Array<{ file: string; state
|
|
3381
|
-
const skipped:
|
|
4634
|
+
const results: Array<{ file: string; state?: string; version: GrepText['version']; matches: GrepMatch[] }> = [];
|
|
4635
|
+
const skipped: GrepSkip[] = [];
|
|
3382
4636
|
const incomplete: Array<{ path: string; reason: string }> = [];
|
|
3383
4637
|
|
|
3384
4638
|
const search = (content: string): GrepMatch[] => {
|
|
@@ -3398,12 +4652,13 @@ export class WorkspaceModule implements Module {
|
|
|
3398
4652
|
return found;
|
|
3399
4653
|
};
|
|
3400
4654
|
|
|
3401
|
-
for (const { mount, relativePath } of mountsToSearch) {
|
|
4655
|
+
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
3402
4656
|
// A path named in another mount's directory, as holderOf resolves it,
|
|
3403
4657
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
3404
|
-
// says whose it is.
|
|
3405
|
-
|
|
3406
|
-
|
|
4658
|
+
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
4659
|
+
// found where its root lies now (movedNesting).
|
|
4660
|
+
if (this.holderOf(mount.config.name, relativePath, false, moved)) {
|
|
4661
|
+
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
3407
4662
|
continue;
|
|
3408
4663
|
}
|
|
3409
4664
|
// A `path` and a filter whose directories don't overlap leave no file
|
|
@@ -3411,110 +4666,16 @@ export class WorkspaceModule implements Module {
|
|
|
3411
4666
|
const deeper = deeperOf(relativePath, filterDir);
|
|
3412
4667
|
if (deeper === null) continue;
|
|
3413
4668
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
);
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
// on: one selected in between would label its content with the other
|
|
3425
|
-
// branch's states. So a pass whose branch is no longer selected runs
|
|
3426
|
-
// again, and a branch that keeps changing leaves the scope unsearched.
|
|
3427
|
-
//
|
|
3428
|
-
// A copy held at a path that now leads into a nested mount's directory
|
|
3429
|
-
// through a link is that mount's file too: read refuses it
|
|
3430
|
-
// (parseFilePath), so grep searches none of it and says why. The pass
|
|
3431
|
-
// can't check such a path's disk, so only an unverified one can be
|
|
3432
|
-
// one. Asking awaits disk, so it's done before the branch is
|
|
3433
|
-
// compared, and a branch selected meanwhile runs the pass again.
|
|
3434
|
-
let pass: PassResult | null = null;
|
|
3435
|
-
let refused = new Map<string, string>();
|
|
3436
|
-
for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
|
|
3437
|
-
const next = await this.passUnlocked(mount, fileScope
|
|
3438
|
-
? { kind: 'paths', paths: [relativePath] }
|
|
3439
|
-
: { kind: 'dir', dir: start, recursive: true });
|
|
3440
|
-
const asked = new Map<string, string>();
|
|
3441
|
-
for (const report of next.reports.values()) {
|
|
3442
|
-
if (report.state !== 'unverified') continue;
|
|
3443
|
-
const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
|
|
3444
|
-
if (refusal) asked.set(report.path, refusal.message);
|
|
3445
|
-
}
|
|
3446
|
-
if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
|
|
3447
|
-
pass = next;
|
|
3448
|
-
refused = asked;
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
if (pass === null) {
|
|
3452
|
-
incomplete.push({
|
|
3453
|
-
path: `${mount.config.name}/${relativePath}`,
|
|
3454
|
-
reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
|
|
3455
|
-
});
|
|
3456
|
-
return;
|
|
3457
|
-
}
|
|
3458
|
-
for (const region of pass.incomplete) {
|
|
3459
|
-
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
3460
|
-
}
|
|
3461
|
-
|
|
3462
|
-
const intents = this.intents.get(mount.config.name)!;
|
|
3463
|
-
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
3464
|
-
if (fileGlob && !fileGlob.test(report.path)) continue;
|
|
3465
|
-
const file = `${mount.config.name}/${report.path}`;
|
|
3466
|
-
if (report.state === 'disk-only') {
|
|
3467
|
-
skipped.push({
|
|
3468
|
-
file,
|
|
3469
|
-
reason: 'disk-only: binary or over the size limit, never stored',
|
|
3470
|
-
...(report.size !== undefined ? { size: report.size } : {}),
|
|
3471
|
-
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
3472
|
-
});
|
|
3473
|
-
continue;
|
|
3474
|
-
}
|
|
3475
|
-
// A path in another mount's directory is that mount's: grep gives
|
|
3476
|
-
// none of this mount's copy of it, as read refuses it. Beneath a
|
|
3477
|
-
// scope it's passed over by design, like the directory.
|
|
3478
|
-
if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
|
|
3479
|
-
const refusal = refused.get(report.path);
|
|
3480
|
-
if (refusal !== undefined) {
|
|
3481
|
-
skipped.push({ file, reason: refusal });
|
|
3482
|
-
continue;
|
|
3483
|
-
}
|
|
3484
|
-
// The workspace's version, labelled with the path's state.
|
|
3485
|
-
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
3486
|
-
const blob = entry ? store.getBlob(entry.blobHash) : null;
|
|
3487
|
-
if (blob) {
|
|
3488
|
-
const found = search(blob.toString('utf-8'));
|
|
3489
|
-
if (found.length > 0) results.push({ file, state: report.state, version: 'workspace', matches: found });
|
|
3490
|
-
} else if (entry) {
|
|
3491
|
-
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
3492
|
-
}
|
|
3493
|
-
if (report.state !== 'conflict') continue;
|
|
3494
|
-
// A conflict's disk side. The version recorded with the conflict is
|
|
3495
|
-
// disk's only while disk still holds it; once disk has changed
|
|
3496
|
-
// since, it is labelled as recorded, and a file disk holds now
|
|
3497
|
-
// (never stored) is listed as not searched. A binary or oversize
|
|
3498
|
-
// disk version was never stored either.
|
|
3499
|
-
const disk = intents.get(report.path)?.conflict?.disk;
|
|
3500
|
-
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
3501
|
-
const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
|
|
3502
|
-
if (diskBlob) {
|
|
3503
|
-
const found = search(diskBlob.toString('utf-8'));
|
|
3504
|
-
if (found.length > 0) results.push({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', matches: found });
|
|
3505
|
-
}
|
|
3506
|
-
if (disk?.stored && !diskBlob) {
|
|
3507
|
-
skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
|
|
3508
|
-
}
|
|
3509
|
-
if (changed) {
|
|
3510
|
-
if (report.diskNow === 'file') {
|
|
3511
|
-
skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
|
|
3512
|
-
}
|
|
3513
|
-
} else if (disk && !disk.stored) {
|
|
3514
|
-
skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
|
|
3515
|
-
}
|
|
3516
|
-
}
|
|
3517
|
-
});
|
|
4669
|
+
const searched = (text: GrepText): void => {
|
|
4670
|
+
const matches = search(text.text);
|
|
4671
|
+
if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
|
|
4672
|
+
};
|
|
4673
|
+
const kept = (path: string): boolean => fileGlob === null || fileGlob.test(path);
|
|
4674
|
+
const found = mount.direct
|
|
4675
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
4676
|
+
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
4677
|
+
skipped.push(...found.skipped);
|
|
4678
|
+
incomplete.push(...found.incomplete);
|
|
3518
4679
|
}
|
|
3519
4680
|
|
|
3520
4681
|
return {
|
|
@@ -3529,6 +4690,169 @@ export class WorkspaceModule implements Module {
|
|
|
3529
4690
|
};
|
|
3530
4691
|
}
|
|
3531
4692
|
|
|
4693
|
+
/**
|
|
4694
|
+
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
4695
|
+
* single file `relativePath` names, those `keep` lets through, each handed
|
|
4696
|
+
* to `each` in path order: a pass over the scope, then each path's
|
|
4697
|
+
* workspace copy, labelled with its state, and a conflict's disk side.
|
|
4698
|
+
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
4699
|
+
* `incomplete`, each mount-prefixed.
|
|
4700
|
+
*/
|
|
4701
|
+
private async textsFromStore(
|
|
4702
|
+
mount: MountState,
|
|
4703
|
+
relativePath: string,
|
|
4704
|
+
start: string,
|
|
4705
|
+
keep: (path: string) => boolean,
|
|
4706
|
+
each: (text: GrepText) => void,
|
|
4707
|
+
): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
|
|
4708
|
+
const store = this.getStore();
|
|
4709
|
+
const skipped: GrepSkip[] = [];
|
|
4710
|
+
const incomplete: Array<{ path: string; reason: string }> = [];
|
|
4711
|
+
await this.withMount(mount, async () => {
|
|
4712
|
+
// A `path` naming a single file greps just that file; otherwise it is a
|
|
4713
|
+
// directory prefix. (A file path once produced the prefix "notes.md/"
|
|
4714
|
+
// and silently matched nothing.)
|
|
4715
|
+
const fileScope = await this.namesFile(mount, relativePath, resolve(mount.config.path));
|
|
4716
|
+
// What follows reads the tree and branch intent to go with the pass's
|
|
4717
|
+
// reports, synchronously. They must be the branch the pass decided
|
|
4718
|
+
// on: one selected in between would label its content with the other
|
|
4719
|
+
// branch's states. So a pass whose branch is no longer selected runs
|
|
4720
|
+
// again, and a branch that keeps changing leaves the scope unsearched.
|
|
4721
|
+
//
|
|
4722
|
+
// A copy held at a path that now leads into a nested mount's directory
|
|
4723
|
+
// through a link is that mount's file too, and one that now lands on a
|
|
4724
|
+
// file this mount's include leaves out is no file of this mount's:
|
|
4725
|
+
// read refuses either (parseFilePath), so grep searches none of it and
|
|
4726
|
+
// says why. The pass
|
|
4727
|
+
// can't check such a path's disk, so only an unverified one can be
|
|
4728
|
+
// one. Asking awaits disk, so it's done before the branch is
|
|
4729
|
+
// compared, and a branch selected meanwhile runs the pass again.
|
|
4730
|
+
let pass: PassResult | null = null;
|
|
4731
|
+
let refused = new Map<string, string>();
|
|
4732
|
+
for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
|
|
4733
|
+
const next = await this.passUnlocked(mount, fileScope
|
|
4734
|
+
? { kind: 'paths', paths: [relativePath] }
|
|
4735
|
+
: { kind: 'dir', dir: start, recursive: true });
|
|
4736
|
+
const asked = new Map<string, string>();
|
|
4737
|
+
for (const report of next.reports.values()) {
|
|
4738
|
+
if (report.state !== 'unverified') continue;
|
|
4739
|
+
const refusal = await this.landingRefusal(mount, report.path, `${mount.config.name}/${report.path}`, this.nestedOf(mount.config.name), resolve(mount.config.path));
|
|
4740
|
+
if (refusal) asked.set(report.path, refusal.message);
|
|
4741
|
+
}
|
|
4742
|
+
if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
|
|
4743
|
+
pass = next;
|
|
4744
|
+
refused = asked;
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
if (pass === null) {
|
|
4748
|
+
incomplete.push({
|
|
4749
|
+
path: `${mount.config.name}/${relativePath}`,
|
|
4750
|
+
reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
|
|
4751
|
+
});
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
for (const region of pass.incomplete) {
|
|
4755
|
+
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
const intents = this.intents.get(mount.config.name)!;
|
|
4759
|
+
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
4760
|
+
if (!keep(report.path)) continue;
|
|
4761
|
+
const file = `${mount.config.name}/${report.path}`;
|
|
4762
|
+
if (report.state === 'disk-only') {
|
|
4763
|
+
skipped.push({
|
|
4764
|
+
file,
|
|
4765
|
+
reason: 'disk-only: binary or over the size limit, never stored',
|
|
4766
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
4767
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
4768
|
+
});
|
|
4769
|
+
continue;
|
|
4770
|
+
}
|
|
4771
|
+
// A path in another mount's directory is that mount's: grep gives
|
|
4772
|
+
// none of this mount's copy of it, as read refuses it. Beneath a
|
|
4773
|
+
// scope it's passed over by design, like the directory.
|
|
4774
|
+
if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
|
|
4775
|
+
const refusal = refused.get(report.path);
|
|
4776
|
+
if (refusal !== undefined) {
|
|
4777
|
+
skipped.push({ file, reason: refusal });
|
|
4778
|
+
continue;
|
|
4779
|
+
}
|
|
4780
|
+
// The workspace's version, labelled with the path's state.
|
|
4781
|
+
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
4782
|
+
const blob = entry ? store.getBlob(entry.blobHash) : null;
|
|
4783
|
+
if (blob) {
|
|
4784
|
+
each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
|
|
4785
|
+
} else if (entry) {
|
|
4786
|
+
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
4787
|
+
}
|
|
4788
|
+
if (report.state !== 'conflict') continue;
|
|
4789
|
+
// A conflict's disk side. The version recorded with the conflict is
|
|
4790
|
+
// disk's only while disk still holds it; once disk has changed
|
|
4791
|
+
// since, it is labelled as recorded, and a file disk holds now
|
|
4792
|
+
// (never stored) is listed as not searched. A binary or oversize
|
|
4793
|
+
// disk version was never stored either.
|
|
4794
|
+
const disk = intents.get(report.path)?.conflict?.disk;
|
|
4795
|
+
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
4796
|
+
const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
|
|
4797
|
+
if (diskBlob) {
|
|
4798
|
+
each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
|
|
4799
|
+
}
|
|
4800
|
+
if (disk?.stored && !diskBlob) {
|
|
4801
|
+
skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
|
|
4802
|
+
}
|
|
4803
|
+
if (changed) {
|
|
4804
|
+
if (report.diskNow === 'file') {
|
|
4805
|
+
skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
|
|
4806
|
+
}
|
|
4807
|
+
} else if (disk && !disk.stored) {
|
|
4808
|
+
skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
});
|
|
4812
|
+
return { skipped, incomplete };
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4815
|
+
/**
|
|
4816
|
+
* The texts a direct mount's grep searches beneath `start`, or in the
|
|
4817
|
+
* single file `relativePath` names, as namesFile judges it beneath `root`,
|
|
4818
|
+
* the root grep's parse resolved, those `keep` lets through, each handed
|
|
4819
|
+
* to `each` in path order: each file its walk took (diskWalk), read from
|
|
4820
|
+
* disk as it is now, DIRECT_READS_AT_ONCE at a time. A file over the size
|
|
4821
|
+
* limit, a binary one and one the mount doesn't take are in `skipped`,
|
|
4822
|
+
* each with why, as a 'chronicle' mount's grep lists what it never stored;
|
|
4823
|
+
* one gone since the walk listed it isn't there to search.
|
|
4824
|
+
*/
|
|
4825
|
+
private async textsFromDisk(
|
|
4826
|
+
mount: MountState,
|
|
4827
|
+
relativePath: string,
|
|
4828
|
+
root: string,
|
|
4829
|
+
start: string,
|
|
4830
|
+
keep: (path: string) => boolean,
|
|
4831
|
+
each: (text: GrepText) => void,
|
|
4832
|
+
): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
|
|
4833
|
+
const name = mount.config.name;
|
|
4834
|
+
const fileScope = await this.namesFile(mount, relativePath, root);
|
|
4835
|
+
const { view, rootReal, taken, incomplete } = fileScope
|
|
4836
|
+
? { ...(await this.diskView(mount)), taken: [relativePath], incomplete: [] }
|
|
4837
|
+
: await this.diskWalk(mount, start, true);
|
|
4838
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4839
|
+
const skipped: GrepSkip[] = [];
|
|
4840
|
+
await inOrder(taken.filter(keep), DIRECT_READS_AT_ONCE, (path) => readBytes(view, rootReal, path, maxSize), (read, path) => {
|
|
4841
|
+
const file = `${name}/${path}`;
|
|
4842
|
+
if (read.kind === 'absent') return;
|
|
4843
|
+
if (read.kind === 'unobserved') {
|
|
4844
|
+
skipped.push({ file, reason: read.reason });
|
|
4845
|
+
return;
|
|
4846
|
+
}
|
|
4847
|
+
const mimeType = sniffImageMime(read.head);
|
|
4848
|
+
const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
|
|
4849
|
+
if (read.bytes === null) skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
|
|
4850
|
+
else if (looksBinary(read.head)) skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
|
|
4851
|
+
else each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
|
|
4852
|
+
});
|
|
4853
|
+
return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
|
|
4854
|
+
}
|
|
4855
|
+
|
|
3532
4856
|
private async handleStatus(input: StatusInput): Promise<ToolResult> {
|
|
3533
4857
|
const store = this.getStore();
|
|
3534
4858
|
const status: Record<string, unknown> = {};
|
|
@@ -3538,25 +4862,30 @@ export class WorkspaceModule implements Module {
|
|
|
3538
4862
|
|
|
3539
4863
|
for (const [name, mount] of this.mounts) {
|
|
3540
4864
|
if (input.mount !== undefined && name !== input.mount) continue;
|
|
3541
|
-
|
|
4865
|
+
if (mount.direct) {
|
|
4866
|
+
// A direct mount keeps nothing to count: its kind, and the ignore
|
|
4867
|
+
// list its walks go by, whose default no config shows.
|
|
4868
|
+
status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
4869
|
+
continue;
|
|
4870
|
+
}
|
|
4871
|
+
// What the workspace's history holds outside the mount's include isn't the mount's,
|
|
4872
|
+
// and nor is what it holds in a nested mount's directory that mount's include leaves
|
|
4873
|
+
// out (withinIncludes), which no listing names either.
|
|
4874
|
+
const listable = { include: mount.config.include, nested: this.nestedOf(name) };
|
|
4875
|
+
const entries = store.treeList(mount.treeStateId).filter((e) => withinIncludes(listable, e.path));
|
|
3542
4876
|
const currentSeq = store.currentSequence();
|
|
3543
|
-
const
|
|
3544
|
-
const
|
|
3545
|
-
const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
|
|
3546
|
-
// A workspace deletion is pending until a push confirms it on disk:
|
|
3547
|
-
// left there without applyDeletions, or unlinked but unconfirmed.
|
|
3548
|
-
const deletionPaths = intents.filter(([, bi]) => bi.tombstone).map(([p]) => p);
|
|
3549
|
-
// Pending is what disk owes by the evidence: every entry it doesn't
|
|
3550
|
-
// show on disk (a refused or failed push stays owed), conflicts and
|
|
3551
|
-
// deletions. An entry from before the evidence isn't counted until a
|
|
3552
|
-
// listing or a materialize checks it: unchecked isn't unpushed.
|
|
3553
|
-
const pending = new Set([...conflictPaths, ...deletionPaths, ...this.entriesByEvidence(mount).owed]);
|
|
4877
|
+
const { conflicts: conflictPaths, deletions: deletionPaths, owed } = this.heldChanges(mount);
|
|
4878
|
+
const pending = new Set([...conflictPaths, ...deletionPaths, ...owed]);
|
|
3554
4879
|
|
|
3555
4880
|
const currentBranch = store.currentBranch();
|
|
3556
4881
|
status[name] = {
|
|
3557
4882
|
path: mount.config.path,
|
|
3558
4883
|
mode: mount.config.mode,
|
|
3559
|
-
|
|
4884
|
+
// A mount configured direct runs as 'chronicle' this session
|
|
4885
|
+
// (chooseKinds), by the direct kind's ignore list where its config
|
|
4886
|
+
// names none.
|
|
4887
|
+
...(mount.config.backend === 'direct' ? { backend: 'chronicle', ignore: this.ignoreOf(mount) } : {}),
|
|
4888
|
+
watch: watchOf(mount.config),
|
|
3560
4889
|
fileCount: entries.length,
|
|
3561
4890
|
lastMaterializedSeq: mount.lastMaterializedSeq,
|
|
3562
4891
|
currentSeq,
|
|
@@ -3574,6 +4903,37 @@ export class WorkspaceModule implements Module {
|
|
|
3574
4903
|
return { success: true, data: status };
|
|
3575
4904
|
}
|
|
3576
4905
|
|
|
4906
|
+
/**
|
|
4907
|
+
* What the store holds of a mount that disk lacks, on the selected branch,
|
|
4908
|
+
* among the mount's own paths. Status counts the first three as pending:
|
|
4909
|
+
* recorded conflicts; workspace deletions, pending until a push confirms
|
|
4910
|
+
* them on disk (left there without applyDeletions, or unlinked but
|
|
4911
|
+
* unconfirmed); and what disk owes by the evidence (entriesByEvidence's
|
|
4912
|
+
* `owed`: every entry it doesn't show on disk, a refused or failed push's
|
|
4913
|
+
* included). An entry from before the evidence isn't among them until a
|
|
4914
|
+
* listing or a materialize checks it: unchecked isn't unpushed. A
|
|
4915
|
+
* tombstone beside an entry that's still there, left by a deletion or a
|
|
4916
|
+
* write that stopped partway (commitToolChanges), is no deletion: it holds
|
|
4917
|
+
* no change disk lacks, and its entry is judged by the evidence as any
|
|
4918
|
+
* other is. The fourth, intents that can't be read, may hold a deletion
|
|
4919
|
+
* or a conflict, so a mount configured direct counts them too (initStore).
|
|
4920
|
+
* Each lies where the mount's include covers, as status counts them: what
|
|
4921
|
+
* the workspace's history holds outside it is no change of the mount's,
|
|
4922
|
+
* and no push writes it to disk (pushUnlocked).
|
|
4923
|
+
*/
|
|
4924
|
+
private heldChanges(mount: MountState): { conflicts: string[]; deletions: string[]; owed: string[]; unreadable: string[] } {
|
|
4925
|
+
const store = this.getStore();
|
|
4926
|
+
const own = this.ownPaths(mount);
|
|
4927
|
+
// What the workspace's history holds outside the mount's include isn't the mount's.
|
|
4928
|
+
const intents = this.intents.get(mount.config.name)!.list().filter(([p]) => insideMount(p, mount.config.include) && own(p));
|
|
4929
|
+
return {
|
|
4930
|
+
conflicts: intents.filter(([, bi]) => bi.conflict).map(([p]) => p),
|
|
4931
|
+
deletions: intents.filter(([p, bi]) => bi.tombstone && !store.treeGet(mount.treeStateId, p)).map(([p]) => p),
|
|
4932
|
+
owed: this.entriesByEvidence(mount).owed,
|
|
4933
|
+
unreadable: intents.filter(([, bi]) => bi.unreadable !== undefined).map(([p]) => p),
|
|
4934
|
+
};
|
|
4935
|
+
}
|
|
4936
|
+
|
|
3577
4937
|
/**
|
|
3578
4938
|
* What a materialize of `mount` should consider: a named file, or every
|
|
3579
4939
|
* tracked path beneath a named directory; otherwise what the evidence says
|
|
@@ -3600,17 +4960,26 @@ export class WorkspaceModule implements Module {
|
|
|
3600
4960
|
|
|
3601
4961
|
const { owed, unchecked, agreed } = this.entriesByEvidence(mount);
|
|
3602
4962
|
for (const path of [...owed, ...unchecked]) selected.add(path);
|
|
3603
|
-
|
|
4963
|
+
// A tombstone is a deletion only where its entry is gone: one beside an
|
|
4964
|
+
// entry that's still there, left by a change that stopped partway
|
|
4965
|
+
// (commitToolChanges), would select an agreed entry, and a forced push
|
|
4966
|
+
// would write it over a newer disk copy. A deletion or conflict held
|
|
4967
|
+
// outside the mount's include isn't the mount's, as status counts it:
|
|
4968
|
+
// not taken up, so not reported skipped.
|
|
4969
|
+
for (const [p, bi] of intents) {
|
|
4970
|
+
if ((bi.conflict || (bi.tombstone && !store.treeGet(mount.treeStateId, p))) && insideMount(p, mount.config.include)) selected.add(p);
|
|
4971
|
+
}
|
|
3604
4972
|
return { paths: [...selected], inPlace: agreed > 0 };
|
|
3605
4973
|
}
|
|
3606
4974
|
|
|
3607
4975
|
/**
|
|
3608
4976
|
* Whether a path of the mount is its own to push and to count as owed:
|
|
3609
4977
|
* not in another mount's directory nested in it. Copies the mount's tree
|
|
3610
|
-
* holds there (stored before the nesting) stay in its history and its
|
|
3611
|
-
* listings
|
|
3612
|
-
*
|
|
3613
|
-
*
|
|
4978
|
+
* holds there (stored before the nesting) stay in its history, and in its
|
|
4979
|
+
* listings and status's fileCount where that mount's include covers them
|
|
4980
|
+
* (withinIncludes). Its syncs pass over them, and
|
|
4981
|
+
* they're left out of what it pushes and of status's pending changes,
|
|
4982
|
+
* conflicts and workspace deletions.
|
|
3614
4983
|
*/
|
|
3615
4984
|
private ownPaths(mount: MountState): (path: string) => boolean {
|
|
3616
4985
|
const others = this.othersOf(mount.config.name);
|
|
@@ -3639,7 +5008,9 @@ export class WorkspaceModule implements Module {
|
|
|
3639
5008
|
let agreed = 0;
|
|
3640
5009
|
const own = this.ownPaths(mount);
|
|
3641
5010
|
for (const e of store.treeList(mount.treeStateId)) {
|
|
3642
|
-
|
|
5011
|
+
// Outside the mount's include, or in another mount's directory nested
|
|
5012
|
+
// in it: neither owed to disk nor counted as on it.
|
|
5013
|
+
if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
|
|
3643
5014
|
const p = agreement.get(name, e.path);
|
|
3644
5015
|
if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
|
|
3645
5016
|
else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
|
|
@@ -3660,7 +5031,16 @@ export class WorkspaceModule implements Module {
|
|
|
3660
5031
|
const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
|
|
3661
5032
|
|
|
3662
5033
|
let explicit: { mount: MountState; relativePath: string } | null = null;
|
|
3663
|
-
if (input.path)
|
|
5034
|
+
if (input.path) {
|
|
5035
|
+
const parsed = await this.parsePath(input.path);
|
|
5036
|
+
explicit = parsed;
|
|
5037
|
+
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5038
|
+
// is refused; beneath a directory, the push leaves out what the include
|
|
5039
|
+
// doesn't cover.
|
|
5040
|
+
if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
|
|
5041
|
+
return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
3664
5044
|
|
|
3665
5045
|
let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
|
|
3666
5046
|
if (explicit) {
|
|
@@ -3686,6 +5066,10 @@ export class WorkspaceModule implements Module {
|
|
|
3686
5066
|
// whatever branch is current when its turn comes, unguarded. The push
|
|
3687
5067
|
// then writes nothing if the branch changes after that.
|
|
3688
5068
|
const blocked: Array<{ mount: string; reason: string }> = [];
|
|
5069
|
+
// What the push left because the mount's include doesn't cover it is
|
|
5070
|
+
// counted, as a sync's passedOver counts it, and not named: no tool names
|
|
5071
|
+
// a file outside the include.
|
|
5072
|
+
const passedOver: Array<{ mount: string; reason: string; count: number }> = [];
|
|
3689
5073
|
const guarded: string[] = [];
|
|
3690
5074
|
let proceeded = 0;
|
|
3691
5075
|
|
|
@@ -3731,10 +5115,13 @@ export class WorkspaceModule implements Module {
|
|
|
3731
5115
|
for (const p of pushed.pendingDeletions) pendingDeletions.push({ mount: name, path: p });
|
|
3732
5116
|
// Refusals ride the same skipped list as branch blocks: divergence must
|
|
3733
5117
|
// be visible, not resolved silently either way.
|
|
5118
|
+
let outside = 0;
|
|
3734
5119
|
for (const s of pushed.skipped) {
|
|
5120
|
+
if (s.reason === OUTSIDE_INCLUDE) { outside++; continue; }
|
|
3735
5121
|
const reason = s.reason === NOT_PUSHED_IN_OTHER_MOUNT ? this.otherMountNote(name, s.path, 'push') : s.reason;
|
|
3736
5122
|
blocked.push({ mount: name, reason: `${s.path}: ${reason}` });
|
|
3737
5123
|
}
|
|
5124
|
+
if (outside > 0) passedOver.push({ mount: name, reason: OUTSIDE_INCLUDE, count: outside });
|
|
3738
5125
|
// Written paths whose mode stayed as disk had it: no refusal, so not
|
|
3739
5126
|
// among what was skipped.
|
|
3740
5127
|
for (const k of pushed.modesKept) modesKept.push({ mount: name, ...k });
|
|
@@ -3780,6 +5167,7 @@ export class WorkspaceModule implements Module {
|
|
|
3780
5167
|
}
|
|
3781
5168
|
: {}),
|
|
3782
5169
|
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
5170
|
+
...(passedOver.length > 0 ? { passedOver } : {}),
|
|
3783
5171
|
...(modesKept.length > 0 ? { modesKept } : {}),
|
|
3784
5172
|
},
|
|
3785
5173
|
// What disk lost, then what it was written: kept in the stub if the
|
|
@@ -3846,7 +5234,13 @@ export class WorkspaceModule implements Module {
|
|
|
3846
5234
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
3847
5235
|
let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
|
|
3848
5236
|
if (input.path) {
|
|
3849
|
-
const { mount, relativePath } = await this.parsePath(input.path);
|
|
5237
|
+
const { mount, relativePath, moved, root } = await this.parsePath(input.path);
|
|
5238
|
+
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5239
|
+
// is refused; beneath a directory, the walk passes over what the include
|
|
5240
|
+
// doesn't cover.
|
|
5241
|
+
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5242
|
+
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5243
|
+
}
|
|
3850
5244
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
3851
5245
|
} else if (input.mount) {
|
|
3852
5246
|
const m = this.mounts.get(input.mount);
|
|
@@ -3859,6 +5253,12 @@ export class WorkspaceModule implements Module {
|
|
|
3859
5253
|
}
|
|
3860
5254
|
|
|
3861
5255
|
for (const { name, mount, relativePath } of targets) {
|
|
5256
|
+
// A direct mount's tools read disk as it is, so a sync has nothing to
|
|
5257
|
+
// take in there, and says it didn't look rather than that nothing changed.
|
|
5258
|
+
if (mount.direct) {
|
|
5259
|
+
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
5260
|
+
continue;
|
|
5261
|
+
}
|
|
3862
5262
|
const pass = await this.withMount(mount, async () => {
|
|
3863
5263
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
|
|
3864
5264
|
if (!relativePath) {
|
|
@@ -3931,7 +5331,7 @@ export class WorkspaceModule implements Module {
|
|
|
3931
5331
|
// without a path passes over it, so nothing else keeps it current
|
|
3932
5332
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
3933
5333
|
// synced at all, and the note says so.
|
|
3934
|
-
const passedOver = passedOverAt(relativePath, mount
|
|
5334
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
3935
5335
|
if (passedOver !== undefined) {
|
|
3936
5336
|
allIgnored.push({
|
|
3937
5337
|
mount: name,
|
|
@@ -3995,11 +5395,14 @@ export class WorkspaceModule implements Module {
|
|
|
3995
5395
|
* The watcher's ops only choose which paths to look at: each path is
|
|
3996
5396
|
* observed again and decided under the three-way rule, so an event that
|
|
3997
5397
|
* arrives late or out of order can't delete or revert anything by itself.
|
|
5398
|
+
* A path outside the mount (insideMount), such as one its include doesn't
|
|
5399
|
+
* cover, is no pass's to decide (gather), so a batch of nothing else takes
|
|
5400
|
+
* no turn of the mount.
|
|
3998
5401
|
*/
|
|
3999
5402
|
private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
|
|
4000
5403
|
const mount = this.mounts.get(mountName);
|
|
4001
5404
|
if (!this.store || !this.running() || !mount) return;
|
|
4002
|
-
const paths = [...new Set(changes.map((c) => c.path))];
|
|
5405
|
+
const paths = [...new Set(changes.map((c) => c.path))].filter((p) => insideMount(p, mount.config.include));
|
|
4003
5406
|
if (paths.length === 0) return;
|
|
4004
5407
|
await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'paths', paths }));
|
|
4005
5408
|
}
|
|
@@ -4020,7 +5423,7 @@ export class WorkspaceModule implements Module {
|
|
|
4020
5423
|
async onToolBatchComplete(_agentName?: string): Promise<void> {
|
|
4021
5424
|
// Every agent's workspace is the store's current branch, so the agent
|
|
4022
5425
|
// that ran the batch doesn't choose what is scanned.
|
|
4023
|
-
const mounts = [...this.mounts.values()].filter((m) => m.config
|
|
5426
|
+
const mounts = [...this.mounts.values()].filter((m) => watchOf(m.config) === 'on-agent-action');
|
|
4024
5427
|
if (mounts.length === 0 || !this.store || !this.running()) return;
|
|
4025
5428
|
const deadlineMs = this.agentActionScanDeadlineMs();
|
|
4026
5429
|
await Promise.all(mounts.map(async (mount) => {
|
|
@@ -4169,6 +5572,72 @@ export class WorkspaceModule implements Module {
|
|
|
4169
5572
|
|
|
4170
5573
|
}
|
|
4171
5574
|
|
|
5575
|
+
/**
|
|
5576
|
+
* A text grep searches (textsFromStore, textsFromDisk): its mount-prefixed
|
|
5577
|
+
* file, which copy it is, and on a 'chronicle' mount, the path's state.
|
|
5578
|
+
*/
|
|
5579
|
+
interface GrepText {
|
|
5580
|
+
file: string;
|
|
5581
|
+
state?: string;
|
|
5582
|
+
version: 'workspace' | 'conflicting-disk' | 'recorded-disk' | 'disk';
|
|
5583
|
+
text: string;
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5586
|
+
/** A file, or a copy of one, grep doesn't search, with why. */
|
|
5587
|
+
type GrepSkip = { file: string; reason: string; size?: number; mimeType?: string };
|
|
5588
|
+
|
|
5589
|
+
/** What a listing shows of a directory scope (listingOf). */
|
|
5590
|
+
interface Listing {
|
|
5591
|
+
/** Each file it shows, by mount-relative path, in order, with what a listing says of it beyond its name. */
|
|
5592
|
+
files: Array<{ path: string } & Record<string, unknown>>;
|
|
5593
|
+
/** For a listing that doesn't descend, the directories directly beneath the scope, mount-relative; none for one that does. */
|
|
5594
|
+
dirs: string[];
|
|
5595
|
+
/** The regions it couldn't see, mount-relative, each with why. */
|
|
5596
|
+
incomplete: Array<{ path: string; reason: string }>;
|
|
5597
|
+
}
|
|
5598
|
+
|
|
5599
|
+
/**
|
|
5600
|
+
* Run `work` on each item, at most `width` at once, and hand each result to
|
|
5601
|
+
* `each` in the items' order, as soon as it and those before it are done.
|
|
5602
|
+
* A result handed on is let go, so what's held at once is at most `width`
|
|
5603
|
+
* results, as a direct grep's file contents are. A `work` that rejects
|
|
5604
|
+
* rejects this when its turn comes; any started after it finish unheard.
|
|
5605
|
+
* Exported for its tests.
|
|
5606
|
+
*/
|
|
5607
|
+
export async function inOrder<T, R>(items: readonly T[], width: number, work: (item: T) => Promise<R>, each: (result: R, item: T) => void): Promise<void> {
|
|
5608
|
+
const waiting: Array<Promise<R>> = [];
|
|
5609
|
+
let started = 0;
|
|
5610
|
+
for (let i = 0; i < items.length; i++) {
|
|
5611
|
+
while (started < items.length && started < i + width) {
|
|
5612
|
+
const running = work(items[started]!);
|
|
5613
|
+
// Handled from the start, so a rejection that comes before its turn
|
|
5614
|
+
// isn't an unhandled one; awaiting it below still throws it.
|
|
5615
|
+
running.catch(() => {});
|
|
5616
|
+
waiting.push(running);
|
|
5617
|
+
started++;
|
|
5618
|
+
}
|
|
5619
|
+
each(await waiting.shift()!, items[i]!);
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
|
|
5623
|
+
/** read_image's answer for an image, wherever it was read from. */
|
|
5624
|
+
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
|
|
5625
|
+
return {
|
|
5626
|
+
success: true,
|
|
5627
|
+
data: [
|
|
5628
|
+
{
|
|
5629
|
+
type: 'text',
|
|
5630
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
5631
|
+
},
|
|
5632
|
+
{
|
|
5633
|
+
type: 'image',
|
|
5634
|
+
data: image.bytes.toString('base64'),
|
|
5635
|
+
mimeType: image.mimeType,
|
|
5636
|
+
},
|
|
5637
|
+
],
|
|
5638
|
+
};
|
|
5639
|
+
}
|
|
5640
|
+
|
|
4172
5641
|
/** What a listing shows of a path beyond its name. */
|
|
4173
5642
|
function stateView(report: PathReport): Record<string, unknown> {
|
|
4174
5643
|
return {
|
|
@@ -4187,6 +5656,122 @@ function stateView(report: PathReport): Record<string, unknown> {
|
|
|
4187
5656
|
/** A lone UTF-16 surrogate, which UTF-8 can't store: in `u` mode, a pair is one code point. */
|
|
4188
5657
|
const LONE_SURROGATE = /\p{Cs}/u;
|
|
4189
5658
|
|
|
5659
|
+
/** Why a direct mount's read stopped when it found another directory put at its root as it opened the file. */
|
|
5660
|
+
const DIRECT_ROOT_REPLACED = 'the mount root was replaced as the file was opened: reading it again reads the directory there now';
|
|
5661
|
+
|
|
5662
|
+
/**
|
|
5663
|
+
* How the alert that a mount configured direct runs as 'chronicle' names an
|
|
5664
|
+
* intent the workspace can't read. A sync of its path settles it, with
|
|
5665
|
+
* disk's copy, or with none where disk has none, and where disk's file
|
|
5666
|
+
* can't be read it skips the path and says why. Materialize settles one
|
|
5667
|
+
* only where the workspace holds a copy there: a plain one skips it and says
|
|
5668
|
+
* to sync, a forced one writes the copy, and where the workspace holds none
|
|
5669
|
+
* it neither selects nor names it.
|
|
5670
|
+
*/
|
|
5671
|
+
const UNREADABLE_INTENT = "its workspace intent can't be read; a sync of the path settles it or says why not";
|
|
5672
|
+
|
|
5673
|
+
/**
|
|
5674
|
+
* A mount's watch mode as it runs. A mount configured direct watches
|
|
5675
|
+
* nothing, whichever kind it runs as this session: its config takes no
|
|
5676
|
+
* other mode (directRefusals), and leaving it out means 'never' there.
|
|
5677
|
+
*/
|
|
5678
|
+
function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
|
|
5679
|
+
return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
|
|
5680
|
+
}
|
|
5681
|
+
|
|
5682
|
+
/**
|
|
5683
|
+
* What a direct mount's config sets that would never act there, each with
|
|
5684
|
+
* the way out (MountConfig.backend): a direct mount is read-only, and has no
|
|
5685
|
+
* watcher to tune, no change event to wake on and no push to make.
|
|
5686
|
+
*/
|
|
5687
|
+
function directRefusals(mount: MountConfig): string[] {
|
|
5688
|
+
const refused: string[] = [];
|
|
5689
|
+
if (mount.mode !== 'read-only') {
|
|
5690
|
+
refused.push(mount.mode === undefined ? "mode must be 'read-only' (left out, it's 'read-write')" : `mode must be 'read-only', not ${shownValue(mount.mode)}`);
|
|
5691
|
+
}
|
|
5692
|
+
if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
|
|
5693
|
+
for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
|
|
5694
|
+
if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);
|
|
5695
|
+
}
|
|
5696
|
+
const wake = mount.wakeOnChange;
|
|
5697
|
+
if (wake === true || (Array.isArray(wake) && wake.length > 0)) refused.push('wakeOnChange would never wake (leave it out)');
|
|
5698
|
+
if (mount.autoMaterialize === true) refused.push('autoMaterialize would never push (leave it out)');
|
|
5699
|
+
return refused;
|
|
5700
|
+
}
|
|
5701
|
+
|
|
5702
|
+
/** A change commitToolChanges makes to one path's tree entry. */
|
|
5703
|
+
type ToolChange = { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string };
|
|
5704
|
+
|
|
5705
|
+
/** A file an edit or edit_many call edits: one stored path, and every edit naming it. */
|
|
5706
|
+
interface EditTarget {
|
|
5707
|
+
/** Its path in its mount, as disk spells it: the store's key for it. */
|
|
5708
|
+
relativePath: string;
|
|
5709
|
+
/** Its mount-prefixed path as disk spells it, which its answer uses. */
|
|
5710
|
+
path: string;
|
|
5711
|
+
/** Every edit naming it, by index, and the specs of those fit to be matched. */
|
|
5712
|
+
edits: number[];
|
|
5713
|
+
specs: EditSpec[];
|
|
5714
|
+
}
|
|
5715
|
+
|
|
5716
|
+
/**
|
|
5717
|
+
* Whether a tree entry is what a change leaves: for a set, an entry with its
|
|
5718
|
+
* blob, size and mode; for a removal, none.
|
|
5719
|
+
*/
|
|
5720
|
+
function holds(entry: ReturnType<JsStore['treeGet']>, change: ToolChange): boolean {
|
|
5721
|
+
return change.kind === 'set'
|
|
5722
|
+
? entry !== null && entry.blobHash === change.blobHash && entry.size === change.size && entry.mode === (change.mode ?? 0o644)
|
|
5723
|
+
: entry === null;
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5726
|
+
/**
|
|
5727
|
+
* Whether a store's set that threw recorded the changes anyway: every path
|
|
5728
|
+
* holds what its change leaves. The changes given are those the tree didn't
|
|
5729
|
+
* hold before the set, and one record takes all of them or none, so their
|
|
5730
|
+
* holding shows the record is in. Reading them back can fail too, and then
|
|
5731
|
+
* whether they're stored isn't known (CommitUnknownError).
|
|
5732
|
+
*/
|
|
5733
|
+
function changesLanded(
|
|
5734
|
+
store: JsStore,
|
|
5735
|
+
treeStateId: string,
|
|
5736
|
+
changes: Array<{ path: string; change: ToolChange }>,
|
|
5737
|
+
setError: unknown,
|
|
5738
|
+
): boolean {
|
|
5739
|
+
try {
|
|
5740
|
+
return changes.every(({ path, change }) => holds(store.treeGet(treeStateId, path), change));
|
|
5741
|
+
} catch (readError) {
|
|
5742
|
+
throw new CommitUnknownError(setError, readError);
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
5745
|
+
|
|
5746
|
+
/**
|
|
5747
|
+
* A store's set threw, and reading its entries back threw too, so whether the
|
|
5748
|
+
* change is stored isn't known. Its message says so after both errors.
|
|
5749
|
+
*/
|
|
5750
|
+
class CommitUnknownError extends Error {
|
|
5751
|
+
readonly setError: string;
|
|
5752
|
+
readonly readError: string;
|
|
5753
|
+
constructor(setError: unknown, readError: unknown) {
|
|
5754
|
+
const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
5755
|
+
super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
|
|
5756
|
+
this.name = 'CommitUnknownError';
|
|
5757
|
+
this.setError = text(setError);
|
|
5758
|
+
this.readError = text(readError);
|
|
5759
|
+
}
|
|
5760
|
+
}
|
|
5761
|
+
|
|
5762
|
+
/**
|
|
5763
|
+
* A store's set that threw after recording the change (changesLanded): the
|
|
5764
|
+
* entries are set and their intents follow, and the store's error goes on in
|
|
5765
|
+
* place of the push. `pushes` says whether the mount would have pushed the
|
|
5766
|
+
* change to disk, and `notes` are commitToolChanges' own, by path.
|
|
5767
|
+
*/
|
|
5768
|
+
class StoredThenFailed extends Error {
|
|
5769
|
+
constructor(storeError: unknown, readonly pushes: boolean, readonly notes: Map<string, string>) {
|
|
5770
|
+
super(storeError instanceof Error ? storeError.message : String(storeError));
|
|
5771
|
+
this.name = 'StoredThenFailed';
|
|
5772
|
+
}
|
|
5773
|
+
}
|
|
5774
|
+
|
|
4190
5775
|
/**
|
|
4191
5776
|
* The answer to a write, edit or delete that the workspace stored but
|
|
4192
5777
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -4195,12 +5780,13 @@ const LONE_SURROGATE = /\p{Cs}/u;
|
|
|
4195
5780
|
* and sending it again could apply it twice. The push's reason says what disk
|
|
4196
5781
|
* holds and the way out, as materialize takes it. The text carries all of it,
|
|
4197
5782
|
* since an error's data reaches neither the model nor a code_execution
|
|
4198
|
-
* script.
|
|
5783
|
+
* script. A note comes last: an edit's on which copy it applied to, or a
|
|
5784
|
+
* deletion's that it unlinked nothing.
|
|
4199
5785
|
*/
|
|
4200
5786
|
function storedButUnfinished(
|
|
4201
5787
|
tool: 'write' | 'edit' | 'delete',
|
|
4202
5788
|
path: string,
|
|
4203
|
-
outcome: { refused: string } | { threw: string },
|
|
5789
|
+
outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
4204
5790
|
note: string | null = null,
|
|
4205
5791
|
): ToolResult {
|
|
4206
5792
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
@@ -4208,7 +5794,12 @@ function storedButUnfinished(
|
|
|
4208
5794
|
+ (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
|
|
4209
5795
|
const rest = 'refused' in outcome
|
|
4210
5796
|
? ` Its push to disk didn't complete: ${outcome.refused}.`
|
|
4211
|
-
:
|
|
5797
|
+
: 'storeFailed' in outcome
|
|
5798
|
+
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
5799
|
+
: tool === 'delete'
|
|
5800
|
+
? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
|
|
5801
|
+
: ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
|
|
5802
|
+
: ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
|
|
4212
5803
|
return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
|
|
4213
5804
|
}
|
|
4214
5805
|
|
|
@@ -4230,16 +5821,19 @@ function linesOf(content: string): string[] {
|
|
|
4230
5821
|
const INPUT_MINIMUMS: Record<string, Record<string, number>> = {
|
|
4231
5822
|
read: { offset: 1, limit: 1, offsetChars: 0, limitChars: 1 },
|
|
4232
5823
|
grep: { contextBefore: 0, contextAfter: 0 },
|
|
5824
|
+
edit_many: { occurrences: 1 },
|
|
4233
5825
|
};
|
|
4234
5826
|
|
|
4235
5827
|
/**
|
|
4236
5828
|
* Why a tool call's input doesn't fit its tool's schema, or null when it does:
|
|
4237
5829
|
* an object, each required field present, and each field its schema calls a
|
|
4238
5830
|
* string, a boolean or an integer of that type, an integer at least its
|
|
4239
|
-
* minimum.
|
|
4240
|
-
*
|
|
4241
|
-
*
|
|
4242
|
-
*
|
|
5831
|
+
* minimum. A field it calls an array of objects is an array, and each of its
|
|
5832
|
+
* items is checked the same way against the items' schema, with the same
|
|
5833
|
+
* minimums (edit_many's edits). The workspace's schemas use no other types.
|
|
5834
|
+
* Without it, an edit missing `newString` wrote the word "undefined", and an
|
|
5835
|
+
* offset of 1.5 numbered lines 1.5, 2.5 and on. A field the schema doesn't
|
|
5836
|
+
* describe is left to its handler.
|
|
4243
5837
|
*/
|
|
4244
5838
|
function inputProblem(
|
|
4245
5839
|
schema: { properties?: Record<string, unknown>; required?: string[] },
|
|
@@ -4254,17 +5848,28 @@ function inputProblem(
|
|
|
4254
5848
|
if (fields[name] === undefined) return `"${name}" is required`;
|
|
4255
5849
|
}
|
|
4256
5850
|
for (const [name, value] of Object.entries(fields)) {
|
|
4257
|
-
const
|
|
5851
|
+
const property = schema.properties?.[name] as { type?: string; items?: { type?: string } } | undefined;
|
|
5852
|
+
const type = property?.type;
|
|
4258
5853
|
if (value === undefined || type === undefined) continue;
|
|
4259
5854
|
const minimum = minimums[name];
|
|
4260
5855
|
const fits = type === 'string' ? typeof value === 'string'
|
|
4261
5856
|
: type === 'boolean' ? typeof value === 'boolean'
|
|
4262
5857
|
: type === 'integer'
|
|
4263
5858
|
? Number.isSafeInteger(value) && (minimum === undefined || (value as number) >= minimum)
|
|
5859
|
+
: type === 'array' ? Array.isArray(value)
|
|
4264
5860
|
: true;
|
|
5861
|
+
if (fits && type === 'array' && property?.items?.type === 'object') {
|
|
5862
|
+
for (const [i, item] of (value as unknown[]).entries()) {
|
|
5863
|
+
const problem = item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
5864
|
+
? inputProblem(property.items as Parameters<typeof inputProblem>[0], item, minimums)
|
|
5865
|
+
: 'it must be an object of the fields its schema lists';
|
|
5866
|
+
if (problem !== null) return `"${name}" item ${i + 1}: ${problem}`;
|
|
5867
|
+
}
|
|
5868
|
+
}
|
|
4265
5869
|
if (!fits) {
|
|
4266
5870
|
const wanted = type === 'string' ? 'a string'
|
|
4267
5871
|
: type === 'boolean' ? 'true or false'
|
|
5872
|
+
: type === 'array' ? 'an array'
|
|
4268
5873
|
: minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
|
|
4269
5874
|
const got = value === null ? 'null'
|
|
4270
5875
|
: typeof value === 'number' ? String(value)
|