@animalabs/agent-framework 0.27.0 → 0.29.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 +145 -41
- package/dist/src/agent.d.ts +29 -2
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +73 -12
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server-core.d.ts.map +1 -1
- package/dist/src/api/mcp-server-core.js +3 -12
- package/dist/src/api/mcp-server-core.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +11 -5
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +5 -2
- package/dist/src/auto-continue.d.ts.map +1 -1
- package/dist/src/auto-continue.js +9 -5
- package/dist/src/auto-continue.js.map +1 -1
- package/dist/src/code-execution/isolate/runner.d.ts +7 -5
- package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/runner.js +7 -5
- package/dist/src/code-execution/isolate/runner.js.map +1 -1
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +3 -2
- 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 +468 -125
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3651 -1111
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +133 -13
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +654 -59
- 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/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 +173 -2
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/heartbeat/heartbeat.d.ts +202 -0
- package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat/heartbeat.js +778 -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/held-writes.d.ts +3 -3
- package/dist/src/held-writes.js +3 -3
- package/dist/src/host-presentation.d.ts +170 -0
- package/dist/src/host-presentation.d.ts.map +1 -0
- package/dist/src/host-presentation.js +561 -0
- package/dist/src/host-presentation.js.map +1 -0
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/line-reader.d.ts +4 -0
- package/dist/src/line-reader.d.ts.map +1 -0
- package/dist/src/line-reader.js +59 -0
- package/dist/src/line-reader.js.map +1 -0
- 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 +34 -6
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +133 -54
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +2 -1
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +1 -0
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +16 -4
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +4 -3
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +29 -11
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +43 -70
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +11 -7
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +3 -2
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +25 -3
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-handler.js +2 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +12 -40
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +29 -23
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -8
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +70 -30
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
- package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
- package/dist/src/mcpl/skip-reply-wake.js +25 -0
- package/dist/src/mcpl/skip-reply-wake.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-presence.d.ts +66 -14
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
- package/dist/src/mcpl/tool-presence.js +150 -80
- package/dist/src/mcpl/tool-presence.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +10 -3
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
- package/dist/src/mcpl/tool-signature.js +60 -30
- package/dist/src/mcpl/tool-signature.js.map +1 -1
- package/dist/src/mcpl/toolset-host.d.ts +88 -55
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
- package/dist/src/mcpl/toolset-host.js +220 -111
- package/dist/src/mcpl/toolset-host.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts +27 -35
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +313 -91
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +1 -1
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +16 -16
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +31 -13
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +10 -8
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +15 -9
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +4 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +33 -25
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +30 -17
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/sender-name.d.ts +7 -0
- package/dist/src/modules/sender-name.d.ts.map +1 -0
- package/dist/src/modules/sender-name.js +10 -0
- package/dist/src/modules/sender-name.js.map +1 -0
- package/dist/src/modules/workspace/answers.d.ts +26 -0
- package/dist/src/modules/workspace/answers.d.ts.map +1 -0
- package/dist/src/modules/workspace/answers.js +138 -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 +3 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
- package/dist/src/modules/workspace/direct-write.js +59 -24
- package/dist/src/modules/workspace/direct-write.js.map +1 -1
- 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 +2 -1
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +4 -2
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +4 -2
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +67 -50
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +63 -51
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +980 -547
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +125 -82
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +465 -241
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
- package/dist/src/modules/workspace/read-page.js +7 -1
- package/dist/src/modules/workspace/read-page.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +7 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +156 -50
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/result-text.d.ts +1 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/result-text.js.map +1 -1
- 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/types.d.ts +96 -43
- 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/notice-tags.d.ts +79 -0
- package/dist/src/notice-tags.d.ts.map +1 -0
- package/dist/src/notice-tags.js +83 -0
- package/dist/src/notice-tags.js.map +1 -0
- 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 +52 -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.js +1 -1
- package/dist/src/silent-heartbeat.d.ts +1 -53
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +56 -3
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/supplied-text.d.ts +35 -0
- package/dist/src/supplied-text.d.ts.map +1 -0
- package/dist/src/supplied-text.js +141 -0
- package/dist/src/supplied-text.js.map +1 -0
- package/dist/src/thinking-drops.d.ts +4 -11
- package/dist/src/thinking-drops.d.ts.map +1 -1
- package/dist/src/thinking-drops.js +7 -14
- package/dist/src/thinking-drops.js.map +1 -1
- 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 +39 -4
- package/dist/src/tool-args.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +2 -1
- 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 +5 -4
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +8 -4
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +23 -11
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +3 -2
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +24 -26
- 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 +63 -18
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +18 -8
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +58 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +27 -13
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +34 -1
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agent.ts +76 -12
- package/src/api/mcp-server-core.ts +3 -14
- package/src/api/server.ts +11 -5
- package/src/auto-continue.ts +12 -5
- package/src/code-execution/isolate/runner.ts +7 -5
- package/src/code-execution/isolate/scripts.ts +3 -2
- package/src/cut-off-round.ts +98 -0
- package/src/framework.ts +3772 -1138
- package/src/gate/event-gate.ts +705 -60
- package/src/gate/index.ts +9 -1
- package/src/gate/reply-judge.ts +512 -0
- package/src/gate/types.ts +176 -3
- package/src/heartbeat/heartbeat.ts +843 -0
- package/src/heartbeat/reminder-time.ts +107 -0
- package/src/held-writes.ts +3 -3
- package/src/host-presentation.ts +605 -0
- package/src/index.ts +5 -1
- package/src/line-reader.ts +58 -0
- package/src/mcpl/capability-mask.ts +3 -2
- package/src/mcpl/channel-registry.ts +139 -53
- package/src/mcpl/conversation-router.ts +2 -1
- package/src/mcpl/convert-block.ts +17 -4
- package/src/mcpl/hook-orchestrator.ts +4 -3
- package/src/mcpl/inbound-source.ts +45 -70
- package/src/mcpl/modern-connection.ts +11 -7
- package/src/mcpl/protocol-family.ts +24 -3
- package/src/mcpl/push-handler.ts +2 -2
- package/src/mcpl/references.ts +31 -23
- package/src/mcpl/server-connection.ts +76 -34
- package/src/mcpl/skip-reply-wake.ts +35 -0
- package/src/mcpl/tags.ts +13 -2
- package/src/mcpl/tool-presence.ts +149 -82
- package/src/mcpl/tool-signature.ts +64 -32
- package/src/mcpl/toolset-host.ts +212 -110
- package/src/mcpl/toolsets-module.ts +277 -91
- package/src/mcpl/transport.ts +18 -14
- package/src/mcpl/types.ts +32 -13
- package/src/module-registry.ts +14 -9
- package/src/modules/api/index.ts +4 -2
- package/src/modules/discord/index.ts +37 -25
- package/src/modules/history/index.ts +30 -18
- package/src/modules/sender-name.ts +9 -0
- package/src/modules/workspace/answers.ts +151 -0
- package/src/modules/workspace/branch-intent.ts +10 -5
- package/src/modules/workspace/direct-write.ts +59 -23
- package/src/modules/workspace/disk-agreement.ts +52 -21
- package/src/modules/workspace/edit-text.ts +7 -5
- package/src/modules/workspace/effects.ts +69 -50
- package/src/modules/workspace/index.ts +966 -556
- package/src/modules/workspace/observe.ts +497 -242
- package/src/modules/workspace/read-page.ts +7 -1
- package/src/modules/workspace/reconcile.ts +184 -49
- package/src/modules/workspace/result-text.ts +2 -0
- package/src/modules/workspace/sync.ts +16 -0
- package/src/modules/workspace/types.ts +96 -43
- package/src/modules/workspace/watcher.ts +8 -3
- package/src/notice-tags.ts +84 -0
- package/src/prose-drafts.ts +4 -3
- package/src/reasoning-loss-notice.ts +64 -0
- package/src/reasoning-loss.ts +120 -0
- package/src/send-tools.ts +3 -2
- package/src/sent-rows.ts +1 -1
- package/src/silent-heartbeat.ts +4 -3
- package/src/supplied-text.ts +154 -0
- package/src/thinking-drops.ts +10 -16
- package/src/tool-args.ts +38 -4
- package/src/tool-image-ledger.ts +2 -1
- package/src/tool-presentation.ts +5 -4
- package/src/tool-result-guard.ts +26 -11
- package/src/tune-out/coordinator.ts +25 -27
- package/src/tune-out/tools.ts +4 -1
- package/src/types/agent.ts +63 -18
- package/src/types/events.ts +18 -8
- package/src/types/framework.ts +59 -0
- package/src/types/module.ts +28 -13
- package/src/types/trace.ts +36 -1
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
* Provides a unified workspace with mount-based filesystem access,
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
|
+
import { NOTICE_TAG } from '../../notice-tags.js';
|
|
7
8
|
import { constants as fsConstants } from 'node:fs';
|
|
8
9
|
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
9
10
|
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
10
11
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
11
12
|
import { MountWatcher } from './watcher.js';
|
|
12
|
-
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
13
|
-
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
13
|
+
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
14
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
14
15
|
import { BranchIntents } from './branch-intent.js';
|
|
15
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE,
|
|
16
|
+
import { CHANGED, DIRECTORY, IGNORED, NONBLOCK, 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
17
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
17
18
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
18
19
|
import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
@@ -20,10 +21,13 @@ import { cutLine, editList, editReceipt, fitted, refusalText } from './edit-text
|
|
|
20
21
|
import { fileText } from './text.js';
|
|
21
22
|
import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
|
|
22
23
|
import { grepText, textAnswer } from './result-text.js';
|
|
23
|
-
import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
|
|
24
|
+
import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, explained, pushPaths, reconcilePass, settleAdoptionBeforeMutation, wayOn, } from './reconcile.js';
|
|
24
25
|
import { EffectFailed, unlinkContained } from './effects.js';
|
|
26
|
+
import { AWAITS_REOPEN, MOUNT_IT, ROOT_RECHECK, Refusal, UNFOLLOWED_STEP, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
|
|
27
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
28
|
+
import { closestNames } from '../../closest-names.js';
|
|
25
29
|
import { RecordJournal } from '../../record-journal.js';
|
|
26
|
-
import { FileChanged, ROOT_MOVED, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
|
|
30
|
+
import { FileChanged, ROOT_MOVED, WriteRefused, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
|
|
27
31
|
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
28
32
|
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
29
33
|
/**
|
|
@@ -42,6 +46,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
|
42
46
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
43
47
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
44
48
|
const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
49
|
+
const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
|
|
50
|
+
+ 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
|
|
51
|
+
+ 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
|
|
45
52
|
/**
|
|
46
53
|
* How many files a direct mount's grep reads, and its listings look at, at
|
|
47
54
|
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
@@ -54,16 +61,23 @@ const DIRECT_READS_AT_ONCE = 16;
|
|
|
54
61
|
const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
|
|
55
62
|
/** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
|
|
56
63
|
const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
|
|
57
|
-
class WorkspaceImageReadError extends
|
|
58
|
-
|
|
59
|
-
constructor(
|
|
60
|
-
super(
|
|
61
|
-
this.
|
|
64
|
+
class WorkspaceImageReadError extends Refusal {
|
|
65
|
+
reason;
|
|
66
|
+
constructor(reason, wayOn) {
|
|
67
|
+
super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
|
|
68
|
+
this.reason = reason;
|
|
62
69
|
this.name = 'WorkspaceImageReadError';
|
|
63
70
|
}
|
|
64
71
|
}
|
|
72
|
+
const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
|
|
73
|
+
function truncatedImage(mountPrefixedPath) {
|
|
74
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
|
|
75
|
+
}
|
|
76
|
+
function emptyImage(mountPrefixedPath) {
|
|
77
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
|
|
78
|
+
}
|
|
65
79
|
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
66
|
-
class OutsideIncludeError extends
|
|
80
|
+
class OutsideIncludeError extends Refusal {
|
|
67
81
|
constructor(shownPath, mount) {
|
|
68
82
|
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
69
83
|
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
@@ -74,9 +88,17 @@ class OutsideIncludeError extends Error {
|
|
|
74
88
|
* mount it names. The message names the mount that holds it and the path to
|
|
75
89
|
* use there.
|
|
76
90
|
*/
|
|
77
|
-
class NestedMountError extends
|
|
78
|
-
constructor(path, mount, holder,
|
|
79
|
-
|
|
91
|
+
class NestedMountError extends Refusal {
|
|
92
|
+
constructor(path, mount, holder, how = 'lies in') {
|
|
93
|
+
const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
|
|
94
|
+
super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
class PathRefusal extends Refusal {
|
|
98
|
+
code;
|
|
99
|
+
constructor(code, message) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.code = code;
|
|
80
102
|
}
|
|
81
103
|
}
|
|
82
104
|
/**
|
|
@@ -98,37 +120,46 @@ export class WorkspaceReadError extends Error {
|
|
|
98
120
|
this.name = 'WorkspaceReadError';
|
|
99
121
|
}
|
|
100
122
|
}
|
|
123
|
+
class NotRegularFile extends WorkspaceReadError {
|
|
124
|
+
constructor(path, stage) {
|
|
125
|
+
super('not_found', `Not a regular file: ${path}`, stage);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
class LinkOnTheWay extends WorkspaceReadError {
|
|
129
|
+
reason;
|
|
130
|
+
constructor(code, message, reason, errno) {
|
|
131
|
+
super(code, message, 'readlink', errno);
|
|
132
|
+
this.reason = reason;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
class RootUnavailable extends WorkspaceReadError {
|
|
136
|
+
mount;
|
|
137
|
+
reason;
|
|
138
|
+
refusal;
|
|
139
|
+
constructor(mount, reason, stage, errno, refusal) {
|
|
140
|
+
super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
|
|
141
|
+
this.mount = mount;
|
|
142
|
+
this.reason = reason;
|
|
143
|
+
this.refusal = refusal;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
101
146
|
/**
|
|
102
147
|
* Why a pass decided nothing, or null where it decided its paths. A pass gives
|
|
103
148
|
* up deciding, and reports no path, when the selected branch kept changing
|
|
104
149
|
* while it gathered, or when a sync's branch was no longer selected; it then
|
|
105
|
-
* carries no branchId, and
|
|
150
|
+
* carries no branchId, and says why in `undecided`.
|
|
106
151
|
*/
|
|
107
152
|
function undecidedOf(pass) {
|
|
108
|
-
return pass.branchId === undefined ? (pass.
|
|
153
|
+
return pass.branchId === undefined ? (pass.undecided ?? 'the pass decided nothing') : null;
|
|
109
154
|
}
|
|
110
155
|
function errnoOf(err) {
|
|
111
156
|
return typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string'
|
|
112
157
|
? err.code
|
|
113
158
|
: undefined;
|
|
114
159
|
}
|
|
115
|
-
/**
|
|
116
|
-
* What is at a mount root's path now, followed as the root is: for a
|
|
117
|
-
* directory, undefined; for anything else, ENOTDIR; and where the path
|
|
118
|
-
* can't be followed to anything, the errno its stat failed with, ENOENT
|
|
119
|
-
* when nothing is there.
|
|
120
|
-
*/
|
|
121
|
-
async function rootAbsence(root) {
|
|
122
|
-
try {
|
|
123
|
-
return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
return errnoOf(err);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
160
|
/** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
|
|
130
|
-
function imageTooLarge(maxSize, mountPrefixedPath) {
|
|
131
|
-
return new WorkspaceImageReadError(
|
|
161
|
+
function imageTooLarge(maxSize, mountPrefixedPath, size) {
|
|
162
|
+
return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
|
|
132
163
|
}
|
|
133
164
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
134
165
|
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
|
|
@@ -144,9 +175,6 @@ const JPEG_SOI = 0xd8;
|
|
|
144
175
|
const JPEG_EOI = 0xd9;
|
|
145
176
|
const JPEG_SOS = 0xda;
|
|
146
177
|
const JPEG_TEM = 0x01;
|
|
147
|
-
function isErrnoCode(err, code) {
|
|
148
|
-
return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
|
|
149
|
-
}
|
|
150
178
|
function startsWithBytes(buffer, prefix) {
|
|
151
179
|
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
152
180
|
}
|
|
@@ -174,15 +202,16 @@ function matchesPartialPrefix(buffer, prefix) {
|
|
|
174
202
|
*/
|
|
175
203
|
async function linkReadError(mount, found, mountPrefixedPath, relativePath, nested, rootReal) {
|
|
176
204
|
if (!('link' in found)) {
|
|
177
|
-
|
|
205
|
+
const reason = linkWords(mount.config.name, found, null);
|
|
206
|
+
return new LinkOnTheWay('not_found', `Unable to read file: ${mountPrefixedPath}: ${reason}`, reason, found.code);
|
|
178
207
|
}
|
|
179
208
|
const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
|
|
180
|
-
const reason =
|
|
209
|
+
const reason = linkWords(mount.config.name, found, leads, nested, mount.config.include);
|
|
181
210
|
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
182
211
|
const code = leads?.kind === 'outside' ? 'escape'
|
|
183
|
-
: leads?.kind === 'inside' && holder && namesHolder(leads,
|
|
212
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
|
|
184
213
|
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
185
|
-
return new
|
|
214
|
+
return new LinkOnTheWay(code, `${mountPrefixedPath}: ${reason}`, reason);
|
|
186
215
|
}
|
|
187
216
|
/**
|
|
188
217
|
* `to` beneath `from`, mount-relative and `/`-separated, whatever the
|
|
@@ -211,28 +240,27 @@ function ancestorsOf(path) {
|
|
|
211
240
|
* directory as configured beneath the outer mount's configured path
|
|
212
241
|
* (`outerPath`), beneath each directory above its configured path that is
|
|
213
242
|
* the outer root on disk (`above`, each with where it lies on disk), and
|
|
214
|
-
* where its real root (`real`) lies beneath the outer root (`outerReal`)
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* whole. Undefined where it's nested nowhere.
|
|
243
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`).
|
|
244
|
+
* Its real root is claimed where it doesn't hold the outer root, which it
|
|
245
|
+
* would otherwise claim whole. Undefined where it's nested nowhere.
|
|
218
246
|
*/
|
|
219
|
-
|
|
247
|
+
function placementOf(outerPath, outerReal, innerPath, real, above) {
|
|
220
248
|
const configured = nestedDir(outerPath, innerPath);
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
const dirs = new Map();
|
|
250
|
+
const place = (dir, sure) => { if (dir !== undefined)
|
|
251
|
+
dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
|
|
252
|
+
place(configured, true);
|
|
253
|
+
for (const a of above) {
|
|
254
|
+
if (sameRoot(a.real, outerReal))
|
|
255
|
+
place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
|
|
256
|
+
}
|
|
257
|
+
const below = beneathRoot(real, outerReal);
|
|
258
|
+
place(below, below !== undefined && surelyAtRoot(real, outerReal));
|
|
259
|
+
if (dirs.size === 0)
|
|
228
260
|
return undefined;
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (!spellings.some((s) => s.dir === dir))
|
|
233
|
-
spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
234
|
-
}
|
|
235
|
-
return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
|
|
261
|
+
const fromRoots = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true }) }));
|
|
262
|
+
const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
|
|
263
|
+
return { configured, fromRoots, claims };
|
|
236
264
|
}
|
|
237
265
|
/** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
|
|
238
266
|
const LINKS_FOLLOWED = 40;
|
|
@@ -298,7 +326,7 @@ function isContainedPath(root, candidate) {
|
|
|
298
326
|
return candidate.startsWith(normalizedRoot);
|
|
299
327
|
}
|
|
300
328
|
function invalidImage(format, mountPrefixedPath) {
|
|
301
|
-
throw new WorkspaceImageReadError(
|
|
329
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
|
|
302
330
|
}
|
|
303
331
|
function requireBufferRange(bytes, start, length, format, mountPrefixedPath) {
|
|
304
332
|
if (start < 0 || length < 0 || start + length > bytes.length) {
|
|
@@ -634,21 +662,21 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
|
|
|
634
662
|
return 'image/png';
|
|
635
663
|
}
|
|
636
664
|
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
637
|
-
throw
|
|
665
|
+
throw truncatedImage(mountPrefixedPath);
|
|
638
666
|
}
|
|
639
667
|
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
640
668
|
validateJpeg(bytes, mountPrefixedPath);
|
|
641
669
|
return 'image/jpeg';
|
|
642
670
|
}
|
|
643
671
|
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
644
|
-
throw
|
|
672
|
+
throw truncatedImage(mountPrefixedPath);
|
|
645
673
|
}
|
|
646
674
|
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
647
675
|
validateGif(bytes, mountPrefixedPath);
|
|
648
676
|
return 'image/gif';
|
|
649
677
|
}
|
|
650
678
|
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
651
|
-
throw
|
|
679
|
+
throw truncatedImage(mountPrefixedPath);
|
|
652
680
|
}
|
|
653
681
|
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
654
682
|
validateWebp(bytes, mountPrefixedPath);
|
|
@@ -656,9 +684,9 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
|
|
|
656
684
|
}
|
|
657
685
|
if ((bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
658
686
|
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)) {
|
|
659
|
-
throw
|
|
687
|
+
throw truncatedImage(mountPrefixedPath);
|
|
660
688
|
}
|
|
661
|
-
throw new WorkspaceImageReadError('
|
|
689
|
+
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.");
|
|
662
690
|
}
|
|
663
691
|
export class WorkspaceModule {
|
|
664
692
|
name = 'workspace';
|
|
@@ -684,14 +712,11 @@ export class WorkspaceModule {
|
|
|
684
712
|
ctimeTrust = new Map();
|
|
685
713
|
/**
|
|
686
714
|
* Each mount's directories that are other mounts nested inside it, with
|
|
687
|
-
* each mount's name and real root,
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
* the
|
|
692
|
-
* with each root as resolved standing for its real one, and each start
|
|
693
|
-
* reads it all again with the roots placed where disk has them
|
|
694
|
-
* (readNesting).
|
|
715
|
+
* each mount's name and real root: as configured, and as the real roots
|
|
716
|
+
* place it, where that differs (`fromRoots`). Each is mount-relative and
|
|
717
|
+
* `/`-separated. The constructor nests by configured path, with each root
|
|
718
|
+
* as resolved standing for its real one, and each start reads it all again
|
|
719
|
+
* with the roots placed where disk has them (readNesting).
|
|
695
720
|
*/
|
|
696
721
|
otherMounts = new Map();
|
|
697
722
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
@@ -748,20 +773,19 @@ export class WorkspaceModule {
|
|
|
748
773
|
if (refused.length > 0) {
|
|
749
774
|
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
750
775
|
}
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
if (!Array.isArray(
|
|
756
|
-
throw new Error(`[workspace] Mount "${mount.name}":
|
|
776
|
+
for (const list of ['ignore', 'include']) {
|
|
777
|
+
const patterns = mount[list];
|
|
778
|
+
if (patterns === undefined)
|
|
779
|
+
continue;
|
|
780
|
+
if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
|
|
781
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
|
|
757
782
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
+ `${shownValue(unreadable)} is none of these`);
|
|
783
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
784
|
+
const pattern = patterns[i];
|
|
785
|
+
const why = patternRefusal(pattern, list);
|
|
786
|
+
if (why !== undefined) {
|
|
787
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
|
|
788
|
+
}
|
|
765
789
|
}
|
|
766
790
|
}
|
|
767
791
|
}
|
|
@@ -782,8 +806,8 @@ export class WorkspaceModule {
|
|
|
782
806
|
const dir = nestedDir(outerPath, innerPath);
|
|
783
807
|
if (dir === undefined)
|
|
784
808
|
continue;
|
|
785
|
-
const configured = [{ dir
|
|
786
|
-
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured
|
|
809
|
+
const configured = [{ dir }];
|
|
810
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
|
|
787
811
|
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
788
812
|
`at ${dir}/ — passing over it as another mount's directory`);
|
|
789
813
|
}
|
|
@@ -1067,7 +1091,7 @@ export class WorkspaceModule {
|
|
|
1067
1091
|
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
1068
1092
|
let start = base;
|
|
1069
1093
|
for (const segment of beneath.split('/')) {
|
|
1070
|
-
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
|
|
1094
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined))
|
|
1071
1095
|
break;
|
|
1072
1096
|
const here = await checkDir(view, rootReal, start);
|
|
1073
1097
|
if (here.kind !== 'checked')
|
|
@@ -1125,11 +1149,10 @@ export class WorkspaceModule {
|
|
|
1125
1149
|
}
|
|
1126
1150
|
async start(ctx) {
|
|
1127
1151
|
// Which mounts are nested where, with the roots placed where disk has
|
|
1128
|
-
// them and each
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
// sets nothing.
|
|
1152
|
+
// them, and whether each mount's root folds case, read where disk has
|
|
1153
|
+
// it, before this start sets anything, so a restart takes no call
|
|
1154
|
+
// and walks or watches nothing before they're known. A stop begun
|
|
1155
|
+
// meanwhile stands, and this start then sets nothing.
|
|
1133
1156
|
const stops = this.stopsBegun;
|
|
1134
1157
|
const { nesting, reals, above } = await this.readNesting();
|
|
1135
1158
|
const rootFoldsCase = new Map();
|
|
@@ -1242,7 +1265,8 @@ export class WorkspaceModule {
|
|
|
1242
1265
|
const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
|
|
1243
1266
|
return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
1244
1267
|
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
1245
|
-
`never had.
|
|
1268
|
+
`never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
|
|
1269
|
+
'if the current branch is what disk should hold, pass force: true to overwrite disk from it.');
|
|
1246
1270
|
}
|
|
1247
1271
|
/**
|
|
1248
1272
|
* Re-pin mounts whose last-materialized branch is a proven ancestor of the
|
|
@@ -1866,7 +1890,12 @@ export class WorkspaceModule {
|
|
|
1866
1890
|
work.then(done, done);
|
|
1867
1891
|
return work;
|
|
1868
1892
|
}
|
|
1869
|
-
|
|
1893
|
+
runToolCall(call) {
|
|
1894
|
+
const gate = { passed: false };
|
|
1895
|
+
return callGate.run(gate, () => this.answerToolCall(call, gate));
|
|
1896
|
+
}
|
|
1897
|
+
async answerToolCall(call, gate) {
|
|
1898
|
+
const reopenAtStart = this.agreement?.awaitsReopen === true;
|
|
1870
1899
|
try {
|
|
1871
1900
|
// Every handler reads its input as its schema describes it; a call
|
|
1872
1901
|
// that sends something else is refused here, before anything is read.
|
|
@@ -1881,12 +1910,12 @@ export class WorkspaceModule {
|
|
|
1881
1910
|
// ResultTooLarge in its place. A call made in process carries none and
|
|
1882
1911
|
// takes the data itself, so its read pages by its line limit alone.
|
|
1883
1912
|
const resultMaxChars = call.resultMaxChars;
|
|
1884
|
-
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1913
|
+
const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1885
1914
|
if (generated) {
|
|
1886
1915
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
1887
1916
|
return await this.handleRead(input, resultMaxChars, generated.read());
|
|
1888
1917
|
}
|
|
1889
|
-
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
1918
|
+
return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path, generated, call.callerAgentName)) };
|
|
1890
1919
|
}
|
|
1891
1920
|
switch (call.name) {
|
|
1892
1921
|
case 'read': return await this.handleRead(input, resultMaxChars);
|
|
@@ -1902,12 +1931,40 @@ export class WorkspaceModule {
|
|
|
1902
1931
|
case 'materialize': return await this.handleMaterialize(input);
|
|
1903
1932
|
case 'sync': return await this.handleSync(input);
|
|
1904
1933
|
default:
|
|
1905
|
-
return { success: false, error:
|
|
1934
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
1906
1935
|
}
|
|
1907
1936
|
}
|
|
1908
1937
|
catch (err) {
|
|
1909
|
-
return { success: false, error: err
|
|
1938
|
+
return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
failureAnswer(call, err, reopenAtStart, passedGate) {
|
|
1942
|
+
const input = (typeof call.input === 'object' && call.input !== null ? call.input : {});
|
|
1943
|
+
const path = typeof input.path === 'string' ? input.path : undefined;
|
|
1944
|
+
const mountName = path !== undefined ? path.split('/')[0] : typeof input.mount === 'string' ? input.mount : undefined;
|
|
1945
|
+
const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
|
|
1946
|
+
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
|
|
1947
|
+
if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
|
|
1948
|
+
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
|
|
1949
|
+
}
|
|
1950
|
+
if (err instanceof Refusal)
|
|
1951
|
+
return didNothing(call.name, err.message);
|
|
1952
|
+
if (err instanceof WorkspaceReadError)
|
|
1953
|
+
return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
|
|
1954
|
+
const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
|
|
1955
|
+
: mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
|
|
1956
|
+
: 'ls of each mount, with recursive: true, shows how each file stands against disk now';
|
|
1957
|
+
if (err instanceof NotStored || err instanceof AlreadyHeld) {
|
|
1958
|
+
console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
|
|
1959
|
+
return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
|
|
1960
|
+
}
|
|
1961
|
+
console.error(`[workspace] ${call.name} failed:`, err);
|
|
1962
|
+
if (err instanceof CommitUnknownError) {
|
|
1963
|
+
return unexpectedAnswer(call.name, subject, err.setCause, {
|
|
1964
|
+
said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
|
|
1965
|
+
});
|
|
1910
1966
|
}
|
|
1967
|
+
return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
|
|
1911
1968
|
}
|
|
1912
1969
|
async onProcess(event, _state) {
|
|
1913
1970
|
// Wake agents on filesystem events when the originating mount opted in.
|
|
@@ -1948,14 +2005,14 @@ export class WorkspaceModule {
|
|
|
1948
2005
|
return { requestInference: true };
|
|
1949
2006
|
let text;
|
|
1950
2007
|
if (paths.length === 1) {
|
|
1951
|
-
text = `[
|
|
2008
|
+
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
|
|
1952
2009
|
}
|
|
1953
2010
|
else {
|
|
1954
2011
|
const list = paths.map(p => `- ${p}`).join('\n');
|
|
1955
|
-
text = `[
|
|
2012
|
+
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
1956
2013
|
}
|
|
1957
2014
|
if (conflicts && conflicts.length > 0) {
|
|
1958
|
-
text += `\n[
|
|
2015
|
+
text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
1959
2016
|
}
|
|
1960
2017
|
return {
|
|
1961
2018
|
requestInference: true,
|
|
@@ -2003,16 +2060,15 @@ export class WorkspaceModule {
|
|
|
2003
2060
|
resolveAbsolutePath(mountPrefixedPath) {
|
|
2004
2061
|
try {
|
|
2005
2062
|
// Synchronous, so the path as given. The nested-mount check takes it
|
|
2006
|
-
// in
|
|
2007
|
-
//
|
|
2008
|
-
//
|
|
2009
|
-
// include judges it as given, so on a disk that
|
|
2063
|
+
// in any spelling a disk that folds names may fold to the nested
|
|
2064
|
+
// mount's directory, erring toward that mount (holderOf). The include
|
|
2065
|
+
// judges it as given, so on a disk that
|
|
2010
2066
|
// folds names a covered name whose file disk spells otherwise returns
|
|
2011
2067
|
// that file's path, which the tools refuse, and a covered file named in
|
|
2012
2068
|
// another case is null, which the tools read. A path the mount doesn't
|
|
2013
2069
|
// hold, outside its include or in another mount's directory nested in
|
|
2014
2070
|
// it, is null, as for a path that leaves the mount's directory.
|
|
2015
|
-
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath
|
|
2071
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
|
|
2016
2072
|
return resolve(mount.config.path, relativePath);
|
|
2017
2073
|
}
|
|
2018
2074
|
catch {
|
|
@@ -2040,6 +2096,9 @@ export class WorkspaceModule {
|
|
|
2040
2096
|
* `agentName` names the agent whose write it is for the head-move notice
|
|
2041
2097
|
* (a write naming none tells no agent).
|
|
2042
2098
|
*
|
|
2099
|
+
* A success's `disk` says where the file stands on disk as the workspace
|
|
2100
|
+
* last saw it, as the write tool's answer does.
|
|
2101
|
+
*
|
|
2043
2102
|
* A write whose tree entry is set but that then fails returns success
|
|
2044
2103
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2045
2104
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -2067,12 +2126,14 @@ export class WorkspaceModule {
|
|
|
2067
2126
|
isError: true,
|
|
2068
2127
|
};
|
|
2069
2128
|
}
|
|
2070
|
-
|
|
2129
|
+
if (relativePath === '')
|
|
2130
|
+
return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
|
|
2131
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
2071
2132
|
if (refusal)
|
|
2072
2133
|
return { success: false, error: refusal, isError: true };
|
|
2073
2134
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2074
2135
|
if (data.byteLength > maxSize) {
|
|
2075
|
-
return { success: false, error:
|
|
2136
|
+
return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
|
|
2076
2137
|
}
|
|
2077
2138
|
const store = this.getStore();
|
|
2078
2139
|
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
@@ -2087,7 +2148,7 @@ export class WorkspaceModule {
|
|
|
2087
2148
|
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2088
2149
|
if ('error' in written)
|
|
2089
2150
|
return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
|
|
2090
|
-
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
|
|
2151
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
2091
2152
|
}
|
|
2092
2153
|
return this.withMount(mount, async () => {
|
|
2093
2154
|
await this.settleBeforeMutation(mount, relativePath);
|
|
@@ -2102,9 +2163,11 @@ export class WorkspaceModule {
|
|
|
2102
2163
|
let committed = false;
|
|
2103
2164
|
let failure;
|
|
2104
2165
|
let modeKept = null;
|
|
2166
|
+
let disk = null;
|
|
2105
2167
|
try {
|
|
2106
2168
|
const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
|
|
2107
2169
|
modeKept = answer.modeKept;
|
|
2170
|
+
disk = answer.disk;
|
|
2108
2171
|
failure = answer.refused === null ? null
|
|
2109
2172
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
2110
2173
|
}
|
|
@@ -2113,10 +2176,13 @@ export class WorkspaceModule {
|
|
|
2113
2176
|
throw error;
|
|
2114
2177
|
// As write words it: a store that failed after recording the file
|
|
2115
2178
|
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2179
|
+
const reopen = this.agreement?.awaitsReopen === true;
|
|
2116
2180
|
failure = error instanceof StoredThenFailed
|
|
2117
2181
|
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
2118
|
-
+ (error.pushes ?
|
|
2182
|
+
+ (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 : ''}.` : '')
|
|
2119
2183
|
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
2184
|
+
if (reopen)
|
|
2185
|
+
failure += ` ${AWAITS_REOPEN}`;
|
|
2120
2186
|
}
|
|
2121
2187
|
if (failure !== null) {
|
|
2122
2188
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -2126,7 +2192,7 @@ export class WorkspaceModule {
|
|
|
2126
2192
|
}
|
|
2127
2193
|
return {
|
|
2128
2194
|
success: true,
|
|
2129
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
|
|
2195
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
|
|
2130
2196
|
};
|
|
2131
2197
|
});
|
|
2132
2198
|
}
|
|
@@ -2162,12 +2228,13 @@ export class WorkspaceModule {
|
|
|
2162
2228
|
return this.withMount(mount, async () => {
|
|
2163
2229
|
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
2164
2230
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2165
|
-
if (!entry)
|
|
2166
|
-
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2231
|
+
if (!entry) {
|
|
2232
|
+
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
|
|
2233
|
+
}
|
|
2234
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2235
|
+
if ('lost' in contents)
|
|
2236
|
+
return { error: nothingToRead(contents.lost) };
|
|
2237
|
+
return { data: contents.bytes };
|
|
2171
2238
|
});
|
|
2172
2239
|
}
|
|
2173
2240
|
/**
|
|
@@ -2245,9 +2312,9 @@ export class WorkspaceModule {
|
|
|
2245
2312
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
2246
2313
|
}
|
|
2247
2314
|
}
|
|
2248
|
-
/** The directories of the mounts nested inside a mount, mount-relative
|
|
2315
|
+
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
2249
2316
|
othersOf(mountName) {
|
|
2250
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2317
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
|
|
2251
2318
|
}
|
|
2252
2319
|
/**
|
|
2253
2320
|
* Which mounts are nested in which, read afresh at each start. A mount is
|
|
@@ -2262,13 +2329,8 @@ export class WorkspaceModule {
|
|
|
2262
2329
|
* alone, with no real root, since through it it would claim the other's
|
|
2263
2330
|
* every path.
|
|
2264
2331
|
*
|
|
2265
|
-
* Each way it lies there is a spelling of its directory (`fromRoots`)
|
|
2266
|
-
*
|
|
2267
|
-
* disk that folds names, the outer mount's walks name the directory as
|
|
2268
|
-
* disk does, and so does a path parsePath has respelled, while a path given
|
|
2269
|
-
* as configured still names it as configured (resolveAbsolutePath). A
|
|
2270
|
-
* directory not on disk keeps the spellings the roots give alone. Each
|
|
2271
|
-
* mount's real root comes back beside the nesting (`reals`), for what
|
|
2332
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`).
|
|
2333
|
+
* Each mount's real root comes back beside the nesting (`reals`), for what
|
|
2272
2334
|
* start reads there, and each directory above each configured path with
|
|
2273
2335
|
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2274
2336
|
* (movedNesting).
|
|
@@ -2295,15 +2357,16 @@ export class WorkspaceModule {
|
|
|
2295
2357
|
if (outer === inner)
|
|
2296
2358
|
continue;
|
|
2297
2359
|
const real = reals.get(inner.name);
|
|
2298
|
-
const at =
|
|
2360
|
+
const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name));
|
|
2299
2361
|
if (at === undefined)
|
|
2300
2362
|
continue;
|
|
2301
|
-
const { configured, fromRoots,
|
|
2302
|
-
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots
|
|
2363
|
+
const { configured, fromRoots, claims } = at;
|
|
2364
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
|
|
2303
2365
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2304
2366
|
if (placed.length > 0) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2367
|
+
const at = (sure) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
|
|
2368
|
+
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 ');
|
|
2369
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
|
|
2307
2370
|
}
|
|
2308
2371
|
}
|
|
2309
2372
|
}
|
|
@@ -2311,10 +2374,10 @@ export class WorkspaceModule {
|
|
|
2311
2374
|
}
|
|
2312
2375
|
/**
|
|
2313
2376
|
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2314
|
-
* has one, and include (MountView's `nested`)
|
|
2377
|
+
* has one, and include (MountView's `nested`).
|
|
2315
2378
|
*/
|
|
2316
2379
|
nestedOf(mountName) {
|
|
2317
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2380
|
+
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 })));
|
|
2318
2381
|
}
|
|
2319
2382
|
/**
|
|
2320
2383
|
* The mounts nested in `mount` as a call of it that resolved its root to
|
|
@@ -2416,12 +2479,13 @@ export class WorkspaceModule {
|
|
|
2416
2479
|
continue;
|
|
2417
2480
|
above = placedAbove.get(inner.name) ?? [];
|
|
2418
2481
|
}
|
|
2419
|
-
const at =
|
|
2482
|
+
const at = placementOf(outerPath, rootReal, innerPath, real, above);
|
|
2420
2483
|
if (at === undefined)
|
|
2421
2484
|
continue;
|
|
2422
2485
|
const include = this.mounts.get(inner.name)?.config.include;
|
|
2423
|
-
for (const s of at.
|
|
2424
|
-
nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2486
|
+
for (const s of at.fromRoots) {
|
|
2487
|
+
nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2488
|
+
}
|
|
2425
2489
|
}
|
|
2426
2490
|
return nested;
|
|
2427
2491
|
}
|
|
@@ -2430,15 +2494,9 @@ export class WorkspaceModule {
|
|
|
2430
2494
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2431
2495
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2432
2496
|
* outer mount isn't followed), with the path to use there; undefined when
|
|
2433
|
-
* no nested mount's directory holds it.
|
|
2434
|
-
* compared with the directory's spellings exactly. One judged as given
|
|
2435
|
-
* (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
|
|
2436
|
-
* folds names may compare it (beginsWithDir), so it can't reach that
|
|
2437
|
-
* mount's directory by spelling a segment otherwise. On a disk that tells
|
|
2438
|
-
* names apart, that takes a directory of this mount's own spelled like the
|
|
2439
|
-
* nested one in another way as that mount's too.
|
|
2497
|
+
* no nested mount's directory holds it.
|
|
2440
2498
|
*/
|
|
2441
|
-
holderOf(mountName, relativePath,
|
|
2499
|
+
holderOf(mountName, relativePath, moved = null) {
|
|
2442
2500
|
const mount = this.mounts.get(mountName);
|
|
2443
2501
|
if (!mount)
|
|
2444
2502
|
return undefined;
|
|
@@ -2446,20 +2504,15 @@ export class WorkspaceModule {
|
|
|
2446
2504
|
const resolved = resolve(root, relativePath);
|
|
2447
2505
|
if (!isContainedPath(root, resolved))
|
|
2448
2506
|
return undefined;
|
|
2449
|
-
const
|
|
2507
|
+
const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
|
|
2450
2508
|
// A direct mount whose root a call found moved is judged by that call's
|
|
2451
2509
|
// nesting (movedNesting), each directory beneath its configured path.
|
|
2452
|
-
const
|
|
2453
|
-
? moved.map((n) => ({ name: n.name, dir: n.dir }))
|
|
2454
|
-
: (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
|
|
2455
|
-
const holder = dirs
|
|
2456
|
-
.filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
|
|
2457
|
-
.map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
|
|
2458
|
-
.sort((a, b) => b.depth - a.depth)[0];
|
|
2510
|
+
const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
|
|
2459
2511
|
if (!holder)
|
|
2460
2512
|
return undefined;
|
|
2461
|
-
const rest =
|
|
2462
|
-
|
|
2513
|
+
const rest = restBeneath(path, holder);
|
|
2514
|
+
const guess = nestedGuess(mountName, path.split('/'), holder);
|
|
2515
|
+
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
|
|
2463
2516
|
}
|
|
2464
2517
|
/**
|
|
2465
2518
|
* Why a mount leaves a path in a nested mount's directory, naming that
|
|
@@ -2467,10 +2520,10 @@ export class WorkspaceModule {
|
|
|
2467
2520
|
* it; for a listing, whose the path is.
|
|
2468
2521
|
*/
|
|
2469
2522
|
otherMountNote(mountName, relativePath, by, moved = null) {
|
|
2470
|
-
const holder = this.holderOf(mountName, relativePath,
|
|
2523
|
+
const holder = this.holderOf(mountName, relativePath, moved);
|
|
2471
2524
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2472
2525
|
return holder
|
|
2473
|
-
? `it lies in mount "${holder.name}", nested in this one${what}:
|
|
2526
|
+
? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
|
|
2474
2527
|
: `it lies in another mount's directory${what}`;
|
|
2475
2528
|
}
|
|
2476
2529
|
/**
|
|
@@ -2489,7 +2542,7 @@ export class WorkspaceModule {
|
|
|
2489
2542
|
// Both checks judge by the nesting the parse found, and the second reads
|
|
2490
2543
|
// disk beneath the root the parse resolved.
|
|
2491
2544
|
const { mount, root, moved } = spelled;
|
|
2492
|
-
const parsed = this.refuseNotHeld(spelled, path,
|
|
2545
|
+
const parsed = this.refuseNotHeld(spelled, path, moved);
|
|
2493
2546
|
// A path can also reach a nested mount's directory, or a file this
|
|
2494
2547
|
// mount's own include leaves out, through a symlink on its way. No push
|
|
2495
2548
|
// of this mount writes it there and no read reads it, so it's refused
|
|
@@ -2531,7 +2584,9 @@ export class WorkspaceModule {
|
|
|
2531
2584
|
// whatever the place realpath stopped short of would say.
|
|
2532
2585
|
if (judged && unresolved.code !== undefined)
|
|
2533
2586
|
return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2534
|
-
|
|
2587
|
+
let holder = await nestedAtReal(real, nested);
|
|
2588
|
+
if (holder?.guess === null)
|
|
2589
|
+
holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
|
|
2535
2590
|
if (!holder) {
|
|
2536
2591
|
if (!judged)
|
|
2537
2592
|
return null;
|
|
@@ -2550,26 +2605,25 @@ export class WorkspaceModule {
|
|
|
2550
2605
|
// on the place's way as that mount's, so a place beneath anything else
|
|
2551
2606
|
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2552
2607
|
// a directory not there yet is.
|
|
2553
|
-
const onItsWay = holder.
|
|
2608
|
+
const onItsWay = !holder.judgedByInclude
|
|
2554
2609
|
|| await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
|
|
2555
|
-
const directory = holder.
|
|
2556
|
-
if (!onItsWay || !
|
|
2610
|
+
const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2611
|
+
if (!onItsWay || !holder.names(directory))
|
|
2557
2612
|
return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2558
|
-
return new NestedMountError(path, mount.config.name, holder
|
|
2613
|
+
return new NestedMountError(path, mount.config.name, holder, 'leads into');
|
|
2559
2614
|
}
|
|
2560
2615
|
/**
|
|
2561
2616
|
* A parsed path, refused where the mount doesn't hold it: in a nested
|
|
2562
2617
|
* mount's directory first, naming the mount that holds it, then outside
|
|
2563
2618
|
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2564
2619
|
* as disk spells it for parseFilePath, as given for resolveAbsolutePath,
|
|
2565
|
-
* which is synchronous
|
|
2566
|
-
* folds where disk does). The include's refusal shows the path as given
|
|
2620
|
+
* which is synchronous. The include's refusal shows the path as given
|
|
2567
2621
|
* (`given`), since it answers the path the caller asked about.
|
|
2568
2622
|
*/
|
|
2569
|
-
refuseNotHeld(parsed, given,
|
|
2570
|
-
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath,
|
|
2623
|
+
refuseNotHeld(parsed, given, moved = null) {
|
|
2624
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
|
|
2571
2625
|
if (holder)
|
|
2572
|
-
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder
|
|
2626
|
+
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
|
|
2573
2627
|
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2574
2628
|
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2575
2629
|
}
|
|
@@ -2615,7 +2669,7 @@ export class WorkspaceModule {
|
|
|
2615
2669
|
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2616
2670
|
*/
|
|
2617
2671
|
async refusesAsOutside(mount, relativePath, moved, root) {
|
|
2618
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
2672
|
+
if (this.holderOf(mount.config.name, relativePath, moved))
|
|
2619
2673
|
return false;
|
|
2620
2674
|
if (insideMount(relativePath, mount.config.include))
|
|
2621
2675
|
return false;
|
|
@@ -2690,20 +2744,12 @@ export class WorkspaceModule {
|
|
|
2690
2744
|
}
|
|
2691
2745
|
/**
|
|
2692
2746
|
* How many segments the innermost nested mount's directory that holds
|
|
2693
|
-
* `relativePath` has,
|
|
2694
|
-
*
|
|
2747
|
+
* `relativePath` has, or, for a direct mount whose root has moved, in the
|
|
2748
|
+
* nesting there (`moved`);
|
|
2695
2749
|
* undefined when no nested mount's directory holds it.
|
|
2696
2750
|
*/
|
|
2697
2751
|
nestedDepth(mountName, relativePath, moved = null) {
|
|
2698
|
-
|
|
2699
|
-
for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
|
|
2700
|
-
if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
|
|
2701
|
-
continue;
|
|
2702
|
-
const segments = dir.split('/').length;
|
|
2703
|
-
if (depth === undefined || segments > depth)
|
|
2704
|
-
depth = segments;
|
|
2705
|
-
}
|
|
2706
|
-
return depth;
|
|
2752
|
+
return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
|
|
2707
2753
|
}
|
|
2708
2754
|
/**
|
|
2709
2755
|
* A mount's entries on the current branch, and the paths it records an
|
|
@@ -2725,11 +2771,7 @@ export class WorkspaceModule {
|
|
|
2725
2771
|
// Only an entry this mount's include covers spells a draft's path
|
|
2726
2772
|
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
2727
2773
|
// from an earlier version, nor one the include leaves out, which would
|
|
2728
|
-
// respell a covered draft into a name the include leaves out.
|
|
2729
|
-
// in a nested mount's directory that this mount's include covers
|
|
2730
|
-
// spells one whatever that mount's include says, so on a disk that
|
|
2731
|
-
// folds names, a draft naming the directory in another spelling takes
|
|
2732
|
-
// the entry's spelling and is judged as in it.
|
|
2774
|
+
// respell a covered draft into a name the include leaves out.
|
|
2733
2775
|
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
2734
2776
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
2735
2777
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
@@ -2754,25 +2796,25 @@ export class WorkspaceModule {
|
|
|
2754
2796
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
2755
2797
|
// store would keep one name and disk another.
|
|
2756
2798
|
if (LONE_SURROGATE.test(path)) {
|
|
2757
|
-
throw new
|
|
2799
|
+
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.`);
|
|
2758
2800
|
}
|
|
2759
2801
|
const slashIdx = path.indexOf('/');
|
|
2760
2802
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
2761
2803
|
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
2762
2804
|
const mount = this.mounts.get(mountName);
|
|
2763
|
-
if (!mount)
|
|
2764
|
-
throw new
|
|
2765
|
-
}
|
|
2805
|
+
if (!mount)
|
|
2806
|
+
throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
|
|
2766
2807
|
const segments = pathSegments(given);
|
|
2767
2808
|
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
2768
|
-
throw new
|
|
2809
|
+
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>".`);
|
|
2769
2810
|
}
|
|
2770
2811
|
const kept = [];
|
|
2771
2812
|
for (const segment of segments) {
|
|
2772
2813
|
if (segment !== '..')
|
|
2773
2814
|
kept.push(segment);
|
|
2774
2815
|
else if (kept.pop() === undefined) {
|
|
2775
|
-
throw new
|
|
2816
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
|
|
2817
|
+
+ `Name a file from its own mount's name; ${this.mountList()}.`);
|
|
2776
2818
|
}
|
|
2777
2819
|
}
|
|
2778
2820
|
const relativePath = kept.join('/');
|
|
@@ -2786,10 +2828,47 @@ export class WorkspaceModule {
|
|
|
2786
2828
|
const root = resolve(mount.config.path);
|
|
2787
2829
|
const resolved = resolve(root, relativePath);
|
|
2788
2830
|
if (!isContainedPath(root, resolved)) {
|
|
2789
|
-
throw new
|
|
2831
|
+
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>".`);
|
|
2790
2832
|
}
|
|
2791
2833
|
return { mount, relativePath };
|
|
2792
2834
|
}
|
|
2835
|
+
writableNames() {
|
|
2836
|
+
return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
|
|
2837
|
+
}
|
|
2838
|
+
mountList() {
|
|
2839
|
+
const names = [...this.mounts.keys()];
|
|
2840
|
+
return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
|
|
2841
|
+
}
|
|
2842
|
+
emptyPathField(mount, bare) {
|
|
2843
|
+
const empty = '"path" is "", which names no file or directory.';
|
|
2844
|
+
if (mount === undefined)
|
|
2845
|
+
return `${empty} Leaving "path" out ${bare}.`;
|
|
2846
|
+
if (mount === '')
|
|
2847
|
+
return `${empty} "mount" is "" too; leaving both out ${bare}.`;
|
|
2848
|
+
if (this.mounts.has(mount))
|
|
2849
|
+
return `${empty} Leaving "path" out covers mount ${JSON.stringify(mount)}.`;
|
|
2850
|
+
return `${empty} "mount" is ${JSON.stringify(mount)}, which is no mount's name; ${this.mountList()}. Leaving both out ${bare}.`;
|
|
2851
|
+
}
|
|
2852
|
+
noSuchMountField(given, bare) {
|
|
2853
|
+
const [close] = closestNames(given, [...this.mounts.keys()], 1);
|
|
2854
|
+
return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
|
|
2855
|
+
+ (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
|
|
2856
|
+
+ ` Leaving "mount" out ${bare}.`;
|
|
2857
|
+
}
|
|
2858
|
+
noSuchMount(path, mountName) {
|
|
2859
|
+
const names = [...this.mounts.keys()];
|
|
2860
|
+
const rest = path.slice(mountName.length);
|
|
2861
|
+
const unslashed = path.replace(/^[/\\]+/, '');
|
|
2862
|
+
if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
|
|
2863
|
+
return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
|
|
2864
|
+
}
|
|
2865
|
+
if (mountName === '')
|
|
2866
|
+
return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
2867
|
+
const [close] = closestNames(mountName, names, 1);
|
|
2868
|
+
const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
|
|
2869
|
+
: `${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()}.`;
|
|
2870
|
+
return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
|
|
2871
|
+
}
|
|
2793
2872
|
/**
|
|
2794
2873
|
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
2795
2874
|
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
@@ -2808,25 +2887,23 @@ export class WorkspaceModule {
|
|
|
2808
2887
|
}
|
|
2809
2888
|
validateImageBytes(bytes, mountPrefixedPath, maxSize) {
|
|
2810
2889
|
if (bytes.byteLength === 0) {
|
|
2811
|
-
throw
|
|
2890
|
+
throw emptyImage(mountPrefixedPath);
|
|
2812
2891
|
}
|
|
2813
2892
|
if (bytes.byteLength > maxSize)
|
|
2814
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
2893
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
|
|
2815
2894
|
return {
|
|
2816
2895
|
bytes,
|
|
2817
2896
|
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
2818
2897
|
};
|
|
2819
2898
|
}
|
|
2820
|
-
|
|
2821
|
-
const
|
|
2822
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2899
|
+
async readImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
2900
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
2823
2901
|
if (!entry)
|
|
2824
2902
|
return null;
|
|
2825
|
-
const
|
|
2826
|
-
if (
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
2903
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2904
|
+
if ('lost' in contents)
|
|
2905
|
+
return contents;
|
|
2906
|
+
return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
|
|
2830
2907
|
}
|
|
2831
2908
|
/**
|
|
2832
2909
|
* The mount root a disk read reads beneath: resolved, then checked as
|
|
@@ -2842,20 +2919,14 @@ export class WorkspaceModule {
|
|
|
2842
2919
|
* another directory carries none. The identity is rootIdentityForRead's,
|
|
2843
2920
|
* which answers while the evidence awaits reopening; where even it can't
|
|
2844
2921
|
* be read, as when the evidence was never read back whole, the read is
|
|
2845
|
-
* refused
|
|
2922
|
+
* refused before its root is resolved, with an errno only where no
|
|
2923
|
+
* directory is at the root's path.
|
|
2846
2924
|
* A direct mount's root is checked against no identity, and its read reads
|
|
2847
2925
|
* no evidence: it promises what's at its root now (diskView).
|
|
2848
2926
|
*/
|
|
2849
2927
|
async readRoot(mount) {
|
|
2850
2928
|
const name = mount.config.name;
|
|
2851
2929
|
const root = resolve(mount.config.path);
|
|
2852
|
-
let real;
|
|
2853
|
-
try {
|
|
2854
|
-
real = await realpath(root);
|
|
2855
|
-
}
|
|
2856
|
-
catch (err) {
|
|
2857
|
-
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
2858
|
-
}
|
|
2859
2930
|
let rootIdentity;
|
|
2860
2931
|
if (!mount.direct) {
|
|
2861
2932
|
try {
|
|
@@ -2864,9 +2935,16 @@ export class WorkspaceModule {
|
|
|
2864
2935
|
catch (err) {
|
|
2865
2936
|
// The evidence can't be read back, so neither can the check be made.
|
|
2866
2937
|
// Where no directory is at the root's path, its errno still says so.
|
|
2867
|
-
throw new
|
|
2938
|
+
throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
|
|
2868
2939
|
}
|
|
2869
2940
|
}
|
|
2941
|
+
let real;
|
|
2942
|
+
try {
|
|
2943
|
+
real = await realpath(root);
|
|
2944
|
+
}
|
|
2945
|
+
catch (err) {
|
|
2946
|
+
throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
|
|
2947
|
+
}
|
|
2870
2948
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
2871
2949
|
if ('dir' in checked)
|
|
2872
2950
|
return { real, dir: checked.dir };
|
|
@@ -2874,7 +2952,7 @@ export class WorkspaceModule {
|
|
|
2874
2952
|
// root's path now decides: nothing, something that isn't a directory, or
|
|
2875
2953
|
// a directory the path has come to name since it was resolved (no errno).
|
|
2876
2954
|
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
2877
|
-
throw new
|
|
2955
|
+
throw new RootUnavailable(name, checked.reason, 'root', errno);
|
|
2878
2956
|
}
|
|
2879
2957
|
/**
|
|
2880
2958
|
* The refusal for a root that stopped being the directory a read checked,
|
|
@@ -2888,7 +2966,7 @@ export class WorkspaceModule {
|
|
|
2888
2966
|
async rootMoved(mount) {
|
|
2889
2967
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
2890
2968
|
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
2891
|
-
return new
|
|
2969
|
+
return new RootUnavailable(mount.config.name, reason, 'root', errno);
|
|
2892
2970
|
}
|
|
2893
2971
|
/**
|
|
2894
2972
|
* Open a mount-relative file for reading with the mount boundary enforced on
|
|
@@ -2978,7 +3056,7 @@ export class WorkspaceModule {
|
|
|
2978
3056
|
}
|
|
2979
3057
|
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
2980
3058
|
// A FIFO, socket or device is never opened.
|
|
2981
|
-
throw new
|
|
3059
|
+
throw new NotRegularFile(mountPrefixedPath, 'lstat');
|
|
2982
3060
|
}
|
|
2983
3061
|
let handle;
|
|
2984
3062
|
try {
|
|
@@ -3009,7 +3087,7 @@ export class WorkspaceModule {
|
|
|
3009
3087
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
3010
3088
|
}
|
|
3011
3089
|
if (!fileStat.isFile()) {
|
|
3012
|
-
throw new
|
|
3090
|
+
throw new NotRegularFile(mountPrefixedPath, 'fstat');
|
|
3013
3091
|
}
|
|
3014
3092
|
if (!follow && !useNoFollow) {
|
|
3015
3093
|
let postOpenInfo;
|
|
@@ -3047,13 +3125,13 @@ export class WorkspaceModule {
|
|
|
3047
3125
|
if (resolvedThrough !== null)
|
|
3048
3126
|
throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
3049
3127
|
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
3050
|
-
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3128
|
+
const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
|
|
3051
3129
|
// A place in a nested mount's directory that its include leaves out is
|
|
3052
3130
|
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
3053
3131
|
// does one reaching a file this mount's own include leaves out
|
|
3054
3132
|
// (coversLanding).
|
|
3055
3133
|
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
3056
|
-
if (!inside || own || (holder !== undefined && !
|
|
3134
|
+
if (!inside || own || (holder !== undefined && !holder.names(false))) {
|
|
3057
3135
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
3058
3136
|
}
|
|
3059
3137
|
if (holder)
|
|
@@ -3122,7 +3200,9 @@ export class WorkspaceModule {
|
|
|
3122
3200
|
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
3123
3201
|
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
3124
3202
|
* directory, policy-denied symlink, outside-mount target, and
|
|
3125
|
-
* file-changed-during-read.
|
|
3203
|
+
* file-changed-during-read. An error met while parsing the path that is
|
|
3204
|
+
* none of these refusals, such as the store's, is thrown as it is, and a
|
|
3205
|
+
* `maxBytes` that isn't a non-negative integer throws a `RangeError`.
|
|
3126
3206
|
*/
|
|
3127
3207
|
async readFileFromDisk(mountPrefixedPath, options = {}) {
|
|
3128
3208
|
if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
|
|
@@ -3137,11 +3217,12 @@ export class WorkspaceModule {
|
|
|
3137
3217
|
// A link's refusal (parseFilePath) carries its own code.
|
|
3138
3218
|
if (err instanceof WorkspaceReadError)
|
|
3139
3219
|
throw err;
|
|
3140
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3141
3220
|
const code = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3142
3221
|
: err instanceof NestedMountError ? 'other_mount'
|
|
3143
|
-
:
|
|
3144
|
-
|
|
3222
|
+
: err instanceof PathRefusal ? err.code : null;
|
|
3223
|
+
if (code === null)
|
|
3224
|
+
throw err;
|
|
3225
|
+
throw new WorkspaceReadError(code, err.message, 'parse');
|
|
3145
3226
|
}
|
|
3146
3227
|
if (!relativePath) {
|
|
3147
3228
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
@@ -3171,93 +3252,25 @@ export class WorkspaceModule {
|
|
|
3171
3252
|
await handle.close();
|
|
3172
3253
|
}
|
|
3173
3254
|
}
|
|
3174
|
-
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
3175
|
-
imageErrorFromRead(err, mount, mountPrefixedPath) {
|
|
3176
|
-
const p = mountPrefixedPath;
|
|
3177
|
-
switch (err.code) {
|
|
3178
|
-
case 'mount_unavailable':
|
|
3179
|
-
// With why, where the root's check says, such as a directory that
|
|
3180
|
-
// isn't the mount's.
|
|
3181
|
-
return new WorkspaceImageReadError('mount_unavailable', err.message);
|
|
3182
|
-
case 'directory':
|
|
3183
|
-
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
3184
|
-
case 'symlink':
|
|
3185
|
-
// The core reader's own words for a link at the path: "Symlinks are not
|
|
3186
|
-
// allowed: <path>".
|
|
3187
|
-
return new WorkspaceImageReadError('symlink', err.message);
|
|
3188
|
-
case 'parent_symlink':
|
|
3189
|
-
// The core reader's own words for a link on its way: which link and
|
|
3190
|
-
// where it leads, with the setting for a link to a directory.
|
|
3191
|
-
return new WorkspaceImageReadError('parent_symlink', err.message);
|
|
3192
|
-
case 'other_mount':
|
|
3193
|
-
// The core reader's own words for a path a link takes into a nested
|
|
3194
|
-
// mount's directory: that mount and the path to use there. (#460's
|
|
3195
|
-
// own other_mount, a path in that directory, is refused at parse,
|
|
3196
|
-
// and so is a link found there before the read.)
|
|
3197
|
-
return new WorkspaceImageReadError('other_mount', err.message);
|
|
3198
|
-
case 'escape':
|
|
3199
|
-
// The core reader's own words: "Symlink escape detected: <path>" for a
|
|
3200
|
-
// followed link that resolves outside the mount, and for a link on the
|
|
3201
|
-
// way that leads outside a mount that doesn't follow links, which link.
|
|
3202
|
-
return new WorkspaceImageReadError('escape', err.message);
|
|
3203
|
-
case 'changed':
|
|
3204
|
-
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
3205
|
-
case 'not_found':
|
|
3206
|
-
default:
|
|
3207
|
-
// The core reader's own words for a file that isn't regular, and for
|
|
3208
|
-
// a directory on the way it couldn't ask about.
|
|
3209
|
-
if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
|
|
3210
|
-
return new WorkspaceImageReadError('not_found', err.message);
|
|
3211
|
-
}
|
|
3212
|
-
if (err.message.startsWith('File not found')) {
|
|
3213
|
-
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
3214
|
-
}
|
|
3215
|
-
if (err.stage === 'realpath') {
|
|
3216
|
-
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
3217
|
-
}
|
|
3218
|
-
if (err.stage === 'stat') {
|
|
3219
|
-
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
3220
|
-
}
|
|
3221
|
-
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
3222
|
-
}
|
|
3223
|
-
}
|
|
3224
3255
|
async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
if (fileStat.size > maxSize)
|
|
3232
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3233
|
-
});
|
|
3234
|
-
}
|
|
3235
|
-
catch (err) {
|
|
3236
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3237
|
-
throw err;
|
|
3238
|
-
if (err instanceof WorkspaceReadError)
|
|
3239
|
-
throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
3240
|
-
if (isErrnoCode(err, 'ENOENT')) {
|
|
3241
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3242
|
-
}
|
|
3243
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3244
|
-
}
|
|
3245
|
-
const { handle, fileStat } = opened;
|
|
3256
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
3257
|
+
if (fileStat.size === 0)
|
|
3258
|
+
throw emptyImage(mountPrefixedPath);
|
|
3259
|
+
if (fileStat.size > maxSize)
|
|
3260
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
|
|
3261
|
+
});
|
|
3246
3262
|
try {
|
|
3247
3263
|
// No more than maxSize is loaded: a file grown past it since the fstat
|
|
3248
3264
|
// above is refused here, as one already over it was there.
|
|
3249
3265
|
const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
|
|
3250
3266
|
if (more)
|
|
3251
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3267
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
|
|
3252
3268
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
3253
3269
|
}
|
|
3254
3270
|
catch (err) {
|
|
3255
3271
|
if (err instanceof WorkspaceImageReadError)
|
|
3256
3272
|
throw err;
|
|
3257
|
-
|
|
3258
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3259
|
-
}
|
|
3260
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3273
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
3261
3274
|
}
|
|
3262
3275
|
finally {
|
|
3263
3276
|
await handle.close();
|
|
@@ -3289,13 +3302,14 @@ export class WorkspaceModule {
|
|
|
3289
3302
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
3290
3303
|
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
3291
3304
|
* deletion that cleared a conflict naming a disk file the workspace never
|
|
3292
|
-
* took in, that nothing was unlinked (`note`).
|
|
3293
|
-
*
|
|
3294
|
-
* the path,
|
|
3305
|
+
* took in, that nothing was unlinked (`note`). And the answer's `disk` for
|
|
3306
|
+
* the path, unless it was refused. The caller holds the mount's turn and has
|
|
3307
|
+
* settled the path first (settleBeforeMutation, or a pass over the path, as
|
|
3308
|
+
* an edit makes).
|
|
3295
3309
|
*/
|
|
3296
3310
|
async commitToolChange(mount, relativePath, change, onCommitted) {
|
|
3297
|
-
const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3298
|
-
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
|
|
3311
|
+
const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3312
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
|
|
3299
3313
|
}
|
|
3300
3314
|
/**
|
|
3301
3315
|
* commitToolChange for several paths of one mount at once, as edit_many
|
|
@@ -3310,9 +3324,10 @@ export class WorkspaceModule {
|
|
|
3310
3324
|
* (changesLanded).
|
|
3311
3325
|
* Answers, by path, the reason for each that reached the workspace but not
|
|
3312
3326
|
* disk (`refused`), and, apart from it, why one whose bytes reached disk
|
|
3313
|
-
* left the file's mode as disk had it (`modeKept`),
|
|
3314
|
-
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3315
|
-
*
|
|
3327
|
+
* left the file's mode as disk had it (`modeKept`), that a deletion
|
|
3328
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3329
|
+
* and the answer's `disk` for each path not refused, as commitToolChange
|
|
3330
|
+
* answers them for one path.
|
|
3316
3331
|
*/
|
|
3317
3332
|
async commitToolChanges(mount, changes, onCommitted) {
|
|
3318
3333
|
const store = this.getStore();
|
|
@@ -3461,7 +3476,7 @@ export class WorkspaceModule {
|
|
|
3461
3476
|
// nothing.
|
|
3462
3477
|
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3463
3478
|
putBack();
|
|
3464
|
-
throw error;
|
|
3479
|
+
throw changing.length === 0 ? new AlreadyHeld(error) : error;
|
|
3465
3480
|
}
|
|
3466
3481
|
landedThenThrew = { error };
|
|
3467
3482
|
}
|
|
@@ -3479,8 +3494,9 @@ export class WorkspaceModule {
|
|
|
3479
3494
|
throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3480
3495
|
const refused = new Map();
|
|
3481
3496
|
const modeKept = new Map();
|
|
3482
|
-
if (!this.pushesOnCommit(mount) || pushing.length === 0)
|
|
3483
|
-
return { refused, modeKept, notes };
|
|
3497
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) {
|
|
3498
|
+
return { refused, modeKept, notes, disk: new Map(changes.map(({ path }) => [path, this.diskOf(mount, path)])) };
|
|
3499
|
+
}
|
|
3484
3500
|
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3485
3501
|
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
3486
3502
|
branchId: store.currentBranch().id,
|
|
@@ -3502,7 +3518,7 @@ export class WorkspaceModule {
|
|
|
3502
3518
|
error: skipped.reason,
|
|
3503
3519
|
});
|
|
3504
3520
|
}
|
|
3505
|
-
return { refused, modeKept, notes };
|
|
3521
|
+
return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }) => [path, 'done'])) };
|
|
3506
3522
|
}
|
|
3507
3523
|
// ==========================================================================
|
|
3508
3524
|
// Lazy Sync
|
|
@@ -3551,25 +3567,29 @@ export class WorkspaceModule {
|
|
|
3551
3567
|
* call made again checks it.
|
|
3552
3568
|
*/
|
|
3553
3569
|
notCheckedYet(path, reason) {
|
|
3554
|
-
return `${
|
|
3570
|
+
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.`;
|
|
3555
3571
|
}
|
|
3556
3572
|
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
3557
|
-
notInWorkspace(path, report) {
|
|
3573
|
+
notInWorkspace(path, report, mount, missing) {
|
|
3574
|
+
const shown = JSON.stringify(path);
|
|
3558
3575
|
switch (report?.state) {
|
|
3559
3576
|
case 'workspace-deleted':
|
|
3560
|
-
return `${
|
|
3577
|
+
return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
3561
3578
|
case 'not-in-branch':
|
|
3562
|
-
return `${
|
|
3563
|
-
case 'disk-only':
|
|
3564
|
-
|
|
3565
|
-
|
|
3579
|
+
return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
3580
|
+
case 'disk-only': {
|
|
3581
|
+
const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
3582
|
+
return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
|
|
3583
|
+
+ 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
|
|
3584
|
+
+ (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
|
|
3585
|
+
}
|
|
3566
3586
|
case 'conflict':
|
|
3567
|
-
return `${
|
|
3587
|
+
return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
3568
3588
|
'Sync this path to take the disk version.';
|
|
3569
3589
|
case 'unverified':
|
|
3570
|
-
return
|
|
3590
|
+
return uncheckedFile(shown, report.note, report.noteCause ?? {});
|
|
3571
3591
|
default:
|
|
3572
|
-
return `
|
|
3592
|
+
return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
3573
3593
|
}
|
|
3574
3594
|
}
|
|
3575
3595
|
/**
|
|
@@ -3611,15 +3631,15 @@ export class WorkspaceModule {
|
|
|
3611
3631
|
// A pass also drops a copy disk made binary or too large, which
|
|
3612
3632
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3613
3633
|
return {
|
|
3614
|
-
error: took('deleted') && report?.state !== 'disk-only'
|
|
3615
|
-
|
|
3616
|
-
|
|
3634
|
+
error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
|
|
3635
|
+
: undecided !== null ? this.notCheckedYet(path, undecided)
|
|
3636
|
+
: this.notInWorkspace(path, report, mount),
|
|
3617
3637
|
};
|
|
3618
3638
|
}
|
|
3619
|
-
const
|
|
3620
|
-
if (
|
|
3621
|
-
return { error:
|
|
3622
|
-
return { bytes:
|
|
3639
|
+
const contents = await this.contentsOf(mount, relativePath, path, entry);
|
|
3640
|
+
if ('lost' in contents)
|
|
3641
|
+
return { error: nothingToRead(contents.lost) };
|
|
3642
|
+
return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3623
3643
|
});
|
|
3624
3644
|
}
|
|
3625
3645
|
/**
|
|
@@ -3632,37 +3652,145 @@ export class WorkspaceModule {
|
|
|
3632
3652
|
*/
|
|
3633
3653
|
async bytesOnDisk(mount, relativePath, path) {
|
|
3634
3654
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3635
|
-
const over = `${path} is
|
|
3655
|
+
const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
|
|
3636
3656
|
let opened;
|
|
3637
3657
|
try {
|
|
3638
3658
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3639
3659
|
if (fileStat.size > maxSize)
|
|
3640
|
-
throw new
|
|
3660
|
+
throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
3641
3661
|
});
|
|
3642
3662
|
}
|
|
3643
3663
|
catch (err) {
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3664
|
+
if (err instanceof Refusal)
|
|
3665
|
+
return { error: err.message };
|
|
3666
|
+
if (err instanceof WorkspaceReadError)
|
|
3667
|
+
return { error: readRefusal(err, path) };
|
|
3668
|
+
throw err;
|
|
3647
3669
|
}
|
|
3648
3670
|
try {
|
|
3649
3671
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
3650
3672
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
3651
3673
|
if (more)
|
|
3652
|
-
return { error: over };
|
|
3674
|
+
return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
3653
3675
|
if (looksBinary(bytes)) {
|
|
3654
|
-
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '
|
|
3676
|
+
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.'}` };
|
|
3655
3677
|
}
|
|
3656
3678
|
return { bytes };
|
|
3657
3679
|
}
|
|
3658
3680
|
catch (err) {
|
|
3659
|
-
|
|
3660
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
3681
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
3661
3682
|
}
|
|
3662
3683
|
finally {
|
|
3663
3684
|
await opened.handle.close();
|
|
3664
3685
|
}
|
|
3665
3686
|
}
|
|
3687
|
+
async contentsOf(mount, relativePath, path, version, what = 'saved', tell = 'now') {
|
|
3688
|
+
const store = this.getStore();
|
|
3689
|
+
const stored = storedBytes(store, version.blobHash);
|
|
3690
|
+
if ('bytes' in stored)
|
|
3691
|
+
return stored;
|
|
3692
|
+
const otherSize = new Error('other size');
|
|
3693
|
+
let found;
|
|
3694
|
+
try {
|
|
3695
|
+
const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3696
|
+
if (fileStat.size !== version.size)
|
|
3697
|
+
throw otherSize;
|
|
3698
|
+
});
|
|
3699
|
+
try {
|
|
3700
|
+
const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
|
|
3701
|
+
found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
|
|
3702
|
+
}
|
|
3703
|
+
finally {
|
|
3704
|
+
await opened.handle.close();
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
catch (err) {
|
|
3708
|
+
found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
|
|
3709
|
+
}
|
|
3710
|
+
const subject = contentsSubject(what, path);
|
|
3711
|
+
const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
|
|
3712
|
+
if ('disk' in found) {
|
|
3713
|
+
const now = storedBytes(store, version.blobHash);
|
|
3714
|
+
if ('bytes' in now)
|
|
3715
|
+
return now;
|
|
3716
|
+
const lost = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
|
|
3717
|
+
if (tell === 'now')
|
|
3718
|
+
this.tellLost(mount.config.name, what, version.blobHash, lost);
|
|
3719
|
+
return { lost };
|
|
3720
|
+
}
|
|
3721
|
+
const { bytes } = found;
|
|
3722
|
+
if ('bytes' in storedBytes(store, version.blobHash))
|
|
3723
|
+
return { bytes };
|
|
3724
|
+
let thrown = null;
|
|
3725
|
+
try {
|
|
3726
|
+
store.storeBlob(bytes, 'application/octet-stream');
|
|
3727
|
+
}
|
|
3728
|
+
catch (error) {
|
|
3729
|
+
thrown = error instanceof Error ? error.message : String(error);
|
|
3730
|
+
}
|
|
3731
|
+
// Chronicle's storeBlob takes any file already at the hash's path as
|
|
3732
|
+
// stored, so whether the bytes took is what a read of them gives now.
|
|
3733
|
+
const now = storedBytes(store, version.blobHash);
|
|
3734
|
+
if ('bytes' in now) {
|
|
3735
|
+
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);
|
|
3736
|
+
}
|
|
3737
|
+
else {
|
|
3738
|
+
const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
|
|
3739
|
+
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}. `
|
|
3740
|
+
+ ('unreadable' in now
|
|
3741
|
+
? "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."
|
|
3742
|
+
: '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 });
|
|
3743
|
+
}
|
|
3744
|
+
return { bytes };
|
|
3745
|
+
}
|
|
3746
|
+
tellLost(mountName, what, blobHash, lost) {
|
|
3747
|
+
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)}. `
|
|
3748
|
+
+ "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 } : {}) });
|
|
3749
|
+
}
|
|
3750
|
+
async diskLacks(mount, relativePath, path, err) {
|
|
3751
|
+
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
3752
|
+
if (!absent)
|
|
3753
|
+
return { unreadable: containedRefusal(err, path) };
|
|
3754
|
+
const root = resolve(mount.config.path);
|
|
3755
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
3756
|
+
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
3757
|
+
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
3758
|
+
if (!('reason' in proof))
|
|
3759
|
+
return 'none';
|
|
3760
|
+
return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
|
|
3761
|
+
}
|
|
3762
|
+
async restoreEach(mount, versions, each) {
|
|
3763
|
+
const byHash = new Map();
|
|
3764
|
+
for (const version of versions) {
|
|
3765
|
+
const sharing = byHash.get(version.hash);
|
|
3766
|
+
if (sharing)
|
|
3767
|
+
sharing.push(version);
|
|
3768
|
+
else
|
|
3769
|
+
byHash.set(version.hash, [version]);
|
|
3770
|
+
}
|
|
3771
|
+
for (const [hash, sharing] of byHash) {
|
|
3772
|
+
let bytes = null;
|
|
3773
|
+
const lost = [];
|
|
3774
|
+
for (const version of sharing) {
|
|
3775
|
+
const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
|
|
3776
|
+
if ('bytes' in contents) {
|
|
3777
|
+
bytes = contents.bytes;
|
|
3778
|
+
break;
|
|
3779
|
+
}
|
|
3780
|
+
lost.push([version, contents.lost]);
|
|
3781
|
+
}
|
|
3782
|
+
if (bytes === null) {
|
|
3783
|
+
for (const [version, found] of lost) {
|
|
3784
|
+
this.tellLost(mount.config.name, version.what, hash, found);
|
|
3785
|
+
each(version, { lost: found });
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
else {
|
|
3789
|
+
for (const version of sharing)
|
|
3790
|
+
each(version, { bytes });
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3666
3794
|
// ==========================================================================
|
|
3667
3795
|
// Tool Handlers
|
|
3668
3796
|
// ==========================================================================
|
|
@@ -3677,16 +3805,18 @@ export class WorkspaceModule {
|
|
|
3677
3805
|
// Each number is a whole number of at least its schema's minimum by now
|
|
3678
3806
|
// (inputProblem).
|
|
3679
3807
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
3680
|
-
return { success: false, isError: true, error: '
|
|
3808
|
+
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.') };
|
|
3681
3809
|
}
|
|
3682
3810
|
let file;
|
|
3683
3811
|
let notes = [];
|
|
3684
3812
|
if (generatedContent === undefined) {
|
|
3685
3813
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3814
|
+
if (relativePath === '')
|
|
3815
|
+
return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3686
3816
|
input = { ...input, path };
|
|
3687
3817
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
3688
3818
|
if ('error' in found)
|
|
3689
|
-
return { success: false, error: found.error, isError: true };
|
|
3819
|
+
return { success: false, error: didNothing('read', found.error), isError: true };
|
|
3690
3820
|
file = fileText(found.bytes);
|
|
3691
3821
|
notes = found.notes;
|
|
3692
3822
|
}
|
|
@@ -3702,31 +3832,23 @@ export class WorkspaceModule {
|
|
|
3702
3832
|
: linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
|
|
3703
3833
|
}
|
|
3704
3834
|
async handleReadImage(input) {
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
(
|
|
3710
|
-
|
|
3711
|
-
}
|
|
3712
|
-
catch (err) {
|
|
3713
|
-
return {
|
|
3714
|
-
success: false,
|
|
3715
|
-
error: err instanceof Error ? err.message : String(err),
|
|
3716
|
-
isError: true,
|
|
3717
|
-
};
|
|
3718
|
-
}
|
|
3719
|
-
if (!relativePath) {
|
|
3720
|
-
return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
|
|
3721
|
-
}
|
|
3835
|
+
const given = input.path;
|
|
3836
|
+
const refused = (body) => ({ success: false, isError: true, error: didNothing('read_image', body) });
|
|
3837
|
+
const { mount, relativePath, path } = await this.parseFilePath(given);
|
|
3838
|
+
if (!relativePath)
|
|
3839
|
+
return refused(namesRoot(given, mount.config.name));
|
|
3840
|
+
input = { ...input, path };
|
|
3722
3841
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3723
3842
|
let treeError = null;
|
|
3843
|
+
let lost = null;
|
|
3724
3844
|
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
3725
3845
|
// mount holds none, so its image is disk's.
|
|
3726
3846
|
if (!mount.direct) {
|
|
3727
3847
|
try {
|
|
3728
|
-
const image = this.
|
|
3729
|
-
if (image)
|
|
3848
|
+
const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
|
|
3849
|
+
if (image && 'lost' in image)
|
|
3850
|
+
lost = image.lost;
|
|
3851
|
+
else if (image)
|
|
3730
3852
|
return imageResult(input.path, image);
|
|
3731
3853
|
}
|
|
3732
3854
|
catch (err) {
|
|
@@ -3739,31 +3861,40 @@ export class WorkspaceModule {
|
|
|
3739
3861
|
}
|
|
3740
3862
|
}
|
|
3741
3863
|
try {
|
|
3742
|
-
|
|
3864
|
+
const note = lost === null ? undefined
|
|
3865
|
+
: `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
|
|
3866
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
|
|
3743
3867
|
}
|
|
3744
3868
|
catch (err) {
|
|
3745
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3869
|
+
if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError))
|
|
3870
|
+
throw err;
|
|
3871
|
+
const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
|
|
3872
|
+
if (lost) {
|
|
3873
|
+
const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
|
|
3874
|
+
return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
|
|
3875
|
+
: `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
|
|
3750
3876
|
}
|
|
3751
|
-
|
|
3877
|
+
if (noFile && treeError)
|
|
3878
|
+
return refused(treeError.message);
|
|
3879
|
+
return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
|
|
3752
3880
|
}
|
|
3753
3881
|
}
|
|
3754
3882
|
async handleWrite(input, callerAgentName) {
|
|
3755
3883
|
if (LONE_SURROGATE.test(input.content)) {
|
|
3756
|
-
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
3884
|
+
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.' };
|
|
3757
3885
|
}
|
|
3758
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3886
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
|
|
3887
|
+
if (relativePath === '')
|
|
3888
|
+
return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3759
3889
|
input = { ...input, path };
|
|
3760
|
-
const refusal = writeRefusal(mount);
|
|
3890
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
3761
3891
|
if (refusal)
|
|
3762
|
-
return { success: false, error: refusal, isError: true };
|
|
3892
|
+
return { success: false, error: didNothing('write', refusal), isError: true };
|
|
3763
3893
|
const store = this.getStore();
|
|
3764
3894
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3765
|
-
|
|
3766
|
-
|
|
3895
|
+
const bytes = Buffer.byteLength(input.content);
|
|
3896
|
+
if (bytes > maxSize) {
|
|
3897
|
+
return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
|
|
3767
3898
|
}
|
|
3768
3899
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
3769
3900
|
const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
|
|
@@ -3772,13 +3903,14 @@ export class WorkspaceModule {
|
|
|
3772
3903
|
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
3773
3904
|
if ('error' in written)
|
|
3774
3905
|
return { success: false, isError: true, error: written.error };
|
|
3775
|
-
return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
|
|
3906
|
+
return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
3776
3907
|
}
|
|
3777
3908
|
let committed = false;
|
|
3778
3909
|
let refused;
|
|
3779
3910
|
let modeKept;
|
|
3911
|
+
let disk;
|
|
3780
3912
|
try {
|
|
3781
|
-
({ refused, modeKept } = await this.withMount(mount, async () => {
|
|
3913
|
+
({ refused, modeKept, disk } = await this.withMount(mount, async () => {
|
|
3782
3914
|
await this.settleBeforeMutation(mount, relativePath);
|
|
3783
3915
|
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
3784
3916
|
// An overwrite keeps the file's mode, as an edit does.
|
|
@@ -3788,14 +3920,14 @@ export class WorkspaceModule {
|
|
|
3788
3920
|
}
|
|
3789
3921
|
catch (error) {
|
|
3790
3922
|
if (!committed)
|
|
3791
|
-
throw error;
|
|
3923
|
+
throw notStored(error);
|
|
3792
3924
|
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
3793
3925
|
? { storeFailed: error.message, pushes: error.pushes }
|
|
3794
|
-
: { threw: error instanceof Error ? error.message : String(error) });
|
|
3926
|
+
: { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
3795
3927
|
}
|
|
3796
3928
|
if (refused !== null)
|
|
3797
3929
|
return storedButUnfinished('write', input.path, { refused });
|
|
3798
|
-
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
3930
|
+
return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
|
|
3799
3931
|
}
|
|
3800
3932
|
async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
|
|
3801
3933
|
return this.applyEdits('edit', [{
|
|
@@ -3835,12 +3967,14 @@ export class WorkspaceModule {
|
|
|
3835
3967
|
* stored where its record landed, and that it can't tell where they
|
|
3836
3968
|
* couldn't be read back.
|
|
3837
3969
|
* Under the mount's turn, one pass settles every path with disk, by the
|
|
3838
|
-
* rule a listing uses, so the edits apply to what disk now holds.
|
|
3839
|
-
*
|
|
3840
|
-
*
|
|
3841
|
-
*
|
|
3842
|
-
*
|
|
3843
|
-
* of the
|
|
3970
|
+
* rule a listing uses, so the edits apply to what disk now holds. Each
|
|
3971
|
+
* file's stored contents are then read; where the store has lost some,
|
|
3972
|
+
* they're restored from disk's matching copy (contentsOf) and the pass runs
|
|
3973
|
+
* again, so the plan reads a pass taken after every await. From there
|
|
3974
|
+
* nothing awaits before the commit: every edit is planned and every entry
|
|
3975
|
+
* set (commitToolChanges) with no turn of the event loop between, so no
|
|
3976
|
+
* pass, branch change or other call moves the tree between a file's read
|
|
3977
|
+
* and its set. Disk then takes what it can of the stored change.
|
|
3844
3978
|
*
|
|
3845
3979
|
* The answer is held to `maxChars`, the length the call's result may have
|
|
3846
3980
|
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
@@ -3875,7 +4009,7 @@ export class WorkspaceModule {
|
|
|
3875
4009
|
}
|
|
3876
4010
|
for (const field of ['oldString', 'newString']) {
|
|
3877
4011
|
if (LONE_SURROGATE.test(edit[field])) {
|
|
3878
|
-
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
|
|
4012
|
+
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.` });
|
|
3879
4013
|
}
|
|
3880
4014
|
}
|
|
3881
4015
|
if (problems.length === found)
|
|
@@ -3937,7 +4071,31 @@ export class WorkspaceModule {
|
|
|
3937
4071
|
return await this.withMount(mount, async () => {
|
|
3938
4072
|
// As every file tool's pass does, it names the paths it acts on, so a
|
|
3939
4073
|
// file the mount passes over by design is observed (see PassOptions.named).
|
|
3940
|
-
|
|
4074
|
+
let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4075
|
+
let ops = pass.ops;
|
|
4076
|
+
const contents = new Map();
|
|
4077
|
+
const lost = new Map();
|
|
4078
|
+
const toRecover = [];
|
|
4079
|
+
for (const target of targets.values()) {
|
|
4080
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4081
|
+
if (!entry || contents.has(entry.blobHash))
|
|
4082
|
+
continue;
|
|
4083
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
4084
|
+
if ('bytes' in stored)
|
|
4085
|
+
contents.set(entry.blobHash, stored.bytes);
|
|
4086
|
+
else
|
|
4087
|
+
toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
|
|
4088
|
+
}
|
|
4089
|
+
if (toRecover.length > 0) {
|
|
4090
|
+
await this.restoreEach(mount, toRecover, (version, found) => {
|
|
4091
|
+
if ('bytes' in found)
|
|
4092
|
+
contents.set(version.hash, found.bytes);
|
|
4093
|
+
else
|
|
4094
|
+
lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
|
|
4095
|
+
});
|
|
4096
|
+
pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4097
|
+
ops = [...ops, ...pass.ops];
|
|
4098
|
+
}
|
|
3941
4099
|
// A pass that decided nothing checked no file's disk copy.
|
|
3942
4100
|
const undecided = undecidedOf(pass);
|
|
3943
4101
|
// Nothing below awaits until the commit's push.
|
|
@@ -3957,7 +4115,7 @@ export class WorkspaceModule {
|
|
|
3957
4115
|
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
3958
4116
|
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
3959
4117
|
// then write at the mode disk has, so it leaves nothing to say.
|
|
3960
|
-
const took = (op) =>
|
|
4118
|
+
const took = (op) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
3961
4119
|
// Which copy the edits used, when it isn't the one the workspace
|
|
3962
4120
|
// last agreed on with disk: disk's newer text, or the workspace's
|
|
3963
4121
|
// copy because disk's couldn't be read or disk has none. A file the
|
|
@@ -4021,18 +4179,28 @@ export class WorkspaceModule {
|
|
|
4021
4179
|
// A pass also drops a copy disk made binary or too large, which
|
|
4022
4180
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
4023
4181
|
const text = took('deleted') && report?.state !== 'disk-only'
|
|
4024
|
-
?
|
|
4025
|
-
: this.notInWorkspace(target.path, report);
|
|
4026
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole:
|
|
4182
|
+
? deletedOnDisk(target.path)
|
|
4183
|
+
: this.notInWorkspace(target.path, report, mount);
|
|
4184
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
|
|
4027
4185
|
continue;
|
|
4028
4186
|
}
|
|
4029
|
-
|
|
4030
|
-
const
|
|
4031
|
-
if (!
|
|
4032
|
-
|
|
4187
|
+
const known = contents.get(entry.blobHash);
|
|
4188
|
+
const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
|
|
4189
|
+
if (!('bytes' in stored)) {
|
|
4190
|
+
const found = lost.get(target.relativePath);
|
|
4191
|
+
problems.push({
|
|
4192
|
+
kind: 'file',
|
|
4193
|
+
edits: target.edits,
|
|
4194
|
+
path: target.path,
|
|
4195
|
+
text: found?.blobHash === entry.blobHash
|
|
4196
|
+
? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
|
|
4197
|
+
: `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.`,
|
|
4198
|
+
whole: true,
|
|
4199
|
+
});
|
|
4033
4200
|
continue;
|
|
4034
4201
|
}
|
|
4035
|
-
|
|
4202
|
+
notes.push(...lookedIn);
|
|
4203
|
+
const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
|
|
4036
4204
|
if (plan === null)
|
|
4037
4205
|
continue;
|
|
4038
4206
|
plans.push({
|
|
@@ -4048,7 +4216,7 @@ export class WorkspaceModule {
|
|
|
4048
4216
|
}
|
|
4049
4217
|
if (problems.length)
|
|
4050
4218
|
return refuse(problems, notes);
|
|
4051
|
-
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4219
|
+
const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4052
4220
|
path: target.relativePath,
|
|
4053
4221
|
change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4054
4222
|
})), () => { committed = true; });
|
|
@@ -4059,7 +4227,7 @@ export class WorkspaceModule {
|
|
|
4059
4227
|
plan,
|
|
4060
4228
|
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
|
|
4061
4229
|
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4062
|
-
})), given.length, maxChars);
|
|
4230
|
+
})), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath))));
|
|
4063
4231
|
});
|
|
4064
4232
|
}
|
|
4065
4233
|
catch (error) {
|
|
@@ -4067,12 +4235,12 @@ export class WorkspaceModule {
|
|
|
4067
4235
|
if (committed) {
|
|
4068
4236
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
4069
4237
|
? { storeFailed: message, pushes: error.pushes }
|
|
4070
|
-
: { threw: message }, maxChars);
|
|
4238
|
+
: { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
|
|
4071
4239
|
}
|
|
4072
4240
|
// A set that threw and couldn't be read back may have stored the edits,
|
|
4073
4241
|
// and a second call could then apply them twice.
|
|
4074
4242
|
if (error instanceof CommitUnknownError) {
|
|
4075
|
-
const [set, read] = [
|
|
4243
|
+
const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
|
|
4076
4244
|
return {
|
|
4077
4245
|
success: false,
|
|
4078
4246
|
isError: true,
|
|
@@ -4085,10 +4253,8 @@ export class WorkspaceModule {
|
|
|
4085
4253
|
}
|
|
4086
4254
|
// The commit sets every entry in one record or none, and a set that
|
|
4087
4255
|
// threw was read back, so nothing is stored; or every change was one
|
|
4088
|
-
// the tree already held
|
|
4089
|
-
|
|
4090
|
-
return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
|
|
4091
|
-
throw error;
|
|
4256
|
+
// the tree already held (AlreadyHeld).
|
|
4257
|
+
throw notStored(error);
|
|
4092
4258
|
}
|
|
4093
4259
|
}
|
|
4094
4260
|
/**
|
|
@@ -4101,7 +4267,7 @@ export class WorkspaceModule {
|
|
|
4101
4267
|
* (handleToolCall checks a call's `path`, which edit_many's input lacks).
|
|
4102
4268
|
*/
|
|
4103
4269
|
async editablePath(tool, path, callerAgentName) {
|
|
4104
|
-
const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole:
|
|
4270
|
+
const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: false });
|
|
4105
4271
|
try {
|
|
4106
4272
|
// In the one spelling every file tool reads, as disk spells it, so two
|
|
4107
4273
|
// spellings of a file are one entry and, in one call, one plan
|
|
@@ -4111,17 +4277,21 @@ export class WorkspaceModule {
|
|
|
4111
4277
|
// One that names the mount's root, its bare name included, names no
|
|
4112
4278
|
// file.
|
|
4113
4279
|
if (where.relativePath === '')
|
|
4114
|
-
return unusable(
|
|
4115
|
-
const refusal = writeRefusal(where.mount);
|
|
4280
|
+
return unusable(namesRoot(path, where.mount.config.name));
|
|
4281
|
+
const refusal = writeRefusal(where.mount, this.writableNames());
|
|
4116
4282
|
if (refusal)
|
|
4117
4283
|
return unusable(refusal);
|
|
4118
4284
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4119
4285
|
if (generated)
|
|
4120
|
-
return unusable(
|
|
4286
|
+
return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
|
|
4121
4287
|
return where;
|
|
4122
4288
|
}
|
|
4123
4289
|
catch (error) {
|
|
4124
|
-
|
|
4290
|
+
if (error instanceof Refusal)
|
|
4291
|
+
return unusable(error.message);
|
|
4292
|
+
if (error instanceof WorkspaceReadError)
|
|
4293
|
+
return unusable(readRefusal(error, path));
|
|
4294
|
+
throw notStored(error);
|
|
4125
4295
|
}
|
|
4126
4296
|
}
|
|
4127
4297
|
/**
|
|
@@ -4129,10 +4299,10 @@ export class WorkspaceModule {
|
|
|
4129
4299
|
* and the agent's own is read-only, since it shows the agent's tool
|
|
4130
4300
|
* presentation, which set_tool_visibility and set_tool_description change.
|
|
4131
4301
|
*/
|
|
4132
|
-
generatedFileRefusal(generated, callerAgentName) {
|
|
4302
|
+
generatedFileRefusal(path, generated, callerAgentName) {
|
|
4133
4303
|
return callerAgentName !== generated.agentName
|
|
4134
|
-
?
|
|
4135
|
-
:
|
|
4304
|
+
? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
|
|
4305
|
+
: `${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.`;
|
|
4136
4306
|
}
|
|
4137
4307
|
/**
|
|
4138
4308
|
* The answer to edits stored but not finished: the commit set every file's
|
|
@@ -4148,12 +4318,12 @@ export class WorkspaceModule {
|
|
|
4148
4318
|
* which stand as on a success, it names each conflict the success answer
|
|
4149
4319
|
* would (`conflicts`, found only where the mount doesn't push).
|
|
4150
4320
|
*/
|
|
4151
|
-
editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
|
|
4321
|
+
editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars, reopen = false) {
|
|
4152
4322
|
const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4153
4323
|
if (tool === 'edit') {
|
|
4154
4324
|
const [target] = targets;
|
|
4155
4325
|
const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
|
|
4156
|
-
return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
|
|
4326
|
+
return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null, reopen);
|
|
4157
4327
|
}
|
|
4158
4328
|
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.";
|
|
4159
4329
|
// The head is never cut, so what stands for the whole call goes in it,
|
|
@@ -4176,10 +4346,11 @@ export class WorkspaceModule {
|
|
|
4176
4346
|
}
|
|
4177
4347
|
else if ('storeFailed' in outcome) {
|
|
4178
4348
|
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4179
|
-
+ (outcome.pushes ?
|
|
4349
|
+
+ (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
4350
|
+
+ (reopen ? ` ${AWAITS_REOPEN}` : '');
|
|
4180
4351
|
}
|
|
4181
4352
|
else {
|
|
4182
|
-
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}
|
|
4353
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
|
|
4183
4354
|
}
|
|
4184
4355
|
const kept = 'modeKept' in outcome
|
|
4185
4356
|
? targets.flatMap((target) => {
|
|
@@ -4213,7 +4384,7 @@ export class WorkspaceModule {
|
|
|
4213
4384
|
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4214
4385
|
}
|
|
4215
4386
|
catch (error) {
|
|
4216
|
-
return { error:
|
|
4387
|
+
return { error: directDidNothing('write', path, error) };
|
|
4217
4388
|
}
|
|
4218
4389
|
try {
|
|
4219
4390
|
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
@@ -4228,7 +4399,7 @@ export class WorkspaceModule {
|
|
|
4228
4399
|
}
|
|
4229
4400
|
catch (error) {
|
|
4230
4401
|
await discard(staged);
|
|
4231
|
-
return { error:
|
|
4402
|
+
return { error: directDidNothing('write', path, error) };
|
|
4232
4403
|
}
|
|
4233
4404
|
this.recordDirectWrite(mount, agentName);
|
|
4234
4405
|
return { note: linksNote(path, staged, 'write') };
|
|
@@ -4288,7 +4459,7 @@ export class WorkspaceModule {
|
|
|
4288
4459
|
return 'changed';
|
|
4289
4460
|
}
|
|
4290
4461
|
if ('error' in got)
|
|
4291
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole:
|
|
4462
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
|
|
4292
4463
|
else
|
|
4293
4464
|
read.set(target, got);
|
|
4294
4465
|
}
|
|
@@ -4358,7 +4529,8 @@ export class WorkspaceModule {
|
|
|
4358
4529
|
edits: target.edits,
|
|
4359
4530
|
path: target.path,
|
|
4360
4531
|
text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
|
|
4361
|
-
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4532
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4533
|
+
+ (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
|
|
4362
4534
|
whole: true,
|
|
4363
4535
|
}]);
|
|
4364
4536
|
for (const { target, plan } of plans) {
|
|
@@ -4428,7 +4600,7 @@ export class WorkspaceModule {
|
|
|
4428
4600
|
plan: plans.find((p) => p.target === target).plan,
|
|
4429
4601
|
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
|
|
4430
4602
|
modeKept: null,
|
|
4431
|
-
})), editCount, maxChars);
|
|
4603
|
+
})), editCount, maxChars, 'done');
|
|
4432
4604
|
}
|
|
4433
4605
|
/**
|
|
4434
4606
|
* The answer to an edit_many call on a direct mount whose rename of its
|
|
@@ -4440,7 +4612,8 @@ export class WorkspaceModule {
|
|
|
4440
4612
|
directEditsPartway(staged, failed, error, maxChars) {
|
|
4441
4613
|
// The failed file is named among the outcomes, so the head stays as
|
|
4442
4614
|
// long as its reason, whatever the paths' lengths.
|
|
4443
|
-
const
|
|
4615
|
+
const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
|
|
4616
|
+
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}.` : ''} `
|
|
4444
4617
|
+ "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:";
|
|
4445
4618
|
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'}`);
|
|
4446
4619
|
const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
|
|
@@ -4457,33 +4630,37 @@ export class WorkspaceModule {
|
|
|
4457
4630
|
*/
|
|
4458
4631
|
async directEditText(mount, relativePath, path) {
|
|
4459
4632
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4460
|
-
const over = `${path} is
|
|
4633
|
+
const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
|
|
4461
4634
|
let opened;
|
|
4462
4635
|
try {
|
|
4463
4636
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4464
4637
|
if (fileStat.size > maxSize)
|
|
4465
|
-
throw new
|
|
4638
|
+
throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
4466
4639
|
});
|
|
4467
4640
|
}
|
|
4468
4641
|
catch (err) {
|
|
4469
4642
|
if (err instanceof WorkspaceReadError && err.code === 'changed')
|
|
4470
4643
|
return { changed: true };
|
|
4471
|
-
|
|
4644
|
+
if (err instanceof Refusal)
|
|
4645
|
+
return { error: err.message };
|
|
4646
|
+
if (err instanceof WorkspaceReadError)
|
|
4647
|
+
return { error: readRefusal(err, path) };
|
|
4648
|
+
throw err;
|
|
4472
4649
|
}
|
|
4473
4650
|
try {
|
|
4474
4651
|
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4475
4652
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4476
4653
|
if (more)
|
|
4477
|
-
return { error: over };
|
|
4478
|
-
if (looksBinary(bytes))
|
|
4479
|
-
return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
|
|
4654
|
+
return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4655
|
+
if (looksBinary(bytes)) {
|
|
4656
|
+
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.` };
|
|
4657
|
+
}
|
|
4480
4658
|
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
|
|
4481
4659
|
return { changed: true };
|
|
4482
4660
|
return { bytes, identity: before };
|
|
4483
4661
|
}
|
|
4484
4662
|
catch (err) {
|
|
4485
|
-
|
|
4486
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
4663
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4487
4664
|
}
|
|
4488
4665
|
finally {
|
|
4489
4666
|
await opened.handle.close();
|
|
@@ -4512,11 +4689,9 @@ export class WorkspaceModule {
|
|
|
4512
4689
|
catch (err) {
|
|
4513
4690
|
const code = errnoOf(err);
|
|
4514
4691
|
const why = code === 'ENOENT' || code === 'ENOTDIR'
|
|
4515
|
-
? `the mount root
|
|
4516
|
-
:
|
|
4517
|
-
|
|
4518
|
-
: `the mount root can't be read (${code ?? String(err)})`;
|
|
4519
|
-
return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
|
|
4692
|
+
? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
|
|
4693
|
+
: `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
|
|
4694
|
+
return { success: false, isError: true, error: didNothing('delete', why, path) };
|
|
4520
4695
|
}
|
|
4521
4696
|
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
4522
4697
|
const { rootReal } = disk;
|
|
@@ -4526,10 +4701,12 @@ export class WorkspaceModule {
|
|
|
4526
4701
|
}
|
|
4527
4702
|
catch (err) {
|
|
4528
4703
|
// The open's own words for a replaced root speak of reading.
|
|
4529
|
-
if (err instanceof
|
|
4530
|
-
return { success: false, isError: true, error:
|
|
4704
|
+
if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
|
|
4705
|
+
return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
|
|
4531
4706
|
}
|
|
4532
|
-
|
|
4707
|
+
if (err instanceof WorkspaceReadError)
|
|
4708
|
+
return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
|
|
4709
|
+
throw err;
|
|
4533
4710
|
}
|
|
4534
4711
|
// The unlink is awaited, unlike a write's rename, so where the head
|
|
4535
4712
|
// was just before it says whether the head moved while it ran.
|
|
@@ -4542,12 +4719,12 @@ export class WorkspaceModule {
|
|
|
4542
4719
|
// whether the file is gone, so it's taken as the agent's write too.
|
|
4543
4720
|
if (error instanceof EffectFailed && error.touched) {
|
|
4544
4721
|
this.recordDirectWrite(mount, agentName, since);
|
|
4545
|
-
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.` };
|
|
4722
|
+
return { success: false, isError: true, error: `The delete of ${JSON.stringify(path)} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
|
|
4546
4723
|
}
|
|
4547
|
-
return { success: false, isError: true, error:
|
|
4724
|
+
return { success: false, isError: true, error: directDidNothing('delete', path, error) };
|
|
4548
4725
|
}
|
|
4549
4726
|
this.recordDirectWrite(mount, agentName, since);
|
|
4550
|
-
return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
4727
|
+
return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
|
|
4551
4728
|
});
|
|
4552
4729
|
}
|
|
4553
4730
|
/**
|
|
@@ -4695,11 +4872,13 @@ export class WorkspaceModule {
|
|
|
4695
4872
|
this.historyLookup = lookup;
|
|
4696
4873
|
}
|
|
4697
4874
|
async handleDelete(input, callerAgentName) {
|
|
4698
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4875
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
|
|
4876
|
+
if (relativePath === '')
|
|
4877
|
+
return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4699
4878
|
input = { ...input, path };
|
|
4700
|
-
const refusal = writeRefusal(mount);
|
|
4879
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
4701
4880
|
if (refusal)
|
|
4702
|
-
return { success: false, error: refusal, isError: true };
|
|
4881
|
+
return { success: false, error: didNothing('delete', refusal), isError: true };
|
|
4703
4882
|
if (mount.direct)
|
|
4704
4883
|
return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
4705
4884
|
const store = this.getStore();
|
|
@@ -4710,31 +4889,31 @@ export class WorkspaceModule {
|
|
|
4710
4889
|
await this.settleBeforeMutation(mount, relativePath);
|
|
4711
4890
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
4712
4891
|
if (!entry) {
|
|
4713
|
-
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
|
|
4714
|
-
return { success: false, error, isError: true };
|
|
4892
|
+
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
|
|
4893
|
+
return { success: false, error: didNothing('delete', error), isError: true };
|
|
4715
4894
|
}
|
|
4716
|
-
const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
4895
|
+
const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
4717
4896
|
if (refused !== null)
|
|
4718
4897
|
return storedButUnfinished('delete', input.path, { refused });
|
|
4719
4898
|
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
4720
4899
|
// withholds the result (agent-framework #277).
|
|
4721
4900
|
return {
|
|
4722
4901
|
success: true,
|
|
4723
|
-
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
4724
|
-
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
4902
|
+
data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
|
|
4903
|
+
keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
|
|
4725
4904
|
};
|
|
4726
4905
|
});
|
|
4727
4906
|
}
|
|
4728
4907
|
catch (error) {
|
|
4729
4908
|
if (!committed)
|
|
4730
|
-
throw error;
|
|
4909
|
+
throw notStored(error);
|
|
4731
4910
|
if (error instanceof StoredThenFailed) {
|
|
4732
4911
|
// Its note, as the success answer's: a deletion that cleared a
|
|
4733
4912
|
// conflict naming a disk file unlinked nothing.
|
|
4734
4913
|
const note = error.notes.get(relativePath);
|
|
4735
|
-
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}
|
|
4914
|
+
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);
|
|
4736
4915
|
}
|
|
4737
|
-
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
|
|
4916
|
+
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
4738
4917
|
}
|
|
4739
4918
|
}
|
|
4740
4919
|
async handleLs(input) {
|
|
@@ -4758,7 +4937,7 @@ export class WorkspaceModule {
|
|
|
4758
4937
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
4759
4938
|
// is refused, as glob refuses it, and shown as given.
|
|
4760
4939
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4761
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
4940
|
+
return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4762
4941
|
}
|
|
4763
4942
|
input = { ...input, path };
|
|
4764
4943
|
const recursive = input.recursive === true;
|
|
@@ -4767,6 +4946,9 @@ export class WorkspaceModule {
|
|
|
4767
4946
|
// shows disk's.
|
|
4768
4947
|
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
4769
4948
|
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
4949
|
+
const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
|
|
4950
|
+
const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
|
|
4951
|
+
const noted = note !== null ? { note } : {};
|
|
4770
4952
|
if (recursive) {
|
|
4771
4953
|
return {
|
|
4772
4954
|
success: true,
|
|
@@ -4775,6 +4957,7 @@ export class WorkspaceModule {
|
|
|
4775
4957
|
entries: listing.files,
|
|
4776
4958
|
count: listing.files.length,
|
|
4777
4959
|
...incomplete,
|
|
4960
|
+
...noted,
|
|
4778
4961
|
},
|
|
4779
4962
|
};
|
|
4780
4963
|
}
|
|
@@ -4791,9 +4974,34 @@ export class WorkspaceModule {
|
|
|
4791
4974
|
entries: children,
|
|
4792
4975
|
count: children.length,
|
|
4793
4976
|
...incomplete,
|
|
4977
|
+
...noted,
|
|
4794
4978
|
},
|
|
4795
4979
|
};
|
|
4796
4980
|
}
|
|
4981
|
+
async emptyScopeNote(mount, relativePath, path) {
|
|
4982
|
+
const shown = JSON.stringify(path);
|
|
4983
|
+
const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
|
|
4984
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4985
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
4986
|
+
if (seen.kind === 'file')
|
|
4987
|
+
return isFile;
|
|
4988
|
+
if (seen.kind !== 'absent')
|
|
4989
|
+
return null;
|
|
4990
|
+
if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null)
|
|
4991
|
+
return isFile;
|
|
4992
|
+
return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
|
|
4993
|
+
}
|
|
4994
|
+
async absentOnDisk(mount, relativePath) {
|
|
4995
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4996
|
+
return (await observePath(view, rootReal, relativePath)).kind === 'absent';
|
|
4997
|
+
}
|
|
4998
|
+
holdsAnythingAt(mount, relativePath) {
|
|
4999
|
+
if (mount.direct)
|
|
5000
|
+
return false;
|
|
5001
|
+
const store = this.getStore();
|
|
5002
|
+
const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree) => tree !== undefined);
|
|
5003
|
+
return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
|
|
5004
|
+
}
|
|
4797
5005
|
/**
|
|
4798
5006
|
* What a listing shows of a directory scope, from where the mount keeps
|
|
4799
5007
|
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
@@ -4870,7 +5078,8 @@ export class WorkspaceModule {
|
|
|
4870
5078
|
.filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
|
|
4871
5079
|
.map((region) => ({
|
|
4872
5080
|
path: region.path,
|
|
4873
|
-
reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
|
|
5081
|
+
reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
|
|
5082
|
+
: explained(region.reason, region.errno !== undefined ? { errno: region.errno } : {}, 'list'),
|
|
4874
5083
|
}));
|
|
4875
5084
|
return {
|
|
4876
5085
|
view,
|
|
@@ -4895,7 +5104,7 @@ export class WorkspaceModule {
|
|
|
4895
5104
|
if (input.path) {
|
|
4896
5105
|
const { mount, relativePath, moved, root } = mountsToSearch[0];
|
|
4897
5106
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4898
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5107
|
+
return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4899
5108
|
}
|
|
4900
5109
|
}
|
|
4901
5110
|
// Every match lies beneath the pattern's literal directory, so the walk
|
|
@@ -4916,7 +5125,7 @@ export class WorkspaceModule {
|
|
|
4916
5125
|
if (prefixed) {
|
|
4917
5126
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
4918
5127
|
if (dir === null) {
|
|
4919
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5128
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
4920
5129
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
4921
5130
|
continue;
|
|
4922
5131
|
}
|
|
@@ -4952,8 +5161,16 @@ export class WorkspaceModule {
|
|
|
4952
5161
|
regex = new RegExp(input.pattern);
|
|
4953
5162
|
}
|
|
4954
5163
|
catch (e) {
|
|
4955
|
-
//
|
|
4956
|
-
|
|
5164
|
+
// V8's message ends with the parser's reason, after the pattern: "Invalid
|
|
5165
|
+
// regular expression: /foo(/: Unterminated group".
|
|
5166
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5167
|
+
const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
|
|
5168
|
+
return {
|
|
5169
|
+
success: false,
|
|
5170
|
+
isError: true,
|
|
5171
|
+
error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
|
|
5172
|
+
+ 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
|
|
5173
|
+
};
|
|
4957
5174
|
}
|
|
4958
5175
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
4959
5176
|
// The filter matches mount-relative paths, from the mount's root whatever
|
|
@@ -4979,7 +5196,7 @@ export class WorkspaceModule {
|
|
|
4979
5196
|
if (input.path) {
|
|
4980
5197
|
const { mount, relativePath, moved, root } = mountsToSearch[0];
|
|
4981
5198
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4982
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5199
|
+
return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4983
5200
|
}
|
|
4984
5201
|
}
|
|
4985
5202
|
const results = [];
|
|
@@ -5002,10 +5219,6 @@ export class WorkspaceModule {
|
|
|
5002
5219
|
const search = (text) => {
|
|
5003
5220
|
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5004
5221
|
const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
|
|
5005
|
-
if (lineEndings !== undefined && !crlfFiles.has(text.file))
|
|
5006
|
-
crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
|
|
5007
|
-
if (bom && !bomFiles.has(text.file))
|
|
5008
|
-
bomFiles.set(text.file, text.version);
|
|
5009
5222
|
const found = [];
|
|
5010
5223
|
for (let i = 0; i < lines.length; i++) {
|
|
5011
5224
|
if (regex.test(lines[i])) {
|
|
@@ -5018,14 +5231,14 @@ export class WorkspaceModule {
|
|
|
5018
5231
|
found.push(match);
|
|
5019
5232
|
}
|
|
5020
5233
|
}
|
|
5021
|
-
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
|
|
5234
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
|
|
5022
5235
|
};
|
|
5023
5236
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
5024
5237
|
// A path named in another mount's directory, as holderOf resolves it,
|
|
5025
5238
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
5026
5239
|
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
5027
5240
|
// found where its root lies now (movedNesting).
|
|
5028
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5241
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5029
5242
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5030
5243
|
continue;
|
|
5031
5244
|
}
|
|
@@ -5044,16 +5257,23 @@ export class WorkspaceModule {
|
|
|
5044
5257
|
}
|
|
5045
5258
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
5046
5259
|
const searched = (text) => {
|
|
5047
|
-
const hits = search(text);
|
|
5048
|
-
|
|
5049
|
-
|
|
5260
|
+
const { bom, ...hits } = search(text);
|
|
5261
|
+
const { file, state, version } = text;
|
|
5262
|
+
return () => {
|
|
5263
|
+
if (hits.lineEndings !== undefined && !crlfFiles.has(file))
|
|
5264
|
+
crlfFiles.set(file, { ends: hits.lineEndings, version });
|
|
5265
|
+
if (bom && !bomFiles.has(file))
|
|
5266
|
+
bomFiles.set(file, version);
|
|
5267
|
+
if (hits.matches.length > 0)
|
|
5268
|
+
results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
|
|
5269
|
+
};
|
|
5050
5270
|
};
|
|
5051
5271
|
const kept = prefixed
|
|
5052
5272
|
? (path) => prefixed.regex.test(path)
|
|
5053
5273
|
: (path) => fileGlob === null || fileGlob.test(path)
|
|
5054
5274
|
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
5055
5275
|
const found = mount.direct
|
|
5056
|
-
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
5276
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
|
|
5057
5277
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
5058
5278
|
skipped.push(...found.skipped);
|
|
5059
5279
|
incomplete.push(...found.incomplete);
|
|
@@ -5102,12 +5322,13 @@ export class WorkspaceModule {
|
|
|
5102
5322
|
/**
|
|
5103
5323
|
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
5104
5324
|
* single file `relativePath` names, those `keep` lets through, each handed
|
|
5105
|
-
* to `
|
|
5106
|
-
* workspace copy, labelled with its state, and a
|
|
5325
|
+
* to `search`, whose answers are recorded in path order: a pass over the
|
|
5326
|
+
* scope, then each path's workspace copy, labelled with its state, and a
|
|
5327
|
+
* conflict's disk side.
|
|
5107
5328
|
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
5108
5329
|
* `incomplete`, each mount-prefixed.
|
|
5109
5330
|
*/
|
|
5110
|
-
async textsFromStore(mount, relativePath, start, keep,
|
|
5331
|
+
async textsFromStore(mount, relativePath, start, keep, search) {
|
|
5111
5332
|
const store = this.getStore();
|
|
5112
5333
|
const skipped = [];
|
|
5113
5334
|
const incomplete = [];
|
|
@@ -5160,17 +5381,18 @@ export class WorkspaceModule {
|
|
|
5160
5381
|
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
5161
5382
|
}
|
|
5162
5383
|
const intents = this.intents.get(mount.config.name);
|
|
5384
|
+
const found = [];
|
|
5163
5385
|
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
5164
5386
|
if (!keep(report.path))
|
|
5165
5387
|
continue;
|
|
5166
5388
|
const file = `${mount.config.name}/${report.path}`;
|
|
5167
5389
|
if (report.state === 'disk-only') {
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5390
|
+
found.push({ skip: {
|
|
5391
|
+
file,
|
|
5392
|
+
reason: 'disk-only: binary or over the size limit, never stored',
|
|
5393
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
5394
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
5395
|
+
} });
|
|
5174
5396
|
continue;
|
|
5175
5397
|
}
|
|
5176
5398
|
// A path in another mount's directory is that mount's: grep gives
|
|
@@ -5180,17 +5402,16 @@ export class WorkspaceModule {
|
|
|
5180
5402
|
continue;
|
|
5181
5403
|
const refusal = refused.get(report.path);
|
|
5182
5404
|
if (refusal !== undefined) {
|
|
5183
|
-
|
|
5405
|
+
found.push({ skip: { file, reason: refusal } });
|
|
5184
5406
|
continue;
|
|
5185
5407
|
}
|
|
5186
5408
|
// The workspace's version, labelled with the path's state.
|
|
5187
5409
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
5410
|
+
if (entry) {
|
|
5411
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
5412
|
+
found.push('bytes' in stored
|
|
5413
|
+
? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
|
|
5414
|
+
: { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
|
|
5194
5415
|
}
|
|
5195
5416
|
if (report.state !== 'conflict')
|
|
5196
5417
|
continue;
|
|
@@ -5201,22 +5422,40 @@ export class WorkspaceModule {
|
|
|
5201
5422
|
// disk version was never stored either.
|
|
5202
5423
|
const disk = intents.get(report.path)?.conflict?.disk;
|
|
5203
5424
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5425
|
+
if (disk?.stored) {
|
|
5426
|
+
const version = changed ? 'recorded-disk' : 'conflicting-disk';
|
|
5427
|
+
const stored = storedBytes(store, disk.stored);
|
|
5428
|
+
found.push('bytes' in stored
|
|
5429
|
+
? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
|
|
5430
|
+
: { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
|
|
5210
5431
|
}
|
|
5211
5432
|
if (changed) {
|
|
5212
5433
|
if (report.diskNow === 'file') {
|
|
5213
|
-
|
|
5434
|
+
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' } });
|
|
5214
5435
|
}
|
|
5215
5436
|
}
|
|
5216
5437
|
else if (disk && !disk.stored) {
|
|
5217
|
-
|
|
5438
|
+
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 } });
|
|
5218
5439
|
}
|
|
5219
5440
|
}
|
|
5441
|
+
const answers = new Map();
|
|
5442
|
+
await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
|
|
5443
|
+
const { file, state, version, what } = lost;
|
|
5444
|
+
answers.set(lost, 'bytes' in contents
|
|
5445
|
+
? { searched: search({ file, state, version, bytes: contents.bytes }) }
|
|
5446
|
+
: { skip: {
|
|
5447
|
+
file,
|
|
5448
|
+
reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
|
|
5449
|
+
+ `and ${lostOnDisk(contents.lost)}, so it was not searched`,
|
|
5450
|
+
} });
|
|
5451
|
+
});
|
|
5452
|
+
for (const item of found) {
|
|
5453
|
+
const answer = 'lost' in item ? answers.get(item.lost) : item;
|
|
5454
|
+
if ('searched' in answer)
|
|
5455
|
+
answer.searched();
|
|
5456
|
+
else
|
|
5457
|
+
skipped.push(answer.skip);
|
|
5458
|
+
}
|
|
5220
5459
|
});
|
|
5221
5460
|
return { skipped, incomplete };
|
|
5222
5461
|
}
|
|
@@ -5261,7 +5500,7 @@ export class WorkspaceModule {
|
|
|
5261
5500
|
const store = this.getStore();
|
|
5262
5501
|
const status = {};
|
|
5263
5502
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
5264
|
-
return { success: false, error:
|
|
5503
|
+
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5265
5504
|
}
|
|
5266
5505
|
for (const [name, mount] of this.mounts) {
|
|
5267
5506
|
if (input.mount !== undefined && name !== input.mount)
|
|
@@ -5403,9 +5642,6 @@ export class WorkspaceModule {
|
|
|
5403
5642
|
*/
|
|
5404
5643
|
entriesByEvidence(mount) {
|
|
5405
5644
|
const store = this.getStore();
|
|
5406
|
-
const agreement = this.agreementOrThrow();
|
|
5407
|
-
const name = mount.config.name;
|
|
5408
|
-
const intents = this.intents.get(name);
|
|
5409
5645
|
const owed = [];
|
|
5410
5646
|
const unchecked = [];
|
|
5411
5647
|
let agreed = 0;
|
|
@@ -5415,20 +5651,37 @@ export class WorkspaceModule {
|
|
|
5415
5651
|
// in it: neither owed to disk nor counted as on it.
|
|
5416
5652
|
if (!insideMount(e.path, mount.config.include) || !own(e.path))
|
|
5417
5653
|
continue;
|
|
5418
|
-
const
|
|
5419
|
-
if (
|
|
5420
|
-
(intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
|
|
5421
|
-
else if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
5422
|
-
owed.push(e.path);
|
|
5423
|
-
// A mode the store records, other than the one disk last agreed with,
|
|
5424
|
-
// where modes are observed: elsewhere no push applies one.
|
|
5425
|
-
else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
|
|
5654
|
+
const kind = this.evidenceOf(mount, e);
|
|
5655
|
+
if (kind === 'owed')
|
|
5426
5656
|
owed.push(e.path);
|
|
5657
|
+
else if (kind === 'unchecked')
|
|
5658
|
+
unchecked.push(e.path);
|
|
5427
5659
|
else
|
|
5428
5660
|
agreed++;
|
|
5429
5661
|
}
|
|
5430
5662
|
return { owed, unchecked, agreed };
|
|
5431
5663
|
}
|
|
5664
|
+
evidenceOf(mount, e) {
|
|
5665
|
+
const p = this.agreementOrThrow().get(mount.config.name, e.path);
|
|
5666
|
+
if (p === undefined)
|
|
5667
|
+
return this.intents.get(mount.config.name).get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
|
|
5668
|
+
if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
5669
|
+
return 'owed';
|
|
5670
|
+
// A mode the store records, other than the one disk last agreed with,
|
|
5671
|
+
// where modes are observed: elsewhere no push applies one.
|
|
5672
|
+
if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
|
|
5673
|
+
return 'owed';
|
|
5674
|
+
return 'agreed';
|
|
5675
|
+
}
|
|
5676
|
+
diskOf(mount, relativePath) {
|
|
5677
|
+
const intent = this.intents.get(mount.config.name).get(relativePath);
|
|
5678
|
+
if (intent?.conflict)
|
|
5679
|
+
return 'in conflict with disk';
|
|
5680
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
5681
|
+
if (!entry)
|
|
5682
|
+
return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
|
|
5683
|
+
return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
|
|
5684
|
+
}
|
|
5432
5685
|
async handleMaterialize(input) {
|
|
5433
5686
|
const store = this.getStore();
|
|
5434
5687
|
const allWritten = [];
|
|
@@ -5436,24 +5689,27 @@ export class WorkspaceModule {
|
|
|
5436
5689
|
const pendingDeletions = [];
|
|
5437
5690
|
const modesKept = [];
|
|
5438
5691
|
let explicit = null;
|
|
5439
|
-
if (input.path) {
|
|
5692
|
+
if (input.path !== undefined) {
|
|
5693
|
+
if (input.path === '') {
|
|
5694
|
+
return { success: false, error: didNothing('materialize', this.emptyPathField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
|
|
5695
|
+
}
|
|
5440
5696
|
const parsed = await this.parsePath(input.path);
|
|
5441
5697
|
explicit = parsed;
|
|
5442
5698
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5443
5699
|
// is refused; beneath a directory, the push leaves out what the include
|
|
5444
5700
|
// doesn't cover.
|
|
5445
5701
|
if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
|
|
5446
|
-
return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
|
|
5702
|
+
return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
|
|
5447
5703
|
}
|
|
5448
5704
|
}
|
|
5449
5705
|
let mountsToMaterialize;
|
|
5450
5706
|
if (explicit) {
|
|
5451
5707
|
mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
|
|
5452
5708
|
}
|
|
5453
|
-
else if (input.mount) {
|
|
5709
|
+
else if (input.mount !== undefined) {
|
|
5454
5710
|
const m = this.mounts.get(input.mount);
|
|
5455
5711
|
if (!m) {
|
|
5456
|
-
return { success: false, error:
|
|
5712
|
+
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 };
|
|
5457
5713
|
}
|
|
5458
5714
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
5459
5715
|
}
|
|
@@ -5567,15 +5823,20 @@ export class WorkspaceModule {
|
|
|
5567
5823
|
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
5568
5824
|
return {
|
|
5569
5825
|
success: false,
|
|
5570
|
-
error:
|
|
5826
|
+
error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
|
|
5571
5827
|
isError: true,
|
|
5572
5828
|
};
|
|
5573
5829
|
}
|
|
5830
|
+
const held = explicit;
|
|
5831
|
+
const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
|
|
5832
|
+
&& allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
|
|
5833
|
+
&& !this.holdsAnythingAt(held.mount, held.relativePath);
|
|
5574
5834
|
return {
|
|
5575
5835
|
success: true,
|
|
5576
5836
|
data: {
|
|
5577
5837
|
materialized: allWritten,
|
|
5578
5838
|
count: allWritten.length,
|
|
5839
|
+
...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
|
|
5579
5840
|
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
5580
5841
|
...(pendingDeletions.length > 0
|
|
5581
5842
|
? {
|
|
@@ -5637,24 +5898,32 @@ export class WorkspaceModule {
|
|
|
5637
5898
|
const allIgnored = [];
|
|
5638
5899
|
const rootsAccepted = [];
|
|
5639
5900
|
if (input.acceptRoot && input.path) {
|
|
5640
|
-
|
|
5901
|
+
const named = input.path.split('/')[0];
|
|
5902
|
+
return {
|
|
5903
|
+
success: false,
|
|
5904
|
+
isError: true,
|
|
5905
|
+
error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
|
|
5906
|
+
+ `${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.`),
|
|
5907
|
+
};
|
|
5641
5908
|
}
|
|
5642
5909
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
5643
5910
|
let targets;
|
|
5644
|
-
if (input.path) {
|
|
5911
|
+
if (input.path !== undefined) {
|
|
5912
|
+
if (input.path === '')
|
|
5913
|
+
return { success: false, error: didNothing('sync', this.emptyPathField(input.mount, 'covers every mount')), isError: true };
|
|
5645
5914
|
const { mount, relativePath, moved, root } = await this.parsePath(input.path);
|
|
5646
5915
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5647
5916
|
// is refused; beneath a directory, the walk passes over what the include
|
|
5648
5917
|
// doesn't cover.
|
|
5649
5918
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5650
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5919
|
+
return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5651
5920
|
}
|
|
5652
5921
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
5653
5922
|
}
|
|
5654
|
-
else if (input.mount) {
|
|
5923
|
+
else if (input.mount !== undefined) {
|
|
5655
5924
|
const m = this.mounts.get(input.mount);
|
|
5656
5925
|
if (!m) {
|
|
5657
|
-
return { success: false, error:
|
|
5926
|
+
return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5658
5927
|
}
|
|
5659
5928
|
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
5660
5929
|
}
|
|
@@ -5668,6 +5937,7 @@ export class WorkspaceModule {
|
|
|
5668
5937
|
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
5669
5938
|
continue;
|
|
5670
5939
|
}
|
|
5940
|
+
let namedDirectory = false;
|
|
5671
5941
|
const pass = await this.withMount(mount, async () => {
|
|
5672
5942
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount))
|
|
5673
5943
|
rootsAccepted.push(name);
|
|
@@ -5682,6 +5952,7 @@ export class WorkspaceModule {
|
|
|
5682
5952
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
5683
5953
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
5684
5954
|
this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
5955
|
+
namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
|
|
5685
5956
|
// named: ignore patterns covering the path asked for don't apply
|
|
5686
5957
|
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
5687
5958
|
const opts = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
@@ -5730,7 +6001,7 @@ export class WorkspaceModule {
|
|
|
5730
6001
|
// refused" don't look identical from the outside.
|
|
5731
6002
|
for (const r of pass.reports.values()) {
|
|
5732
6003
|
if (r.state === 'unverified') {
|
|
5733
|
-
allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
|
|
6004
|
+
allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${explained(r.note ?? 'not observed', r.noteCause ?? {}, 'sync')}` });
|
|
5734
6005
|
}
|
|
5735
6006
|
else if (r.state === 'disk-only') {
|
|
5736
6007
|
allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
|
|
@@ -5745,7 +6016,7 @@ export class WorkspaceModule {
|
|
|
5745
6016
|
// without a path passes over it, so nothing else keeps it current
|
|
5746
6017
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
5747
6018
|
// synced at all, and the note says so.
|
|
5748
|
-
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
6019
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
|
|
5749
6020
|
if (passedOver !== undefined) {
|
|
5750
6021
|
allIgnored.push({
|
|
5751
6022
|
mount: name,
|
|
@@ -5758,10 +6029,17 @@ export class WorkspaceModule {
|
|
|
5758
6029
|
});
|
|
5759
6030
|
}
|
|
5760
6031
|
}
|
|
6032
|
+
const named = input.path !== undefined && input.path !== '' ? targets[0] : null;
|
|
6033
|
+
const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
|
|
6034
|
+
&& allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
|
|
6035
|
+
&& allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
|
|
6036
|
+
&& !this.holdsAnythingAt(named.mount, named.relativePath)
|
|
6037
|
+
&& await this.absentOnDisk(named.mount, named.relativePath);
|
|
5761
6038
|
return {
|
|
5762
6039
|
success: true,
|
|
5763
6040
|
data: {
|
|
5764
6041
|
results: allResults,
|
|
6042
|
+
...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
|
|
5765
6043
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
5766
6044
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
5767
6045
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
@@ -6007,13 +6285,13 @@ export async function inOrder(items, width, work, each) {
|
|
|
6007
6285
|
}
|
|
6008
6286
|
}
|
|
6009
6287
|
/** read_image's answer for an image, wherever it was read from. */
|
|
6010
|
-
function imageResult(path, image) {
|
|
6288
|
+
function imageResult(path, image, note) {
|
|
6011
6289
|
return {
|
|
6012
6290
|
success: true,
|
|
6013
6291
|
data: [
|
|
6014
6292
|
{
|
|
6015
6293
|
type: 'text',
|
|
6016
|
-
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
6294
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
|
|
6017
6295
|
},
|
|
6018
6296
|
{
|
|
6019
6297
|
type: 'image',
|
|
@@ -6067,14 +6345,16 @@ function watchOf(config) {
|
|
|
6067
6345
|
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6068
6346
|
* the next start once none is held runs it direct.
|
|
6069
6347
|
*/
|
|
6070
|
-
function writeRefusal(mount) {
|
|
6348
|
+
function writeRefusal(mount, writable = []) {
|
|
6071
6349
|
const name = mount.config.name;
|
|
6072
|
-
if (mount.config.mode === 'read-only')
|
|
6073
|
-
return `
|
|
6350
|
+
if (mount.config.mode === 'read-only') {
|
|
6351
|
+
return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
|
|
6352
|
+
+ (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
|
|
6353
|
+
}
|
|
6074
6354
|
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6075
|
-
return `
|
|
6355
|
+
return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6076
6356
|
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
6077
|
-
+ `(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
|
|
6357
|
+
+ `(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.`;
|
|
6078
6358
|
}
|
|
6079
6359
|
return null;
|
|
6080
6360
|
}
|
|
@@ -6150,6 +6430,23 @@ function directReason(error) {
|
|
|
6150
6430
|
return ROOT_MOVED;
|
|
6151
6431
|
return error instanceof Error ? error.message : String(error);
|
|
6152
6432
|
}
|
|
6433
|
+
function directWayOn(error) {
|
|
6434
|
+
if (error instanceof FileChanged)
|
|
6435
|
+
return 'Calling again reads it afresh.';
|
|
6436
|
+
if (error instanceof WriteRefused)
|
|
6437
|
+
return error.wayOn;
|
|
6438
|
+
if (error instanceof EffectFailed) {
|
|
6439
|
+
if (error.detail.errno !== undefined)
|
|
6440
|
+
return errnoWayOn(error.detail.errno);
|
|
6441
|
+
if (error.detail.again === true)
|
|
6442
|
+
return 'Calling again checks it afresh.';
|
|
6443
|
+
}
|
|
6444
|
+
return null;
|
|
6445
|
+
}
|
|
6446
|
+
function directDidNothing(tool, path, error) {
|
|
6447
|
+
const on = directWayOn(error);
|
|
6448
|
+
return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
|
|
6449
|
+
}
|
|
6153
6450
|
/** "a", "a and b", or "a, b and c". */
|
|
6154
6451
|
function listed(items) {
|
|
6155
6452
|
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
@@ -6172,7 +6469,114 @@ function linksNote(path, staged, tool) {
|
|
|
6172
6469
|
*/
|
|
6173
6470
|
function containedRefusal(err, path) {
|
|
6174
6471
|
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
6175
|
-
return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
|
|
6472
|
+
return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
|
|
6473
|
+
}
|
|
6474
|
+
function deletedOnDisk(path) {
|
|
6475
|
+
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.`;
|
|
6476
|
+
}
|
|
6477
|
+
function uncheckedFile(shown, note, cause) {
|
|
6478
|
+
if (note === DIRECTORY)
|
|
6479
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6480
|
+
const on = note === ROOT_REPLACED || cause.ownStep === true ? null
|
|
6481
|
+
: (note === undefined ? null : wayOn(note, cause, 'read')) ?? 'calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk';
|
|
6482
|
+
return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on[0].toUpperCase()}${sentence(on.slice(1))}` : ''}`;
|
|
6483
|
+
}
|
|
6484
|
+
function namesRoot(path, mount) {
|
|
6485
|
+
return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
|
|
6486
|
+
}
|
|
6487
|
+
function tooLargeToWrite(path, bytes, maxSize) {
|
|
6488
|
+
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.');
|
|
6489
|
+
}
|
|
6490
|
+
function pastSizeLimit(what, maxSize, outcome, within) {
|
|
6491
|
+
return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
|
|
6492
|
+
+ "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
|
|
6493
|
+
}
|
|
6494
|
+
function sentence(text) {
|
|
6495
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
6496
|
+
}
|
|
6497
|
+
function unreadable(shown, errno) {
|
|
6498
|
+
return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
|
|
6499
|
+
}
|
|
6500
|
+
function readRefusal(err, path) {
|
|
6501
|
+
const shown = JSON.stringify(path);
|
|
6502
|
+
if (err instanceof RootUnavailable) {
|
|
6503
|
+
if (err.refusal)
|
|
6504
|
+
return err.refusal.message;
|
|
6505
|
+
const mount = JSON.stringify(err.mount);
|
|
6506
|
+
if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
|
|
6507
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
|
|
6508
|
+
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")}`;
|
|
6509
|
+
}
|
|
6510
|
+
if (err.errno !== undefined) {
|
|
6511
|
+
return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
|
|
6512
|
+
+ errnoWayOn(err.errno, ROOT_RECHECK, true);
|
|
6513
|
+
}
|
|
6514
|
+
return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
|
|
6515
|
+
}
|
|
6516
|
+
return `${shown} can't be read: ${sentence(err.reason)}`;
|
|
6517
|
+
}
|
|
6518
|
+
if (err instanceof LinkOnTheWay)
|
|
6519
|
+
return `${shown} can't be reached: ${sentence(err.reason)}`;
|
|
6520
|
+
switch (err.code) {
|
|
6521
|
+
case 'directory':
|
|
6522
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6523
|
+
case 'symlink':
|
|
6524
|
+
return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
|
|
6525
|
+
+ `Calling again won't change that; ${UNFOLLOWED_STEP}.`;
|
|
6526
|
+
case 'escape':
|
|
6527
|
+
return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
|
|
6528
|
+
+ `Calling again won't change that; ${MOUNT_IT}.`;
|
|
6529
|
+
case 'changed':
|
|
6530
|
+
return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
|
|
6531
|
+
case 'not_found':
|
|
6532
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR')
|
|
6533
|
+
return `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
6534
|
+
if (err instanceof NotRegularFile) {
|
|
6535
|
+
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')}`;
|
|
6536
|
+
}
|
|
6537
|
+
if (err.stage === 'realpath')
|
|
6538
|
+
return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
|
|
6539
|
+
return unreadable(shown, err.errno);
|
|
6540
|
+
default:
|
|
6541
|
+
return sentence(err.message);
|
|
6542
|
+
}
|
|
6543
|
+
}
|
|
6544
|
+
function contentsSubject(what, path) {
|
|
6545
|
+
return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
|
|
6546
|
+
}
|
|
6547
|
+
function inTheStore(stored) {
|
|
6548
|
+
return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
|
|
6549
|
+
}
|
|
6550
|
+
function lostInStore(lost) {
|
|
6551
|
+
return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
|
|
6552
|
+
}
|
|
6553
|
+
function lostOnDisk(lost) {
|
|
6554
|
+
return lost.disk === 'other' ? "disk's copy holds other contents"
|
|
6555
|
+
: lost.disk === 'none' ? 'disk has no copy'
|
|
6556
|
+
: `disk's copy can't be read (${lost.disk.unreadable})`;
|
|
6557
|
+
}
|
|
6558
|
+
function lostText(lost, the = 'The') {
|
|
6559
|
+
return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
|
|
6560
|
+
}
|
|
6561
|
+
function nothingToRead(lost) {
|
|
6562
|
+
return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
|
|
6563
|
+
}
|
|
6564
|
+
function lostRemedy(lost) {
|
|
6565
|
+
const after = remedyOfLost(lost);
|
|
6566
|
+
if (!lost.reopen)
|
|
6567
|
+
return after;
|
|
6568
|
+
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. "
|
|
6569
|
+
+ `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
|
|
6570
|
+
}
|
|
6571
|
+
function remedyOfLost(lost) {
|
|
6572
|
+
const rewrite = lost.writable && 'missing' in lost.store;
|
|
6573
|
+
if (lost.disk === 'other')
|
|
6574
|
+
return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
|
|
6575
|
+
if (lost.writable)
|
|
6576
|
+
return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
|
|
6577
|
+
return lost.disk === 'none'
|
|
6578
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy."
|
|
6579
|
+
: "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.";
|
|
6176
6580
|
}
|
|
6177
6581
|
/**
|
|
6178
6582
|
* The notice that writes an agent made direct remain on disk from
|
|
@@ -6183,7 +6587,7 @@ function containedRefusal(err, path) {
|
|
|
6183
6587
|
*/
|
|
6184
6588
|
function headMoveText(mounts) {
|
|
6185
6589
|
const one = mounts.length === 1;
|
|
6186
|
-
return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6590
|
+
return `[${NOTICE_TAG.workspace}] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6187
6591
|
+ `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
|
|
6188
6592
|
+ 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
|
|
6189
6593
|
}
|
|
@@ -6229,9 +6633,7 @@ function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems)
|
|
|
6229
6633
|
kind: 'file',
|
|
6230
6634
|
edits: target.edits,
|
|
6231
6635
|
path: target.path,
|
|
6232
|
-
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)},
|
|
6233
|
-
+ `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
|
|
6234
|
-
+ "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.",
|
|
6636
|
+
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.'),
|
|
6235
6637
|
whole: false,
|
|
6236
6638
|
});
|
|
6237
6639
|
return null;
|
|
@@ -6267,16 +6669,38 @@ function changesLanded(store, treeStateId, changes, setError) {
|
|
|
6267
6669
|
* change is stored isn't known. Its message says so after both errors.
|
|
6268
6670
|
*/
|
|
6269
6671
|
class CommitUnknownError extends Error {
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
constructor(
|
|
6672
|
+
setCause;
|
|
6673
|
+
readCause;
|
|
6674
|
+
constructor(setCause, readCause) {
|
|
6273
6675
|
const text = (error) => (error instanceof Error ? error.message : String(error));
|
|
6274
|
-
super(`${text(
|
|
6676
|
+
super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
|
|
6677
|
+
this.setCause = setCause;
|
|
6678
|
+
this.readCause = readCause;
|
|
6275
6679
|
this.name = 'CommitUnknownError';
|
|
6276
|
-
this.setError = text(setError);
|
|
6277
|
-
this.readError = text(readError);
|
|
6278
6680
|
}
|
|
6279
6681
|
}
|
|
6682
|
+
function storeWords(error) {
|
|
6683
|
+
const code = errnoCode(error);
|
|
6684
|
+
return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
|
|
6685
|
+
}
|
|
6686
|
+
class NotStored extends Error {
|
|
6687
|
+
thrown;
|
|
6688
|
+
constructor(thrown) {
|
|
6689
|
+
super('stopped before the change was stored');
|
|
6690
|
+
this.thrown = thrown;
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
class AlreadyHeld extends Error {
|
|
6694
|
+
thrown;
|
|
6695
|
+
constructor(thrown) {
|
|
6696
|
+
super(thrown instanceof Error ? thrown.message : String(thrown));
|
|
6697
|
+
this.thrown = thrown;
|
|
6698
|
+
}
|
|
6699
|
+
}
|
|
6700
|
+
function notStored(error) {
|
|
6701
|
+
return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
|
|
6702
|
+
? error : new NotStored(error);
|
|
6703
|
+
}
|
|
6280
6704
|
/**
|
|
6281
6705
|
* A store's set that threw after recording the change (changesLanded): the
|
|
6282
6706
|
* entries are set and their intents follow, and the store's error goes on in
|
|
@@ -6293,6 +6717,11 @@ class StoredThenFailed extends Error {
|
|
|
6293
6717
|
this.name = 'StoredThenFailed';
|
|
6294
6718
|
}
|
|
6295
6719
|
}
|
|
6720
|
+
const ONCE_REOPENED = ', once the store is reopened';
|
|
6721
|
+
const DISK_ORDER = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
|
|
6722
|
+
function furthestFromDisk(values) {
|
|
6723
|
+
return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
|
|
6724
|
+
}
|
|
6296
6725
|
/**
|
|
6297
6726
|
* The answer to a write, edit or delete that the workspace stored but
|
|
6298
6727
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -6304,7 +6733,7 @@ class StoredThenFailed extends Error {
|
|
|
6304
6733
|
* script. A note comes last: an edit's on which copy it applied to, or a
|
|
6305
6734
|
* deletion's that it unlinked nothing.
|
|
6306
6735
|
*/
|
|
6307
|
-
function storedButUnfinished(tool, path, outcome, note = null) {
|
|
6736
|
+
function storedButUnfinished(tool, path, outcome, note = null, reopen = false) {
|
|
6308
6737
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
6309
6738
|
const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
|
|
6310
6739
|
+ (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
|
|
@@ -6313,10 +6742,10 @@ function storedButUnfinished(tool, path, outcome, note = null) {
|
|
|
6313
6742
|
: 'storeFailed' in outcome
|
|
6314
6743
|
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
6315
6744
|
: tool === 'delete'
|
|
6316
|
-
?
|
|
6317
|
-
:
|
|
6318
|
-
: ` The rest of the call failed: ${outcome.threw}
|
|
6319
|
-
return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
|
|
6745
|
+
? ` 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 : ''}.`
|
|
6746
|
+
: ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
|
|
6747
|
+
: ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
|
|
6748
|
+
return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
|
|
6320
6749
|
}
|
|
6321
6750
|
/**
|
|
6322
6751
|
* What a read says about the copy it shows, when that isn't simply the one the
|
|
@@ -6345,18 +6774,22 @@ function whichCopyRead(report, tookChange, readOnly) {
|
|
|
6345
6774
|
// can hold it (not binary or too large). A read-only mount can hold a
|
|
6346
6775
|
// copy disk doesn't, such as one a branch switch selected, but a
|
|
6347
6776
|
// materialize, forced or not, writes nothing to it.
|
|
6348
|
-
const
|
|
6349
|
-
|
|
6350
|
-
: 'Sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6351
|
-
const force = readOnly
|
|
6352
|
-
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6353
|
-
: report.diskNow === 'absent'
|
|
6354
|
-
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6355
|
-
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6356
|
-
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
|
|
6777
|
+
const settle = settleConflict(report.diskNow, readOnly);
|
|
6778
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0].toUpperCase()}${settle.slice(1)}`];
|
|
6357
6779
|
}
|
|
6358
6780
|
return [];
|
|
6359
6781
|
}
|
|
6782
|
+
function settleConflict(diskNow, readOnly) {
|
|
6783
|
+
const sync = diskNow === 'absent'
|
|
6784
|
+
? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
6785
|
+
: 'sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6786
|
+
const force = readOnly
|
|
6787
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6788
|
+
: diskNow === 'absent'
|
|
6789
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6790
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6791
|
+
return `${sync}${force}`;
|
|
6792
|
+
}
|
|
6360
6793
|
/**
|
|
6361
6794
|
* The least each tool's whole-number fields may be, beside their schemas,
|
|
6362
6795
|
* whose type (membrane's ToolParameter) has no `minimum`.
|
|
@@ -6548,13 +6981,13 @@ function globToRegex(pattern) {
|
|
|
6548
6981
|
closeIdx = j;
|
|
6549
6982
|
}
|
|
6550
6983
|
if (closeIdx < 0)
|
|
6551
|
-
throw new
|
|
6984
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
|
|
6552
6985
|
const group = pattern.slice(i + 1, closeIdx);
|
|
6553
6986
|
if (group.includes('{')) {
|
|
6554
|
-
throw new
|
|
6987
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
|
|
6555
6988
|
}
|
|
6556
6989
|
if (!group.includes(',')) {
|
|
6557
|
-
throw new
|
|
6990
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
|
|
6558
6991
|
}
|
|
6559
6992
|
// A `/` right after the group goes into each alternative, as `{a,b}/x`
|
|
6560
6993
|
// and `{a/,b/}x` name the same paths, so an alternative's own `**`
|