@animalabs/agent-framework 0.26.2 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -8
- package/dist/src/agent.d.ts +291 -23
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +596 -61
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +5 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +34 -0
- package/dist/src/auto-continue.d.ts.map +1 -0
- package/dist/src/auto-continue.js +36 -0
- package/dist/src/auto-continue.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -1
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/cut-off-round.d.ts +24 -0
- package/dist/src/cut-off-round.d.ts.map +1 -0
- package/dist/src/cut-off-round.js +87 -0
- package/dist/src/cut-off-round.js.map +1 -0
- package/dist/src/framework.d.ts +1038 -193
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +5967 -1514
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +405 -46
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +2646 -399
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +3 -2
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +2 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts +103 -0
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
- package/dist/src/gate/recipe-reconcile.js +325 -0
- package/dist/src/gate/recipe-reconcile.js.map +1 -0
- package/dist/src/gate/reply-judge.d.ts +116 -0
- package/dist/src/gate/reply-judge.d.ts.map +1 -0
- package/dist/src/gate/reply-judge.js +467 -0
- package/dist/src/gate/reply-judge.js.map +1 -0
- package/dist/src/gate/types.d.ts +229 -8
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/heartbeat/heartbeat.d.ts +184 -0
- package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat/heartbeat.js +752 -0
- package/dist/src/heartbeat/heartbeat.js.map +1 -0
- package/dist/src/heartbeat/reminder-time.d.ts +5 -0
- package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
- package/dist/src/heartbeat/reminder-time.js +107 -0
- package/dist/src/heartbeat/reminder-time.js.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
- package/dist/src/mcpl/capability-mask.js +3 -2
- package/dist/src/mcpl/capability-mask.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +433 -113
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1154 -435
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +16 -8
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +29 -15
- 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 +14 -2
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +32 -7
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +26 -10
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +40 -69
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +4 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +14 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +73 -28
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +6 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +3 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +20 -2
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +33 -11
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +177 -145
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +380 -46
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +736 -77
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +93 -4
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +176 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +9 -2
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +12 -2
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +21 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +311 -0
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
- package/dist/src/mcpl/tool-presence.js +697 -0
- package/dist/src/mcpl/tool-presence.js.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js +14 -6
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +46 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
- package/dist/src/mcpl/tool-signature.js +321 -0
- package/dist/src/mcpl/tool-signature.js.map +1 -0
- package/dist/src/mcpl/toolset-host.d.ts +251 -0
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
- package/dist/src/mcpl/toolset-host.js +720 -0
- package/dist/src/mcpl/toolset-host.js.map +1 -0
- package/dist/src/mcpl/toolsets-module.d.ts +83 -0
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
- package/dist/src/mcpl/toolsets-module.js +523 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +2 -2
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +3 -2
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +24 -2
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +7 -9
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +21 -9
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +27 -9
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +77 -17
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +2 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +73 -28
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +108 -6
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +273 -57
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/answers.d.ts +22 -0
- package/dist/src/modules/workspace/answers.d.ts.map +1 -0
- package/dist/src/modules/workspace/answers.js +123 -0
- package/dist/src/modules/workspace/answers.js.map +1 -0
- package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +6 -2
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +184 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
- package/dist/src/modules/workspace/direct-write.js +417 -0
- package/dist/src/modules/workspace/direct-write.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +48 -17
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/edit-text.d.ts +27 -12
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +61 -29
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +10 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +21 -14
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +308 -78
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2103 -720
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +75 -63
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +366 -180
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts +41 -0
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
- package/dist/src/modules/workspace/read-page.js +219 -0
- package/dist/src/modules/workspace/read-page.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +26 -16
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/result-text.d.ts +68 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/result-text.js +94 -0
- package/dist/src/modules/workspace/result-text.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +9 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +14 -0
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +29 -12
- package/dist/src/modules/workspace/text.d.ts.map +1 -1
- package/dist/src/modules/workspace/text.js +35 -33
- package/dist/src/modules/workspace/text.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +137 -60
- 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.map +1 -1
- package/dist/src/modules/workspace/watcher.js +8 -3
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/prose-drafts.d.ts +4 -3
- package/dist/src/prose-drafts.d.ts.map +1 -1
- package/dist/src/prose-drafts.js +3 -2
- package/dist/src/prose-drafts.js.map +1 -1
- package/dist/src/reasoning-loss-notice.d.ts +23 -0
- package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
- package/dist/src/reasoning-loss-notice.js +50 -0
- package/dist/src/reasoning-loss-notice.js.map +1 -0
- package/dist/src/reasoning-loss.d.ts +24 -0
- package/dist/src/reasoning-loss.d.ts.map +1 -0
- package/dist/src/reasoning-loss.js +102 -0
- package/dist/src/reasoning-loss.js.map +1 -0
- package/dist/src/send-tools.d.ts +3 -2
- package/dist/src/send-tools.d.ts.map +1 -1
- package/dist/src/send-tools.js +3 -2
- package/dist/src/send-tools.js.map +1 -1
- package/dist/src/sent-rows.d.ts +59 -0
- package/dist/src/sent-rows.d.ts.map +1 -0
- package/dist/src/sent-rows.js +145 -0
- package/dist/src/sent-rows.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +17 -10
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stable-hash.d.ts +3 -0
- package/dist/src/stable-hash.d.ts.map +1 -0
- package/dist/src/stable-hash.js +15 -0
- package/dist/src/stable-hash.js.map +1 -0
- package/dist/src/stored-injections.d.ts +99 -0
- package/dist/src/stored-injections.d.ts.map +1 -0
- package/dist/src/stored-injections.js +106 -0
- package/dist/src/stored-injections.js.map +1 -0
- package/dist/src/text-as-stored.d.ts +4 -3
- package/dist/src/text-as-stored.d.ts.map +1 -1
- package/dist/src/text-as-stored.js +4 -3
- package/dist/src/text-as-stored.js.map +1 -1
- package/dist/src/thinking-drops.d.ts +74 -0
- package/dist/src/thinking-drops.d.ts.map +1 -0
- package/dist/src/thinking-drops.js +96 -0
- package/dist/src/thinking-drops.js.map +1 -0
- package/dist/src/tool-args.d.ts +12 -0
- package/dist/src/tool-args.d.ts.map +1 -1
- package/dist/src/tool-args.js +35 -1
- package/dist/src/tool-args.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +9 -8
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +9 -8
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +3 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +12 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +44 -17
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +176 -24
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +411 -91
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +17 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +84 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +4 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +217 -7
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +33 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +39 -3
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +95 -9
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +91 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/agent.ts +661 -67
- package/src/api/server.ts +5 -4
- package/src/auto-continue.ts +43 -0
- package/src/code-execution/isolate/scripts.ts +11 -3
- package/src/cut-off-round.ts +98 -0
- package/src/framework.ts +6168 -1540
- package/src/gate/event-gate.ts +2895 -405
- package/src/gate/index.ts +10 -1
- package/src/gate/recipe-reconcile.ts +355 -0
- package/src/gate/reply-judge.ts +512 -0
- package/src/gate/types.ts +234 -9
- package/src/heartbeat/heartbeat.ts +804 -0
- package/src/heartbeat/reminder-time.ts +107 -0
- package/src/index.ts +11 -4
- package/src/mcpl/capability-mask.ts +3 -2
- package/src/mcpl/channel-registry.ts +1222 -468
- package/src/mcpl/conversation-router.ts +34 -16
- package/src/mcpl/convert-block.ts +14 -1
- package/src/mcpl/hook-orchestrator.ts +33 -6
- package/src/mcpl/inbound-source.ts +42 -69
- package/src/mcpl/index.ts +6 -1
- package/src/mcpl/inference-router.ts +79 -34
- package/src/mcpl/prose-grammar.ts +7 -0
- package/src/mcpl/protocol-family.ts +2 -0
- package/src/mcpl/push-coalescer.ts +51 -13
- package/src/mcpl/reference-fetcher.ts +201 -162
- package/src/mcpl/references.ts +851 -77
- package/src/mcpl/server-connection.ts +189 -11
- package/src/mcpl/tags.ts +13 -2
- package/src/mcpl/tool-lifecycle.ts +21 -0
- package/src/mcpl/tool-presence.ts +716 -0
- package/src/mcpl/tool-result-normalize.ts +18 -6
- package/src/mcpl/tool-signature.ts +316 -0
- package/src/mcpl/toolset-host.ts +727 -0
- package/src/mcpl/toolsets-module.ts +545 -0
- package/src/mcpl/transport.ts +3 -2
- package/src/mcpl/types.ts +25 -2
- package/src/mcpl/visible-content.ts +22 -9
- package/src/module-registry.ts +85 -26
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +70 -30
- package/src/modules/history/semantic.ts +308 -54
- package/src/modules/workspace/answers.ts +133 -0
- package/src/modules/workspace/branch-intent.ts +10 -5
- package/src/modules/workspace/direct-write.ts +460 -0
- package/src/modules/workspace/disk-agreement.ts +52 -21
- package/src/modules/workspace/edit-text.ts +84 -35
- package/src/modules/workspace/effects.ts +22 -13
- package/src/modules/workspace/index.ts +2142 -723
- package/src/modules/workspace/observe.ts +381 -177
- package/src/modules/workspace/read-page.ts +228 -0
- package/src/modules/workspace/reconcile.ts +27 -15
- package/src/modules/workspace/result-text.ts +121 -0
- package/src/modules/workspace/sync.ts +16 -0
- package/src/modules/workspace/text.ts +58 -32
- package/src/modules/workspace/types.ts +137 -60
- package/src/modules/workspace/watcher.ts +8 -3
- package/src/prose-drafts.ts +4 -3
- package/src/reasoning-loss-notice.ts +62 -0
- package/src/reasoning-loss.ts +120 -0
- package/src/send-tools.ts +3 -2
- package/src/sent-rows.ts +162 -0
- package/src/silent-heartbeat.ts +17 -10
- package/src/stable-hash.ts +14 -0
- package/src/stored-injections.ts +131 -0
- package/src/text-as-stored.ts +4 -3
- package/src/thinking-drops.ts +135 -0
- package/src/tool-args.ts +34 -1
- package/src/tool-image-ledger.ts +9 -8
- package/src/tool-presentation.ts +3 -1
- package/src/tool-result-guard.ts +45 -17
- package/src/tool-result-history.ts +471 -90
- package/src/tune-out/coordinator.ts +80 -37
- package/src/tune-out/tools.ts +4 -1
- package/src/types/agent.ts +220 -7
- package/src/types/events.ts +33 -9
- package/src/types/framework.ts +36 -3
- package/src/types/module.ts +91 -9
- package/src/types/trace.ts +94 -3
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { constants as fsConstants } from 'node:fs';
|
|
9
|
-
import type { Stats } from 'node:fs';
|
|
9
|
+
import type { BigIntStats, Stats } from 'node:fs';
|
|
10
10
|
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
11
11
|
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
12
12
|
import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
|
|
@@ -37,14 +37,17 @@ import type {
|
|
|
37
37
|
} from './types.js';
|
|
38
38
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
39
39
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
40
|
-
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
41
|
-
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
40
|
+
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
|
|
41
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
42
42
|
import { type BranchIntent, BranchIntents } from './branch-intent.js';
|
|
43
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE,
|
|
43
|
+
import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
|
|
44
44
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
45
45
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
46
46
|
import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
47
47
|
import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
|
|
48
|
+
import { type FileText, fileText } from './text.js';
|
|
49
|
+
import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
|
|
50
|
+
import { type DiskState, type GrepAnswer, grepText, textAnswer } from './result-text.js';
|
|
48
51
|
import {
|
|
49
52
|
type MountRuntime,
|
|
50
53
|
type PassOptions,
|
|
@@ -61,6 +64,26 @@ import {
|
|
|
61
64
|
reconcilePass,
|
|
62
65
|
settleAdoptionBeforeMutation,
|
|
63
66
|
} from './reconcile.js';
|
|
67
|
+
import { EffectFailed, unlinkContained } from './effects.js';
|
|
68
|
+
import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
|
|
69
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
70
|
+
import { closestNames } from '../../closest-names.js';
|
|
71
|
+
import { RecordJournal } from '../../record-journal.js';
|
|
72
|
+
import {
|
|
73
|
+
type FileIdentity,
|
|
74
|
+
type Staged,
|
|
75
|
+
FileChanged,
|
|
76
|
+
ROOT_MOVED,
|
|
77
|
+
WriteRefused,
|
|
78
|
+
checkUnchanged,
|
|
79
|
+
directWriteTestSeam,
|
|
80
|
+
discard,
|
|
81
|
+
identityOf,
|
|
82
|
+
placedStill,
|
|
83
|
+
renameInto,
|
|
84
|
+
sameIdentity,
|
|
85
|
+
stageContained,
|
|
86
|
+
} from './direct-write.js';
|
|
64
87
|
|
|
65
88
|
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
66
89
|
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
@@ -74,10 +97,15 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
|
74
97
|
const CATCH_UP_BOUND_MS = 30_000;
|
|
75
98
|
/** Why a call is refused once the module has stopped. */
|
|
76
99
|
const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
|
|
100
|
+
/** writeBinary's refusal when the store's branch is no longer the one its file is for. */
|
|
101
|
+
const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
77
102
|
/** The workspace's own deadline must end before the framework's hook bound. */
|
|
78
103
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
79
104
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
80
105
|
const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
106
|
+
const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
|
|
107
|
+
+ 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
|
|
108
|
+
+ 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
|
|
81
109
|
/**
|
|
82
110
|
* How many files a direct mount's grep reads, and its listings look at, at
|
|
83
111
|
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
@@ -88,6 +116,8 @@ const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git',
|
|
|
88
116
|
const DIRECT_READS_AT_ONCE = 16;
|
|
89
117
|
/** Why sync passes over a direct mount, as its result's `skipped` says. */
|
|
90
118
|
const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
|
|
119
|
+
/** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
|
|
120
|
+
const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
|
|
91
121
|
|
|
92
122
|
export type {
|
|
93
123
|
WorkspaceConfig,
|
|
@@ -110,30 +140,23 @@ export type {
|
|
|
110
140
|
|
|
111
141
|
type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
112
142
|
|
|
113
|
-
class WorkspaceImageReadError extends
|
|
114
|
-
constructor(
|
|
115
|
-
|
|
116
|
-
| 'mount_unavailable'
|
|
117
|
-
| 'not_found'
|
|
118
|
-
| 'directory'
|
|
119
|
-
| 'symlink'
|
|
120
|
-
| 'parent_symlink'
|
|
121
|
-
| 'other_mount'
|
|
122
|
-
| 'escape'
|
|
123
|
-
| 'changed'
|
|
124
|
-
| 'empty'
|
|
125
|
-
| 'too_large'
|
|
126
|
-
| 'truncated'
|
|
127
|
-
| 'invalid'
|
|
128
|
-
| 'unsupported'
|
|
129
|
-
| 'blob_missing',
|
|
130
|
-
message: string,
|
|
131
|
-
) {
|
|
132
|
-
super(message);
|
|
143
|
+
class WorkspaceImageReadError extends Refusal {
|
|
144
|
+
constructor(readonly reason: string, wayOn: string | null) {
|
|
145
|
+
super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
|
|
133
146
|
this.name = 'WorkspaceImageReadError';
|
|
134
147
|
}
|
|
135
148
|
}
|
|
136
149
|
|
|
150
|
+
const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
|
|
151
|
+
|
|
152
|
+
function truncatedImage(mountPrefixedPath: string): WorkspaceImageReadError {
|
|
153
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function emptyImage(mountPrefixedPath: string): WorkspaceImageReadError {
|
|
157
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
|
|
158
|
+
}
|
|
159
|
+
|
|
137
160
|
/**
|
|
138
161
|
* Why a workspace-owned filesystem read was refused. Distinguishes the cases a
|
|
139
162
|
* peer needs to react to differently: a path that never resolved inside the
|
|
@@ -151,10 +174,12 @@ class WorkspaceImageReadError extends Error {
|
|
|
151
174
|
* directories leading out of it (`escape`), a file swapped underneath the read
|
|
152
175
|
* (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
|
|
153
176
|
* and a path in another mount's directory nested in the one named
|
|
154
|
-
* (`other_mount`), whose message names the mount that holds it
|
|
155
|
-
* to use there. A symlink leading into such a directory is
|
|
156
|
-
* only where its refusal names that mount: a directory, or a
|
|
157
|
-
* mount's include covers
|
|
177
|
+
* (`other_mount`), whose message names the mount that holds it, or may hold
|
|
178
|
+
* it, and the path to use there. A symlink leading into such a directory is
|
|
179
|
+
* `other_mount` only where its refusal names that mount: a directory, or a
|
|
180
|
+
* file that mount's include covers, and so does the include of each other
|
|
181
|
+
* nested mount whose directory may hold it, out to the first that surely
|
|
182
|
+
* does. Anywhere else, on a mount that doesn't follow
|
|
158
183
|
* symlinks, it gets the code of a link where it stands, `symlink` at the
|
|
159
184
|
* path itself and `parent_symlink` on its way, and on one that does,
|
|
160
185
|
* `escape`, as a path that leaves the mount. On a mount with an include, a
|
|
@@ -196,7 +221,7 @@ export type WorkspaceReadStage =
|
|
|
196
221
|
| 'read';
|
|
197
222
|
|
|
198
223
|
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
199
|
-
class OutsideIncludeError extends
|
|
224
|
+
class OutsideIncludeError extends Refusal {
|
|
200
225
|
constructor(shownPath: string, mount: MountConfig) {
|
|
201
226
|
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
202
227
|
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
@@ -208,9 +233,16 @@ class OutsideIncludeError extends Error {
|
|
|
208
233
|
* mount it names. The message names the mount that holds it and the path to
|
|
209
234
|
* use there.
|
|
210
235
|
*/
|
|
211
|
-
class NestedMountError extends
|
|
212
|
-
constructor(path: string, mount: string, holder: string
|
|
213
|
-
|
|
236
|
+
class NestedMountError extends Refusal {
|
|
237
|
+
constructor(path: string, mount: string, holder: { name: string; use: string; guess?: NestedGuess }, how: 'lies in' | 'leads into' = 'lies in') {
|
|
238
|
+
const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
|
|
239
|
+
super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
class PathRefusal extends Refusal {
|
|
244
|
+
constructor(readonly code: 'unknown_mount' | 'traversal', message: string) {
|
|
245
|
+
super(message);
|
|
214
246
|
}
|
|
215
247
|
}
|
|
216
248
|
|
|
@@ -232,6 +264,18 @@ export class WorkspaceReadError extends Error {
|
|
|
232
264
|
}
|
|
233
265
|
}
|
|
234
266
|
|
|
267
|
+
class NotRegularFile extends WorkspaceReadError {
|
|
268
|
+
constructor(path: string, stage: WorkspaceReadStage) {
|
|
269
|
+
super('not_found', `Not a regular file: ${path}`, stage);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
class RootUnavailable extends WorkspaceReadError {
|
|
274
|
+
constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
|
|
275
|
+
super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
235
279
|
/** Successful `readFileFromDisk()` result. */
|
|
236
280
|
export interface WorkspaceDiskReadResult {
|
|
237
281
|
/**
|
|
@@ -284,23 +328,10 @@ function errnoOf(err: unknown): string | undefined {
|
|
|
284
328
|
: undefined;
|
|
285
329
|
}
|
|
286
330
|
|
|
287
|
-
/**
|
|
288
|
-
* What is at a mount root's path now, followed as the root is: for a
|
|
289
|
-
* directory, undefined; for anything else, ENOTDIR; and where the path
|
|
290
|
-
* can't be followed to anything, the errno its stat failed with, ENOENT
|
|
291
|
-
* when nothing is there.
|
|
292
|
-
*/
|
|
293
|
-
async function rootAbsence(root: string): Promise<string | undefined> {
|
|
294
|
-
try {
|
|
295
|
-
return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
|
|
296
|
-
} catch (err) {
|
|
297
|
-
return errnoOf(err);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
331
|
|
|
301
332
|
/** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
|
|
302
|
-
function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
|
|
303
|
-
return new WorkspaceImageReadError(
|
|
333
|
+
function imageTooLarge(maxSize: number, mountPrefixedPath: string, size: string): WorkspaceImageReadError {
|
|
334
|
+
return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
|
|
304
335
|
}
|
|
305
336
|
|
|
306
337
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -318,10 +349,6 @@ const JPEG_EOI = 0xd9;
|
|
|
318
349
|
const JPEG_SOS = 0xda;
|
|
319
350
|
const JPEG_TEM = 0x01;
|
|
320
351
|
|
|
321
|
-
function isErrnoCode(err: unknown, code: string): boolean {
|
|
322
|
-
return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === code;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
352
|
function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
|
|
326
353
|
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
327
354
|
}
|
|
@@ -364,7 +391,7 @@ async function linkReadError(
|
|
|
364
391
|
const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
|
|
365
392
|
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
366
393
|
const code = leads?.kind === 'outside' ? 'escape'
|
|
367
|
-
: leads?.kind === 'inside' && holder && namesHolder(leads,
|
|
394
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
|
|
368
395
|
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
369
396
|
return new WorkspaceReadError(code, reason, 'readlink');
|
|
370
397
|
}
|
|
@@ -396,32 +423,30 @@ function ancestorsOf(path: string): string[] {
|
|
|
396
423
|
* directory as configured beneath the outer mount's configured path
|
|
397
424
|
* (`outerPath`), beneath each directory above its configured path that is
|
|
398
425
|
* the outer root on disk (`above`, each with where it lies on disk), and
|
|
399
|
-
* where its real root (`real`) lies beneath the outer root (`outerReal`)
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
* whole. Undefined where it's nested nowhere.
|
|
426
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`).
|
|
427
|
+
* Its real root is claimed where it doesn't hold the outer root, which it
|
|
428
|
+
* would otherwise claim whole. Undefined where it's nested nowhere.
|
|
403
429
|
*/
|
|
404
|
-
|
|
430
|
+
function placementOf(
|
|
405
431
|
outerPath: string,
|
|
406
432
|
outerReal: string,
|
|
407
433
|
innerPath: string,
|
|
408
434
|
real: string,
|
|
409
435
|
above: ReadonlyArray<{ dir: string; real: string }>,
|
|
410
|
-
|
|
411
|
-
): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
|
|
436
|
+
): { configured: string | undefined; fromRoots: NestedMountEntry['fromRoots']; claims: boolean } | undefined {
|
|
412
437
|
const configured = nestedDir(outerPath, innerPath);
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return { configured, fromRoots,
|
|
438
|
+
const dirs = new Map<string, boolean>();
|
|
439
|
+
const place = (dir: string | undefined, sure: boolean) => { if (dir !== undefined) dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
|
|
440
|
+
place(configured, true);
|
|
441
|
+
for (const a of above) {
|
|
442
|
+
if (sameRoot(a.real, outerReal)) place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
|
|
443
|
+
}
|
|
444
|
+
const below = beneathRoot(real, outerReal);
|
|
445
|
+
place(below, below !== undefined && surelyAtRoot(real, outerReal));
|
|
446
|
+
if (dirs.size === 0) return undefined;
|
|
447
|
+
const fromRoots: NestedMountEntry['fromRoots'] = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true as const }) }));
|
|
448
|
+
const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
|
|
449
|
+
return { configured, fromRoots, claims };
|
|
425
450
|
}
|
|
426
451
|
|
|
427
452
|
/** A mount nested in another, as the outer mount's otherMounts holds it. */
|
|
@@ -434,9 +459,7 @@ interface NestedMountEntry {
|
|
|
434
459
|
*/
|
|
435
460
|
real?: string;
|
|
436
461
|
/** Its directory as configured and as the real roots place it. */
|
|
437
|
-
fromRoots: Array<{ dir: string;
|
|
438
|
-
/** Those, and each as disk spells it where that differs. */
|
|
439
|
-
spellings: Array<{ dir: string; root: string }>;
|
|
462
|
+
fromRoots: Array<{ dir: string; guessed?: true }>;
|
|
440
463
|
}
|
|
441
464
|
|
|
442
465
|
/**
|
|
@@ -515,7 +538,7 @@ function isContainedPath(root: string, candidate: string): boolean {
|
|
|
515
538
|
}
|
|
516
539
|
|
|
517
540
|
function invalidImage(format: string, mountPrefixedPath: string): never {
|
|
518
|
-
throw new WorkspaceImageReadError(
|
|
541
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
|
|
519
542
|
}
|
|
520
543
|
|
|
521
544
|
function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
|
|
@@ -891,7 +914,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
891
914
|
return 'image/png';
|
|
892
915
|
}
|
|
893
916
|
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
894
|
-
throw
|
|
917
|
+
throw truncatedImage(mountPrefixedPath);
|
|
895
918
|
}
|
|
896
919
|
|
|
897
920
|
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
@@ -899,7 +922,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
899
922
|
return 'image/jpeg';
|
|
900
923
|
}
|
|
901
924
|
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
902
|
-
throw
|
|
925
|
+
throw truncatedImage(mountPrefixedPath);
|
|
903
926
|
}
|
|
904
927
|
|
|
905
928
|
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
@@ -907,7 +930,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
907
930
|
return 'image/gif';
|
|
908
931
|
}
|
|
909
932
|
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
910
|
-
throw
|
|
933
|
+
throw truncatedImage(mountPrefixedPath);
|
|
911
934
|
}
|
|
912
935
|
|
|
913
936
|
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
@@ -918,10 +941,10 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
918
941
|
(bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
919
942
|
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
|
|
920
943
|
) {
|
|
921
|
-
throw
|
|
944
|
+
throw truncatedImage(mountPrefixedPath);
|
|
922
945
|
}
|
|
923
946
|
|
|
924
|
-
throw new WorkspaceImageReadError('
|
|
947
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows`, "read shows it, if it's text.");
|
|
925
948
|
}
|
|
926
949
|
|
|
927
950
|
export class WorkspaceModule implements Module {
|
|
@@ -949,14 +972,11 @@ export class WorkspaceModule implements Module {
|
|
|
949
972
|
private ctimeTrust = new Map<string, string | null>();
|
|
950
973
|
/**
|
|
951
974
|
* Each mount's directories that are other mounts nested inside it, with
|
|
952
|
-
* each mount's name and real root,
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
* the
|
|
957
|
-
* with each root as resolved standing for its real one, and each start
|
|
958
|
-
* reads it all again with the roots placed where disk has them
|
|
959
|
-
* (readNesting).
|
|
975
|
+
* each mount's name and real root: as configured, and as the real roots
|
|
976
|
+
* place it, where that differs (`fromRoots`). Each is mount-relative and
|
|
977
|
+
* `/`-separated. The constructor nests by configured path, with each root
|
|
978
|
+
* as resolved standing for its real one, and each start reads it all again
|
|
979
|
+
* with the roots placed where disk has them (readNesting).
|
|
960
980
|
*/
|
|
961
981
|
private otherMounts = new Map<string, NestedMountEntry[]>();
|
|
962
982
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
@@ -983,6 +1003,28 @@ export class WorkspaceModule implements Module {
|
|
|
983
1003
|
*/
|
|
984
1004
|
private notDirect = new Map<string, Array<{ path: string; what: string }>>();
|
|
985
1005
|
|
|
1006
|
+
/**
|
|
1007
|
+
* Each agent's latest direct write on each mount it wrote direct, P, with
|
|
1008
|
+
* the notice of its departure from it, N (DirectHead), by mount, then by
|
|
1009
|
+
* agent. Saved at stop() in a global record (headsJournal), not the
|
|
1010
|
+
* module's branch state: they describe disk, which doesn't rewind with a
|
|
1011
|
+
* branch, so a restart on another branch, such as one an offline rollback
|
|
1012
|
+
* made, still finds them.
|
|
1013
|
+
*/
|
|
1014
|
+
private directHeads = new Map<string, Map<string, DirectHead>>();
|
|
1015
|
+
|
|
1016
|
+
/** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
|
|
1017
|
+
private headsJournal: RecordJournal<never, DirectHeadsSnapshot> | null = null;
|
|
1018
|
+
|
|
1019
|
+
/** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
|
|
1020
|
+
private loadedHeads: string | null = null;
|
|
1021
|
+
|
|
1022
|
+
/** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
|
|
1023
|
+
private carriedHeads: DirectHeadsEntry[] = [];
|
|
1024
|
+
|
|
1025
|
+
/** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
|
|
1026
|
+
private historyLookup: ((agentName: string, messageId: string) => boolean) | null = null;
|
|
1027
|
+
|
|
986
1028
|
constructor(config: WorkspaceConfig) {
|
|
987
1029
|
for (const mount of config.mounts) {
|
|
988
1030
|
const cap: unknown = mount.walkCap;
|
|
@@ -999,24 +1041,18 @@ export class WorkspaceModule implements Module {
|
|
|
999
1041
|
if (refused.length > 0) {
|
|
1000
1042
|
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
1001
1043
|
}
|
|
1002
|
-
const include
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
throw new Error(
|
|
1008
|
-
`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
|
|
1009
|
-
);
|
|
1044
|
+
for (const list of ['ignore', 'include'] as const) {
|
|
1045
|
+
const patterns: unknown = mount[list];
|
|
1046
|
+
if (patterns === undefined) continue;
|
|
1047
|
+
if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
|
|
1048
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
|
|
1010
1049
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
+ `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
|
|
1018
|
-
+ `${shownValue(unreadable)} is none of these`,
|
|
1019
|
-
);
|
|
1050
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
1051
|
+
const pattern: unknown = patterns[i];
|
|
1052
|
+
const why = patternRefusal(pattern, list);
|
|
1053
|
+
if (why !== undefined) {
|
|
1054
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
|
|
1055
|
+
}
|
|
1020
1056
|
}
|
|
1021
1057
|
}
|
|
1022
1058
|
}
|
|
@@ -1035,8 +1071,8 @@ export class WorkspaceModule implements Module {
|
|
|
1035
1071
|
const innerPath = resolve(inner.path);
|
|
1036
1072
|
const dir = nestedDir(outerPath, innerPath);
|
|
1037
1073
|
if (dir === undefined) continue;
|
|
1038
|
-
const configured = [{ dir
|
|
1039
|
-
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured
|
|
1074
|
+
const configured = [{ dir }];
|
|
1075
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
|
|
1040
1076
|
console.warn(
|
|
1041
1077
|
`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
1042
1078
|
`at ${dir}/ — passing over it as another mount's directory`,
|
|
@@ -1116,10 +1152,64 @@ export class WorkspaceModule implements Module {
|
|
|
1116
1152
|
this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
1117
1153
|
|
|
1118
1154
|
this.chooseKinds();
|
|
1155
|
+
this.loadDirectHeads(store);
|
|
1119
1156
|
this.applySavedState();
|
|
1120
1157
|
this.ensureRunning();
|
|
1121
1158
|
}
|
|
1122
1159
|
|
|
1160
|
+
/**
|
|
1161
|
+
* The positions stop() last saved (directHeads), before any call can
|
|
1162
|
+
* write: each saved entry is a mount's positions at one root, and a mount
|
|
1163
|
+
* this session configures direct gets the entry at its own root. Every
|
|
1164
|
+
* other entry, a mount configured at another root, not direct, or not at
|
|
1165
|
+
* all, is kept for the next save (carriedHeads), since its writes are
|
|
1166
|
+
* still on disk beneath that root; a mount removed for good, and each
|
|
1167
|
+
* root a mount has moved from, leave that small entry behind. A record
|
|
1168
|
+
* the store can't read is logged, and this session starts with none and
|
|
1169
|
+
* saves none, so the record stands for a session that can read it. One
|
|
1170
|
+
* read but not of the shape saved is logged too, and this session starts
|
|
1171
|
+
* with none and its stop replaces it, so a damaged record doesn't keep
|
|
1172
|
+
* every later session from saving. Either way, a move away from the
|
|
1173
|
+
* writes before it goes untold, as after a crash.
|
|
1174
|
+
*/
|
|
1175
|
+
private loadDirectHeads(store: JsStore): void {
|
|
1176
|
+
const journal = new RecordJournal<never, DirectHeadsSnapshot>(store, { type: DIRECT_HEADS_RECORD_TYPE });
|
|
1177
|
+
const restored = new Map<string, Map<string, DirectHead>>();
|
|
1178
|
+
const carried: DirectHeadsEntry[] = [];
|
|
1179
|
+
let loaded: unknown;
|
|
1180
|
+
try {
|
|
1181
|
+
loaded = journal.load().snapshot;
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
console.error('[workspace] could not read the saved direct-write positions:', err);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
let snapshot: DirectHeadsSnapshot;
|
|
1187
|
+
let text: string | null;
|
|
1188
|
+
try {
|
|
1189
|
+
snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
|
|
1190
|
+
text = canonicalHeads(snapshot);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
console.error("[workspace] the saved direct-write positions aren't of the shape saved, so this session starts with none and its stop replaces them:", err);
|
|
1193
|
+
snapshot = [];
|
|
1194
|
+
text = null;
|
|
1195
|
+
}
|
|
1196
|
+
for (const saved of snapshot) {
|
|
1197
|
+
// Only a direct mount's writes are the agent's on disk, and a
|
|
1198
|
+
// store-backed one's disk follows its store.
|
|
1199
|
+
const mount = this.mounts.get(saved.mount);
|
|
1200
|
+
if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
|
|
1201
|
+
carried.push(saved);
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) =>
|
|
1205
|
+
[agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
|
|
1206
|
+
}
|
|
1207
|
+
this.headsJournal = journal;
|
|
1208
|
+
this.loadedHeads = text;
|
|
1209
|
+
this.carriedHeads = carried;
|
|
1210
|
+
for (const [name, heads] of restored) this.directHeads.set(name, heads);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1123
1213
|
/**
|
|
1124
1214
|
* Whether each mount configured direct runs direct: not while the store
|
|
1125
1215
|
* holds changes there that disk lacks, on the branch selected then, so
|
|
@@ -1272,7 +1362,7 @@ export class WorkspaceModule implements Module {
|
|
|
1272
1362
|
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
1273
1363
|
let start = base;
|
|
1274
1364
|
for (const segment of beneath.split('/')) {
|
|
1275
|
-
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
|
|
1365
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined)) break;
|
|
1276
1366
|
const here = await checkDir(view, rootReal, start);
|
|
1277
1367
|
if (here.kind !== 'checked') break;
|
|
1278
1368
|
const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
|
|
@@ -1325,11 +1415,10 @@ export class WorkspaceModule implements Module {
|
|
|
1325
1415
|
|
|
1326
1416
|
async start(ctx: ModuleContext): Promise<void> {
|
|
1327
1417
|
// Which mounts are nested where, with the roots placed where disk has
|
|
1328
|
-
// them and each
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
// sets nothing.
|
|
1418
|
+
// them, and whether each mount's root folds case, read where disk has
|
|
1419
|
+
// it, before this start sets anything, so a restart takes no call
|
|
1420
|
+
// and walks or watches nothing before they're known. A stop begun
|
|
1421
|
+
// meanwhile stands, and this start then sets nothing.
|
|
1333
1422
|
const stops = this.stopsBegun;
|
|
1334
1423
|
const { nesting, reals, above } = await this.readNesting();
|
|
1335
1424
|
const rootFoldsCase = new Map<string, boolean>();
|
|
@@ -1445,7 +1534,8 @@ export class WorkspaceModule implements Module {
|
|
|
1445
1534
|
return (
|
|
1446
1535
|
`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
1447
1536
|
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
1448
|
-
`never had.
|
|
1537
|
+
`never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
|
|
1538
|
+
'if the current branch is what disk should hold, pass force: true to overwrite disk from it.'
|
|
1449
1539
|
);
|
|
1450
1540
|
}
|
|
1451
1541
|
|
|
@@ -1612,8 +1702,8 @@ export class WorkspaceModule implements Module {
|
|
|
1612
1702
|
const message = (one
|
|
1613
1703
|
? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
|
|
1614
1704
|
: "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
|
|
1615
|
-
"sync its path to take disk's copy, or
|
|
1616
|
-
`follow the answer
|
|
1705
|
+
"sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
|
|
1706
|
+
`and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
|
|
1617
1707
|
`${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
|
|
1618
1708
|
console.warn(`[workspace] ${message}`);
|
|
1619
1709
|
this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
|
|
@@ -1683,18 +1773,58 @@ export class WorkspaceModule implements Module {
|
|
|
1683
1773
|
}
|
|
1684
1774
|
this.ctx.setState(state);
|
|
1685
1775
|
}
|
|
1776
|
+
this.saveDirectHeads();
|
|
1686
1777
|
this.ctx = null;
|
|
1687
1778
|
}
|
|
1688
1779
|
|
|
1689
|
-
/**
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
|
|
1780
|
+
/**
|
|
1781
|
+
* Save directHeads as one global record, the latest of which the next
|
|
1782
|
+
* start loads (loadDirectHeads): each mount's positions with the root
|
|
1783
|
+
* they were made under, and the entries this session didn't take up
|
|
1784
|
+
* (carriedHeads), unchanged. Only when that differs from what the
|
|
1785
|
+
* start loaded, so a session that changed nothing, or a store no direct
|
|
1786
|
+
* write reached, adds no record, and always where the record read wasn't
|
|
1787
|
+
* of the shape saved, which this replaces. Nothing where the store couldn't
|
|
1788
|
+
* read the record, so it stands. A save that fails is logged, and the next
|
|
1789
|
+
* start finds the positions of the last save that landed, as after a
|
|
1790
|
+
* crash.
|
|
1791
|
+
*/
|
|
1792
|
+
private saveDirectHeads(): void {
|
|
1793
|
+
if (!this.headsJournal) return;
|
|
1794
|
+
const snapshot: DirectHeadsSnapshot = [
|
|
1795
|
+
...this.carriedHeads,
|
|
1796
|
+
...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
|
|
1797
|
+
mount: name,
|
|
1798
|
+
root: resolve(this.mounts.get(name)!.config.path),
|
|
1799
|
+
agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
|
|
1800
|
+
})),
|
|
1801
|
+
];
|
|
1802
|
+
const text = canonicalHeads(snapshot);
|
|
1803
|
+
if (text === this.loadedHeads) return;
|
|
1804
|
+
try {
|
|
1805
|
+
this.headsJournal.checkpoint(snapshot);
|
|
1806
|
+
this.loadedHeads = text;
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
console.error('[workspace] could not save the direct-write positions:', err);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** Mount names + modes, each mount's include where it sets one, and
|
|
1813
|
+
* `backend: 'direct'` for one configured direct, whether it runs direct
|
|
1814
|
+
* this session or, while the store holds work there disk lacks, as
|
|
1815
|
+
* 'chronicle': either way it's a directory agents write directly when it
|
|
1816
|
+
* runs direct, whose files an undo leaves. For the
|
|
1817
|
+
* framework's tool-result spills, saved payloads and fetched references,
|
|
1818
|
+
* which need a mount for files of their own: the first writable one
|
|
1819
|
+
* without an include that isn't direct (firstWritableMountName), without
|
|
1820
|
+
* reaching into private state. */
|
|
1821
|
+
getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[]; backend?: 'direct' }> {
|
|
1694
1822
|
return [...this.mounts.values()].map((m) => ({
|
|
1695
1823
|
name: m.config.name,
|
|
1696
|
-
|
|
1824
|
+
// Read-only where this session takes no writes there (writeRefusal).
|
|
1825
|
+
mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
|
|
1697
1826
|
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
1827
|
+
...(m.config.backend === 'direct' ? { backend: 'direct' as const } : {}),
|
|
1698
1828
|
}));
|
|
1699
1829
|
}
|
|
1700
1830
|
|
|
@@ -1702,10 +1832,19 @@ export class WorkspaceModule implements Module {
|
|
|
1702
1832
|
// Tool Definitions
|
|
1703
1833
|
// ==========================================================================
|
|
1704
1834
|
|
|
1835
|
+
/**
|
|
1836
|
+
* Whether any of this host's mounts runs direct. The tools' descriptions
|
|
1837
|
+
* and grep's notes mention direct mounts only on such a host, so both ask
|
|
1838
|
+
* this one question.
|
|
1839
|
+
*/
|
|
1840
|
+
private anyRunsDirect(): boolean {
|
|
1841
|
+
return [...this.mounts.values()].some((m) => m.direct);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1705
1844
|
getTools(): ToolDefinition[] {
|
|
1706
1845
|
// Where a mount runs direct, four descriptions say what that changes.
|
|
1707
1846
|
// A host with none keeps them as they were, and its residents' caches.
|
|
1708
|
-
const direct =
|
|
1847
|
+
const direct = this.anyRunsDirect();
|
|
1709
1848
|
const lsDirect = direct
|
|
1710
1849
|
? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
|
|
1711
1850
|
+ '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.'
|
|
@@ -1716,6 +1855,23 @@ export class WorkspaceModule implements Module {
|
|
|
1716
1855
|
: '';
|
|
1717
1856
|
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.' : '';
|
|
1718
1857
|
const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
|
|
1858
|
+
// Where a direct mount is writable, the four writing tools say what a
|
|
1859
|
+
// write is there: on disk when it answers, and left there by a head move.
|
|
1860
|
+
const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
|
|
1861
|
+
const writeDirect = writable
|
|
1862
|
+
? ' On a direct mount, the file is on disk, whole and with its mode kept, when the call answers, and an undo or other head move leaves it there.'
|
|
1863
|
+
: '';
|
|
1864
|
+
const editDirect = writable
|
|
1865
|
+
? " On a direct mount, the edit applies to disk's text as it is now and is on disk when the call answers; a change another writer makes "
|
|
1866
|
+
+ 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
|
|
1867
|
+
: '';
|
|
1868
|
+
const editManyDirect = writable
|
|
1869
|
+
? " On a direct mount, every edit is checked against disk's text before any file is written, and then the files are written one by one: "
|
|
1870
|
+
+ "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
|
|
1871
|
+
+ 'An undo or other head move leaves them on disk.'
|
|
1872
|
+
: '';
|
|
1873
|
+
const deleteDirect = writable ? ' On a direct mount, the file is removed from disk at once, and an undo or other head move doesn\'t bring it back.' : '';
|
|
1874
|
+
const materializeDirect = writable ? ' A direct mount writes its files to disk as they\'re made, so materialize passes over it, under `skipped` where you name it.' : '';
|
|
1719
1875
|
return [
|
|
1720
1876
|
{
|
|
1721
1877
|
name: 'read',
|
|
@@ -1749,7 +1905,7 @@ export class WorkspaceModule implements Module {
|
|
|
1749
1905
|
},
|
|
1750
1906
|
{
|
|
1751
1907
|
name: 'write',
|
|
1752
|
-
description: 'Create or overwrite a file in the workspace.',
|
|
1908
|
+
description: 'Create or overwrite a file in the workspace.' + writeDirect,
|
|
1753
1909
|
inputSchema: {
|
|
1754
1910
|
type: 'object' as const,
|
|
1755
1911
|
properties: {
|
|
@@ -1770,7 +1926,7 @@ export class WorkspaceModule implements Module {
|
|
|
1770
1926
|
+ "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
|
|
1771
1927
|
+ 'A change disk made to the file is taken in first, and the answer says so. '
|
|
1772
1928
|
+ 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
|
|
1773
|
-
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
|
|
1929
|
+
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
|
|
1774
1930
|
inputSchema: {
|
|
1775
1931
|
type: 'object' as const,
|
|
1776
1932
|
properties: {
|
|
@@ -1792,7 +1948,8 @@ export class WorkspaceModule implements Module {
|
|
|
1792
1948
|
+ "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
|
|
1793
1949
|
+ 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
|
|
1794
1950
|
+ "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
|
|
1795
|
-
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
|
|
1951
|
+
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
|
|
1952
|
+
+ editManyDirect,
|
|
1796
1953
|
inputSchema: {
|
|
1797
1954
|
type: 'object' as const,
|
|
1798
1955
|
properties: {
|
|
@@ -1821,7 +1978,7 @@ export class WorkspaceModule implements Module {
|
|
|
1821
1978
|
name: 'delete',
|
|
1822
1979
|
description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
|
|
1823
1980
|
+ 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
|
|
1824
|
-
+ 'or a sync of the path restores it.',
|
|
1981
|
+
+ 'or a sync of the path restores it.' + deleteDirect,
|
|
1825
1982
|
inputSchema: {
|
|
1826
1983
|
type: 'object' as const,
|
|
1827
1984
|
properties: {
|
|
@@ -1874,7 +2031,7 @@ export class WorkspaceModule implements Module {
|
|
|
1874
2031
|
properties: {
|
|
1875
2032
|
pattern: { type: 'string', description: 'Regular expression pattern' },
|
|
1876
2033
|
path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
|
|
1877
|
-
glob: { type: 'string', description: 'Glob pattern to filter files
|
|
2034
|
+
glob: { type: 'string', description: 'Glob pattern to filter files: one with a "/" is matched from the mount\'s root, whatever the path (e.g., "src/**/*.ts"), and in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), in every other mount as written; one with no "/" also matches a file\'s name at any depth (e.g., "*.ts")' },
|
|
1878
2035
|
contextBefore: { type: 'integer', description: 'Context lines before match' },
|
|
1879
2036
|
contextAfter: { type: 'integer', description: 'Context lines after match' },
|
|
1880
2037
|
},
|
|
@@ -1896,7 +2053,7 @@ export class WorkspaceModule implements Module {
|
|
|
1896
2053
|
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
|
|
1897
2054
|
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
|
|
1898
2055
|
+ 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
|
|
1899
|
-
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
|
|
2056
|
+
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
|
|
1900
2057
|
inputSchema: {
|
|
1901
2058
|
type: 'object' as const,
|
|
1902
2059
|
properties: {
|
|
@@ -2017,7 +2174,13 @@ export class WorkspaceModule implements Module {
|
|
|
2017
2174
|
return work;
|
|
2018
2175
|
}
|
|
2019
2176
|
|
|
2020
|
-
private
|
|
2177
|
+
private runToolCall(call: ToolCall): Promise<ToolResult> {
|
|
2178
|
+
const gate = { passed: false };
|
|
2179
|
+
return callGate.run(gate, () => this.answerToolCall(call, gate));
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
private async answerToolCall(call: ToolCall, gate: { passed: boolean }): Promise<ToolResult> {
|
|
2183
|
+
const reopenAtStart = this.agreement?.awaitsReopen === true;
|
|
2021
2184
|
try {
|
|
2022
2185
|
// Every handler reads its input as its schema describes it; a call
|
|
2023
2186
|
// that sends something else is refused here, before anything is read.
|
|
@@ -2027,21 +2190,26 @@ export class WorkspaceModule implements Module {
|
|
|
2027
2190
|
return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
|
|
2028
2191
|
}
|
|
2029
2192
|
const input = call.input as Record<string, unknown>;
|
|
2030
|
-
|
|
2193
|
+
// The most of this call's result that can reach its caller whole: past
|
|
2194
|
+
// it, a model's result spills into a file, and a script gets
|
|
2195
|
+
// ResultTooLarge in its place. A call made in process carries none and
|
|
2196
|
+
// takes the data itself, so its read pages by its line limit alone.
|
|
2197
|
+
const resultMaxChars = call.resultMaxChars;
|
|
2198
|
+
const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
2031
2199
|
if (generated) {
|
|
2032
2200
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
2033
|
-
return await this.handleRead(input as unknown as ReadInput, generated.read());
|
|
2201
|
+
return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
|
|
2034
2202
|
}
|
|
2035
|
-
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
2203
|
+
return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path as string, generated, call.callerAgentName)) };
|
|
2036
2204
|
}
|
|
2037
2205
|
|
|
2038
2206
|
switch (call.name) {
|
|
2039
|
-
case 'read': return await this.handleRead(input as unknown as ReadInput);
|
|
2207
|
+
case 'read': return await this.handleRead(input as unknown as ReadInput, resultMaxChars);
|
|
2040
2208
|
case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
|
|
2041
|
-
case 'write': return await this.handleWrite(input as unknown as WriteInput);
|
|
2042
|
-
case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars);
|
|
2209
|
+
case 'write': return await this.handleWrite(input as unknown as WriteInput, call.callerAgentName);
|
|
2210
|
+
case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars, call.callerAgentName);
|
|
2043
2211
|
case 'edit_many': return await this.handleEditMany(input as unknown as EditManyInput, call.resultMaxChars, call.callerAgentName);
|
|
2044
|
-
case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
|
|
2212
|
+
case 'delete': return await this.handleDelete(input as unknown as DeleteInput, call.callerAgentName);
|
|
2045
2213
|
case 'ls': return await this.handleLs(input as unknown as LsInput);
|
|
2046
2214
|
case 'glob': return await this.handleGlob(input as unknown as GlobInput);
|
|
2047
2215
|
case 'grep': return await this.handleGrep(input as unknown as GrepInput);
|
|
@@ -2049,13 +2217,41 @@ export class WorkspaceModule implements Module {
|
|
|
2049
2217
|
case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
|
|
2050
2218
|
case 'sync': return await this.handleSync(input as unknown as SyncInput);
|
|
2051
2219
|
default:
|
|
2052
|
-
return { success: false, error:
|
|
2220
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
2053
2221
|
}
|
|
2054
2222
|
} catch (err) {
|
|
2055
|
-
return { success: false, error: err
|
|
2223
|
+
return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
private failureAnswer(call: ToolCall, err: unknown, reopenAtStart: boolean, passedGate: boolean): string {
|
|
2228
|
+
const input = (typeof call.input === 'object' && call.input !== null ? call.input : {}) as { path?: unknown; mount?: unknown };
|
|
2229
|
+
const path = typeof input.path === 'string' ? input.path : undefined;
|
|
2230
|
+
const mountName = path !== undefined ? path.split('/')[0]! : typeof input.mount === 'string' ? input.mount : undefined;
|
|
2231
|
+
const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
|
|
2232
|
+
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
|
|
2233
|
+
if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
|
|
2234
|
+
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
|
|
2235
|
+
}
|
|
2236
|
+
if (err instanceof Refusal) return didNothing(call.name, err.message);
|
|
2237
|
+
if (err instanceof WorkspaceReadError) return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
|
|
2238
|
+
const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
|
|
2239
|
+
: mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
|
|
2240
|
+
: 'ls of each mount, with recursive: true, shows how each file stands against disk now';
|
|
2241
|
+
if (err instanceof NotStored || err instanceof AlreadyHeld) {
|
|
2242
|
+
console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
|
|
2243
|
+
return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
|
|
2244
|
+
}
|
|
2245
|
+
console.error(`[workspace] ${call.name} failed:`, err);
|
|
2246
|
+
if (err instanceof CommitUnknownError) {
|
|
2247
|
+
return unexpectedAnswer(call.name, subject, err.setCause, {
|
|
2248
|
+
said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
|
|
2249
|
+
});
|
|
2056
2250
|
}
|
|
2251
|
+
return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
|
|
2057
2252
|
}
|
|
2058
2253
|
|
|
2254
|
+
|
|
2059
2255
|
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
2060
2256
|
// Wake agents on filesystem events when the originating mount opted in.
|
|
2061
2257
|
// The EventGate still has final say — gate policies can further filter by
|
|
@@ -2100,7 +2296,7 @@ export class WorkspaceModule implements Module {
|
|
|
2100
2296
|
text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
2101
2297
|
}
|
|
2102
2298
|
if (conflicts && conflicts.length > 0) {
|
|
2103
|
-
text += `\n[conflicts: ${conflicts.join(', ')}]`;
|
|
2299
|
+
text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
2104
2300
|
}
|
|
2105
2301
|
|
|
2106
2302
|
return {
|
|
@@ -2151,16 +2347,15 @@ export class WorkspaceModule implements Module {
|
|
|
2151
2347
|
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
2152
2348
|
try {
|
|
2153
2349
|
// Synchronous, so the path as given. The nested-mount check takes it
|
|
2154
|
-
// in
|
|
2155
|
-
//
|
|
2156
|
-
//
|
|
2157
|
-
// include judges it as given, so on a disk that
|
|
2350
|
+
// in any spelling a disk that folds names may fold to the nested
|
|
2351
|
+
// mount's directory, erring toward that mount (holderOf). The include
|
|
2352
|
+
// judges it as given, so on a disk that
|
|
2158
2353
|
// folds names a covered name whose file disk spells otherwise returns
|
|
2159
2354
|
// that file's path, which the tools refuse, and a covered file named in
|
|
2160
2355
|
// another case is null, which the tools read. A path the mount doesn't
|
|
2161
2356
|
// hold, outside its include or in another mount's directory nested in
|
|
2162
2357
|
// it, is null, as for a path that leaves the mount's directory.
|
|
2163
|
-
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath
|
|
2358
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
|
|
2164
2359
|
return resolve(mount.config.path, relativePath);
|
|
2165
2360
|
} catch {
|
|
2166
2361
|
return null;
|
|
@@ -2182,6 +2377,15 @@ export class WorkspaceModule implements Module {
|
|
|
2182
2377
|
* withheld original this way, for the branch its stub is on
|
|
2183
2378
|
* (agent-framework #277).
|
|
2184
2379
|
*
|
|
2380
|
+
* On a mount running direct, the bytes go to disk as a direct `write`'s
|
|
2381
|
+
* do, whole or not at all, and nothing goes into the store: `branch` is
|
|
2382
|
+
* checked before the write starts and again just before its rename, and
|
|
2383
|
+
* `agentName` names the agent whose write it is for the head-move notice
|
|
2384
|
+
* (a write naming none tells no agent).
|
|
2385
|
+
*
|
|
2386
|
+
* A success's `disk` says where the file stands on disk as the workspace
|
|
2387
|
+
* last saw it, as the write tool's answer does.
|
|
2388
|
+
*
|
|
2185
2389
|
* A write whose tree entry is set but that then fails returns success
|
|
2186
2390
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2187
2391
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -2195,7 +2399,7 @@ export class WorkspaceModule implements Module {
|
|
|
2195
2399
|
mountPrefixedPath: string,
|
|
2196
2400
|
data: Buffer,
|
|
2197
2401
|
mimeType: string,
|
|
2198
|
-
options: { branch?: string } = {},
|
|
2402
|
+
options: { branch?: string; agentName?: string } = {},
|
|
2199
2403
|
): Promise<ToolResult> {
|
|
2200
2404
|
if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
|
|
2201
2405
|
return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
|
|
@@ -2205,7 +2409,7 @@ export class WorkspaceModule implements Module {
|
|
|
2205
2409
|
mountPrefixedPath: string,
|
|
2206
2410
|
data: Buffer,
|
|
2207
2411
|
mimeType: string,
|
|
2208
|
-
options: { branch?: string },
|
|
2412
|
+
options: { branch?: string; agentName?: string },
|
|
2209
2413
|
): Promise<ToolResult> {
|
|
2210
2414
|
let mount: MountState;
|
|
2211
2415
|
let relativePath: string;
|
|
@@ -2218,20 +2422,33 @@ export class WorkspaceModule implements Module {
|
|
|
2218
2422
|
isError: true,
|
|
2219
2423
|
};
|
|
2220
2424
|
}
|
|
2221
|
-
if (
|
|
2222
|
-
|
|
2223
|
-
}
|
|
2425
|
+
if (relativePath === '') return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
|
|
2426
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
2427
|
+
if (refusal) return { success: false, error: refusal, isError: true };
|
|
2224
2428
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2225
2429
|
if (data.byteLength > maxSize) {
|
|
2226
|
-
return { success: false, error:
|
|
2430
|
+
return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
|
|
2227
2431
|
}
|
|
2228
2432
|
const store = this.getStore();
|
|
2433
|
+
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
2434
|
+
// as the caller's agent's write, where it names one. A branch it names
|
|
2435
|
+
// is checked just before the rename, since disk is no branch's.
|
|
2436
|
+
if (mount.direct) {
|
|
2437
|
+
// Checked first too, so a request already for a left branch makes no
|
|
2438
|
+
// directory and stages nothing.
|
|
2439
|
+
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
2440
|
+
return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
|
|
2441
|
+
}
|
|
2442
|
+
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2443
|
+
if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
|
|
2444
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
2445
|
+
}
|
|
2229
2446
|
return this.withMount(mount, async () => {
|
|
2230
2447
|
await this.settleBeforeMutation(mount, relativePath);
|
|
2231
2448
|
// Checked inside the mount's turn, after anything the write waited on:
|
|
2232
2449
|
// commitToolChange sets the tree entry before it first yields.
|
|
2233
2450
|
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
2234
|
-
return { success: false, error:
|
|
2451
|
+
return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
|
|
2235
2452
|
}
|
|
2236
2453
|
const blobHash = store.storeBlob(data, mimeType);
|
|
2237
2454
|
// An overwrite keeps the file's mode, as the write tool's does.
|
|
@@ -2239,20 +2456,24 @@ export class WorkspaceModule implements Module {
|
|
|
2239
2456
|
let committed = false;
|
|
2240
2457
|
let failure: string | null;
|
|
2241
2458
|
let modeKept: string | null = null;
|
|
2459
|
+
let disk: DiskState | null = null;
|
|
2242
2460
|
try {
|
|
2243
2461
|
const answer = await this.commitToolChange(mount, relativePath,
|
|
2244
2462
|
{ kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
|
|
2245
2463
|
modeKept = answer.modeKept;
|
|
2464
|
+
disk = answer.disk;
|
|
2246
2465
|
failure = answer.refused === null ? null
|
|
2247
2466
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
2248
2467
|
} catch (error) {
|
|
2249
2468
|
if (!committed) throw error;
|
|
2250
2469
|
// As write words it: a store that failed after recording the file
|
|
2251
2470
|
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2471
|
+
const reopen = this.agreement?.awaitsReopen === true;
|
|
2252
2472
|
failure = error instanceof StoredThenFailed
|
|
2253
2473
|
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
2254
|
-
+ (error.pushes ?
|
|
2474
|
+
+ (error.pushes ? ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
2255
2475
|
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
2476
|
+
if (reopen) failure += ` ${AWAITS_REOPEN}`;
|
|
2256
2477
|
}
|
|
2257
2478
|
if (failure !== null) {
|
|
2258
2479
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -2262,17 +2483,21 @@ export class WorkspaceModule implements Module {
|
|
|
2262
2483
|
}
|
|
2263
2484
|
return {
|
|
2264
2485
|
success: true,
|
|
2265
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
|
|
2486
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
|
|
2266
2487
|
};
|
|
2267
2488
|
});
|
|
2268
2489
|
}
|
|
2269
2490
|
|
|
2270
2491
|
/**
|
|
2271
|
-
* Read a file's raw bytes from a mount
|
|
2272
|
-
*
|
|
2273
|
-
* readBinaryFromDisk reads it, at most the mount's
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2492
|
+
* Read a file's raw bytes from a mount: through the Chronicle tree, a path
|
|
2493
|
+
* the workspace doesn't hold observed first (ensureSynced); or, on a direct
|
|
2494
|
+
* mount, from disk as readBinaryFromDisk reads it, at most the mount's
|
|
2495
|
+
* `maxFileSize`. A path the workspace holds is read as the workspace holds
|
|
2496
|
+
* it, not settled as the `read` tool settles it: the tree takes in no
|
|
2497
|
+
* binary disk copy, so a pass over a held binary file that disk changed
|
|
2498
|
+
* would drop the workspace's copy. Public API for the framework's
|
|
2499
|
+
* synthesized `read_image` tool, where the workspace module doesn't serve
|
|
2500
|
+
* read_image itself, and other peer callers that need binary content.
|
|
2276
2501
|
*/
|
|
2277
2502
|
async readBinary(
|
|
2278
2503
|
mountPrefixedPath: string,
|
|
@@ -2297,10 +2522,12 @@ export class WorkspaceModule implements Module {
|
|
|
2297
2522
|
return this.withMount(mount, async () => {
|
|
2298
2523
|
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
2299
2524
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2300
|
-
if (!entry)
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2525
|
+
if (!entry) {
|
|
2526
|
+
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
|
|
2527
|
+
}
|
|
2528
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2529
|
+
if ('lost' in contents) return { error: nothingToRead(contents.lost) };
|
|
2530
|
+
return { data: contents.bytes };
|
|
2304
2531
|
});
|
|
2305
2532
|
}
|
|
2306
2533
|
|
|
@@ -2383,9 +2610,9 @@ export class WorkspaceModule implements Module {
|
|
|
2383
2610
|
}
|
|
2384
2611
|
}
|
|
2385
2612
|
|
|
2386
|
-
/** The directories of the mounts nested inside a mount, mount-relative
|
|
2613
|
+
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
2387
2614
|
private othersOf(mountName: string): string[] {
|
|
2388
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2615
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
|
|
2389
2616
|
}
|
|
2390
2617
|
|
|
2391
2618
|
/**
|
|
@@ -2401,13 +2628,8 @@ export class WorkspaceModule implements Module {
|
|
|
2401
2628
|
* alone, with no real root, since through it it would claim the other's
|
|
2402
2629
|
* every path.
|
|
2403
2630
|
*
|
|
2404
|
-
* Each way it lies there is a spelling of its directory (`fromRoots`)
|
|
2405
|
-
*
|
|
2406
|
-
* disk that folds names, the outer mount's walks name the directory as
|
|
2407
|
-
* disk does, and so does a path parsePath has respelled, while a path given
|
|
2408
|
-
* as configured still names it as configured (resolveAbsolutePath). A
|
|
2409
|
-
* directory not on disk keeps the spellings the roots give alone. Each
|
|
2410
|
-
* mount's real root comes back beside the nesting (`reals`), for what
|
|
2631
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`).
|
|
2632
|
+
* Each mount's real root comes back beside the nesting (`reals`), for what
|
|
2411
2633
|
* start reads there, and each directory above each configured path with
|
|
2412
2634
|
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2413
2635
|
* (movedNesting).
|
|
@@ -2432,16 +2654,15 @@ export class WorkspaceModule implements Module {
|
|
|
2432
2654
|
for (const inner of this.config.mounts) {
|
|
2433
2655
|
if (outer === inner) continue;
|
|
2434
2656
|
const real = reals.get(inner.name)!;
|
|
2435
|
-
const at =
|
|
2657
|
+
const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!);
|
|
2436
2658
|
if (at === undefined) continue;
|
|
2437
|
-
const { configured, fromRoots,
|
|
2438
|
-
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots
|
|
2659
|
+
const { configured, fromRoots, claims } = at;
|
|
2660
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
|
|
2439
2661
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2440
2662
|
if (placed.length > 0) {
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
);
|
|
2663
|
+
const at = (sure: boolean) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
|
|
2664
|
+
const where = [at(true) && `where it lies on disk, at ${at(true)}`, at(false) && `where it may lie on disk, at ${at(false)}`].filter(Boolean).join(', and ');
|
|
2665
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
|
|
2445
2666
|
}
|
|
2446
2667
|
}
|
|
2447
2668
|
}
|
|
@@ -2450,11 +2671,11 @@ export class WorkspaceModule implements Module {
|
|
|
2450
2671
|
|
|
2451
2672
|
/**
|
|
2452
2673
|
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2453
|
-
* has one, and include (MountView's `nested`)
|
|
2674
|
+
* has one, and include (MountView's `nested`).
|
|
2454
2675
|
*/
|
|
2455
2676
|
private nestedOf(mountName: string): NestedMount[] {
|
|
2456
2677
|
return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
|
|
2457
|
-
o.
|
|
2678
|
+
o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
|
|
2458
2679
|
}
|
|
2459
2680
|
|
|
2460
2681
|
/**
|
|
@@ -2490,8 +2711,10 @@ export class WorkspaceModule implements Module {
|
|
|
2490
2711
|
* mount nested by its directories alone, since its real root holds this
|
|
2491
2712
|
* one, is judged by the parse's nesting, so a root repointed between a
|
|
2492
2713
|
* read's parse and its open can have it serve a path in that mount's
|
|
2493
|
-
* directory
|
|
2494
|
-
*
|
|
2714
|
+
* directory, and one repointed between a direct write's parse and its
|
|
2715
|
+
* staging can have the write land there. Each mount is placed as a start
|
|
2716
|
+
* places it (placementOf), from where its real root and the directories
|
|
2717
|
+
* above its configured path lie on
|
|
2495
2718
|
* disk. For a mount configured at this mount's own path or beneath it,
|
|
2496
2719
|
* that's where they lie now: beneath `rootReal` where they lie beneath this
|
|
2497
2720
|
* mount's path, and the directories above this mount's path as resolving
|
|
@@ -2551,10 +2774,12 @@ export class WorkspaceModule implements Module {
|
|
|
2551
2774
|
if (real === undefined) continue;
|
|
2552
2775
|
above = placedAbove.get(inner.name) ?? [];
|
|
2553
2776
|
}
|
|
2554
|
-
const at =
|
|
2777
|
+
const at = placementOf(outerPath, rootReal, innerPath, real, above);
|
|
2555
2778
|
if (at === undefined) continue;
|
|
2556
2779
|
const include = this.mounts.get(inner.name)?.config.include;
|
|
2557
|
-
for (const s of at.
|
|
2780
|
+
for (const s of at.fromRoots) {
|
|
2781
|
+
nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2782
|
+
}
|
|
2558
2783
|
}
|
|
2559
2784
|
return nested;
|
|
2560
2785
|
}
|
|
@@ -2564,33 +2789,22 @@ export class WorkspaceModule implements Module {
|
|
|
2564
2789
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2565
2790
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2566
2791
|
* outer mount isn't followed), with the path to use there; undefined when
|
|
2567
|
-
* no nested mount's directory holds it.
|
|
2568
|
-
* compared with the directory's spellings exactly. One judged as given
|
|
2569
|
-
* (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
|
|
2570
|
-
* folds names may compare it (beginsWithDir), so it can't reach that
|
|
2571
|
-
* mount's directory by spelling a segment otherwise. On a disk that tells
|
|
2572
|
-
* names apart, that takes a directory of this mount's own spelled like the
|
|
2573
|
-
* nested one in another way as that mount's too.
|
|
2792
|
+
* no nested mount's directory holds it.
|
|
2574
2793
|
*/
|
|
2575
|
-
private holderOf(mountName: string, relativePath: string,
|
|
2794
|
+
private holderOf(mountName: string, relativePath: string, moved: NestedMount[] | null = null): { name: string; use: string; guess?: NestedGuess } | undefined {
|
|
2576
2795
|
const mount = this.mounts.get(mountName);
|
|
2577
2796
|
if (!mount) return undefined;
|
|
2578
2797
|
const root = resolve(mount.config.path);
|
|
2579
2798
|
const resolved = resolve(root, relativePath);
|
|
2580
2799
|
if (!isContainedPath(root, resolved)) return undefined;
|
|
2581
|
-
const
|
|
2800
|
+
const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
|
|
2582
2801
|
// A direct mount whose root a call found moved is judged by that call's
|
|
2583
2802
|
// nesting (movedNesting), each directory beneath its configured path.
|
|
2584
|
-
const
|
|
2585
|
-
? moved.map((n) => ({ name: n.name, dir: n.dir }))
|
|
2586
|
-
: (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
|
|
2587
|
-
const holder = dirs
|
|
2588
|
-
.filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
|
|
2589
|
-
.map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
|
|
2590
|
-
.sort((a, b) => b.depth - a.depth)[0];
|
|
2803
|
+
const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
|
|
2591
2804
|
if (!holder) return undefined;
|
|
2592
|
-
const rest =
|
|
2593
|
-
|
|
2805
|
+
const rest = restBeneath(path, holder);
|
|
2806
|
+
const guess = nestedGuess(mountName, path.split('/'), holder);
|
|
2807
|
+
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
|
|
2594
2808
|
}
|
|
2595
2809
|
|
|
2596
2810
|
/**
|
|
@@ -2599,10 +2813,10 @@ export class WorkspaceModule implements Module {
|
|
|
2599
2813
|
* it; for a listing, whose the path is.
|
|
2600
2814
|
*/
|
|
2601
2815
|
private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
|
|
2602
|
-
const holder = this.holderOf(mountName, relativePath,
|
|
2816
|
+
const holder = this.holderOf(mountName, relativePath, moved);
|
|
2603
2817
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2604
2818
|
return holder
|
|
2605
|
-
? `it lies in mount "${holder.name}", nested in this one${what}:
|
|
2819
|
+
? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
|
|
2606
2820
|
: `it lies in another mount's directory${what}`;
|
|
2607
2821
|
}
|
|
2608
2822
|
|
|
@@ -2622,7 +2836,7 @@ export class WorkspaceModule implements Module {
|
|
|
2622
2836
|
// Both checks judge by the nesting the parse found, and the second reads
|
|
2623
2837
|
// disk beneath the root the parse resolved.
|
|
2624
2838
|
const { mount, root, moved } = spelled;
|
|
2625
|
-
const parsed = this.refuseNotHeld(spelled, path,
|
|
2839
|
+
const parsed = this.refuseNotHeld(spelled, path, moved);
|
|
2626
2840
|
// A path can also reach a nested mount's directory, or a file this
|
|
2627
2841
|
// mount's own include leaves out, through a symlink on its way. No push
|
|
2628
2842
|
// of this mount writes it there and no read reads it, so it's refused
|
|
@@ -2662,7 +2876,8 @@ export class WorkspaceModule implements Module {
|
|
|
2662
2876
|
// operation, in the words a disk read gives a path it can't resolve,
|
|
2663
2877
|
// whatever the place realpath stopped short of would say.
|
|
2664
2878
|
if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2665
|
-
|
|
2879
|
+
let holder = await nestedAtReal(real, nested);
|
|
2880
|
+
if (holder?.guess === null) holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
|
|
2666
2881
|
if (!holder) {
|
|
2667
2882
|
if (!judged) return null;
|
|
2668
2883
|
const rootReal = await realBeneath(root);
|
|
@@ -2678,11 +2893,11 @@ export class WorkspaceModule implements Module {
|
|
|
2678
2893
|
// on the place's way as that mount's, so a place beneath anything else
|
|
2679
2894
|
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2680
2895
|
// a directory not there yet is.
|
|
2681
|
-
const onItsWay = holder.
|
|
2896
|
+
const onItsWay = !holder.judgedByInclude
|
|
2682
2897
|
|| await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
|
|
2683
|
-
const directory = holder.
|
|
2684
|
-
if (!onItsWay || !
|
|
2685
|
-
return new NestedMountError(path, mount.config.name, holder
|
|
2898
|
+
const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2899
|
+
if (!onItsWay || !holder.names(directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2900
|
+
return new NestedMountError(path, mount.config.name, holder, 'leads into');
|
|
2686
2901
|
}
|
|
2687
2902
|
|
|
2688
2903
|
/**
|
|
@@ -2690,13 +2905,12 @@ export class WorkspaceModule implements Module {
|
|
|
2690
2905
|
* mount's directory first, naming the mount that holds it, then outside
|
|
2691
2906
|
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2692
2907
|
* as disk spells it for parseFilePath, as given for resolveAbsolutePath,
|
|
2693
|
-
* which is synchronous
|
|
2694
|
-
* folds where disk does). The include's refusal shows the path as given
|
|
2908
|
+
* which is synchronous. The include's refusal shows the path as given
|
|
2695
2909
|
* (`given`), since it answers the path the caller asked about.
|
|
2696
2910
|
*/
|
|
2697
|
-
private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string,
|
|
2698
|
-
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath,
|
|
2699
|
-
if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder
|
|
2911
|
+
private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, moved: NestedMount[] | null = null): P {
|
|
2912
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
|
|
2913
|
+
if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
|
|
2700
2914
|
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2701
2915
|
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2702
2916
|
}
|
|
@@ -2746,7 +2960,7 @@ export class WorkspaceModule implements Module {
|
|
|
2746
2960
|
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2747
2961
|
*/
|
|
2748
2962
|
private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
|
|
2749
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
2963
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) return false;
|
|
2750
2964
|
if (insideMount(relativePath, mount.config.include)) return false;
|
|
2751
2965
|
const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
|
|
2752
2966
|
if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
|
|
@@ -2819,18 +3033,12 @@ export class WorkspaceModule implements Module {
|
|
|
2819
3033
|
|
|
2820
3034
|
/**
|
|
2821
3035
|
* How many segments the innermost nested mount's directory that holds
|
|
2822
|
-
* `relativePath` has,
|
|
2823
|
-
*
|
|
3036
|
+
* `relativePath` has, or, for a direct mount whose root has moved, in the
|
|
3037
|
+
* nesting there (`moved`);
|
|
2824
3038
|
* undefined when no nested mount's directory holds it.
|
|
2825
3039
|
*/
|
|
2826
3040
|
private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
|
|
2827
|
-
|
|
2828
|
-
for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
|
|
2829
|
-
if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
|
|
2830
|
-
const segments = dir.split('/').length;
|
|
2831
|
-
if (depth === undefined || segments > depth) depth = segments;
|
|
2832
|
-
}
|
|
2833
|
-
return depth;
|
|
3041
|
+
return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
|
|
2834
3042
|
}
|
|
2835
3043
|
|
|
2836
3044
|
/**
|
|
@@ -2853,11 +3061,7 @@ export class WorkspaceModule implements Module {
|
|
|
2853
3061
|
// Only an entry this mount's include covers spells a draft's path
|
|
2854
3062
|
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
2855
3063
|
// from an earlier version, nor one the include leaves out, which would
|
|
2856
|
-
// respell a covered draft into a name the include leaves out.
|
|
2857
|
-
// in a nested mount's directory that this mount's include covers
|
|
2858
|
-
// spells one whatever that mount's include says, so on a disk that
|
|
2859
|
-
// folds names, a draft naming the directory in another spelling takes
|
|
2860
|
-
// the entry's spelling and is judged as in it.
|
|
3064
|
+
// respell a covered draft into a name the include leaves out.
|
|
2861
3065
|
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
2862
3066
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
2863
3067
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
@@ -2883,25 +3087,24 @@ export class WorkspaceModule implements Module {
|
|
|
2883
3087
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
2884
3088
|
// store would keep one name and disk another.
|
|
2885
3089
|
if (LONE_SURROGATE.test(path)) {
|
|
2886
|
-
throw new
|
|
3090
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.`);
|
|
2887
3091
|
}
|
|
2888
3092
|
const slashIdx = path.indexOf('/');
|
|
2889
3093
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
2890
3094
|
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
2891
3095
|
|
|
2892
3096
|
const mount = this.mounts.get(mountName);
|
|
2893
|
-
if (!mount)
|
|
2894
|
-
throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
|
|
2895
|
-
}
|
|
3097
|
+
if (!mount) throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
|
|
2896
3098
|
const segments = pathSegments(given);
|
|
2897
3099
|
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
2898
|
-
throw new
|
|
3100
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root, as "${mountName}/<path>".`);
|
|
2899
3101
|
}
|
|
2900
3102
|
const kept: string[] = [];
|
|
2901
3103
|
for (const segment of segments) {
|
|
2902
3104
|
if (segment !== '..') kept.push(segment);
|
|
2903
3105
|
else if (kept.pop() === undefined) {
|
|
2904
|
-
throw new
|
|
3106
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
|
|
3107
|
+
+ `Name a file from its own mount's name; ${this.mountList()}.`);
|
|
2905
3108
|
}
|
|
2906
3109
|
}
|
|
2907
3110
|
const relativePath = kept.join('/');
|
|
@@ -2916,11 +3119,41 @@ export class WorkspaceModule implements Module {
|
|
|
2916
3119
|
const root = resolve(mount.config.path);
|
|
2917
3120
|
const resolved = resolve(root, relativePath);
|
|
2918
3121
|
if (!isContainedPath(root, resolved)) {
|
|
2919
|
-
throw new
|
|
3122
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} resolves outside mount "${mountName}", as a drive or root named in it does: give the path from the mount's root, as "${mountName}/<path>".`);
|
|
2920
3123
|
}
|
|
2921
3124
|
return { mount, relativePath };
|
|
2922
3125
|
}
|
|
2923
3126
|
|
|
3127
|
+
private writableNames(): string[] {
|
|
3128
|
+
return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
private mountList(): string {
|
|
3132
|
+
const names = [...this.mounts.keys()];
|
|
3133
|
+
return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
private noSuchMountField(given: string, bare: string): string {
|
|
3137
|
+
const [close] = closestNames(given, [...this.mounts.keys()], 1);
|
|
3138
|
+
return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
|
|
3139
|
+
+ (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
|
|
3140
|
+
+ ` Leaving "mount" out ${bare}.`;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
private noSuchMount(path: string, mountName: string): string {
|
|
3144
|
+
const names = [...this.mounts.keys()];
|
|
3145
|
+
const rest = path.slice(mountName.length);
|
|
3146
|
+
const unslashed = path.replace(/^[/\\]+/, '');
|
|
3147
|
+
if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
|
|
3148
|
+
return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
|
|
3149
|
+
}
|
|
3150
|
+
if (mountName === '') return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
3151
|
+
const [close] = closestNames(mountName, names, 1);
|
|
3152
|
+
const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
|
|
3153
|
+
: `${JSON.stringify(path)} starts with ${JSON.stringify(mountName)}, which is no mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
3154
|
+
return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
|
|
3155
|
+
}
|
|
3156
|
+
|
|
2924
3157
|
/**
|
|
2925
3158
|
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
2926
3159
|
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
@@ -2944,31 +3177,26 @@ export class WorkspaceModule implements Module {
|
|
|
2944
3177
|
maxSize: number,
|
|
2945
3178
|
): { bytes: Buffer; mimeType: SupportedImageMimeType } {
|
|
2946
3179
|
if (bytes.byteLength === 0) {
|
|
2947
|
-
throw
|
|
3180
|
+
throw emptyImage(mountPrefixedPath);
|
|
2948
3181
|
}
|
|
2949
|
-
if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3182
|
+
if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
|
|
2950
3183
|
return {
|
|
2951
3184
|
bytes,
|
|
2952
3185
|
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
2953
3186
|
};
|
|
2954
3187
|
}
|
|
2955
3188
|
|
|
2956
|
-
private
|
|
3189
|
+
private async readImageFromTree(
|
|
2957
3190
|
mount: MountState,
|
|
2958
3191
|
relativePath: string,
|
|
2959
3192
|
mountPrefixedPath: string,
|
|
2960
3193
|
maxSize: number,
|
|
2961
|
-
): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
|
|
2962
|
-
const
|
|
2963
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3194
|
+
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> {
|
|
3195
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
2964
3196
|
if (!entry) return null;
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
|
|
2969
|
-
}
|
|
2970
|
-
|
|
2971
|
-
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
3197
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
3198
|
+
if ('lost' in contents) return contents;
|
|
3199
|
+
return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
|
|
2972
3200
|
}
|
|
2973
3201
|
|
|
2974
3202
|
/**
|
|
@@ -2985,19 +3213,14 @@ export class WorkspaceModule implements Module {
|
|
|
2985
3213
|
* another directory carries none. The identity is rootIdentityForRead's,
|
|
2986
3214
|
* which answers while the evidence awaits reopening; where even it can't
|
|
2987
3215
|
* be read, as when the evidence was never read back whole, the read is
|
|
2988
|
-
* refused
|
|
3216
|
+
* refused before its root is resolved, with an errno only where no
|
|
3217
|
+
* directory is at the root's path.
|
|
2989
3218
|
* A direct mount's root is checked against no identity, and its read reads
|
|
2990
3219
|
* no evidence: it promises what's at its root now (diskView).
|
|
2991
3220
|
*/
|
|
2992
3221
|
private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
|
|
2993
3222
|
const name = mount.config.name;
|
|
2994
3223
|
const root = resolve(mount.config.path);
|
|
2995
|
-
let real: string;
|
|
2996
|
-
try {
|
|
2997
|
-
real = await realpath(root);
|
|
2998
|
-
} catch (err) {
|
|
2999
|
-
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
3000
|
-
}
|
|
3001
3224
|
let rootIdentity: MountView['rootIdentity'];
|
|
3002
3225
|
if (!mount.direct) {
|
|
3003
3226
|
try {
|
|
@@ -3005,17 +3228,22 @@ export class WorkspaceModule implements Module {
|
|
|
3005
3228
|
} catch (err) {
|
|
3006
3229
|
// The evidence can't be read back, so neither can the check be made.
|
|
3007
3230
|
// Where no directory is at the root's path, its errno still says so.
|
|
3008
|
-
throw new
|
|
3009
|
-
`Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
|
|
3231
|
+
throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
|
|
3010
3232
|
}
|
|
3011
3233
|
}
|
|
3234
|
+
let real: string;
|
|
3235
|
+
try {
|
|
3236
|
+
real = await realpath(root);
|
|
3237
|
+
} catch (err) {
|
|
3238
|
+
throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
|
|
3239
|
+
}
|
|
3012
3240
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
3013
3241
|
if ('dir' in checked) return { real, dir: checked.dir };
|
|
3014
3242
|
// Another directory is no absence. For any other refusal, what is at the
|
|
3015
3243
|
// root's path now decides: nothing, something that isn't a directory, or
|
|
3016
3244
|
// a directory the path has come to name since it was resolved (no errno).
|
|
3017
3245
|
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
3018
|
-
throw new
|
|
3246
|
+
throw new RootUnavailable(name, checked.reason, 'root', errno);
|
|
3019
3247
|
}
|
|
3020
3248
|
|
|
3021
3249
|
/**
|
|
@@ -3030,7 +3258,7 @@ export class WorkspaceModule implements Module {
|
|
|
3030
3258
|
private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
|
|
3031
3259
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
3032
3260
|
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
3033
|
-
return new
|
|
3261
|
+
return new RootUnavailable(mount.config.name, reason, 'root', errno);
|
|
3034
3262
|
}
|
|
3035
3263
|
|
|
3036
3264
|
/**
|
|
@@ -3132,7 +3360,7 @@ export class WorkspaceModule implements Module {
|
|
|
3132
3360
|
}
|
|
3133
3361
|
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
3134
3362
|
// A FIFO, socket or device is never opened.
|
|
3135
|
-
throw new
|
|
3363
|
+
throw new NotRegularFile(mountPrefixedPath, 'lstat');
|
|
3136
3364
|
}
|
|
3137
3365
|
|
|
3138
3366
|
let handle: Awaited<ReturnType<typeof open>>;
|
|
@@ -3168,7 +3396,7 @@ export class WorkspaceModule implements Module {
|
|
|
3168
3396
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
3169
3397
|
}
|
|
3170
3398
|
if (!fileStat.isFile()) {
|
|
3171
|
-
throw new
|
|
3399
|
+
throw new NotRegularFile(mountPrefixedPath, 'fstat');
|
|
3172
3400
|
}
|
|
3173
3401
|
|
|
3174
3402
|
if (!follow && !useNoFollow) {
|
|
@@ -3207,13 +3435,13 @@ export class WorkspaceModule implements Module {
|
|
|
3207
3435
|
if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
3208
3436
|
|
|
3209
3437
|
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
3210
|
-
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3438
|
+
const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
|
|
3211
3439
|
// A place in a nested mount's directory that its include leaves out is
|
|
3212
3440
|
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
3213
3441
|
// does one reaching a file this mount's own include leaves out
|
|
3214
3442
|
// (coversLanding).
|
|
3215
3443
|
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
3216
|
-
if (!inside || own || (holder !== undefined && !
|
|
3444
|
+
if (!inside || own || (holder !== undefined && !holder.names(false))) {
|
|
3217
3445
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
3218
3446
|
}
|
|
3219
3447
|
if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
|
|
@@ -3281,7 +3509,9 @@ export class WorkspaceModule implements Module {
|
|
|
3281
3509
|
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
3282
3510
|
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
3283
3511
|
* directory, policy-denied symlink, outside-mount target, and
|
|
3284
|
-
* file-changed-during-read.
|
|
3512
|
+
* file-changed-during-read. An error met while parsing the path that is
|
|
3513
|
+
* none of these refusals, such as the store's, is thrown as it is, and a
|
|
3514
|
+
* `maxBytes` that isn't a non-negative integer throws a `RangeError`.
|
|
3285
3515
|
*/
|
|
3286
3516
|
async readFileFromDisk(
|
|
3287
3517
|
mountPrefixedPath: string,
|
|
@@ -3297,11 +3527,11 @@ export class WorkspaceModule implements Module {
|
|
|
3297
3527
|
} catch (err) {
|
|
3298
3528
|
// A link's refusal (parseFilePath) carries its own code.
|
|
3299
3529
|
if (err instanceof WorkspaceReadError) throw err;
|
|
3300
|
-
const
|
|
3301
|
-
const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3530
|
+
const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3302
3531
|
: err instanceof NestedMountError ? 'other_mount'
|
|
3303
|
-
:
|
|
3304
|
-
|
|
3532
|
+
: err instanceof PathRefusal ? err.code : null;
|
|
3533
|
+
if (code === null) throw err;
|
|
3534
|
+
throw new WorkspaceReadError(code, (err as Error).message, 'parse');
|
|
3305
3535
|
}
|
|
3306
3536
|
if (!relativePath) {
|
|
3307
3537
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
@@ -3329,92 +3559,25 @@ export class WorkspaceModule implements Module {
|
|
|
3329
3559
|
}
|
|
3330
3560
|
}
|
|
3331
3561
|
|
|
3332
|
-
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
3333
|
-
private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
|
|
3334
|
-
const p = mountPrefixedPath;
|
|
3335
|
-
switch (err.code) {
|
|
3336
|
-
case 'mount_unavailable':
|
|
3337
|
-
// With why, where the root's check says, such as a directory that
|
|
3338
|
-
// isn't the mount's.
|
|
3339
|
-
return new WorkspaceImageReadError('mount_unavailable', err.message);
|
|
3340
|
-
case 'directory':
|
|
3341
|
-
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
3342
|
-
case 'symlink':
|
|
3343
|
-
// The core reader's own words for a link at the path: "Symlinks are not
|
|
3344
|
-
// allowed: <path>".
|
|
3345
|
-
return new WorkspaceImageReadError('symlink', err.message);
|
|
3346
|
-
case 'parent_symlink':
|
|
3347
|
-
// The core reader's own words for a link on its way: which link and
|
|
3348
|
-
// where it leads, with the setting for a link to a directory.
|
|
3349
|
-
return new WorkspaceImageReadError('parent_symlink', err.message);
|
|
3350
|
-
case 'other_mount':
|
|
3351
|
-
// The core reader's own words for a path a link takes into a nested
|
|
3352
|
-
// mount's directory: that mount and the path to use there. (#460's
|
|
3353
|
-
// own other_mount, a path in that directory, is refused at parse,
|
|
3354
|
-
// and so is a link found there before the read.)
|
|
3355
|
-
return new WorkspaceImageReadError('other_mount', err.message);
|
|
3356
|
-
case 'escape':
|
|
3357
|
-
// The core reader's own words: "Symlink escape detected: <path>" for a
|
|
3358
|
-
// followed link that resolves outside the mount, and for a link on the
|
|
3359
|
-
// way that leads outside a mount that doesn't follow links, which link.
|
|
3360
|
-
return new WorkspaceImageReadError('escape', err.message);
|
|
3361
|
-
case 'changed':
|
|
3362
|
-
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
3363
|
-
case 'not_found':
|
|
3364
|
-
default:
|
|
3365
|
-
// The core reader's own words for a file that isn't regular, and for
|
|
3366
|
-
// a directory on the way it couldn't ask about.
|
|
3367
|
-
if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
|
|
3368
|
-
return new WorkspaceImageReadError('not_found', err.message);
|
|
3369
|
-
}
|
|
3370
|
-
if (err.message.startsWith('File not found')) {
|
|
3371
|
-
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
3372
|
-
}
|
|
3373
|
-
if (err.stage === 'realpath') {
|
|
3374
|
-
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
3375
|
-
}
|
|
3376
|
-
if (err.stage === 'stat') {
|
|
3377
|
-
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
3378
|
-
}
|
|
3379
|
-
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3382
|
-
|
|
3383
3562
|
private async readImageFromFilesystem(
|
|
3384
3563
|
mount: MountState,
|
|
3385
3564
|
relativePath: string,
|
|
3386
3565
|
mountPrefixedPath: string,
|
|
3387
3566
|
maxSize: number,
|
|
3388
3567
|
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
3394
|
-
}
|
|
3395
|
-
if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3396
|
-
});
|
|
3397
|
-
} catch (err) {
|
|
3398
|
-
if (err instanceof WorkspaceImageReadError) throw err;
|
|
3399
|
-
if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
3400
|
-
if (isErrnoCode(err, 'ENOENT')) {
|
|
3401
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3402
|
-
}
|
|
3403
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3404
|
-
}
|
|
3405
|
-
const { handle, fileStat } = opened;
|
|
3568
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
3569
|
+
if (fileStat.size === 0) throw emptyImage(mountPrefixedPath);
|
|
3570
|
+
if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
|
|
3571
|
+
});
|
|
3406
3572
|
try {
|
|
3407
3573
|
// No more than maxSize is loaded: a file grown past it since the fstat
|
|
3408
3574
|
// above is refused here, as one already over it was there.
|
|
3409
3575
|
const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
|
|
3410
|
-
if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3576
|
+
if (more) throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
|
|
3411
3577
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
3412
3578
|
} catch (err) {
|
|
3413
3579
|
if (err instanceof WorkspaceImageReadError) throw err;
|
|
3414
|
-
|
|
3415
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3416
|
-
}
|
|
3417
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3580
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
3418
3581
|
} finally {
|
|
3419
3582
|
await handle.close();
|
|
3420
3583
|
}
|
|
@@ -3447,18 +3610,19 @@ export class WorkspaceModule implements Module {
|
|
|
3447
3610
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
3448
3611
|
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
3449
3612
|
* deletion that cleared a conflict naming a disk file the workspace never
|
|
3450
|
-
* took in, that nothing was unlinked (`note`).
|
|
3451
|
-
*
|
|
3452
|
-
* the path,
|
|
3613
|
+
* took in, that nothing was unlinked (`note`). And the answer's `disk` for
|
|
3614
|
+
* the path, unless it was refused. The caller holds the mount's turn and has
|
|
3615
|
+
* settled the path first (settleBeforeMutation, or a pass over the path, as
|
|
3616
|
+
* an edit makes).
|
|
3453
3617
|
*/
|
|
3454
3618
|
private async commitToolChange(
|
|
3455
3619
|
mount: MountState,
|
|
3456
3620
|
relativePath: string,
|
|
3457
3621
|
change: ToolChange,
|
|
3458
3622
|
onCommitted?: () => void,
|
|
3459
|
-
): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
|
|
3460
|
-
const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3461
|
-
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
|
|
3623
|
+
): Promise<{ refused: string | null; modeKept: string | null; note: string | null; disk: DiskState | null }> {
|
|
3624
|
+
const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3625
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
|
|
3462
3626
|
}
|
|
3463
3627
|
|
|
3464
3628
|
/**
|
|
@@ -3474,15 +3638,16 @@ export class WorkspaceModule implements Module {
|
|
|
3474
3638
|
* (changesLanded).
|
|
3475
3639
|
* Answers, by path, the reason for each that reached the workspace but not
|
|
3476
3640
|
* disk (`refused`), and, apart from it, why one whose bytes reached disk
|
|
3477
|
-
* left the file's mode as disk had it (`modeKept`),
|
|
3478
|
-
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3479
|
-
*
|
|
3641
|
+
* left the file's mode as disk had it (`modeKept`), that a deletion
|
|
3642
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3643
|
+
* and the answer's `disk` for each path not refused, as commitToolChange
|
|
3644
|
+
* answers them for one path.
|
|
3480
3645
|
*/
|
|
3481
3646
|
private async commitToolChanges(
|
|
3482
3647
|
mount: MountState,
|
|
3483
3648
|
changes: Array<{ path: string; change: ToolChange }>,
|
|
3484
3649
|
onCommitted?: (path: string) => void,
|
|
3485
|
-
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
|
|
3650
|
+
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string>; disk: Map<string, DiskState> }> {
|
|
3486
3651
|
const store = this.getStore();
|
|
3487
3652
|
const agreement = this.agreementOrThrow();
|
|
3488
3653
|
const intents = this.intents.get(mount.config.name)!;
|
|
@@ -3621,7 +3786,7 @@ export class WorkspaceModule implements Module {
|
|
|
3621
3786
|
// nothing.
|
|
3622
3787
|
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3623
3788
|
putBack();
|
|
3624
|
-
throw error;
|
|
3789
|
+
throw changing.length === 0 ? new AlreadyHeld(error) : error;
|
|
3625
3790
|
}
|
|
3626
3791
|
landedThenThrew = { error };
|
|
3627
3792
|
}
|
|
@@ -3635,7 +3800,9 @@ export class WorkspaceModule implements Module {
|
|
|
3635
3800
|
if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3636
3801
|
const refused = new Map<string, string>();
|
|
3637
3802
|
const modeKept = new Map<string, string>();
|
|
3638
|
-
if (!this.pushesOnCommit(mount) || pushing.length === 0)
|
|
3803
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) {
|
|
3804
|
+
return { refused, modeKept, notes, disk: new Map(changes.map(({ path }): [string, DiskState] => [path, this.diskOf(mount, path)])) };
|
|
3805
|
+
}
|
|
3639
3806
|
|
|
3640
3807
|
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3641
3808
|
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
@@ -3657,7 +3824,7 @@ export class WorkspaceModule implements Module {
|
|
|
3657
3824
|
error: skipped.reason,
|
|
3658
3825
|
} as ProcessEvent);
|
|
3659
3826
|
}
|
|
3660
|
-
return { refused, modeKept, notes };
|
|
3827
|
+
return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }): [string, DiskState] => [path, 'done'])) };
|
|
3661
3828
|
}
|
|
3662
3829
|
|
|
3663
3830
|
// ==========================================================================
|
|
@@ -3681,15 +3848,19 @@ export class WorkspaceModule implements Module {
|
|
|
3681
3848
|
}
|
|
3682
3849
|
|
|
3683
3850
|
/**
|
|
3684
|
-
* Before a
|
|
3685
|
-
* three-way rule, which ingests a new disk file but
|
|
3686
|
-
* workspace deletion or a file absent from this branch.
|
|
3687
|
-
* path
|
|
3688
|
-
*
|
|
3689
|
-
*
|
|
3690
|
-
*
|
|
3691
|
-
* the
|
|
3692
|
-
*
|
|
3851
|
+
* Before a deletion, and for readBinary: a path the workspace doesn't hold
|
|
3852
|
+
* is observed under the three-way rule, which ingests a new disk file but
|
|
3853
|
+
* never brings back a workspace deletion or a file absent from this branch.
|
|
3854
|
+
* A path it holds is left alone. For a deletion, as for a write, taking
|
|
3855
|
+
* disk's change in first would let the push remove that change unseen; for
|
|
3856
|
+
* readBinary, a pass would drop a held binary file that disk changed, since
|
|
3857
|
+
* the tree takes in no binary disk copy. The pass names the path, as a file
|
|
3858
|
+
* tool names the one it acts on: a path the mount passes over by design is
|
|
3859
|
+
* observed, as a sync naming it would observe it (#276), while a path in
|
|
3860
|
+
* another mount's directory is refused before this, and passed over by any
|
|
3861
|
+
* pass. Answers the pass's report on the path, and why the pass decided
|
|
3862
|
+
* nothing where it did (undecidedOf), which leaves disk unchecked. The
|
|
3863
|
+
* caller holds the mount's turn.
|
|
3693
3864
|
*/
|
|
3694
3865
|
private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
|
|
3695
3866
|
const store = this.getStore();
|
|
@@ -3704,45 +3875,80 @@ export class WorkspaceModule implements Module {
|
|
|
3704
3875
|
* call made again checks it.
|
|
3705
3876
|
*/
|
|
3706
3877
|
private notCheckedYet(path: string, reason: string): string {
|
|
3707
|
-
return `${
|
|
3878
|
+
return `${JSON.stringify(path)} isn't in the workspace, and disk couldn't be checked: ${reason}. Make the same call again, which checks disk first.`;
|
|
3708
3879
|
}
|
|
3709
3880
|
|
|
3710
3881
|
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
3711
|
-
private notInWorkspace(path: string, report: PathReport | undefined): string {
|
|
3882
|
+
private notInWorkspace(path: string, report: PathReport | undefined, mount: MountState, missing?: string): string {
|
|
3883
|
+
const shown = JSON.stringify(path);
|
|
3712
3884
|
switch (report?.state) {
|
|
3713
3885
|
case 'workspace-deleted':
|
|
3714
|
-
return `${
|
|
3886
|
+
return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
3715
3887
|
case 'not-in-branch':
|
|
3716
|
-
return `${
|
|
3717
|
-
case 'disk-only':
|
|
3718
|
-
|
|
3719
|
-
|
|
3888
|
+
return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
3889
|
+
case 'disk-only': {
|
|
3890
|
+
const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
3891
|
+
return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
|
|
3892
|
+
+ 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
|
|
3893
|
+
+ (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
|
|
3894
|
+
}
|
|
3720
3895
|
case 'conflict':
|
|
3721
|
-
return `${
|
|
3896
|
+
return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
3722
3897
|
'Sync this path to take the disk version.';
|
|
3723
3898
|
case 'unverified':
|
|
3724
|
-
return
|
|
3899
|
+
return uncheckedFile(shown, report.note);
|
|
3725
3900
|
default:
|
|
3726
|
-
return `
|
|
3901
|
+
return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
3727
3902
|
}
|
|
3728
3903
|
}
|
|
3729
3904
|
|
|
3730
3905
|
/**
|
|
3731
|
-
* A file's bytes for read,
|
|
3732
|
-
* workspace's copy,
|
|
3733
|
-
*
|
|
3734
|
-
* (bytesOnDisk)
|
|
3906
|
+
* A file's bytes for read, and the notes that say which copy they are. On a
|
|
3907
|
+
* 'chronicle' mount it's the workspace's copy, after a pass over the path
|
|
3908
|
+
* takes disk's change to it in first. On a direct mount it's disk's file as
|
|
3909
|
+
* it is now (bytesOnDisk), with no notes, since nothing else is kept. An
|
|
3910
|
+
* error says why there are none to read.
|
|
3735
3911
|
*/
|
|
3736
|
-
private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3737
|
-
if (mount.direct)
|
|
3912
|
+
private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> {
|
|
3913
|
+
if (mount.direct) {
|
|
3914
|
+
const read = await this.bytesOnDisk(mount, relativePath, path);
|
|
3915
|
+
return 'error' in read ? read : { bytes: read.bytes, notes: [] };
|
|
3916
|
+
}
|
|
3738
3917
|
const store = this.getStore();
|
|
3739
|
-
return this.withMount(mount, async () => {
|
|
3740
|
-
|
|
3918
|
+
return this.withMount(mount, async (): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> => {
|
|
3919
|
+
// A read shows the path as disk holds it now: a change on disk to a
|
|
3920
|
+
// file the workspace holds is taken in first, by the rule a listing
|
|
3921
|
+
// uses, as an edit's is. Where the stored copy is a draft and disk
|
|
3922
|
+
// changed too, the read shows the draft and names the conflict. The
|
|
3923
|
+
// pass names the path, as a file tool names the one it acts on, so a
|
|
3924
|
+
// path the mount passes over by design is observed, as a sync naming
|
|
3925
|
+
// it would observe it (#276).
|
|
3926
|
+
const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
|
|
3927
|
+
// A pass that decided nothing (undecidedOf: the selected branch kept
|
|
3928
|
+
// changing) leaves no report. It's read as a copy disk couldn't be
|
|
3929
|
+
// checked, with its reason, so the answer says the check didn't finish.
|
|
3930
|
+
const undecided = undecidedOf(pass);
|
|
3931
|
+
const report = pass.reports.get(relativePath)
|
|
3932
|
+
?? (undecided !== null ? { path: relativePath, state: 'unverified' as const, note: undecided } : undefined);
|
|
3933
|
+
// A check that decided nothing may well finish on the next read, as
|
|
3934
|
+
// grep's answer to the same says of grep.
|
|
3935
|
+
const again = undecided !== null ? ['Read again to check it.'] : [];
|
|
3936
|
+
// A mode taken in alone isn't a change to the text, so it leaves the
|
|
3937
|
+
// read nothing to say, as it leaves an edit nothing.
|
|
3938
|
+
const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
|
|
3741
3939
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3742
|
-
if (!entry)
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3940
|
+
if (!entry) {
|
|
3941
|
+
// A pass also drops a copy disk made binary or too large, which
|
|
3942
|
+
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3943
|
+
return {
|
|
3944
|
+
error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
|
|
3945
|
+
: undecided !== null ? this.notCheckedYet(path, undecided)
|
|
3946
|
+
: this.notInWorkspace(path, report, mount),
|
|
3947
|
+
};
|
|
3948
|
+
}
|
|
3949
|
+
const contents = await this.contentsOf(mount, relativePath, path, entry);
|
|
3950
|
+
if ('lost' in contents) return { error: nothingToRead(contents.lost) };
|
|
3951
|
+
return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3746
3952
|
});
|
|
3747
3953
|
}
|
|
3748
3954
|
|
|
@@ -3756,153 +3962,201 @@ export class WorkspaceModule implements Module {
|
|
|
3756
3962
|
*/
|
|
3757
3963
|
private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3758
3964
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3759
|
-
const over = `${path} is
|
|
3965
|
+
const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
|
|
3760
3966
|
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
3761
3967
|
try {
|
|
3762
3968
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3763
|
-
if (fileStat.size > maxSize) throw new
|
|
3969
|
+
if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
3764
3970
|
});
|
|
3765
3971
|
} catch (err) {
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
|
|
3972
|
+
if (err instanceof Refusal) return { error: err.message };
|
|
3973
|
+
if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
|
|
3974
|
+
throw err;
|
|
3770
3975
|
}
|
|
3771
3976
|
try {
|
|
3772
3977
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
3773
3978
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
3774
|
-
if (more) return { error: over };
|
|
3979
|
+
if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
3775
3980
|
if (looksBinary(bytes)) {
|
|
3776
|
-
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '
|
|
3981
|
+
return { error: `${JSON.stringify(path)} is binary, so read doesn't show it as text. ${sniffImageMime(bytes) ? 'read_image shows it, if its data holds together as an image.' : 'No tool shows it as text, so calling again won\'t change that.'}` };
|
|
3777
3982
|
}
|
|
3778
3983
|
return { bytes };
|
|
3779
3984
|
} catch (err) {
|
|
3780
|
-
|
|
3781
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
3985
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
3782
3986
|
} finally {
|
|
3783
3987
|
await opened.handle.close();
|
|
3784
3988
|
}
|
|
3785
3989
|
}
|
|
3786
3990
|
|
|
3991
|
+
private async contentsOf(
|
|
3992
|
+
mount: MountState,
|
|
3993
|
+
relativePath: string,
|
|
3994
|
+
path: string,
|
|
3995
|
+
version: { blobHash: string; size: number },
|
|
3996
|
+
what: 'saved' | 'conflict-disk' = 'saved',
|
|
3997
|
+
tell: 'now' | 'later' = 'now',
|
|
3998
|
+
): Promise<{ bytes: Buffer } | { lost: LostContents }> {
|
|
3999
|
+
const store = this.getStore();
|
|
4000
|
+
const stored = storedBytes(store, version.blobHash);
|
|
4001
|
+
if ('bytes' in stored) return stored;
|
|
4002
|
+
const otherSize = new Error('other size');
|
|
4003
|
+
let found: { bytes: Buffer } | { disk: LostContents['disk'] };
|
|
4004
|
+
try {
|
|
4005
|
+
const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4006
|
+
if (fileStat.size !== version.size) throw otherSize;
|
|
4007
|
+
});
|
|
4008
|
+
try {
|
|
4009
|
+
const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
|
|
4010
|
+
found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
|
|
4011
|
+
} finally {
|
|
4012
|
+
await opened.handle.close();
|
|
4013
|
+
}
|
|
4014
|
+
} catch (err) {
|
|
4015
|
+
found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
|
|
4016
|
+
}
|
|
4017
|
+
const subject = contentsSubject(what, path);
|
|
4018
|
+
const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
|
|
4019
|
+
if ('disk' in found) {
|
|
4020
|
+
const now = storedBytes(store, version.blobHash);
|
|
4021
|
+
if ('bytes' in now) return now;
|
|
4022
|
+
const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
|
|
4023
|
+
if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost);
|
|
4024
|
+
return { lost };
|
|
4025
|
+
}
|
|
4026
|
+
const { bytes } = found;
|
|
4027
|
+
if ('bytes' in storedBytes(store, version.blobHash)) return { bytes };
|
|
4028
|
+
let thrown: string | null = null;
|
|
4029
|
+
try {
|
|
4030
|
+
store.storeBlob(bytes, 'application/octet-stream');
|
|
4031
|
+
} catch (error) {
|
|
4032
|
+
thrown = error instanceof Error ? error.message : String(error);
|
|
4033
|
+
}
|
|
4034
|
+
// Chronicle's storeBlob takes any file already at the hash's path as
|
|
4035
|
+
// stored, so whether the bytes took is what a read of them gives now.
|
|
4036
|
+
const now = storedBytes(store, version.blobHash);
|
|
4037
|
+
if ('bytes' in now) {
|
|
4038
|
+
this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace',
|
|
4039
|
+
`Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
|
|
4040
|
+
} else {
|
|
4041
|
+
const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
|
|
4042
|
+
this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace',
|
|
4043
|
+
`Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
|
|
4044
|
+
+ ('unreadable' in now
|
|
4045
|
+
? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them."
|
|
4046
|
+
: 'Each read does this again until the store takes them.'),
|
|
4047
|
+
{ mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
|
|
4048
|
+
}
|
|
4049
|
+
return { bytes };
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void {
|
|
4053
|
+
this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace',
|
|
4054
|
+
`Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
|
|
4055
|
+
+ "Putting back the store's blob file for that hash, from a copy of the store, brings them back.",
|
|
4056
|
+
{ mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
|
|
4060
|
+
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
4061
|
+
if (!absent) return { unreadable: containedRefusal(err, path) };
|
|
4062
|
+
const root = resolve(mount.config.path);
|
|
4063
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
4064
|
+
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
4065
|
+
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
4066
|
+
if (typeof proof !== 'string') return 'none';
|
|
4067
|
+
return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
|
|
4071
|
+
mount: MountState,
|
|
4072
|
+
versions: readonly T[],
|
|
4073
|
+
each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void,
|
|
4074
|
+
): Promise<void> {
|
|
4075
|
+
const byHash = new Map<string, T[]>();
|
|
4076
|
+
for (const version of versions) {
|
|
4077
|
+
const sharing = byHash.get(version.hash);
|
|
4078
|
+
if (sharing) sharing.push(version);
|
|
4079
|
+
else byHash.set(version.hash, [version]);
|
|
4080
|
+
}
|
|
4081
|
+
for (const [hash, sharing] of byHash) {
|
|
4082
|
+
let bytes: Buffer | null = null;
|
|
4083
|
+
const lost: Array<[T, LostContents]> = [];
|
|
4084
|
+
for (const version of sharing) {
|
|
4085
|
+
const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
|
|
4086
|
+
if ('bytes' in contents) {
|
|
4087
|
+
bytes = contents.bytes;
|
|
4088
|
+
break;
|
|
4089
|
+
}
|
|
4090
|
+
lost.push([version, contents.lost]);
|
|
4091
|
+
}
|
|
4092
|
+
if (bytes === null) {
|
|
4093
|
+
for (const [version, found] of lost) {
|
|
4094
|
+
this.tellLost(mount.config.name, version.what, hash, found);
|
|
4095
|
+
each(version, { lost: found });
|
|
4096
|
+
}
|
|
4097
|
+
} else {
|
|
4098
|
+
for (const version of sharing) each(version, { bytes });
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
|
|
3787
4103
|
// ==========================================================================
|
|
3788
4104
|
// Tool Handlers
|
|
3789
4105
|
// ==========================================================================
|
|
3790
4106
|
|
|
3791
|
-
|
|
4107
|
+
/**
|
|
4108
|
+
* A read's page, held to `maxChars`, the most of this call's result that
|
|
4109
|
+
* can reach its caller whole. A call that carries none, made in process,
|
|
4110
|
+
* takes the data itself: its page holds to its line limit alone, with every
|
|
4111
|
+
* line whole, as it did before pages had a budget.
|
|
4112
|
+
*/
|
|
4113
|
+
private async handleRead(input: ReadInput, maxChars: number | undefined, generatedContent?: string): Promise<ToolResult> {
|
|
3792
4114
|
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
3793
|
-
const offsetChars = input.offsetChars ?? 0;
|
|
3794
|
-
const limitChars = input.limitChars ?? 2000;
|
|
3795
4115
|
// Each number is a whole number of at least its schema's minimum by now
|
|
3796
4116
|
// (inputProblem).
|
|
3797
4117
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
3798
|
-
return { success: false, isError: true, error: '
|
|
4118
|
+
return { success: false, isError: true, error: didNothing('read', 'it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.') };
|
|
3799
4119
|
}
|
|
3800
|
-
let
|
|
3801
|
-
|
|
4120
|
+
let file: FileText;
|
|
4121
|
+
let notes: string[] = [];
|
|
4122
|
+
if (generatedContent === undefined) {
|
|
3802
4123
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4124
|
+
if (relativePath === '') return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3803
4125
|
input = { ...input, path };
|
|
3804
4126
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
3805
|
-
if ('error' in found) return { success: false, error: found.error, isError: true };
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
const splitsPair = (at: number): boolean =>
|
|
3811
|
-
at > 0 && at < content.length
|
|
3812
|
-
&& content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
|
|
3813
|
-
&& content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
|
|
3814
|
-
if (splitsPair(start)) {
|
|
3815
|
-
return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
|
|
3816
|
-
}
|
|
3817
|
-
let end = start + Math.min(limitChars, content.length - start);
|
|
3818
|
-
if (splitsPair(end)) end++;
|
|
3819
|
-
return {
|
|
3820
|
-
success: true,
|
|
3821
|
-
data: {
|
|
3822
|
-
path: input.path,
|
|
3823
|
-
totalChars: content.length,
|
|
3824
|
-
offsetChars: start,
|
|
3825
|
-
nextOffsetChars: end < content.length ? end : null,
|
|
3826
|
-
content: content.slice(start, end),
|
|
3827
|
-
},
|
|
3828
|
-
};
|
|
3829
|
-
}
|
|
3830
|
-
const lines = linesOf(content);
|
|
3831
|
-
|
|
3832
|
-
// Apply offset/limit. An unlimited read of a large file would inject the
|
|
3833
|
-
// whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
|
|
3834
|
-
// past the model context and 400s), so an omitted limit falls back to a
|
|
3835
|
-
// default cap; the result reports total/from/to so the agent can page.
|
|
3836
|
-
const DEFAULT_READ_LINE_LIMIT = 2000;
|
|
3837
|
-
const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
|
|
3838
|
-
if (startLine >= lines.length) {
|
|
3839
|
-
// Nothing to show: an empty file, or an offset past the last line. No
|
|
3840
|
-
// range is claimed, and the line count says where the file ends.
|
|
3841
|
-
return {
|
|
3842
|
-
success: true,
|
|
3843
|
-
data: {
|
|
3844
|
-
path: input.path,
|
|
3845
|
-
totalLines: lines.length,
|
|
3846
|
-
// Reading an empty file from its start is reading it whole.
|
|
3847
|
-
...(startLine > 0
|
|
3848
|
-
? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
|
|
3849
|
-
: {}),
|
|
3850
|
-
content: '',
|
|
3851
|
-
},
|
|
3852
|
-
};
|
|
4127
|
+
if ('error' in found) return { success: false, error: didNothing('read', found.error), isError: true };
|
|
4128
|
+
file = fileText(found.bytes);
|
|
4129
|
+
notes = found.notes;
|
|
4130
|
+
} else {
|
|
4131
|
+
file = fileText(Buffer.from(generatedContent, 'utf-8'));
|
|
3853
4132
|
}
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
.map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
|
|
3862
|
-
.join('\n');
|
|
3863
|
-
|
|
3864
|
-
return {
|
|
3865
|
-
success: true,
|
|
3866
|
-
data: {
|
|
3867
|
-
path: input.path,
|
|
3868
|
-
totalLines: lines.length,
|
|
3869
|
-
fromLine: startLine + 1,
|
|
3870
|
-
toLine: Math.min(endLine, lines.length),
|
|
3871
|
-
...(truncated
|
|
3872
|
-
? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
|
|
3873
|
-
: {}),
|
|
3874
|
-
content: formatted,
|
|
3875
|
-
},
|
|
3876
|
-
};
|
|
4133
|
+
// An unlimited read of a large file would inject the whole thing into the
|
|
4134
|
+
// turn (a 2MB file ≈ 600k tokens → the request blows past the model
|
|
4135
|
+
// context and 400s), so an omitted limit falls back to a default, and a
|
|
4136
|
+
// page stops at the call's result limit where it has one.
|
|
4137
|
+
return characterPaging
|
|
4138
|
+
? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
|
|
4139
|
+
: linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
|
|
3877
4140
|
}
|
|
3878
4141
|
|
|
3879
4142
|
private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
input = { ...input, path };
|
|
3886
|
-
} catch (err) {
|
|
3887
|
-
return {
|
|
3888
|
-
success: false,
|
|
3889
|
-
error: err instanceof Error ? err.message : String(err),
|
|
3890
|
-
isError: true,
|
|
3891
|
-
};
|
|
3892
|
-
}
|
|
3893
|
-
if (!relativePath) {
|
|
3894
|
-
return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
|
|
3895
|
-
}
|
|
4143
|
+
const given = input.path;
|
|
4144
|
+
const refused = (body: string): ToolResult => ({ success: false, isError: true, error: didNothing('read_image', body) });
|
|
4145
|
+
const { mount, relativePath, path } = await this.parseFilePath(given);
|
|
4146
|
+
if (!relativePath) return refused(namesRoot(given, mount.config.name));
|
|
4147
|
+
input = { ...input, path };
|
|
3896
4148
|
|
|
3897
4149
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3898
4150
|
let treeError: WorkspaceImageReadError | null = null;
|
|
4151
|
+
let lost: LostContents | null = null;
|
|
3899
4152
|
|
|
3900
4153
|
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
3901
4154
|
// mount holds none, so its image is disk's.
|
|
3902
4155
|
if (!mount.direct) {
|
|
3903
4156
|
try {
|
|
3904
|
-
const image = this.
|
|
3905
|
-
if (image)
|
|
4157
|
+
const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
|
|
4158
|
+
if (image && 'lost' in image) lost = image.lost;
|
|
4159
|
+
else if (image) return imageResult(input.path, image);
|
|
3906
4160
|
} catch (err) {
|
|
3907
4161
|
if (err instanceof WorkspaceImageReadError) {
|
|
3908
4162
|
treeError = err;
|
|
@@ -3913,41 +4167,53 @@ export class WorkspaceModule implements Module {
|
|
|
3913
4167
|
}
|
|
3914
4168
|
|
|
3915
4169
|
try {
|
|
3916
|
-
|
|
4170
|
+
const note = lost === null ? undefined
|
|
4171
|
+
: `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
|
|
4172
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
|
|
3917
4173
|
} catch (err) {
|
|
3918
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
return
|
|
4174
|
+
if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError)) throw err;
|
|
4175
|
+
const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
|
|
4176
|
+
if (lost) {
|
|
4177
|
+
const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
|
|
4178
|
+
return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
|
|
4179
|
+
: `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
|
|
3923
4180
|
}
|
|
3924
|
-
|
|
4181
|
+
if (noFile && treeError) return refused(treeError.message);
|
|
4182
|
+
return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
|
|
3925
4183
|
}
|
|
3926
4184
|
}
|
|
3927
4185
|
|
|
3928
|
-
private async handleWrite(input: WriteInput): Promise<ToolResult> {
|
|
4186
|
+
private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
3929
4187
|
if (LONE_SURROGATE.test(input.content)) {
|
|
3930
|
-
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
4188
|
+
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store. Give it without the surrogate, or with the other half of its pair.' };
|
|
3931
4189
|
}
|
|
3932
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4190
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
|
|
4191
|
+
if (relativePath === '') return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3933
4192
|
input = { ...input, path };
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
}
|
|
4193
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
4194
|
+
if (refusal) return { success: false, error: didNothing('write', refusal), isError: true };
|
|
3937
4195
|
|
|
3938
4196
|
const store = this.getStore();
|
|
3939
4197
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3940
|
-
|
|
3941
|
-
|
|
4198
|
+
const bytes = Buffer.byteLength(input.content);
|
|
4199
|
+
if (bytes > maxSize) {
|
|
4200
|
+
return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
|
|
3942
4201
|
}
|
|
3943
4202
|
|
|
3944
4203
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
3945
4204
|
const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
|
|
4205
|
+
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
|
|
4206
|
+
if (mount.direct) {
|
|
4207
|
+
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
4208
|
+
if ('error' in written) return { success: false, isError: true, error: written.error };
|
|
4209
|
+
return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
4210
|
+
}
|
|
3946
4211
|
let committed = false;
|
|
3947
4212
|
let refused: string | null;
|
|
3948
4213
|
let modeKept: string | null;
|
|
4214
|
+
let disk: DiskState | null;
|
|
3949
4215
|
try {
|
|
3950
|
-
({ refused, modeKept } = await this.withMount(mount, async () => {
|
|
4216
|
+
({ refused, modeKept, disk } = await this.withMount(mount, async () => {
|
|
3951
4217
|
await this.settleBeforeMutation(mount, relativePath);
|
|
3952
4218
|
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
3953
4219
|
// An overwrite keeps the file's mode, as an edit does.
|
|
@@ -3956,23 +4222,23 @@ export class WorkspaceModule implements Module {
|
|
|
3956
4222
|
() => { committed = true; });
|
|
3957
4223
|
}));
|
|
3958
4224
|
} catch (error) {
|
|
3959
|
-
if (!committed) throw error;
|
|
4225
|
+
if (!committed) throw notStored(error);
|
|
3960
4226
|
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
3961
4227
|
? { storeFailed: error.message, pushes: error.pushes }
|
|
3962
|
-
: { threw: error instanceof Error ? error.message : String(error) });
|
|
4228
|
+
: { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
3963
4229
|
}
|
|
3964
4230
|
if (refused !== null) return storedButUnfinished('write', input.path, { refused });
|
|
3965
4231
|
|
|
3966
|
-
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
4232
|
+
return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
|
|
3967
4233
|
}
|
|
3968
4234
|
|
|
3969
|
-
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS): Promise<ToolResult> {
|
|
4235
|
+
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
|
|
3970
4236
|
return this.applyEdits('edit', [{
|
|
3971
4237
|
path: input.path,
|
|
3972
4238
|
oldString: input.oldString,
|
|
3973
4239
|
newString: input.newString,
|
|
3974
4240
|
expected: input.replaceAll ? 'all' : 1,
|
|
3975
|
-
}], maxChars);
|
|
4241
|
+
}], maxChars, callerAgentName);
|
|
3976
4242
|
}
|
|
3977
4243
|
|
|
3978
4244
|
private async handleEditMany(
|
|
@@ -4010,12 +4276,14 @@ export class WorkspaceModule implements Module {
|
|
|
4010
4276
|
* stored where its record landed, and that it can't tell where they
|
|
4011
4277
|
* couldn't be read back.
|
|
4012
4278
|
* Under the mount's turn, one pass settles every path with disk, by the
|
|
4013
|
-
* rule a listing uses, so the edits apply to what disk now holds.
|
|
4014
|
-
*
|
|
4015
|
-
*
|
|
4016
|
-
*
|
|
4017
|
-
*
|
|
4018
|
-
* of the
|
|
4279
|
+
* rule a listing uses, so the edits apply to what disk now holds. Each
|
|
4280
|
+
* file's stored contents are then read; where the store has lost some,
|
|
4281
|
+
* they're restored from disk's matching copy (contentsOf) and the pass runs
|
|
4282
|
+
* again, so the plan reads a pass taken after every await. From there
|
|
4283
|
+
* nothing awaits before the commit: every edit is planned and every entry
|
|
4284
|
+
* set (commitToolChanges) with no turn of the event loop between, so no
|
|
4285
|
+
* pass, branch change or other call moves the tree between a file's read
|
|
4286
|
+
* and its set. Disk then takes what it can of the stored change.
|
|
4019
4287
|
*
|
|
4020
4288
|
* The answer is held to `maxChars`, the length the call's result may have
|
|
4021
4289
|
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
@@ -4056,7 +4324,7 @@ export class WorkspaceModule implements Module {
|
|
|
4056
4324
|
}
|
|
4057
4325
|
for (const field of ['oldString', 'newString'] as const) {
|
|
4058
4326
|
if (LONE_SURROGATE.test(edit[field])) {
|
|
4059
|
-
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
|
|
4327
|
+
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.` });
|
|
4060
4328
|
}
|
|
4061
4329
|
}
|
|
4062
4330
|
if (problems.length === found) specs.set(index, spec);
|
|
@@ -4093,6 +4361,9 @@ export class WorkspaceModule implements Module {
|
|
|
4093
4361
|
const [mount] = mounts.values();
|
|
4094
4362
|
if (tool === 'edit' && problems.length) return refuse(problems);
|
|
4095
4363
|
if (!mount || mounts.size > 1) return refuse(problems);
|
|
4364
|
+
// A direct mount's edits apply to disk's text as it is now, and land on
|
|
4365
|
+
// disk or don't happen (applyDirectEdits).
|
|
4366
|
+
if (mount.direct) return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
|
|
4096
4367
|
|
|
4097
4368
|
const store = this.getStore();
|
|
4098
4369
|
// Whether the commit set the call's entries, all of them at once.
|
|
@@ -4107,7 +4378,26 @@ export class WorkspaceModule implements Module {
|
|
|
4107
4378
|
return await this.withMount(mount, async (): Promise<ToolResult> => {
|
|
4108
4379
|
// As every file tool's pass does, it names the paths it acts on, so a
|
|
4109
4380
|
// file the mount passes over by design is observed (see PassOptions.named).
|
|
4110
|
-
|
|
4381
|
+
let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4382
|
+
let ops = pass.ops;
|
|
4383
|
+
const contents = new Map<string, Buffer>();
|
|
4384
|
+
const lost = new Map<string, { blobHash: string; lost: LostContents }>();
|
|
4385
|
+
const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = [];
|
|
4386
|
+
for (const target of targets.values()) {
|
|
4387
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4388
|
+
if (!entry || contents.has(entry.blobHash)) continue;
|
|
4389
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
4390
|
+
if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes);
|
|
4391
|
+
else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
|
|
4392
|
+
}
|
|
4393
|
+
if (toRecover.length > 0) {
|
|
4394
|
+
await this.restoreEach(mount, toRecover, (version, found) => {
|
|
4395
|
+
if ('bytes' in found) contents.set(version.hash, found.bytes);
|
|
4396
|
+
else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
|
|
4397
|
+
});
|
|
4398
|
+
pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4399
|
+
ops = [...ops, ...pass.ops];
|
|
4400
|
+
}
|
|
4111
4401
|
// A pass that decided nothing checked no file's disk copy.
|
|
4112
4402
|
const undecided = undecidedOf(pass);
|
|
4113
4403
|
// Nothing below awaits until the commit's push.
|
|
@@ -4126,7 +4416,7 @@ export class WorkspaceModule implements Module {
|
|
|
4126
4416
|
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
4127
4417
|
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
4128
4418
|
// then write at the mode disk has, so it leaves nothing to say.
|
|
4129
|
-
const took = (op: TreeOp) =>
|
|
4419
|
+
const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
4130
4420
|
// Which copy the edits used, when it isn't the one the workspace
|
|
4131
4421
|
// last agreed on with disk: disk's newer text, or the workspace's
|
|
4132
4422
|
// copy because disk's couldn't be read or disk has none. A file the
|
|
@@ -4191,59 +4481,32 @@ export class WorkspaceModule implements Module {
|
|
|
4191
4481
|
// A pass also drops a copy disk made binary or too large, which
|
|
4192
4482
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
4193
4483
|
const text = took('deleted') && report?.state !== 'disk-only'
|
|
4194
|
-
?
|
|
4195
|
-
: this.notInWorkspace(target.path, report);
|
|
4196
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole:
|
|
4197
|
-
continue;
|
|
4198
|
-
}
|
|
4199
|
-
notes.push(...lookedIn);
|
|
4200
|
-
const blob = store.getBlob(entry.blobHash);
|
|
4201
|
-
if (!blob) {
|
|
4202
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
|
|
4203
|
-
continue;
|
|
4204
|
-
}
|
|
4205
|
-
// Decoding would turn each invalid sequence into U+FFFD, and writing
|
|
4206
|
-
// the text back would change bytes the edits never named.
|
|
4207
|
-
const file = decodeForEdit(blob);
|
|
4208
|
-
if (!file) {
|
|
4209
|
-
problems.push({
|
|
4210
|
-
kind: 'file',
|
|
4211
|
-
edits: target.edits,
|
|
4212
|
-
path: target.path,
|
|
4213
|
-
text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
|
|
4214
|
-
+ '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.',
|
|
4215
|
-
whole: false,
|
|
4216
|
-
});
|
|
4217
|
-
continue;
|
|
4218
|
-
}
|
|
4219
|
-
if (!target.specs.length) continue;
|
|
4220
|
-
const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
|
|
4221
|
-
if ('problems' in planned) {
|
|
4222
|
-
// One at a time: a call of many edits can have more problems
|
|
4223
|
-
// than a spread passes as arguments.
|
|
4224
|
-
for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
|
|
4484
|
+
? deletedOnDisk(target.path)
|
|
4485
|
+
: this.notInWorkspace(target.path, report, mount);
|
|
4486
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
|
|
4225
4487
|
continue;
|
|
4226
4488
|
}
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
4232
|
-
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
4489
|
+
const known = contents.get(entry.blobHash);
|
|
4490
|
+
const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
|
|
4491
|
+
if (!('bytes' in stored)) {
|
|
4492
|
+
const found = lost.get(target.relativePath);
|
|
4233
4493
|
problems.push({
|
|
4234
4494
|
kind: 'file',
|
|
4235
4495
|
edits: target.edits,
|
|
4236
4496
|
path: target.path,
|
|
4237
|
-
text:
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
whole:
|
|
4497
|
+
text: found?.blobHash === entry.blobHash
|
|
4498
|
+
? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
|
|
4499
|
+
: `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`,
|
|
4500
|
+
whole: true,
|
|
4241
4501
|
});
|
|
4242
4502
|
continue;
|
|
4243
4503
|
}
|
|
4504
|
+
notes.push(...lookedIn);
|
|
4505
|
+
const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
|
|
4506
|
+
if (plan === null) continue;
|
|
4244
4507
|
plans.push({
|
|
4245
4508
|
target,
|
|
4246
|
-
plan
|
|
4509
|
+
plan,
|
|
4247
4510
|
mode: entry.mode,
|
|
4248
4511
|
backOnDisk,
|
|
4249
4512
|
conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
|
|
@@ -4253,32 +4516,29 @@ export class WorkspaceModule implements Module {
|
|
|
4253
4516
|
}
|
|
4254
4517
|
if (problems.length) return refuse(problems, notes);
|
|
4255
4518
|
|
|
4256
|
-
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4519
|
+
const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4257
4520
|
path: target.relativePath,
|
|
4258
4521
|
change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4259
4522
|
})), () => { committed = true; });
|
|
4260
4523
|
if (refused.size > 0) return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
|
|
4261
|
-
return {
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4268
|
-
})), given.length, maxChars),
|
|
4269
|
-
};
|
|
4524
|
+
return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
|
|
4525
|
+
path: target.path,
|
|
4526
|
+
plan,
|
|
4527
|
+
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
|
|
4528
|
+
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4529
|
+
})), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath)!)));
|
|
4270
4530
|
});
|
|
4271
4531
|
} catch (error) {
|
|
4272
4532
|
const message = error instanceof Error ? error.message : String(error);
|
|
4273
4533
|
if (committed) {
|
|
4274
4534
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
4275
4535
|
? { storeFailed: message, pushes: error.pushes }
|
|
4276
|
-
: { threw: message }, maxChars);
|
|
4536
|
+
: { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
|
|
4277
4537
|
}
|
|
4278
4538
|
// A set that threw and couldn't be read back may have stored the edits,
|
|
4279
4539
|
// and a second call could then apply them twice.
|
|
4280
4540
|
if (error instanceof CommitUnknownError) {
|
|
4281
|
-
const [set, read] = [
|
|
4541
|
+
const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
|
|
4282
4542
|
return {
|
|
4283
4543
|
success: false,
|
|
4284
4544
|
isError: true,
|
|
@@ -4291,9 +4551,8 @@ export class WorkspaceModule implements Module {
|
|
|
4291
4551
|
}
|
|
4292
4552
|
// The commit sets every entry in one record or none, and a set that
|
|
4293
4553
|
// threw was read back, so nothing is stored; or every change was one
|
|
4294
|
-
// the tree already held
|
|
4295
|
-
|
|
4296
|
-
throw error;
|
|
4554
|
+
// the tree already held (AlreadyHeld).
|
|
4555
|
+
throw notStored(error);
|
|
4297
4556
|
}
|
|
4298
4557
|
}
|
|
4299
4558
|
|
|
@@ -4311,7 +4570,7 @@ export class WorkspaceModule implements Module {
|
|
|
4311
4570
|
path: string,
|
|
4312
4571
|
callerAgentName: string | undefined,
|
|
4313
4572
|
): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
|
|
4314
|
-
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole:
|
|
4573
|
+
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: false });
|
|
4315
4574
|
try {
|
|
4316
4575
|
// In the one spelling every file tool reads, as disk spells it, so two
|
|
4317
4576
|
// spellings of a file are one entry and, in one call, one plan
|
|
@@ -4320,13 +4579,16 @@ export class WorkspaceModule implements Module {
|
|
|
4320
4579
|
const where = await this.parseFilePath(path);
|
|
4321
4580
|
// One that names the mount's root, its bare name included, names no
|
|
4322
4581
|
// file.
|
|
4323
|
-
if (where.relativePath === '') return unusable(
|
|
4324
|
-
|
|
4582
|
+
if (where.relativePath === '') return unusable(namesRoot(path, where.mount.config.name));
|
|
4583
|
+
const refusal = writeRefusal(where.mount, this.writableNames());
|
|
4584
|
+
if (refusal) return unusable(refusal);
|
|
4325
4585
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4326
|
-
if (generated) return unusable(
|
|
4586
|
+
if (generated) return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
|
|
4327
4587
|
return where;
|
|
4328
4588
|
} catch (error) {
|
|
4329
|
-
|
|
4589
|
+
if (error instanceof Refusal) return unusable(error.message);
|
|
4590
|
+
if (error instanceof WorkspaceReadError) return unusable(readRefusal(error, path));
|
|
4591
|
+
throw notStored(error);
|
|
4330
4592
|
}
|
|
4331
4593
|
}
|
|
4332
4594
|
|
|
@@ -4335,10 +4597,10 @@ export class WorkspaceModule implements Module {
|
|
|
4335
4597
|
* and the agent's own is read-only, since it shows the agent's tool
|
|
4336
4598
|
* presentation, which set_tool_visibility and set_tool_description change.
|
|
4337
4599
|
*/
|
|
4338
|
-
private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4600
|
+
private generatedFileRefusal(path: string, generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4339
4601
|
return callerAgentName !== generated.agentName
|
|
4340
|
-
?
|
|
4341
|
-
:
|
|
4602
|
+
? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
|
|
4603
|
+
: `${JSON.stringify(path)} is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.`;
|
|
4342
4604
|
}
|
|
4343
4605
|
|
|
4344
4606
|
/**
|
|
@@ -4362,12 +4624,13 @@ export class WorkspaceModule implements Module {
|
|
|
4362
4624
|
conflicts: Map<EditTarget, string>,
|
|
4363
4625
|
outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
4364
4626
|
maxChars: number,
|
|
4627
|
+
reopen = false,
|
|
4365
4628
|
): ToolResult {
|
|
4366
4629
|
const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4367
4630
|
if (tool === 'edit') {
|
|
4368
4631
|
const [target] = targets;
|
|
4369
4632
|
const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
|
|
4370
|
-
return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
|
|
4633
|
+
return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null, reopen);
|
|
4371
4634
|
}
|
|
4372
4635
|
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.";
|
|
4373
4636
|
// The head is never cut, so what stands for the whole call goes in it,
|
|
@@ -4388,9 +4651,10 @@ export class WorkspaceModule implements Module {
|
|
|
4388
4651
|
}
|
|
4389
4652
|
} else if ('storeFailed' in outcome) {
|
|
4390
4653
|
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4391
|
-
+ (outcome.pushes ?
|
|
4654
|
+
+ (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
4655
|
+
+ (reopen ? ` ${AWAITS_REOPEN}` : '');
|
|
4392
4656
|
} else {
|
|
4393
|
-
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}
|
|
4657
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
|
|
4394
4658
|
}
|
|
4395
4659
|
const kept = 'modeKept' in outcome
|
|
4396
4660
|
? targets.flatMap((target) => {
|
|
@@ -4405,47 +4669,553 @@ export class WorkspaceModule implements Module {
|
|
|
4405
4669
|
return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
|
|
4406
4670
|
}
|
|
4407
4671
|
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
if (mount.config.mode === 'read-only') {
|
|
4412
|
-
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
4413
|
-
}
|
|
4672
|
+
// ==========================================================================
|
|
4673
|
+
// Direct writes
|
|
4674
|
+
// ==========================================================================
|
|
4414
4675
|
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4676
|
+
/**
|
|
4677
|
+
* A direct mount's write of a whole file: its bytes staged beside it and
|
|
4678
|
+
* renamed over it (direct-write.ts), under the mount's turn, so the
|
|
4679
|
+
* module's own calls take turns. Answers a note when the file had other
|
|
4680
|
+
* hard links, which keep its old text, or why nothing was written. One
|
|
4681
|
+
* that lands is the caller's latest write on the mount (recordDirectWrite).
|
|
4682
|
+
* With `branch`, nothing is written once the store's branch is another,
|
|
4683
|
+
* checked with no turn of the event loop before the rename (writeBinary's).
|
|
4684
|
+
*/
|
|
4685
|
+
private async writeDirect(
|
|
4686
|
+
mount: MountState,
|
|
4687
|
+
relativePath: string,
|
|
4688
|
+
path: string,
|
|
4689
|
+
bytes: Buffer,
|
|
4690
|
+
agentName: string | undefined,
|
|
4691
|
+
branch?: string,
|
|
4692
|
+
): Promise<{ note: string | null } | { error: string; branchLeft?: true }> {
|
|
4693
|
+
return this.withMount(mount, async () => {
|
|
4694
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4695
|
+
let staged: Staged;
|
|
4696
|
+
try {
|
|
4697
|
+
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4698
|
+
} catch (error) {
|
|
4699
|
+
return { error: directDidNothing('write', path, error) };
|
|
4700
|
+
}
|
|
4701
|
+
try {
|
|
4702
|
+
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
4703
|
+
await placedStill(staged);
|
|
4704
|
+
// A write reads nothing first, so it replaces whatever is there.
|
|
4705
|
+
checkUnchanged(staged, null);
|
|
4706
|
+
if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
|
|
4707
|
+
await discard(staged);
|
|
4708
|
+
return { error: BRANCH_LEFT, branchLeft: true };
|
|
4425
4709
|
}
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4710
|
+
renameInto(staged);
|
|
4711
|
+
} catch (error) {
|
|
4712
|
+
await discard(staged);
|
|
4713
|
+
return { error: directDidNothing('write', path, error) };
|
|
4714
|
+
}
|
|
4715
|
+
this.recordDirectWrite(mount, agentName);
|
|
4716
|
+
return { note: linksNote(path, staged, 'write') };
|
|
4717
|
+
});
|
|
4718
|
+
}
|
|
4719
|
+
|
|
4720
|
+
/**
|
|
4721
|
+
* applyEdits on a direct mount, under the mount's turn, whose edits land
|
|
4722
|
+
* on disk or don't happen. Each attempt reads every file from disk as it
|
|
4723
|
+
* is now (directEditText), plans every edit against those bytes as on any
|
|
4724
|
+
* mount (planTargetEdits), stages every file beside itself, checks every
|
|
4725
|
+
* one unchanged since its read, and then renames each over its file in
|
|
4726
|
+
* turn (direct-write.ts). A file another writer changed between its read
|
|
4727
|
+
* and its check has the call read and planned again against its new
|
|
4728
|
+
* text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
|
|
4729
|
+
* longer apply to it, a file that changed at every attempt, or a file the
|
|
4730
|
+
* next read refuses as any read would (now past the size limit, say) are
|
|
4731
|
+
* refused, with nothing written. A rename that fails after others leaves
|
|
4732
|
+
* those written and the rest as they were, and the answer names each
|
|
4733
|
+
* file's outcome; nothing puts the written ones back.
|
|
4734
|
+
*/
|
|
4735
|
+
private async applyDirectEdits(
|
|
4736
|
+
tool: EditTool,
|
|
4737
|
+
mount: MountState,
|
|
4738
|
+
targets: EditTarget[],
|
|
4739
|
+
early: EditProblem[],
|
|
4740
|
+
editCount: number,
|
|
4741
|
+
maxChars: number,
|
|
4742
|
+
agentName: string | undefined,
|
|
4743
|
+
): Promise<ToolResult> {
|
|
4744
|
+
const changedMeanwhile = new Set<EditTarget>();
|
|
4745
|
+
return this.withMount(mount, async () => {
|
|
4746
|
+
for (let attempt = 1; ; attempt++) {
|
|
4747
|
+
const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
|
|
4748
|
+
if (outcome !== 'changed') return outcome;
|
|
4749
|
+
if (attempt === DIRECT_EDIT_ATTEMPTS) {
|
|
4750
|
+
const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
|
|
4751
|
+
// Each attempt stops at the first file it finds changed, so these are
|
|
4752
|
+
// the files some attempt found changed, at most one per attempt.
|
|
4753
|
+
return {
|
|
4754
|
+
success: false,
|
|
4755
|
+
isError: true,
|
|
4756
|
+
error: tool === 'edit'
|
|
4757
|
+
? `${changed[0]} kept changing on disk while this edit was being applied (${DIRECT_EDIT_ATTEMPTS} attempts), so nothing was written. Read it again before retrying.`
|
|
4758
|
+
: `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
|
|
4759
|
+
+ `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
|
|
4760
|
+
};
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
4763
|
+
});
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
/**
|
|
4767
|
+
* One attempt of applyDirectEdits: its answer, or 'changed' when another
|
|
4768
|
+
* writer changed a file since its read, with nothing written. `early` are
|
|
4769
|
+
* the problems found before any file was read, which a refusal names with
|
|
4770
|
+
* the rest. The caller holds the mount's turn.
|
|
4771
|
+
*/
|
|
4772
|
+
private async attemptDirectEdits(
|
|
4773
|
+
tool: EditTool,
|
|
4774
|
+
mount: MountState,
|
|
4775
|
+
targets: EditTarget[],
|
|
4776
|
+
early: EditProblem[],
|
|
4777
|
+
changedMeanwhile: Set<EditTarget>,
|
|
4778
|
+
editCount: number,
|
|
4779
|
+
maxChars: number,
|
|
4780
|
+
agentName: string | undefined,
|
|
4781
|
+
): Promise<ToolResult | 'changed'> {
|
|
4782
|
+
const problems = [...early];
|
|
4783
|
+
const read = new Map<EditTarget, { bytes: Buffer; identity: FileIdentity }>();
|
|
4784
|
+
for (const target of targets) {
|
|
4785
|
+
const got = await this.directEditText(mount, target.relativePath, target.path);
|
|
4786
|
+
if ('changed' in got) {
|
|
4787
|
+
changedMeanwhile.add(target);
|
|
4788
|
+
return 'changed';
|
|
4789
|
+
}
|
|
4790
|
+
if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
|
|
4791
|
+
else read.set(target, got);
|
|
4792
|
+
}
|
|
4793
|
+
// Two paths that reach one file, through a link this mount follows, or
|
|
4794
|
+
// as two hard links (a disk that folds case has its two spellings made
|
|
4795
|
+
// one path by the parse, short of a directory link on the way, past
|
|
4796
|
+
// which they're refused here as one file named twice): each
|
|
4797
|
+
// would be planned against the same text and renamed over the same file
|
|
4798
|
+
// or split from it, so one's edits would be lost or kept apart from the
|
|
4799
|
+
// other's. They're refused. One file read twice has one identity here
|
|
4800
|
+
// unless a writer changed it between the reads, and the check before the
|
|
4801
|
+
// renames then sees that and reads again.
|
|
4802
|
+
const byIdentity = new Map<string, EditTarget>();
|
|
4803
|
+
for (const [target, { identity }] of read) {
|
|
4804
|
+
const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
|
|
4805
|
+
const other = byIdentity.get(key);
|
|
4806
|
+
if (!other) {
|
|
4807
|
+
byIdentity.set(key, target);
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4810
|
+
problems.push({
|
|
4811
|
+
kind: 'file',
|
|
4812
|
+
edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
|
|
4813
|
+
path: other.path,
|
|
4814
|
+
text: `${other.path} and ${target.path} are one file, reached through a link or as two hard links; name it one way, so its edits are matched in one text`,
|
|
4815
|
+
whole: true,
|
|
4816
|
+
});
|
|
4817
|
+
}
|
|
4818
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4819
|
+
const hints = hintBudget();
|
|
4820
|
+
// As much as the largest file the call names holds, at least, so the
|
|
4821
|
+
// budget is the same whatever order its files are planned in.
|
|
4822
|
+
let largest = maxSize;
|
|
4823
|
+
for (const { bytes } of read.values()) largest = Math.max(largest, bytes.length);
|
|
4824
|
+
const counts = countBudget(largest);
|
|
4825
|
+
const plans: Array<{ target: EditTarget; plan: FilePlan }> = [];
|
|
4826
|
+
for (const target of targets) {
|
|
4827
|
+
const got = read.get(target);
|
|
4828
|
+
if (!got) continue;
|
|
4829
|
+
const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
|
|
4830
|
+
if (plan !== null) plans.push({ target, plan });
|
|
4831
|
+
}
|
|
4832
|
+
// A file read again for another writer's change says so, whichever way
|
|
4833
|
+
// its edits went.
|
|
4834
|
+
const reread = (target: EditTarget, verb: string): string | null => !changedMeanwhile.has(target) ? null
|
|
4835
|
+
: `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
|
|
4836
|
+
+ `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
|
|
4837
|
+
const refuse = (found: EditProblem[]): ToolResult => ({
|
|
4838
|
+
success: false,
|
|
4839
|
+
isError: true,
|
|
4840
|
+
error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note): note is string => note !== null), maxChars, 'wrote'),
|
|
4841
|
+
});
|
|
4842
|
+
if (problems.length) return refuse(problems);
|
|
4843
|
+
|
|
4844
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4845
|
+
const staged: Array<{ target: EditTarget; staged: Staged }> = [];
|
|
4846
|
+
const discardAll = async (from = 0): Promise<void> => {
|
|
4847
|
+
for (const { staged: file } of staged.slice(from)) await discard(file);
|
|
4848
|
+
};
|
|
4849
|
+
// A refusal before any file is renamed says so where the call names several.
|
|
4850
|
+
const nothingWritten = (target: EditTarget, error: unknown): ToolResult => refuse([{
|
|
4851
|
+
kind: 'file',
|
|
4852
|
+
edits: target.edits,
|
|
4853
|
+
path: target.path,
|
|
4854
|
+
text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
|
|
4855
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4856
|
+
+ (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
|
|
4857
|
+
whole: true,
|
|
4858
|
+
}]);
|
|
4859
|
+
for (const { target, plan } of plans) {
|
|
4860
|
+
try {
|
|
4861
|
+
staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
|
|
4862
|
+
} catch (error) {
|
|
4863
|
+
await discardAll();
|
|
4864
|
+
if (error instanceof FileChanged) {
|
|
4865
|
+
changedMeanwhile.add(target);
|
|
4866
|
+
return 'changed';
|
|
4867
|
+
}
|
|
4868
|
+
return nothingWritten(target, error);
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
try {
|
|
4872
|
+
await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
|
|
4873
|
+
} catch (error) {
|
|
4874
|
+
await discardAll();
|
|
4875
|
+
throw error;
|
|
4876
|
+
}
|
|
4877
|
+
for (const { target, staged: file } of staged) {
|
|
4878
|
+
try {
|
|
4879
|
+
await placedStill(file);
|
|
4880
|
+
} catch (error) {
|
|
4881
|
+
await discardAll();
|
|
4882
|
+
return nothingWritten(target, error);
|
|
4883
|
+
}
|
|
4884
|
+
}
|
|
4885
|
+
// Every file is checked, then every file renamed, with no turn of the
|
|
4886
|
+
// event loop between: a change to any of them before its check is seen
|
|
4887
|
+
// before any is written, and one landing between its check and the last
|
|
4888
|
+
// rename isn't (checkUnchanged).
|
|
4889
|
+
for (const { target, staged: file } of staged) {
|
|
4890
|
+
try {
|
|
4891
|
+
checkUnchanged(file, read.get(target)!);
|
|
4892
|
+
} catch (error) {
|
|
4893
|
+
await discardAll();
|
|
4894
|
+
if (error instanceof FileChanged) {
|
|
4895
|
+
changedMeanwhile.add(target);
|
|
4896
|
+
return 'changed';
|
|
4897
|
+
}
|
|
4898
|
+
return nothingWritten(target, error);
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
for (const [i, { target, staged: file }] of staged.entries()) {
|
|
4902
|
+
try {
|
|
4903
|
+
renameInto(file);
|
|
4904
|
+
} catch (error) {
|
|
4905
|
+
if (i === 0) {
|
|
4906
|
+
await discardAll();
|
|
4907
|
+
return nothingWritten(target, error);
|
|
4908
|
+
}
|
|
4909
|
+
// Read with no turn of the event loop since the last rename.
|
|
4910
|
+
this.recordDirectWrite(mount, agentName);
|
|
4911
|
+
await discardAll(i);
|
|
4912
|
+
return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
this.recordDirectWrite(mount, agentName);
|
|
4916
|
+
return editReceipt(tool, staged.map(({ target, staged: file }) => ({
|
|
4917
|
+
path: target.path,
|
|
4918
|
+
plan: plans.find((p) => p.target === target)!.plan,
|
|
4919
|
+
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
|
|
4920
|
+
modeKept: null,
|
|
4921
|
+
})), editCount, maxChars, 'done');
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
/**
|
|
4925
|
+
* The answer to an edit_many call on a direct mount whose rename of its
|
|
4926
|
+
* `failed`th file failed after the ones before it were written: what was
|
|
4927
|
+
* written, the failure, and that nothing puts them back, then each file's
|
|
4928
|
+
* outcome, within `maxChars`, with the hard links a written one split
|
|
4929
|
+
* from, as a full answer names them (linksNote).
|
|
4930
|
+
*/
|
|
4931
|
+
private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
|
|
4932
|
+
// The failed file is named among the outcomes, so the head stays as
|
|
4933
|
+
// long as its reason, whatever the paths' lengths.
|
|
4934
|
+
const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
|
|
4935
|
+
const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}.${can !== undefined ? ` Whoever runs this host can ${can}.` : ''} `
|
|
4936
|
+
+ "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
|
|
4937
|
+
const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
|
|
4938
|
+
const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
|
|
4939
|
+
return fitted(head, parts, notes, maxChars, 'written');
|
|
4940
|
+
}
|
|
4941
|
+
|
|
4942
|
+
/**
|
|
4943
|
+
* A direct mount's file as an edit reads it: its bytes on disk as they are
|
|
4944
|
+
* now, through the mount's boundary as read reaches them
|
|
4945
|
+
* (openContainedFile), with the file's identity when the read began, which
|
|
4946
|
+
* the write checks it against just before its rename. A file changed while
|
|
4947
|
+
* it was read is `changed`, so the call reads it again. What the
|
|
4948
|
+
* workspace doesn't show as text, it doesn't edit either: a file over the
|
|
4949
|
+
* mount's size limit, and a binary one.
|
|
4950
|
+
*/
|
|
4951
|
+
private async directEditText(
|
|
4952
|
+
mount: MountState,
|
|
4953
|
+
relativePath: string,
|
|
4954
|
+
path: string,
|
|
4955
|
+
): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
|
|
4956
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4957
|
+
const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
|
|
4958
|
+
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
4959
|
+
try {
|
|
4960
|
+
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4961
|
+
if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
4962
|
+
});
|
|
4963
|
+
} catch (err) {
|
|
4964
|
+
if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
|
|
4965
|
+
if (err instanceof Refusal) return { error: err.message };
|
|
4966
|
+
if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
|
|
4967
|
+
throw err;
|
|
4968
|
+
}
|
|
4969
|
+
try {
|
|
4970
|
+
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4971
|
+
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4972
|
+
if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4973
|
+
if (looksBinary(bytes)) {
|
|
4974
|
+
return { error: `${JSON.stringify(path)} is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.` };
|
|
4975
|
+
}
|
|
4976
|
+
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
|
|
4977
|
+
return { bytes, identity: before };
|
|
4978
|
+
} catch (err) {
|
|
4979
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4980
|
+
} finally {
|
|
4981
|
+
await opened.handle.close();
|
|
4982
|
+
}
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
/**
|
|
4986
|
+
* A direct mount's deletion: the file the read tools reach, by their
|
|
4987
|
+
* refusals (openContainedFile), unlinked through the mount's boundary as
|
|
4988
|
+
* a push's deletion unlinks one (unlinkContained). A followed link is the
|
|
4989
|
+
* entry removed, as there. One that lands is the caller's latest write on
|
|
4990
|
+
* the mount (recordDirectWrite).
|
|
4991
|
+
*/
|
|
4992
|
+
private async deleteDirect(mount: MountState, relativePath: string, path: string, agentName: string | undefined): Promise<ToolResult> {
|
|
4993
|
+
return this.withMount(mount, async (): Promise<ToolResult> => {
|
|
4994
|
+
// One root for the call, by its identity as the call starts: the
|
|
4995
|
+
// open's checks and the unlink's own refuse any other directory there,
|
|
4996
|
+
// in ROOT_MOVED's words, so a root replaced after this stat is refused
|
|
4997
|
+
// rather than taken, but in the instant between the unlink's last
|
|
4998
|
+
// check and the unlink, which Node can't close (effects.ts's header).
|
|
4999
|
+
// One that can't be read now is refused here.
|
|
5000
|
+
const disk = await this.diskView(mount);
|
|
5001
|
+
let rootStat: BigIntStats;
|
|
5002
|
+
try {
|
|
5003
|
+
rootStat = await stat(disk.rootReal, { bigint: true });
|
|
5004
|
+
} catch (err) {
|
|
5005
|
+
const code = errnoOf(err);
|
|
5006
|
+
const why = code === 'ENOENT' || code === 'ENOTDIR'
|
|
5007
|
+
? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
|
|
5008
|
+
: `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
|
|
5009
|
+
return { success: false, isError: true, error: didNothing('delete', why, path) };
|
|
5010
|
+
}
|
|
5011
|
+
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
5012
|
+
const { rootReal } = disk;
|
|
5013
|
+
try {
|
|
5014
|
+
const opened = await this.openContainedFile(mount, relativePath, path);
|
|
5015
|
+
await opened.handle.close();
|
|
5016
|
+
} catch (err) {
|
|
5017
|
+
// The open's own words for a replaced root speak of reading.
|
|
5018
|
+
if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
|
|
5019
|
+
return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
|
|
5020
|
+
}
|
|
5021
|
+
if (err instanceof WorkspaceReadError) return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
|
|
5022
|
+
throw err;
|
|
5023
|
+
}
|
|
5024
|
+
// The unlink is awaited, unlike a write's rename, so where the head
|
|
5025
|
+
// was just before it says whether the head moved while it ran.
|
|
5026
|
+
const since = this.positionNow();
|
|
5027
|
+
try {
|
|
5028
|
+
await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
|
|
5029
|
+
} catch (error) {
|
|
5030
|
+
// A failure after the unlink reached the file's entry can't say
|
|
5031
|
+
// whether the file is gone, so it's taken as the agent's write too.
|
|
5032
|
+
if (error instanceof EffectFailed && error.touched) {
|
|
5033
|
+
this.recordDirectWrite(mount, agentName, since);
|
|
5034
|
+
return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
|
|
5035
|
+
}
|
|
5036
|
+
return { success: false, isError: true, error: directDidNothing('delete', path, error) };
|
|
5037
|
+
}
|
|
5038
|
+
this.recordDirectWrite(mount, agentName, since);
|
|
5039
|
+
return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
|
|
5040
|
+
});
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
/**
|
|
5044
|
+
* After a direct write lands: the agent's latest write on the mount, P,
|
|
5045
|
+
* moves to it, where the agent's history holds P or the notice of its
|
|
5046
|
+
* departure from P, N (historyHoldsWrites), and N clears. Otherwise P
|
|
5047
|
+
* stays where it is: the head moved away from it under the call's turn,
|
|
5048
|
+
* and the next start's notice covers what it left (headMoveNotice). P is
|
|
5049
|
+
* the branch and the store's head as it is now, read with no turn of the
|
|
5050
|
+
* event loop since the write, so no record that lands after the write is
|
|
5051
|
+
* taken for one before it. A deletion, whose unlink is awaited, passes
|
|
5052
|
+
* where the head was just before its unlink (`since`): where the branch
|
|
5053
|
+
* changed meanwhile, the head moved under the call, and P is set there,
|
|
5054
|
+
* so the next start judges the deletion by the branch its turn was on,
|
|
5055
|
+
* unless the history doesn't hold P already, whose notice is owed. A write no agent
|
|
5056
|
+
* made tells no one. The store's reads here come after the write landed,
|
|
5057
|
+
* so one that throws is logged and P stays, as for a history that can't
|
|
5058
|
+
* be read, and the write still answers that it landed.
|
|
5059
|
+
*/
|
|
5060
|
+
private recordDirectWrite(mount: MountState, agentName: string | undefined, since?: DirectPosition | null): void {
|
|
5061
|
+
if (agentName === undefined) return;
|
|
5062
|
+
try {
|
|
5063
|
+
const store = this.getStore();
|
|
5064
|
+
let heads = this.directHeads.get(mount.config.name);
|
|
5065
|
+
if (!heads) this.directHeads.set(mount.config.name, (heads = new Map()));
|
|
5066
|
+
const head = heads.get(agentName);
|
|
5067
|
+
const branchId = store.currentBranch().id;
|
|
5068
|
+
if (since && since.branchId !== branchId) {
|
|
5069
|
+
if (!head || this.historyHoldsWrites(agentName, head) === true) heads.set(agentName, { ...since, notice: null });
|
|
5070
|
+
return;
|
|
5071
|
+
}
|
|
5072
|
+
if (head && this.historyHoldsWrites(agentName, head) !== true) return;
|
|
5073
|
+
heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
|
|
5074
|
+
} catch (err) {
|
|
5075
|
+
console.error('[workspace] could not record a direct write\'s position:', err);
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
|
|
5079
|
+
/** The store's branch and head now, or null where the store can't say. */
|
|
5080
|
+
private positionNow(): DirectPosition | null {
|
|
5081
|
+
try {
|
|
5082
|
+
const store = this.getStore();
|
|
5083
|
+
return { branchId: store.currentBranch().id, after: store.currentSequence() };
|
|
5084
|
+
} catch {
|
|
5085
|
+
return null;
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
|
|
5089
|
+
/**
|
|
5090
|
+
* Whether an agent's history holds its latest direct write on a mount
|
|
5091
|
+
* (holdsPosition), or the notice of its departure from it, judged by the
|
|
5092
|
+
* notice's own presence there, through the lookup the framework hands the
|
|
5093
|
+
* module (useHistory); null where that lookup threw, which is logged. A
|
|
5094
|
+
* history that can't be read decides nothing: a write takes it as not
|
|
5095
|
+
* held, so its position stays and the notice stays owed, and a start
|
|
5096
|
+
* gives no notice, which may not be true of a history it can't read, and
|
|
5097
|
+
* leaves the next start to decide (headMoveNotice).
|
|
5098
|
+
*/
|
|
5099
|
+
private historyHoldsWrites(agentName: string, head: DirectHead): boolean | null {
|
|
5100
|
+
if (this.holdsPosition(head)) return true;
|
|
5101
|
+
if (head.notice === null) return false;
|
|
5102
|
+
try {
|
|
5103
|
+
return this.historyLookup?.(agentName, head.notice) === true;
|
|
5104
|
+
} catch (err) {
|
|
5105
|
+
console.error('[workspace] history lookup for the head-move notice failed:', err);
|
|
5106
|
+
return null;
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
|
|
5110
|
+
/**
|
|
5111
|
+
* Whether the store's current branch holds what came before a direct
|
|
5112
|
+
* write: it's the write's branch, or continues it with every fork point on
|
|
5113
|
+
* the way after `after`, the store's head when the write landed. A fork at
|
|
5114
|
+
* exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
|
|
5115
|
+
* which is the head at the write when nothing was recorded in the turn
|
|
5116
|
+
* before it. A missing branch or one without fork metadata holds nothing,
|
|
5117
|
+
* as for the materialize guard (isLinearContinuation).
|
|
5118
|
+
*/
|
|
5119
|
+
private holdsPosition({ branchId, after }: DirectHead): boolean {
|
|
5120
|
+
const store = this.getStore();
|
|
5121
|
+
const current = store.currentBranch();
|
|
5122
|
+
if (current.id === branchId) return true;
|
|
5123
|
+
const byId = new Map(store.listBranches().map((b) => [b.id, b]));
|
|
5124
|
+
const visited = new Set<string>();
|
|
5125
|
+
let cursor = byId.get(current.id);
|
|
5126
|
+
while (cursor && !visited.has(cursor.id)) {
|
|
5127
|
+
visited.add(cursor.id);
|
|
5128
|
+
if (cursor.id === branchId) return true;
|
|
5129
|
+
if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after) return false;
|
|
5130
|
+
cursor = byId.get(cursor.parentId);
|
|
5131
|
+
}
|
|
5132
|
+
return false;
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
/**
|
|
5136
|
+
* The framework's check at every start of an agent's turn, opening,
|
|
5137
|
+
* continuing or resuming: the notice due for each mount the agent wrote
|
|
5138
|
+
* direct where its history holds neither its latest write there nor the
|
|
5139
|
+
* notice of its departure from it (historyHoldsWrites), with those
|
|
5140
|
+
* mounts' names; or null, as on a head that hasn't moved. The framework
|
|
5141
|
+
* stores the text in the agent's history before the turn's compile and
|
|
5142
|
+
* hands its message id back (headMoveNoticeStored).
|
|
5143
|
+
*/
|
|
5144
|
+
headMoveNotice(agentName: string): { text: string; mounts: string[] } | null {
|
|
5145
|
+
if (!this.store) return null;
|
|
5146
|
+
const due: string[] = [];
|
|
5147
|
+
// Every mount where the agent wrote direct, whether it runs direct this
|
|
5148
|
+
// session or as 'chronicle': the writes are on disk either way.
|
|
5149
|
+
for (const name of this.mounts.keys()) {
|
|
5150
|
+
const head = this.directHeads.get(name)?.get(agentName);
|
|
5151
|
+
if (!head) continue;
|
|
5152
|
+
const held = this.historyHoldsWrites(agentName, head);
|
|
5153
|
+
// A history that can't be read decides nothing at this start.
|
|
5154
|
+
if (held === null) return null;
|
|
5155
|
+
if (!held) due.push(name);
|
|
5156
|
+
}
|
|
5157
|
+
return due.length ? { text: headMoveText(due), mounts: due } : null;
|
|
5158
|
+
}
|
|
5159
|
+
|
|
5160
|
+
/** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
|
|
5161
|
+
headMoveNoticeStored(agentName: string, mounts: readonly string[], messageId: string): void {
|
|
5162
|
+
for (const name of mounts) {
|
|
5163
|
+
const head = this.directHeads.get(name)?.get(agentName);
|
|
5164
|
+
if (head) head.notice = messageId;
|
|
5165
|
+
}
|
|
5166
|
+
}
|
|
5167
|
+
|
|
5168
|
+
/**
|
|
5169
|
+
* How the module asks whether an agent's history holds a message, as the
|
|
5170
|
+
* framework judges its own notices (`cm.getMessage(id) !== null`): handed
|
|
5171
|
+
* over once, when the module is added. Without it, no notice is held.
|
|
5172
|
+
*/
|
|
5173
|
+
useHistory(lookup: (agentName: string, messageId: string) => boolean): void {
|
|
5174
|
+
this.historyLookup = lookup;
|
|
5175
|
+
}
|
|
5176
|
+
|
|
5177
|
+
private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
5178
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
|
|
5179
|
+
if (relativePath === '') return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
|
|
5180
|
+
input = { ...input, path };
|
|
5181
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
5182
|
+
if (refusal) return { success: false, error: didNothing('delete', refusal), isError: true };
|
|
5183
|
+
if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
5184
|
+
|
|
5185
|
+
const store = this.getStore();
|
|
5186
|
+
let committed = false;
|
|
5187
|
+
try {
|
|
5188
|
+
return await this.withMount(mount, async (): Promise<ToolResult> => {
|
|
5189
|
+
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
5190
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
5191
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
5192
|
+
if (!entry) {
|
|
5193
|
+
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
|
|
5194
|
+
return { success: false, error: didNothing('delete', error), isError: true };
|
|
5195
|
+
}
|
|
5196
|
+
|
|
5197
|
+
const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
|
|
5198
|
+
() => { committed = true; });
|
|
4429
5199
|
if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
|
|
4430
5200
|
|
|
4431
5201
|
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
4432
5202
|
// withholds the result (agent-framework #277).
|
|
4433
5203
|
return {
|
|
4434
5204
|
success: true,
|
|
4435
|
-
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
4436
|
-
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
5205
|
+
data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
|
|
5206
|
+
keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
|
|
4437
5207
|
};
|
|
4438
5208
|
});
|
|
4439
5209
|
} catch (error) {
|
|
4440
|
-
if (!committed) throw error;
|
|
5210
|
+
if (!committed) throw notStored(error);
|
|
4441
5211
|
if (error instanceof StoredThenFailed) {
|
|
4442
5212
|
// Its note, as the success answer's: a deletion that cleared a
|
|
4443
5213
|
// conflict naming a disk file unlinked nothing.
|
|
4444
5214
|
const note = error.notes.get(relativePath);
|
|
4445
5215
|
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
|
|
4446
|
-
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}
|
|
5216
|
+
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
|
|
4447
5217
|
}
|
|
4448
|
-
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
|
|
5218
|
+
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
4449
5219
|
}
|
|
4450
5220
|
}
|
|
4451
5221
|
|
|
@@ -4458,7 +5228,9 @@ export class WorkspaceModule implements Module {
|
|
|
4458
5228
|
const mounts = [...this.mounts.entries()].map(([name, m]) => ({
|
|
4459
5229
|
name,
|
|
4460
5230
|
path: m.config.path,
|
|
4461
|
-
mode
|
|
5231
|
+
// A mount configured direct says its mode where its config names
|
|
5232
|
+
// none, so a writable one shows as writable.
|
|
5233
|
+
mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
|
|
4462
5234
|
...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
|
|
4463
5235
|
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
4464
5236
|
}));
|
|
@@ -4469,7 +5241,7 @@ export class WorkspaceModule implements Module {
|
|
|
4469
5241
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
4470
5242
|
// is refused, as glob refuses it, and shown as given.
|
|
4471
5243
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4472
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5244
|
+
return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4473
5245
|
}
|
|
4474
5246
|
input = { ...input, path };
|
|
4475
5247
|
const recursive = input.recursive === true;
|
|
@@ -4479,6 +5251,9 @@ export class WorkspaceModule implements Module {
|
|
|
4479
5251
|
// shows disk's.
|
|
4480
5252
|
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
4481
5253
|
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
5254
|
+
const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
|
|
5255
|
+
const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
|
|
5256
|
+
const noted = note !== null ? { note } : {};
|
|
4482
5257
|
if (recursive) {
|
|
4483
5258
|
return {
|
|
4484
5259
|
success: true,
|
|
@@ -4487,6 +5262,7 @@ export class WorkspaceModule implements Module {
|
|
|
4487
5262
|
entries: listing.files,
|
|
4488
5263
|
count: listing.files.length,
|
|
4489
5264
|
...incomplete,
|
|
5265
|
+
...noted,
|
|
4490
5266
|
},
|
|
4491
5267
|
};
|
|
4492
5268
|
}
|
|
@@ -4504,10 +5280,34 @@ export class WorkspaceModule implements Module {
|
|
|
4504
5280
|
entries: children,
|
|
4505
5281
|
count: children.length,
|
|
4506
5282
|
...incomplete,
|
|
5283
|
+
...noted,
|
|
4507
5284
|
},
|
|
4508
5285
|
};
|
|
4509
5286
|
}
|
|
4510
5287
|
|
|
5288
|
+
private async emptyScopeNote(mount: MountState, relativePath: string, path: string): Promise<string | null> {
|
|
5289
|
+
const shown = JSON.stringify(path);
|
|
5290
|
+
const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
|
|
5291
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
5292
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
5293
|
+
if (seen.kind === 'file') return isFile;
|
|
5294
|
+
if (seen.kind !== 'absent') return null;
|
|
5295
|
+
if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) return isFile;
|
|
5296
|
+
return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
private async absentOnDisk(mount: MountState, relativePath: string): Promise<boolean> {
|
|
5300
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
5301
|
+
return (await observePath(view, rootReal, relativePath)).kind === 'absent';
|
|
5302
|
+
}
|
|
5303
|
+
|
|
5304
|
+
private holdsAnythingAt(mount: MountState, relativePath: string): boolean {
|
|
5305
|
+
if (mount.direct) return false;
|
|
5306
|
+
const store = this.getStore();
|
|
5307
|
+
const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree): tree is string => tree !== undefined);
|
|
5308
|
+
return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
|
|
5309
|
+
}
|
|
5310
|
+
|
|
4511
5311
|
/**
|
|
4512
5312
|
* What a listing shows of a directory scope, from where the mount keeps
|
|
4513
5313
|
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
@@ -4611,7 +5411,7 @@ export class WorkspaceModule implements Module {
|
|
|
4611
5411
|
if (input.path) {
|
|
4612
5412
|
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
4613
5413
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4614
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5414
|
+
return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4615
5415
|
}
|
|
4616
5416
|
}
|
|
4617
5417
|
|
|
@@ -4619,15 +5419,26 @@ export class WorkspaceModule implements Module {
|
|
|
4619
5419
|
// starts there: past the walk's cap, a walk of the whole scope can stop
|
|
4620
5420
|
// before it gets there. In the mount a pattern's first segment names, the
|
|
4621
5421
|
// pattern is read mount-prefixed (prefixedGlob), from its rest's directory
|
|
4622
|
-
// within `path`.
|
|
5422
|
+
// within `path`. A `path` that directory can't overlap leaves no file the
|
|
5423
|
+
// pattern can match, so the glob is refused, as grep's is, saying how
|
|
5424
|
+
// the pattern is read, rather than answered with no matches; but a path
|
|
5425
|
+
// in another mount's directory is that mount's, and is said so, as grep
|
|
5426
|
+
// says it. Only a `path` can do either, and it names one mount, so
|
|
5427
|
+
// nothing has been searched yet.
|
|
4623
5428
|
const patternDir = literalDirOf(input.pattern);
|
|
4624
|
-
for (const { mount, relativePath } of mountsToSearch) {
|
|
5429
|
+
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
4625
5430
|
const prefixed = prefixedGlob(input.pattern, mount.config.name);
|
|
4626
5431
|
let start: string;
|
|
4627
5432
|
let matched: (path: string) => boolean;
|
|
4628
5433
|
if (prefixed) {
|
|
4629
5434
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
4630
|
-
if (dir === null)
|
|
5435
|
+
if (dir === null) {
|
|
5436
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5437
|
+
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5438
|
+
continue;
|
|
5439
|
+
}
|
|
5440
|
+
return { success: false, error: await this.disjointRefusal('glob', input.path!, input.pattern, true, mount, relativePath, root), isError: true };
|
|
5441
|
+
}
|
|
4631
5442
|
start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
|
|
4632
5443
|
matched = (path: string) => prefixed.regex.test(path);
|
|
4633
5444
|
} else {
|
|
@@ -4657,16 +5468,32 @@ export class WorkspaceModule implements Module {
|
|
|
4657
5468
|
try {
|
|
4658
5469
|
regex = new RegExp(input.pattern);
|
|
4659
5470
|
} catch (e) {
|
|
4660
|
-
//
|
|
4661
|
-
|
|
5471
|
+
// V8's message ends with the parser's reason, after the pattern: "Invalid
|
|
5472
|
+
// regular expression: /foo(/: Unterminated group".
|
|
5473
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5474
|
+
const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
|
|
5475
|
+
return {
|
|
5476
|
+
success: false,
|
|
5477
|
+
isError: true,
|
|
5478
|
+
error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
|
|
5479
|
+
+ 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
|
|
5480
|
+
};
|
|
4662
5481
|
}
|
|
4663
5482
|
|
|
4664
5483
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
4665
|
-
// The filter matches mount-relative paths,
|
|
4666
|
-
//
|
|
4667
|
-
//
|
|
4668
|
-
// mount
|
|
5484
|
+
// The filter matches mount-relative paths, from the mount's root whatever
|
|
5485
|
+
// the `path`, so every file it lets through lies beneath its literal
|
|
5486
|
+
// directory, and the walk starts at the deeper of that and `path`. In the
|
|
5487
|
+
// mount a filter's first segment names, its mount-prefixed reading takes
|
|
5488
|
+
// the filter's place (below). One with no "/" also matches a file's name,
|
|
5489
|
+
// wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
|
|
5490
|
+
// file through, where tested against the path alone it let through only
|
|
5491
|
+
// those at the mount's root. The path is still tried, so a "**" in such a
|
|
5492
|
+
// filter spans directories as it did, and nothing it let through before
|
|
5493
|
+
// is lost. A filter read mount-prefixed always holds a "/" after the
|
|
5494
|
+
// mount's name, so it never takes the name test.
|
|
4669
5495
|
const filterDir = input.glob ? literalDirOf(input.glob) : '';
|
|
5496
|
+
const byName = fileGlob !== null && !input.glob!.includes('/');
|
|
4670
5497
|
const contextBefore = input.contextBefore ?? 0;
|
|
4671
5498
|
const contextAfter = input.contextAfter ?? 0;
|
|
4672
5499
|
|
|
@@ -4678,17 +5505,33 @@ export class WorkspaceModule implements Module {
|
|
|
4678
5505
|
if (input.path) {
|
|
4679
5506
|
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
4680
5507
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4681
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5508
|
+
return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4682
5509
|
}
|
|
4683
5510
|
}
|
|
4684
5511
|
|
|
4685
5512
|
type GrepMatch = { line: number; text: string; context?: string[] };
|
|
4686
|
-
|
|
5513
|
+
type Searched = { lineEndings?: 'CRLF' | 'mixed'; matches: GrepMatch[] };
|
|
5514
|
+
const results: Array<{ file: string; state?: string; version: GrepText['version'] } & Searched> = [];
|
|
4687
5515
|
const skipped: GrepSkip[] = [];
|
|
4688
5516
|
const incomplete: Array<{ path: string; reason: string }> = [];
|
|
4689
5517
|
|
|
4690
|
-
|
|
4691
|
-
|
|
5518
|
+
// Lines as read shows them (fileText): a CRLF end's CR is left out, for
|
|
5519
|
+
// matching as well as display, unless the line's own text ends in a CR, so
|
|
5520
|
+
// `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
|
|
5521
|
+
// the line copies into an edit as read's do. ripgrep's --crlf still prints
|
|
5522
|
+
// the line with its CR.
|
|
5523
|
+
//
|
|
5524
|
+
// Whether a file has CRLF ends or a BOM is a fact about the file, not a
|
|
5525
|
+
// line, so each searched file that has either is kept here by its path,
|
|
5526
|
+
// for the note on a pattern that names a CR or U+FEFF to name it. A
|
|
5527
|
+
// conflict's disk version is kept, for each, only where the workspace's
|
|
5528
|
+
// copy of its path doesn't have it, and is named as that version, since
|
|
5529
|
+
// read doesn't show it.
|
|
5530
|
+
const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
|
|
5531
|
+
const bomFiles = new Map<string, GrepText['version']>();
|
|
5532
|
+
const search = (text: GrepText): Searched & { bom: boolean } => {
|
|
5533
|
+
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5534
|
+
const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
|
|
4692
5535
|
const found: GrepMatch[] = [];
|
|
4693
5536
|
for (let i = 0; i < lines.length; i++) {
|
|
4694
5537
|
if (regex.test(lines[i])) {
|
|
@@ -4701,7 +5544,7 @@ export class WorkspaceModule implements Module {
|
|
|
4701
5544
|
found.push(match);
|
|
4702
5545
|
}
|
|
4703
5546
|
}
|
|
4704
|
-
return found;
|
|
5547
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
|
|
4705
5548
|
};
|
|
4706
5549
|
|
|
4707
5550
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
@@ -4709,49 +5552,91 @@ export class WorkspaceModule implements Module {
|
|
|
4709
5552
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
4710
5553
|
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
4711
5554
|
// found where its root lies now (movedNesting).
|
|
4712
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5555
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
4713
5556
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
4714
5557
|
continue;
|
|
4715
5558
|
}
|
|
4716
5559
|
// In the mount a filter's first segment names, the filter is read
|
|
4717
5560
|
// mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
|
|
4718
|
-
// a filter whose directories
|
|
4719
|
-
// let through, so
|
|
5561
|
+
// a filter whose directories, in the reading this mount takes, don't
|
|
5562
|
+
// overlap leave no file the filter can let through, so the grep is
|
|
5563
|
+
// refused, saying how the filter is read, rather than answered with no
|
|
5564
|
+
// matches: written relative to the `path`, as glob's pattern is, it
|
|
5565
|
+
// names nothing there. Only a `path` can do this, and it names one
|
|
5566
|
+
// mount, so nothing has been searched yet.
|
|
4720
5567
|
const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
|
|
4721
5568
|
const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
|
|
4722
|
-
if (deeper === null)
|
|
5569
|
+
if (deeper === null) {
|
|
5570
|
+
return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
|
|
5571
|
+
}
|
|
4723
5572
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
4724
|
-
const searched = (text: GrepText): void => {
|
|
4725
|
-
const
|
|
4726
|
-
|
|
5573
|
+
const searched = (text: GrepText): (() => void) => {
|
|
5574
|
+
const { bom, ...hits } = search(text);
|
|
5575
|
+
const { file, state, version } = text;
|
|
5576
|
+
return () => {
|
|
5577
|
+
if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version });
|
|
5578
|
+
if (bom && !bomFiles.has(file)) bomFiles.set(file, version);
|
|
5579
|
+
if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
|
|
5580
|
+
};
|
|
4727
5581
|
};
|
|
4728
5582
|
const kept = prefixed
|
|
4729
5583
|
? (path: string): boolean => prefixed.regex.test(path)
|
|
4730
|
-
: (path: string): boolean => fileGlob === null || fileGlob.test(path)
|
|
5584
|
+
: (path: string): boolean => fileGlob === null || fileGlob.test(path)
|
|
5585
|
+
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
4731
5586
|
const found = mount.direct
|
|
4732
|
-
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
5587
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
|
|
4733
5588
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
4734
5589
|
skipped.push(...found.skipped);
|
|
4735
5590
|
incomplete.push(...found.incomplete);
|
|
4736
5591
|
}
|
|
4737
5592
|
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
},
|
|
5593
|
+
const answer: GrepAnswer = {
|
|
5594
|
+
pattern: input.pattern,
|
|
5595
|
+
results,
|
|
5596
|
+
totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
5597
|
+
// A pattern written for a CR can't find a CRLF end's, nor one written
|
|
5598
|
+
// for U+FEFF a BOM, which lines as read shows them leave out, so the
|
|
5599
|
+
// answer says why it didn't, and names the files that have them.
|
|
5600
|
+
...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
|
|
5601
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
5602
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
4747
5603
|
};
|
|
5604
|
+
return textAnswer(grepText(answer, contextBefore), { ...answer });
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
/**
|
|
5608
|
+
* Why a grep or a glob whose `path` (`given`, in the mount at
|
|
5609
|
+
* `relativePath`) and filter or pattern can't overlap did nothing. grep's
|
|
5610
|
+
* filter is matched from the mount's root, and in the mount its first
|
|
5611
|
+
* segment names, either tool's is read mount-prefixed (`prefixed`), from
|
|
5612
|
+
* that mount's root; either way no file at or beneath the path can pass
|
|
5613
|
+
* it. Where the path isn't a file, it names the one that reaches beneath
|
|
5614
|
+
* it, the one meant if it was written relative to the path, mount-prefixed
|
|
5615
|
+
* as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
|
|
5616
|
+
* `?`, which matches each of them, and since the search stays beneath the
|
|
5617
|
+
* path, a `?` that would match a sibling's name too takes nothing in from
|
|
5618
|
+
* outside it. One with an empty, `.` or `..` segment gets no suggestion,
|
|
5619
|
+
* since no path holds such a segment. Segments are read as written, so one
|
|
5620
|
+
* a brace group splits around isn't told apart.
|
|
5621
|
+
*/
|
|
5622
|
+
private async disjointRefusal(tool: 'grep' | 'glob', given: string, filter: string, prefixed: boolean, mount: MountState, relativePath: string, root: string): Promise<string> {
|
|
5623
|
+
const name = mount.config.name;
|
|
5624
|
+
const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
|
|
5625
|
+
const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
|
|
5626
|
+
+ (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
|
|
5627
|
+
if (await this.namesFile(mount, relativePath, root)) return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
|
|
5628
|
+
const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
|
|
5629
|
+
if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) return within;
|
|
5630
|
+
const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
|
|
5631
|
+
return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
|
|
4748
5632
|
}
|
|
4749
5633
|
|
|
4750
5634
|
/**
|
|
4751
5635
|
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
4752
5636
|
* single file `relativePath` names, those `keep` lets through, each handed
|
|
4753
|
-
* to `
|
|
4754
|
-
* workspace copy, labelled with its state, and a
|
|
5637
|
+
* to `search`, whose answers are recorded in path order: a pass over the
|
|
5638
|
+
* scope, then each path's workspace copy, labelled with its state, and a
|
|
5639
|
+
* conflict's disk side.
|
|
4755
5640
|
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
4756
5641
|
* `incomplete`, each mount-prefixed.
|
|
4757
5642
|
*/
|
|
@@ -4760,7 +5645,7 @@ export class WorkspaceModule implements Module {
|
|
|
4760
5645
|
relativePath: string,
|
|
4761
5646
|
start: string,
|
|
4762
5647
|
keep: (path: string) => boolean,
|
|
4763
|
-
|
|
5648
|
+
search: (text: GrepText) => () => void,
|
|
4764
5649
|
): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
|
|
4765
5650
|
const store = this.getStore();
|
|
4766
5651
|
const skipped: GrepSkip[] = [];
|
|
@@ -4813,16 +5698,18 @@ export class WorkspaceModule implements Module {
|
|
|
4813
5698
|
}
|
|
4814
5699
|
|
|
4815
5700
|
const intents = this.intents.get(mount.config.name)!;
|
|
5701
|
+
type Lost = Omit<GrepText, 'bytes'> & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' };
|
|
5702
|
+
const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = [];
|
|
4816
5703
|
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
4817
5704
|
if (!keep(report.path)) continue;
|
|
4818
5705
|
const file = `${mount.config.name}/${report.path}`;
|
|
4819
5706
|
if (report.state === 'disk-only') {
|
|
4820
|
-
|
|
5707
|
+
found.push({ skip: {
|
|
4821
5708
|
file,
|
|
4822
5709
|
reason: 'disk-only: binary or over the size limit, never stored',
|
|
4823
5710
|
...(report.size !== undefined ? { size: report.size } : {}),
|
|
4824
5711
|
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
4825
|
-
});
|
|
5712
|
+
} });
|
|
4826
5713
|
continue;
|
|
4827
5714
|
}
|
|
4828
5715
|
// A path in another mount's directory is that mount's: grep gives
|
|
@@ -4831,16 +5718,16 @@ export class WorkspaceModule implements Module {
|
|
|
4831
5718
|
if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
|
|
4832
5719
|
const refusal = refused.get(report.path);
|
|
4833
5720
|
if (refusal !== undefined) {
|
|
4834
|
-
|
|
5721
|
+
found.push({ skip: { file, reason: refusal } });
|
|
4835
5722
|
continue;
|
|
4836
5723
|
}
|
|
4837
5724
|
// The workspace's version, labelled with the path's state.
|
|
4838
5725
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
5726
|
+
if (entry) {
|
|
5727
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
5728
|
+
found.push('bytes' in stored
|
|
5729
|
+
? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
|
|
5730
|
+
: { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
|
|
4844
5731
|
}
|
|
4845
5732
|
if (report.state !== 'conflict') continue;
|
|
4846
5733
|
// A conflict's disk side. The version recorded with the conflict is
|
|
@@ -4850,21 +5737,37 @@ export class WorkspaceModule implements Module {
|
|
|
4850
5737
|
// disk version was never stored either.
|
|
4851
5738
|
const disk = intents.get(report.path)?.conflict?.disk;
|
|
4852
5739
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
5740
|
+
if (disk?.stored) {
|
|
5741
|
+
const version = changed ? 'recorded-disk' : 'conflicting-disk';
|
|
5742
|
+
const stored = storedBytes(store, disk.stored);
|
|
5743
|
+
found.push('bytes' in stored
|
|
5744
|
+
? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
|
|
5745
|
+
: { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
|
|
4859
5746
|
}
|
|
4860
5747
|
if (changed) {
|
|
4861
5748
|
if (report.diskNow === 'file') {
|
|
4862
|
-
|
|
5749
|
+
found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } });
|
|
4863
5750
|
}
|
|
4864
5751
|
} else if (disk && !disk.stored) {
|
|
4865
|
-
|
|
5752
|
+
found.push({ skip: { file, reason: `conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; ${settleConflict('file', mount.config.mode === 'read-only').replace(/\.$/, '')}`, size: disk.size } });
|
|
4866
5753
|
}
|
|
4867
5754
|
}
|
|
5755
|
+
const answers = new Map<Lost, { searched: () => void } | { skip: GrepSkip }>();
|
|
5756
|
+
await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
|
|
5757
|
+
const { file, state, version, what } = lost;
|
|
5758
|
+
answers.set(lost, 'bytes' in contents
|
|
5759
|
+
? { searched: search({ file, state, version, bytes: contents.bytes }) }
|
|
5760
|
+
: { skip: {
|
|
5761
|
+
file,
|
|
5762
|
+
reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
|
|
5763
|
+
+ `and ${lostOnDisk(contents.lost)}, so it was not searched`,
|
|
5764
|
+
} });
|
|
5765
|
+
});
|
|
5766
|
+
for (const item of found) {
|
|
5767
|
+
const answer = 'lost' in item ? answers.get(item.lost)! : item;
|
|
5768
|
+
if ('searched' in answer) answer.searched();
|
|
5769
|
+
else skipped.push(answer.skip);
|
|
5770
|
+
}
|
|
4868
5771
|
});
|
|
4869
5772
|
return { skipped, incomplete };
|
|
4870
5773
|
}
|
|
@@ -4905,7 +5808,7 @@ export class WorkspaceModule implements Module {
|
|
|
4905
5808
|
const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
|
|
4906
5809
|
if (read.bytes === null) skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
|
|
4907
5810
|
else if (looksBinary(read.head)) skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
|
|
4908
|
-
else each({ file, version: 'disk',
|
|
5811
|
+
else each({ file, version: 'disk', bytes: read.bytes });
|
|
4909
5812
|
});
|
|
4910
5813
|
return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
|
|
4911
5814
|
}
|
|
@@ -4914,7 +5817,7 @@ export class WorkspaceModule implements Module {
|
|
|
4914
5817
|
const store = this.getStore();
|
|
4915
5818
|
const status: Record<string, unknown> = {};
|
|
4916
5819
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
4917
|
-
return { success: false, error:
|
|
5820
|
+
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
4918
5821
|
}
|
|
4919
5822
|
|
|
4920
5823
|
for (const [name, mount] of this.mounts) {
|
|
@@ -4922,7 +5825,7 @@ export class WorkspaceModule implements Module {
|
|
|
4922
5825
|
if (mount.direct) {
|
|
4923
5826
|
// A direct mount keeps nothing to count: its kind, and the ignore
|
|
4924
5827
|
// list its walks go by, whose default no config shows.
|
|
4925
|
-
status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
5828
|
+
status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
4926
5829
|
continue;
|
|
4927
5830
|
}
|
|
4928
5831
|
// What the workspace's history holds outside the mount's include isn't the mount's,
|
|
@@ -4937,7 +5840,7 @@ export class WorkspaceModule implements Module {
|
|
|
4937
5840
|
const currentBranch = store.currentBranch();
|
|
4938
5841
|
status[name] = {
|
|
4939
5842
|
path: mount.config.path,
|
|
4940
|
-
mode: mount.config.mode,
|
|
5843
|
+
mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
|
|
4941
5844
|
// A mount configured direct runs as 'chronicle' this session
|
|
4942
5845
|
// (chooseKinds), by the direct kind's ignore list where its config
|
|
4943
5846
|
// names none.
|
|
@@ -5057,9 +5960,6 @@ export class WorkspaceModule implements Module {
|
|
|
5057
5960
|
*/
|
|
5058
5961
|
private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
|
|
5059
5962
|
const store = this.getStore();
|
|
5060
|
-
const agreement = this.agreementOrThrow();
|
|
5061
|
-
const name = mount.config.name;
|
|
5062
|
-
const intents = this.intents.get(name)!;
|
|
5063
5963
|
const owed: string[] = [];
|
|
5064
5964
|
const unchecked: string[] = [];
|
|
5065
5965
|
let agreed = 0;
|
|
@@ -5068,17 +5968,32 @@ export class WorkspaceModule implements Module {
|
|
|
5068
5968
|
// Outside the mount's include, or in another mount's directory nested
|
|
5069
5969
|
// in it: neither owed to disk nor counted as on it.
|
|
5070
5970
|
if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
|
|
5071
|
-
const
|
|
5072
|
-
if (
|
|
5073
|
-
else if (
|
|
5074
|
-
// A mode the store records, other than the one disk last agreed with,
|
|
5075
|
-
// where modes are observed: elsewhere no push applies one.
|
|
5076
|
-
else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
|
|
5971
|
+
const kind = this.evidenceOf(mount, e);
|
|
5972
|
+
if (kind === 'owed') owed.push(e.path);
|
|
5973
|
+
else if (kind === 'unchecked') unchecked.push(e.path);
|
|
5077
5974
|
else agreed++;
|
|
5078
5975
|
}
|
|
5079
5976
|
return { owed, unchecked, agreed };
|
|
5080
5977
|
}
|
|
5081
5978
|
|
|
5979
|
+
private evidenceOf(mount: MountState, e: { path: string; blobHash: string; mode: number }): 'owed' | 'unchecked' | 'agreed' {
|
|
5980
|
+
const p = this.agreementOrThrow().get(mount.config.name, e.path);
|
|
5981
|
+
if (p === undefined) return this.intents.get(mount.config.name)!.get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
|
|
5982
|
+
if (p.kind !== 'content' || p.hash !== e.blobHash) return 'owed';
|
|
5983
|
+
// A mode the store records, other than the one disk last agreed with,
|
|
5984
|
+
// where modes are observed: elsewhere no push applies one.
|
|
5985
|
+
if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) return 'owed';
|
|
5986
|
+
return 'agreed';
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
private diskOf(mount: MountState, relativePath: string): DiskState {
|
|
5990
|
+
const intent = this.intents.get(mount.config.name)!.get(relativePath);
|
|
5991
|
+
if (intent?.conflict) return 'in conflict with disk';
|
|
5992
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
5993
|
+
if (!entry) return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
|
|
5994
|
+
return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5082
5997
|
private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
|
|
5083
5998
|
const store = this.getStore();
|
|
5084
5999
|
|
|
@@ -5095,7 +6010,7 @@ export class WorkspaceModule implements Module {
|
|
|
5095
6010
|
// is refused; beneath a directory, the push leaves out what the include
|
|
5096
6011
|
// doesn't cover.
|
|
5097
6012
|
if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
|
|
5098
|
-
return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
|
|
6013
|
+
return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
|
|
5099
6014
|
}
|
|
5100
6015
|
}
|
|
5101
6016
|
|
|
@@ -5105,12 +6020,13 @@ export class WorkspaceModule implements Module {
|
|
|
5105
6020
|
} else if (input.mount) {
|
|
5106
6021
|
const m = this.mounts.get(input.mount);
|
|
5107
6022
|
if (!m) {
|
|
5108
|
-
return { success: false, error:
|
|
6023
|
+
return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
|
|
5109
6024
|
}
|
|
5110
6025
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
5111
6026
|
} else {
|
|
6027
|
+
// Not a mount running direct, whose writes are on disk already.
|
|
5112
6028
|
mountsToMaterialize = [...this.mounts.entries()]
|
|
5113
|
-
.filter(([, m]) => m.config.mode === 'read-write')
|
|
6029
|
+
.filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
|
|
5114
6030
|
.map(([name, mount]) => ({ name, mount }));
|
|
5115
6031
|
}
|
|
5116
6032
|
|
|
@@ -5131,6 +6047,12 @@ export class WorkspaceModule implements Module {
|
|
|
5131
6047
|
let proceeded = 0;
|
|
5132
6048
|
|
|
5133
6049
|
for (const { name, mount } of mountsToMaterialize) {
|
|
6050
|
+
// Only a writable direct mount the caller named gets here: it keeps no
|
|
6051
|
+
// tree to push. A read-only one is named as any read-only mount is.
|
|
6052
|
+
if (mount.direct && mount.config.mode !== 'read-only') {
|
|
6053
|
+
blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
|
|
6054
|
+
continue;
|
|
6055
|
+
}
|
|
5134
6056
|
if (mount.config.mode === 'read-only') {
|
|
5135
6057
|
// Only a mount the caller named gets here read-only (a bare
|
|
5136
6058
|
// materialize takes the read-write ones): say why nothing was written.
|
|
@@ -5206,16 +6128,21 @@ export class WorkspaceModule implements Module {
|
|
|
5206
6128
|
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
5207
6129
|
return {
|
|
5208
6130
|
success: false,
|
|
5209
|
-
error:
|
|
6131
|
+
error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
|
|
5210
6132
|
isError: true,
|
|
5211
6133
|
};
|
|
5212
6134
|
}
|
|
5213
6135
|
|
|
6136
|
+
const held = explicit;
|
|
6137
|
+
const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
|
|
6138
|
+
&& allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
|
|
6139
|
+
&& !this.holdsAnythingAt(held.mount, held.relativePath);
|
|
5214
6140
|
return {
|
|
5215
6141
|
success: true,
|
|
5216
6142
|
data: {
|
|
5217
6143
|
materialized: allWritten,
|
|
5218
6144
|
count: allWritten.length,
|
|
6145
|
+
...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
|
|
5219
6146
|
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
5220
6147
|
...(pendingDeletions.length > 0
|
|
5221
6148
|
? {
|
|
@@ -5243,7 +6170,8 @@ export class WorkspaceModule implements Module {
|
|
|
5243
6170
|
if (!this.running()) return [];
|
|
5244
6171
|
const store = this.getStore();
|
|
5245
6172
|
const mount = this.mounts.get(mountName);
|
|
5246
|
-
|
|
6173
|
+
// A direct mount keeps no tree to push: disk is its only copy.
|
|
6174
|
+
if (!mount || mount.config.mode === 'read-only' || mount.direct) return [];
|
|
5247
6175
|
|
|
5248
6176
|
// Reset tracking — we're deliberately materializing on the new branch
|
|
5249
6177
|
mount.lastMaterializedBranchId = null;
|
|
@@ -5285,7 +6213,13 @@ export class WorkspaceModule implements Module {
|
|
|
5285
6213
|
const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
|
|
5286
6214
|
const rootsAccepted: string[] = [];
|
|
5287
6215
|
if (input.acceptRoot && input.path) {
|
|
5288
|
-
|
|
6216
|
+
const named = input.path.split('/')[0]!;
|
|
6217
|
+
return {
|
|
6218
|
+
success: false,
|
|
6219
|
+
isError: true,
|
|
6220
|
+
error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
|
|
6221
|
+
+ `${this.mounts.has(named) ? `Give "mount" (${JSON.stringify(named)}) with acceptRoot to take that mount's root` : 'Give "mount", naming the mount whose root to take, with acceptRoot'}, or leave acceptRoot out to sync the path.`),
|
|
6222
|
+
};
|
|
5289
6223
|
}
|
|
5290
6224
|
|
|
5291
6225
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
@@ -5296,13 +6230,13 @@ export class WorkspaceModule implements Module {
|
|
|
5296
6230
|
// is refused; beneath a directory, the walk passes over what the include
|
|
5297
6231
|
// doesn't cover.
|
|
5298
6232
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5299
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
6233
|
+
return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5300
6234
|
}
|
|
5301
6235
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
5302
6236
|
} else if (input.mount) {
|
|
5303
6237
|
const m = this.mounts.get(input.mount);
|
|
5304
6238
|
if (!m) {
|
|
5305
|
-
return { success: false, error:
|
|
6239
|
+
return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5306
6240
|
}
|
|
5307
6241
|
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
5308
6242
|
} else {
|
|
@@ -5316,6 +6250,7 @@ export class WorkspaceModule implements Module {
|
|
|
5316
6250
|
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
5317
6251
|
continue;
|
|
5318
6252
|
}
|
|
6253
|
+
let namedDirectory = false;
|
|
5319
6254
|
const pass = await this.withMount(mount, async () => {
|
|
5320
6255
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
|
|
5321
6256
|
if (!relativePath) {
|
|
@@ -5330,6 +6265,7 @@ export class WorkspaceModule implements Module {
|
|
|
5330
6265
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
5331
6266
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
5332
6267
|
this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
6268
|
+
namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
|
|
5333
6269
|
// named: ignore patterns covering the path asked for don't apply
|
|
5334
6270
|
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
5335
6271
|
const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
@@ -5388,7 +6324,7 @@ export class WorkspaceModule implements Module {
|
|
|
5388
6324
|
// without a path passes over it, so nothing else keeps it current
|
|
5389
6325
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
5390
6326
|
// synced at all, and the note says so.
|
|
5391
|
-
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
6327
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
|
|
5392
6328
|
if (passedOver !== undefined) {
|
|
5393
6329
|
allIgnored.push({
|
|
5394
6330
|
mount: name,
|
|
@@ -5402,10 +6338,17 @@ export class WorkspaceModule implements Module {
|
|
|
5402
6338
|
}
|
|
5403
6339
|
}
|
|
5404
6340
|
|
|
6341
|
+
const named = input.path !== undefined && input.path !== '' ? targets[0]! : null;
|
|
6342
|
+
const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
|
|
6343
|
+
&& allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
|
|
6344
|
+
&& allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
|
|
6345
|
+
&& !this.holdsAnythingAt(named.mount, named.relativePath)
|
|
6346
|
+
&& await this.absentOnDisk(named.mount, named.relativePath);
|
|
5405
6347
|
return {
|
|
5406
6348
|
success: true,
|
|
5407
6349
|
data: {
|
|
5408
6350
|
results: allResults,
|
|
6351
|
+
...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
|
|
5409
6352
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
5410
6353
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
5411
6354
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
@@ -5470,9 +6413,9 @@ export class WorkspaceModule implements Module {
|
|
|
5470
6413
|
* on disk (a shell command's files, a deletion) is in the workspace when the
|
|
5471
6414
|
* agent next looks. On a mount over its walkCap, that holds for the first
|
|
5472
6415
|
* walkCap entries in the walk's order, which every batch's scan takes; files
|
|
5473
|
-
* past them come in through syncs
|
|
5474
|
-
*
|
|
5475
|
-
* outlasts the deadline stops holding the round: the miss is recorded on
|
|
6416
|
+
* past them come in through syncs, the listings that reach them, and a read
|
|
6417
|
+
* of the path, which takes disk's change in first as a listing does. A scan
|
|
6418
|
+
* that outlasts the deadline stops holding the round: the miss is recorded on
|
|
5476
6419
|
* the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
|
|
5477
6420
|
* event, and the scan continues, deciding on the branch selected when it
|
|
5478
6421
|
* applies.
|
|
@@ -5631,13 +6574,14 @@ export class WorkspaceModule implements Module {
|
|
|
5631
6574
|
|
|
5632
6575
|
/**
|
|
5633
6576
|
* A text grep searches (textsFromStore, textsFromDisk): its mount-prefixed
|
|
5634
|
-
* file, which copy it is,
|
|
6577
|
+
* file, which copy it is, on a 'chronicle' mount the path's state, and its
|
|
6578
|
+
* bytes, whose lines grep matches as read shows them (fileText).
|
|
5635
6579
|
*/
|
|
5636
6580
|
interface GrepText {
|
|
5637
6581
|
file: string;
|
|
5638
6582
|
state?: string;
|
|
5639
6583
|
version: 'workspace' | 'conflicting-disk' | 'recorded-disk' | 'disk';
|
|
5640
|
-
|
|
6584
|
+
bytes: Buffer;
|
|
5641
6585
|
}
|
|
5642
6586
|
|
|
5643
6587
|
/** A file, or a copy of one, grep doesn't search, with why. */
|
|
@@ -5678,13 +6622,13 @@ export async function inOrder<T, R>(items: readonly T[], width: number, work: (i
|
|
|
5678
6622
|
}
|
|
5679
6623
|
|
|
5680
6624
|
/** read_image's answer for an image, wherever it was read from. */
|
|
5681
|
-
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
|
|
6625
|
+
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult {
|
|
5682
6626
|
return {
|
|
5683
6627
|
success: true,
|
|
5684
6628
|
data: [
|
|
5685
6629
|
{
|
|
5686
6630
|
type: 'text',
|
|
5687
|
-
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
6631
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
|
|
5688
6632
|
},
|
|
5689
6633
|
{
|
|
5690
6634
|
type: 'image',
|
|
@@ -5736,16 +6680,36 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
|
|
|
5736
6680
|
return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
|
|
5737
6681
|
}
|
|
5738
6682
|
|
|
6683
|
+
/**
|
|
6684
|
+
* Why a write, edit, deletion or peer module's writeBinary to this mount is
|
|
6685
|
+
* refused, or null where it may go ahead: a read-only mount, and a mount
|
|
6686
|
+
* configured direct that runs as 'chronicle' this session (chooseKinds),
|
|
6687
|
+
* since the store holds changes there that disk lacks. A write there would
|
|
6688
|
+
* stay in the store, beside an agent told the mount is its checkout, and
|
|
6689
|
+
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6690
|
+
* the next start once none is held runs it direct.
|
|
6691
|
+
*/
|
|
6692
|
+
function writeRefusal(mount: MountState, writable: readonly string[] = []): string | null {
|
|
6693
|
+
const name = mount.config.name;
|
|
6694
|
+
if (mount.config.mode === 'read-only') {
|
|
6695
|
+
return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
|
|
6696
|
+
+ (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
|
|
6697
|
+
}
|
|
6698
|
+
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6699
|
+
return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6700
|
+
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
6701
|
+
+ `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct.`;
|
|
6702
|
+
}
|
|
6703
|
+
return null;
|
|
6704
|
+
}
|
|
6705
|
+
|
|
5739
6706
|
/**
|
|
5740
6707
|
* What a direct mount's config sets that would never act there, each with
|
|
5741
|
-
* the way out (MountConfig.backend): a direct mount
|
|
5742
|
-
*
|
|
6708
|
+
* the way out (MountConfig.backend): a direct mount has no watcher to tune,
|
|
6709
|
+
* no change event to wake on and no push to make. Either mode is its own.
|
|
5743
6710
|
*/
|
|
5744
6711
|
function directRefusals(mount: MountConfig): string[] {
|
|
5745
6712
|
const refused: string[] = [];
|
|
5746
|
-
if (mount.mode !== 'read-only') {
|
|
5747
|
-
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)}`);
|
|
5748
|
-
}
|
|
5749
6713
|
if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
|
|
5750
6714
|
for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
|
|
5751
6715
|
if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);
|
|
@@ -5756,6 +6720,261 @@ function directRefusals(mount: MountConfig): string[] {
|
|
|
5756
6720
|
return refused;
|
|
5757
6721
|
}
|
|
5758
6722
|
|
|
6723
|
+
/**
|
|
6724
|
+
* An agent's latest direct write on a mount, P: the store's branch, and its
|
|
6725
|
+
* head when the write landed (`after`); and the id of the message that last
|
|
6726
|
+
* told the agent its history no longer holds P, N, or null.
|
|
6727
|
+
*/
|
|
6728
|
+
interface DirectHead {
|
|
6729
|
+
branchId: string;
|
|
6730
|
+
after: number;
|
|
6731
|
+
notice: string | null;
|
|
6732
|
+
}
|
|
6733
|
+
|
|
6734
|
+
/** A store position: a branch and its head. */
|
|
6735
|
+
type DirectPosition = Pick<DirectHead, 'branchId' | 'after'>;
|
|
6736
|
+
|
|
6737
|
+
/** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
|
|
6738
|
+
const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
|
|
6739
|
+
|
|
6740
|
+
/** One mount's positions at one root (resolved, as DiskAgreement keeps a root), by agent, with N only where it's set. */
|
|
6741
|
+
interface DirectHeadsEntry {
|
|
6742
|
+
mount: string;
|
|
6743
|
+
root: string;
|
|
6744
|
+
agents: Record<string, { branchId: string; after: number; notice?: string }>;
|
|
6745
|
+
}
|
|
6746
|
+
|
|
6747
|
+
/** directHeads as saved: an entry per mount and root its agents wrote direct under. */
|
|
6748
|
+
type DirectHeadsSnapshot = DirectHeadsEntry[];
|
|
6749
|
+
|
|
6750
|
+
/**
|
|
6751
|
+
* A saved record as saveDirectHeads writes it, or a throw naming what isn't:
|
|
6752
|
+
* a list of entries, each a mount's name, root and agents, and each agent's
|
|
6753
|
+
* branch, head and, where set, notice. Checked whole, carried entries
|
|
6754
|
+
* included, so a record of another shape is refused at load rather than
|
|
6755
|
+
* carried into the next save.
|
|
6756
|
+
*/
|
|
6757
|
+
function checkedHeads(value: unknown): DirectHeadsSnapshot {
|
|
6758
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6759
|
+
if (!Array.isArray(value)) throw new Error("the record isn't of the shape saved");
|
|
6760
|
+
for (const saved of value as unknown[]) {
|
|
6761
|
+
if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
|
|
6762
|
+
throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
|
|
6763
|
+
}
|
|
6764
|
+
for (const [agent, head] of Object.entries(saved.agents)) {
|
|
6765
|
+
if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
|
|
6766
|
+
|| (head.notice !== undefined && typeof head.notice !== 'string')) {
|
|
6767
|
+
throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
|
|
6768
|
+
}
|
|
6769
|
+
}
|
|
6770
|
+
}
|
|
6771
|
+
return value as DirectHeadsSnapshot;
|
|
6772
|
+
}
|
|
6773
|
+
|
|
6774
|
+
/** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
|
|
6775
|
+
function canonicalHeads(snapshot: DirectHeadsSnapshot): string {
|
|
6776
|
+
return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
|
|
6777
|
+
const { branchId, after, notice } = agents[agent]!;
|
|
6778
|
+
return [agent, branchId, after, notice ?? null];
|
|
6779
|
+
})]).sort((a, b) => {
|
|
6780
|
+
const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
|
|
6781
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
6782
|
+
}));
|
|
6783
|
+
}
|
|
6784
|
+
|
|
6785
|
+
/**
|
|
6786
|
+
* How many times a direct edit reads and plans a file another writer keeps
|
|
6787
|
+
* changing: enough to land on top of a writer that changes it now and then,
|
|
6788
|
+
* such as a formatter or a build, and bounded for one that never stops.
|
|
6789
|
+
*/
|
|
6790
|
+
const DIRECT_EDIT_ATTEMPTS = 3;
|
|
6791
|
+
|
|
6792
|
+
/** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
|
|
6793
|
+
function directReason(error: unknown): string {
|
|
6794
|
+
if (error instanceof FileChanged) return 'it changed on disk while this call was being applied';
|
|
6795
|
+
// A deletion's root check (unlinkContained) has a store-backed mount's
|
|
6796
|
+
// words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
|
|
6797
|
+
if (error instanceof Error && error.message === ROOT_REPLACED) return ROOT_MOVED;
|
|
6798
|
+
return error instanceof Error ? error.message : String(error);
|
|
6799
|
+
}
|
|
6800
|
+
|
|
6801
|
+
function directWayOn(error: unknown): string | null {
|
|
6802
|
+
return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
|
|
6803
|
+
}
|
|
6804
|
+
|
|
6805
|
+
function directDidNothing(tool: string, path: string, error: unknown): string {
|
|
6806
|
+
const on = directWayOn(error);
|
|
6807
|
+
return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
|
|
6808
|
+
}
|
|
6809
|
+
|
|
6810
|
+
/** "a", "a and b", or "a, b and c". */
|
|
6811
|
+
function listed(items: readonly string[]): string {
|
|
6812
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
6813
|
+
}
|
|
6814
|
+
|
|
6815
|
+
/**
|
|
6816
|
+
* The note for a direct write that gave a hard-linked path a file of its
|
|
6817
|
+
* own: the other links keep the old text. Null for a file with none.
|
|
6818
|
+
*/
|
|
6819
|
+
function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): string | null {
|
|
6820
|
+
const n = staged.otherLinks;
|
|
6821
|
+
if (n <= 0) return null;
|
|
6822
|
+
return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
|
|
6823
|
+
+ `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
|
|
6824
|
+
}
|
|
6825
|
+
|
|
6826
|
+
/**
|
|
6827
|
+
* A refusal of openContainedFile's as a direct tool gives it: its message,
|
|
6828
|
+
* and for one that names only the path, its errno too, as a direct read's
|
|
6829
|
+
* answer gives it (bytesOnDisk).
|
|
6830
|
+
*/
|
|
6831
|
+
function containedRefusal(err: unknown, path: string): string {
|
|
6832
|
+
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
6833
|
+
return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
|
|
6834
|
+
}
|
|
6835
|
+
|
|
6836
|
+
function deletedOnDisk(path: string): string {
|
|
6837
|
+
return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
|
|
6838
|
+
}
|
|
6839
|
+
|
|
6840
|
+
function uncheckedFile(shown: string, note: string | undefined): string {
|
|
6841
|
+
if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6842
|
+
const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
|
|
6843
|
+
: note === ROOT_REPLACED ? null
|
|
6844
|
+
: note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
|
|
6845
|
+
: note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
|
|
6846
|
+
: note === NOT_REGULAR ? onlyTheHost('put a regular file there')
|
|
6847
|
+
: note === CHANGED ? 'Calling again checks it afresh.'
|
|
6848
|
+
: 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
|
|
6849
|
+
return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
|
|
6850
|
+
}
|
|
6851
|
+
|
|
6852
|
+
function namesRoot(path: string, mount: string): string {
|
|
6853
|
+
return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
|
|
6854
|
+
}
|
|
6855
|
+
|
|
6856
|
+
function tooLargeToWrite(path: string, bytes: number, maxSize: number): string {
|
|
6857
|
+
return pastSizeLimit(`${JSON.stringify(path)} would be ${bytes.toLocaleString('en-US')} bytes`, maxSize, "the workspace couldn't keep it", 'Content within the limit is written.');
|
|
6858
|
+
}
|
|
6859
|
+
|
|
6860
|
+
function pastSizeLimit(what: string, maxSize: number, outcome: string, within: string | null): string {
|
|
6861
|
+
return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
|
|
6862
|
+
+ "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
|
|
6863
|
+
}
|
|
6864
|
+
|
|
6865
|
+
function sentence(text: string): string {
|
|
6866
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
6867
|
+
}
|
|
6868
|
+
|
|
6869
|
+
function unreadable(shown: string, errno: string | undefined): string {
|
|
6870
|
+
return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
|
|
6871
|
+
}
|
|
6872
|
+
|
|
6873
|
+
function readRefusal(err: WorkspaceReadError, path: string): string {
|
|
6874
|
+
const shown = JSON.stringify(path);
|
|
6875
|
+
if (err instanceof RootUnavailable) {
|
|
6876
|
+
if (err.refusal) return err.refusal.message;
|
|
6877
|
+
const mount = JSON.stringify(err.mount);
|
|
6878
|
+
if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
|
|
6879
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
|
|
6880
|
+
return `the directory of mount ${mount} isn't on disk, so nothing in it can be read. ${onlyTheHost("put the mount's directory back at its path")}`;
|
|
6881
|
+
}
|
|
6882
|
+
if (err.errno !== undefined) {
|
|
6883
|
+
return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
|
|
6884
|
+
+ errnoWayOn(err.errno, ROOT_RECHECK, true);
|
|
6885
|
+
}
|
|
6886
|
+
return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
|
|
6887
|
+
}
|
|
6888
|
+
return `${shown} can't be read: ${sentence(err.reason)}`;
|
|
6889
|
+
}
|
|
6890
|
+
if (err.stage === 'readlink') return sentence(err.message);
|
|
6891
|
+
switch (err.code) {
|
|
6892
|
+
case 'directory':
|
|
6893
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6894
|
+
case 'symlink':
|
|
6895
|
+
return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
|
|
6896
|
+
+ 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
|
|
6897
|
+
case 'escape':
|
|
6898
|
+
return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
|
|
6899
|
+
+ 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
|
|
6900
|
+
case 'changed':
|
|
6901
|
+
return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
|
|
6902
|
+
case 'not_found':
|
|
6903
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
6904
|
+
if (err instanceof NotRegularFile) {
|
|
6905
|
+
return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
|
|
6906
|
+
}
|
|
6907
|
+
if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
|
|
6908
|
+
return unreadable(shown, err.errno);
|
|
6909
|
+
default:
|
|
6910
|
+
return sentence(err.message);
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
6913
|
+
|
|
6914
|
+
type LostContents = {
|
|
6915
|
+
path: string;
|
|
6916
|
+
store: Exclude<StoredBytes, { bytes: Buffer }>;
|
|
6917
|
+
disk: 'other' | 'none' | { unreadable: string };
|
|
6918
|
+
writable: boolean;
|
|
6919
|
+
reopen: boolean;
|
|
6920
|
+
};
|
|
6921
|
+
|
|
6922
|
+
function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string {
|
|
6923
|
+
return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
|
|
6924
|
+
}
|
|
6925
|
+
|
|
6926
|
+
function inTheStore(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
|
|
6927
|
+
return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
|
|
6928
|
+
}
|
|
6929
|
+
|
|
6930
|
+
function lostInStore(lost: Pick<LostContents, 'store'>): string {
|
|
6931
|
+
return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
|
|
6932
|
+
}
|
|
6933
|
+
|
|
6934
|
+
function lostOnDisk(lost: LostContents): string {
|
|
6935
|
+
return lost.disk === 'other' ? "disk's copy holds other contents"
|
|
6936
|
+
: lost.disk === 'none' ? 'disk has no copy'
|
|
6937
|
+
: `disk's copy can't be read (${lost.disk.unreadable})`;
|
|
6938
|
+
}
|
|
6939
|
+
|
|
6940
|
+
function lostText(lost: LostContents, the: 'The' | 'the' = 'The'): string {
|
|
6941
|
+
return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
|
|
6942
|
+
}
|
|
6943
|
+
|
|
6944
|
+
function nothingToRead(lost: LostContents): string {
|
|
6945
|
+
return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
|
|
6946
|
+
}
|
|
6947
|
+
|
|
6948
|
+
function lostRemedy(lost: LostContents): string {
|
|
6949
|
+
const after = remedyOfLost(lost);
|
|
6950
|
+
if (!lost.reopen) return after;
|
|
6951
|
+
return "Nothing you do clears this until the workspace's store is reopened, and calling again won't change that; whoever runs this host can reopen it with a restart. "
|
|
6952
|
+
+ `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
|
|
6953
|
+
}
|
|
6954
|
+
|
|
6955
|
+
function remedyOfLost(lost: LostContents): string {
|
|
6956
|
+
const rewrite = lost.writable && 'missing' in lost.store;
|
|
6957
|
+
if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
|
|
6958
|
+
if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
|
|
6959
|
+
return lost.disk === 'none'
|
|
6960
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy."
|
|
6961
|
+
: "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's.";
|
|
6962
|
+
}
|
|
6963
|
+
|
|
6964
|
+
/**
|
|
6965
|
+
* The notice that writes an agent made direct remain on disk from
|
|
6966
|
+
* turns the agent's history no longer holds. `git status` stands beside
|
|
6967
|
+
* `git diff` because a departed turn's new file is untracked, which `git
|
|
6968
|
+
* diff` doesn't show; and the warning says "unstaged", since `git checkout
|
|
6969
|
+
* -- <file>` puts back a file's staged text, so a staged change survives it.
|
|
6970
|
+
*/
|
|
6971
|
+
function headMoveText(mounts: readonly string[]): string {
|
|
6972
|
+
const one = mounts.length === 1;
|
|
6973
|
+
return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6974
|
+
+ `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
|
|
6975
|
+
+ 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
|
|
6976
|
+
}
|
|
6977
|
+
|
|
5759
6978
|
/** A change commitToolChanges makes to one path's tree entry. */
|
|
5760
6979
|
type ToolChange = { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string };
|
|
5761
6980
|
|
|
@@ -5770,6 +6989,63 @@ interface EditTarget {
|
|
|
5770
6989
|
specs: EditSpec[];
|
|
5771
6990
|
}
|
|
5772
6991
|
|
|
6992
|
+
/**
|
|
6993
|
+
* One file's edits planned against its bytes, as both kinds of mount plan
|
|
6994
|
+
* them: its text decoded, every edit matched in it (edit-plan.ts), and the
|
|
6995
|
+
* result held to the mount's size limit. Each problem found goes into
|
|
6996
|
+
* `problems`, and then, as for a file named only by edits whose own input
|
|
6997
|
+
* was refused, there's no plan (null).
|
|
6998
|
+
*/
|
|
6999
|
+
function planTargetEdits(
|
|
7000
|
+
tool: EditTool,
|
|
7001
|
+
target: EditTarget,
|
|
7002
|
+
bytes: Buffer,
|
|
7003
|
+
hints: ReturnType<typeof hintBudget>,
|
|
7004
|
+
maxSize: number,
|
|
7005
|
+
counts: ReturnType<typeof countBudget>,
|
|
7006
|
+
problems: EditProblem[],
|
|
7007
|
+
): FilePlan | null {
|
|
7008
|
+
// Decoding would turn each invalid sequence into U+FFFD, and writing
|
|
7009
|
+
// the text back would change bytes the edits never named.
|
|
7010
|
+
const file = decodeForEdit(bytes);
|
|
7011
|
+
if (!file) {
|
|
7012
|
+
problems.push({
|
|
7013
|
+
kind: 'file',
|
|
7014
|
+
edits: target.edits,
|
|
7015
|
+
path: target.path,
|
|
7016
|
+
text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
|
|
7017
|
+
+ '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.',
|
|
7018
|
+
whole: false,
|
|
7019
|
+
});
|
|
7020
|
+
return null;
|
|
7021
|
+
}
|
|
7022
|
+
if (!target.specs.length) return null;
|
|
7023
|
+
const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
|
|
7024
|
+
if ('problems' in planned) {
|
|
7025
|
+
// One at a time: a call of many edits can have more problems
|
|
7026
|
+
// than a spread passes as arguments.
|
|
7027
|
+
for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
|
|
7028
|
+
return null;
|
|
7029
|
+
}
|
|
7030
|
+
// The planner refuses a file its counts take past the limit before
|
|
7031
|
+
// building it, from the least its bytes can be; one it built past
|
|
7032
|
+
// the limit, as its CRs or multi-byte characters can take it, is
|
|
7033
|
+
// refused here.
|
|
7034
|
+
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
7035
|
+
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
7036
|
+
problems.push({
|
|
7037
|
+
kind: 'file',
|
|
7038
|
+
edits: target.edits,
|
|
7039
|
+
path: target.path,
|
|
7040
|
+
text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize,
|
|
7041
|
+
"the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
|
|
7042
|
+
whole: false,
|
|
7043
|
+
});
|
|
7044
|
+
return null;
|
|
7045
|
+
}
|
|
7046
|
+
return planned.plan;
|
|
7047
|
+
}
|
|
7048
|
+
|
|
5773
7049
|
/**
|
|
5774
7050
|
* Whether a tree entry is what a change leaves: for a set, an entry with its
|
|
5775
7051
|
* blob, size and mode; for a removal, none.
|
|
@@ -5805,17 +7081,36 @@ function changesLanded(
|
|
|
5805
7081
|
* change is stored isn't known. Its message says so after both errors.
|
|
5806
7082
|
*/
|
|
5807
7083
|
class CommitUnknownError extends Error {
|
|
5808
|
-
readonly
|
|
5809
|
-
readonly readError: string;
|
|
5810
|
-
constructor(setError: unknown, readError: unknown) {
|
|
7084
|
+
constructor(readonly setCause: unknown, readonly readCause: unknown) {
|
|
5811
7085
|
const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
5812
|
-
super(`${text(
|
|
7086
|
+
super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
|
|
5813
7087
|
this.name = 'CommitUnknownError';
|
|
5814
|
-
this.setError = text(setError);
|
|
5815
|
-
this.readError = text(readError);
|
|
5816
7088
|
}
|
|
5817
7089
|
}
|
|
5818
7090
|
|
|
7091
|
+
function storeWords(error: unknown): string {
|
|
7092
|
+
const code = errnoCode(error);
|
|
7093
|
+
return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
|
|
7094
|
+
}
|
|
7095
|
+
|
|
7096
|
+
class NotStored extends Error {
|
|
7097
|
+
constructor(readonly thrown: unknown) {
|
|
7098
|
+
super('stopped before the change was stored');
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
|
|
7102
|
+
class AlreadyHeld extends Error {
|
|
7103
|
+
constructor(readonly thrown: unknown) {
|
|
7104
|
+
super(thrown instanceof Error ? thrown.message : String(thrown));
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
7107
|
+
|
|
7108
|
+
function notStored(error: unknown): unknown {
|
|
7109
|
+
return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
|
|
7110
|
+
? error : new NotStored(error);
|
|
7111
|
+
}
|
|
7112
|
+
|
|
7113
|
+
|
|
5819
7114
|
/**
|
|
5820
7115
|
* A store's set that threw after recording the change (changesLanded): the
|
|
5821
7116
|
* entries are set and their intents follow, and the store's error goes on in
|
|
@@ -5829,6 +7124,14 @@ class StoredThenFailed extends Error {
|
|
|
5829
7124
|
}
|
|
5830
7125
|
}
|
|
5831
7126
|
|
|
7127
|
+
const ONCE_REOPENED = ', once the store is reopened';
|
|
7128
|
+
|
|
7129
|
+
const DISK_ORDER: DiskState[] = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
|
|
7130
|
+
|
|
7131
|
+
function furthestFromDisk(values: DiskState[]): DiskState {
|
|
7132
|
+
return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
|
|
7133
|
+
}
|
|
7134
|
+
|
|
5832
7135
|
/**
|
|
5833
7136
|
* The answer to a write, edit or delete that the workspace stored but
|
|
5834
7137
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -5845,6 +7148,7 @@ function storedButUnfinished(
|
|
|
5845
7148
|
path: string,
|
|
5846
7149
|
outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
5847
7150
|
note: string | null = null,
|
|
7151
|
+
reopen = false,
|
|
5848
7152
|
): ToolResult {
|
|
5849
7153
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
5850
7154
|
const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
|
|
@@ -5854,21 +7158,55 @@ function storedButUnfinished(
|
|
|
5854
7158
|
: 'storeFailed' in outcome
|
|
5855
7159
|
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
5856
7160
|
: tool === 'delete'
|
|
5857
|
-
?
|
|
5858
|
-
:
|
|
5859
|
-
: ` The rest of the call failed: ${outcome.threw}
|
|
5860
|
-
return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
|
|
7161
|
+
? ` Its push to disk didn't run, so disk keeps the file until a materialize of the path with applyDeletions deletes it${reopen ? ONCE_REOPENED : ''}.`
|
|
7162
|
+
: ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
|
|
7163
|
+
: ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
|
|
7164
|
+
return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
|
|
5861
7165
|
}
|
|
5862
7166
|
|
|
5863
7167
|
/**
|
|
5864
|
-
*
|
|
5865
|
-
* last
|
|
5866
|
-
*
|
|
7168
|
+
* What a read says about the copy it shows, when that isn't simply the one the
|
|
7169
|
+
* workspace last agreed on with disk: disk's newer text, taken in first; the
|
|
7170
|
+
* workspace's copy, because disk's couldn't be checked or disk has none that
|
|
7171
|
+
* anything accounts for; or a draft in conflict with disk's change. A file
|
|
7172
|
+
* the workspace didn't hold has no older copy, so taking it in needs no word.
|
|
7173
|
+
* A conflict's note names the ways to settle it that work on the path's mount.
|
|
5867
7174
|
*/
|
|
5868
|
-
function
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
7175
|
+
function whichCopyRead(report: PathReport | undefined, tookChange: boolean, readOnly: boolean): string[] {
|
|
7176
|
+
if (tookChange) {
|
|
7177
|
+
return ['Disk\'s copy had changed since the workspace last agreed with it, so it was taken in first, and this read shows that text.'];
|
|
7178
|
+
}
|
|
7179
|
+
if (report?.state === 'unverified') {
|
|
7180
|
+
return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
|
|
7181
|
+
}
|
|
7182
|
+
if (report?.state === 'disk-missing-provenance-unknown') {
|
|
7183
|
+
return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
|
|
7184
|
+
}
|
|
7185
|
+
if (report?.state === 'conflict') {
|
|
7186
|
+
const kind = report.conflict?.kind ?? 'both-changed';
|
|
7187
|
+
// A sync takes disk's state as it is now, which a recorded conflict's kind
|
|
7188
|
+
// doesn't follow: where disk has no copy, the sync takes its deletion, and
|
|
7189
|
+
// the workspace's copy goes with it. Where disk has one, the sync keeps it
|
|
7190
|
+
// and gives up the workspace's, taking disk's in only where the workspace
|
|
7191
|
+
// can hold it (not binary or too large). A read-only mount can hold a
|
|
7192
|
+
// copy disk doesn't, such as one a branch switch selected, but a
|
|
7193
|
+
// materialize, forced or not, writes nothing to it.
|
|
7194
|
+
const settle = settleConflict(report.diskNow, readOnly);
|
|
7195
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0]!.toUpperCase()}${settle.slice(1)}`];
|
|
7196
|
+
}
|
|
7197
|
+
return [];
|
|
7198
|
+
}
|
|
7199
|
+
|
|
7200
|
+
function settleConflict(diskNow: PathReport['diskNow'], readOnly: boolean): string {
|
|
7201
|
+
const sync = diskNow === 'absent'
|
|
7202
|
+
? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
7203
|
+
: 'sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
7204
|
+
const force = readOnly
|
|
7205
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
7206
|
+
: diskNow === 'absent'
|
|
7207
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
7208
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
7209
|
+
return `${sync}${force}`;
|
|
5872
7210
|
}
|
|
5873
7211
|
|
|
5874
7212
|
/**
|
|
@@ -5939,6 +7277,87 @@ function inputProblem(
|
|
|
5939
7277
|
return null;
|
|
5940
7278
|
}
|
|
5941
7279
|
|
|
7280
|
+
/** How many files a grep note names before it gives the count of the rest. */
|
|
7281
|
+
const NOTE_FILES_MAX = 20;
|
|
7282
|
+
|
|
7283
|
+
/**
|
|
7284
|
+
* The files a grep note names, in the order grep searched them, as its
|
|
7285
|
+
* results are: at most NOTE_FILES_MAX of them, then how many more and how to
|
|
7286
|
+
* see them. Each is named by its mount-prefixed path, with what `detail` says
|
|
7287
|
+
* of it.
|
|
7288
|
+
*/
|
|
7289
|
+
function namedFiles<T>(files: Map<string, T>, detail: (value: T) => string): string {
|
|
7290
|
+
const names = [...files.keys()];
|
|
7291
|
+
const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name)!)}`);
|
|
7292
|
+
const rest = names.length - shown.length;
|
|
7293
|
+
return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
|
|
7294
|
+
}
|
|
7295
|
+
|
|
7296
|
+
/** What a note says of a conflict's disk version, which read doesn't show. */
|
|
7297
|
+
const ofVersion = (version: GrepText['version']): string =>
|
|
7298
|
+
version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
|
|
7299
|
+
|
|
7300
|
+
/**
|
|
7301
|
+
* grep's note for a pattern that names a character its lines leave out, where
|
|
7302
|
+
* a file it searched has one: it says how many files searched have it and
|
|
7303
|
+
* names them, so a search for a CR or a BOM still finds the files that have
|
|
7304
|
+
* them. The CR note gives each file's ends, CRLF or mixed, and either note
|
|
7305
|
+
* names a conflict's disk version as that version. The note says a
|
|
7306
|
+
* character read shows it in the workspace's copy, since grep also searches a
|
|
7307
|
+
* conflict's disk version, which read doesn't show. A direct mount keeps no
|
|
7308
|
+
* workspace copy, and its read shows disk's file, so on a host with one
|
|
7309
|
+
* (`direct`) the note names that too, as the tools' descriptions mention
|
|
7310
|
+
* direct mounts only on such a host.
|
|
7311
|
+
*/
|
|
7312
|
+
function leftOutNote(
|
|
7313
|
+
pattern: string,
|
|
7314
|
+
crlfFiles: Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>,
|
|
7315
|
+
bomFiles: Map<string, GrepText['version']>,
|
|
7316
|
+
direct: boolean,
|
|
7317
|
+
): { note?: string } {
|
|
7318
|
+
if (crlfFiles.size === 0 && bomFiles.size === 0) return {};
|
|
7319
|
+
const named = namedLeftOut(pattern);
|
|
7320
|
+
const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
|
|
7321
|
+
const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
|
|
7322
|
+
const notes = [
|
|
7323
|
+
named.cr && crlfFiles.size > 0
|
|
7324
|
+
? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
|
|
7325
|
+
+ `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
|
|
7326
|
+
+ `which leaves out a CRLF end's CR unless the line's own text ends in a CR. read with offsetChars shows every CR in the workspace's copy${onDisk}.`
|
|
7327
|
+
: null,
|
|
7328
|
+
named.bom && bomFiles.size > 0
|
|
7329
|
+
? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
|
|
7330
|
+
+ `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
|
|
7331
|
+
+ `and a BOM isn't part of the first line. read with offsetChars shows any BOM ${direct ? `in the workspace's copy${onDisk}` : 'the workspace\'s copy has'}.`
|
|
7332
|
+
: null,
|
|
7333
|
+
].filter((n) => n !== null);
|
|
7334
|
+
return notes.length ? { note: notes.join(' ') } : {};
|
|
7335
|
+
}
|
|
7336
|
+
|
|
7337
|
+
/**
|
|
7338
|
+
* Which of the characters grep's lines leave out a pattern names: a CR, as a
|
|
7339
|
+
* literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
|
|
7340
|
+
* before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
|
|
7341
|
+
* before a literal one. An escape counts only where its backslash isn't itself
|
|
7342
|
+
* escaped. A pattern compiled without flags can match a CR other ways too,
|
|
7343
|
+
* such as `\15` in a pattern of fewer than 15 groups or a class range that
|
|
7344
|
+
* spans U+000D, and those go unnoticed.
|
|
7345
|
+
*/
|
|
7346
|
+
function namedLeftOut(pattern: string): { cr: boolean; bom: boolean } {
|
|
7347
|
+
const named = { cr: false, bom: false };
|
|
7348
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
7349
|
+
if (pattern[i] === '\r') named.cr = true;
|
|
7350
|
+
else if (pattern[i] === '\uFEFF') named.bom = true;
|
|
7351
|
+
else if (pattern[i] === '\\') {
|
|
7352
|
+
const next = pattern.slice(i + 1, i + 6);
|
|
7353
|
+
if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next)) named.cr = true;
|
|
7354
|
+
else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next)) named.bom = true;
|
|
7355
|
+
i++;
|
|
7356
|
+
}
|
|
7357
|
+
}
|
|
7358
|
+
return named;
|
|
7359
|
+
}
|
|
7360
|
+
|
|
5942
7361
|
/**
|
|
5943
7362
|
* A glob's reading in the mount its first segment names, in place of the
|
|
5944
7363
|
* pattern as written there. Every path the tools take and return is
|
|
@@ -5987,13 +7406,13 @@ function globToRegex(pattern: string): RegExp {
|
|
|
5987
7406
|
if (pattern[j] === '{') depth++;
|
|
5988
7407
|
else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
|
|
5989
7408
|
}
|
|
5990
|
-
if (closeIdx < 0) throw new
|
|
7409
|
+
if (closeIdx < 0) throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
|
|
5991
7410
|
const group = pattern.slice(i + 1, closeIdx);
|
|
5992
7411
|
if (group.includes('{')) {
|
|
5993
|
-
throw new
|
|
7412
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
|
|
5994
7413
|
}
|
|
5995
7414
|
if (!group.includes(',')) {
|
|
5996
|
-
throw new
|
|
7415
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
|
|
5997
7416
|
}
|
|
5998
7417
|
// A `/` right after the group goes into each alternative, as `{a,b}/x`
|
|
5999
7418
|
// and `{a/,b/}x` name the same paths, so an alternative's own `**`
|