@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
|
@@ -9,15 +9,24 @@ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises
|
|
|
9
9
|
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
10
10
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
11
11
|
import { MountWatcher } from './watcher.js';
|
|
12
|
-
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
13
|
-
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
12
|
+
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
13
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
14
14
|
import { BranchIntents } from './branch-intent.js';
|
|
15
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE,
|
|
15
|
+
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, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes } from './observe.js';
|
|
16
16
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
17
17
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
18
18
|
import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
19
19
|
import { cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
|
|
20
|
+
import { fileText } from './text.js';
|
|
21
|
+
import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
|
|
22
|
+
import { grepText, textAnswer } from './result-text.js';
|
|
20
23
|
import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
|
|
24
|
+
import { EffectFailed, unlinkContained } from './effects.js';
|
|
25
|
+
import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
|
|
26
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
27
|
+
import { closestNames } from '../../closest-names.js';
|
|
28
|
+
import { RecordJournal } from '../../record-journal.js';
|
|
29
|
+
import { FileChanged, ROOT_MOVED, WriteRefused, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
|
|
21
30
|
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
22
31
|
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
23
32
|
/**
|
|
@@ -30,10 +39,15 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
|
30
39
|
const CATCH_UP_BOUND_MS = 30_000;
|
|
31
40
|
/** Why a call is refused once the module has stopped. */
|
|
32
41
|
const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
|
|
42
|
+
/** writeBinary's refusal when the store's branch is no longer the one its file is for. */
|
|
43
|
+
const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
33
44
|
/** The workspace's own deadline must end before the framework's hook bound. */
|
|
34
45
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
35
46
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
36
47
|
const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
48
|
+
const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
|
|
49
|
+
+ 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
|
|
50
|
+
+ 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
|
|
37
51
|
/**
|
|
38
52
|
* How many files a direct mount's grep reads, and its listings look at, at
|
|
39
53
|
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
@@ -44,16 +58,25 @@ const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage'
|
|
|
44
58
|
const DIRECT_READS_AT_ONCE = 16;
|
|
45
59
|
/** Why sync passes over a direct mount, as its result's `skipped` says. */
|
|
46
60
|
const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
/** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
|
|
62
|
+
const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
|
|
63
|
+
class WorkspaceImageReadError extends Refusal {
|
|
64
|
+
reason;
|
|
65
|
+
constructor(reason, wayOn) {
|
|
66
|
+
super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
|
|
67
|
+
this.reason = reason;
|
|
52
68
|
this.name = 'WorkspaceImageReadError';
|
|
53
69
|
}
|
|
54
70
|
}
|
|
71
|
+
const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
|
|
72
|
+
function truncatedImage(mountPrefixedPath) {
|
|
73
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
|
|
74
|
+
}
|
|
75
|
+
function emptyImage(mountPrefixedPath) {
|
|
76
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
|
|
77
|
+
}
|
|
55
78
|
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
56
|
-
class OutsideIncludeError extends
|
|
79
|
+
class OutsideIncludeError extends Refusal {
|
|
57
80
|
constructor(shownPath, mount) {
|
|
58
81
|
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
59
82
|
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
@@ -64,9 +87,17 @@ class OutsideIncludeError extends Error {
|
|
|
64
87
|
* mount it names. The message names the mount that holds it and the path to
|
|
65
88
|
* use there.
|
|
66
89
|
*/
|
|
67
|
-
class NestedMountError extends
|
|
68
|
-
constructor(path, mount, holder,
|
|
69
|
-
|
|
90
|
+
class NestedMountError extends Refusal {
|
|
91
|
+
constructor(path, mount, holder, how = 'lies in') {
|
|
92
|
+
const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
|
|
93
|
+
super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
class PathRefusal extends Refusal {
|
|
97
|
+
code;
|
|
98
|
+
constructor(code, message) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.code = code;
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
103
|
/**
|
|
@@ -88,6 +119,22 @@ export class WorkspaceReadError extends Error {
|
|
|
88
119
|
this.name = 'WorkspaceReadError';
|
|
89
120
|
}
|
|
90
121
|
}
|
|
122
|
+
class NotRegularFile extends WorkspaceReadError {
|
|
123
|
+
constructor(path, stage) {
|
|
124
|
+
super('not_found', `Not a regular file: ${path}`, stage);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
class RootUnavailable extends WorkspaceReadError {
|
|
128
|
+
mount;
|
|
129
|
+
reason;
|
|
130
|
+
refusal;
|
|
131
|
+
constructor(mount, reason, stage, errno, refusal) {
|
|
132
|
+
super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
|
|
133
|
+
this.mount = mount;
|
|
134
|
+
this.reason = reason;
|
|
135
|
+
this.refusal = refusal;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
91
138
|
/**
|
|
92
139
|
* Why a pass decided nothing, or null where it decided its paths. A pass gives
|
|
93
140
|
* up deciding, and reports no path, when the selected branch kept changing
|
|
@@ -102,23 +149,9 @@ function errnoOf(err) {
|
|
|
102
149
|
? err.code
|
|
103
150
|
: undefined;
|
|
104
151
|
}
|
|
105
|
-
/**
|
|
106
|
-
* What is at a mount root's path now, followed as the root is: for a
|
|
107
|
-
* directory, undefined; for anything else, ENOTDIR; and where the path
|
|
108
|
-
* can't be followed to anything, the errno its stat failed with, ENOENT
|
|
109
|
-
* when nothing is there.
|
|
110
|
-
*/
|
|
111
|
-
async function rootAbsence(root) {
|
|
112
|
-
try {
|
|
113
|
-
return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
116
|
-
return errnoOf(err);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
152
|
/** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
|
|
120
|
-
function imageTooLarge(maxSize, mountPrefixedPath) {
|
|
121
|
-
return new WorkspaceImageReadError(
|
|
153
|
+
function imageTooLarge(maxSize, mountPrefixedPath, size) {
|
|
154
|
+
return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
|
|
122
155
|
}
|
|
123
156
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
124
157
|
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
|
|
@@ -134,9 +167,6 @@ const JPEG_SOI = 0xd8;
|
|
|
134
167
|
const JPEG_EOI = 0xd9;
|
|
135
168
|
const JPEG_SOS = 0xda;
|
|
136
169
|
const JPEG_TEM = 0x01;
|
|
137
|
-
function isErrnoCode(err, code) {
|
|
138
|
-
return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
|
|
139
|
-
}
|
|
140
170
|
function startsWithBytes(buffer, prefix) {
|
|
141
171
|
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
142
172
|
}
|
|
@@ -170,7 +200,7 @@ async function linkReadError(mount, found, mountPrefixedPath, relativePath, nest
|
|
|
170
200
|
const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
|
|
171
201
|
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
172
202
|
const code = leads?.kind === 'outside' ? 'escape'
|
|
173
|
-
: leads?.kind === 'inside' && holder && namesHolder(leads,
|
|
203
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
|
|
174
204
|
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
175
205
|
return new WorkspaceReadError(code, reason, 'readlink');
|
|
176
206
|
}
|
|
@@ -201,28 +231,27 @@ function ancestorsOf(path) {
|
|
|
201
231
|
* directory as configured beneath the outer mount's configured path
|
|
202
232
|
* (`outerPath`), beneath each directory above its configured path that is
|
|
203
233
|
* the outer root on disk (`above`, each with where it lies on disk), and
|
|
204
|
-
* where its real root (`real`) lies beneath the outer root (`outerReal`)
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* whole. Undefined where it's nested nowhere.
|
|
234
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`).
|
|
235
|
+
* Its real root is claimed where it doesn't hold the outer root, which it
|
|
236
|
+
* would otherwise claim whole. Undefined where it's nested nowhere.
|
|
208
237
|
*/
|
|
209
|
-
|
|
238
|
+
function placementOf(outerPath, outerReal, innerPath, real, above) {
|
|
210
239
|
const configured = nestedDir(outerPath, innerPath);
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
240
|
+
const dirs = new Map();
|
|
241
|
+
const place = (dir, sure) => { if (dir !== undefined)
|
|
242
|
+
dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
|
|
243
|
+
place(configured, true);
|
|
244
|
+
for (const a of above) {
|
|
245
|
+
if (sameRoot(a.real, outerReal))
|
|
246
|
+
place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
|
|
247
|
+
}
|
|
248
|
+
const below = beneathRoot(real, outerReal);
|
|
249
|
+
place(below, below !== undefined && surelyAtRoot(real, outerReal));
|
|
250
|
+
if (dirs.size === 0)
|
|
218
251
|
return undefined;
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (!spellings.some((s) => s.dir === dir))
|
|
223
|
-
spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
224
|
-
}
|
|
225
|
-
return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
|
|
252
|
+
const fromRoots = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true }) }));
|
|
253
|
+
const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
|
|
254
|
+
return { configured, fromRoots, claims };
|
|
226
255
|
}
|
|
227
256
|
/** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
|
|
228
257
|
const LINKS_FOLLOWED = 40;
|
|
@@ -288,7 +317,7 @@ function isContainedPath(root, candidate) {
|
|
|
288
317
|
return candidate.startsWith(normalizedRoot);
|
|
289
318
|
}
|
|
290
319
|
function invalidImage(format, mountPrefixedPath) {
|
|
291
|
-
throw new WorkspaceImageReadError(
|
|
320
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
|
|
292
321
|
}
|
|
293
322
|
function requireBufferRange(bytes, start, length, format, mountPrefixedPath) {
|
|
294
323
|
if (start < 0 || length < 0 || start + length > bytes.length) {
|
|
@@ -624,21 +653,21 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
|
|
|
624
653
|
return 'image/png';
|
|
625
654
|
}
|
|
626
655
|
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
627
|
-
throw
|
|
656
|
+
throw truncatedImage(mountPrefixedPath);
|
|
628
657
|
}
|
|
629
658
|
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
630
659
|
validateJpeg(bytes, mountPrefixedPath);
|
|
631
660
|
return 'image/jpeg';
|
|
632
661
|
}
|
|
633
662
|
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
634
|
-
throw
|
|
663
|
+
throw truncatedImage(mountPrefixedPath);
|
|
635
664
|
}
|
|
636
665
|
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
637
666
|
validateGif(bytes, mountPrefixedPath);
|
|
638
667
|
return 'image/gif';
|
|
639
668
|
}
|
|
640
669
|
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
641
|
-
throw
|
|
670
|
+
throw truncatedImage(mountPrefixedPath);
|
|
642
671
|
}
|
|
643
672
|
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
644
673
|
validateWebp(bytes, mountPrefixedPath);
|
|
@@ -646,9 +675,9 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
|
|
|
646
675
|
}
|
|
647
676
|
if ((bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
648
677
|
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)) {
|
|
649
|
-
throw
|
|
678
|
+
throw truncatedImage(mountPrefixedPath);
|
|
650
679
|
}
|
|
651
|
-
throw new WorkspaceImageReadError('
|
|
680
|
+
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.");
|
|
652
681
|
}
|
|
653
682
|
export class WorkspaceModule {
|
|
654
683
|
name = 'workspace';
|
|
@@ -674,14 +703,11 @@ export class WorkspaceModule {
|
|
|
674
703
|
ctimeTrust = new Map();
|
|
675
704
|
/**
|
|
676
705
|
* Each mount's directories that are other mounts nested inside it, with
|
|
677
|
-
* each mount's name and real root,
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* the
|
|
682
|
-
* with each root as resolved standing for its real one, and each start
|
|
683
|
-
* reads it all again with the roots placed where disk has them
|
|
684
|
-
* (readNesting).
|
|
706
|
+
* each mount's name and real root: as configured, and as the real roots
|
|
707
|
+
* place it, where that differs (`fromRoots`). Each is mount-relative and
|
|
708
|
+
* `/`-separated. The constructor nests by configured path, with each root
|
|
709
|
+
* as resolved standing for its real one, and each start reads it all again
|
|
710
|
+
* with the roots placed where disk has them (readNesting).
|
|
685
711
|
*/
|
|
686
712
|
otherMounts = new Map();
|
|
687
713
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
@@ -707,6 +733,23 @@ export class WorkspaceModule {
|
|
|
707
733
|
* ensureRunning to say once both halves of the lifecycle have run.
|
|
708
734
|
*/
|
|
709
735
|
notDirect = new Map();
|
|
736
|
+
/**
|
|
737
|
+
* Each agent's latest direct write on each mount it wrote direct, P, with
|
|
738
|
+
* the notice of its departure from it, N (DirectHead), by mount, then by
|
|
739
|
+
* agent. Saved at stop() in a global record (headsJournal), not the
|
|
740
|
+
* module's branch state: they describe disk, which doesn't rewind with a
|
|
741
|
+
* branch, so a restart on another branch, such as one an offline rollback
|
|
742
|
+
* made, still finds them.
|
|
743
|
+
*/
|
|
744
|
+
directHeads = new Map();
|
|
745
|
+
/** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
|
|
746
|
+
headsJournal = null;
|
|
747
|
+
/** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
|
|
748
|
+
loadedHeads = null;
|
|
749
|
+
/** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
|
|
750
|
+
carriedHeads = [];
|
|
751
|
+
/** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
|
|
752
|
+
historyLookup = null;
|
|
710
753
|
constructor(config) {
|
|
711
754
|
for (const mount of config.mounts) {
|
|
712
755
|
const cap = mount.walkCap;
|
|
@@ -721,20 +764,19 @@ export class WorkspaceModule {
|
|
|
721
764
|
if (refused.length > 0) {
|
|
722
765
|
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
723
766
|
}
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (!Array.isArray(
|
|
729
|
-
throw new Error(`[workspace] Mount "${mount.name}":
|
|
767
|
+
for (const list of ['ignore', 'include']) {
|
|
768
|
+
const patterns = mount[list];
|
|
769
|
+
if (patterns === undefined)
|
|
770
|
+
continue;
|
|
771
|
+
if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
|
|
772
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
|
|
730
773
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
+ `${shownValue(unreadable)} is none of these`);
|
|
774
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
775
|
+
const pattern = patterns[i];
|
|
776
|
+
const why = patternRefusal(pattern, list);
|
|
777
|
+
if (why !== undefined) {
|
|
778
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
|
|
779
|
+
}
|
|
738
780
|
}
|
|
739
781
|
}
|
|
740
782
|
}
|
|
@@ -755,8 +797,8 @@ export class WorkspaceModule {
|
|
|
755
797
|
const dir = nestedDir(outerPath, innerPath);
|
|
756
798
|
if (dir === undefined)
|
|
757
799
|
continue;
|
|
758
|
-
const configured = [{ dir
|
|
759
|
-
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured
|
|
800
|
+
const configured = [{ dir }];
|
|
801
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
|
|
760
802
|
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
761
803
|
`at ${dir}/ — passing over it as another mount's directory`);
|
|
762
804
|
}
|
|
@@ -830,9 +872,64 @@ export class WorkspaceModule {
|
|
|
830
872
|
this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
|
|
831
873
|
this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
832
874
|
this.chooseKinds();
|
|
875
|
+
this.loadDirectHeads(store);
|
|
833
876
|
this.applySavedState();
|
|
834
877
|
this.ensureRunning();
|
|
835
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* The positions stop() last saved (directHeads), before any call can
|
|
881
|
+
* write: each saved entry is a mount's positions at one root, and a mount
|
|
882
|
+
* this session configures direct gets the entry at its own root. Every
|
|
883
|
+
* other entry, a mount configured at another root, not direct, or not at
|
|
884
|
+
* all, is kept for the next save (carriedHeads), since its writes are
|
|
885
|
+
* still on disk beneath that root; a mount removed for good, and each
|
|
886
|
+
* root a mount has moved from, leave that small entry behind. A record
|
|
887
|
+
* the store can't read is logged, and this session starts with none and
|
|
888
|
+
* saves none, so the record stands for a session that can read it. One
|
|
889
|
+
* read but not of the shape saved is logged too, and this session starts
|
|
890
|
+
* with none and its stop replaces it, so a damaged record doesn't keep
|
|
891
|
+
* every later session from saving. Either way, a move away from the
|
|
892
|
+
* writes before it goes untold, as after a crash.
|
|
893
|
+
*/
|
|
894
|
+
loadDirectHeads(store) {
|
|
895
|
+
const journal = new RecordJournal(store, { type: DIRECT_HEADS_RECORD_TYPE });
|
|
896
|
+
const restored = new Map();
|
|
897
|
+
const carried = [];
|
|
898
|
+
let loaded;
|
|
899
|
+
try {
|
|
900
|
+
loaded = journal.load().snapshot;
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
console.error('[workspace] could not read the saved direct-write positions:', err);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
let snapshot;
|
|
907
|
+
let text;
|
|
908
|
+
try {
|
|
909
|
+
snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
|
|
910
|
+
text = canonicalHeads(snapshot);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
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);
|
|
914
|
+
snapshot = [];
|
|
915
|
+
text = null;
|
|
916
|
+
}
|
|
917
|
+
for (const saved of snapshot) {
|
|
918
|
+
// Only a direct mount's writes are the agent's on disk, and a
|
|
919
|
+
// store-backed one's disk follows its store.
|
|
920
|
+
const mount = this.mounts.get(saved.mount);
|
|
921
|
+
if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
|
|
922
|
+
carried.push(saved);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) => [agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
|
|
926
|
+
}
|
|
927
|
+
this.headsJournal = journal;
|
|
928
|
+
this.loadedHeads = text;
|
|
929
|
+
this.carriedHeads = carried;
|
|
930
|
+
for (const [name, heads] of restored)
|
|
931
|
+
this.directHeads.set(name, heads);
|
|
932
|
+
}
|
|
836
933
|
/**
|
|
837
934
|
* Whether each mount configured direct runs direct: not while the store
|
|
838
935
|
* holds changes there that disk lacks, on the branch selected then, so
|
|
@@ -985,7 +1082,7 @@ export class WorkspaceModule {
|
|
|
985
1082
|
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
986
1083
|
let start = base;
|
|
987
1084
|
for (const segment of beneath.split('/')) {
|
|
988
|
-
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
|
|
1085
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined))
|
|
989
1086
|
break;
|
|
990
1087
|
const here = await checkDir(view, rootReal, start);
|
|
991
1088
|
if (here.kind !== 'checked')
|
|
@@ -1043,11 +1140,10 @@ export class WorkspaceModule {
|
|
|
1043
1140
|
}
|
|
1044
1141
|
async start(ctx) {
|
|
1045
1142
|
// Which mounts are nested where, with the roots placed where disk has
|
|
1046
|
-
// them and each
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
// sets nothing.
|
|
1143
|
+
// them, and whether each mount's root folds case, read where disk has
|
|
1144
|
+
// it, before this start sets anything, so a restart takes no call
|
|
1145
|
+
// and walks or watches nothing before they're known. A stop begun
|
|
1146
|
+
// meanwhile stands, and this start then sets nothing.
|
|
1051
1147
|
const stops = this.stopsBegun;
|
|
1052
1148
|
const { nesting, reals, above } = await this.readNesting();
|
|
1053
1149
|
const rootFoldsCase = new Map();
|
|
@@ -1160,7 +1256,8 @@ export class WorkspaceModule {
|
|
|
1160
1256
|
const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
|
|
1161
1257
|
return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
1162
1258
|
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
1163
|
-
`never had.
|
|
1259
|
+
`never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
|
|
1260
|
+
'if the current branch is what disk should hold, pass force: true to overwrite disk from it.');
|
|
1164
1261
|
}
|
|
1165
1262
|
/**
|
|
1166
1263
|
* Re-pin mounts whose last-materialized branch is a proven ancestor of the
|
|
@@ -1318,8 +1415,8 @@ export class WorkspaceModule {
|
|
|
1318
1415
|
const message = (one
|
|
1319
1416
|
? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
|
|
1320
1417
|
: "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
|
|
1321
|
-
"sync its path to take disk's copy, or
|
|
1322
|
-
`follow the answer
|
|
1418
|
+
"sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
|
|
1419
|
+
`and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
|
|
1323
1420
|
`${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
|
|
1324
1421
|
console.warn(`[workspace] ${message}`);
|
|
1325
1422
|
this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
|
|
@@ -1388,26 +1485,76 @@ export class WorkspaceModule {
|
|
|
1388
1485
|
}
|
|
1389
1486
|
this.ctx.setState(state);
|
|
1390
1487
|
}
|
|
1488
|
+
this.saveDirectHeads();
|
|
1391
1489
|
this.ctx = null;
|
|
1392
1490
|
}
|
|
1393
|
-
/**
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1491
|
+
/**
|
|
1492
|
+
* Save directHeads as one global record, the latest of which the next
|
|
1493
|
+
* start loads (loadDirectHeads): each mount's positions with the root
|
|
1494
|
+
* they were made under, and the entries this session didn't take up
|
|
1495
|
+
* (carriedHeads), unchanged. Only when that differs from what the
|
|
1496
|
+
* start loaded, so a session that changed nothing, or a store no direct
|
|
1497
|
+
* write reached, adds no record, and always where the record read wasn't
|
|
1498
|
+
* of the shape saved, which this replaces. Nothing where the store couldn't
|
|
1499
|
+
* read the record, so it stands. A save that fails is logged, and the next
|
|
1500
|
+
* start finds the positions of the last save that landed, as after a
|
|
1501
|
+
* crash.
|
|
1502
|
+
*/
|
|
1503
|
+
saveDirectHeads() {
|
|
1504
|
+
if (!this.headsJournal)
|
|
1505
|
+
return;
|
|
1506
|
+
const snapshot = [
|
|
1507
|
+
...this.carriedHeads,
|
|
1508
|
+
...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
|
|
1509
|
+
mount: name,
|
|
1510
|
+
root: resolve(this.mounts.get(name).config.path),
|
|
1511
|
+
agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
|
|
1512
|
+
})),
|
|
1513
|
+
];
|
|
1514
|
+
const text = canonicalHeads(snapshot);
|
|
1515
|
+
if (text === this.loadedHeads)
|
|
1516
|
+
return;
|
|
1517
|
+
try {
|
|
1518
|
+
this.headsJournal.checkpoint(snapshot);
|
|
1519
|
+
this.loadedHeads = text;
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
console.error('[workspace] could not save the direct-write positions:', err);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/** Mount names + modes, each mount's include where it sets one, and
|
|
1526
|
+
* `backend: 'direct'` for one configured direct, whether it runs direct
|
|
1527
|
+
* this session or, while the store holds work there disk lacks, as
|
|
1528
|
+
* 'chronicle': either way it's a directory agents write directly when it
|
|
1529
|
+
* runs direct, whose files an undo leaves. For the
|
|
1530
|
+
* framework's tool-result spills, saved payloads and fetched references,
|
|
1531
|
+
* which need a mount for files of their own: the first writable one
|
|
1532
|
+
* without an include that isn't direct (firstWritableMountName), without
|
|
1533
|
+
* reaching into private state. */
|
|
1397
1534
|
getMounts() {
|
|
1398
1535
|
return [...this.mounts.values()].map((m) => ({
|
|
1399
1536
|
name: m.config.name,
|
|
1400
|
-
|
|
1537
|
+
// Read-only where this session takes no writes there (writeRefusal).
|
|
1538
|
+
mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
|
|
1401
1539
|
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
1540
|
+
...(m.config.backend === 'direct' ? { backend: 'direct' } : {}),
|
|
1402
1541
|
}));
|
|
1403
1542
|
}
|
|
1404
1543
|
// ==========================================================================
|
|
1405
1544
|
// Tool Definitions
|
|
1406
1545
|
// ==========================================================================
|
|
1546
|
+
/**
|
|
1547
|
+
* Whether any of this host's mounts runs direct. The tools' descriptions
|
|
1548
|
+
* and grep's notes mention direct mounts only on such a host, so both ask
|
|
1549
|
+
* this one question.
|
|
1550
|
+
*/
|
|
1551
|
+
anyRunsDirect() {
|
|
1552
|
+
return [...this.mounts.values()].some((m) => m.direct);
|
|
1553
|
+
}
|
|
1407
1554
|
getTools() {
|
|
1408
1555
|
// Where a mount runs direct, four descriptions say what that changes.
|
|
1409
1556
|
// A host with none keeps them as they were, and its residents' caches.
|
|
1410
|
-
const direct =
|
|
1557
|
+
const direct = this.anyRunsDirect();
|
|
1411
1558
|
const lsDirect = direct
|
|
1412
1559
|
? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
|
|
1413
1560
|
+ '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.'
|
|
@@ -1418,6 +1565,23 @@ export class WorkspaceModule {
|
|
|
1418
1565
|
: '';
|
|
1419
1566
|
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.' : '';
|
|
1420
1567
|
const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
|
|
1568
|
+
// Where a direct mount is writable, the four writing tools say what a
|
|
1569
|
+
// write is there: on disk when it answers, and left there by a head move.
|
|
1570
|
+
const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
|
|
1571
|
+
const writeDirect = writable
|
|
1572
|
+
? ' 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.'
|
|
1573
|
+
: '';
|
|
1574
|
+
const editDirect = writable
|
|
1575
|
+
? " 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 "
|
|
1576
|
+
+ 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
|
|
1577
|
+
: '';
|
|
1578
|
+
const editManyDirect = writable
|
|
1579
|
+
? " 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: "
|
|
1580
|
+
+ "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
|
|
1581
|
+
+ 'An undo or other head move leaves them on disk.'
|
|
1582
|
+
: '';
|
|
1583
|
+
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.' : '';
|
|
1584
|
+
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.' : '';
|
|
1421
1585
|
return [
|
|
1422
1586
|
{
|
|
1423
1587
|
name: 'read',
|
|
@@ -1451,7 +1615,7 @@ export class WorkspaceModule {
|
|
|
1451
1615
|
},
|
|
1452
1616
|
{
|
|
1453
1617
|
name: 'write',
|
|
1454
|
-
description: 'Create or overwrite a file in the workspace.',
|
|
1618
|
+
description: 'Create or overwrite a file in the workspace.' + writeDirect,
|
|
1455
1619
|
inputSchema: {
|
|
1456
1620
|
type: 'object',
|
|
1457
1621
|
properties: {
|
|
@@ -1472,7 +1636,7 @@ export class WorkspaceModule {
|
|
|
1472
1636
|
+ "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
|
|
1473
1637
|
+ 'A change disk made to the file is taken in first, and the answer says so. '
|
|
1474
1638
|
+ 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
|
|
1475
|
-
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
|
|
1639
|
+
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
|
|
1476
1640
|
inputSchema: {
|
|
1477
1641
|
type: 'object',
|
|
1478
1642
|
properties: {
|
|
@@ -1494,7 +1658,8 @@ export class WorkspaceModule {
|
|
|
1494
1658
|
+ "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
|
|
1495
1659
|
+ 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
|
|
1496
1660
|
+ "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
|
|
1497
|
-
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
|
|
1661
|
+
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
|
|
1662
|
+
+ editManyDirect,
|
|
1498
1663
|
inputSchema: {
|
|
1499
1664
|
type: 'object',
|
|
1500
1665
|
properties: {
|
|
@@ -1523,7 +1688,7 @@ export class WorkspaceModule {
|
|
|
1523
1688
|
name: 'delete',
|
|
1524
1689
|
description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
|
|
1525
1690
|
+ 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
|
|
1526
|
-
+ 'or a sync of the path restores it.',
|
|
1691
|
+
+ 'or a sync of the path restores it.' + deleteDirect,
|
|
1527
1692
|
inputSchema: {
|
|
1528
1693
|
type: 'object',
|
|
1529
1694
|
properties: {
|
|
@@ -1576,7 +1741,7 @@ export class WorkspaceModule {
|
|
|
1576
1741
|
properties: {
|
|
1577
1742
|
pattern: { type: 'string', description: 'Regular expression pattern' },
|
|
1578
1743
|
path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
|
|
1579
|
-
glob: { type: 'string', description: 'Glob pattern to filter files
|
|
1744
|
+
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")' },
|
|
1580
1745
|
contextBefore: { type: 'integer', description: 'Context lines before match' },
|
|
1581
1746
|
contextAfter: { type: 'integer', description: 'Context lines after match' },
|
|
1582
1747
|
},
|
|
@@ -1598,7 +1763,7 @@ export class WorkspaceModule {
|
|
|
1598
1763
|
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
|
|
1599
1764
|
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
|
|
1600
1765
|
+ 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
|
|
1601
|
-
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
|
|
1766
|
+
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
|
|
1602
1767
|
inputSchema: {
|
|
1603
1768
|
type: 'object',
|
|
1604
1769
|
properties: {
|
|
@@ -1716,7 +1881,12 @@ export class WorkspaceModule {
|
|
|
1716
1881
|
work.then(done, done);
|
|
1717
1882
|
return work;
|
|
1718
1883
|
}
|
|
1719
|
-
|
|
1884
|
+
runToolCall(call) {
|
|
1885
|
+
const gate = { passed: false };
|
|
1886
|
+
return callGate.run(gate, () => this.answerToolCall(call, gate));
|
|
1887
|
+
}
|
|
1888
|
+
async answerToolCall(call, gate) {
|
|
1889
|
+
const reopenAtStart = this.agreement?.awaitsReopen === true;
|
|
1720
1890
|
try {
|
|
1721
1891
|
// Every handler reads its input as its schema describes it; a call
|
|
1722
1892
|
// that sends something else is refused here, before anything is read.
|
|
@@ -1726,20 +1896,25 @@ export class WorkspaceModule {
|
|
|
1726
1896
|
return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
|
|
1727
1897
|
}
|
|
1728
1898
|
const input = call.input;
|
|
1729
|
-
|
|
1899
|
+
// The most of this call's result that can reach its caller whole: past
|
|
1900
|
+
// it, a model's result spills into a file, and a script gets
|
|
1901
|
+
// ResultTooLarge in its place. A call made in process carries none and
|
|
1902
|
+
// takes the data itself, so its read pages by its line limit alone.
|
|
1903
|
+
const resultMaxChars = call.resultMaxChars;
|
|
1904
|
+
const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1730
1905
|
if (generated) {
|
|
1731
1906
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
1732
|
-
return await this.handleRead(input, generated.read());
|
|
1907
|
+
return await this.handleRead(input, resultMaxChars, generated.read());
|
|
1733
1908
|
}
|
|
1734
|
-
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
1909
|
+
return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path, generated, call.callerAgentName)) };
|
|
1735
1910
|
}
|
|
1736
1911
|
switch (call.name) {
|
|
1737
|
-
case 'read': return await this.handleRead(input);
|
|
1912
|
+
case 'read': return await this.handleRead(input, resultMaxChars);
|
|
1738
1913
|
case 'read_image': return await this.handleReadImage(input);
|
|
1739
|
-
case 'write': return await this.handleWrite(input);
|
|
1740
|
-
case 'edit': return await this.handleEdit(input, call.resultMaxChars);
|
|
1914
|
+
case 'write': return await this.handleWrite(input, call.callerAgentName);
|
|
1915
|
+
case 'edit': return await this.handleEdit(input, call.resultMaxChars, call.callerAgentName);
|
|
1741
1916
|
case 'edit_many': return await this.handleEditMany(input, call.resultMaxChars, call.callerAgentName);
|
|
1742
|
-
case 'delete': return await this.handleDelete(input);
|
|
1917
|
+
case 'delete': return await this.handleDelete(input, call.callerAgentName);
|
|
1743
1918
|
case 'ls': return await this.handleLs(input);
|
|
1744
1919
|
case 'glob': return await this.handleGlob(input);
|
|
1745
1920
|
case 'grep': return await this.handleGrep(input);
|
|
@@ -1747,12 +1922,40 @@ export class WorkspaceModule {
|
|
|
1747
1922
|
case 'materialize': return await this.handleMaterialize(input);
|
|
1748
1923
|
case 'sync': return await this.handleSync(input);
|
|
1749
1924
|
default:
|
|
1750
|
-
return { success: false, error:
|
|
1925
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
1751
1926
|
}
|
|
1752
1927
|
}
|
|
1753
1928
|
catch (err) {
|
|
1754
|
-
return { success: false, error: err
|
|
1929
|
+
return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
failureAnswer(call, err, reopenAtStart, passedGate) {
|
|
1933
|
+
const input = (typeof call.input === 'object' && call.input !== null ? call.input : {});
|
|
1934
|
+
const path = typeof input.path === 'string' ? input.path : undefined;
|
|
1935
|
+
const mountName = path !== undefined ? path.split('/')[0] : typeof input.mount === 'string' ? input.mount : undefined;
|
|
1936
|
+
const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
|
|
1937
|
+
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
|
|
1938
|
+
if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
|
|
1939
|
+
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
|
|
1940
|
+
}
|
|
1941
|
+
if (err instanceof Refusal)
|
|
1942
|
+
return didNothing(call.name, err.message);
|
|
1943
|
+
if (err instanceof WorkspaceReadError)
|
|
1944
|
+
return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
|
|
1945
|
+
const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
|
|
1946
|
+
: mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
|
|
1947
|
+
: 'ls of each mount, with recursive: true, shows how each file stands against disk now';
|
|
1948
|
+
if (err instanceof NotStored || err instanceof AlreadyHeld) {
|
|
1949
|
+
console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
|
|
1950
|
+
return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
|
|
1951
|
+
}
|
|
1952
|
+
console.error(`[workspace] ${call.name} failed:`, err);
|
|
1953
|
+
if (err instanceof CommitUnknownError) {
|
|
1954
|
+
return unexpectedAnswer(call.name, subject, err.setCause, {
|
|
1955
|
+
said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
|
|
1956
|
+
});
|
|
1755
1957
|
}
|
|
1958
|
+
return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
|
|
1756
1959
|
}
|
|
1757
1960
|
async onProcess(event, _state) {
|
|
1758
1961
|
// Wake agents on filesystem events when the originating mount opted in.
|
|
@@ -1800,7 +2003,7 @@ export class WorkspaceModule {
|
|
|
1800
2003
|
text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
1801
2004
|
}
|
|
1802
2005
|
if (conflicts && conflicts.length > 0) {
|
|
1803
|
-
text += `\n[conflicts: ${conflicts.join(', ')}]`;
|
|
2006
|
+
text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
1804
2007
|
}
|
|
1805
2008
|
return {
|
|
1806
2009
|
requestInference: true,
|
|
@@ -1848,16 +2051,15 @@ export class WorkspaceModule {
|
|
|
1848
2051
|
resolveAbsolutePath(mountPrefixedPath) {
|
|
1849
2052
|
try {
|
|
1850
2053
|
// Synchronous, so the path as given. The nested-mount check takes it
|
|
1851
|
-
// in
|
|
1852
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
// include judges it as given, so on a disk that
|
|
2054
|
+
// in any spelling a disk that folds names may fold to the nested
|
|
2055
|
+
// mount's directory, erring toward that mount (holderOf). The include
|
|
2056
|
+
// judges it as given, so on a disk that
|
|
1855
2057
|
// folds names a covered name whose file disk spells otherwise returns
|
|
1856
2058
|
// that file's path, which the tools refuse, and a covered file named in
|
|
1857
2059
|
// another case is null, which the tools read. A path the mount doesn't
|
|
1858
2060
|
// hold, outside its include or in another mount's directory nested in
|
|
1859
2061
|
// it, is null, as for a path that leaves the mount's directory.
|
|
1860
|
-
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath
|
|
2062
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
|
|
1861
2063
|
return resolve(mount.config.path, relativePath);
|
|
1862
2064
|
}
|
|
1863
2065
|
catch {
|
|
@@ -1879,6 +2081,15 @@ export class WorkspaceModule {
|
|
|
1879
2081
|
* withheld original this way, for the branch its stub is on
|
|
1880
2082
|
* (agent-framework #277).
|
|
1881
2083
|
*
|
|
2084
|
+
* On a mount running direct, the bytes go to disk as a direct `write`'s
|
|
2085
|
+
* do, whole or not at all, and nothing goes into the store: `branch` is
|
|
2086
|
+
* checked before the write starts and again just before its rename, and
|
|
2087
|
+
* `agentName` names the agent whose write it is for the head-move notice
|
|
2088
|
+
* (a write naming none tells no agent).
|
|
2089
|
+
*
|
|
2090
|
+
* A success's `disk` says where the file stands on disk as the workspace
|
|
2091
|
+
* last saw it, as the write tool's answer does.
|
|
2092
|
+
*
|
|
1882
2093
|
* A write whose tree entry is set but that then fails returns success
|
|
1883
2094
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
1884
2095
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -1906,20 +2117,36 @@ export class WorkspaceModule {
|
|
|
1906
2117
|
isError: true,
|
|
1907
2118
|
};
|
|
1908
2119
|
}
|
|
1909
|
-
if (
|
|
1910
|
-
return { success: false, error:
|
|
1911
|
-
|
|
2120
|
+
if (relativePath === '')
|
|
2121
|
+
return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
|
|
2122
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
2123
|
+
if (refusal)
|
|
2124
|
+
return { success: false, error: refusal, isError: true };
|
|
1912
2125
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
1913
2126
|
if (data.byteLength > maxSize) {
|
|
1914
|
-
return { success: false, error:
|
|
2127
|
+
return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
|
|
1915
2128
|
}
|
|
1916
2129
|
const store = this.getStore();
|
|
2130
|
+
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
2131
|
+
// as the caller's agent's write, where it names one. A branch it names
|
|
2132
|
+
// is checked just before the rename, since disk is no branch's.
|
|
2133
|
+
if (mount.direct) {
|
|
2134
|
+
// Checked first too, so a request already for a left branch makes no
|
|
2135
|
+
// directory and stages nothing.
|
|
2136
|
+
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
2137
|
+
return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
|
|
2138
|
+
}
|
|
2139
|
+
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2140
|
+
if ('error' in written)
|
|
2141
|
+
return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
|
|
2142
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
2143
|
+
}
|
|
1917
2144
|
return this.withMount(mount, async () => {
|
|
1918
2145
|
await this.settleBeforeMutation(mount, relativePath);
|
|
1919
2146
|
// Checked inside the mount's turn, after anything the write waited on:
|
|
1920
2147
|
// commitToolChange sets the tree entry before it first yields.
|
|
1921
2148
|
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
1922
|
-
return { success: false, error:
|
|
2149
|
+
return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
|
|
1923
2150
|
}
|
|
1924
2151
|
const blobHash = store.storeBlob(data, mimeType);
|
|
1925
2152
|
// An overwrite keeps the file's mode, as the write tool's does.
|
|
@@ -1927,9 +2154,11 @@ export class WorkspaceModule {
|
|
|
1927
2154
|
let committed = false;
|
|
1928
2155
|
let failure;
|
|
1929
2156
|
let modeKept = null;
|
|
2157
|
+
let disk = null;
|
|
1930
2158
|
try {
|
|
1931
2159
|
const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
|
|
1932
2160
|
modeKept = answer.modeKept;
|
|
2161
|
+
disk = answer.disk;
|
|
1933
2162
|
failure = answer.refused === null ? null
|
|
1934
2163
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
1935
2164
|
}
|
|
@@ -1938,10 +2167,13 @@ export class WorkspaceModule {
|
|
|
1938
2167
|
throw error;
|
|
1939
2168
|
// As write words it: a store that failed after recording the file
|
|
1940
2169
|
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2170
|
+
const reopen = this.agreement?.awaitsReopen === true;
|
|
1941
2171
|
failure = error instanceof StoredThenFailed
|
|
1942
2172
|
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
1943
|
-
+ (error.pushes ?
|
|
2173
|
+
+ (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 : ''}.` : '')
|
|
1944
2174
|
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
2175
|
+
if (reopen)
|
|
2176
|
+
failure += ` ${AWAITS_REOPEN}`;
|
|
1945
2177
|
}
|
|
1946
2178
|
if (failure !== null) {
|
|
1947
2179
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -1951,16 +2183,20 @@ export class WorkspaceModule {
|
|
|
1951
2183
|
}
|
|
1952
2184
|
return {
|
|
1953
2185
|
success: true,
|
|
1954
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
|
|
2186
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
|
|
1955
2187
|
};
|
|
1956
2188
|
});
|
|
1957
2189
|
}
|
|
1958
2190
|
/**
|
|
1959
|
-
* Read a file's raw bytes from a mount
|
|
1960
|
-
*
|
|
1961
|
-
* readBinaryFromDisk reads it, at most the mount's
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
2191
|
+
* Read a file's raw bytes from a mount: through the Chronicle tree, a path
|
|
2192
|
+
* the workspace doesn't hold observed first (ensureSynced); or, on a direct
|
|
2193
|
+
* mount, from disk as readBinaryFromDisk reads it, at most the mount's
|
|
2194
|
+
* `maxFileSize`. A path the workspace holds is read as the workspace holds
|
|
2195
|
+
* it, not settled as the `read` tool settles it: the tree takes in no
|
|
2196
|
+
* binary disk copy, so a pass over a held binary file that disk changed
|
|
2197
|
+
* would drop the workspace's copy. Public API for the framework's
|
|
2198
|
+
* synthesized `read_image` tool, where the workspace module doesn't serve
|
|
2199
|
+
* read_image itself, and other peer callers that need binary content.
|
|
1964
2200
|
*/
|
|
1965
2201
|
async readBinary(mountPrefixedPath) {
|
|
1966
2202
|
if (!this.running())
|
|
@@ -1983,12 +2219,13 @@ export class WorkspaceModule {
|
|
|
1983
2219
|
return this.withMount(mount, async () => {
|
|
1984
2220
|
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
1985
2221
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1986
|
-
if (!entry)
|
|
1987
|
-
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2222
|
+
if (!entry) {
|
|
2223
|
+
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
|
|
2224
|
+
}
|
|
2225
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2226
|
+
if ('lost' in contents)
|
|
2227
|
+
return { error: nothingToRead(contents.lost) };
|
|
2228
|
+
return { data: contents.bytes };
|
|
1992
2229
|
});
|
|
1993
2230
|
}
|
|
1994
2231
|
/**
|
|
@@ -2066,9 +2303,9 @@ export class WorkspaceModule {
|
|
|
2066
2303
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
2067
2304
|
}
|
|
2068
2305
|
}
|
|
2069
|
-
/** The directories of the mounts nested inside a mount, mount-relative
|
|
2306
|
+
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
2070
2307
|
othersOf(mountName) {
|
|
2071
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2308
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
|
|
2072
2309
|
}
|
|
2073
2310
|
/**
|
|
2074
2311
|
* Which mounts are nested in which, read afresh at each start. A mount is
|
|
@@ -2083,13 +2320,8 @@ export class WorkspaceModule {
|
|
|
2083
2320
|
* alone, with no real root, since through it it would claim the other's
|
|
2084
2321
|
* every path.
|
|
2085
2322
|
*
|
|
2086
|
-
* Each way it lies there is a spelling of its directory (`fromRoots`)
|
|
2087
|
-
*
|
|
2088
|
-
* disk that folds names, the outer mount's walks name the directory as
|
|
2089
|
-
* disk does, and so does a path parsePath has respelled, while a path given
|
|
2090
|
-
* as configured still names it as configured (resolveAbsolutePath). A
|
|
2091
|
-
* directory not on disk keeps the spellings the roots give alone. Each
|
|
2092
|
-
* mount's real root comes back beside the nesting (`reals`), for what
|
|
2323
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`).
|
|
2324
|
+
* Each mount's real root comes back beside the nesting (`reals`), for what
|
|
2093
2325
|
* start reads there, and each directory above each configured path with
|
|
2094
2326
|
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2095
2327
|
* (movedNesting).
|
|
@@ -2116,15 +2348,16 @@ export class WorkspaceModule {
|
|
|
2116
2348
|
if (outer === inner)
|
|
2117
2349
|
continue;
|
|
2118
2350
|
const real = reals.get(inner.name);
|
|
2119
|
-
const at =
|
|
2351
|
+
const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name));
|
|
2120
2352
|
if (at === undefined)
|
|
2121
2353
|
continue;
|
|
2122
|
-
const { configured, fromRoots,
|
|
2123
|
-
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots
|
|
2354
|
+
const { configured, fromRoots, claims } = at;
|
|
2355
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
|
|
2124
2356
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2125
2357
|
if (placed.length > 0) {
|
|
2126
|
-
|
|
2127
|
-
|
|
2358
|
+
const at = (sure) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
|
|
2359
|
+
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 ');
|
|
2360
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
|
|
2128
2361
|
}
|
|
2129
2362
|
}
|
|
2130
2363
|
}
|
|
@@ -2132,10 +2365,10 @@ export class WorkspaceModule {
|
|
|
2132
2365
|
}
|
|
2133
2366
|
/**
|
|
2134
2367
|
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2135
|
-
* has one, and include (MountView's `nested`)
|
|
2368
|
+
* has one, and include (MountView's `nested`).
|
|
2136
2369
|
*/
|
|
2137
2370
|
nestedOf(mountName) {
|
|
2138
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2371
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => 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 })));
|
|
2139
2372
|
}
|
|
2140
2373
|
/**
|
|
2141
2374
|
* The mounts nested in `mount` as a call of it that resolved its root to
|
|
@@ -2169,8 +2402,10 @@ export class WorkspaceModule {
|
|
|
2169
2402
|
* mount nested by its directories alone, since its real root holds this
|
|
2170
2403
|
* one, is judged by the parse's nesting, so a root repointed between a
|
|
2171
2404
|
* read's parse and its open can have it serve a path in that mount's
|
|
2172
|
-
* directory
|
|
2173
|
-
*
|
|
2405
|
+
* directory, and one repointed between a direct write's parse and its
|
|
2406
|
+
* staging can have the write land there. Each mount is placed as a start
|
|
2407
|
+
* places it (placementOf), from where its real root and the directories
|
|
2408
|
+
* above its configured path lie on
|
|
2174
2409
|
* disk. For a mount configured at this mount's own path or beneath it,
|
|
2175
2410
|
* that's where they lie now: beneath `rootReal` where they lie beneath this
|
|
2176
2411
|
* mount's path, and the directories above this mount's path as resolving
|
|
@@ -2235,12 +2470,13 @@ export class WorkspaceModule {
|
|
|
2235
2470
|
continue;
|
|
2236
2471
|
above = placedAbove.get(inner.name) ?? [];
|
|
2237
2472
|
}
|
|
2238
|
-
const at =
|
|
2473
|
+
const at = placementOf(outerPath, rootReal, innerPath, real, above);
|
|
2239
2474
|
if (at === undefined)
|
|
2240
2475
|
continue;
|
|
2241
2476
|
const include = this.mounts.get(inner.name)?.config.include;
|
|
2242
|
-
for (const s of at.
|
|
2243
|
-
nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2477
|
+
for (const s of at.fromRoots) {
|
|
2478
|
+
nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2479
|
+
}
|
|
2244
2480
|
}
|
|
2245
2481
|
return nested;
|
|
2246
2482
|
}
|
|
@@ -2249,15 +2485,9 @@ export class WorkspaceModule {
|
|
|
2249
2485
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2250
2486
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2251
2487
|
* outer mount isn't followed), with the path to use there; undefined when
|
|
2252
|
-
* no nested mount's directory holds it.
|
|
2253
|
-
* compared with the directory's spellings exactly. One judged as given
|
|
2254
|
-
* (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
|
|
2255
|
-
* folds names may compare it (beginsWithDir), so it can't reach that
|
|
2256
|
-
* mount's directory by spelling a segment otherwise. On a disk that tells
|
|
2257
|
-
* names apart, that takes a directory of this mount's own spelled like the
|
|
2258
|
-
* nested one in another way as that mount's too.
|
|
2488
|
+
* no nested mount's directory holds it.
|
|
2259
2489
|
*/
|
|
2260
|
-
holderOf(mountName, relativePath,
|
|
2490
|
+
holderOf(mountName, relativePath, moved = null) {
|
|
2261
2491
|
const mount = this.mounts.get(mountName);
|
|
2262
2492
|
if (!mount)
|
|
2263
2493
|
return undefined;
|
|
@@ -2265,20 +2495,15 @@ export class WorkspaceModule {
|
|
|
2265
2495
|
const resolved = resolve(root, relativePath);
|
|
2266
2496
|
if (!isContainedPath(root, resolved))
|
|
2267
2497
|
return undefined;
|
|
2268
|
-
const
|
|
2498
|
+
const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
|
|
2269
2499
|
// A direct mount whose root a call found moved is judged by that call's
|
|
2270
2500
|
// nesting (movedNesting), each directory beneath its configured path.
|
|
2271
|
-
const
|
|
2272
|
-
? moved.map((n) => ({ name: n.name, dir: n.dir }))
|
|
2273
|
-
: (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
|
|
2274
|
-
const holder = dirs
|
|
2275
|
-
.filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
|
|
2276
|
-
.map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
|
|
2277
|
-
.sort((a, b) => b.depth - a.depth)[0];
|
|
2501
|
+
const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
|
|
2278
2502
|
if (!holder)
|
|
2279
2503
|
return undefined;
|
|
2280
|
-
const rest =
|
|
2281
|
-
|
|
2504
|
+
const rest = restBeneath(path, holder);
|
|
2505
|
+
const guess = nestedGuess(mountName, path.split('/'), holder);
|
|
2506
|
+
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
|
|
2282
2507
|
}
|
|
2283
2508
|
/**
|
|
2284
2509
|
* Why a mount leaves a path in a nested mount's directory, naming that
|
|
@@ -2286,10 +2511,10 @@ export class WorkspaceModule {
|
|
|
2286
2511
|
* it; for a listing, whose the path is.
|
|
2287
2512
|
*/
|
|
2288
2513
|
otherMountNote(mountName, relativePath, by, moved = null) {
|
|
2289
|
-
const holder = this.holderOf(mountName, relativePath,
|
|
2514
|
+
const holder = this.holderOf(mountName, relativePath, moved);
|
|
2290
2515
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2291
2516
|
return holder
|
|
2292
|
-
? `it lies in mount "${holder.name}", nested in this one${what}:
|
|
2517
|
+
? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
|
|
2293
2518
|
: `it lies in another mount's directory${what}`;
|
|
2294
2519
|
}
|
|
2295
2520
|
/**
|
|
@@ -2308,7 +2533,7 @@ export class WorkspaceModule {
|
|
|
2308
2533
|
// Both checks judge by the nesting the parse found, and the second reads
|
|
2309
2534
|
// disk beneath the root the parse resolved.
|
|
2310
2535
|
const { mount, root, moved } = spelled;
|
|
2311
|
-
const parsed = this.refuseNotHeld(spelled, path,
|
|
2536
|
+
const parsed = this.refuseNotHeld(spelled, path, moved);
|
|
2312
2537
|
// A path can also reach a nested mount's directory, or a file this
|
|
2313
2538
|
// mount's own include leaves out, through a symlink on its way. No push
|
|
2314
2539
|
// of this mount writes it there and no read reads it, so it's refused
|
|
@@ -2350,7 +2575,9 @@ export class WorkspaceModule {
|
|
|
2350
2575
|
// whatever the place realpath stopped short of would say.
|
|
2351
2576
|
if (judged && unresolved.code !== undefined)
|
|
2352
2577
|
return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2353
|
-
|
|
2578
|
+
let holder = await nestedAtReal(real, nested);
|
|
2579
|
+
if (holder?.guess === null)
|
|
2580
|
+
holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
|
|
2354
2581
|
if (!holder) {
|
|
2355
2582
|
if (!judged)
|
|
2356
2583
|
return null;
|
|
@@ -2369,26 +2596,25 @@ export class WorkspaceModule {
|
|
|
2369
2596
|
// on the place's way as that mount's, so a place beneath anything else
|
|
2370
2597
|
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2371
2598
|
// a directory not there yet is.
|
|
2372
|
-
const onItsWay = holder.
|
|
2599
|
+
const onItsWay = !holder.judgedByInclude
|
|
2373
2600
|
|| await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
|
|
2374
|
-
const directory = holder.
|
|
2375
|
-
if (!onItsWay || !
|
|
2601
|
+
const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2602
|
+
if (!onItsWay || !holder.names(directory))
|
|
2376
2603
|
return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2377
|
-
return new NestedMountError(path, mount.config.name, holder
|
|
2604
|
+
return new NestedMountError(path, mount.config.name, holder, 'leads into');
|
|
2378
2605
|
}
|
|
2379
2606
|
/**
|
|
2380
2607
|
* A parsed path, refused where the mount doesn't hold it: in a nested
|
|
2381
2608
|
* mount's directory first, naming the mount that holds it, then outside
|
|
2382
2609
|
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2383
2610
|
* as disk spells it for parseFilePath, as given for resolveAbsolutePath,
|
|
2384
|
-
* which is synchronous
|
|
2385
|
-
* folds where disk does). The include's refusal shows the path as given
|
|
2611
|
+
* which is synchronous. The include's refusal shows the path as given
|
|
2386
2612
|
* (`given`), since it answers the path the caller asked about.
|
|
2387
2613
|
*/
|
|
2388
|
-
refuseNotHeld(parsed, given,
|
|
2389
|
-
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath,
|
|
2614
|
+
refuseNotHeld(parsed, given, moved = null) {
|
|
2615
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
|
|
2390
2616
|
if (holder)
|
|
2391
|
-
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder
|
|
2617
|
+
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
|
|
2392
2618
|
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2393
2619
|
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2394
2620
|
}
|
|
@@ -2434,7 +2660,7 @@ export class WorkspaceModule {
|
|
|
2434
2660
|
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2435
2661
|
*/
|
|
2436
2662
|
async refusesAsOutside(mount, relativePath, moved, root) {
|
|
2437
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
2663
|
+
if (this.holderOf(mount.config.name, relativePath, moved))
|
|
2438
2664
|
return false;
|
|
2439
2665
|
if (insideMount(relativePath, mount.config.include))
|
|
2440
2666
|
return false;
|
|
@@ -2509,20 +2735,12 @@ export class WorkspaceModule {
|
|
|
2509
2735
|
}
|
|
2510
2736
|
/**
|
|
2511
2737
|
* How many segments the innermost nested mount's directory that holds
|
|
2512
|
-
* `relativePath` has,
|
|
2513
|
-
*
|
|
2738
|
+
* `relativePath` has, or, for a direct mount whose root has moved, in the
|
|
2739
|
+
* nesting there (`moved`);
|
|
2514
2740
|
* undefined when no nested mount's directory holds it.
|
|
2515
2741
|
*/
|
|
2516
2742
|
nestedDepth(mountName, relativePath, moved = null) {
|
|
2517
|
-
|
|
2518
|
-
for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
|
|
2519
|
-
if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
|
|
2520
|
-
continue;
|
|
2521
|
-
const segments = dir.split('/').length;
|
|
2522
|
-
if (depth === undefined || segments > depth)
|
|
2523
|
-
depth = segments;
|
|
2524
|
-
}
|
|
2525
|
-
return depth;
|
|
2743
|
+
return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
|
|
2526
2744
|
}
|
|
2527
2745
|
/**
|
|
2528
2746
|
* A mount's entries on the current branch, and the paths it records an
|
|
@@ -2544,11 +2762,7 @@ export class WorkspaceModule {
|
|
|
2544
2762
|
// Only an entry this mount's include covers spells a draft's path
|
|
2545
2763
|
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
2546
2764
|
// from an earlier version, nor one the include leaves out, which would
|
|
2547
|
-
// respell a covered draft into a name the include leaves out.
|
|
2548
|
-
// in a nested mount's directory that this mount's include covers
|
|
2549
|
-
// spells one whatever that mount's include says, so on a disk that
|
|
2550
|
-
// folds names, a draft naming the directory in another spelling takes
|
|
2551
|
-
// the entry's spelling and is judged as in it.
|
|
2765
|
+
// respell a covered draft into a name the include leaves out.
|
|
2552
2766
|
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
2553
2767
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
2554
2768
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
@@ -2573,25 +2787,25 @@ export class WorkspaceModule {
|
|
|
2573
2787
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
2574
2788
|
// store would keep one name and disk another.
|
|
2575
2789
|
if (LONE_SURROGATE.test(path)) {
|
|
2576
|
-
throw new
|
|
2790
|
+
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.`);
|
|
2577
2791
|
}
|
|
2578
2792
|
const slashIdx = path.indexOf('/');
|
|
2579
2793
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
2580
2794
|
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
2581
2795
|
const mount = this.mounts.get(mountName);
|
|
2582
|
-
if (!mount)
|
|
2583
|
-
throw new
|
|
2584
|
-
}
|
|
2796
|
+
if (!mount)
|
|
2797
|
+
throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
|
|
2585
2798
|
const segments = pathSegments(given);
|
|
2586
2799
|
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
2587
|
-
throw new
|
|
2800
|
+
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>".`);
|
|
2588
2801
|
}
|
|
2589
2802
|
const kept = [];
|
|
2590
2803
|
for (const segment of segments) {
|
|
2591
2804
|
if (segment !== '..')
|
|
2592
2805
|
kept.push(segment);
|
|
2593
2806
|
else if (kept.pop() === undefined) {
|
|
2594
|
-
throw new
|
|
2807
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
|
|
2808
|
+
+ `Name a file from its own mount's name; ${this.mountList()}.`);
|
|
2595
2809
|
}
|
|
2596
2810
|
}
|
|
2597
2811
|
const relativePath = kept.join('/');
|
|
@@ -2605,10 +2819,37 @@ export class WorkspaceModule {
|
|
|
2605
2819
|
const root = resolve(mount.config.path);
|
|
2606
2820
|
const resolved = resolve(root, relativePath);
|
|
2607
2821
|
if (!isContainedPath(root, resolved)) {
|
|
2608
|
-
throw new
|
|
2822
|
+
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>".`);
|
|
2609
2823
|
}
|
|
2610
2824
|
return { mount, relativePath };
|
|
2611
2825
|
}
|
|
2826
|
+
writableNames() {
|
|
2827
|
+
return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
|
|
2828
|
+
}
|
|
2829
|
+
mountList() {
|
|
2830
|
+
const names = [...this.mounts.keys()];
|
|
2831
|
+
return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
|
|
2832
|
+
}
|
|
2833
|
+
noSuchMountField(given, bare) {
|
|
2834
|
+
const [close] = closestNames(given, [...this.mounts.keys()], 1);
|
|
2835
|
+
return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
|
|
2836
|
+
+ (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
|
|
2837
|
+
+ ` Leaving "mount" out ${bare}.`;
|
|
2838
|
+
}
|
|
2839
|
+
noSuchMount(path, mountName) {
|
|
2840
|
+
const names = [...this.mounts.keys()];
|
|
2841
|
+
const rest = path.slice(mountName.length);
|
|
2842
|
+
const unslashed = path.replace(/^[/\\]+/, '');
|
|
2843
|
+
if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
|
|
2844
|
+
return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
|
|
2845
|
+
}
|
|
2846
|
+
if (mountName === '')
|
|
2847
|
+
return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
2848
|
+
const [close] = closestNames(mountName, names, 1);
|
|
2849
|
+
const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
|
|
2850
|
+
: `${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()}.`;
|
|
2851
|
+
return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
|
|
2852
|
+
}
|
|
2612
2853
|
/**
|
|
2613
2854
|
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
2614
2855
|
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
@@ -2627,25 +2868,23 @@ export class WorkspaceModule {
|
|
|
2627
2868
|
}
|
|
2628
2869
|
validateImageBytes(bytes, mountPrefixedPath, maxSize) {
|
|
2629
2870
|
if (bytes.byteLength === 0) {
|
|
2630
|
-
throw
|
|
2871
|
+
throw emptyImage(mountPrefixedPath);
|
|
2631
2872
|
}
|
|
2632
2873
|
if (bytes.byteLength > maxSize)
|
|
2633
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
2874
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
|
|
2634
2875
|
return {
|
|
2635
2876
|
bytes,
|
|
2636
2877
|
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
2637
2878
|
};
|
|
2638
2879
|
}
|
|
2639
|
-
|
|
2640
|
-
const
|
|
2641
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2880
|
+
async readImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
2881
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
2642
2882
|
if (!entry)
|
|
2643
2883
|
return null;
|
|
2644
|
-
const
|
|
2645
|
-
if (
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
2884
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2885
|
+
if ('lost' in contents)
|
|
2886
|
+
return contents;
|
|
2887
|
+
return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
|
|
2649
2888
|
}
|
|
2650
2889
|
/**
|
|
2651
2890
|
* The mount root a disk read reads beneath: resolved, then checked as
|
|
@@ -2661,20 +2900,14 @@ export class WorkspaceModule {
|
|
|
2661
2900
|
* another directory carries none. The identity is rootIdentityForRead's,
|
|
2662
2901
|
* which answers while the evidence awaits reopening; where even it can't
|
|
2663
2902
|
* be read, as when the evidence was never read back whole, the read is
|
|
2664
|
-
* refused
|
|
2903
|
+
* refused before its root is resolved, with an errno only where no
|
|
2904
|
+
* directory is at the root's path.
|
|
2665
2905
|
* A direct mount's root is checked against no identity, and its read reads
|
|
2666
2906
|
* no evidence: it promises what's at its root now (diskView).
|
|
2667
2907
|
*/
|
|
2668
2908
|
async readRoot(mount) {
|
|
2669
2909
|
const name = mount.config.name;
|
|
2670
2910
|
const root = resolve(mount.config.path);
|
|
2671
|
-
let real;
|
|
2672
|
-
try {
|
|
2673
|
-
real = await realpath(root);
|
|
2674
|
-
}
|
|
2675
|
-
catch (err) {
|
|
2676
|
-
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
2677
|
-
}
|
|
2678
2911
|
let rootIdentity;
|
|
2679
2912
|
if (!mount.direct) {
|
|
2680
2913
|
try {
|
|
@@ -2683,9 +2916,16 @@ export class WorkspaceModule {
|
|
|
2683
2916
|
catch (err) {
|
|
2684
2917
|
// The evidence can't be read back, so neither can the check be made.
|
|
2685
2918
|
// Where no directory is at the root's path, its errno still says so.
|
|
2686
|
-
throw new
|
|
2919
|
+
throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
|
|
2687
2920
|
}
|
|
2688
2921
|
}
|
|
2922
|
+
let real;
|
|
2923
|
+
try {
|
|
2924
|
+
real = await realpath(root);
|
|
2925
|
+
}
|
|
2926
|
+
catch (err) {
|
|
2927
|
+
throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
|
|
2928
|
+
}
|
|
2689
2929
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
2690
2930
|
if ('dir' in checked)
|
|
2691
2931
|
return { real, dir: checked.dir };
|
|
@@ -2693,7 +2933,7 @@ export class WorkspaceModule {
|
|
|
2693
2933
|
// root's path now decides: nothing, something that isn't a directory, or
|
|
2694
2934
|
// a directory the path has come to name since it was resolved (no errno).
|
|
2695
2935
|
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
2696
|
-
throw new
|
|
2936
|
+
throw new RootUnavailable(name, checked.reason, 'root', errno);
|
|
2697
2937
|
}
|
|
2698
2938
|
/**
|
|
2699
2939
|
* The refusal for a root that stopped being the directory a read checked,
|
|
@@ -2707,7 +2947,7 @@ export class WorkspaceModule {
|
|
|
2707
2947
|
async rootMoved(mount) {
|
|
2708
2948
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
2709
2949
|
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
2710
|
-
return new
|
|
2950
|
+
return new RootUnavailable(mount.config.name, reason, 'root', errno);
|
|
2711
2951
|
}
|
|
2712
2952
|
/**
|
|
2713
2953
|
* Open a mount-relative file for reading with the mount boundary enforced on
|
|
@@ -2797,7 +3037,7 @@ export class WorkspaceModule {
|
|
|
2797
3037
|
}
|
|
2798
3038
|
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
2799
3039
|
// A FIFO, socket or device is never opened.
|
|
2800
|
-
throw new
|
|
3040
|
+
throw new NotRegularFile(mountPrefixedPath, 'lstat');
|
|
2801
3041
|
}
|
|
2802
3042
|
let handle;
|
|
2803
3043
|
try {
|
|
@@ -2828,7 +3068,7 @@ export class WorkspaceModule {
|
|
|
2828
3068
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
2829
3069
|
}
|
|
2830
3070
|
if (!fileStat.isFile()) {
|
|
2831
|
-
throw new
|
|
3071
|
+
throw new NotRegularFile(mountPrefixedPath, 'fstat');
|
|
2832
3072
|
}
|
|
2833
3073
|
if (!follow && !useNoFollow) {
|
|
2834
3074
|
let postOpenInfo;
|
|
@@ -2866,13 +3106,13 @@ export class WorkspaceModule {
|
|
|
2866
3106
|
if (resolvedThrough !== null)
|
|
2867
3107
|
throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
2868
3108
|
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
2869
|
-
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3109
|
+
const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
|
|
2870
3110
|
// A place in a nested mount's directory that its include leaves out is
|
|
2871
3111
|
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
2872
3112
|
// does one reaching a file this mount's own include leaves out
|
|
2873
3113
|
// (coversLanding).
|
|
2874
3114
|
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
2875
|
-
if (!inside || own || (holder !== undefined && !
|
|
3115
|
+
if (!inside || own || (holder !== undefined && !holder.names(false))) {
|
|
2876
3116
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
2877
3117
|
}
|
|
2878
3118
|
if (holder)
|
|
@@ -2941,7 +3181,9 @@ export class WorkspaceModule {
|
|
|
2941
3181
|
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
2942
3182
|
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
2943
3183
|
* directory, policy-denied symlink, outside-mount target, and
|
|
2944
|
-
* file-changed-during-read.
|
|
3184
|
+
* file-changed-during-read. An error met while parsing the path that is
|
|
3185
|
+
* none of these refusals, such as the store's, is thrown as it is, and a
|
|
3186
|
+
* `maxBytes` that isn't a non-negative integer throws a `RangeError`.
|
|
2945
3187
|
*/
|
|
2946
3188
|
async readFileFromDisk(mountPrefixedPath, options = {}) {
|
|
2947
3189
|
if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
|
|
@@ -2956,11 +3198,12 @@ export class WorkspaceModule {
|
|
|
2956
3198
|
// A link's refusal (parseFilePath) carries its own code.
|
|
2957
3199
|
if (err instanceof WorkspaceReadError)
|
|
2958
3200
|
throw err;
|
|
2959
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2960
3201
|
const code = err instanceof OutsideIncludeError ? 'outside_include'
|
|
2961
3202
|
: err instanceof NestedMountError ? 'other_mount'
|
|
2962
|
-
:
|
|
2963
|
-
|
|
3203
|
+
: err instanceof PathRefusal ? err.code : null;
|
|
3204
|
+
if (code === null)
|
|
3205
|
+
throw err;
|
|
3206
|
+
throw new WorkspaceReadError(code, err.message, 'parse');
|
|
2964
3207
|
}
|
|
2965
3208
|
if (!relativePath) {
|
|
2966
3209
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
@@ -2990,93 +3233,25 @@ export class WorkspaceModule {
|
|
|
2990
3233
|
await handle.close();
|
|
2991
3234
|
}
|
|
2992
3235
|
}
|
|
2993
|
-
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
2994
|
-
imageErrorFromRead(err, mount, mountPrefixedPath) {
|
|
2995
|
-
const p = mountPrefixedPath;
|
|
2996
|
-
switch (err.code) {
|
|
2997
|
-
case 'mount_unavailable':
|
|
2998
|
-
// With why, where the root's check says, such as a directory that
|
|
2999
|
-
// isn't the mount's.
|
|
3000
|
-
return new WorkspaceImageReadError('mount_unavailable', err.message);
|
|
3001
|
-
case 'directory':
|
|
3002
|
-
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
3003
|
-
case 'symlink':
|
|
3004
|
-
// The core reader's own words for a link at the path: "Symlinks are not
|
|
3005
|
-
// allowed: <path>".
|
|
3006
|
-
return new WorkspaceImageReadError('symlink', err.message);
|
|
3007
|
-
case 'parent_symlink':
|
|
3008
|
-
// The core reader's own words for a link on its way: which link and
|
|
3009
|
-
// where it leads, with the setting for a link to a directory.
|
|
3010
|
-
return new WorkspaceImageReadError('parent_symlink', err.message);
|
|
3011
|
-
case 'other_mount':
|
|
3012
|
-
// The core reader's own words for a path a link takes into a nested
|
|
3013
|
-
// mount's directory: that mount and the path to use there. (#460's
|
|
3014
|
-
// own other_mount, a path in that directory, is refused at parse,
|
|
3015
|
-
// and so is a link found there before the read.)
|
|
3016
|
-
return new WorkspaceImageReadError('other_mount', err.message);
|
|
3017
|
-
case 'escape':
|
|
3018
|
-
// The core reader's own words: "Symlink escape detected: <path>" for a
|
|
3019
|
-
// followed link that resolves outside the mount, and for a link on the
|
|
3020
|
-
// way that leads outside a mount that doesn't follow links, which link.
|
|
3021
|
-
return new WorkspaceImageReadError('escape', err.message);
|
|
3022
|
-
case 'changed':
|
|
3023
|
-
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
3024
|
-
case 'not_found':
|
|
3025
|
-
default:
|
|
3026
|
-
// The core reader's own words for a file that isn't regular, and for
|
|
3027
|
-
// a directory on the way it couldn't ask about.
|
|
3028
|
-
if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
|
|
3029
|
-
return new WorkspaceImageReadError('not_found', err.message);
|
|
3030
|
-
}
|
|
3031
|
-
if (err.message.startsWith('File not found')) {
|
|
3032
|
-
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
3033
|
-
}
|
|
3034
|
-
if (err.stage === 'realpath') {
|
|
3035
|
-
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
3036
|
-
}
|
|
3037
|
-
if (err.stage === 'stat') {
|
|
3038
|
-
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
3039
|
-
}
|
|
3040
|
-
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
3236
|
async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
if (fileStat.size > maxSize)
|
|
3051
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3052
|
-
});
|
|
3053
|
-
}
|
|
3054
|
-
catch (err) {
|
|
3055
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3056
|
-
throw err;
|
|
3057
|
-
if (err instanceof WorkspaceReadError)
|
|
3058
|
-
throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
3059
|
-
if (isErrnoCode(err, 'ENOENT')) {
|
|
3060
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3061
|
-
}
|
|
3062
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3063
|
-
}
|
|
3064
|
-
const { handle, fileStat } = opened;
|
|
3237
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
3238
|
+
if (fileStat.size === 0)
|
|
3239
|
+
throw emptyImage(mountPrefixedPath);
|
|
3240
|
+
if (fileStat.size > maxSize)
|
|
3241
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
|
|
3242
|
+
});
|
|
3065
3243
|
try {
|
|
3066
3244
|
// No more than maxSize is loaded: a file grown past it since the fstat
|
|
3067
3245
|
// above is refused here, as one already over it was there.
|
|
3068
3246
|
const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
|
|
3069
3247
|
if (more)
|
|
3070
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3248
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
|
|
3071
3249
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
3072
3250
|
}
|
|
3073
3251
|
catch (err) {
|
|
3074
3252
|
if (err instanceof WorkspaceImageReadError)
|
|
3075
3253
|
throw err;
|
|
3076
|
-
|
|
3077
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3078
|
-
}
|
|
3079
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3254
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
3080
3255
|
}
|
|
3081
3256
|
finally {
|
|
3082
3257
|
await handle.close();
|
|
@@ -3108,13 +3283,14 @@ export class WorkspaceModule {
|
|
|
3108
3283
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
3109
3284
|
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
3110
3285
|
* deletion that cleared a conflict naming a disk file the workspace never
|
|
3111
|
-
* took in, that nothing was unlinked (`note`).
|
|
3112
|
-
*
|
|
3113
|
-
* the path,
|
|
3286
|
+
* took in, that nothing was unlinked (`note`). And the answer's `disk` for
|
|
3287
|
+
* the path, unless it was refused. The caller holds the mount's turn and has
|
|
3288
|
+
* settled the path first (settleBeforeMutation, or a pass over the path, as
|
|
3289
|
+
* an edit makes).
|
|
3114
3290
|
*/
|
|
3115
3291
|
async commitToolChange(mount, relativePath, change, onCommitted) {
|
|
3116
|
-
const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3117
|
-
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
|
|
3292
|
+
const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3293
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
|
|
3118
3294
|
}
|
|
3119
3295
|
/**
|
|
3120
3296
|
* commitToolChange for several paths of one mount at once, as edit_many
|
|
@@ -3129,9 +3305,10 @@ export class WorkspaceModule {
|
|
|
3129
3305
|
* (changesLanded).
|
|
3130
3306
|
* Answers, by path, the reason for each that reached the workspace but not
|
|
3131
3307
|
* disk (`refused`), and, apart from it, why one whose bytes reached disk
|
|
3132
|
-
* left the file's mode as disk had it (`modeKept`),
|
|
3133
|
-
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3134
|
-
*
|
|
3308
|
+
* left the file's mode as disk had it (`modeKept`), that a deletion
|
|
3309
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3310
|
+
* and the answer's `disk` for each path not refused, as commitToolChange
|
|
3311
|
+
* answers them for one path.
|
|
3135
3312
|
*/
|
|
3136
3313
|
async commitToolChanges(mount, changes, onCommitted) {
|
|
3137
3314
|
const store = this.getStore();
|
|
@@ -3280,7 +3457,7 @@ export class WorkspaceModule {
|
|
|
3280
3457
|
// nothing.
|
|
3281
3458
|
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3282
3459
|
putBack();
|
|
3283
|
-
throw error;
|
|
3460
|
+
throw changing.length === 0 ? new AlreadyHeld(error) : error;
|
|
3284
3461
|
}
|
|
3285
3462
|
landedThenThrew = { error };
|
|
3286
3463
|
}
|
|
@@ -3298,8 +3475,9 @@ export class WorkspaceModule {
|
|
|
3298
3475
|
throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3299
3476
|
const refused = new Map();
|
|
3300
3477
|
const modeKept = new Map();
|
|
3301
|
-
if (!this.pushesOnCommit(mount) || pushing.length === 0)
|
|
3302
|
-
return { refused, modeKept, notes };
|
|
3478
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) {
|
|
3479
|
+
return { refused, modeKept, notes, disk: new Map(changes.map(({ path }) => [path, this.diskOf(mount, path)])) };
|
|
3480
|
+
}
|
|
3303
3481
|
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3304
3482
|
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
3305
3483
|
branchId: store.currentBranch().id,
|
|
@@ -3321,7 +3499,7 @@ export class WorkspaceModule {
|
|
|
3321
3499
|
error: skipped.reason,
|
|
3322
3500
|
});
|
|
3323
3501
|
}
|
|
3324
|
-
return { refused, modeKept, notes };
|
|
3502
|
+
return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }) => [path, 'done'])) };
|
|
3325
3503
|
}
|
|
3326
3504
|
// ==========================================================================
|
|
3327
3505
|
// Lazy Sync
|
|
@@ -3343,15 +3521,19 @@ export class WorkspaceModule {
|
|
|
3343
3521
|
await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
|
|
3344
3522
|
}
|
|
3345
3523
|
/**
|
|
3346
|
-
* Before a
|
|
3347
|
-
* three-way rule, which ingests a new disk file but
|
|
3348
|
-
* workspace deletion or a file absent from this branch.
|
|
3349
|
-
* path
|
|
3350
|
-
*
|
|
3351
|
-
*
|
|
3352
|
-
*
|
|
3353
|
-
* the
|
|
3354
|
-
*
|
|
3524
|
+
* Before a deletion, and for readBinary: a path the workspace doesn't hold
|
|
3525
|
+
* is observed under the three-way rule, which ingests a new disk file but
|
|
3526
|
+
* never brings back a workspace deletion or a file absent from this branch.
|
|
3527
|
+
* A path it holds is left alone. For a deletion, as for a write, taking
|
|
3528
|
+
* disk's change in first would let the push remove that change unseen; for
|
|
3529
|
+
* readBinary, a pass would drop a held binary file that disk changed, since
|
|
3530
|
+
* the tree takes in no binary disk copy. The pass names the path, as a file
|
|
3531
|
+
* tool names the one it acts on: a path the mount passes over by design is
|
|
3532
|
+
* observed, as a sync naming it would observe it (#276), while a path in
|
|
3533
|
+
* another mount's directory is refused before this, and passed over by any
|
|
3534
|
+
* pass. Answers the pass's report on the path, and why the pass decided
|
|
3535
|
+
* nothing where it did (undecidedOf), which leaves disk unchecked. The
|
|
3536
|
+
* caller holds the mount's turn.
|
|
3355
3537
|
*/
|
|
3356
3538
|
async ensureSynced(mount, relativePath) {
|
|
3357
3539
|
const store = this.getStore();
|
|
@@ -3366,46 +3548,79 @@ export class WorkspaceModule {
|
|
|
3366
3548
|
* call made again checks it.
|
|
3367
3549
|
*/
|
|
3368
3550
|
notCheckedYet(path, reason) {
|
|
3369
|
-
return `${
|
|
3551
|
+
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.`;
|
|
3370
3552
|
}
|
|
3371
3553
|
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
3372
|
-
notInWorkspace(path, report) {
|
|
3554
|
+
notInWorkspace(path, report, mount, missing) {
|
|
3555
|
+
const shown = JSON.stringify(path);
|
|
3373
3556
|
switch (report?.state) {
|
|
3374
3557
|
case 'workspace-deleted':
|
|
3375
|
-
return `${
|
|
3558
|
+
return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
3376
3559
|
case 'not-in-branch':
|
|
3377
|
-
return `${
|
|
3378
|
-
case 'disk-only':
|
|
3379
|
-
|
|
3380
|
-
|
|
3560
|
+
return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
3561
|
+
case 'disk-only': {
|
|
3562
|
+
const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
3563
|
+
return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
|
|
3564
|
+
+ 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
|
|
3565
|
+
+ (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
|
|
3566
|
+
}
|
|
3381
3567
|
case 'conflict':
|
|
3382
|
-
return `${
|
|
3568
|
+
return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
3383
3569
|
'Sync this path to take the disk version.';
|
|
3384
3570
|
case 'unverified':
|
|
3385
|
-
return
|
|
3571
|
+
return uncheckedFile(shown, report.note);
|
|
3386
3572
|
default:
|
|
3387
|
-
return `
|
|
3573
|
+
return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
3388
3574
|
}
|
|
3389
3575
|
}
|
|
3390
3576
|
/**
|
|
3391
|
-
* A file's bytes for read,
|
|
3392
|
-
* workspace's copy,
|
|
3393
|
-
*
|
|
3394
|
-
* (bytesOnDisk)
|
|
3577
|
+
* A file's bytes for read, and the notes that say which copy they are. On a
|
|
3578
|
+
* 'chronicle' mount it's the workspace's copy, after a pass over the path
|
|
3579
|
+
* takes disk's change to it in first. On a direct mount it's disk's file as
|
|
3580
|
+
* it is now (bytesOnDisk), with no notes, since nothing else is kept. An
|
|
3581
|
+
* error says why there are none to read.
|
|
3395
3582
|
*/
|
|
3396
3583
|
async bytesOf(mount, relativePath, path) {
|
|
3397
|
-
if (mount.direct)
|
|
3398
|
-
|
|
3584
|
+
if (mount.direct) {
|
|
3585
|
+
const read = await this.bytesOnDisk(mount, relativePath, path);
|
|
3586
|
+
return 'error' in read ? read : { bytes: read.bytes, notes: [] };
|
|
3587
|
+
}
|
|
3399
3588
|
const store = this.getStore();
|
|
3400
3589
|
return this.withMount(mount, async () => {
|
|
3401
|
-
|
|
3590
|
+
// A read shows the path as disk holds it now: a change on disk to a
|
|
3591
|
+
// file the workspace holds is taken in first, by the rule a listing
|
|
3592
|
+
// uses, as an edit's is. Where the stored copy is a draft and disk
|
|
3593
|
+
// changed too, the read shows the draft and names the conflict. The
|
|
3594
|
+
// pass names the path, as a file tool names the one it acts on, so a
|
|
3595
|
+
// path the mount passes over by design is observed, as a sync naming
|
|
3596
|
+
// it would observe it (#276).
|
|
3597
|
+
const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
|
|
3598
|
+
// A pass that decided nothing (undecidedOf: the selected branch kept
|
|
3599
|
+
// changing) leaves no report. It's read as a copy disk couldn't be
|
|
3600
|
+
// checked, with its reason, so the answer says the check didn't finish.
|
|
3601
|
+
const undecided = undecidedOf(pass);
|
|
3602
|
+
const report = pass.reports.get(relativePath)
|
|
3603
|
+
?? (undecided !== null ? { path: relativePath, state: 'unverified', note: undecided } : undefined);
|
|
3604
|
+
// A check that decided nothing may well finish on the next read, as
|
|
3605
|
+
// grep's answer to the same says of grep.
|
|
3606
|
+
const again = undecided !== null ? ['Read again to check it.'] : [];
|
|
3607
|
+
// A mode taken in alone isn't a change to the text, so it leaves the
|
|
3608
|
+
// read nothing to say, as it leaves an edit nothing.
|
|
3609
|
+
const took = (op) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
|
|
3402
3610
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3403
|
-
if (!entry)
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3611
|
+
if (!entry) {
|
|
3612
|
+
// A pass also drops a copy disk made binary or too large, which
|
|
3613
|
+
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3614
|
+
return {
|
|
3615
|
+
error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
|
|
3616
|
+
: undecided !== null ? this.notCheckedYet(path, undecided)
|
|
3617
|
+
: this.notInWorkspace(path, report, mount),
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3620
|
+
const contents = await this.contentsOf(mount, relativePath, path, entry);
|
|
3621
|
+
if ('lost' in contents)
|
|
3622
|
+
return { error: nothingToRead(contents.lost) };
|
|
3623
|
+
return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3409
3624
|
});
|
|
3410
3625
|
}
|
|
3411
3626
|
/**
|
|
@@ -3418,152 +3633,203 @@ export class WorkspaceModule {
|
|
|
3418
3633
|
*/
|
|
3419
3634
|
async bytesOnDisk(mount, relativePath, path) {
|
|
3420
3635
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3421
|
-
const over = `${path} is
|
|
3636
|
+
const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
|
|
3422
3637
|
let opened;
|
|
3423
3638
|
try {
|
|
3424
3639
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3425
3640
|
if (fileStat.size > maxSize)
|
|
3426
|
-
throw new
|
|
3641
|
+
throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
3427
3642
|
});
|
|
3428
3643
|
}
|
|
3429
3644
|
catch (err) {
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3645
|
+
if (err instanceof Refusal)
|
|
3646
|
+
return { error: err.message };
|
|
3647
|
+
if (err instanceof WorkspaceReadError)
|
|
3648
|
+
return { error: readRefusal(err, path) };
|
|
3649
|
+
throw err;
|
|
3434
3650
|
}
|
|
3435
3651
|
try {
|
|
3436
3652
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
3437
3653
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
3438
3654
|
if (more)
|
|
3439
|
-
return { error: over };
|
|
3655
|
+
return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
3440
3656
|
if (looksBinary(bytes)) {
|
|
3441
|
-
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '
|
|
3657
|
+
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.'}` };
|
|
3442
3658
|
}
|
|
3443
3659
|
return { bytes };
|
|
3444
3660
|
}
|
|
3445
3661
|
catch (err) {
|
|
3446
|
-
|
|
3447
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
3662
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
3448
3663
|
}
|
|
3449
3664
|
finally {
|
|
3450
3665
|
await opened.handle.close();
|
|
3451
3666
|
}
|
|
3452
3667
|
}
|
|
3668
|
+
async contentsOf(mount, relativePath, path, version, what = 'saved', tell = 'now') {
|
|
3669
|
+
const store = this.getStore();
|
|
3670
|
+
const stored = storedBytes(store, version.blobHash);
|
|
3671
|
+
if ('bytes' in stored)
|
|
3672
|
+
return stored;
|
|
3673
|
+
const otherSize = new Error('other size');
|
|
3674
|
+
let found;
|
|
3675
|
+
try {
|
|
3676
|
+
const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3677
|
+
if (fileStat.size !== version.size)
|
|
3678
|
+
throw otherSize;
|
|
3679
|
+
});
|
|
3680
|
+
try {
|
|
3681
|
+
const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
|
|
3682
|
+
found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
|
|
3683
|
+
}
|
|
3684
|
+
finally {
|
|
3685
|
+
await opened.handle.close();
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
catch (err) {
|
|
3689
|
+
found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
|
|
3690
|
+
}
|
|
3691
|
+
const subject = contentsSubject(what, path);
|
|
3692
|
+
const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
|
|
3693
|
+
if ('disk' in found) {
|
|
3694
|
+
const now = storedBytes(store, version.blobHash);
|
|
3695
|
+
if ('bytes' in now)
|
|
3696
|
+
return now;
|
|
3697
|
+
const lost = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
|
|
3698
|
+
if (tell === 'now')
|
|
3699
|
+
this.tellLost(mount.config.name, what, version.blobHash, lost);
|
|
3700
|
+
return { lost };
|
|
3701
|
+
}
|
|
3702
|
+
const { bytes } = found;
|
|
3703
|
+
if ('bytes' in storedBytes(store, version.blobHash))
|
|
3704
|
+
return { bytes };
|
|
3705
|
+
let thrown = null;
|
|
3706
|
+
try {
|
|
3707
|
+
store.storeBlob(bytes, 'application/octet-stream');
|
|
3708
|
+
}
|
|
3709
|
+
catch (error) {
|
|
3710
|
+
thrown = error instanceof Error ? error.message : String(error);
|
|
3711
|
+
}
|
|
3712
|
+
// Chronicle's storeBlob takes any file already at the hash's path as
|
|
3713
|
+
// stored, so whether the bytes took is what a read of them gives now.
|
|
3714
|
+
const now = storedBytes(store, version.blobHash);
|
|
3715
|
+
if ('bytes' in now) {
|
|
3716
|
+
this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
|
|
3717
|
+
}
|
|
3718
|
+
else {
|
|
3719
|
+
const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
|
|
3720
|
+
this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', `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}. `
|
|
3721
|
+
+ ('unreadable' in now
|
|
3722
|
+
? "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."
|
|
3723
|
+
: 'Each read does this again until the store takes them.'), { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
|
|
3724
|
+
}
|
|
3725
|
+
return { bytes };
|
|
3726
|
+
}
|
|
3727
|
+
tellLost(mountName, what, blobHash, lost) {
|
|
3728
|
+
this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', `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)}. `
|
|
3729
|
+
+ "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
|
|
3730
|
+
}
|
|
3731
|
+
async diskLacks(mount, relativePath, path, err) {
|
|
3732
|
+
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
3733
|
+
if (!absent)
|
|
3734
|
+
return { unreadable: containedRefusal(err, path) };
|
|
3735
|
+
const root = resolve(mount.config.path);
|
|
3736
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
3737
|
+
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
3738
|
+
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
3739
|
+
if (typeof proof !== 'string')
|
|
3740
|
+
return 'none';
|
|
3741
|
+
return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
|
|
3742
|
+
}
|
|
3743
|
+
async restoreEach(mount, versions, each) {
|
|
3744
|
+
const byHash = new Map();
|
|
3745
|
+
for (const version of versions) {
|
|
3746
|
+
const sharing = byHash.get(version.hash);
|
|
3747
|
+
if (sharing)
|
|
3748
|
+
sharing.push(version);
|
|
3749
|
+
else
|
|
3750
|
+
byHash.set(version.hash, [version]);
|
|
3751
|
+
}
|
|
3752
|
+
for (const [hash, sharing] of byHash) {
|
|
3753
|
+
let bytes = null;
|
|
3754
|
+
const lost = [];
|
|
3755
|
+
for (const version of sharing) {
|
|
3756
|
+
const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
|
|
3757
|
+
if ('bytes' in contents) {
|
|
3758
|
+
bytes = contents.bytes;
|
|
3759
|
+
break;
|
|
3760
|
+
}
|
|
3761
|
+
lost.push([version, contents.lost]);
|
|
3762
|
+
}
|
|
3763
|
+
if (bytes === null) {
|
|
3764
|
+
for (const [version, found] of lost) {
|
|
3765
|
+
this.tellLost(mount.config.name, version.what, hash, found);
|
|
3766
|
+
each(version, { lost: found });
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
else {
|
|
3770
|
+
for (const version of sharing)
|
|
3771
|
+
each(version, { bytes });
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3453
3775
|
// ==========================================================================
|
|
3454
3776
|
// Tool Handlers
|
|
3455
3777
|
// ==========================================================================
|
|
3456
|
-
|
|
3778
|
+
/**
|
|
3779
|
+
* A read's page, held to `maxChars`, the most of this call's result that
|
|
3780
|
+
* can reach its caller whole. A call that carries none, made in process,
|
|
3781
|
+
* takes the data itself: its page holds to its line limit alone, with every
|
|
3782
|
+
* line whole, as it did before pages had a budget.
|
|
3783
|
+
*/
|
|
3784
|
+
async handleRead(input, maxChars, generatedContent) {
|
|
3457
3785
|
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
3458
|
-
const offsetChars = input.offsetChars ?? 0;
|
|
3459
|
-
const limitChars = input.limitChars ?? 2000;
|
|
3460
3786
|
// Each number is a whole number of at least its schema's minimum by now
|
|
3461
3787
|
// (inputProblem).
|
|
3462
3788
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
3463
|
-
return { success: false, isError: true, error: '
|
|
3789
|
+
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.') };
|
|
3464
3790
|
}
|
|
3465
|
-
let
|
|
3466
|
-
|
|
3791
|
+
let file;
|
|
3792
|
+
let notes = [];
|
|
3793
|
+
if (generatedContent === undefined) {
|
|
3467
3794
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3795
|
+
if (relativePath === '')
|
|
3796
|
+
return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3468
3797
|
input = { ...input, path };
|
|
3469
3798
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
3470
3799
|
if ('error' in found)
|
|
3471
|
-
return { success: false, error: found.error, isError: true };
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
if (characterPaging) {
|
|
3475
|
-
const start = Math.min(offsetChars, content.length);
|
|
3476
|
-
const splitsPair = (at) => at > 0 && at < content.length
|
|
3477
|
-
&& content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
|
|
3478
|
-
&& content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
|
|
3479
|
-
if (splitsPair(start)) {
|
|
3480
|
-
return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
|
|
3481
|
-
}
|
|
3482
|
-
let end = start + Math.min(limitChars, content.length - start);
|
|
3483
|
-
if (splitsPair(end))
|
|
3484
|
-
end++;
|
|
3485
|
-
return {
|
|
3486
|
-
success: true,
|
|
3487
|
-
data: {
|
|
3488
|
-
path: input.path,
|
|
3489
|
-
totalChars: content.length,
|
|
3490
|
-
offsetChars: start,
|
|
3491
|
-
nextOffsetChars: end < content.length ? end : null,
|
|
3492
|
-
content: content.slice(start, end),
|
|
3493
|
-
},
|
|
3494
|
-
};
|
|
3800
|
+
return { success: false, error: didNothing('read', found.error), isError: true };
|
|
3801
|
+
file = fileText(found.bytes);
|
|
3802
|
+
notes = found.notes;
|
|
3495
3803
|
}
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
// whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
|
|
3499
|
-
// past the model context and 400s), so an omitted limit falls back to a
|
|
3500
|
-
// default cap; the result reports total/from/to so the agent can page.
|
|
3501
|
-
const DEFAULT_READ_LINE_LIMIT = 2000;
|
|
3502
|
-
const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
|
|
3503
|
-
if (startLine >= lines.length) {
|
|
3504
|
-
// Nothing to show: an empty file, or an offset past the last line. No
|
|
3505
|
-
// range is claimed, and the line count says where the file ends.
|
|
3506
|
-
return {
|
|
3507
|
-
success: true,
|
|
3508
|
-
data: {
|
|
3509
|
-
path: input.path,
|
|
3510
|
-
totalLines: lines.length,
|
|
3511
|
-
// Reading an empty file from its start is reading it whole.
|
|
3512
|
-
...(startLine > 0
|
|
3513
|
-
? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
|
|
3514
|
-
: {}),
|
|
3515
|
-
content: '',
|
|
3516
|
-
},
|
|
3517
|
-
};
|
|
3804
|
+
else {
|
|
3805
|
+
file = fileText(Buffer.from(generatedContent, 'utf-8'));
|
|
3518
3806
|
}
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
.
|
|
3526
|
-
.join('\n');
|
|
3527
|
-
return {
|
|
3528
|
-
success: true,
|
|
3529
|
-
data: {
|
|
3530
|
-
path: input.path,
|
|
3531
|
-
totalLines: lines.length,
|
|
3532
|
-
fromLine: startLine + 1,
|
|
3533
|
-
toLine: Math.min(endLine, lines.length),
|
|
3534
|
-
...(truncated
|
|
3535
|
-
? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
|
|
3536
|
-
: {}),
|
|
3537
|
-
content: formatted,
|
|
3538
|
-
},
|
|
3539
|
-
};
|
|
3807
|
+
// An unlimited read of a large file would inject the whole thing into the
|
|
3808
|
+
// turn (a 2MB file ≈ 600k tokens → the request blows past the model
|
|
3809
|
+
// context and 400s), so an omitted limit falls back to a default, and a
|
|
3810
|
+
// page stops at the call's result limit where it has one.
|
|
3811
|
+
return characterPaging
|
|
3812
|
+
? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
|
|
3813
|
+
: linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
|
|
3540
3814
|
}
|
|
3541
3815
|
async handleReadImage(input) {
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
(
|
|
3547
|
-
|
|
3548
|
-
}
|
|
3549
|
-
catch (err) {
|
|
3550
|
-
return {
|
|
3551
|
-
success: false,
|
|
3552
|
-
error: err instanceof Error ? err.message : String(err),
|
|
3553
|
-
isError: true,
|
|
3554
|
-
};
|
|
3555
|
-
}
|
|
3556
|
-
if (!relativePath) {
|
|
3557
|
-
return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
|
|
3558
|
-
}
|
|
3816
|
+
const given = input.path;
|
|
3817
|
+
const refused = (body) => ({ success: false, isError: true, error: didNothing('read_image', body) });
|
|
3818
|
+
const { mount, relativePath, path } = await this.parseFilePath(given);
|
|
3819
|
+
if (!relativePath)
|
|
3820
|
+
return refused(namesRoot(given, mount.config.name));
|
|
3821
|
+
input = { ...input, path };
|
|
3559
3822
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3560
3823
|
let treeError = null;
|
|
3824
|
+
let lost = null;
|
|
3561
3825
|
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
3562
3826
|
// mount holds none, so its image is disk's.
|
|
3563
3827
|
if (!mount.direct) {
|
|
3564
3828
|
try {
|
|
3565
|
-
const image = this.
|
|
3566
|
-
if (image)
|
|
3829
|
+
const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
|
|
3830
|
+
if (image && 'lost' in image)
|
|
3831
|
+
lost = image.lost;
|
|
3832
|
+
else if (image)
|
|
3567
3833
|
return imageResult(input.path, image);
|
|
3568
3834
|
}
|
|
3569
3835
|
catch (err) {
|
|
@@ -3576,39 +3842,56 @@ export class WorkspaceModule {
|
|
|
3576
3842
|
}
|
|
3577
3843
|
}
|
|
3578
3844
|
try {
|
|
3579
|
-
|
|
3845
|
+
const note = lost === null ? undefined
|
|
3846
|
+
: `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
|
|
3847
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
|
|
3580
3848
|
}
|
|
3581
3849
|
catch (err) {
|
|
3582
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3850
|
+
if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError))
|
|
3851
|
+
throw err;
|
|
3852
|
+
const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
|
|
3853
|
+
if (lost) {
|
|
3854
|
+
const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
|
|
3855
|
+
return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
|
|
3856
|
+
: `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
|
|
3587
3857
|
}
|
|
3588
|
-
|
|
3858
|
+
if (noFile && treeError)
|
|
3859
|
+
return refused(treeError.message);
|
|
3860
|
+
return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
|
|
3589
3861
|
}
|
|
3590
3862
|
}
|
|
3591
|
-
async handleWrite(input) {
|
|
3863
|
+
async handleWrite(input, callerAgentName) {
|
|
3592
3864
|
if (LONE_SURROGATE.test(input.content)) {
|
|
3593
|
-
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
3865
|
+
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.' };
|
|
3594
3866
|
}
|
|
3595
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3867
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
|
|
3868
|
+
if (relativePath === '')
|
|
3869
|
+
return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3596
3870
|
input = { ...input, path };
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3871
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
3872
|
+
if (refusal)
|
|
3873
|
+
return { success: false, error: didNothing('write', refusal), isError: true };
|
|
3600
3874
|
const store = this.getStore();
|
|
3601
3875
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3602
|
-
|
|
3603
|
-
|
|
3876
|
+
const bytes = Buffer.byteLength(input.content);
|
|
3877
|
+
if (bytes > maxSize) {
|
|
3878
|
+
return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
|
|
3604
3879
|
}
|
|
3605
3880
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
3606
3881
|
const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
|
|
3882
|
+
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
|
|
3883
|
+
if (mount.direct) {
|
|
3884
|
+
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
3885
|
+
if ('error' in written)
|
|
3886
|
+
return { success: false, isError: true, error: written.error };
|
|
3887
|
+
return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
3888
|
+
}
|
|
3607
3889
|
let committed = false;
|
|
3608
3890
|
let refused;
|
|
3609
3891
|
let modeKept;
|
|
3892
|
+
let disk;
|
|
3610
3893
|
try {
|
|
3611
|
-
({ refused, modeKept } = await this.withMount(mount, async () => {
|
|
3894
|
+
({ refused, modeKept, disk } = await this.withMount(mount, async () => {
|
|
3612
3895
|
await this.settleBeforeMutation(mount, relativePath);
|
|
3613
3896
|
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
3614
3897
|
// An overwrite keeps the file's mode, as an edit does.
|
|
@@ -3618,22 +3901,22 @@ export class WorkspaceModule {
|
|
|
3618
3901
|
}
|
|
3619
3902
|
catch (error) {
|
|
3620
3903
|
if (!committed)
|
|
3621
|
-
throw error;
|
|
3904
|
+
throw notStored(error);
|
|
3622
3905
|
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
3623
3906
|
? { storeFailed: error.message, pushes: error.pushes }
|
|
3624
|
-
: { threw: error instanceof Error ? error.message : String(error) });
|
|
3907
|
+
: { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
3625
3908
|
}
|
|
3626
3909
|
if (refused !== null)
|
|
3627
3910
|
return storedButUnfinished('write', input.path, { refused });
|
|
3628
|
-
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
3911
|
+
return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
|
|
3629
3912
|
}
|
|
3630
|
-
async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS) {
|
|
3913
|
+
async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
|
|
3631
3914
|
return this.applyEdits('edit', [{
|
|
3632
3915
|
path: input.path,
|
|
3633
3916
|
oldString: input.oldString,
|
|
3634
3917
|
newString: input.newString,
|
|
3635
3918
|
expected: input.replaceAll ? 'all' : 1,
|
|
3636
|
-
}], maxChars);
|
|
3919
|
+
}], maxChars, callerAgentName);
|
|
3637
3920
|
}
|
|
3638
3921
|
async handleEditMany(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
|
|
3639
3922
|
if (input.edits.length === 0) {
|
|
@@ -3665,12 +3948,14 @@ export class WorkspaceModule {
|
|
|
3665
3948
|
* stored where its record landed, and that it can't tell where they
|
|
3666
3949
|
* couldn't be read back.
|
|
3667
3950
|
* Under the mount's turn, one pass settles every path with disk, by the
|
|
3668
|
-
* rule a listing uses, so the edits apply to what disk now holds.
|
|
3669
|
-
*
|
|
3670
|
-
*
|
|
3671
|
-
*
|
|
3672
|
-
*
|
|
3673
|
-
* of the
|
|
3951
|
+
* rule a listing uses, so the edits apply to what disk now holds. Each
|
|
3952
|
+
* file's stored contents are then read; where the store has lost some,
|
|
3953
|
+
* they're restored from disk's matching copy (contentsOf) and the pass runs
|
|
3954
|
+
* again, so the plan reads a pass taken after every await. From there
|
|
3955
|
+
* nothing awaits before the commit: every edit is planned and every entry
|
|
3956
|
+
* set (commitToolChanges) with no turn of the event loop between, so no
|
|
3957
|
+
* pass, branch change or other call moves the tree between a file's read
|
|
3958
|
+
* and its set. Disk then takes what it can of the stored change.
|
|
3674
3959
|
*
|
|
3675
3960
|
* The answer is held to `maxChars`, the length the call's result may have
|
|
3676
3961
|
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
@@ -3705,7 +3990,7 @@ export class WorkspaceModule {
|
|
|
3705
3990
|
}
|
|
3706
3991
|
for (const field of ['oldString', 'newString']) {
|
|
3707
3992
|
if (LONE_SURROGATE.test(edit[field])) {
|
|
3708
|
-
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
|
|
3993
|
+
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.` });
|
|
3709
3994
|
}
|
|
3710
3995
|
}
|
|
3711
3996
|
if (problems.length === found)
|
|
@@ -3750,6 +4035,10 @@ export class WorkspaceModule {
|
|
|
3750
4035
|
return refuse(problems);
|
|
3751
4036
|
if (!mount || mounts.size > 1)
|
|
3752
4037
|
return refuse(problems);
|
|
4038
|
+
// A direct mount's edits apply to disk's text as it is now, and land on
|
|
4039
|
+
// disk or don't happen (applyDirectEdits).
|
|
4040
|
+
if (mount.direct)
|
|
4041
|
+
return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
|
|
3753
4042
|
const store = this.getStore();
|
|
3754
4043
|
// Whether the commit set the call's entries, all of them at once.
|
|
3755
4044
|
let committed = false;
|
|
@@ -3763,7 +4052,31 @@ export class WorkspaceModule {
|
|
|
3763
4052
|
return await this.withMount(mount, async () => {
|
|
3764
4053
|
// As every file tool's pass does, it names the paths it acts on, so a
|
|
3765
4054
|
// file the mount passes over by design is observed (see PassOptions.named).
|
|
3766
|
-
|
|
4055
|
+
let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4056
|
+
let ops = pass.ops;
|
|
4057
|
+
const contents = new Map();
|
|
4058
|
+
const lost = new Map();
|
|
4059
|
+
const toRecover = [];
|
|
4060
|
+
for (const target of targets.values()) {
|
|
4061
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4062
|
+
if (!entry || contents.has(entry.blobHash))
|
|
4063
|
+
continue;
|
|
4064
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
4065
|
+
if ('bytes' in stored)
|
|
4066
|
+
contents.set(entry.blobHash, stored.bytes);
|
|
4067
|
+
else
|
|
4068
|
+
toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
|
|
4069
|
+
}
|
|
4070
|
+
if (toRecover.length > 0) {
|
|
4071
|
+
await this.restoreEach(mount, toRecover, (version, found) => {
|
|
4072
|
+
if ('bytes' in found)
|
|
4073
|
+
contents.set(version.hash, found.bytes);
|
|
4074
|
+
else
|
|
4075
|
+
lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
|
|
4076
|
+
});
|
|
4077
|
+
pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4078
|
+
ops = [...ops, ...pass.ops];
|
|
4079
|
+
}
|
|
3767
4080
|
// A pass that decided nothing checked no file's disk copy.
|
|
3768
4081
|
const undecided = undecidedOf(pass);
|
|
3769
4082
|
// Nothing below awaits until the commit's push.
|
|
@@ -3783,7 +4096,7 @@ export class WorkspaceModule {
|
|
|
3783
4096
|
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
3784
4097
|
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
3785
4098
|
// then write at the mode disk has, so it leaves nothing to say.
|
|
3786
|
-
const took = (op) =>
|
|
4099
|
+
const took = (op) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
3787
4100
|
// Which copy the edits used, when it isn't the one the workspace
|
|
3788
4101
|
// last agreed on with disk: disk's newer text, or the workspace's
|
|
3789
4102
|
// copy because disk's couldn't be read or disk has none. A file the
|
|
@@ -3847,61 +4160,33 @@ export class WorkspaceModule {
|
|
|
3847
4160
|
// A pass also drops a copy disk made binary or too large, which
|
|
3848
4161
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3849
4162
|
const text = took('deleted') && report?.state !== 'disk-only'
|
|
3850
|
-
?
|
|
3851
|
-
: this.notInWorkspace(target.path, report);
|
|
3852
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole:
|
|
3853
|
-
continue;
|
|
3854
|
-
}
|
|
3855
|
-
notes.push(...lookedIn);
|
|
3856
|
-
const blob = store.getBlob(entry.blobHash);
|
|
3857
|
-
if (!blob) {
|
|
3858
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
|
|
4163
|
+
? deletedOnDisk(target.path)
|
|
4164
|
+
: this.notInWorkspace(target.path, report, mount);
|
|
4165
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
|
|
3859
4166
|
continue;
|
|
3860
4167
|
}
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
4168
|
+
const known = contents.get(entry.blobHash);
|
|
4169
|
+
const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
|
|
4170
|
+
if (!('bytes' in stored)) {
|
|
4171
|
+
const found = lost.get(target.relativePath);
|
|
3865
4172
|
problems.push({
|
|
3866
4173
|
kind: 'file',
|
|
3867
4174
|
edits: target.edits,
|
|
3868
4175
|
path: target.path,
|
|
3869
|
-
text:
|
|
3870
|
-
|
|
3871
|
-
|
|
4176
|
+
text: found?.blobHash === entry.blobHash
|
|
4177
|
+
? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
|
|
4178
|
+
: `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.`,
|
|
4179
|
+
whole: true,
|
|
3872
4180
|
});
|
|
3873
4181
|
continue;
|
|
3874
4182
|
}
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
if ('problems' in planned) {
|
|
3879
|
-
// One at a time: a call of many edits can have more problems
|
|
3880
|
-
// than a spread passes as arguments.
|
|
3881
|
-
for (const problem of planned.problems)
|
|
3882
|
-
problems.push({ ...problem, path: target.path });
|
|
3883
|
-
continue;
|
|
3884
|
-
}
|
|
3885
|
-
// The planner refuses a file its counts take past the limit before
|
|
3886
|
-
// building it, from the least its bytes can be; one it built past
|
|
3887
|
-
// the limit, as its CRs or multi-byte characters can take it, is
|
|
3888
|
-
// refused here.
|
|
3889
|
-
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
3890
|
-
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
3891
|
-
problems.push({
|
|
3892
|
-
kind: 'file',
|
|
3893
|
-
edits: target.edits,
|
|
3894
|
-
path: target.path,
|
|
3895
|
-
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
|
|
3896
|
-
+ `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
|
|
3897
|
-
+ "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
|
|
3898
|
-
whole: false,
|
|
3899
|
-
});
|
|
4183
|
+
notes.push(...lookedIn);
|
|
4184
|
+
const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
|
|
4185
|
+
if (plan === null)
|
|
3900
4186
|
continue;
|
|
3901
|
-
}
|
|
3902
4187
|
plans.push({
|
|
3903
4188
|
target,
|
|
3904
|
-
plan
|
|
4189
|
+
plan,
|
|
3905
4190
|
mode: entry.mode,
|
|
3906
4191
|
backOnDisk,
|
|
3907
4192
|
conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
|
|
@@ -3912,21 +4197,18 @@ export class WorkspaceModule {
|
|
|
3912
4197
|
}
|
|
3913
4198
|
if (problems.length)
|
|
3914
4199
|
return refuse(problems, notes);
|
|
3915
|
-
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4200
|
+
const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
3916
4201
|
path: target.relativePath,
|
|
3917
4202
|
change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
3918
4203
|
})), () => { committed = true; });
|
|
3919
4204
|
if (refused.size > 0)
|
|
3920
4205
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
|
|
3921
|
-
return {
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
3928
|
-
})), given.length, maxChars),
|
|
3929
|
-
};
|
|
4206
|
+
return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
|
|
4207
|
+
path: target.path,
|
|
4208
|
+
plan,
|
|
4209
|
+
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
|
|
4210
|
+
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4211
|
+
})), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath))));
|
|
3930
4212
|
});
|
|
3931
4213
|
}
|
|
3932
4214
|
catch (error) {
|
|
@@ -3934,12 +4216,12 @@ export class WorkspaceModule {
|
|
|
3934
4216
|
if (committed) {
|
|
3935
4217
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
3936
4218
|
? { storeFailed: message, pushes: error.pushes }
|
|
3937
|
-
: { threw: message }, maxChars);
|
|
4219
|
+
: { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
|
|
3938
4220
|
}
|
|
3939
4221
|
// A set that threw and couldn't be read back may have stored the edits,
|
|
3940
4222
|
// and a second call could then apply them twice.
|
|
3941
4223
|
if (error instanceof CommitUnknownError) {
|
|
3942
|
-
const [set, read] = [
|
|
4224
|
+
const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
|
|
3943
4225
|
return {
|
|
3944
4226
|
success: false,
|
|
3945
4227
|
isError: true,
|
|
@@ -3952,10 +4234,8 @@ export class WorkspaceModule {
|
|
|
3952
4234
|
}
|
|
3953
4235
|
// The commit sets every entry in one record or none, and a set that
|
|
3954
4236
|
// threw was read back, so nothing is stored; or every change was one
|
|
3955
|
-
// the tree already held
|
|
3956
|
-
|
|
3957
|
-
return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
|
|
3958
|
-
throw error;
|
|
4237
|
+
// the tree already held (AlreadyHeld).
|
|
4238
|
+
throw notStored(error);
|
|
3959
4239
|
}
|
|
3960
4240
|
}
|
|
3961
4241
|
/**
|
|
@@ -3968,7 +4248,7 @@ export class WorkspaceModule {
|
|
|
3968
4248
|
* (handleToolCall checks a call's `path`, which edit_many's input lacks).
|
|
3969
4249
|
*/
|
|
3970
4250
|
async editablePath(tool, path, callerAgentName) {
|
|
3971
|
-
const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole:
|
|
4251
|
+
const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: false });
|
|
3972
4252
|
try {
|
|
3973
4253
|
// In the one spelling every file tool reads, as disk spells it, so two
|
|
3974
4254
|
// spellings of a file are one entry and, in one call, one plan
|
|
@@ -3978,16 +4258,21 @@ export class WorkspaceModule {
|
|
|
3978
4258
|
// One that names the mount's root, its bare name included, names no
|
|
3979
4259
|
// file.
|
|
3980
4260
|
if (where.relativePath === '')
|
|
3981
|
-
return unusable(
|
|
3982
|
-
|
|
3983
|
-
|
|
4261
|
+
return unusable(namesRoot(path, where.mount.config.name));
|
|
4262
|
+
const refusal = writeRefusal(where.mount, this.writableNames());
|
|
4263
|
+
if (refusal)
|
|
4264
|
+
return unusable(refusal);
|
|
3984
4265
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
3985
4266
|
if (generated)
|
|
3986
|
-
return unusable(
|
|
4267
|
+
return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
|
|
3987
4268
|
return where;
|
|
3988
4269
|
}
|
|
3989
4270
|
catch (error) {
|
|
3990
|
-
|
|
4271
|
+
if (error instanceof Refusal)
|
|
4272
|
+
return unusable(error.message);
|
|
4273
|
+
if (error instanceof WorkspaceReadError)
|
|
4274
|
+
return unusable(readRefusal(error, path));
|
|
4275
|
+
throw notStored(error);
|
|
3991
4276
|
}
|
|
3992
4277
|
}
|
|
3993
4278
|
/**
|
|
@@ -3995,10 +4280,10 @@ export class WorkspaceModule {
|
|
|
3995
4280
|
* and the agent's own is read-only, since it shows the agent's tool
|
|
3996
4281
|
* presentation, which set_tool_visibility and set_tool_description change.
|
|
3997
4282
|
*/
|
|
3998
|
-
generatedFileRefusal(generated, callerAgentName) {
|
|
4283
|
+
generatedFileRefusal(path, generated, callerAgentName) {
|
|
3999
4284
|
return callerAgentName !== generated.agentName
|
|
4000
|
-
?
|
|
4001
|
-
:
|
|
4285
|
+
? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
|
|
4286
|
+
: `${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.`;
|
|
4002
4287
|
}
|
|
4003
4288
|
/**
|
|
4004
4289
|
* The answer to edits stored but not finished: the commit set every file's
|
|
@@ -4014,12 +4299,12 @@ export class WorkspaceModule {
|
|
|
4014
4299
|
* which stand as on a success, it names each conflict the success answer
|
|
4015
4300
|
* would (`conflicts`, found only where the mount doesn't push).
|
|
4016
4301
|
*/
|
|
4017
|
-
editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
|
|
4302
|
+
editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars, reopen = false) {
|
|
4018
4303
|
const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4019
4304
|
if (tool === 'edit') {
|
|
4020
4305
|
const [target] = targets;
|
|
4021
4306
|
const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
|
|
4022
|
-
return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
|
|
4307
|
+
return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null, reopen);
|
|
4023
4308
|
}
|
|
4024
4309
|
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.";
|
|
4025
4310
|
// The head is never cut, so what stands for the whole call goes in it,
|
|
@@ -4042,10 +4327,11 @@ export class WorkspaceModule {
|
|
|
4042
4327
|
}
|
|
4043
4328
|
else if ('storeFailed' in outcome) {
|
|
4044
4329
|
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4045
|
-
+ (outcome.pushes ?
|
|
4330
|
+
+ (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
4331
|
+
+ (reopen ? ` ${AWAITS_REOPEN}` : '');
|
|
4046
4332
|
}
|
|
4047
4333
|
else {
|
|
4048
|
-
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}
|
|
4334
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
|
|
4049
4335
|
}
|
|
4050
4336
|
const kept = 'modeKept' in outcome
|
|
4051
4337
|
? targets.flatMap((target) => {
|
|
@@ -4059,48 +4345,559 @@ export class WorkspaceModule {
|
|
|
4059
4345
|
];
|
|
4060
4346
|
return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
|
|
4061
4347
|
}
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4348
|
+
// ==========================================================================
|
|
4349
|
+
// Direct writes
|
|
4350
|
+
// ==========================================================================
|
|
4351
|
+
/**
|
|
4352
|
+
* A direct mount's write of a whole file: its bytes staged beside it and
|
|
4353
|
+
* renamed over it (direct-write.ts), under the mount's turn, so the
|
|
4354
|
+
* module's own calls take turns. Answers a note when the file had other
|
|
4355
|
+
* hard links, which keep its old text, or why nothing was written. One
|
|
4356
|
+
* that lands is the caller's latest write on the mount (recordDirectWrite).
|
|
4357
|
+
* With `branch`, nothing is written once the store's branch is another,
|
|
4358
|
+
* checked with no turn of the event loop before the rename (writeBinary's).
|
|
4359
|
+
*/
|
|
4360
|
+
async writeDirect(mount, relativePath, path, bytes, agentName, branch) {
|
|
4361
|
+
return this.withMount(mount, async () => {
|
|
4362
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4363
|
+
let staged;
|
|
4364
|
+
try {
|
|
4365
|
+
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4366
|
+
}
|
|
4367
|
+
catch (error) {
|
|
4368
|
+
return { error: directDidNothing('write', path, error) };
|
|
4369
|
+
}
|
|
4370
|
+
try {
|
|
4371
|
+
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
4372
|
+
await placedStill(staged);
|
|
4373
|
+
// A write reads nothing first, so it replaces whatever is there.
|
|
4374
|
+
checkUnchanged(staged, null);
|
|
4375
|
+
if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
|
|
4376
|
+
await discard(staged);
|
|
4377
|
+
return { error: BRANCH_LEFT, branchLeft: true };
|
|
4078
4378
|
}
|
|
4079
|
-
|
|
4080
|
-
if (refused !== null)
|
|
4081
|
-
return storedButUnfinished('delete', input.path, { refused });
|
|
4082
|
-
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
4083
|
-
// withholds the result (agent-framework #277).
|
|
4084
|
-
return {
|
|
4085
|
-
success: true,
|
|
4086
|
-
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
4087
|
-
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
4088
|
-
};
|
|
4089
|
-
});
|
|
4090
|
-
}
|
|
4091
|
-
catch (error) {
|
|
4092
|
-
if (!committed)
|
|
4093
|
-
throw error;
|
|
4094
|
-
if (error instanceof StoredThenFailed) {
|
|
4095
|
-
// Its note, as the success answer's: a deletion that cleared a
|
|
4096
|
-
// conflict naming a disk file unlinked nothing.
|
|
4097
|
-
const note = error.notes.get(relativePath);
|
|
4098
|
-
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
|
|
4379
|
+
renameInto(staged);
|
|
4099
4380
|
}
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4381
|
+
catch (error) {
|
|
4382
|
+
await discard(staged);
|
|
4383
|
+
return { error: directDidNothing('write', path, error) };
|
|
4384
|
+
}
|
|
4385
|
+
this.recordDirectWrite(mount, agentName);
|
|
4386
|
+
return { note: linksNote(path, staged, 'write') };
|
|
4387
|
+
});
|
|
4388
|
+
}
|
|
4389
|
+
/**
|
|
4390
|
+
* applyEdits on a direct mount, under the mount's turn, whose edits land
|
|
4391
|
+
* on disk or don't happen. Each attempt reads every file from disk as it
|
|
4392
|
+
* is now (directEditText), plans every edit against those bytes as on any
|
|
4393
|
+
* mount (planTargetEdits), stages every file beside itself, checks every
|
|
4394
|
+
* one unchanged since its read, and then renames each over its file in
|
|
4395
|
+
* turn (direct-write.ts). A file another writer changed between its read
|
|
4396
|
+
* and its check has the call read and planned again against its new
|
|
4397
|
+
* text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
|
|
4398
|
+
* longer apply to it, a file that changed at every attempt, or a file the
|
|
4399
|
+
* next read refuses as any read would (now past the size limit, say) are
|
|
4400
|
+
* refused, with nothing written. A rename that fails after others leaves
|
|
4401
|
+
* those written and the rest as they were, and the answer names each
|
|
4402
|
+
* file's outcome; nothing puts the written ones back.
|
|
4403
|
+
*/
|
|
4404
|
+
async applyDirectEdits(tool, mount, targets, early, editCount, maxChars, agentName) {
|
|
4405
|
+
const changedMeanwhile = new Set();
|
|
4406
|
+
return this.withMount(mount, async () => {
|
|
4407
|
+
for (let attempt = 1;; attempt++) {
|
|
4408
|
+
const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
|
|
4409
|
+
if (outcome !== 'changed')
|
|
4410
|
+
return outcome;
|
|
4411
|
+
if (attempt === DIRECT_EDIT_ATTEMPTS) {
|
|
4412
|
+
const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
|
|
4413
|
+
// Each attempt stops at the first file it finds changed, so these are
|
|
4414
|
+
// the files some attempt found changed, at most one per attempt.
|
|
4415
|
+
return {
|
|
4416
|
+
success: false,
|
|
4417
|
+
isError: true,
|
|
4418
|
+
error: tool === 'edit'
|
|
4419
|
+
? `${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.`
|
|
4420
|
+
: `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
|
|
4421
|
+
+ `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
});
|
|
4426
|
+
}
|
|
4427
|
+
/**
|
|
4428
|
+
* One attempt of applyDirectEdits: its answer, or 'changed' when another
|
|
4429
|
+
* writer changed a file since its read, with nothing written. `early` are
|
|
4430
|
+
* the problems found before any file was read, which a refusal names with
|
|
4431
|
+
* the rest. The caller holds the mount's turn.
|
|
4432
|
+
*/
|
|
4433
|
+
async attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName) {
|
|
4434
|
+
const problems = [...early];
|
|
4435
|
+
const read = new Map();
|
|
4436
|
+
for (const target of targets) {
|
|
4437
|
+
const got = await this.directEditText(mount, target.relativePath, target.path);
|
|
4438
|
+
if ('changed' in got) {
|
|
4439
|
+
changedMeanwhile.add(target);
|
|
4440
|
+
return 'changed';
|
|
4441
|
+
}
|
|
4442
|
+
if ('error' in got)
|
|
4443
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
|
|
4444
|
+
else
|
|
4445
|
+
read.set(target, got);
|
|
4446
|
+
}
|
|
4447
|
+
// Two paths that reach one file, through a link this mount follows, or
|
|
4448
|
+
// as two hard links (a disk that folds case has its two spellings made
|
|
4449
|
+
// one path by the parse, short of a directory link on the way, past
|
|
4450
|
+
// which they're refused here as one file named twice): each
|
|
4451
|
+
// would be planned against the same text and renamed over the same file
|
|
4452
|
+
// or split from it, so one's edits would be lost or kept apart from the
|
|
4453
|
+
// other's. They're refused. One file read twice has one identity here
|
|
4454
|
+
// unless a writer changed it between the reads, and the check before the
|
|
4455
|
+
// renames then sees that and reads again.
|
|
4456
|
+
const byIdentity = new Map();
|
|
4457
|
+
for (const [target, { identity }] of read) {
|
|
4458
|
+
const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
|
|
4459
|
+
const other = byIdentity.get(key);
|
|
4460
|
+
if (!other) {
|
|
4461
|
+
byIdentity.set(key, target);
|
|
4462
|
+
continue;
|
|
4463
|
+
}
|
|
4464
|
+
problems.push({
|
|
4465
|
+
kind: 'file',
|
|
4466
|
+
edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
|
|
4467
|
+
path: other.path,
|
|
4468
|
+
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`,
|
|
4469
|
+
whole: true,
|
|
4470
|
+
});
|
|
4471
|
+
}
|
|
4472
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4473
|
+
const hints = hintBudget();
|
|
4474
|
+
// As much as the largest file the call names holds, at least, so the
|
|
4475
|
+
// budget is the same whatever order its files are planned in.
|
|
4476
|
+
let largest = maxSize;
|
|
4477
|
+
for (const { bytes } of read.values())
|
|
4478
|
+
largest = Math.max(largest, bytes.length);
|
|
4479
|
+
const counts = countBudget(largest);
|
|
4480
|
+
const plans = [];
|
|
4481
|
+
for (const target of targets) {
|
|
4482
|
+
const got = read.get(target);
|
|
4483
|
+
if (!got)
|
|
4484
|
+
continue;
|
|
4485
|
+
const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
|
|
4486
|
+
if (plan !== null)
|
|
4487
|
+
plans.push({ target, plan });
|
|
4488
|
+
}
|
|
4489
|
+
// A file read again for another writer's change says so, whichever way
|
|
4490
|
+
// its edits went.
|
|
4491
|
+
const reread = (target, verb) => !changedMeanwhile.has(target) ? null
|
|
4492
|
+
: `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
|
|
4493
|
+
+ `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
|
|
4494
|
+
const refuse = (found) => ({
|
|
4495
|
+
success: false,
|
|
4496
|
+
isError: true,
|
|
4497
|
+
error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note) => note !== null), maxChars, 'wrote'),
|
|
4498
|
+
});
|
|
4499
|
+
if (problems.length)
|
|
4500
|
+
return refuse(problems);
|
|
4501
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4502
|
+
const staged = [];
|
|
4503
|
+
const discardAll = async (from = 0) => {
|
|
4504
|
+
for (const { staged: file } of staged.slice(from))
|
|
4505
|
+
await discard(file);
|
|
4506
|
+
};
|
|
4507
|
+
// A refusal before any file is renamed says so where the call names several.
|
|
4508
|
+
const nothingWritten = (target, error) => refuse([{
|
|
4509
|
+
kind: 'file',
|
|
4510
|
+
edits: target.edits,
|
|
4511
|
+
path: target.path,
|
|
4512
|
+
text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
|
|
4513
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4514
|
+
+ (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
|
|
4515
|
+
whole: true,
|
|
4516
|
+
}]);
|
|
4517
|
+
for (const { target, plan } of plans) {
|
|
4518
|
+
try {
|
|
4519
|
+
staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
|
|
4520
|
+
}
|
|
4521
|
+
catch (error) {
|
|
4522
|
+
await discardAll();
|
|
4523
|
+
if (error instanceof FileChanged) {
|
|
4524
|
+
changedMeanwhile.add(target);
|
|
4525
|
+
return 'changed';
|
|
4526
|
+
}
|
|
4527
|
+
return nothingWritten(target, error);
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
try {
|
|
4531
|
+
await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
|
|
4532
|
+
}
|
|
4533
|
+
catch (error) {
|
|
4534
|
+
await discardAll();
|
|
4535
|
+
throw error;
|
|
4536
|
+
}
|
|
4537
|
+
for (const { target, staged: file } of staged) {
|
|
4538
|
+
try {
|
|
4539
|
+
await placedStill(file);
|
|
4540
|
+
}
|
|
4541
|
+
catch (error) {
|
|
4542
|
+
await discardAll();
|
|
4543
|
+
return nothingWritten(target, error);
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
// Every file is checked, then every file renamed, with no turn of the
|
|
4547
|
+
// event loop between: a change to any of them before its check is seen
|
|
4548
|
+
// before any is written, and one landing between its check and the last
|
|
4549
|
+
// rename isn't (checkUnchanged).
|
|
4550
|
+
for (const { target, staged: file } of staged) {
|
|
4551
|
+
try {
|
|
4552
|
+
checkUnchanged(file, read.get(target));
|
|
4553
|
+
}
|
|
4554
|
+
catch (error) {
|
|
4555
|
+
await discardAll();
|
|
4556
|
+
if (error instanceof FileChanged) {
|
|
4557
|
+
changedMeanwhile.add(target);
|
|
4558
|
+
return 'changed';
|
|
4559
|
+
}
|
|
4560
|
+
return nothingWritten(target, error);
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
for (const [i, { target, staged: file }] of staged.entries()) {
|
|
4564
|
+
try {
|
|
4565
|
+
renameInto(file);
|
|
4566
|
+
}
|
|
4567
|
+
catch (error) {
|
|
4568
|
+
if (i === 0) {
|
|
4569
|
+
await discardAll();
|
|
4570
|
+
return nothingWritten(target, error);
|
|
4571
|
+
}
|
|
4572
|
+
// Read with no turn of the event loop since the last rename.
|
|
4573
|
+
this.recordDirectWrite(mount, agentName);
|
|
4574
|
+
await discardAll(i);
|
|
4575
|
+
return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
|
|
4576
|
+
}
|
|
4577
|
+
}
|
|
4578
|
+
this.recordDirectWrite(mount, agentName);
|
|
4579
|
+
return editReceipt(tool, staged.map(({ target, staged: file }) => ({
|
|
4580
|
+
path: target.path,
|
|
4581
|
+
plan: plans.find((p) => p.target === target).plan,
|
|
4582
|
+
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
|
|
4583
|
+
modeKept: null,
|
|
4584
|
+
})), editCount, maxChars, 'done');
|
|
4585
|
+
}
|
|
4586
|
+
/**
|
|
4587
|
+
* The answer to an edit_many call on a direct mount whose rename of its
|
|
4588
|
+
* `failed`th file failed after the ones before it were written: what was
|
|
4589
|
+
* written, the failure, and that nothing puts them back, then each file's
|
|
4590
|
+
* outcome, within `maxChars`, with the hard links a written one split
|
|
4591
|
+
* from, as a full answer names them (linksNote).
|
|
4592
|
+
*/
|
|
4593
|
+
directEditsPartway(staged, failed, error, maxChars) {
|
|
4594
|
+
// The failed file is named among the outcomes, so the head stays as
|
|
4595
|
+
// long as its reason, whatever the paths' lengths.
|
|
4596
|
+
const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
|
|
4597
|
+
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}.` : ''} `
|
|
4598
|
+
+ "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:";
|
|
4599
|
+
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'}`);
|
|
4600
|
+
const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
|
|
4601
|
+
return fitted(head, parts, notes, maxChars, 'written');
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* A direct mount's file as an edit reads it: its bytes on disk as they are
|
|
4605
|
+
* now, through the mount's boundary as read reaches them
|
|
4606
|
+
* (openContainedFile), with the file's identity when the read began, which
|
|
4607
|
+
* the write checks it against just before its rename. A file changed while
|
|
4608
|
+
* it was read is `changed`, so the call reads it again. What the
|
|
4609
|
+
* workspace doesn't show as text, it doesn't edit either: a file over the
|
|
4610
|
+
* mount's size limit, and a binary one.
|
|
4611
|
+
*/
|
|
4612
|
+
async directEditText(mount, relativePath, path) {
|
|
4613
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4614
|
+
const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
|
|
4615
|
+
let opened;
|
|
4616
|
+
try {
|
|
4617
|
+
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4618
|
+
if (fileStat.size > maxSize)
|
|
4619
|
+
throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
4620
|
+
});
|
|
4621
|
+
}
|
|
4622
|
+
catch (err) {
|
|
4623
|
+
if (err instanceof WorkspaceReadError && err.code === 'changed')
|
|
4624
|
+
return { changed: true };
|
|
4625
|
+
if (err instanceof Refusal)
|
|
4626
|
+
return { error: err.message };
|
|
4627
|
+
if (err instanceof WorkspaceReadError)
|
|
4628
|
+
return { error: readRefusal(err, path) };
|
|
4629
|
+
throw err;
|
|
4630
|
+
}
|
|
4631
|
+
try {
|
|
4632
|
+
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4633
|
+
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4634
|
+
if (more)
|
|
4635
|
+
return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4636
|
+
if (looksBinary(bytes)) {
|
|
4637
|
+
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.` };
|
|
4638
|
+
}
|
|
4639
|
+
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
|
|
4640
|
+
return { changed: true };
|
|
4641
|
+
return { bytes, identity: before };
|
|
4642
|
+
}
|
|
4643
|
+
catch (err) {
|
|
4644
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4645
|
+
}
|
|
4646
|
+
finally {
|
|
4647
|
+
await opened.handle.close();
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
/**
|
|
4651
|
+
* A direct mount's deletion: the file the read tools reach, by their
|
|
4652
|
+
* refusals (openContainedFile), unlinked through the mount's boundary as
|
|
4653
|
+
* a push's deletion unlinks one (unlinkContained). A followed link is the
|
|
4654
|
+
* entry removed, as there. One that lands is the caller's latest write on
|
|
4655
|
+
* the mount (recordDirectWrite).
|
|
4656
|
+
*/
|
|
4657
|
+
async deleteDirect(mount, relativePath, path, agentName) {
|
|
4658
|
+
return this.withMount(mount, async () => {
|
|
4659
|
+
// One root for the call, by its identity as the call starts: the
|
|
4660
|
+
// open's checks and the unlink's own refuse any other directory there,
|
|
4661
|
+
// in ROOT_MOVED's words, so a root replaced after this stat is refused
|
|
4662
|
+
// rather than taken, but in the instant between the unlink's last
|
|
4663
|
+
// check and the unlink, which Node can't close (effects.ts's header).
|
|
4664
|
+
// One that can't be read now is refused here.
|
|
4665
|
+
const disk = await this.diskView(mount);
|
|
4666
|
+
let rootStat;
|
|
4667
|
+
try {
|
|
4668
|
+
rootStat = await stat(disk.rootReal, { bigint: true });
|
|
4669
|
+
}
|
|
4670
|
+
catch (err) {
|
|
4671
|
+
const code = errnoOf(err);
|
|
4672
|
+
const why = code === 'ENOENT' || code === 'ENOTDIR'
|
|
4673
|
+
? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
|
|
4674
|
+
: `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
|
|
4675
|
+
return { success: false, isError: true, error: didNothing('delete', why, path) };
|
|
4676
|
+
}
|
|
4677
|
+
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
4678
|
+
const { rootReal } = disk;
|
|
4679
|
+
try {
|
|
4680
|
+
const opened = await this.openContainedFile(mount, relativePath, path);
|
|
4681
|
+
await opened.handle.close();
|
|
4682
|
+
}
|
|
4683
|
+
catch (err) {
|
|
4684
|
+
// The open's own words for a replaced root speak of reading.
|
|
4685
|
+
if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
|
|
4686
|
+
return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
|
|
4687
|
+
}
|
|
4688
|
+
if (err instanceof WorkspaceReadError)
|
|
4689
|
+
return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
|
|
4690
|
+
throw err;
|
|
4691
|
+
}
|
|
4692
|
+
// The unlink is awaited, unlike a write's rename, so where the head
|
|
4693
|
+
// was just before it says whether the head moved while it ran.
|
|
4694
|
+
const since = this.positionNow();
|
|
4695
|
+
try {
|
|
4696
|
+
await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
|
|
4697
|
+
}
|
|
4698
|
+
catch (error) {
|
|
4699
|
+
// A failure after the unlink reached the file's entry can't say
|
|
4700
|
+
// whether the file is gone, so it's taken as the agent's write too.
|
|
4701
|
+
if (error instanceof EffectFailed && error.touched) {
|
|
4702
|
+
this.recordDirectWrite(mount, agentName, since);
|
|
4703
|
+
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.` };
|
|
4704
|
+
}
|
|
4705
|
+
return { success: false, isError: true, error: directDidNothing('delete', path, error) };
|
|
4706
|
+
}
|
|
4707
|
+
this.recordDirectWrite(mount, agentName, since);
|
|
4708
|
+
return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
|
|
4709
|
+
});
|
|
4710
|
+
}
|
|
4711
|
+
/**
|
|
4712
|
+
* After a direct write lands: the agent's latest write on the mount, P,
|
|
4713
|
+
* moves to it, where the agent's history holds P or the notice of its
|
|
4714
|
+
* departure from P, N (historyHoldsWrites), and N clears. Otherwise P
|
|
4715
|
+
* stays where it is: the head moved away from it under the call's turn,
|
|
4716
|
+
* and the next start's notice covers what it left (headMoveNotice). P is
|
|
4717
|
+
* the branch and the store's head as it is now, read with no turn of the
|
|
4718
|
+
* event loop since the write, so no record that lands after the write is
|
|
4719
|
+
* taken for one before it. A deletion, whose unlink is awaited, passes
|
|
4720
|
+
* where the head was just before its unlink (`since`): where the branch
|
|
4721
|
+
* changed meanwhile, the head moved under the call, and P is set there,
|
|
4722
|
+
* so the next start judges the deletion by the branch its turn was on,
|
|
4723
|
+
* unless the history doesn't hold P already, whose notice is owed. A write no agent
|
|
4724
|
+
* made tells no one. The store's reads here come after the write landed,
|
|
4725
|
+
* so one that throws is logged and P stays, as for a history that can't
|
|
4726
|
+
* be read, and the write still answers that it landed.
|
|
4727
|
+
*/
|
|
4728
|
+
recordDirectWrite(mount, agentName, since) {
|
|
4729
|
+
if (agentName === undefined)
|
|
4730
|
+
return;
|
|
4731
|
+
try {
|
|
4732
|
+
const store = this.getStore();
|
|
4733
|
+
let heads = this.directHeads.get(mount.config.name);
|
|
4734
|
+
if (!heads)
|
|
4735
|
+
this.directHeads.set(mount.config.name, (heads = new Map()));
|
|
4736
|
+
const head = heads.get(agentName);
|
|
4737
|
+
const branchId = store.currentBranch().id;
|
|
4738
|
+
if (since && since.branchId !== branchId) {
|
|
4739
|
+
if (!head || this.historyHoldsWrites(agentName, head) === true)
|
|
4740
|
+
heads.set(agentName, { ...since, notice: null });
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
if (head && this.historyHoldsWrites(agentName, head) !== true)
|
|
4744
|
+
return;
|
|
4745
|
+
heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
|
|
4746
|
+
}
|
|
4747
|
+
catch (err) {
|
|
4748
|
+
console.error('[workspace] could not record a direct write\'s position:', err);
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
/** The store's branch and head now, or null where the store can't say. */
|
|
4752
|
+
positionNow() {
|
|
4753
|
+
try {
|
|
4754
|
+
const store = this.getStore();
|
|
4755
|
+
return { branchId: store.currentBranch().id, after: store.currentSequence() };
|
|
4756
|
+
}
|
|
4757
|
+
catch {
|
|
4758
|
+
return null;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
/**
|
|
4762
|
+
* Whether an agent's history holds its latest direct write on a mount
|
|
4763
|
+
* (holdsPosition), or the notice of its departure from it, judged by the
|
|
4764
|
+
* notice's own presence there, through the lookup the framework hands the
|
|
4765
|
+
* module (useHistory); null where that lookup threw, which is logged. A
|
|
4766
|
+
* history that can't be read decides nothing: a write takes it as not
|
|
4767
|
+
* held, so its position stays and the notice stays owed, and a start
|
|
4768
|
+
* gives no notice, which may not be true of a history it can't read, and
|
|
4769
|
+
* leaves the next start to decide (headMoveNotice).
|
|
4770
|
+
*/
|
|
4771
|
+
historyHoldsWrites(agentName, head) {
|
|
4772
|
+
if (this.holdsPosition(head))
|
|
4773
|
+
return true;
|
|
4774
|
+
if (head.notice === null)
|
|
4775
|
+
return false;
|
|
4776
|
+
try {
|
|
4777
|
+
return this.historyLookup?.(agentName, head.notice) === true;
|
|
4778
|
+
}
|
|
4779
|
+
catch (err) {
|
|
4780
|
+
console.error('[workspace] history lookup for the head-move notice failed:', err);
|
|
4781
|
+
return null;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
/**
|
|
4785
|
+
* Whether the store's current branch holds what came before a direct
|
|
4786
|
+
* write: it's the write's branch, or continues it with every fork point on
|
|
4787
|
+
* the way after `after`, the store's head when the write landed. A fork at
|
|
4788
|
+
* exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
|
|
4789
|
+
* which is the head at the write when nothing was recorded in the turn
|
|
4790
|
+
* before it. A missing branch or one without fork metadata holds nothing,
|
|
4791
|
+
* as for the materialize guard (isLinearContinuation).
|
|
4792
|
+
*/
|
|
4793
|
+
holdsPosition({ branchId, after }) {
|
|
4794
|
+
const store = this.getStore();
|
|
4795
|
+
const current = store.currentBranch();
|
|
4796
|
+
if (current.id === branchId)
|
|
4797
|
+
return true;
|
|
4798
|
+
const byId = new Map(store.listBranches().map((b) => [b.id, b]));
|
|
4799
|
+
const visited = new Set();
|
|
4800
|
+
let cursor = byId.get(current.id);
|
|
4801
|
+
while (cursor && !visited.has(cursor.id)) {
|
|
4802
|
+
visited.add(cursor.id);
|
|
4803
|
+
if (cursor.id === branchId)
|
|
4804
|
+
return true;
|
|
4805
|
+
if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after)
|
|
4806
|
+
return false;
|
|
4807
|
+
cursor = byId.get(cursor.parentId);
|
|
4808
|
+
}
|
|
4809
|
+
return false;
|
|
4810
|
+
}
|
|
4811
|
+
/**
|
|
4812
|
+
* The framework's check at every start of an agent's turn, opening,
|
|
4813
|
+
* continuing or resuming: the notice due for each mount the agent wrote
|
|
4814
|
+
* direct where its history holds neither its latest write there nor the
|
|
4815
|
+
* notice of its departure from it (historyHoldsWrites), with those
|
|
4816
|
+
* mounts' names; or null, as on a head that hasn't moved. The framework
|
|
4817
|
+
* stores the text in the agent's history before the turn's compile and
|
|
4818
|
+
* hands its message id back (headMoveNoticeStored).
|
|
4819
|
+
*/
|
|
4820
|
+
headMoveNotice(agentName) {
|
|
4821
|
+
if (!this.store)
|
|
4822
|
+
return null;
|
|
4823
|
+
const due = [];
|
|
4824
|
+
// Every mount where the agent wrote direct, whether it runs direct this
|
|
4825
|
+
// session or as 'chronicle': the writes are on disk either way.
|
|
4826
|
+
for (const name of this.mounts.keys()) {
|
|
4827
|
+
const head = this.directHeads.get(name)?.get(agentName);
|
|
4828
|
+
if (!head)
|
|
4829
|
+
continue;
|
|
4830
|
+
const held = this.historyHoldsWrites(agentName, head);
|
|
4831
|
+
// A history that can't be read decides nothing at this start.
|
|
4832
|
+
if (held === null)
|
|
4833
|
+
return null;
|
|
4834
|
+
if (!held)
|
|
4835
|
+
due.push(name);
|
|
4836
|
+
}
|
|
4837
|
+
return due.length ? { text: headMoveText(due), mounts: due } : null;
|
|
4838
|
+
}
|
|
4839
|
+
/** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
|
|
4840
|
+
headMoveNoticeStored(agentName, mounts, messageId) {
|
|
4841
|
+
for (const name of mounts) {
|
|
4842
|
+
const head = this.directHeads.get(name)?.get(agentName);
|
|
4843
|
+
if (head)
|
|
4844
|
+
head.notice = messageId;
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4847
|
+
/**
|
|
4848
|
+
* How the module asks whether an agent's history holds a message, as the
|
|
4849
|
+
* framework judges its own notices (`cm.getMessage(id) !== null`): handed
|
|
4850
|
+
* over once, when the module is added. Without it, no notice is held.
|
|
4851
|
+
*/
|
|
4852
|
+
useHistory(lookup) {
|
|
4853
|
+
this.historyLookup = lookup;
|
|
4854
|
+
}
|
|
4855
|
+
async handleDelete(input, callerAgentName) {
|
|
4856
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
|
|
4857
|
+
if (relativePath === '')
|
|
4858
|
+
return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4859
|
+
input = { ...input, path };
|
|
4860
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
4861
|
+
if (refusal)
|
|
4862
|
+
return { success: false, error: didNothing('delete', refusal), isError: true };
|
|
4863
|
+
if (mount.direct)
|
|
4864
|
+
return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
4865
|
+
const store = this.getStore();
|
|
4866
|
+
let committed = false;
|
|
4867
|
+
try {
|
|
4868
|
+
return await this.withMount(mount, async () => {
|
|
4869
|
+
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
4870
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
4871
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
4872
|
+
if (!entry) {
|
|
4873
|
+
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
|
|
4874
|
+
return { success: false, error: didNothing('delete', error), isError: true };
|
|
4875
|
+
}
|
|
4876
|
+
const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
4877
|
+
if (refused !== null)
|
|
4878
|
+
return storedButUnfinished('delete', input.path, { refused });
|
|
4879
|
+
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
4880
|
+
// withholds the result (agent-framework #277).
|
|
4881
|
+
return {
|
|
4882
|
+
success: true,
|
|
4883
|
+
data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
|
|
4884
|
+
keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
|
|
4885
|
+
};
|
|
4886
|
+
});
|
|
4887
|
+
}
|
|
4888
|
+
catch (error) {
|
|
4889
|
+
if (!committed)
|
|
4890
|
+
throw notStored(error);
|
|
4891
|
+
if (error instanceof StoredThenFailed) {
|
|
4892
|
+
// Its note, as the success answer's: a deletion that cleared a
|
|
4893
|
+
// conflict naming a disk file unlinked nothing.
|
|
4894
|
+
const note = error.notes.get(relativePath);
|
|
4895
|
+
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
|
|
4896
|
+
}
|
|
4897
|
+
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
async handleLs(input) {
|
|
4104
4901
|
if (!input.path) {
|
|
4105
4902
|
// List all mounts, with the kind a mount configured direct runs as
|
|
4106
4903
|
// this session and the ignore list it walks by, whose default no
|
|
@@ -4109,7 +4906,9 @@ export class WorkspaceModule {
|
|
|
4109
4906
|
const mounts = [...this.mounts.entries()].map(([name, m]) => ({
|
|
4110
4907
|
name,
|
|
4111
4908
|
path: m.config.path,
|
|
4112
|
-
mode
|
|
4909
|
+
// A mount configured direct says its mode where its config names
|
|
4910
|
+
// none, so a writable one shows as writable.
|
|
4911
|
+
mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
|
|
4113
4912
|
...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
|
|
4114
4913
|
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
4115
4914
|
}));
|
|
@@ -4119,7 +4918,7 @@ export class WorkspaceModule {
|
|
|
4119
4918
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
4120
4919
|
// is refused, as glob refuses it, and shown as given.
|
|
4121
4920
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4122
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
4921
|
+
return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4123
4922
|
}
|
|
4124
4923
|
input = { ...input, path };
|
|
4125
4924
|
const recursive = input.recursive === true;
|
|
@@ -4128,6 +4927,9 @@ export class WorkspaceModule {
|
|
|
4128
4927
|
// shows disk's.
|
|
4129
4928
|
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
4130
4929
|
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
4930
|
+
const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
|
|
4931
|
+
const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
|
|
4932
|
+
const noted = note !== null ? { note } : {};
|
|
4131
4933
|
if (recursive) {
|
|
4132
4934
|
return {
|
|
4133
4935
|
success: true,
|
|
@@ -4136,6 +4938,7 @@ export class WorkspaceModule {
|
|
|
4136
4938
|
entries: listing.files,
|
|
4137
4939
|
count: listing.files.length,
|
|
4138
4940
|
...incomplete,
|
|
4941
|
+
...noted,
|
|
4139
4942
|
},
|
|
4140
4943
|
};
|
|
4141
4944
|
}
|
|
@@ -4152,9 +4955,34 @@ export class WorkspaceModule {
|
|
|
4152
4955
|
entries: children,
|
|
4153
4956
|
count: children.length,
|
|
4154
4957
|
...incomplete,
|
|
4958
|
+
...noted,
|
|
4155
4959
|
},
|
|
4156
4960
|
};
|
|
4157
4961
|
}
|
|
4962
|
+
async emptyScopeNote(mount, relativePath, path) {
|
|
4963
|
+
const shown = JSON.stringify(path);
|
|
4964
|
+
const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
|
|
4965
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4966
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
4967
|
+
if (seen.kind === 'file')
|
|
4968
|
+
return isFile;
|
|
4969
|
+
if (seen.kind !== 'absent')
|
|
4970
|
+
return null;
|
|
4971
|
+
if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null)
|
|
4972
|
+
return isFile;
|
|
4973
|
+
return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
|
|
4974
|
+
}
|
|
4975
|
+
async absentOnDisk(mount, relativePath) {
|
|
4976
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4977
|
+
return (await observePath(view, rootReal, relativePath)).kind === 'absent';
|
|
4978
|
+
}
|
|
4979
|
+
holdsAnythingAt(mount, relativePath) {
|
|
4980
|
+
if (mount.direct)
|
|
4981
|
+
return false;
|
|
4982
|
+
const store = this.getStore();
|
|
4983
|
+
const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree) => tree !== undefined);
|
|
4984
|
+
return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
|
|
4985
|
+
}
|
|
4158
4986
|
/**
|
|
4159
4987
|
* What a listing shows of a directory scope, from where the mount keeps
|
|
4160
4988
|
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
@@ -4256,23 +5084,33 @@ export class WorkspaceModule {
|
|
|
4256
5084
|
if (input.path) {
|
|
4257
5085
|
const { mount, relativePath, moved, root } = mountsToSearch[0];
|
|
4258
5086
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4259
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5087
|
+
return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4260
5088
|
}
|
|
4261
5089
|
}
|
|
4262
5090
|
// Every match lies beneath the pattern's literal directory, so the walk
|
|
4263
5091
|
// starts there: past the walk's cap, a walk of the whole scope can stop
|
|
4264
5092
|
// before it gets there. In the mount a pattern's first segment names, the
|
|
4265
5093
|
// pattern is read mount-prefixed (prefixedGlob), from its rest's directory
|
|
4266
|
-
// within `path`.
|
|
5094
|
+
// within `path`. A `path` that directory can't overlap leaves no file the
|
|
5095
|
+
// pattern can match, so the glob is refused, as grep's is, saying how
|
|
5096
|
+
// the pattern is read, rather than answered with no matches; but a path
|
|
5097
|
+
// in another mount's directory is that mount's, and is said so, as grep
|
|
5098
|
+
// says it. Only a `path` can do either, and it names one mount, so
|
|
5099
|
+
// nothing has been searched yet.
|
|
4267
5100
|
const patternDir = literalDirOf(input.pattern);
|
|
4268
|
-
for (const { mount, relativePath } of mountsToSearch) {
|
|
5101
|
+
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
4269
5102
|
const prefixed = prefixedGlob(input.pattern, mount.config.name);
|
|
4270
5103
|
let start;
|
|
4271
5104
|
let matched;
|
|
4272
5105
|
if (prefixed) {
|
|
4273
5106
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
4274
|
-
if (dir === null)
|
|
4275
|
-
|
|
5107
|
+
if (dir === null) {
|
|
5108
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5109
|
+
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5110
|
+
continue;
|
|
5111
|
+
}
|
|
5112
|
+
return { success: false, error: await this.disjointRefusal('glob', input.path, input.pattern, true, mount, relativePath, root), isError: true };
|
|
5113
|
+
}
|
|
4276
5114
|
start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
|
|
4277
5115
|
matched = (path) => prefixed.regex.test(path);
|
|
4278
5116
|
}
|
|
@@ -4303,15 +5141,31 @@ export class WorkspaceModule {
|
|
|
4303
5141
|
regex = new RegExp(input.pattern);
|
|
4304
5142
|
}
|
|
4305
5143
|
catch (e) {
|
|
4306
|
-
//
|
|
4307
|
-
|
|
5144
|
+
// V8's message ends with the parser's reason, after the pattern: "Invalid
|
|
5145
|
+
// regular expression: /foo(/: Unterminated group".
|
|
5146
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5147
|
+
const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
|
|
5148
|
+
return {
|
|
5149
|
+
success: false,
|
|
5150
|
+
isError: true,
|
|
5151
|
+
error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
|
|
5152
|
+
+ 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
|
|
5153
|
+
};
|
|
4308
5154
|
}
|
|
4309
5155
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
4310
|
-
// The filter matches mount-relative paths,
|
|
4311
|
-
//
|
|
4312
|
-
//
|
|
4313
|
-
// mount
|
|
5156
|
+
// The filter matches mount-relative paths, from the mount's root whatever
|
|
5157
|
+
// the `path`, so every file it lets through lies beneath its literal
|
|
5158
|
+
// directory, and the walk starts at the deeper of that and `path`. In the
|
|
5159
|
+
// mount a filter's first segment names, its mount-prefixed reading takes
|
|
5160
|
+
// the filter's place (below). One with no "/" also matches a file's name,
|
|
5161
|
+
// wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
|
|
5162
|
+
// file through, where tested against the path alone it let through only
|
|
5163
|
+
// those at the mount's root. The path is still tried, so a "**" in such a
|
|
5164
|
+
// filter spans directories as it did, and nothing it let through before
|
|
5165
|
+
// is lost. A filter read mount-prefixed always holds a "/" after the
|
|
5166
|
+
// mount's name, so it never takes the name test.
|
|
4314
5167
|
const filterDir = input.glob ? literalDirOf(input.glob) : '';
|
|
5168
|
+
const byName = fileGlob !== null && !input.glob.includes('/');
|
|
4315
5169
|
const contextBefore = input.contextBefore ?? 0;
|
|
4316
5170
|
const contextAfter = input.contextAfter ?? 0;
|
|
4317
5171
|
const mountsToSearch = input.path
|
|
@@ -4322,14 +5176,29 @@ export class WorkspaceModule {
|
|
|
4322
5176
|
if (input.path) {
|
|
4323
5177
|
const { mount, relativePath, moved, root } = mountsToSearch[0];
|
|
4324
5178
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4325
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5179
|
+
return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4326
5180
|
}
|
|
4327
5181
|
}
|
|
4328
5182
|
const results = [];
|
|
4329
5183
|
const skipped = [];
|
|
4330
5184
|
const incomplete = [];
|
|
4331
|
-
|
|
4332
|
-
|
|
5185
|
+
// Lines as read shows them (fileText): a CRLF end's CR is left out, for
|
|
5186
|
+
// matching as well as display, unless the line's own text ends in a CR, so
|
|
5187
|
+
// `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
|
|
5188
|
+
// the line copies into an edit as read's do. ripgrep's --crlf still prints
|
|
5189
|
+
// the line with its CR.
|
|
5190
|
+
//
|
|
5191
|
+
// Whether a file has CRLF ends or a BOM is a fact about the file, not a
|
|
5192
|
+
// line, so each searched file that has either is kept here by its path,
|
|
5193
|
+
// for the note on a pattern that names a CR or U+FEFF to name it. A
|
|
5194
|
+
// conflict's disk version is kept, for each, only where the workspace's
|
|
5195
|
+
// copy of its path doesn't have it, and is named as that version, since
|
|
5196
|
+
// read doesn't show it.
|
|
5197
|
+
const crlfFiles = new Map();
|
|
5198
|
+
const bomFiles = new Map();
|
|
5199
|
+
const search = (text) => {
|
|
5200
|
+
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5201
|
+
const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
|
|
4333
5202
|
const found = [];
|
|
4334
5203
|
for (let i = 0; i < lines.length; i++) {
|
|
4335
5204
|
if (regex.test(lines[i])) {
|
|
@@ -4342,60 +5211,104 @@ export class WorkspaceModule {
|
|
|
4342
5211
|
found.push(match);
|
|
4343
5212
|
}
|
|
4344
5213
|
}
|
|
4345
|
-
return found;
|
|
5214
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
|
|
4346
5215
|
};
|
|
4347
5216
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
4348
5217
|
// A path named in another mount's directory, as holderOf resolves it,
|
|
4349
5218
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
4350
5219
|
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
4351
5220
|
// found where its root lies now (movedNesting).
|
|
4352
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5221
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
4353
5222
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
4354
5223
|
continue;
|
|
4355
5224
|
}
|
|
4356
5225
|
// In the mount a filter's first segment names, the filter is read
|
|
4357
5226
|
// mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
|
|
4358
|
-
// a filter whose directories
|
|
4359
|
-
// let through, so
|
|
5227
|
+
// a filter whose directories, in the reading this mount takes, don't
|
|
5228
|
+
// overlap leave no file the filter can let through, so the grep is
|
|
5229
|
+
// refused, saying how the filter is read, rather than answered with no
|
|
5230
|
+
// matches: written relative to the `path`, as glob's pattern is, it
|
|
5231
|
+
// names nothing there. Only a `path` can do this, and it names one
|
|
5232
|
+
// mount, so nothing has been searched yet.
|
|
4360
5233
|
const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
|
|
4361
5234
|
const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
|
|
4362
|
-
if (deeper === null)
|
|
4363
|
-
|
|
5235
|
+
if (deeper === null) {
|
|
5236
|
+
return { success: false, error: await this.disjointRefusal('grep', input.path, input.glob, prefixed !== null, mount, relativePath, root), isError: true };
|
|
5237
|
+
}
|
|
4364
5238
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
4365
5239
|
const searched = (text) => {
|
|
4366
|
-
const
|
|
4367
|
-
|
|
4368
|
-
|
|
5240
|
+
const { bom, ...hits } = search(text);
|
|
5241
|
+
const { file, state, version } = text;
|
|
5242
|
+
return () => {
|
|
5243
|
+
if (hits.lineEndings !== undefined && !crlfFiles.has(file))
|
|
5244
|
+
crlfFiles.set(file, { ends: hits.lineEndings, version });
|
|
5245
|
+
if (bom && !bomFiles.has(file))
|
|
5246
|
+
bomFiles.set(file, version);
|
|
5247
|
+
if (hits.matches.length > 0)
|
|
5248
|
+
results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
|
|
5249
|
+
};
|
|
4369
5250
|
};
|
|
4370
5251
|
const kept = prefixed
|
|
4371
5252
|
? (path) => prefixed.regex.test(path)
|
|
4372
|
-
: (path) => fileGlob === null || fileGlob.test(path)
|
|
5253
|
+
: (path) => fileGlob === null || fileGlob.test(path)
|
|
5254
|
+
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
4373
5255
|
const found = mount.direct
|
|
4374
|
-
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
5256
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
|
|
4375
5257
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
4376
5258
|
skipped.push(...found.skipped);
|
|
4377
5259
|
incomplete.push(...found.incomplete);
|
|
4378
5260
|
}
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
},
|
|
5261
|
+
const answer = {
|
|
5262
|
+
pattern: input.pattern,
|
|
5263
|
+
results,
|
|
5264
|
+
totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
5265
|
+
// A pattern written for a CR can't find a CRLF end's, nor one written
|
|
5266
|
+
// for U+FEFF a BOM, which lines as read shows them leave out, so the
|
|
5267
|
+
// answer says why it didn't, and names the files that have them.
|
|
5268
|
+
...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
|
|
5269
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
5270
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
4388
5271
|
};
|
|
5272
|
+
return textAnswer(grepText(answer, contextBefore), { ...answer });
|
|
5273
|
+
}
|
|
5274
|
+
/**
|
|
5275
|
+
* Why a grep or a glob whose `path` (`given`, in the mount at
|
|
5276
|
+
* `relativePath`) and filter or pattern can't overlap did nothing. grep's
|
|
5277
|
+
* filter is matched from the mount's root, and in the mount its first
|
|
5278
|
+
* segment names, either tool's is read mount-prefixed (`prefixed`), from
|
|
5279
|
+
* that mount's root; either way no file at or beneath the path can pass
|
|
5280
|
+
* it. Where the path isn't a file, it names the one that reaches beneath
|
|
5281
|
+
* it, the one meant if it was written relative to the path, mount-prefixed
|
|
5282
|
+
* as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
|
|
5283
|
+
* `?`, which matches each of them, and since the search stays beneath the
|
|
5284
|
+
* path, a `?` that would match a sibling's name too takes nothing in from
|
|
5285
|
+
* outside it. One with an empty, `.` or `..` segment gets no suggestion,
|
|
5286
|
+
* since no path holds such a segment. Segments are read as written, so one
|
|
5287
|
+
* a brace group splits around isn't told apart.
|
|
5288
|
+
*/
|
|
5289
|
+
async disjointRefusal(tool, given, filter, prefixed, mount, relativePath, root) {
|
|
5290
|
+
const name = mount.config.name;
|
|
5291
|
+
const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
|
|
5292
|
+
const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
|
|
5293
|
+
+ (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
|
|
5294
|
+
if (await this.namesFile(mount, relativePath, root))
|
|
5295
|
+
return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
|
|
5296
|
+
const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
|
|
5297
|
+
if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..'))
|
|
5298
|
+
return within;
|
|
5299
|
+
const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
|
|
5300
|
+
return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
|
|
4389
5301
|
}
|
|
4390
5302
|
/**
|
|
4391
5303
|
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
4392
5304
|
* single file `relativePath` names, those `keep` lets through, each handed
|
|
4393
|
-
* to `
|
|
4394
|
-
* workspace copy, labelled with its state, and a
|
|
5305
|
+
* to `search`, whose answers are recorded in path order: a pass over the
|
|
5306
|
+
* scope, then each path's workspace copy, labelled with its state, and a
|
|
5307
|
+
* conflict's disk side.
|
|
4395
5308
|
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
4396
5309
|
* `incomplete`, each mount-prefixed.
|
|
4397
5310
|
*/
|
|
4398
|
-
async textsFromStore(mount, relativePath, start, keep,
|
|
5311
|
+
async textsFromStore(mount, relativePath, start, keep, search) {
|
|
4399
5312
|
const store = this.getStore();
|
|
4400
5313
|
const skipped = [];
|
|
4401
5314
|
const incomplete = [];
|
|
@@ -4448,17 +5361,18 @@ export class WorkspaceModule {
|
|
|
4448
5361
|
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
4449
5362
|
}
|
|
4450
5363
|
const intents = this.intents.get(mount.config.name);
|
|
5364
|
+
const found = [];
|
|
4451
5365
|
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
4452
5366
|
if (!keep(report.path))
|
|
4453
5367
|
continue;
|
|
4454
5368
|
const file = `${mount.config.name}/${report.path}`;
|
|
4455
5369
|
if (report.state === 'disk-only') {
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
5370
|
+
found.push({ skip: {
|
|
5371
|
+
file,
|
|
5372
|
+
reason: 'disk-only: binary or over the size limit, never stored',
|
|
5373
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
5374
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
5375
|
+
} });
|
|
4462
5376
|
continue;
|
|
4463
5377
|
}
|
|
4464
5378
|
// A path in another mount's directory is that mount's: grep gives
|
|
@@ -4468,17 +5382,16 @@ export class WorkspaceModule {
|
|
|
4468
5382
|
continue;
|
|
4469
5383
|
const refusal = refused.get(report.path);
|
|
4470
5384
|
if (refusal !== undefined) {
|
|
4471
|
-
|
|
5385
|
+
found.push({ skip: { file, reason: refusal } });
|
|
4472
5386
|
continue;
|
|
4473
5387
|
}
|
|
4474
5388
|
// The workspace's version, labelled with the path's state.
|
|
4475
5389
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
5390
|
+
if (entry) {
|
|
5391
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
5392
|
+
found.push('bytes' in stored
|
|
5393
|
+
? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
|
|
5394
|
+
: { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
|
|
4482
5395
|
}
|
|
4483
5396
|
if (report.state !== 'conflict')
|
|
4484
5397
|
continue;
|
|
@@ -4489,22 +5402,40 @@ export class WorkspaceModule {
|
|
|
4489
5402
|
// disk version was never stored either.
|
|
4490
5403
|
const disk = intents.get(report.path)?.conflict?.disk;
|
|
4491
5404
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
5405
|
+
if (disk?.stored) {
|
|
5406
|
+
const version = changed ? 'recorded-disk' : 'conflicting-disk';
|
|
5407
|
+
const stored = storedBytes(store, disk.stored);
|
|
5408
|
+
found.push('bytes' in stored
|
|
5409
|
+
? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
|
|
5410
|
+
: { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
|
|
4498
5411
|
}
|
|
4499
5412
|
if (changed) {
|
|
4500
5413
|
if (report.diskNow === 'file') {
|
|
4501
|
-
|
|
5414
|
+
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' } });
|
|
4502
5415
|
}
|
|
4503
5416
|
}
|
|
4504
5417
|
else if (disk && !disk.stored) {
|
|
4505
|
-
|
|
5418
|
+
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 } });
|
|
4506
5419
|
}
|
|
4507
5420
|
}
|
|
5421
|
+
const answers = new Map();
|
|
5422
|
+
await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
|
|
5423
|
+
const { file, state, version, what } = lost;
|
|
5424
|
+
answers.set(lost, 'bytes' in contents
|
|
5425
|
+
? { searched: search({ file, state, version, bytes: contents.bytes }) }
|
|
5426
|
+
: { skip: {
|
|
5427
|
+
file,
|
|
5428
|
+
reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
|
|
5429
|
+
+ `and ${lostOnDisk(contents.lost)}, so it was not searched`,
|
|
5430
|
+
} });
|
|
5431
|
+
});
|
|
5432
|
+
for (const item of found) {
|
|
5433
|
+
const answer = 'lost' in item ? answers.get(item.lost) : item;
|
|
5434
|
+
if ('searched' in answer)
|
|
5435
|
+
answer.searched();
|
|
5436
|
+
else
|
|
5437
|
+
skipped.push(answer.skip);
|
|
5438
|
+
}
|
|
4508
5439
|
});
|
|
4509
5440
|
return { skipped, incomplete };
|
|
4510
5441
|
}
|
|
@@ -4541,7 +5472,7 @@ export class WorkspaceModule {
|
|
|
4541
5472
|
else if (looksBinary(read.head))
|
|
4542
5473
|
skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
|
|
4543
5474
|
else
|
|
4544
|
-
each({ file, version: 'disk',
|
|
5475
|
+
each({ file, version: 'disk', bytes: read.bytes });
|
|
4545
5476
|
});
|
|
4546
5477
|
return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
|
|
4547
5478
|
}
|
|
@@ -4549,7 +5480,7 @@ export class WorkspaceModule {
|
|
|
4549
5480
|
const store = this.getStore();
|
|
4550
5481
|
const status = {};
|
|
4551
5482
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
4552
|
-
return { success: false, error:
|
|
5483
|
+
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
4553
5484
|
}
|
|
4554
5485
|
for (const [name, mount] of this.mounts) {
|
|
4555
5486
|
if (input.mount !== undefined && name !== input.mount)
|
|
@@ -4557,7 +5488,7 @@ export class WorkspaceModule {
|
|
|
4557
5488
|
if (mount.direct) {
|
|
4558
5489
|
// A direct mount keeps nothing to count: its kind, and the ignore
|
|
4559
5490
|
// list its walks go by, whose default no config shows.
|
|
4560
|
-
status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
5491
|
+
status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
4561
5492
|
continue;
|
|
4562
5493
|
}
|
|
4563
5494
|
// What the workspace's history holds outside the mount's include isn't the mount's,
|
|
@@ -4571,7 +5502,7 @@ export class WorkspaceModule {
|
|
|
4571
5502
|
const currentBranch = store.currentBranch();
|
|
4572
5503
|
status[name] = {
|
|
4573
5504
|
path: mount.config.path,
|
|
4574
|
-
mode: mount.config.mode,
|
|
5505
|
+
mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
|
|
4575
5506
|
// A mount configured direct runs as 'chronicle' this session
|
|
4576
5507
|
// (chooseKinds), by the direct kind's ignore list where its config
|
|
4577
5508
|
// names none.
|
|
@@ -4691,9 +5622,6 @@ export class WorkspaceModule {
|
|
|
4691
5622
|
*/
|
|
4692
5623
|
entriesByEvidence(mount) {
|
|
4693
5624
|
const store = this.getStore();
|
|
4694
|
-
const agreement = this.agreementOrThrow();
|
|
4695
|
-
const name = mount.config.name;
|
|
4696
|
-
const intents = this.intents.get(name);
|
|
4697
5625
|
const owed = [];
|
|
4698
5626
|
const unchecked = [];
|
|
4699
5627
|
let agreed = 0;
|
|
@@ -4703,20 +5631,37 @@ export class WorkspaceModule {
|
|
|
4703
5631
|
// in it: neither owed to disk nor counted as on it.
|
|
4704
5632
|
if (!insideMount(e.path, mount.config.include) || !own(e.path))
|
|
4705
5633
|
continue;
|
|
4706
|
-
const
|
|
4707
|
-
if (
|
|
4708
|
-
(intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
|
|
4709
|
-
else if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
4710
|
-
owed.push(e.path);
|
|
4711
|
-
// A mode the store records, other than the one disk last agreed with,
|
|
4712
|
-
// where modes are observed: elsewhere no push applies one.
|
|
4713
|
-
else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
|
|
5634
|
+
const kind = this.evidenceOf(mount, e);
|
|
5635
|
+
if (kind === 'owed')
|
|
4714
5636
|
owed.push(e.path);
|
|
5637
|
+
else if (kind === 'unchecked')
|
|
5638
|
+
unchecked.push(e.path);
|
|
4715
5639
|
else
|
|
4716
5640
|
agreed++;
|
|
4717
5641
|
}
|
|
4718
5642
|
return { owed, unchecked, agreed };
|
|
4719
5643
|
}
|
|
5644
|
+
evidenceOf(mount, e) {
|
|
5645
|
+
const p = this.agreementOrThrow().get(mount.config.name, e.path);
|
|
5646
|
+
if (p === undefined)
|
|
5647
|
+
return this.intents.get(mount.config.name).get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
|
|
5648
|
+
if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
5649
|
+
return 'owed';
|
|
5650
|
+
// A mode the store records, other than the one disk last agreed with,
|
|
5651
|
+
// where modes are observed: elsewhere no push applies one.
|
|
5652
|
+
if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
|
|
5653
|
+
return 'owed';
|
|
5654
|
+
return 'agreed';
|
|
5655
|
+
}
|
|
5656
|
+
diskOf(mount, relativePath) {
|
|
5657
|
+
const intent = this.intents.get(mount.config.name).get(relativePath);
|
|
5658
|
+
if (intent?.conflict)
|
|
5659
|
+
return 'in conflict with disk';
|
|
5660
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
5661
|
+
if (!entry)
|
|
5662
|
+
return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
|
|
5663
|
+
return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
|
|
5664
|
+
}
|
|
4720
5665
|
async handleMaterialize(input) {
|
|
4721
5666
|
const store = this.getStore();
|
|
4722
5667
|
const allWritten = [];
|
|
@@ -4731,7 +5676,7 @@ export class WorkspaceModule {
|
|
|
4731
5676
|
// is refused; beneath a directory, the push leaves out what the include
|
|
4732
5677
|
// doesn't cover.
|
|
4733
5678
|
if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
|
|
4734
|
-
return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
|
|
5679
|
+
return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
|
|
4735
5680
|
}
|
|
4736
5681
|
}
|
|
4737
5682
|
let mountsToMaterialize;
|
|
@@ -4741,13 +5686,14 @@ export class WorkspaceModule {
|
|
|
4741
5686
|
else if (input.mount) {
|
|
4742
5687
|
const m = this.mounts.get(input.mount);
|
|
4743
5688
|
if (!m) {
|
|
4744
|
-
return { success: false, error:
|
|
5689
|
+
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 };
|
|
4745
5690
|
}
|
|
4746
5691
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
4747
5692
|
}
|
|
4748
5693
|
else {
|
|
5694
|
+
// Not a mount running direct, whose writes are on disk already.
|
|
4749
5695
|
mountsToMaterialize = [...this.mounts.entries()]
|
|
4750
|
-
.filter(([, m]) => m.config.mode === 'read-write')
|
|
5696
|
+
.filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
|
|
4751
5697
|
.map(([name, mount]) => ({ name, mount }));
|
|
4752
5698
|
}
|
|
4753
5699
|
// Branch guard, scoped to the mounts actually being materialized: a
|
|
@@ -4766,6 +5712,12 @@ export class WorkspaceModule {
|
|
|
4766
5712
|
const guarded = [];
|
|
4767
5713
|
let proceeded = 0;
|
|
4768
5714
|
for (const { name, mount } of mountsToMaterialize) {
|
|
5715
|
+
// Only a writable direct mount the caller named gets here: it keeps no
|
|
5716
|
+
// tree to push. A read-only one is named as any read-only mount is.
|
|
5717
|
+
if (mount.direct && mount.config.mode !== 'read-only') {
|
|
5718
|
+
blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
|
|
5719
|
+
continue;
|
|
5720
|
+
}
|
|
4769
5721
|
if (mount.config.mode === 'read-only') {
|
|
4770
5722
|
// Only a mount the caller named gets here read-only (a bare
|
|
4771
5723
|
// materialize takes the read-write ones): say why nothing was written.
|
|
@@ -4848,15 +5800,20 @@ export class WorkspaceModule {
|
|
|
4848
5800
|
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
4849
5801
|
return {
|
|
4850
5802
|
success: false,
|
|
4851
|
-
error:
|
|
5803
|
+
error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
|
|
4852
5804
|
isError: true,
|
|
4853
5805
|
};
|
|
4854
5806
|
}
|
|
5807
|
+
const held = explicit;
|
|
5808
|
+
const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
|
|
5809
|
+
&& allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
|
|
5810
|
+
&& !this.holdsAnythingAt(held.mount, held.relativePath);
|
|
4855
5811
|
return {
|
|
4856
5812
|
success: true,
|
|
4857
5813
|
data: {
|
|
4858
5814
|
materialized: allWritten,
|
|
4859
5815
|
count: allWritten.length,
|
|
5816
|
+
...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
|
|
4860
5817
|
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
4861
5818
|
...(pendingDeletions.length > 0
|
|
4862
5819
|
? {
|
|
@@ -4884,7 +5841,8 @@ export class WorkspaceModule {
|
|
|
4884
5841
|
return [];
|
|
4885
5842
|
const store = this.getStore();
|
|
4886
5843
|
const mount = this.mounts.get(mountName);
|
|
4887
|
-
|
|
5844
|
+
// A direct mount keeps no tree to push: disk is its only copy.
|
|
5845
|
+
if (!mount || mount.config.mode === 'read-only' || mount.direct)
|
|
4888
5846
|
return [];
|
|
4889
5847
|
// Reset tracking — we're deliberately materializing on the new branch
|
|
4890
5848
|
mount.lastMaterializedBranchId = null;
|
|
@@ -4917,7 +5875,13 @@ export class WorkspaceModule {
|
|
|
4917
5875
|
const allIgnored = [];
|
|
4918
5876
|
const rootsAccepted = [];
|
|
4919
5877
|
if (input.acceptRoot && input.path) {
|
|
4920
|
-
|
|
5878
|
+
const named = input.path.split('/')[0];
|
|
5879
|
+
return {
|
|
5880
|
+
success: false,
|
|
5881
|
+
isError: true,
|
|
5882
|
+
error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
|
|
5883
|
+
+ `${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.`),
|
|
5884
|
+
};
|
|
4921
5885
|
}
|
|
4922
5886
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
4923
5887
|
let targets;
|
|
@@ -4927,14 +5891,14 @@ export class WorkspaceModule {
|
|
|
4927
5891
|
// is refused; beneath a directory, the walk passes over what the include
|
|
4928
5892
|
// doesn't cover.
|
|
4929
5893
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4930
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5894
|
+
return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4931
5895
|
}
|
|
4932
5896
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
4933
5897
|
}
|
|
4934
5898
|
else if (input.mount) {
|
|
4935
5899
|
const m = this.mounts.get(input.mount);
|
|
4936
5900
|
if (!m) {
|
|
4937
|
-
return { success: false, error:
|
|
5901
|
+
return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
4938
5902
|
}
|
|
4939
5903
|
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
4940
5904
|
}
|
|
@@ -4948,6 +5912,7 @@ export class WorkspaceModule {
|
|
|
4948
5912
|
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
4949
5913
|
continue;
|
|
4950
5914
|
}
|
|
5915
|
+
let namedDirectory = false;
|
|
4951
5916
|
const pass = await this.withMount(mount, async () => {
|
|
4952
5917
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount))
|
|
4953
5918
|
rootsAccepted.push(name);
|
|
@@ -4962,6 +5927,7 @@ export class WorkspaceModule {
|
|
|
4962
5927
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
4963
5928
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
4964
5929
|
this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
5930
|
+
namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
|
|
4965
5931
|
// named: ignore patterns covering the path asked for don't apply
|
|
4966
5932
|
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
4967
5933
|
const opts = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
@@ -5025,7 +5991,7 @@ export class WorkspaceModule {
|
|
|
5025
5991
|
// without a path passes over it, so nothing else keeps it current
|
|
5026
5992
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
5027
5993
|
// synced at all, and the note says so.
|
|
5028
|
-
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
5994
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
|
|
5029
5995
|
if (passedOver !== undefined) {
|
|
5030
5996
|
allIgnored.push({
|
|
5031
5997
|
mount: name,
|
|
@@ -5038,10 +6004,17 @@ export class WorkspaceModule {
|
|
|
5038
6004
|
});
|
|
5039
6005
|
}
|
|
5040
6006
|
}
|
|
6007
|
+
const named = input.path !== undefined && input.path !== '' ? targets[0] : null;
|
|
6008
|
+
const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
|
|
6009
|
+
&& allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
|
|
6010
|
+
&& allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
|
|
6011
|
+
&& !this.holdsAnythingAt(named.mount, named.relativePath)
|
|
6012
|
+
&& await this.absentOnDisk(named.mount, named.relativePath);
|
|
5041
6013
|
return {
|
|
5042
6014
|
success: true,
|
|
5043
6015
|
data: {
|
|
5044
6016
|
results: allResults,
|
|
6017
|
+
...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
|
|
5045
6018
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
5046
6019
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
5047
6020
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
@@ -5108,9 +6081,9 @@ export class WorkspaceModule {
|
|
|
5108
6081
|
* on disk (a shell command's files, a deletion) is in the workspace when the
|
|
5109
6082
|
* agent next looks. On a mount over its walkCap, that holds for the first
|
|
5110
6083
|
* walkCap entries in the walk's order, which every batch's scan takes; files
|
|
5111
|
-
* past them come in through syncs
|
|
5112
|
-
*
|
|
5113
|
-
* outlasts the deadline stops holding the round: the miss is recorded on
|
|
6084
|
+
* past them come in through syncs, the listings that reach them, and a read
|
|
6085
|
+
* of the path, which takes disk's change in first as a listing does. A scan
|
|
6086
|
+
* that outlasts the deadline stops holding the round: the miss is recorded on
|
|
5114
6087
|
* the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
|
|
5115
6088
|
* event, and the scan continues, deciding on the branch selected when it
|
|
5116
6089
|
* applies.
|
|
@@ -5287,13 +6260,13 @@ export async function inOrder(items, width, work, each) {
|
|
|
5287
6260
|
}
|
|
5288
6261
|
}
|
|
5289
6262
|
/** read_image's answer for an image, wherever it was read from. */
|
|
5290
|
-
function imageResult(path, image) {
|
|
6263
|
+
function imageResult(path, image, note) {
|
|
5291
6264
|
return {
|
|
5292
6265
|
success: true,
|
|
5293
6266
|
data: [
|
|
5294
6267
|
{
|
|
5295
6268
|
type: 'text',
|
|
5296
|
-
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
6269
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
|
|
5297
6270
|
},
|
|
5298
6271
|
{
|
|
5299
6272
|
type: 'image',
|
|
@@ -5338,16 +6311,35 @@ const UNREADABLE_INTENT = "its workspace intent can't be read; a sync of the pat
|
|
|
5338
6311
|
function watchOf(config) {
|
|
5339
6312
|
return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
|
|
5340
6313
|
}
|
|
6314
|
+
/**
|
|
6315
|
+
* Why a write, edit, deletion or peer module's writeBinary to this mount is
|
|
6316
|
+
* refused, or null where it may go ahead: a read-only mount, and a mount
|
|
6317
|
+
* configured direct that runs as 'chronicle' this session (chooseKinds),
|
|
6318
|
+
* since the store holds changes there that disk lacks. A write there would
|
|
6319
|
+
* stay in the store, beside an agent told the mount is its checkout, and
|
|
6320
|
+
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6321
|
+
* the next start once none is held runs it direct.
|
|
6322
|
+
*/
|
|
6323
|
+
function writeRefusal(mount, writable = []) {
|
|
6324
|
+
const name = mount.config.name;
|
|
6325
|
+
if (mount.config.mode === 'read-only') {
|
|
6326
|
+
return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
|
|
6327
|
+
+ (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
|
|
6328
|
+
}
|
|
6329
|
+
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6330
|
+
return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6331
|
+
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
6332
|
+
+ `(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.`;
|
|
6333
|
+
}
|
|
6334
|
+
return null;
|
|
6335
|
+
}
|
|
5341
6336
|
/**
|
|
5342
6337
|
* What a direct mount's config sets that would never act there, each with
|
|
5343
|
-
* the way out (MountConfig.backend): a direct mount
|
|
5344
|
-
*
|
|
6338
|
+
* the way out (MountConfig.backend): a direct mount has no watcher to tune,
|
|
6339
|
+
* no change event to wake on and no push to make. Either mode is its own.
|
|
5345
6340
|
*/
|
|
5346
6341
|
function directRefusals(mount) {
|
|
5347
6342
|
const refused = [];
|
|
5348
|
-
if (mount.mode !== 'read-only') {
|
|
5349
|
-
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)}`);
|
|
5350
|
-
}
|
|
5351
6343
|
if (mount.watch !== undefined && mount.watch !== 'never')
|
|
5352
6344
|
refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
|
|
5353
6345
|
for (const key of ['watchDebounceMs', 'watchRootPollMs']) {
|
|
@@ -5361,6 +6353,263 @@ function directRefusals(mount) {
|
|
|
5361
6353
|
refused.push('autoMaterialize would never push (leave it out)');
|
|
5362
6354
|
return refused;
|
|
5363
6355
|
}
|
|
6356
|
+
/** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
|
|
6357
|
+
const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
|
|
6358
|
+
/**
|
|
6359
|
+
* A saved record as saveDirectHeads writes it, or a throw naming what isn't:
|
|
6360
|
+
* a list of entries, each a mount's name, root and agents, and each agent's
|
|
6361
|
+
* branch, head and, where set, notice. Checked whole, carried entries
|
|
6362
|
+
* included, so a record of another shape is refused at load rather than
|
|
6363
|
+
* carried into the next save.
|
|
6364
|
+
*/
|
|
6365
|
+
function checkedHeads(value) {
|
|
6366
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6367
|
+
if (!Array.isArray(value))
|
|
6368
|
+
throw new Error("the record isn't of the shape saved");
|
|
6369
|
+
for (const saved of value) {
|
|
6370
|
+
if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
|
|
6371
|
+
throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
|
|
6372
|
+
}
|
|
6373
|
+
for (const [agent, head] of Object.entries(saved.agents)) {
|
|
6374
|
+
if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
|
|
6375
|
+
|| (head.notice !== undefined && typeof head.notice !== 'string')) {
|
|
6376
|
+
throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
}
|
|
6380
|
+
return value;
|
|
6381
|
+
}
|
|
6382
|
+
/** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
|
|
6383
|
+
function canonicalHeads(snapshot) {
|
|
6384
|
+
return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
|
|
6385
|
+
const { branchId, after, notice } = agents[agent];
|
|
6386
|
+
return [agent, branchId, after, notice ?? null];
|
|
6387
|
+
})]).sort((a, b) => {
|
|
6388
|
+
const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
|
|
6389
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
6390
|
+
}));
|
|
6391
|
+
}
|
|
6392
|
+
/**
|
|
6393
|
+
* How many times a direct edit reads and plans a file another writer keeps
|
|
6394
|
+
* changing: enough to land on top of a writer that changes it now and then,
|
|
6395
|
+
* such as a formatter or a build, and bounded for one that never stops.
|
|
6396
|
+
*/
|
|
6397
|
+
const DIRECT_EDIT_ATTEMPTS = 3;
|
|
6398
|
+
/** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
|
|
6399
|
+
function directReason(error) {
|
|
6400
|
+
if (error instanceof FileChanged)
|
|
6401
|
+
return 'it changed on disk while this call was being applied';
|
|
6402
|
+
// A deletion's root check (unlinkContained) has a store-backed mount's
|
|
6403
|
+
// words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
|
|
6404
|
+
if (error instanceof Error && error.message === ROOT_REPLACED)
|
|
6405
|
+
return ROOT_MOVED;
|
|
6406
|
+
return error instanceof Error ? error.message : String(error);
|
|
6407
|
+
}
|
|
6408
|
+
function directWayOn(error) {
|
|
6409
|
+
return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
|
|
6410
|
+
}
|
|
6411
|
+
function directDidNothing(tool, path, error) {
|
|
6412
|
+
const on = directWayOn(error);
|
|
6413
|
+
return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
|
|
6414
|
+
}
|
|
6415
|
+
/** "a", "a and b", or "a, b and c". */
|
|
6416
|
+
function listed(items) {
|
|
6417
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
6418
|
+
}
|
|
6419
|
+
/**
|
|
6420
|
+
* The note for a direct write that gave a hard-linked path a file of its
|
|
6421
|
+
* own: the other links keep the old text. Null for a file with none.
|
|
6422
|
+
*/
|
|
6423
|
+
function linksNote(path, staged, tool) {
|
|
6424
|
+
const n = staged.otherLinks;
|
|
6425
|
+
if (n <= 0)
|
|
6426
|
+
return null;
|
|
6427
|
+
return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
|
|
6428
|
+
+ `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
|
|
6429
|
+
}
|
|
6430
|
+
/**
|
|
6431
|
+
* A refusal of openContainedFile's as a direct tool gives it: its message,
|
|
6432
|
+
* and for one that names only the path, its errno too, as a direct read's
|
|
6433
|
+
* answer gives it (bytesOnDisk).
|
|
6434
|
+
*/
|
|
6435
|
+
function containedRefusal(err, path) {
|
|
6436
|
+
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
6437
|
+
return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
|
|
6438
|
+
}
|
|
6439
|
+
function deletedOnDisk(path) {
|
|
6440
|
+
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.`;
|
|
6441
|
+
}
|
|
6442
|
+
function uncheckedFile(shown, note) {
|
|
6443
|
+
if (note === DIRECTORY)
|
|
6444
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6445
|
+
const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
|
|
6446
|
+
: note === ROOT_REPLACED ? null
|
|
6447
|
+
: note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
|
|
6448
|
+
: note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
|
|
6449
|
+
: note === NOT_REGULAR ? onlyTheHost('put a regular file there')
|
|
6450
|
+
: note === CHANGED ? 'Calling again checks it afresh.'
|
|
6451
|
+
: 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
|
|
6452
|
+
return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
|
|
6453
|
+
}
|
|
6454
|
+
function namesRoot(path, mount) {
|
|
6455
|
+
return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
|
|
6456
|
+
}
|
|
6457
|
+
function tooLargeToWrite(path, bytes, maxSize) {
|
|
6458
|
+
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.');
|
|
6459
|
+
}
|
|
6460
|
+
function pastSizeLimit(what, maxSize, outcome, within) {
|
|
6461
|
+
return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
|
|
6462
|
+
+ "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
|
|
6463
|
+
}
|
|
6464
|
+
function sentence(text) {
|
|
6465
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
6466
|
+
}
|
|
6467
|
+
function unreadable(shown, errno) {
|
|
6468
|
+
return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
|
|
6469
|
+
}
|
|
6470
|
+
function readRefusal(err, path) {
|
|
6471
|
+
const shown = JSON.stringify(path);
|
|
6472
|
+
if (err instanceof RootUnavailable) {
|
|
6473
|
+
if (err.refusal)
|
|
6474
|
+
return err.refusal.message;
|
|
6475
|
+
const mount = JSON.stringify(err.mount);
|
|
6476
|
+
if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
|
|
6477
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
|
|
6478
|
+
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")}`;
|
|
6479
|
+
}
|
|
6480
|
+
if (err.errno !== undefined) {
|
|
6481
|
+
return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
|
|
6482
|
+
+ errnoWayOn(err.errno, ROOT_RECHECK, true);
|
|
6483
|
+
}
|
|
6484
|
+
return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
|
|
6485
|
+
}
|
|
6486
|
+
return `${shown} can't be read: ${sentence(err.reason)}`;
|
|
6487
|
+
}
|
|
6488
|
+
if (err.stage === 'readlink')
|
|
6489
|
+
return sentence(err.message);
|
|
6490
|
+
switch (err.code) {
|
|
6491
|
+
case 'directory':
|
|
6492
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6493
|
+
case 'symlink':
|
|
6494
|
+
return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
|
|
6495
|
+
+ 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
|
|
6496
|
+
case 'escape':
|
|
6497
|
+
return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
|
|
6498
|
+
+ 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
|
|
6499
|
+
case 'changed':
|
|
6500
|
+
return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
|
|
6501
|
+
case 'not_found':
|
|
6502
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR')
|
|
6503
|
+
return `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
6504
|
+
if (err instanceof NotRegularFile) {
|
|
6505
|
+
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')}`;
|
|
6506
|
+
}
|
|
6507
|
+
if (err.stage === 'realpath')
|
|
6508
|
+
return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
|
|
6509
|
+
return unreadable(shown, err.errno);
|
|
6510
|
+
default:
|
|
6511
|
+
return sentence(err.message);
|
|
6512
|
+
}
|
|
6513
|
+
}
|
|
6514
|
+
function contentsSubject(what, path) {
|
|
6515
|
+
return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
|
|
6516
|
+
}
|
|
6517
|
+
function inTheStore(stored) {
|
|
6518
|
+
return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
|
|
6519
|
+
}
|
|
6520
|
+
function lostInStore(lost) {
|
|
6521
|
+
return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
|
|
6522
|
+
}
|
|
6523
|
+
function lostOnDisk(lost) {
|
|
6524
|
+
return lost.disk === 'other' ? "disk's copy holds other contents"
|
|
6525
|
+
: lost.disk === 'none' ? 'disk has no copy'
|
|
6526
|
+
: `disk's copy can't be read (${lost.disk.unreadable})`;
|
|
6527
|
+
}
|
|
6528
|
+
function lostText(lost, the = 'The') {
|
|
6529
|
+
return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
|
|
6530
|
+
}
|
|
6531
|
+
function nothingToRead(lost) {
|
|
6532
|
+
return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
|
|
6533
|
+
}
|
|
6534
|
+
function lostRemedy(lost) {
|
|
6535
|
+
const after = remedyOfLost(lost);
|
|
6536
|
+
if (!lost.reopen)
|
|
6537
|
+
return after;
|
|
6538
|
+
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. "
|
|
6539
|
+
+ `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
|
|
6540
|
+
}
|
|
6541
|
+
function remedyOfLost(lost) {
|
|
6542
|
+
const rewrite = lost.writable && 'missing' in lost.store;
|
|
6543
|
+
if (lost.disk === 'other')
|
|
6544
|
+
return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
|
|
6545
|
+
if (lost.writable)
|
|
6546
|
+
return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
|
|
6547
|
+
return lost.disk === 'none'
|
|
6548
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy."
|
|
6549
|
+
: "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.";
|
|
6550
|
+
}
|
|
6551
|
+
/**
|
|
6552
|
+
* The notice that writes an agent made direct remain on disk from
|
|
6553
|
+
* turns the agent's history no longer holds. `git status` stands beside
|
|
6554
|
+
* `git diff` because a departed turn's new file is untracked, which `git
|
|
6555
|
+
* diff` doesn't show; and the warning says "unstaged", since `git checkout
|
|
6556
|
+
* -- <file>` puts back a file's staged text, so a staged change survives it.
|
|
6557
|
+
*/
|
|
6558
|
+
function headMoveText(mounts) {
|
|
6559
|
+
const one = mounts.length === 1;
|
|
6560
|
+
return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6561
|
+
+ `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
|
|
6562
|
+
+ 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
|
|
6563
|
+
}
|
|
6564
|
+
/**
|
|
6565
|
+
* One file's edits planned against its bytes, as both kinds of mount plan
|
|
6566
|
+
* them: its text decoded, every edit matched in it (edit-plan.ts), and the
|
|
6567
|
+
* result held to the mount's size limit. Each problem found goes into
|
|
6568
|
+
* `problems`, and then, as for a file named only by edits whose own input
|
|
6569
|
+
* was refused, there's no plan (null).
|
|
6570
|
+
*/
|
|
6571
|
+
function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems) {
|
|
6572
|
+
// Decoding would turn each invalid sequence into U+FFFD, and writing
|
|
6573
|
+
// the text back would change bytes the edits never named.
|
|
6574
|
+
const file = decodeForEdit(bytes);
|
|
6575
|
+
if (!file) {
|
|
6576
|
+
problems.push({
|
|
6577
|
+
kind: 'file',
|
|
6578
|
+
edits: target.edits,
|
|
6579
|
+
path: target.path,
|
|
6580
|
+
text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
|
|
6581
|
+
+ '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.',
|
|
6582
|
+
whole: false,
|
|
6583
|
+
});
|
|
6584
|
+
return null;
|
|
6585
|
+
}
|
|
6586
|
+
if (!target.specs.length)
|
|
6587
|
+
return null;
|
|
6588
|
+
const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
|
|
6589
|
+
if ('problems' in planned) {
|
|
6590
|
+
// One at a time: a call of many edits can have more problems
|
|
6591
|
+
// than a spread passes as arguments.
|
|
6592
|
+
for (const problem of planned.problems)
|
|
6593
|
+
problems.push({ ...problem, path: target.path });
|
|
6594
|
+
return null;
|
|
6595
|
+
}
|
|
6596
|
+
// The planner refuses a file its counts take past the limit before
|
|
6597
|
+
// building it, from the least its bytes can be; one it built past
|
|
6598
|
+
// the limit, as its CRs or multi-byte characters can take it, is
|
|
6599
|
+
// refused here.
|
|
6600
|
+
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
6601
|
+
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
6602
|
+
problems.push({
|
|
6603
|
+
kind: 'file',
|
|
6604
|
+
edits: target.edits,
|
|
6605
|
+
path: target.path,
|
|
6606
|
+
text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize, "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
|
|
6607
|
+
whole: false,
|
|
6608
|
+
});
|
|
6609
|
+
return null;
|
|
6610
|
+
}
|
|
6611
|
+
return planned.plan;
|
|
6612
|
+
}
|
|
5364
6613
|
/**
|
|
5365
6614
|
* Whether a tree entry is what a change leaves: for a set, an entry with its
|
|
5366
6615
|
* blob, size and mode; for a removal, none.
|
|
@@ -5390,16 +6639,38 @@ function changesLanded(store, treeStateId, changes, setError) {
|
|
|
5390
6639
|
* change is stored isn't known. Its message says so after both errors.
|
|
5391
6640
|
*/
|
|
5392
6641
|
class CommitUnknownError extends Error {
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
constructor(
|
|
6642
|
+
setCause;
|
|
6643
|
+
readCause;
|
|
6644
|
+
constructor(setCause, readCause) {
|
|
5396
6645
|
const text = (error) => (error instanceof Error ? error.message : String(error));
|
|
5397
|
-
super(`${text(
|
|
6646
|
+
super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
|
|
6647
|
+
this.setCause = setCause;
|
|
6648
|
+
this.readCause = readCause;
|
|
5398
6649
|
this.name = 'CommitUnknownError';
|
|
5399
|
-
this.setError = text(setError);
|
|
5400
|
-
this.readError = text(readError);
|
|
5401
6650
|
}
|
|
5402
6651
|
}
|
|
6652
|
+
function storeWords(error) {
|
|
6653
|
+
const code = errnoCode(error);
|
|
6654
|
+
return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
|
|
6655
|
+
}
|
|
6656
|
+
class NotStored extends Error {
|
|
6657
|
+
thrown;
|
|
6658
|
+
constructor(thrown) {
|
|
6659
|
+
super('stopped before the change was stored');
|
|
6660
|
+
this.thrown = thrown;
|
|
6661
|
+
}
|
|
6662
|
+
}
|
|
6663
|
+
class AlreadyHeld extends Error {
|
|
6664
|
+
thrown;
|
|
6665
|
+
constructor(thrown) {
|
|
6666
|
+
super(thrown instanceof Error ? thrown.message : String(thrown));
|
|
6667
|
+
this.thrown = thrown;
|
|
6668
|
+
}
|
|
6669
|
+
}
|
|
6670
|
+
function notStored(error) {
|
|
6671
|
+
return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
|
|
6672
|
+
? error : new NotStored(error);
|
|
6673
|
+
}
|
|
5403
6674
|
/**
|
|
5404
6675
|
* A store's set that threw after recording the change (changesLanded): the
|
|
5405
6676
|
* entries are set and their intents follow, and the store's error goes on in
|
|
@@ -5416,6 +6687,11 @@ class StoredThenFailed extends Error {
|
|
|
5416
6687
|
this.name = 'StoredThenFailed';
|
|
5417
6688
|
}
|
|
5418
6689
|
}
|
|
6690
|
+
const ONCE_REOPENED = ', once the store is reopened';
|
|
6691
|
+
const DISK_ORDER = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
|
|
6692
|
+
function furthestFromDisk(values) {
|
|
6693
|
+
return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
|
|
6694
|
+
}
|
|
5419
6695
|
/**
|
|
5420
6696
|
* The answer to a write, edit or delete that the workspace stored but
|
|
5421
6697
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -5427,7 +6703,7 @@ class StoredThenFailed extends Error {
|
|
|
5427
6703
|
* script. A note comes last: an edit's on which copy it applied to, or a
|
|
5428
6704
|
* deletion's that it unlinked nothing.
|
|
5429
6705
|
*/
|
|
5430
|
-
function storedButUnfinished(tool, path, outcome, note = null) {
|
|
6706
|
+
function storedButUnfinished(tool, path, outcome, note = null, reopen = false) {
|
|
5431
6707
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
5432
6708
|
const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
|
|
5433
6709
|
+ (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
|
|
@@ -5436,21 +6712,53 @@ function storedButUnfinished(tool, path, outcome, note = null) {
|
|
|
5436
6712
|
: 'storeFailed' in outcome
|
|
5437
6713
|
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
5438
6714
|
: tool === 'delete'
|
|
5439
|
-
?
|
|
5440
|
-
:
|
|
5441
|
-
: ` The rest of the call failed: ${outcome.threw}
|
|
5442
|
-
return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
|
|
6715
|
+
? ` 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 : ''}.`
|
|
6716
|
+
: ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
|
|
6717
|
+
: ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
|
|
6718
|
+
return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
|
|
5443
6719
|
}
|
|
5444
6720
|
/**
|
|
5445
|
-
*
|
|
5446
|
-
* last
|
|
5447
|
-
*
|
|
6721
|
+
* What a read says about the copy it shows, when that isn't simply the one the
|
|
6722
|
+
* workspace last agreed on with disk: disk's newer text, taken in first; the
|
|
6723
|
+
* workspace's copy, because disk's couldn't be checked or disk has none that
|
|
6724
|
+
* anything accounts for; or a draft in conflict with disk's change. A file
|
|
6725
|
+
* the workspace didn't hold has no older copy, so taking it in needs no word.
|
|
6726
|
+
* A conflict's note names the ways to settle it that work on the path's mount.
|
|
5448
6727
|
*/
|
|
5449
|
-
function
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
6728
|
+
function whichCopyRead(report, tookChange, readOnly) {
|
|
6729
|
+
if (tookChange) {
|
|
6730
|
+
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.'];
|
|
6731
|
+
}
|
|
6732
|
+
if (report?.state === 'unverified') {
|
|
6733
|
+
return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
|
|
6734
|
+
}
|
|
6735
|
+
if (report?.state === 'disk-missing-provenance-unknown') {
|
|
6736
|
+
return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
|
|
6737
|
+
}
|
|
6738
|
+
if (report?.state === 'conflict') {
|
|
6739
|
+
const kind = report.conflict?.kind ?? 'both-changed';
|
|
6740
|
+
// A sync takes disk's state as it is now, which a recorded conflict's kind
|
|
6741
|
+
// doesn't follow: where disk has no copy, the sync takes its deletion, and
|
|
6742
|
+
// the workspace's copy goes with it. Where disk has one, the sync keeps it
|
|
6743
|
+
// and gives up the workspace's, taking disk's in only where the workspace
|
|
6744
|
+
// can hold it (not binary or too large). A read-only mount can hold a
|
|
6745
|
+
// copy disk doesn't, such as one a branch switch selected, but a
|
|
6746
|
+
// materialize, forced or not, writes nothing to it.
|
|
6747
|
+
const settle = settleConflict(report.diskNow, readOnly);
|
|
6748
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0].toUpperCase()}${settle.slice(1)}`];
|
|
6749
|
+
}
|
|
6750
|
+
return [];
|
|
6751
|
+
}
|
|
6752
|
+
function settleConflict(diskNow, readOnly) {
|
|
6753
|
+
const sync = diskNow === 'absent'
|
|
6754
|
+
? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
6755
|
+
: 'sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6756
|
+
const force = readOnly
|
|
6757
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6758
|
+
: diskNow === 'absent'
|
|
6759
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6760
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6761
|
+
return `${sync}${force}`;
|
|
5454
6762
|
}
|
|
5455
6763
|
/**
|
|
5456
6764
|
* The least each tool's whole-number fields may be, beside their schemas,
|
|
@@ -5517,6 +6825,81 @@ function inputProblem(schema, input, minimums = {}) {
|
|
|
5517
6825
|
}
|
|
5518
6826
|
return null;
|
|
5519
6827
|
}
|
|
6828
|
+
/** How many files a grep note names before it gives the count of the rest. */
|
|
6829
|
+
const NOTE_FILES_MAX = 20;
|
|
6830
|
+
/**
|
|
6831
|
+
* The files a grep note names, in the order grep searched them, as its
|
|
6832
|
+
* results are: at most NOTE_FILES_MAX of them, then how many more and how to
|
|
6833
|
+
* see them. Each is named by its mount-prefixed path, with what `detail` says
|
|
6834
|
+
* of it.
|
|
6835
|
+
*/
|
|
6836
|
+
function namedFiles(files, detail) {
|
|
6837
|
+
const names = [...files.keys()];
|
|
6838
|
+
const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name))}`);
|
|
6839
|
+
const rest = names.length - shown.length;
|
|
6840
|
+
return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
|
|
6841
|
+
}
|
|
6842
|
+
/** What a note says of a conflict's disk version, which read doesn't show. */
|
|
6843
|
+
const ofVersion = (version) => version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
|
|
6844
|
+
/**
|
|
6845
|
+
* grep's note for a pattern that names a character its lines leave out, where
|
|
6846
|
+
* a file it searched has one: it says how many files searched have it and
|
|
6847
|
+
* names them, so a search for a CR or a BOM still finds the files that have
|
|
6848
|
+
* them. The CR note gives each file's ends, CRLF or mixed, and either note
|
|
6849
|
+
* names a conflict's disk version as that version. The note says a
|
|
6850
|
+
* character read shows it in the workspace's copy, since grep also searches a
|
|
6851
|
+
* conflict's disk version, which read doesn't show. A direct mount keeps no
|
|
6852
|
+
* workspace copy, and its read shows disk's file, so on a host with one
|
|
6853
|
+
* (`direct`) the note names that too, as the tools' descriptions mention
|
|
6854
|
+
* direct mounts only on such a host.
|
|
6855
|
+
*/
|
|
6856
|
+
function leftOutNote(pattern, crlfFiles, bomFiles, direct) {
|
|
6857
|
+
if (crlfFiles.size === 0 && bomFiles.size === 0)
|
|
6858
|
+
return {};
|
|
6859
|
+
const named = namedLeftOut(pattern);
|
|
6860
|
+
const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
|
|
6861
|
+
const count = (n, one, many) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
|
|
6862
|
+
const notes = [
|
|
6863
|
+
named.cr && crlfFiles.size > 0
|
|
6864
|
+
? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
|
|
6865
|
+
+ `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
|
|
6866
|
+
+ `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}.`
|
|
6867
|
+
: null,
|
|
6868
|
+
named.bom && bomFiles.size > 0
|
|
6869
|
+
? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
|
|
6870
|
+
+ `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
|
|
6871
|
+
+ `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'}.`
|
|
6872
|
+
: null,
|
|
6873
|
+
].filter((n) => n !== null);
|
|
6874
|
+
return notes.length ? { note: notes.join(' ') } : {};
|
|
6875
|
+
}
|
|
6876
|
+
/**
|
|
6877
|
+
* Which of the characters grep's lines leave out a pattern names: a CR, as a
|
|
6878
|
+
* literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
|
|
6879
|
+
* before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
|
|
6880
|
+
* before a literal one. An escape counts only where its backslash isn't itself
|
|
6881
|
+
* escaped. A pattern compiled without flags can match a CR other ways too,
|
|
6882
|
+
* such as `\15` in a pattern of fewer than 15 groups or a class range that
|
|
6883
|
+
* spans U+000D, and those go unnoticed.
|
|
6884
|
+
*/
|
|
6885
|
+
function namedLeftOut(pattern) {
|
|
6886
|
+
const named = { cr: false, bom: false };
|
|
6887
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
6888
|
+
if (pattern[i] === '\r')
|
|
6889
|
+
named.cr = true;
|
|
6890
|
+
else if (pattern[i] === '\uFEFF')
|
|
6891
|
+
named.bom = true;
|
|
6892
|
+
else if (pattern[i] === '\\') {
|
|
6893
|
+
const next = pattern.slice(i + 1, i + 6);
|
|
6894
|
+
if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next))
|
|
6895
|
+
named.cr = true;
|
|
6896
|
+
else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next))
|
|
6897
|
+
named.bom = true;
|
|
6898
|
+
i++;
|
|
6899
|
+
}
|
|
6900
|
+
}
|
|
6901
|
+
return named;
|
|
6902
|
+
}
|
|
5520
6903
|
/**
|
|
5521
6904
|
* A glob's reading in the mount its first segment names, in place of the
|
|
5522
6905
|
* pattern as written there. Every path the tools take and return is
|
|
@@ -5568,13 +6951,13 @@ function globToRegex(pattern) {
|
|
|
5568
6951
|
closeIdx = j;
|
|
5569
6952
|
}
|
|
5570
6953
|
if (closeIdx < 0)
|
|
5571
|
-
throw new
|
|
6954
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
|
|
5572
6955
|
const group = pattern.slice(i + 1, closeIdx);
|
|
5573
6956
|
if (group.includes('{')) {
|
|
5574
|
-
throw new
|
|
6957
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
|
|
5575
6958
|
}
|
|
5576
6959
|
if (!group.includes(',')) {
|
|
5577
|
-
throw new
|
|
6960
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
|
|
5578
6961
|
}
|
|
5579
6962
|
// A `/` right after the group goes into each alternative, as `{a,b}/x`
|
|
5580
6963
|
// and `{a/,b/}x` name the same paths, so an alternative's own `**`
|