@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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { NOTICE_TAG } from '../../notice-tags.js';
|
|
8
9
|
import { constants as fsConstants } from 'node:fs';
|
|
9
10
|
import type { BigIntStats, Stats } from 'node:fs';
|
|
10
11
|
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
@@ -37,17 +38,17 @@ import type {
|
|
|
37
38
|
} from './types.js';
|
|
38
39
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
39
40
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
40
|
-
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
41
|
-
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
41
|
+
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
|
|
42
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
42
43
|
import { type BranchIntent, BranchIntents } from './branch-intent.js';
|
|
43
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE,
|
|
44
|
+
import { CHANGED, type Cause, 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, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
|
|
44
45
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
45
46
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
46
47
|
import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
47
48
|
import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
|
|
48
49
|
import { type FileText, fileText } from './text.js';
|
|
49
50
|
import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
|
|
50
|
-
import { type GrepAnswer, grepText, textAnswer } from './result-text.js';
|
|
51
|
+
import { type DiskState, type GrepAnswer, grepText, textAnswer } from './result-text.js';
|
|
51
52
|
import {
|
|
52
53
|
type MountRuntime,
|
|
53
54
|
type PassOptions,
|
|
@@ -60,11 +61,16 @@ import {
|
|
|
60
61
|
NOT_PUSHED_IN_OTHER_MOUNT,
|
|
61
62
|
WALK_CAP,
|
|
62
63
|
entryMode,
|
|
64
|
+
explained,
|
|
63
65
|
pushPaths,
|
|
64
66
|
reconcilePass,
|
|
65
67
|
settleAdoptionBeforeMutation,
|
|
68
|
+
wayOn,
|
|
66
69
|
} from './reconcile.js';
|
|
67
70
|
import { EffectFailed, unlinkContained } from './effects.js';
|
|
71
|
+
import { AWAITS_REOPEN, MOUNT_IT, ROOT_RECHECK, Refusal, UNFOLLOWED_STEP, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
|
|
72
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
73
|
+
import { closestNames } from '../../closest-names.js';
|
|
68
74
|
import { RecordJournal } from '../../record-journal.js';
|
|
69
75
|
import {
|
|
70
76
|
type FileIdentity,
|
|
@@ -100,6 +106,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
|
100
106
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
101
107
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
102
108
|
const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
109
|
+
const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
|
|
110
|
+
+ 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
|
|
111
|
+
+ 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
|
|
103
112
|
/**
|
|
104
113
|
* How many files a direct mount's grep reads, and its listings look at, at
|
|
105
114
|
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
@@ -134,30 +143,23 @@ export type {
|
|
|
134
143
|
|
|
135
144
|
type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
136
145
|
|
|
137
|
-
class WorkspaceImageReadError extends
|
|
138
|
-
constructor(
|
|
139
|
-
|
|
140
|
-
| 'mount_unavailable'
|
|
141
|
-
| 'not_found'
|
|
142
|
-
| 'directory'
|
|
143
|
-
| 'symlink'
|
|
144
|
-
| 'parent_symlink'
|
|
145
|
-
| 'other_mount'
|
|
146
|
-
| 'escape'
|
|
147
|
-
| 'changed'
|
|
148
|
-
| 'empty'
|
|
149
|
-
| 'too_large'
|
|
150
|
-
| 'truncated'
|
|
151
|
-
| 'invalid'
|
|
152
|
-
| 'unsupported'
|
|
153
|
-
| 'blob_missing',
|
|
154
|
-
message: string,
|
|
155
|
-
) {
|
|
156
|
-
super(message);
|
|
146
|
+
class WorkspaceImageReadError extends Refusal {
|
|
147
|
+
constructor(readonly reason: string, wayOn: string | null) {
|
|
148
|
+
super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
|
|
157
149
|
this.name = 'WorkspaceImageReadError';
|
|
158
150
|
}
|
|
159
151
|
}
|
|
160
152
|
|
|
153
|
+
const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
|
|
154
|
+
|
|
155
|
+
function truncatedImage(mountPrefixedPath: string): WorkspaceImageReadError {
|
|
156
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function emptyImage(mountPrefixedPath: string): WorkspaceImageReadError {
|
|
160
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
|
|
161
|
+
}
|
|
162
|
+
|
|
161
163
|
/**
|
|
162
164
|
* Why a workspace-owned filesystem read was refused. Distinguishes the cases a
|
|
163
165
|
* peer needs to react to differently: a path that never resolved inside the
|
|
@@ -175,10 +177,12 @@ class WorkspaceImageReadError extends Error {
|
|
|
175
177
|
* directories leading out of it (`escape`), a file swapped underneath the read
|
|
176
178
|
* (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
|
|
177
179
|
* and a path in another mount's directory nested in the one named
|
|
178
|
-
* (`other_mount`), whose message names the mount that holds it
|
|
179
|
-
* to use there. A symlink leading into such a directory is
|
|
180
|
-
* only where its refusal names that mount: a directory, or a
|
|
181
|
-
* mount's include covers
|
|
180
|
+
* (`other_mount`), whose message names the mount that holds it, or may hold
|
|
181
|
+
* it, and the path to use there. A symlink leading into such a directory is
|
|
182
|
+
* `other_mount` only where its refusal names that mount: a directory, or a
|
|
183
|
+
* file that mount's include covers, and so does the include of each other
|
|
184
|
+
* nested mount whose directory may hold it, out to the first that surely
|
|
185
|
+
* does. Anywhere else, on a mount that doesn't follow
|
|
182
186
|
* symlinks, it gets the code of a link where it stands, `symlink` at the
|
|
183
187
|
* path itself and `parent_symlink` on its way, and on one that does,
|
|
184
188
|
* `escape`, as a path that leaves the mount. On a mount with an include, a
|
|
@@ -220,7 +224,7 @@ export type WorkspaceReadStage =
|
|
|
220
224
|
| 'read';
|
|
221
225
|
|
|
222
226
|
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
223
|
-
class OutsideIncludeError extends
|
|
227
|
+
class OutsideIncludeError extends Refusal {
|
|
224
228
|
constructor(shownPath: string, mount: MountConfig) {
|
|
225
229
|
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
226
230
|
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
@@ -232,9 +236,16 @@ class OutsideIncludeError extends Error {
|
|
|
232
236
|
* mount it names. The message names the mount that holds it and the path to
|
|
233
237
|
* use there.
|
|
234
238
|
*/
|
|
235
|
-
class NestedMountError extends
|
|
236
|
-
constructor(path: string, mount: string, holder: string
|
|
237
|
-
|
|
239
|
+
class NestedMountError extends Refusal {
|
|
240
|
+
constructor(path: string, mount: string, holder: { name: string; use: string; guess?: NestedGuess }, how: 'lies in' | 'leads into' = 'lies in') {
|
|
241
|
+
const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
|
|
242
|
+
super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
class PathRefusal extends Refusal {
|
|
247
|
+
constructor(readonly code: 'unknown_mount' | 'traversal', message: string) {
|
|
248
|
+
super(message);
|
|
238
249
|
}
|
|
239
250
|
}
|
|
240
251
|
|
|
@@ -256,6 +267,24 @@ export class WorkspaceReadError extends Error {
|
|
|
256
267
|
}
|
|
257
268
|
}
|
|
258
269
|
|
|
270
|
+
class NotRegularFile extends WorkspaceReadError {
|
|
271
|
+
constructor(path: string, stage: WorkspaceReadStage) {
|
|
272
|
+
super('not_found', `Not a regular file: ${path}`, stage);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
class LinkOnTheWay extends WorkspaceReadError {
|
|
277
|
+
constructor(code: WorkspaceReadErrorCode, message: string, readonly reason: string, errno?: string) {
|
|
278
|
+
super(code, message, 'readlink', errno);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
class RootUnavailable extends WorkspaceReadError {
|
|
283
|
+
constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
|
|
284
|
+
super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
259
288
|
/** Successful `readFileFromDisk()` result. */
|
|
260
289
|
export interface WorkspaceDiskReadResult {
|
|
261
290
|
/**
|
|
@@ -296,10 +325,10 @@ export interface ReadFileFromDiskOptions {
|
|
|
296
325
|
* Why a pass decided nothing, or null where it decided its paths. A pass gives
|
|
297
326
|
* up deciding, and reports no path, when the selected branch kept changing
|
|
298
327
|
* while it gathered, or when a sync's branch was no longer selected; it then
|
|
299
|
-
* carries no branchId, and
|
|
328
|
+
* carries no branchId, and says why in `undecided`.
|
|
300
329
|
*/
|
|
301
330
|
function undecidedOf(pass: PassResult): string | null {
|
|
302
|
-
return pass.branchId === undefined ? (pass.
|
|
331
|
+
return pass.branchId === undefined ? (pass.undecided ?? 'the pass decided nothing') : null;
|
|
303
332
|
}
|
|
304
333
|
|
|
305
334
|
function errnoOf(err: unknown): string | undefined {
|
|
@@ -308,23 +337,10 @@ function errnoOf(err: unknown): string | undefined {
|
|
|
308
337
|
: undefined;
|
|
309
338
|
}
|
|
310
339
|
|
|
311
|
-
/**
|
|
312
|
-
* What is at a mount root's path now, followed as the root is: for a
|
|
313
|
-
* directory, undefined; for anything else, ENOTDIR; and where the path
|
|
314
|
-
* can't be followed to anything, the errno its stat failed with, ENOENT
|
|
315
|
-
* when nothing is there.
|
|
316
|
-
*/
|
|
317
|
-
async function rootAbsence(root: string): Promise<string | undefined> {
|
|
318
|
-
try {
|
|
319
|
-
return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
|
|
320
|
-
} catch (err) {
|
|
321
|
-
return errnoOf(err);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
340
|
|
|
325
341
|
/** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
|
|
326
|
-
function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
|
|
327
|
-
return new WorkspaceImageReadError(
|
|
342
|
+
function imageTooLarge(maxSize: number, mountPrefixedPath: string, size: string): WorkspaceImageReadError {
|
|
343
|
+
return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
|
|
328
344
|
}
|
|
329
345
|
|
|
330
346
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -342,10 +358,6 @@ const JPEG_EOI = 0xd9;
|
|
|
342
358
|
const JPEG_SOS = 0xda;
|
|
343
359
|
const JPEG_TEM = 0x01;
|
|
344
360
|
|
|
345
|
-
function isErrnoCode(err: unknown, code: string): boolean {
|
|
346
|
-
return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === code;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
361
|
function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
|
|
350
362
|
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
351
363
|
}
|
|
@@ -382,15 +394,16 @@ async function linkReadError(
|
|
|
382
394
|
rootReal: string,
|
|
383
395
|
): Promise<WorkspaceReadError> {
|
|
384
396
|
if (!('link' in found)) {
|
|
385
|
-
|
|
397
|
+
const reason = linkWords(mount.config.name, found, null);
|
|
398
|
+
return new LinkOnTheWay('not_found', `Unable to read file: ${mountPrefixedPath}: ${reason}`, reason, found.code);
|
|
386
399
|
}
|
|
387
400
|
const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
|
|
388
|
-
const reason =
|
|
401
|
+
const reason = linkWords(mount.config.name, found, leads, nested, mount.config.include);
|
|
389
402
|
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
390
403
|
const code = leads?.kind === 'outside' ? 'escape'
|
|
391
|
-
: leads?.kind === 'inside' && holder && namesHolder(leads,
|
|
404
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
|
|
392
405
|
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
393
|
-
return new
|
|
406
|
+
return new LinkOnTheWay(code, `${mountPrefixedPath}: ${reason}`, reason);
|
|
394
407
|
}
|
|
395
408
|
|
|
396
409
|
/**
|
|
@@ -420,32 +433,30 @@ function ancestorsOf(path: string): string[] {
|
|
|
420
433
|
* directory as configured beneath the outer mount's configured path
|
|
421
434
|
* (`outerPath`), beneath each directory above its configured path that is
|
|
422
435
|
* the outer root on disk (`above`, each with where it lies on disk), and
|
|
423
|
-
* where its real root (`real`) lies beneath the outer root (`outerReal`)
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
* whole. Undefined where it's nested nowhere.
|
|
436
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`).
|
|
437
|
+
* Its real root is claimed where it doesn't hold the outer root, which it
|
|
438
|
+
* would otherwise claim whole. Undefined where it's nested nowhere.
|
|
427
439
|
*/
|
|
428
|
-
|
|
440
|
+
function placementOf(
|
|
429
441
|
outerPath: string,
|
|
430
442
|
outerReal: string,
|
|
431
443
|
innerPath: string,
|
|
432
444
|
real: string,
|
|
433
445
|
above: ReadonlyArray<{ dir: string; real: string }>,
|
|
434
|
-
|
|
435
|
-
): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
|
|
446
|
+
): { configured: string | undefined; fromRoots: NestedMountEntry['fromRoots']; claims: boolean } | undefined {
|
|
436
447
|
const configured = nestedDir(outerPath, innerPath);
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return { configured, fromRoots,
|
|
448
|
+
const dirs = new Map<string, boolean>();
|
|
449
|
+
const place = (dir: string | undefined, sure: boolean) => { if (dir !== undefined) dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
|
|
450
|
+
place(configured, true);
|
|
451
|
+
for (const a of above) {
|
|
452
|
+
if (sameRoot(a.real, outerReal)) place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
|
|
453
|
+
}
|
|
454
|
+
const below = beneathRoot(real, outerReal);
|
|
455
|
+
place(below, below !== undefined && surelyAtRoot(real, outerReal));
|
|
456
|
+
if (dirs.size === 0) return undefined;
|
|
457
|
+
const fromRoots: NestedMountEntry['fromRoots'] = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true as const }) }));
|
|
458
|
+
const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
|
|
459
|
+
return { configured, fromRoots, claims };
|
|
449
460
|
}
|
|
450
461
|
|
|
451
462
|
/** A mount nested in another, as the outer mount's otherMounts holds it. */
|
|
@@ -458,9 +469,7 @@ interface NestedMountEntry {
|
|
|
458
469
|
*/
|
|
459
470
|
real?: string;
|
|
460
471
|
/** Its directory as configured and as the real roots place it. */
|
|
461
|
-
fromRoots: Array<{ dir: string;
|
|
462
|
-
/** Those, and each as disk spells it where that differs. */
|
|
463
|
-
spellings: Array<{ dir: string; root: string }>;
|
|
472
|
+
fromRoots: Array<{ dir: string; guessed?: true }>;
|
|
464
473
|
}
|
|
465
474
|
|
|
466
475
|
/**
|
|
@@ -539,7 +548,7 @@ function isContainedPath(root: string, candidate: string): boolean {
|
|
|
539
548
|
}
|
|
540
549
|
|
|
541
550
|
function invalidImage(format: string, mountPrefixedPath: string): never {
|
|
542
|
-
throw new WorkspaceImageReadError(
|
|
551
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
|
|
543
552
|
}
|
|
544
553
|
|
|
545
554
|
function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
|
|
@@ -915,7 +924,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
915
924
|
return 'image/png';
|
|
916
925
|
}
|
|
917
926
|
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
918
|
-
throw
|
|
927
|
+
throw truncatedImage(mountPrefixedPath);
|
|
919
928
|
}
|
|
920
929
|
|
|
921
930
|
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
@@ -923,7 +932,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
923
932
|
return 'image/jpeg';
|
|
924
933
|
}
|
|
925
934
|
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
926
|
-
throw
|
|
935
|
+
throw truncatedImage(mountPrefixedPath);
|
|
927
936
|
}
|
|
928
937
|
|
|
929
938
|
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
@@ -931,7 +940,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
931
940
|
return 'image/gif';
|
|
932
941
|
}
|
|
933
942
|
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
934
|
-
throw
|
|
943
|
+
throw truncatedImage(mountPrefixedPath);
|
|
935
944
|
}
|
|
936
945
|
|
|
937
946
|
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
@@ -942,10 +951,10 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
942
951
|
(bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
943
952
|
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
|
|
944
953
|
) {
|
|
945
|
-
throw
|
|
954
|
+
throw truncatedImage(mountPrefixedPath);
|
|
946
955
|
}
|
|
947
956
|
|
|
948
|
-
throw new WorkspaceImageReadError('
|
|
957
|
+
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.");
|
|
949
958
|
}
|
|
950
959
|
|
|
951
960
|
export class WorkspaceModule implements Module {
|
|
@@ -973,14 +982,11 @@ export class WorkspaceModule implements Module {
|
|
|
973
982
|
private ctimeTrust = new Map<string, string | null>();
|
|
974
983
|
/**
|
|
975
984
|
* Each mount's directories that are other mounts nested inside it, with
|
|
976
|
-
* each mount's name and real root,
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
* the
|
|
981
|
-
* with each root as resolved standing for its real one, and each start
|
|
982
|
-
* reads it all again with the roots placed where disk has them
|
|
983
|
-
* (readNesting).
|
|
985
|
+
* each mount's name and real root: as configured, and as the real roots
|
|
986
|
+
* place it, where that differs (`fromRoots`). Each is mount-relative and
|
|
987
|
+
* `/`-separated. The constructor nests by configured path, with each root
|
|
988
|
+
* as resolved standing for its real one, and each start reads it all again
|
|
989
|
+
* with the roots placed where disk has them (readNesting).
|
|
984
990
|
*/
|
|
985
991
|
private otherMounts = new Map<string, NestedMountEntry[]>();
|
|
986
992
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
@@ -1045,24 +1051,18 @@ export class WorkspaceModule implements Module {
|
|
|
1045
1051
|
if (refused.length > 0) {
|
|
1046
1052
|
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
1047
1053
|
}
|
|
1048
|
-
const include
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
throw new Error(
|
|
1054
|
-
`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
|
|
1055
|
-
);
|
|
1054
|
+
for (const list of ['ignore', 'include'] as const) {
|
|
1055
|
+
const patterns: unknown = mount[list];
|
|
1056
|
+
if (patterns === undefined) continue;
|
|
1057
|
+
if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
|
|
1058
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
|
|
1056
1059
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
+ `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
|
|
1064
|
-
+ `${shownValue(unreadable)} is none of these`,
|
|
1065
|
-
);
|
|
1060
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
1061
|
+
const pattern: unknown = patterns[i];
|
|
1062
|
+
const why = patternRefusal(pattern, list);
|
|
1063
|
+
if (why !== undefined) {
|
|
1064
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
|
|
1065
|
+
}
|
|
1066
1066
|
}
|
|
1067
1067
|
}
|
|
1068
1068
|
}
|
|
@@ -1081,8 +1081,8 @@ export class WorkspaceModule implements Module {
|
|
|
1081
1081
|
const innerPath = resolve(inner.path);
|
|
1082
1082
|
const dir = nestedDir(outerPath, innerPath);
|
|
1083
1083
|
if (dir === undefined) continue;
|
|
1084
|
-
const configured = [{ dir
|
|
1085
|
-
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured
|
|
1084
|
+
const configured = [{ dir }];
|
|
1085
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
|
|
1086
1086
|
console.warn(
|
|
1087
1087
|
`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
1088
1088
|
`at ${dir}/ — passing over it as another mount's directory`,
|
|
@@ -1372,7 +1372,7 @@ export class WorkspaceModule implements Module {
|
|
|
1372
1372
|
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
1373
1373
|
let start = base;
|
|
1374
1374
|
for (const segment of beneath.split('/')) {
|
|
1375
|
-
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
|
|
1375
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined)) break;
|
|
1376
1376
|
const here = await checkDir(view, rootReal, start);
|
|
1377
1377
|
if (here.kind !== 'checked') break;
|
|
1378
1378
|
const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
|
|
@@ -1425,11 +1425,10 @@ export class WorkspaceModule implements Module {
|
|
|
1425
1425
|
|
|
1426
1426
|
async start(ctx: ModuleContext): Promise<void> {
|
|
1427
1427
|
// Which mounts are nested where, with the roots placed where disk has
|
|
1428
|
-
// them and each
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
// sets nothing.
|
|
1428
|
+
// them, and whether each mount's root folds case, read where disk has
|
|
1429
|
+
// it, before this start sets anything, so a restart takes no call
|
|
1430
|
+
// and walks or watches nothing before they're known. A stop begun
|
|
1431
|
+
// meanwhile stands, and this start then sets nothing.
|
|
1433
1432
|
const stops = this.stopsBegun;
|
|
1434
1433
|
const { nesting, reals, above } = await this.readNesting();
|
|
1435
1434
|
const rootFoldsCase = new Map<string, boolean>();
|
|
@@ -1545,7 +1544,8 @@ export class WorkspaceModule implements Module {
|
|
|
1545
1544
|
return (
|
|
1546
1545
|
`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
1547
1546
|
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
1548
|
-
`never had.
|
|
1547
|
+
`never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
|
|
1548
|
+
'if the current branch is what disk should hold, pass force: true to overwrite disk from it.'
|
|
1549
1549
|
);
|
|
1550
1550
|
}
|
|
1551
1551
|
|
|
@@ -2184,7 +2184,13 @@ export class WorkspaceModule implements Module {
|
|
|
2184
2184
|
return work;
|
|
2185
2185
|
}
|
|
2186
2186
|
|
|
2187
|
-
private
|
|
2187
|
+
private runToolCall(call: ToolCall): Promise<ToolResult> {
|
|
2188
|
+
const gate = { passed: false };
|
|
2189
|
+
return callGate.run(gate, () => this.answerToolCall(call, gate));
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
private async answerToolCall(call: ToolCall, gate: { passed: boolean }): Promise<ToolResult> {
|
|
2193
|
+
const reopenAtStart = this.agreement?.awaitsReopen === true;
|
|
2188
2194
|
try {
|
|
2189
2195
|
// Every handler reads its input as its schema describes it; a call
|
|
2190
2196
|
// that sends something else is refused here, before anything is read.
|
|
@@ -2199,12 +2205,12 @@ export class WorkspaceModule implements Module {
|
|
|
2199
2205
|
// ResultTooLarge in its place. A call made in process carries none and
|
|
2200
2206
|
// takes the data itself, so its read pages by its line limit alone.
|
|
2201
2207
|
const resultMaxChars = call.resultMaxChars;
|
|
2202
|
-
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
2208
|
+
const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
2203
2209
|
if (generated) {
|
|
2204
2210
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
2205
2211
|
return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
|
|
2206
2212
|
}
|
|
2207
|
-
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
2213
|
+
return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path as string, generated, call.callerAgentName)) };
|
|
2208
2214
|
}
|
|
2209
2215
|
|
|
2210
2216
|
switch (call.name) {
|
|
@@ -2221,13 +2227,41 @@ export class WorkspaceModule implements Module {
|
|
|
2221
2227
|
case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
|
|
2222
2228
|
case 'sync': return await this.handleSync(input as unknown as SyncInput);
|
|
2223
2229
|
default:
|
|
2224
|
-
return { success: false, error:
|
|
2230
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
2225
2231
|
}
|
|
2226
2232
|
} catch (err) {
|
|
2227
|
-
return { success: false, error: err
|
|
2233
|
+
return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
private failureAnswer(call: ToolCall, err: unknown, reopenAtStart: boolean, passedGate: boolean): string {
|
|
2238
|
+
const input = (typeof call.input === 'object' && call.input !== null ? call.input : {}) as { path?: unknown; mount?: unknown };
|
|
2239
|
+
const path = typeof input.path === 'string' ? input.path : undefined;
|
|
2240
|
+
const mountName = path !== undefined ? path.split('/')[0]! : typeof input.mount === 'string' ? input.mount : undefined;
|
|
2241
|
+
const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
|
|
2242
|
+
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
|
|
2243
|
+
if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
|
|
2244
|
+
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
|
|
2245
|
+
}
|
|
2246
|
+
if (err instanceof Refusal) return didNothing(call.name, err.message);
|
|
2247
|
+
if (err instanceof WorkspaceReadError) return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
|
|
2248
|
+
const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
|
|
2249
|
+
: mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
|
|
2250
|
+
: 'ls of each mount, with recursive: true, shows how each file stands against disk now';
|
|
2251
|
+
if (err instanceof NotStored || err instanceof AlreadyHeld) {
|
|
2252
|
+
console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
|
|
2253
|
+
return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
|
|
2254
|
+
}
|
|
2255
|
+
console.error(`[workspace] ${call.name} failed:`, err);
|
|
2256
|
+
if (err instanceof CommitUnknownError) {
|
|
2257
|
+
return unexpectedAnswer(call.name, subject, err.setCause, {
|
|
2258
|
+
said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
|
|
2259
|
+
});
|
|
2228
2260
|
}
|
|
2261
|
+
return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
|
|
2229
2262
|
}
|
|
2230
2263
|
|
|
2264
|
+
|
|
2231
2265
|
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
2232
2266
|
// Wake agents on filesystem events when the originating mount opted in.
|
|
2233
2267
|
// The EventGate still has final say — gate policies can further filter by
|
|
@@ -2266,13 +2300,13 @@ export class WorkspaceModule implements Module {
|
|
|
2266
2300
|
|
|
2267
2301
|
let text: string;
|
|
2268
2302
|
if (paths.length === 1) {
|
|
2269
|
-
text = `[
|
|
2303
|
+
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
|
|
2270
2304
|
} else {
|
|
2271
2305
|
const list = paths.map(p => `- ${p}`).join('\n');
|
|
2272
|
-
text = `[
|
|
2306
|
+
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
2273
2307
|
}
|
|
2274
2308
|
if (conflicts && conflicts.length > 0) {
|
|
2275
|
-
text += `\n[
|
|
2309
|
+
text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
2276
2310
|
}
|
|
2277
2311
|
|
|
2278
2312
|
return {
|
|
@@ -2323,16 +2357,15 @@ export class WorkspaceModule implements Module {
|
|
|
2323
2357
|
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
2324
2358
|
try {
|
|
2325
2359
|
// Synchronous, so the path as given. The nested-mount check takes it
|
|
2326
|
-
// in
|
|
2327
|
-
//
|
|
2328
|
-
//
|
|
2329
|
-
// include judges it as given, so on a disk that
|
|
2360
|
+
// in any spelling a disk that folds names may fold to the nested
|
|
2361
|
+
// mount's directory, erring toward that mount (holderOf). The include
|
|
2362
|
+
// judges it as given, so on a disk that
|
|
2330
2363
|
// folds names a covered name whose file disk spells otherwise returns
|
|
2331
2364
|
// that file's path, which the tools refuse, and a covered file named in
|
|
2332
2365
|
// another case is null, which the tools read. A path the mount doesn't
|
|
2333
2366
|
// hold, outside its include or in another mount's directory nested in
|
|
2334
2367
|
// it, is null, as for a path that leaves the mount's directory.
|
|
2335
|
-
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath
|
|
2368
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
|
|
2336
2369
|
return resolve(mount.config.path, relativePath);
|
|
2337
2370
|
} catch {
|
|
2338
2371
|
return null;
|
|
@@ -2360,6 +2393,9 @@ export class WorkspaceModule implements Module {
|
|
|
2360
2393
|
* `agentName` names the agent whose write it is for the head-move notice
|
|
2361
2394
|
* (a write naming none tells no agent).
|
|
2362
2395
|
*
|
|
2396
|
+
* A success's `disk` says where the file stands on disk as the workspace
|
|
2397
|
+
* last saw it, as the write tool's answer does.
|
|
2398
|
+
*
|
|
2363
2399
|
* A write whose tree entry is set but that then fails returns success
|
|
2364
2400
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2365
2401
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -2396,11 +2432,12 @@ export class WorkspaceModule implements Module {
|
|
|
2396
2432
|
isError: true,
|
|
2397
2433
|
};
|
|
2398
2434
|
}
|
|
2399
|
-
|
|
2435
|
+
if (relativePath === '') return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
|
|
2436
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
2400
2437
|
if (refusal) return { success: false, error: refusal, isError: true };
|
|
2401
2438
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2402
2439
|
if (data.byteLength > maxSize) {
|
|
2403
|
-
return { success: false, error:
|
|
2440
|
+
return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
|
|
2404
2441
|
}
|
|
2405
2442
|
const store = this.getStore();
|
|
2406
2443
|
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
@@ -2414,7 +2451,7 @@ export class WorkspaceModule implements Module {
|
|
|
2414
2451
|
}
|
|
2415
2452
|
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2416
2453
|
if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
|
|
2417
|
-
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
|
|
2454
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
2418
2455
|
}
|
|
2419
2456
|
return this.withMount(mount, async () => {
|
|
2420
2457
|
await this.settleBeforeMutation(mount, relativePath);
|
|
@@ -2429,20 +2466,24 @@ export class WorkspaceModule implements Module {
|
|
|
2429
2466
|
let committed = false;
|
|
2430
2467
|
let failure: string | null;
|
|
2431
2468
|
let modeKept: string | null = null;
|
|
2469
|
+
let disk: DiskState | null = null;
|
|
2432
2470
|
try {
|
|
2433
2471
|
const answer = await this.commitToolChange(mount, relativePath,
|
|
2434
2472
|
{ kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
|
|
2435
2473
|
modeKept = answer.modeKept;
|
|
2474
|
+
disk = answer.disk;
|
|
2436
2475
|
failure = answer.refused === null ? null
|
|
2437
2476
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
2438
2477
|
} catch (error) {
|
|
2439
2478
|
if (!committed) throw error;
|
|
2440
2479
|
// As write words it: a store that failed after recording the file
|
|
2441
2480
|
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2481
|
+
const reopen = this.agreement?.awaitsReopen === true;
|
|
2442
2482
|
failure = error instanceof StoredThenFailed
|
|
2443
2483
|
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
2444
|
-
+ (error.pushes ?
|
|
2484
|
+
+ (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 : ''}.` : '')
|
|
2445
2485
|
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
2486
|
+
if (reopen) failure += ` ${AWAITS_REOPEN}`;
|
|
2446
2487
|
}
|
|
2447
2488
|
if (failure !== null) {
|
|
2448
2489
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -2452,7 +2493,7 @@ export class WorkspaceModule implements Module {
|
|
|
2452
2493
|
}
|
|
2453
2494
|
return {
|
|
2454
2495
|
success: true,
|
|
2455
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
|
|
2496
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
|
|
2456
2497
|
};
|
|
2457
2498
|
});
|
|
2458
2499
|
}
|
|
@@ -2491,10 +2532,12 @@ export class WorkspaceModule implements Module {
|
|
|
2491
2532
|
return this.withMount(mount, async () => {
|
|
2492
2533
|
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
2493
2534
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2494
|
-
if (!entry)
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2535
|
+
if (!entry) {
|
|
2536
|
+
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
|
|
2537
|
+
}
|
|
2538
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2539
|
+
if ('lost' in contents) return { error: nothingToRead(contents.lost) };
|
|
2540
|
+
return { data: contents.bytes };
|
|
2498
2541
|
});
|
|
2499
2542
|
}
|
|
2500
2543
|
|
|
@@ -2577,9 +2620,9 @@ export class WorkspaceModule implements Module {
|
|
|
2577
2620
|
}
|
|
2578
2621
|
}
|
|
2579
2622
|
|
|
2580
|
-
/** The directories of the mounts nested inside a mount, mount-relative
|
|
2623
|
+
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
2581
2624
|
private othersOf(mountName: string): string[] {
|
|
2582
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2625
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
|
|
2583
2626
|
}
|
|
2584
2627
|
|
|
2585
2628
|
/**
|
|
@@ -2595,13 +2638,8 @@ export class WorkspaceModule implements Module {
|
|
|
2595
2638
|
* alone, with no real root, since through it it would claim the other's
|
|
2596
2639
|
* every path.
|
|
2597
2640
|
*
|
|
2598
|
-
* Each way it lies there is a spelling of its directory (`fromRoots`)
|
|
2599
|
-
*
|
|
2600
|
-
* disk that folds names, the outer mount's walks name the directory as
|
|
2601
|
-
* disk does, and so does a path parsePath has respelled, while a path given
|
|
2602
|
-
* as configured still names it as configured (resolveAbsolutePath). A
|
|
2603
|
-
* directory not on disk keeps the spellings the roots give alone. Each
|
|
2604
|
-
* mount's real root comes back beside the nesting (`reals`), for what
|
|
2641
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`).
|
|
2642
|
+
* Each mount's real root comes back beside the nesting (`reals`), for what
|
|
2605
2643
|
* start reads there, and each directory above each configured path with
|
|
2606
2644
|
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2607
2645
|
* (movedNesting).
|
|
@@ -2626,16 +2664,15 @@ export class WorkspaceModule implements Module {
|
|
|
2626
2664
|
for (const inner of this.config.mounts) {
|
|
2627
2665
|
if (outer === inner) continue;
|
|
2628
2666
|
const real = reals.get(inner.name)!;
|
|
2629
|
-
const at =
|
|
2667
|
+
const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!);
|
|
2630
2668
|
if (at === undefined) continue;
|
|
2631
|
-
const { configured, fromRoots,
|
|
2632
|
-
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots
|
|
2669
|
+
const { configured, fromRoots, claims } = at;
|
|
2670
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
|
|
2633
2671
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2634
2672
|
if (placed.length > 0) {
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
);
|
|
2673
|
+
const at = (sure: boolean) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
|
|
2674
|
+
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 ');
|
|
2675
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
|
|
2639
2676
|
}
|
|
2640
2677
|
}
|
|
2641
2678
|
}
|
|
@@ -2644,11 +2681,11 @@ export class WorkspaceModule implements Module {
|
|
|
2644
2681
|
|
|
2645
2682
|
/**
|
|
2646
2683
|
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2647
|
-
* has one, and include (MountView's `nested`)
|
|
2684
|
+
* has one, and include (MountView's `nested`).
|
|
2648
2685
|
*/
|
|
2649
2686
|
private nestedOf(mountName: string): NestedMount[] {
|
|
2650
2687
|
return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
|
|
2651
|
-
o.
|
|
2688
|
+
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 })));
|
|
2652
2689
|
}
|
|
2653
2690
|
|
|
2654
2691
|
/**
|
|
@@ -2747,10 +2784,12 @@ export class WorkspaceModule implements Module {
|
|
|
2747
2784
|
if (real === undefined) continue;
|
|
2748
2785
|
above = placedAbove.get(inner.name) ?? [];
|
|
2749
2786
|
}
|
|
2750
|
-
const at =
|
|
2787
|
+
const at = placementOf(outerPath, rootReal, innerPath, real, above);
|
|
2751
2788
|
if (at === undefined) continue;
|
|
2752
2789
|
const include = this.mounts.get(inner.name)?.config.include;
|
|
2753
|
-
for (const s of at.
|
|
2790
|
+
for (const s of at.fromRoots) {
|
|
2791
|
+
nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2792
|
+
}
|
|
2754
2793
|
}
|
|
2755
2794
|
return nested;
|
|
2756
2795
|
}
|
|
@@ -2760,33 +2799,22 @@ export class WorkspaceModule implements Module {
|
|
|
2760
2799
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2761
2800
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2762
2801
|
* outer mount isn't followed), with the path to use there; undefined when
|
|
2763
|
-
* no nested mount's directory holds it.
|
|
2764
|
-
* compared with the directory's spellings exactly. One judged as given
|
|
2765
|
-
* (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
|
|
2766
|
-
* folds names may compare it (beginsWithDir), so it can't reach that
|
|
2767
|
-
* mount's directory by spelling a segment otherwise. On a disk that tells
|
|
2768
|
-
* names apart, that takes a directory of this mount's own spelled like the
|
|
2769
|
-
* nested one in another way as that mount's too.
|
|
2802
|
+
* no nested mount's directory holds it.
|
|
2770
2803
|
*/
|
|
2771
|
-
private holderOf(mountName: string, relativePath: string,
|
|
2804
|
+
private holderOf(mountName: string, relativePath: string, moved: NestedMount[] | null = null): { name: string; use: string; guess?: NestedGuess } | undefined {
|
|
2772
2805
|
const mount = this.mounts.get(mountName);
|
|
2773
2806
|
if (!mount) return undefined;
|
|
2774
2807
|
const root = resolve(mount.config.path);
|
|
2775
2808
|
const resolved = resolve(root, relativePath);
|
|
2776
2809
|
if (!isContainedPath(root, resolved)) return undefined;
|
|
2777
|
-
const
|
|
2810
|
+
const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
|
|
2778
2811
|
// A direct mount whose root a call found moved is judged by that call's
|
|
2779
2812
|
// nesting (movedNesting), each directory beneath its configured path.
|
|
2780
|
-
const
|
|
2781
|
-
? moved.map((n) => ({ name: n.name, dir: n.dir }))
|
|
2782
|
-
: (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
|
|
2783
|
-
const holder = dirs
|
|
2784
|
-
.filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
|
|
2785
|
-
.map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
|
|
2786
|
-
.sort((a, b) => b.depth - a.depth)[0];
|
|
2813
|
+
const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
|
|
2787
2814
|
if (!holder) return undefined;
|
|
2788
|
-
const rest =
|
|
2789
|
-
|
|
2815
|
+
const rest = restBeneath(path, holder);
|
|
2816
|
+
const guess = nestedGuess(mountName, path.split('/'), holder);
|
|
2817
|
+
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
|
|
2790
2818
|
}
|
|
2791
2819
|
|
|
2792
2820
|
/**
|
|
@@ -2795,10 +2823,10 @@ export class WorkspaceModule implements Module {
|
|
|
2795
2823
|
* it; for a listing, whose the path is.
|
|
2796
2824
|
*/
|
|
2797
2825
|
private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
|
|
2798
|
-
const holder = this.holderOf(mountName, relativePath,
|
|
2826
|
+
const holder = this.holderOf(mountName, relativePath, moved);
|
|
2799
2827
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2800
2828
|
return holder
|
|
2801
|
-
? `it lies in mount "${holder.name}", nested in this one${what}:
|
|
2829
|
+
? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
|
|
2802
2830
|
: `it lies in another mount's directory${what}`;
|
|
2803
2831
|
}
|
|
2804
2832
|
|
|
@@ -2818,7 +2846,7 @@ export class WorkspaceModule implements Module {
|
|
|
2818
2846
|
// Both checks judge by the nesting the parse found, and the second reads
|
|
2819
2847
|
// disk beneath the root the parse resolved.
|
|
2820
2848
|
const { mount, root, moved } = spelled;
|
|
2821
|
-
const parsed = this.refuseNotHeld(spelled, path,
|
|
2849
|
+
const parsed = this.refuseNotHeld(spelled, path, moved);
|
|
2822
2850
|
// A path can also reach a nested mount's directory, or a file this
|
|
2823
2851
|
// mount's own include leaves out, through a symlink on its way. No push
|
|
2824
2852
|
// of this mount writes it there and no read reads it, so it's refused
|
|
@@ -2858,7 +2886,8 @@ export class WorkspaceModule implements Module {
|
|
|
2858
2886
|
// operation, in the words a disk read gives a path it can't resolve,
|
|
2859
2887
|
// whatever the place realpath stopped short of would say.
|
|
2860
2888
|
if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2861
|
-
|
|
2889
|
+
let holder = await nestedAtReal(real, nested);
|
|
2890
|
+
if (holder?.guess === null) holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
|
|
2862
2891
|
if (!holder) {
|
|
2863
2892
|
if (!judged) return null;
|
|
2864
2893
|
const rootReal = await realBeneath(root);
|
|
@@ -2874,11 +2903,11 @@ export class WorkspaceModule implements Module {
|
|
|
2874
2903
|
// on the place's way as that mount's, so a place beneath anything else
|
|
2875
2904
|
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2876
2905
|
// a directory not there yet is.
|
|
2877
|
-
const onItsWay = holder.
|
|
2906
|
+
const onItsWay = !holder.judgedByInclude
|
|
2878
2907
|
|| await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
|
|
2879
|
-
const directory = holder.
|
|
2880
|
-
if (!onItsWay || !
|
|
2881
|
-
return new NestedMountError(path, mount.config.name, holder
|
|
2908
|
+
const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2909
|
+
if (!onItsWay || !holder.names(directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2910
|
+
return new NestedMountError(path, mount.config.name, holder, 'leads into');
|
|
2882
2911
|
}
|
|
2883
2912
|
|
|
2884
2913
|
/**
|
|
@@ -2886,13 +2915,12 @@ export class WorkspaceModule implements Module {
|
|
|
2886
2915
|
* mount's directory first, naming the mount that holds it, then outside
|
|
2887
2916
|
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2888
2917
|
* as disk spells it for parseFilePath, as given for resolveAbsolutePath,
|
|
2889
|
-
* which is synchronous
|
|
2890
|
-
* folds where disk does). The include's refusal shows the path as given
|
|
2918
|
+
* which is synchronous. The include's refusal shows the path as given
|
|
2891
2919
|
* (`given`), since it answers the path the caller asked about.
|
|
2892
2920
|
*/
|
|
2893
|
-
private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string,
|
|
2894
|
-
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath,
|
|
2895
|
-
if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder
|
|
2921
|
+
private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, moved: NestedMount[] | null = null): P {
|
|
2922
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
|
|
2923
|
+
if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
|
|
2896
2924
|
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2897
2925
|
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2898
2926
|
}
|
|
@@ -2942,7 +2970,7 @@ export class WorkspaceModule implements Module {
|
|
|
2942
2970
|
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2943
2971
|
*/
|
|
2944
2972
|
private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
|
|
2945
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
2973
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) return false;
|
|
2946
2974
|
if (insideMount(relativePath, mount.config.include)) return false;
|
|
2947
2975
|
const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
|
|
2948
2976
|
if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
|
|
@@ -3015,18 +3043,12 @@ export class WorkspaceModule implements Module {
|
|
|
3015
3043
|
|
|
3016
3044
|
/**
|
|
3017
3045
|
* How many segments the innermost nested mount's directory that holds
|
|
3018
|
-
* `relativePath` has,
|
|
3019
|
-
*
|
|
3046
|
+
* `relativePath` has, or, for a direct mount whose root has moved, in the
|
|
3047
|
+
* nesting there (`moved`);
|
|
3020
3048
|
* undefined when no nested mount's directory holds it.
|
|
3021
3049
|
*/
|
|
3022
3050
|
private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
|
|
3023
|
-
|
|
3024
|
-
for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
|
|
3025
|
-
if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
|
|
3026
|
-
const segments = dir.split('/').length;
|
|
3027
|
-
if (depth === undefined || segments > depth) depth = segments;
|
|
3028
|
-
}
|
|
3029
|
-
return depth;
|
|
3051
|
+
return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
|
|
3030
3052
|
}
|
|
3031
3053
|
|
|
3032
3054
|
/**
|
|
@@ -3049,11 +3071,7 @@ export class WorkspaceModule implements Module {
|
|
|
3049
3071
|
// Only an entry this mount's include covers spells a draft's path
|
|
3050
3072
|
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
3051
3073
|
// from an earlier version, nor one the include leaves out, which would
|
|
3052
|
-
// respell a covered draft into a name the include leaves out.
|
|
3053
|
-
// in a nested mount's directory that this mount's include covers
|
|
3054
|
-
// spells one whatever that mount's include says, so on a disk that
|
|
3055
|
-
// folds names, a draft naming the directory in another spelling takes
|
|
3056
|
-
// the entry's spelling and is judged as in it.
|
|
3074
|
+
// respell a covered draft into a name the include leaves out.
|
|
3057
3075
|
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
3058
3076
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
3059
3077
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
@@ -3079,25 +3097,24 @@ export class WorkspaceModule implements Module {
|
|
|
3079
3097
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
3080
3098
|
// store would keep one name and disk another.
|
|
3081
3099
|
if (LONE_SURROGATE.test(path)) {
|
|
3082
|
-
throw new
|
|
3100
|
+
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.`);
|
|
3083
3101
|
}
|
|
3084
3102
|
const slashIdx = path.indexOf('/');
|
|
3085
3103
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
3086
3104
|
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
3087
3105
|
|
|
3088
3106
|
const mount = this.mounts.get(mountName);
|
|
3089
|
-
if (!mount)
|
|
3090
|
-
throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
|
|
3091
|
-
}
|
|
3107
|
+
if (!mount) throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
|
|
3092
3108
|
const segments = pathSegments(given);
|
|
3093
3109
|
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
3094
|
-
throw new
|
|
3110
|
+
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>".`);
|
|
3095
3111
|
}
|
|
3096
3112
|
const kept: string[] = [];
|
|
3097
3113
|
for (const segment of segments) {
|
|
3098
3114
|
if (segment !== '..') kept.push(segment);
|
|
3099
3115
|
else if (kept.pop() === undefined) {
|
|
3100
|
-
throw new
|
|
3116
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
|
|
3117
|
+
+ `Name a file from its own mount's name; ${this.mountList()}.`);
|
|
3101
3118
|
}
|
|
3102
3119
|
}
|
|
3103
3120
|
const relativePath = kept.join('/');
|
|
@@ -3112,11 +3129,49 @@ export class WorkspaceModule implements Module {
|
|
|
3112
3129
|
const root = resolve(mount.config.path);
|
|
3113
3130
|
const resolved = resolve(root, relativePath);
|
|
3114
3131
|
if (!isContainedPath(root, resolved)) {
|
|
3115
|
-
throw new
|
|
3132
|
+
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>".`);
|
|
3116
3133
|
}
|
|
3117
3134
|
return { mount, relativePath };
|
|
3118
3135
|
}
|
|
3119
3136
|
|
|
3137
|
+
private writableNames(): string[] {
|
|
3138
|
+
return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
private mountList(): string {
|
|
3142
|
+
const names = [...this.mounts.keys()];
|
|
3143
|
+
return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
private emptyPathField(mount: string | undefined, bare: string): string {
|
|
3147
|
+
const empty = '"path" is "", which names no file or directory.';
|
|
3148
|
+
if (mount === undefined) return `${empty} Leaving "path" out ${bare}.`;
|
|
3149
|
+
if (mount === '') return `${empty} "mount" is "" too; leaving both out ${bare}.`;
|
|
3150
|
+
if (this.mounts.has(mount)) return `${empty} Leaving "path" out covers mount ${JSON.stringify(mount)}.`;
|
|
3151
|
+
return `${empty} "mount" is ${JSON.stringify(mount)}, which is no mount's name; ${this.mountList()}. Leaving both out ${bare}.`;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
private noSuchMountField(given: string, bare: string): string {
|
|
3155
|
+
const [close] = closestNames(given, [...this.mounts.keys()], 1);
|
|
3156
|
+
return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
|
|
3157
|
+
+ (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
|
|
3158
|
+
+ ` Leaving "mount" out ${bare}.`;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
private noSuchMount(path: string, mountName: string): string {
|
|
3162
|
+
const names = [...this.mounts.keys()];
|
|
3163
|
+
const rest = path.slice(mountName.length);
|
|
3164
|
+
const unslashed = path.replace(/^[/\\]+/, '');
|
|
3165
|
+
if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
|
|
3166
|
+
return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
|
|
3167
|
+
}
|
|
3168
|
+
if (mountName === '') return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
3169
|
+
const [close] = closestNames(mountName, names, 1);
|
|
3170
|
+
const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
|
|
3171
|
+
: `${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()}.`;
|
|
3172
|
+
return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3120
3175
|
/**
|
|
3121
3176
|
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
3122
3177
|
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
@@ -3140,31 +3195,26 @@ export class WorkspaceModule implements Module {
|
|
|
3140
3195
|
maxSize: number,
|
|
3141
3196
|
): { bytes: Buffer; mimeType: SupportedImageMimeType } {
|
|
3142
3197
|
if (bytes.byteLength === 0) {
|
|
3143
|
-
throw
|
|
3198
|
+
throw emptyImage(mountPrefixedPath);
|
|
3144
3199
|
}
|
|
3145
|
-
if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3200
|
+
if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
|
|
3146
3201
|
return {
|
|
3147
3202
|
bytes,
|
|
3148
3203
|
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
3149
3204
|
};
|
|
3150
3205
|
}
|
|
3151
3206
|
|
|
3152
|
-
private
|
|
3207
|
+
private async readImageFromTree(
|
|
3153
3208
|
mount: MountState,
|
|
3154
3209
|
relativePath: string,
|
|
3155
3210
|
mountPrefixedPath: string,
|
|
3156
3211
|
maxSize: number,
|
|
3157
|
-
): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
|
|
3158
|
-
const
|
|
3159
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3212
|
+
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> {
|
|
3213
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
3160
3214
|
if (!entry) return null;
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
3215
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
3216
|
+
if ('lost' in contents) return contents;
|
|
3217
|
+
return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
|
|
3168
3218
|
}
|
|
3169
3219
|
|
|
3170
3220
|
/**
|
|
@@ -3181,19 +3231,14 @@ export class WorkspaceModule implements Module {
|
|
|
3181
3231
|
* another directory carries none. The identity is rootIdentityForRead's,
|
|
3182
3232
|
* which answers while the evidence awaits reopening; where even it can't
|
|
3183
3233
|
* be read, as when the evidence was never read back whole, the read is
|
|
3184
|
-
* refused
|
|
3234
|
+
* refused before its root is resolved, with an errno only where no
|
|
3235
|
+
* directory is at the root's path.
|
|
3185
3236
|
* A direct mount's root is checked against no identity, and its read reads
|
|
3186
3237
|
* no evidence: it promises what's at its root now (diskView).
|
|
3187
3238
|
*/
|
|
3188
3239
|
private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
|
|
3189
3240
|
const name = mount.config.name;
|
|
3190
3241
|
const root = resolve(mount.config.path);
|
|
3191
|
-
let real: string;
|
|
3192
|
-
try {
|
|
3193
|
-
real = await realpath(root);
|
|
3194
|
-
} catch (err) {
|
|
3195
|
-
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
3196
|
-
}
|
|
3197
3242
|
let rootIdentity: MountView['rootIdentity'];
|
|
3198
3243
|
if (!mount.direct) {
|
|
3199
3244
|
try {
|
|
@@ -3201,17 +3246,22 @@ export class WorkspaceModule implements Module {
|
|
|
3201
3246
|
} catch (err) {
|
|
3202
3247
|
// The evidence can't be read back, so neither can the check be made.
|
|
3203
3248
|
// Where no directory is at the root's path, its errno still says so.
|
|
3204
|
-
throw new
|
|
3205
|
-
`Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
|
|
3249
|
+
throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
|
|
3206
3250
|
}
|
|
3207
3251
|
}
|
|
3252
|
+
let real: string;
|
|
3253
|
+
try {
|
|
3254
|
+
real = await realpath(root);
|
|
3255
|
+
} catch (err) {
|
|
3256
|
+
throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
|
|
3257
|
+
}
|
|
3208
3258
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
3209
3259
|
if ('dir' in checked) return { real, dir: checked.dir };
|
|
3210
3260
|
// Another directory is no absence. For any other refusal, what is at the
|
|
3211
3261
|
// root's path now decides: nothing, something that isn't a directory, or
|
|
3212
3262
|
// a directory the path has come to name since it was resolved (no errno).
|
|
3213
3263
|
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
3214
|
-
throw new
|
|
3264
|
+
throw new RootUnavailable(name, checked.reason, 'root', errno);
|
|
3215
3265
|
}
|
|
3216
3266
|
|
|
3217
3267
|
/**
|
|
@@ -3226,7 +3276,7 @@ export class WorkspaceModule implements Module {
|
|
|
3226
3276
|
private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
|
|
3227
3277
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
3228
3278
|
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
3229
|
-
return new
|
|
3279
|
+
return new RootUnavailable(mount.config.name, reason, 'root', errno);
|
|
3230
3280
|
}
|
|
3231
3281
|
|
|
3232
3282
|
/**
|
|
@@ -3328,7 +3378,7 @@ export class WorkspaceModule implements Module {
|
|
|
3328
3378
|
}
|
|
3329
3379
|
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
3330
3380
|
// A FIFO, socket or device is never opened.
|
|
3331
|
-
throw new
|
|
3381
|
+
throw new NotRegularFile(mountPrefixedPath, 'lstat');
|
|
3332
3382
|
}
|
|
3333
3383
|
|
|
3334
3384
|
let handle: Awaited<ReturnType<typeof open>>;
|
|
@@ -3364,7 +3414,7 @@ export class WorkspaceModule implements Module {
|
|
|
3364
3414
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
3365
3415
|
}
|
|
3366
3416
|
if (!fileStat.isFile()) {
|
|
3367
|
-
throw new
|
|
3417
|
+
throw new NotRegularFile(mountPrefixedPath, 'fstat');
|
|
3368
3418
|
}
|
|
3369
3419
|
|
|
3370
3420
|
if (!follow && !useNoFollow) {
|
|
@@ -3403,13 +3453,13 @@ export class WorkspaceModule implements Module {
|
|
|
3403
3453
|
if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
3404
3454
|
|
|
3405
3455
|
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
3406
|
-
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3456
|
+
const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
|
|
3407
3457
|
// A place in a nested mount's directory that its include leaves out is
|
|
3408
3458
|
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
3409
3459
|
// does one reaching a file this mount's own include leaves out
|
|
3410
3460
|
// (coversLanding).
|
|
3411
3461
|
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
3412
|
-
if (!inside || own || (holder !== undefined && !
|
|
3462
|
+
if (!inside || own || (holder !== undefined && !holder.names(false))) {
|
|
3413
3463
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
3414
3464
|
}
|
|
3415
3465
|
if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
|
|
@@ -3477,7 +3527,9 @@ export class WorkspaceModule implements Module {
|
|
|
3477
3527
|
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
3478
3528
|
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
3479
3529
|
* directory, policy-denied symlink, outside-mount target, and
|
|
3480
|
-
* file-changed-during-read.
|
|
3530
|
+
* file-changed-during-read. An error met while parsing the path that is
|
|
3531
|
+
* none of these refusals, such as the store's, is thrown as it is, and a
|
|
3532
|
+
* `maxBytes` that isn't a non-negative integer throws a `RangeError`.
|
|
3481
3533
|
*/
|
|
3482
3534
|
async readFileFromDisk(
|
|
3483
3535
|
mountPrefixedPath: string,
|
|
@@ -3493,11 +3545,11 @@ export class WorkspaceModule implements Module {
|
|
|
3493
3545
|
} catch (err) {
|
|
3494
3546
|
// A link's refusal (parseFilePath) carries its own code.
|
|
3495
3547
|
if (err instanceof WorkspaceReadError) throw err;
|
|
3496
|
-
const
|
|
3497
|
-
const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3548
|
+
const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3498
3549
|
: err instanceof NestedMountError ? 'other_mount'
|
|
3499
|
-
:
|
|
3500
|
-
|
|
3550
|
+
: err instanceof PathRefusal ? err.code : null;
|
|
3551
|
+
if (code === null) throw err;
|
|
3552
|
+
throw new WorkspaceReadError(code, (err as Error).message, 'parse');
|
|
3501
3553
|
}
|
|
3502
3554
|
if (!relativePath) {
|
|
3503
3555
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
@@ -3525,92 +3577,25 @@ export class WorkspaceModule implements Module {
|
|
|
3525
3577
|
}
|
|
3526
3578
|
}
|
|
3527
3579
|
|
|
3528
|
-
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
3529
|
-
private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
|
|
3530
|
-
const p = mountPrefixedPath;
|
|
3531
|
-
switch (err.code) {
|
|
3532
|
-
case 'mount_unavailable':
|
|
3533
|
-
// With why, where the root's check says, such as a directory that
|
|
3534
|
-
// isn't the mount's.
|
|
3535
|
-
return new WorkspaceImageReadError('mount_unavailable', err.message);
|
|
3536
|
-
case 'directory':
|
|
3537
|
-
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
3538
|
-
case 'symlink':
|
|
3539
|
-
// The core reader's own words for a link at the path: "Symlinks are not
|
|
3540
|
-
// allowed: <path>".
|
|
3541
|
-
return new WorkspaceImageReadError('symlink', err.message);
|
|
3542
|
-
case 'parent_symlink':
|
|
3543
|
-
// The core reader's own words for a link on its way: which link and
|
|
3544
|
-
// where it leads, with the setting for a link to a directory.
|
|
3545
|
-
return new WorkspaceImageReadError('parent_symlink', err.message);
|
|
3546
|
-
case 'other_mount':
|
|
3547
|
-
// The core reader's own words for a path a link takes into a nested
|
|
3548
|
-
// mount's directory: that mount and the path to use there. (#460's
|
|
3549
|
-
// own other_mount, a path in that directory, is refused at parse,
|
|
3550
|
-
// and so is a link found there before the read.)
|
|
3551
|
-
return new WorkspaceImageReadError('other_mount', err.message);
|
|
3552
|
-
case 'escape':
|
|
3553
|
-
// The core reader's own words: "Symlink escape detected: <path>" for a
|
|
3554
|
-
// followed link that resolves outside the mount, and for a link on the
|
|
3555
|
-
// way that leads outside a mount that doesn't follow links, which link.
|
|
3556
|
-
return new WorkspaceImageReadError('escape', err.message);
|
|
3557
|
-
case 'changed':
|
|
3558
|
-
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
3559
|
-
case 'not_found':
|
|
3560
|
-
default:
|
|
3561
|
-
// The core reader's own words for a file that isn't regular, and for
|
|
3562
|
-
// a directory on the way it couldn't ask about.
|
|
3563
|
-
if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
|
|
3564
|
-
return new WorkspaceImageReadError('not_found', err.message);
|
|
3565
|
-
}
|
|
3566
|
-
if (err.message.startsWith('File not found')) {
|
|
3567
|
-
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
3568
|
-
}
|
|
3569
|
-
if (err.stage === 'realpath') {
|
|
3570
|
-
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
3571
|
-
}
|
|
3572
|
-
if (err.stage === 'stat') {
|
|
3573
|
-
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
3574
|
-
}
|
|
3575
|
-
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
3576
|
-
}
|
|
3577
|
-
}
|
|
3578
|
-
|
|
3579
3580
|
private async readImageFromFilesystem(
|
|
3580
3581
|
mount: MountState,
|
|
3581
3582
|
relativePath: string,
|
|
3582
3583
|
mountPrefixedPath: string,
|
|
3583
3584
|
maxSize: number,
|
|
3584
3585
|
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
3590
|
-
}
|
|
3591
|
-
if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3592
|
-
});
|
|
3593
|
-
} catch (err) {
|
|
3594
|
-
if (err instanceof WorkspaceImageReadError) throw err;
|
|
3595
|
-
if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
3596
|
-
if (isErrnoCode(err, 'ENOENT')) {
|
|
3597
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3598
|
-
}
|
|
3599
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3600
|
-
}
|
|
3601
|
-
const { handle, fileStat } = opened;
|
|
3586
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
3587
|
+
if (fileStat.size === 0) throw emptyImage(mountPrefixedPath);
|
|
3588
|
+
if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
|
|
3589
|
+
});
|
|
3602
3590
|
try {
|
|
3603
3591
|
// No more than maxSize is loaded: a file grown past it since the fstat
|
|
3604
3592
|
// above is refused here, as one already over it was there.
|
|
3605
3593
|
const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
|
|
3606
|
-
if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3594
|
+
if (more) throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
|
|
3607
3595
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
3608
3596
|
} catch (err) {
|
|
3609
3597
|
if (err instanceof WorkspaceImageReadError) throw err;
|
|
3610
|
-
|
|
3611
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3612
|
-
}
|
|
3613
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3598
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
3614
3599
|
} finally {
|
|
3615
3600
|
await handle.close();
|
|
3616
3601
|
}
|
|
@@ -3643,18 +3628,19 @@ export class WorkspaceModule implements Module {
|
|
|
3643
3628
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
3644
3629
|
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
3645
3630
|
* deletion that cleared a conflict naming a disk file the workspace never
|
|
3646
|
-
* took in, that nothing was unlinked (`note`).
|
|
3647
|
-
*
|
|
3648
|
-
* the path,
|
|
3631
|
+
* took in, that nothing was unlinked (`note`). And the answer's `disk` for
|
|
3632
|
+
* the path, unless it was refused. The caller holds the mount's turn and has
|
|
3633
|
+
* settled the path first (settleBeforeMutation, or a pass over the path, as
|
|
3634
|
+
* an edit makes).
|
|
3649
3635
|
*/
|
|
3650
3636
|
private async commitToolChange(
|
|
3651
3637
|
mount: MountState,
|
|
3652
3638
|
relativePath: string,
|
|
3653
3639
|
change: ToolChange,
|
|
3654
3640
|
onCommitted?: () => void,
|
|
3655
|
-
): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
|
|
3656
|
-
const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3657
|
-
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
|
|
3641
|
+
): Promise<{ refused: string | null; modeKept: string | null; note: string | null; disk: DiskState | null }> {
|
|
3642
|
+
const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3643
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
|
|
3658
3644
|
}
|
|
3659
3645
|
|
|
3660
3646
|
/**
|
|
@@ -3670,15 +3656,16 @@ export class WorkspaceModule implements Module {
|
|
|
3670
3656
|
* (changesLanded).
|
|
3671
3657
|
* Answers, by path, the reason for each that reached the workspace but not
|
|
3672
3658
|
* disk (`refused`), and, apart from it, why one whose bytes reached disk
|
|
3673
|
-
* left the file's mode as disk had it (`modeKept`),
|
|
3674
|
-
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3675
|
-
*
|
|
3659
|
+
* left the file's mode as disk had it (`modeKept`), that a deletion
|
|
3660
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3661
|
+
* and the answer's `disk` for each path not refused, as commitToolChange
|
|
3662
|
+
* answers them for one path.
|
|
3676
3663
|
*/
|
|
3677
3664
|
private async commitToolChanges(
|
|
3678
3665
|
mount: MountState,
|
|
3679
3666
|
changes: Array<{ path: string; change: ToolChange }>,
|
|
3680
3667
|
onCommitted?: (path: string) => void,
|
|
3681
|
-
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
|
|
3668
|
+
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string>; disk: Map<string, DiskState> }> {
|
|
3682
3669
|
const store = this.getStore();
|
|
3683
3670
|
const agreement = this.agreementOrThrow();
|
|
3684
3671
|
const intents = this.intents.get(mount.config.name)!;
|
|
@@ -3817,7 +3804,7 @@ export class WorkspaceModule implements Module {
|
|
|
3817
3804
|
// nothing.
|
|
3818
3805
|
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3819
3806
|
putBack();
|
|
3820
|
-
throw error;
|
|
3807
|
+
throw changing.length === 0 ? new AlreadyHeld(error) : error;
|
|
3821
3808
|
}
|
|
3822
3809
|
landedThenThrew = { error };
|
|
3823
3810
|
}
|
|
@@ -3831,7 +3818,9 @@ export class WorkspaceModule implements Module {
|
|
|
3831
3818
|
if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3832
3819
|
const refused = new Map<string, string>();
|
|
3833
3820
|
const modeKept = new Map<string, string>();
|
|
3834
|
-
if (!this.pushesOnCommit(mount) || pushing.length === 0)
|
|
3821
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) {
|
|
3822
|
+
return { refused, modeKept, notes, disk: new Map(changes.map(({ path }): [string, DiskState] => [path, this.diskOf(mount, path)])) };
|
|
3823
|
+
}
|
|
3835
3824
|
|
|
3836
3825
|
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3837
3826
|
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
@@ -3853,7 +3842,7 @@ export class WorkspaceModule implements Module {
|
|
|
3853
3842
|
error: skipped.reason,
|
|
3854
3843
|
} as ProcessEvent);
|
|
3855
3844
|
}
|
|
3856
|
-
return { refused, modeKept, notes };
|
|
3845
|
+
return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }): [string, DiskState] => [path, 'done'])) };
|
|
3857
3846
|
}
|
|
3858
3847
|
|
|
3859
3848
|
// ==========================================================================
|
|
@@ -3904,26 +3893,30 @@ export class WorkspaceModule implements Module {
|
|
|
3904
3893
|
* call made again checks it.
|
|
3905
3894
|
*/
|
|
3906
3895
|
private notCheckedYet(path: string, reason: string): string {
|
|
3907
|
-
return `${
|
|
3896
|
+
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.`;
|
|
3908
3897
|
}
|
|
3909
3898
|
|
|
3910
3899
|
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
3911
|
-
private notInWorkspace(path: string, report: PathReport | undefined): string {
|
|
3900
|
+
private notInWorkspace(path: string, report: PathReport | undefined, mount: MountState, missing?: string): string {
|
|
3901
|
+
const shown = JSON.stringify(path);
|
|
3912
3902
|
switch (report?.state) {
|
|
3913
3903
|
case 'workspace-deleted':
|
|
3914
|
-
return `${
|
|
3904
|
+
return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
3915
3905
|
case 'not-in-branch':
|
|
3916
|
-
return `${
|
|
3917
|
-
case 'disk-only':
|
|
3918
|
-
|
|
3919
|
-
|
|
3906
|
+
return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
3907
|
+
case 'disk-only': {
|
|
3908
|
+
const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
3909
|
+
return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
|
|
3910
|
+
+ 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
|
|
3911
|
+
+ (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
|
|
3912
|
+
}
|
|
3920
3913
|
case 'conflict':
|
|
3921
|
-
return `${
|
|
3914
|
+
return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
3922
3915
|
'Sync this path to take the disk version.';
|
|
3923
3916
|
case 'unverified':
|
|
3924
|
-
return
|
|
3917
|
+
return uncheckedFile(shown, report.note, report.noteCause ?? {});
|
|
3925
3918
|
default:
|
|
3926
|
-
return `
|
|
3919
|
+
return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
3927
3920
|
}
|
|
3928
3921
|
}
|
|
3929
3922
|
|
|
@@ -3966,14 +3959,14 @@ export class WorkspaceModule implements Module {
|
|
|
3966
3959
|
// A pass also drops a copy disk made binary or too large, which
|
|
3967
3960
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3968
3961
|
return {
|
|
3969
|
-
error: took('deleted') && report?.state !== 'disk-only'
|
|
3970
|
-
|
|
3971
|
-
|
|
3962
|
+
error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
|
|
3963
|
+
: undecided !== null ? this.notCheckedYet(path, undecided)
|
|
3964
|
+
: this.notInWorkspace(path, report, mount),
|
|
3972
3965
|
};
|
|
3973
3966
|
}
|
|
3974
|
-
const
|
|
3975
|
-
if (
|
|
3976
|
-
return { bytes:
|
|
3967
|
+
const contents = await this.contentsOf(mount, relativePath, path, entry);
|
|
3968
|
+
if ('lost' in contents) return { error: nothingToRead(contents.lost) };
|
|
3969
|
+
return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3977
3970
|
});
|
|
3978
3971
|
}
|
|
3979
3972
|
|
|
@@ -3987,33 +3980,144 @@ export class WorkspaceModule implements Module {
|
|
|
3987
3980
|
*/
|
|
3988
3981
|
private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3989
3982
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3990
|
-
const over = `${path} is
|
|
3983
|
+
const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
|
|
3991
3984
|
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
3992
3985
|
try {
|
|
3993
3986
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3994
|
-
if (fileStat.size > maxSize) throw new
|
|
3987
|
+
if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
3995
3988
|
});
|
|
3996
3989
|
} catch (err) {
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
3990
|
+
if (err instanceof Refusal) return { error: err.message };
|
|
3991
|
+
if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
|
|
3992
|
+
throw err;
|
|
4000
3993
|
}
|
|
4001
3994
|
try {
|
|
4002
3995
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
4003
3996
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4004
|
-
if (more) return { error: over };
|
|
3997
|
+
if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4005
3998
|
if (looksBinary(bytes)) {
|
|
4006
|
-
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '
|
|
3999
|
+
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.'}` };
|
|
4007
4000
|
}
|
|
4008
4001
|
return { bytes };
|
|
4009
4002
|
} catch (err) {
|
|
4010
|
-
|
|
4011
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
4003
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4012
4004
|
} finally {
|
|
4013
4005
|
await opened.handle.close();
|
|
4014
4006
|
}
|
|
4015
4007
|
}
|
|
4016
4008
|
|
|
4009
|
+
private async contentsOf(
|
|
4010
|
+
mount: MountState,
|
|
4011
|
+
relativePath: string,
|
|
4012
|
+
path: string,
|
|
4013
|
+
version: { blobHash: string; size: number },
|
|
4014
|
+
what: 'saved' | 'conflict-disk' = 'saved',
|
|
4015
|
+
tell: 'now' | 'later' = 'now',
|
|
4016
|
+
): Promise<{ bytes: Buffer } | { lost: LostContents }> {
|
|
4017
|
+
const store = this.getStore();
|
|
4018
|
+
const stored = storedBytes(store, version.blobHash);
|
|
4019
|
+
if ('bytes' in stored) return stored;
|
|
4020
|
+
const otherSize = new Error('other size');
|
|
4021
|
+
let found: { bytes: Buffer } | { disk: LostContents['disk'] };
|
|
4022
|
+
try {
|
|
4023
|
+
const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4024
|
+
if (fileStat.size !== version.size) throw otherSize;
|
|
4025
|
+
});
|
|
4026
|
+
try {
|
|
4027
|
+
const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
|
|
4028
|
+
found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
|
|
4029
|
+
} finally {
|
|
4030
|
+
await opened.handle.close();
|
|
4031
|
+
}
|
|
4032
|
+
} catch (err) {
|
|
4033
|
+
found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
|
|
4034
|
+
}
|
|
4035
|
+
const subject = contentsSubject(what, path);
|
|
4036
|
+
const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
|
|
4037
|
+
if ('disk' in found) {
|
|
4038
|
+
const now = storedBytes(store, version.blobHash);
|
|
4039
|
+
if ('bytes' in now) return now;
|
|
4040
|
+
const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
|
|
4041
|
+
if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost);
|
|
4042
|
+
return { lost };
|
|
4043
|
+
}
|
|
4044
|
+
const { bytes } = found;
|
|
4045
|
+
if ('bytes' in storedBytes(store, version.blobHash)) return { bytes };
|
|
4046
|
+
let thrown: string | null = null;
|
|
4047
|
+
try {
|
|
4048
|
+
store.storeBlob(bytes, 'application/octet-stream');
|
|
4049
|
+
} catch (error) {
|
|
4050
|
+
thrown = error instanceof Error ? error.message : String(error);
|
|
4051
|
+
}
|
|
4052
|
+
// Chronicle's storeBlob takes any file already at the hash's path as
|
|
4053
|
+
// stored, so whether the bytes took is what a read of them gives now.
|
|
4054
|
+
const now = storedBytes(store, version.blobHash);
|
|
4055
|
+
if ('bytes' in now) {
|
|
4056
|
+
this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace',
|
|
4057
|
+
`Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
|
|
4058
|
+
} else {
|
|
4059
|
+
const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
|
|
4060
|
+
this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace',
|
|
4061
|
+
`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}. `
|
|
4062
|
+
+ ('unreadable' in now
|
|
4063
|
+
? "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."
|
|
4064
|
+
: 'Each read does this again until the store takes them.'),
|
|
4065
|
+
{ mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
|
|
4066
|
+
}
|
|
4067
|
+
return { bytes };
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void {
|
|
4071
|
+
this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace',
|
|
4072
|
+
`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)}. `
|
|
4073
|
+
+ "Putting back the store's blob file for that hash, from a copy of the store, brings them back.",
|
|
4074
|
+
{ mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
|
|
4078
|
+
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
4079
|
+
if (!absent) return { unreadable: containedRefusal(err, path) };
|
|
4080
|
+
const root = resolve(mount.config.path);
|
|
4081
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
4082
|
+
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
4083
|
+
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
4084
|
+
if (!('reason' in proof)) return 'none';
|
|
4085
|
+
return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
|
|
4089
|
+
mount: MountState,
|
|
4090
|
+
versions: readonly T[],
|
|
4091
|
+
each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void,
|
|
4092
|
+
): Promise<void> {
|
|
4093
|
+
const byHash = new Map<string, T[]>();
|
|
4094
|
+
for (const version of versions) {
|
|
4095
|
+
const sharing = byHash.get(version.hash);
|
|
4096
|
+
if (sharing) sharing.push(version);
|
|
4097
|
+
else byHash.set(version.hash, [version]);
|
|
4098
|
+
}
|
|
4099
|
+
for (const [hash, sharing] of byHash) {
|
|
4100
|
+
let bytes: Buffer | null = null;
|
|
4101
|
+
const lost: Array<[T, LostContents]> = [];
|
|
4102
|
+
for (const version of sharing) {
|
|
4103
|
+
const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
|
|
4104
|
+
if ('bytes' in contents) {
|
|
4105
|
+
bytes = contents.bytes;
|
|
4106
|
+
break;
|
|
4107
|
+
}
|
|
4108
|
+
lost.push([version, contents.lost]);
|
|
4109
|
+
}
|
|
4110
|
+
if (bytes === null) {
|
|
4111
|
+
for (const [version, found] of lost) {
|
|
4112
|
+
this.tellLost(mount.config.name, version.what, hash, found);
|
|
4113
|
+
each(version, { lost: found });
|
|
4114
|
+
}
|
|
4115
|
+
} else {
|
|
4116
|
+
for (const version of sharing) each(version, { bytes });
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4017
4121
|
// ==========================================================================
|
|
4018
4122
|
// Tool Handlers
|
|
4019
4123
|
// ==========================================================================
|
|
@@ -4029,15 +4133,16 @@ export class WorkspaceModule implements Module {
|
|
|
4029
4133
|
// Each number is a whole number of at least its schema's minimum by now
|
|
4030
4134
|
// (inputProblem).
|
|
4031
4135
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
4032
|
-
return { success: false, isError: true, error: '
|
|
4136
|
+
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.') };
|
|
4033
4137
|
}
|
|
4034
4138
|
let file: FileText;
|
|
4035
4139
|
let notes: string[] = [];
|
|
4036
4140
|
if (generatedContent === undefined) {
|
|
4037
4141
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4142
|
+
if (relativePath === '') return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4038
4143
|
input = { ...input, path };
|
|
4039
4144
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
4040
|
-
if ('error' in found) return { success: false, error: found.error, isError: true };
|
|
4145
|
+
if ('error' in found) return { success: false, error: didNothing('read', found.error), isError: true };
|
|
4041
4146
|
file = fileText(found.bytes);
|
|
4042
4147
|
notes = found.notes;
|
|
4043
4148
|
} else {
|
|
@@ -4053,32 +4158,23 @@ export class WorkspaceModule implements Module {
|
|
|
4053
4158
|
}
|
|
4054
4159
|
|
|
4055
4160
|
private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
input = { ...input, path };
|
|
4062
|
-
} catch (err) {
|
|
4063
|
-
return {
|
|
4064
|
-
success: false,
|
|
4065
|
-
error: err instanceof Error ? err.message : String(err),
|
|
4066
|
-
isError: true,
|
|
4067
|
-
};
|
|
4068
|
-
}
|
|
4069
|
-
if (!relativePath) {
|
|
4070
|
-
return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
|
|
4071
|
-
}
|
|
4161
|
+
const given = input.path;
|
|
4162
|
+
const refused = (body: string): ToolResult => ({ success: false, isError: true, error: didNothing('read_image', body) });
|
|
4163
|
+
const { mount, relativePath, path } = await this.parseFilePath(given);
|
|
4164
|
+
if (!relativePath) return refused(namesRoot(given, mount.config.name));
|
|
4165
|
+
input = { ...input, path };
|
|
4072
4166
|
|
|
4073
4167
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4074
4168
|
let treeError: WorkspaceImageReadError | null = null;
|
|
4169
|
+
let lost: LostContents | null = null;
|
|
4075
4170
|
|
|
4076
4171
|
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
4077
4172
|
// mount holds none, so its image is disk's.
|
|
4078
4173
|
if (!mount.direct) {
|
|
4079
4174
|
try {
|
|
4080
|
-
const image = this.
|
|
4081
|
-
if (image)
|
|
4175
|
+
const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
|
|
4176
|
+
if (image && 'lost' in image) lost = image.lost;
|
|
4177
|
+
else if (image) return imageResult(input.path, image);
|
|
4082
4178
|
} catch (err) {
|
|
4083
4179
|
if (err instanceof WorkspaceImageReadError) {
|
|
4084
4180
|
treeError = err;
|
|
@@ -4089,31 +4185,37 @@ export class WorkspaceModule implements Module {
|
|
|
4089
4185
|
}
|
|
4090
4186
|
|
|
4091
4187
|
try {
|
|
4092
|
-
|
|
4188
|
+
const note = lost === null ? undefined
|
|
4189
|
+
: `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
|
|
4190
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
|
|
4093
4191
|
} catch (err) {
|
|
4094
|
-
if (err instanceof WorkspaceImageReadError)
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
return
|
|
4192
|
+
if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError)) throw err;
|
|
4193
|
+
const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
|
|
4194
|
+
if (lost) {
|
|
4195
|
+
const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
|
|
4196
|
+
return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
|
|
4197
|
+
: `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
|
|
4099
4198
|
}
|
|
4100
|
-
|
|
4199
|
+
if (noFile && treeError) return refused(treeError.message);
|
|
4200
|
+
return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
|
|
4101
4201
|
}
|
|
4102
4202
|
}
|
|
4103
4203
|
|
|
4104
4204
|
private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
4105
4205
|
if (LONE_SURROGATE.test(input.content)) {
|
|
4106
|
-
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
4206
|
+
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.' };
|
|
4107
4207
|
}
|
|
4108
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4208
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
|
|
4209
|
+
if (relativePath === '') return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4109
4210
|
input = { ...input, path };
|
|
4110
|
-
const refusal = writeRefusal(mount);
|
|
4111
|
-
if (refusal) return { success: false, error: refusal, isError: true };
|
|
4211
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
4212
|
+
if (refusal) return { success: false, error: didNothing('write', refusal), isError: true };
|
|
4112
4213
|
|
|
4113
4214
|
const store = this.getStore();
|
|
4114
4215
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4115
|
-
|
|
4116
|
-
|
|
4216
|
+
const bytes = Buffer.byteLength(input.content);
|
|
4217
|
+
if (bytes > maxSize) {
|
|
4218
|
+
return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
|
|
4117
4219
|
}
|
|
4118
4220
|
|
|
4119
4221
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
@@ -4122,13 +4224,14 @@ export class WorkspaceModule implements Module {
|
|
|
4122
4224
|
if (mount.direct) {
|
|
4123
4225
|
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
4124
4226
|
if ('error' in written) return { success: false, isError: true, error: written.error };
|
|
4125
|
-
return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
|
|
4227
|
+
return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
4126
4228
|
}
|
|
4127
4229
|
let committed = false;
|
|
4128
4230
|
let refused: string | null;
|
|
4129
4231
|
let modeKept: string | null;
|
|
4232
|
+
let disk: DiskState | null;
|
|
4130
4233
|
try {
|
|
4131
|
-
({ refused, modeKept } = await this.withMount(mount, async () => {
|
|
4234
|
+
({ refused, modeKept, disk } = await this.withMount(mount, async () => {
|
|
4132
4235
|
await this.settleBeforeMutation(mount, relativePath);
|
|
4133
4236
|
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
4134
4237
|
// An overwrite keeps the file's mode, as an edit does.
|
|
@@ -4137,14 +4240,14 @@ export class WorkspaceModule implements Module {
|
|
|
4137
4240
|
() => { committed = true; });
|
|
4138
4241
|
}));
|
|
4139
4242
|
} catch (error) {
|
|
4140
|
-
if (!committed) throw error;
|
|
4243
|
+
if (!committed) throw notStored(error);
|
|
4141
4244
|
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
4142
4245
|
? { storeFailed: error.message, pushes: error.pushes }
|
|
4143
|
-
: { threw: error instanceof Error ? error.message : String(error) });
|
|
4246
|
+
: { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
4144
4247
|
}
|
|
4145
4248
|
if (refused !== null) return storedButUnfinished('write', input.path, { refused });
|
|
4146
4249
|
|
|
4147
|
-
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
4250
|
+
return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
|
|
4148
4251
|
}
|
|
4149
4252
|
|
|
4150
4253
|
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
|
|
@@ -4191,12 +4294,14 @@ export class WorkspaceModule implements Module {
|
|
|
4191
4294
|
* stored where its record landed, and that it can't tell where they
|
|
4192
4295
|
* couldn't be read back.
|
|
4193
4296
|
* Under the mount's turn, one pass settles every path with disk, by the
|
|
4194
|
-
* rule a listing uses, so the edits apply to what disk now holds.
|
|
4195
|
-
*
|
|
4196
|
-
*
|
|
4197
|
-
*
|
|
4198
|
-
*
|
|
4199
|
-
* of the
|
|
4297
|
+
* rule a listing uses, so the edits apply to what disk now holds. Each
|
|
4298
|
+
* file's stored contents are then read; where the store has lost some,
|
|
4299
|
+
* they're restored from disk's matching copy (contentsOf) and the pass runs
|
|
4300
|
+
* again, so the plan reads a pass taken after every await. From there
|
|
4301
|
+
* nothing awaits before the commit: every edit is planned and every entry
|
|
4302
|
+
* set (commitToolChanges) with no turn of the event loop between, so no
|
|
4303
|
+
* pass, branch change or other call moves the tree between a file's read
|
|
4304
|
+
* and its set. Disk then takes what it can of the stored change.
|
|
4200
4305
|
*
|
|
4201
4306
|
* The answer is held to `maxChars`, the length the call's result may have
|
|
4202
4307
|
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
@@ -4237,7 +4342,7 @@ export class WorkspaceModule implements Module {
|
|
|
4237
4342
|
}
|
|
4238
4343
|
for (const field of ['oldString', 'newString'] as const) {
|
|
4239
4344
|
if (LONE_SURROGATE.test(edit[field])) {
|
|
4240
|
-
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
|
|
4345
|
+
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.` });
|
|
4241
4346
|
}
|
|
4242
4347
|
}
|
|
4243
4348
|
if (problems.length === found) specs.set(index, spec);
|
|
@@ -4291,7 +4396,26 @@ export class WorkspaceModule implements Module {
|
|
|
4291
4396
|
return await this.withMount(mount, async (): Promise<ToolResult> => {
|
|
4292
4397
|
// As every file tool's pass does, it names the paths it acts on, so a
|
|
4293
4398
|
// file the mount passes over by design is observed (see PassOptions.named).
|
|
4294
|
-
|
|
4399
|
+
let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4400
|
+
let ops = pass.ops;
|
|
4401
|
+
const contents = new Map<string, Buffer>();
|
|
4402
|
+
const lost = new Map<string, { blobHash: string; lost: LostContents }>();
|
|
4403
|
+
const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = [];
|
|
4404
|
+
for (const target of targets.values()) {
|
|
4405
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4406
|
+
if (!entry || contents.has(entry.blobHash)) continue;
|
|
4407
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
4408
|
+
if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes);
|
|
4409
|
+
else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
|
|
4410
|
+
}
|
|
4411
|
+
if (toRecover.length > 0) {
|
|
4412
|
+
await this.restoreEach(mount, toRecover, (version, found) => {
|
|
4413
|
+
if ('bytes' in found) contents.set(version.hash, found.bytes);
|
|
4414
|
+
else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
|
|
4415
|
+
});
|
|
4416
|
+
pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4417
|
+
ops = [...ops, ...pass.ops];
|
|
4418
|
+
}
|
|
4295
4419
|
// A pass that decided nothing checked no file's disk copy.
|
|
4296
4420
|
const undecided = undecidedOf(pass);
|
|
4297
4421
|
// Nothing below awaits until the commit's push.
|
|
@@ -4310,7 +4434,7 @@ export class WorkspaceModule implements Module {
|
|
|
4310
4434
|
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
4311
4435
|
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
4312
4436
|
// then write at the mode disk has, so it leaves nothing to say.
|
|
4313
|
-
const took = (op: TreeOp) =>
|
|
4437
|
+
const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
4314
4438
|
// Which copy the edits used, when it isn't the one the workspace
|
|
4315
4439
|
// last agreed on with disk: disk's newer text, or the workspace's
|
|
4316
4440
|
// copy because disk's couldn't be read or disk has none. A file the
|
|
@@ -4375,18 +4499,28 @@ export class WorkspaceModule implements Module {
|
|
|
4375
4499
|
// A pass also drops a copy disk made binary or too large, which
|
|
4376
4500
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
4377
4501
|
const text = took('deleted') && report?.state !== 'disk-only'
|
|
4378
|
-
?
|
|
4379
|
-
: this.notInWorkspace(target.path, report);
|
|
4380
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole:
|
|
4502
|
+
? deletedOnDisk(target.path)
|
|
4503
|
+
: this.notInWorkspace(target.path, report, mount);
|
|
4504
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
|
|
4381
4505
|
continue;
|
|
4382
4506
|
}
|
|
4383
|
-
|
|
4384
|
-
const
|
|
4385
|
-
if (!
|
|
4386
|
-
|
|
4507
|
+
const known = contents.get(entry.blobHash);
|
|
4508
|
+
const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
|
|
4509
|
+
if (!('bytes' in stored)) {
|
|
4510
|
+
const found = lost.get(target.relativePath);
|
|
4511
|
+
problems.push({
|
|
4512
|
+
kind: 'file',
|
|
4513
|
+
edits: target.edits,
|
|
4514
|
+
path: target.path,
|
|
4515
|
+
text: found?.blobHash === entry.blobHash
|
|
4516
|
+
? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
|
|
4517
|
+
: `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.`,
|
|
4518
|
+
whole: true,
|
|
4519
|
+
});
|
|
4387
4520
|
continue;
|
|
4388
4521
|
}
|
|
4389
|
-
|
|
4522
|
+
notes.push(...lookedIn);
|
|
4523
|
+
const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
|
|
4390
4524
|
if (plan === null) continue;
|
|
4391
4525
|
plans.push({
|
|
4392
4526
|
target,
|
|
@@ -4400,7 +4534,7 @@ export class WorkspaceModule implements Module {
|
|
|
4400
4534
|
}
|
|
4401
4535
|
if (problems.length) return refuse(problems, notes);
|
|
4402
4536
|
|
|
4403
|
-
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4537
|
+
const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4404
4538
|
path: target.relativePath,
|
|
4405
4539
|
change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4406
4540
|
})), () => { committed = true; });
|
|
@@ -4410,19 +4544,19 @@ export class WorkspaceModule implements Module {
|
|
|
4410
4544
|
plan,
|
|
4411
4545
|
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
|
|
4412
4546
|
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4413
|
-
})), given.length, maxChars);
|
|
4547
|
+
})), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath)!)));
|
|
4414
4548
|
});
|
|
4415
4549
|
} catch (error) {
|
|
4416
4550
|
const message = error instanceof Error ? error.message : String(error);
|
|
4417
4551
|
if (committed) {
|
|
4418
4552
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
4419
4553
|
? { storeFailed: message, pushes: error.pushes }
|
|
4420
|
-
: { threw: message }, maxChars);
|
|
4554
|
+
: { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
|
|
4421
4555
|
}
|
|
4422
4556
|
// A set that threw and couldn't be read back may have stored the edits,
|
|
4423
4557
|
// and a second call could then apply them twice.
|
|
4424
4558
|
if (error instanceof CommitUnknownError) {
|
|
4425
|
-
const [set, read] = [
|
|
4559
|
+
const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
|
|
4426
4560
|
return {
|
|
4427
4561
|
success: false,
|
|
4428
4562
|
isError: true,
|
|
@@ -4435,9 +4569,8 @@ export class WorkspaceModule implements Module {
|
|
|
4435
4569
|
}
|
|
4436
4570
|
// The commit sets every entry in one record or none, and a set that
|
|
4437
4571
|
// threw was read back, so nothing is stored; or every change was one
|
|
4438
|
-
// the tree already held
|
|
4439
|
-
|
|
4440
|
-
throw error;
|
|
4572
|
+
// the tree already held (AlreadyHeld).
|
|
4573
|
+
throw notStored(error);
|
|
4441
4574
|
}
|
|
4442
4575
|
}
|
|
4443
4576
|
|
|
@@ -4455,7 +4588,7 @@ export class WorkspaceModule implements Module {
|
|
|
4455
4588
|
path: string,
|
|
4456
4589
|
callerAgentName: string | undefined,
|
|
4457
4590
|
): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
|
|
4458
|
-
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole:
|
|
4591
|
+
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: false });
|
|
4459
4592
|
try {
|
|
4460
4593
|
// In the one spelling every file tool reads, as disk spells it, so two
|
|
4461
4594
|
// spellings of a file are one entry and, in one call, one plan
|
|
@@ -4464,14 +4597,16 @@ export class WorkspaceModule implements Module {
|
|
|
4464
4597
|
const where = await this.parseFilePath(path);
|
|
4465
4598
|
// One that names the mount's root, its bare name included, names no
|
|
4466
4599
|
// file.
|
|
4467
|
-
if (where.relativePath === '') return unusable(
|
|
4468
|
-
const refusal = writeRefusal(where.mount);
|
|
4600
|
+
if (where.relativePath === '') return unusable(namesRoot(path, where.mount.config.name));
|
|
4601
|
+
const refusal = writeRefusal(where.mount, this.writableNames());
|
|
4469
4602
|
if (refusal) return unusable(refusal);
|
|
4470
4603
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4471
|
-
if (generated) return unusable(
|
|
4604
|
+
if (generated) return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
|
|
4472
4605
|
return where;
|
|
4473
4606
|
} catch (error) {
|
|
4474
|
-
|
|
4607
|
+
if (error instanceof Refusal) return unusable(error.message);
|
|
4608
|
+
if (error instanceof WorkspaceReadError) return unusable(readRefusal(error, path));
|
|
4609
|
+
throw notStored(error);
|
|
4475
4610
|
}
|
|
4476
4611
|
}
|
|
4477
4612
|
|
|
@@ -4480,10 +4615,10 @@ export class WorkspaceModule implements Module {
|
|
|
4480
4615
|
* and the agent's own is read-only, since it shows the agent's tool
|
|
4481
4616
|
* presentation, which set_tool_visibility and set_tool_description change.
|
|
4482
4617
|
*/
|
|
4483
|
-
private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4618
|
+
private generatedFileRefusal(path: string, generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4484
4619
|
return callerAgentName !== generated.agentName
|
|
4485
|
-
?
|
|
4486
|
-
:
|
|
4620
|
+
? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
|
|
4621
|
+
: `${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.`;
|
|
4487
4622
|
}
|
|
4488
4623
|
|
|
4489
4624
|
/**
|
|
@@ -4507,12 +4642,13 @@ export class WorkspaceModule implements Module {
|
|
|
4507
4642
|
conflicts: Map<EditTarget, string>,
|
|
4508
4643
|
outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
4509
4644
|
maxChars: number,
|
|
4645
|
+
reopen = false,
|
|
4510
4646
|
): ToolResult {
|
|
4511
4647
|
const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4512
4648
|
if (tool === 'edit') {
|
|
4513
4649
|
const [target] = targets;
|
|
4514
4650
|
const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
|
|
4515
|
-
return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
|
|
4651
|
+
return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null, reopen);
|
|
4516
4652
|
}
|
|
4517
4653
|
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.";
|
|
4518
4654
|
// The head is never cut, so what stands for the whole call goes in it,
|
|
@@ -4533,9 +4669,10 @@ export class WorkspaceModule implements Module {
|
|
|
4533
4669
|
}
|
|
4534
4670
|
} else if ('storeFailed' in outcome) {
|
|
4535
4671
|
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4536
|
-
+ (outcome.pushes ?
|
|
4672
|
+
+ (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
4673
|
+
+ (reopen ? ` ${AWAITS_REOPEN}` : '');
|
|
4537
4674
|
} else {
|
|
4538
|
-
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}
|
|
4675
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
|
|
4539
4676
|
}
|
|
4540
4677
|
const kept = 'modeKept' in outcome
|
|
4541
4678
|
? targets.flatMap((target) => {
|
|
@@ -4577,7 +4714,7 @@ export class WorkspaceModule implements Module {
|
|
|
4577
4714
|
try {
|
|
4578
4715
|
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4579
4716
|
} catch (error) {
|
|
4580
|
-
return { error:
|
|
4717
|
+
return { error: directDidNothing('write', path, error) };
|
|
4581
4718
|
}
|
|
4582
4719
|
try {
|
|
4583
4720
|
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
@@ -4591,7 +4728,7 @@ export class WorkspaceModule implements Module {
|
|
|
4591
4728
|
renameInto(staged);
|
|
4592
4729
|
} catch (error) {
|
|
4593
4730
|
await discard(staged);
|
|
4594
|
-
return { error:
|
|
4731
|
+
return { error: directDidNothing('write', path, error) };
|
|
4595
4732
|
}
|
|
4596
4733
|
this.recordDirectWrite(mount, agentName);
|
|
4597
4734
|
return { note: linksNote(path, staged, 'write') };
|
|
@@ -4668,7 +4805,7 @@ export class WorkspaceModule implements Module {
|
|
|
4668
4805
|
changedMeanwhile.add(target);
|
|
4669
4806
|
return 'changed';
|
|
4670
4807
|
}
|
|
4671
|
-
if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole:
|
|
4808
|
+
if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
|
|
4672
4809
|
else read.set(target, got);
|
|
4673
4810
|
}
|
|
4674
4811
|
// Two paths that reach one file, through a link this mount follows, or
|
|
@@ -4733,7 +4870,8 @@ export class WorkspaceModule implements Module {
|
|
|
4733
4870
|
edits: target.edits,
|
|
4734
4871
|
path: target.path,
|
|
4735
4872
|
text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
|
|
4736
|
-
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4873
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4874
|
+
+ (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
|
|
4737
4875
|
whole: true,
|
|
4738
4876
|
}]);
|
|
4739
4877
|
for (const { target, plan } of plans) {
|
|
@@ -4798,7 +4936,7 @@ export class WorkspaceModule implements Module {
|
|
|
4798
4936
|
plan: plans.find((p) => p.target === target)!.plan,
|
|
4799
4937
|
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
|
|
4800
4938
|
modeKept: null,
|
|
4801
|
-
})), editCount, maxChars);
|
|
4939
|
+
})), editCount, maxChars, 'done');
|
|
4802
4940
|
}
|
|
4803
4941
|
|
|
4804
4942
|
/**
|
|
@@ -4811,7 +4949,8 @@ export class WorkspaceModule implements Module {
|
|
|
4811
4949
|
private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
|
|
4812
4950
|
// The failed file is named among the outcomes, so the head stays as
|
|
4813
4951
|
// long as its reason, whatever the paths' lengths.
|
|
4814
|
-
const
|
|
4952
|
+
const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
|
|
4953
|
+
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}.` : ''} `
|
|
4815
4954
|
+ "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:";
|
|
4816
4955
|
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'}`);
|
|
4817
4956
|
const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
|
|
@@ -4833,26 +4972,29 @@ export class WorkspaceModule implements Module {
|
|
|
4833
4972
|
path: string,
|
|
4834
4973
|
): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
|
|
4835
4974
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4836
|
-
const over = `${path} is
|
|
4975
|
+
const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
|
|
4837
4976
|
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
4838
4977
|
try {
|
|
4839
4978
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4840
|
-
if (fileStat.size > maxSize) throw new
|
|
4979
|
+
if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
4841
4980
|
});
|
|
4842
4981
|
} catch (err) {
|
|
4843
4982
|
if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
|
|
4844
|
-
return { error:
|
|
4983
|
+
if (err instanceof Refusal) return { error: err.message };
|
|
4984
|
+
if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
|
|
4985
|
+
throw err;
|
|
4845
4986
|
}
|
|
4846
4987
|
try {
|
|
4847
4988
|
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4848
4989
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4849
|
-
if (more) return { error: over };
|
|
4850
|
-
if (looksBinary(bytes))
|
|
4990
|
+
if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4991
|
+
if (looksBinary(bytes)) {
|
|
4992
|
+
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.` };
|
|
4993
|
+
}
|
|
4851
4994
|
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
|
|
4852
4995
|
return { bytes, identity: before };
|
|
4853
4996
|
} catch (err) {
|
|
4854
|
-
|
|
4855
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
4997
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4856
4998
|
} finally {
|
|
4857
4999
|
await opened.handle.close();
|
|
4858
5000
|
}
|
|
@@ -4880,11 +5022,9 @@ export class WorkspaceModule implements Module {
|
|
|
4880
5022
|
} catch (err) {
|
|
4881
5023
|
const code = errnoOf(err);
|
|
4882
5024
|
const why = code === 'ENOENT' || code === 'ENOTDIR'
|
|
4883
|
-
? `the mount root
|
|
4884
|
-
:
|
|
4885
|
-
|
|
4886
|
-
: `the mount root can't be read (${code ?? String(err)})`;
|
|
4887
|
-
return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
|
|
5025
|
+
? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
|
|
5026
|
+
: `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
|
|
5027
|
+
return { success: false, isError: true, error: didNothing('delete', why, path) };
|
|
4888
5028
|
}
|
|
4889
5029
|
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
4890
5030
|
const { rootReal } = disk;
|
|
@@ -4893,10 +5033,11 @@ export class WorkspaceModule implements Module {
|
|
|
4893
5033
|
await opened.handle.close();
|
|
4894
5034
|
} catch (err) {
|
|
4895
5035
|
// The open's own words for a replaced root speak of reading.
|
|
4896
|
-
if (err instanceof
|
|
4897
|
-
return { success: false, isError: true, error:
|
|
5036
|
+
if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
|
|
5037
|
+
return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
|
|
4898
5038
|
}
|
|
4899
|
-
return { success: false, isError: true, error:
|
|
5039
|
+
if (err instanceof WorkspaceReadError) return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
|
|
5040
|
+
throw err;
|
|
4900
5041
|
}
|
|
4901
5042
|
// The unlink is awaited, unlike a write's rename, so where the head
|
|
4902
5043
|
// was just before it says whether the head moved while it ran.
|
|
@@ -4908,12 +5049,12 @@ export class WorkspaceModule implements Module {
|
|
|
4908
5049
|
// whether the file is gone, so it's taken as the agent's write too.
|
|
4909
5050
|
if (error instanceof EffectFailed && error.touched) {
|
|
4910
5051
|
this.recordDirectWrite(mount, agentName, since);
|
|
4911
|
-
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.` };
|
|
5052
|
+
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.` };
|
|
4912
5053
|
}
|
|
4913
|
-
return { success: false, isError: true, error:
|
|
5054
|
+
return { success: false, isError: true, error: directDidNothing('delete', path, error) };
|
|
4914
5055
|
}
|
|
4915
5056
|
this.recordDirectWrite(mount, agentName, since);
|
|
4916
|
-
return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
5057
|
+
return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
|
|
4917
5058
|
});
|
|
4918
5059
|
}
|
|
4919
5060
|
|
|
@@ -5052,10 +5193,11 @@ export class WorkspaceModule implements Module {
|
|
|
5052
5193
|
}
|
|
5053
5194
|
|
|
5054
5195
|
private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
5055
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
5196
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
|
|
5197
|
+
if (relativePath === '') return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
|
|
5056
5198
|
input = { ...input, path };
|
|
5057
|
-
const refusal = writeRefusal(mount);
|
|
5058
|
-
if (refusal) return { success: false, error: refusal, isError: true };
|
|
5199
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
5200
|
+
if (refusal) return { success: false, error: didNothing('delete', refusal), isError: true };
|
|
5059
5201
|
if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
5060
5202
|
|
|
5061
5203
|
const store = this.getStore();
|
|
@@ -5066,11 +5208,11 @@ export class WorkspaceModule implements Module {
|
|
|
5066
5208
|
await this.settleBeforeMutation(mount, relativePath);
|
|
5067
5209
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
5068
5210
|
if (!entry) {
|
|
5069
|
-
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
|
|
5070
|
-
return { success: false, error, isError: true };
|
|
5211
|
+
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
|
|
5212
|
+
return { success: false, error: didNothing('delete', error), isError: true };
|
|
5071
5213
|
}
|
|
5072
5214
|
|
|
5073
|
-
const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
|
|
5215
|
+
const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
|
|
5074
5216
|
() => { committed = true; });
|
|
5075
5217
|
if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
|
|
5076
5218
|
|
|
@@ -5078,20 +5220,20 @@ export class WorkspaceModule implements Module {
|
|
|
5078
5220
|
// withholds the result (agent-framework #277).
|
|
5079
5221
|
return {
|
|
5080
5222
|
success: true,
|
|
5081
|
-
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
5082
|
-
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
5223
|
+
data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
|
|
5224
|
+
keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
|
|
5083
5225
|
};
|
|
5084
5226
|
});
|
|
5085
5227
|
} catch (error) {
|
|
5086
|
-
if (!committed) throw error;
|
|
5228
|
+
if (!committed) throw notStored(error);
|
|
5087
5229
|
if (error instanceof StoredThenFailed) {
|
|
5088
5230
|
// Its note, as the success answer's: a deletion that cleared a
|
|
5089
5231
|
// conflict naming a disk file unlinked nothing.
|
|
5090
5232
|
const note = error.notes.get(relativePath);
|
|
5091
5233
|
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
|
|
5092
|
-
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}
|
|
5234
|
+
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
|
|
5093
5235
|
}
|
|
5094
|
-
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
|
|
5236
|
+
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
5095
5237
|
}
|
|
5096
5238
|
}
|
|
5097
5239
|
|
|
@@ -5117,7 +5259,7 @@ export class WorkspaceModule implements Module {
|
|
|
5117
5259
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5118
5260
|
// is refused, as glob refuses it, and shown as given.
|
|
5119
5261
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5120
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5262
|
+
return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5121
5263
|
}
|
|
5122
5264
|
input = { ...input, path };
|
|
5123
5265
|
const recursive = input.recursive === true;
|
|
@@ -5127,6 +5269,9 @@ export class WorkspaceModule implements Module {
|
|
|
5127
5269
|
// shows disk's.
|
|
5128
5270
|
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
5129
5271
|
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
5272
|
+
const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
|
|
5273
|
+
const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
|
|
5274
|
+
const noted = note !== null ? { note } : {};
|
|
5130
5275
|
if (recursive) {
|
|
5131
5276
|
return {
|
|
5132
5277
|
success: true,
|
|
@@ -5135,6 +5280,7 @@ export class WorkspaceModule implements Module {
|
|
|
5135
5280
|
entries: listing.files,
|
|
5136
5281
|
count: listing.files.length,
|
|
5137
5282
|
...incomplete,
|
|
5283
|
+
...noted,
|
|
5138
5284
|
},
|
|
5139
5285
|
};
|
|
5140
5286
|
}
|
|
@@ -5152,10 +5298,34 @@ export class WorkspaceModule implements Module {
|
|
|
5152
5298
|
entries: children,
|
|
5153
5299
|
count: children.length,
|
|
5154
5300
|
...incomplete,
|
|
5301
|
+
...noted,
|
|
5155
5302
|
},
|
|
5156
5303
|
};
|
|
5157
5304
|
}
|
|
5158
5305
|
|
|
5306
|
+
private async emptyScopeNote(mount: MountState, relativePath: string, path: string): Promise<string | null> {
|
|
5307
|
+
const shown = JSON.stringify(path);
|
|
5308
|
+
const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
|
|
5309
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
5310
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
5311
|
+
if (seen.kind === 'file') return isFile;
|
|
5312
|
+
if (seen.kind !== 'absent') return null;
|
|
5313
|
+
if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) return isFile;
|
|
5314
|
+
return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
|
|
5315
|
+
}
|
|
5316
|
+
|
|
5317
|
+
private async absentOnDisk(mount: MountState, relativePath: string): Promise<boolean> {
|
|
5318
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
5319
|
+
return (await observePath(view, rootReal, relativePath)).kind === 'absent';
|
|
5320
|
+
}
|
|
5321
|
+
|
|
5322
|
+
private holdsAnythingAt(mount: MountState, relativePath: string): boolean {
|
|
5323
|
+
if (mount.direct) return false;
|
|
5324
|
+
const store = this.getStore();
|
|
5325
|
+
const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree): tree is string => tree !== undefined);
|
|
5326
|
+
return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5159
5329
|
/**
|
|
5160
5330
|
* What a listing shows of a directory scope, from where the mount keeps
|
|
5161
5331
|
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
@@ -5232,7 +5402,8 @@ export class WorkspaceModule implements Module {
|
|
|
5232
5402
|
.filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
|
|
5233
5403
|
.map((region) => ({
|
|
5234
5404
|
path: region.path,
|
|
5235
|
-
reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
|
|
5405
|
+
reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
|
|
5406
|
+
: explained(region.reason, region.errno !== undefined ? { errno: region.errno } : {}, 'list'),
|
|
5236
5407
|
}));
|
|
5237
5408
|
return {
|
|
5238
5409
|
view,
|
|
@@ -5259,7 +5430,7 @@ export class WorkspaceModule implements Module {
|
|
|
5259
5430
|
if (input.path) {
|
|
5260
5431
|
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
5261
5432
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5262
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5433
|
+
return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5263
5434
|
}
|
|
5264
5435
|
}
|
|
5265
5436
|
|
|
@@ -5281,7 +5452,7 @@ export class WorkspaceModule implements Module {
|
|
|
5281
5452
|
if (prefixed) {
|
|
5282
5453
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
5283
5454
|
if (dir === null) {
|
|
5284
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5455
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5285
5456
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5286
5457
|
continue;
|
|
5287
5458
|
}
|
|
@@ -5316,8 +5487,16 @@ export class WorkspaceModule implements Module {
|
|
|
5316
5487
|
try {
|
|
5317
5488
|
regex = new RegExp(input.pattern);
|
|
5318
5489
|
} catch (e) {
|
|
5319
|
-
//
|
|
5320
|
-
|
|
5490
|
+
// V8's message ends with the parser's reason, after the pattern: "Invalid
|
|
5491
|
+
// regular expression: /foo(/: Unterminated group".
|
|
5492
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5493
|
+
const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
|
|
5494
|
+
return {
|
|
5495
|
+
success: false,
|
|
5496
|
+
isError: true,
|
|
5497
|
+
error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
|
|
5498
|
+
+ 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
|
|
5499
|
+
};
|
|
5321
5500
|
}
|
|
5322
5501
|
|
|
5323
5502
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
@@ -5345,7 +5524,7 @@ export class WorkspaceModule implements Module {
|
|
|
5345
5524
|
if (input.path) {
|
|
5346
5525
|
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
5347
5526
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5348
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5527
|
+
return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5349
5528
|
}
|
|
5350
5529
|
}
|
|
5351
5530
|
|
|
@@ -5369,11 +5548,9 @@ export class WorkspaceModule implements Module {
|
|
|
5369
5548
|
// read doesn't show it.
|
|
5370
5549
|
const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
|
|
5371
5550
|
const bomFiles = new Map<string, GrepText['version']>();
|
|
5372
|
-
const search = (text: GrepText): Searched => {
|
|
5551
|
+
const search = (text: GrepText): Searched & { bom: boolean } => {
|
|
5373
5552
|
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5374
5553
|
const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
|
|
5375
|
-
if (lineEndings !== undefined && !crlfFiles.has(text.file)) crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
|
|
5376
|
-
if (bom && !bomFiles.has(text.file)) bomFiles.set(text.file, text.version);
|
|
5377
5554
|
const found: GrepMatch[] = [];
|
|
5378
5555
|
for (let i = 0; i < lines.length; i++) {
|
|
5379
5556
|
if (regex.test(lines[i])) {
|
|
@@ -5386,7 +5563,7 @@ export class WorkspaceModule implements Module {
|
|
|
5386
5563
|
found.push(match);
|
|
5387
5564
|
}
|
|
5388
5565
|
}
|
|
5389
|
-
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
|
|
5566
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
|
|
5390
5567
|
};
|
|
5391
5568
|
|
|
5392
5569
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
@@ -5394,7 +5571,7 @@ export class WorkspaceModule implements Module {
|
|
|
5394
5571
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
5395
5572
|
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
5396
5573
|
// found where its root lies now (movedNesting).
|
|
5397
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5574
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5398
5575
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5399
5576
|
continue;
|
|
5400
5577
|
}
|
|
@@ -5412,16 +5589,21 @@ export class WorkspaceModule implements Module {
|
|
|
5412
5589
|
return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
|
|
5413
5590
|
}
|
|
5414
5591
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
5415
|
-
const searched = (text: GrepText): void => {
|
|
5416
|
-
const hits = search(text);
|
|
5417
|
-
|
|
5592
|
+
const searched = (text: GrepText): (() => void) => {
|
|
5593
|
+
const { bom, ...hits } = search(text);
|
|
5594
|
+
const { file, state, version } = text;
|
|
5595
|
+
return () => {
|
|
5596
|
+
if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version });
|
|
5597
|
+
if (bom && !bomFiles.has(file)) bomFiles.set(file, version);
|
|
5598
|
+
if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
|
|
5599
|
+
};
|
|
5418
5600
|
};
|
|
5419
5601
|
const kept = prefixed
|
|
5420
5602
|
? (path: string): boolean => prefixed.regex.test(path)
|
|
5421
5603
|
: (path: string): boolean => fileGlob === null || fileGlob.test(path)
|
|
5422
5604
|
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
5423
5605
|
const found = mount.direct
|
|
5424
|
-
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
5606
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
|
|
5425
5607
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
5426
5608
|
skipped.push(...found.skipped);
|
|
5427
5609
|
incomplete.push(...found.incomplete);
|
|
@@ -5471,8 +5653,9 @@ export class WorkspaceModule implements Module {
|
|
|
5471
5653
|
/**
|
|
5472
5654
|
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
5473
5655
|
* single file `relativePath` names, those `keep` lets through, each handed
|
|
5474
|
-
* to `
|
|
5475
|
-
* workspace copy, labelled with its state, and a
|
|
5656
|
+
* to `search`, whose answers are recorded in path order: a pass over the
|
|
5657
|
+
* scope, then each path's workspace copy, labelled with its state, and a
|
|
5658
|
+
* conflict's disk side.
|
|
5476
5659
|
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
5477
5660
|
* `incomplete`, each mount-prefixed.
|
|
5478
5661
|
*/
|
|
@@ -5481,7 +5664,7 @@ export class WorkspaceModule implements Module {
|
|
|
5481
5664
|
relativePath: string,
|
|
5482
5665
|
start: string,
|
|
5483
5666
|
keep: (path: string) => boolean,
|
|
5484
|
-
|
|
5667
|
+
search: (text: GrepText) => () => void,
|
|
5485
5668
|
): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
|
|
5486
5669
|
const store = this.getStore();
|
|
5487
5670
|
const skipped: GrepSkip[] = [];
|
|
@@ -5534,16 +5717,18 @@ export class WorkspaceModule implements Module {
|
|
|
5534
5717
|
}
|
|
5535
5718
|
|
|
5536
5719
|
const intents = this.intents.get(mount.config.name)!;
|
|
5720
|
+
type Lost = Omit<GrepText, 'bytes'> & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' };
|
|
5721
|
+
const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = [];
|
|
5537
5722
|
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
5538
5723
|
if (!keep(report.path)) continue;
|
|
5539
5724
|
const file = `${mount.config.name}/${report.path}`;
|
|
5540
5725
|
if (report.state === 'disk-only') {
|
|
5541
|
-
|
|
5726
|
+
found.push({ skip: {
|
|
5542
5727
|
file,
|
|
5543
5728
|
reason: 'disk-only: binary or over the size limit, never stored',
|
|
5544
5729
|
...(report.size !== undefined ? { size: report.size } : {}),
|
|
5545
5730
|
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
5546
|
-
});
|
|
5731
|
+
} });
|
|
5547
5732
|
continue;
|
|
5548
5733
|
}
|
|
5549
5734
|
// A path in another mount's directory is that mount's: grep gives
|
|
@@ -5552,16 +5737,16 @@ export class WorkspaceModule implements Module {
|
|
|
5552
5737
|
if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
|
|
5553
5738
|
const refusal = refused.get(report.path);
|
|
5554
5739
|
if (refusal !== undefined) {
|
|
5555
|
-
|
|
5740
|
+
found.push({ skip: { file, reason: refusal } });
|
|
5556
5741
|
continue;
|
|
5557
5742
|
}
|
|
5558
5743
|
// The workspace's version, labelled with the path's state.
|
|
5559
5744
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5745
|
+
if (entry) {
|
|
5746
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
5747
|
+
found.push('bytes' in stored
|
|
5748
|
+
? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
|
|
5749
|
+
: { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
|
|
5565
5750
|
}
|
|
5566
5751
|
if (report.state !== 'conflict') continue;
|
|
5567
5752
|
// A conflict's disk side. The version recorded with the conflict is
|
|
@@ -5571,21 +5756,37 @@ export class WorkspaceModule implements Module {
|
|
|
5571
5756
|
// disk version was never stored either.
|
|
5572
5757
|
const disk = intents.get(report.path)?.conflict?.disk;
|
|
5573
5758
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5759
|
+
if (disk?.stored) {
|
|
5760
|
+
const version = changed ? 'recorded-disk' : 'conflicting-disk';
|
|
5761
|
+
const stored = storedBytes(store, disk.stored);
|
|
5762
|
+
found.push('bytes' in stored
|
|
5763
|
+
? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
|
|
5764
|
+
: { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
|
|
5580
5765
|
}
|
|
5581
5766
|
if (changed) {
|
|
5582
5767
|
if (report.diskNow === 'file') {
|
|
5583
|
-
|
|
5768
|
+
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' } });
|
|
5584
5769
|
}
|
|
5585
5770
|
} else if (disk && !disk.stored) {
|
|
5586
|
-
|
|
5771
|
+
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 } });
|
|
5587
5772
|
}
|
|
5588
5773
|
}
|
|
5774
|
+
const answers = new Map<Lost, { searched: () => void } | { skip: GrepSkip }>();
|
|
5775
|
+
await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
|
|
5776
|
+
const { file, state, version, what } = lost;
|
|
5777
|
+
answers.set(lost, 'bytes' in contents
|
|
5778
|
+
? { searched: search({ file, state, version, bytes: contents.bytes }) }
|
|
5779
|
+
: { skip: {
|
|
5780
|
+
file,
|
|
5781
|
+
reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
|
|
5782
|
+
+ `and ${lostOnDisk(contents.lost)}, so it was not searched`,
|
|
5783
|
+
} });
|
|
5784
|
+
});
|
|
5785
|
+
for (const item of found) {
|
|
5786
|
+
const answer = 'lost' in item ? answers.get(item.lost)! : item;
|
|
5787
|
+
if ('searched' in answer) answer.searched();
|
|
5788
|
+
else skipped.push(answer.skip);
|
|
5789
|
+
}
|
|
5589
5790
|
});
|
|
5590
5791
|
return { skipped, incomplete };
|
|
5591
5792
|
}
|
|
@@ -5635,7 +5836,7 @@ export class WorkspaceModule implements Module {
|
|
|
5635
5836
|
const store = this.getStore();
|
|
5636
5837
|
const status: Record<string, unknown> = {};
|
|
5637
5838
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
5638
|
-
return { success: false, error:
|
|
5839
|
+
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5639
5840
|
}
|
|
5640
5841
|
|
|
5641
5842
|
for (const [name, mount] of this.mounts) {
|
|
@@ -5778,9 +5979,6 @@ export class WorkspaceModule implements Module {
|
|
|
5778
5979
|
*/
|
|
5779
5980
|
private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
|
|
5780
5981
|
const store = this.getStore();
|
|
5781
|
-
const agreement = this.agreementOrThrow();
|
|
5782
|
-
const name = mount.config.name;
|
|
5783
|
-
const intents = this.intents.get(name)!;
|
|
5784
5982
|
const owed: string[] = [];
|
|
5785
5983
|
const unchecked: string[] = [];
|
|
5786
5984
|
let agreed = 0;
|
|
@@ -5789,17 +5987,32 @@ export class WorkspaceModule implements Module {
|
|
|
5789
5987
|
// Outside the mount's include, or in another mount's directory nested
|
|
5790
5988
|
// in it: neither owed to disk nor counted as on it.
|
|
5791
5989
|
if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
|
|
5792
|
-
const
|
|
5793
|
-
if (
|
|
5794
|
-
else if (
|
|
5795
|
-
// A mode the store records, other than the one disk last agreed with,
|
|
5796
|
-
// where modes are observed: elsewhere no push applies one.
|
|
5797
|
-
else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
|
|
5990
|
+
const kind = this.evidenceOf(mount, e);
|
|
5991
|
+
if (kind === 'owed') owed.push(e.path);
|
|
5992
|
+
else if (kind === 'unchecked') unchecked.push(e.path);
|
|
5798
5993
|
else agreed++;
|
|
5799
5994
|
}
|
|
5800
5995
|
return { owed, unchecked, agreed };
|
|
5801
5996
|
}
|
|
5802
5997
|
|
|
5998
|
+
private evidenceOf(mount: MountState, e: { path: string; blobHash: string; mode: number }): 'owed' | 'unchecked' | 'agreed' {
|
|
5999
|
+
const p = this.agreementOrThrow().get(mount.config.name, e.path);
|
|
6000
|
+
if (p === undefined) return this.intents.get(mount.config.name)!.get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
|
|
6001
|
+
if (p.kind !== 'content' || p.hash !== e.blobHash) return 'owed';
|
|
6002
|
+
// A mode the store records, other than the one disk last agreed with,
|
|
6003
|
+
// where modes are observed: elsewhere no push applies one.
|
|
6004
|
+
if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) return 'owed';
|
|
6005
|
+
return 'agreed';
|
|
6006
|
+
}
|
|
6007
|
+
|
|
6008
|
+
private diskOf(mount: MountState, relativePath: string): DiskState {
|
|
6009
|
+
const intent = this.intents.get(mount.config.name)!.get(relativePath);
|
|
6010
|
+
if (intent?.conflict) return 'in conflict with disk';
|
|
6011
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
6012
|
+
if (!entry) return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
|
|
6013
|
+
return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
|
|
6014
|
+
}
|
|
6015
|
+
|
|
5803
6016
|
private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
|
|
5804
6017
|
const store = this.getStore();
|
|
5805
6018
|
|
|
@@ -5809,24 +6022,27 @@ export class WorkspaceModule implements Module {
|
|
|
5809
6022
|
const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
|
|
5810
6023
|
|
|
5811
6024
|
let explicit: { mount: MountState; relativePath: string } | null = null;
|
|
5812
|
-
if (input.path) {
|
|
6025
|
+
if (input.path !== undefined) {
|
|
6026
|
+
if (input.path === '') {
|
|
6027
|
+
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 };
|
|
6028
|
+
}
|
|
5813
6029
|
const parsed = await this.parsePath(input.path);
|
|
5814
6030
|
explicit = parsed;
|
|
5815
6031
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5816
6032
|
// is refused; beneath a directory, the push leaves out what the include
|
|
5817
6033
|
// doesn't cover.
|
|
5818
6034
|
if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
|
|
5819
|
-
return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
|
|
6035
|
+
return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
|
|
5820
6036
|
}
|
|
5821
6037
|
}
|
|
5822
6038
|
|
|
5823
6039
|
let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
|
|
5824
6040
|
if (explicit) {
|
|
5825
6041
|
mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
|
|
5826
|
-
} else if (input.mount) {
|
|
6042
|
+
} else if (input.mount !== undefined) {
|
|
5827
6043
|
const m = this.mounts.get(input.mount);
|
|
5828
6044
|
if (!m) {
|
|
5829
|
-
return { success: false, error:
|
|
6045
|
+
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 };
|
|
5830
6046
|
}
|
|
5831
6047
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
5832
6048
|
} else {
|
|
@@ -5934,16 +6150,21 @@ export class WorkspaceModule implements Module {
|
|
|
5934
6150
|
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
5935
6151
|
return {
|
|
5936
6152
|
success: false,
|
|
5937
|
-
error:
|
|
6153
|
+
error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
|
|
5938
6154
|
isError: true,
|
|
5939
6155
|
};
|
|
5940
6156
|
}
|
|
5941
6157
|
|
|
6158
|
+
const held = explicit;
|
|
6159
|
+
const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
|
|
6160
|
+
&& allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
|
|
6161
|
+
&& !this.holdsAnythingAt(held.mount, held.relativePath);
|
|
5942
6162
|
return {
|
|
5943
6163
|
success: true,
|
|
5944
6164
|
data: {
|
|
5945
6165
|
materialized: allWritten,
|
|
5946
6166
|
count: allWritten.length,
|
|
6167
|
+
...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
|
|
5947
6168
|
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
5948
6169
|
...(pendingDeletions.length > 0
|
|
5949
6170
|
? {
|
|
@@ -6014,24 +6235,31 @@ export class WorkspaceModule implements Module {
|
|
|
6014
6235
|
const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
|
|
6015
6236
|
const rootsAccepted: string[] = [];
|
|
6016
6237
|
if (input.acceptRoot && input.path) {
|
|
6017
|
-
|
|
6238
|
+
const named = input.path.split('/')[0]!;
|
|
6239
|
+
return {
|
|
6240
|
+
success: false,
|
|
6241
|
+
isError: true,
|
|
6242
|
+
error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
|
|
6243
|
+
+ `${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.`),
|
|
6244
|
+
};
|
|
6018
6245
|
}
|
|
6019
6246
|
|
|
6020
6247
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
6021
6248
|
let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
|
|
6022
|
-
if (input.path) {
|
|
6249
|
+
if (input.path !== undefined) {
|
|
6250
|
+
if (input.path === '') return { success: false, error: didNothing('sync', this.emptyPathField(input.mount, 'covers every mount')), isError: true };
|
|
6023
6251
|
const { mount, relativePath, moved, root } = await this.parsePath(input.path);
|
|
6024
6252
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
6025
6253
|
// is refused; beneath a directory, the walk passes over what the include
|
|
6026
6254
|
// doesn't cover.
|
|
6027
6255
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
6028
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
6256
|
+
return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
6029
6257
|
}
|
|
6030
6258
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
6031
|
-
} else if (input.mount) {
|
|
6259
|
+
} else if (input.mount !== undefined) {
|
|
6032
6260
|
const m = this.mounts.get(input.mount);
|
|
6033
6261
|
if (!m) {
|
|
6034
|
-
return { success: false, error:
|
|
6262
|
+
return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
6035
6263
|
}
|
|
6036
6264
|
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
6037
6265
|
} else {
|
|
@@ -6045,6 +6273,7 @@ export class WorkspaceModule implements Module {
|
|
|
6045
6273
|
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
6046
6274
|
continue;
|
|
6047
6275
|
}
|
|
6276
|
+
let namedDirectory = false;
|
|
6048
6277
|
const pass = await this.withMount(mount, async () => {
|
|
6049
6278
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
|
|
6050
6279
|
if (!relativePath) {
|
|
@@ -6059,6 +6288,7 @@ export class WorkspaceModule implements Module {
|
|
|
6059
6288
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
6060
6289
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
6061
6290
|
this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
6291
|
+
namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
|
|
6062
6292
|
// named: ignore patterns covering the path asked for don't apply
|
|
6063
6293
|
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
6064
6294
|
const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
@@ -6105,7 +6335,7 @@ export class WorkspaceModule implements Module {
|
|
|
6105
6335
|
// refused" don't look identical from the outside.
|
|
6106
6336
|
for (const r of pass.reports.values()) {
|
|
6107
6337
|
if (r.state === 'unverified') {
|
|
6108
|
-
allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
|
|
6338
|
+
allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${explained(r.note ?? 'not observed', r.noteCause ?? {}, 'sync')}` });
|
|
6109
6339
|
} else if (r.state === 'disk-only') {
|
|
6110
6340
|
allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
|
|
6111
6341
|
}
|
|
@@ -6117,7 +6347,7 @@ export class WorkspaceModule implements Module {
|
|
|
6117
6347
|
// without a path passes over it, so nothing else keeps it current
|
|
6118
6348
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
6119
6349
|
// synced at all, and the note says so.
|
|
6120
|
-
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
6350
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
|
|
6121
6351
|
if (passedOver !== undefined) {
|
|
6122
6352
|
allIgnored.push({
|
|
6123
6353
|
mount: name,
|
|
@@ -6131,10 +6361,17 @@ export class WorkspaceModule implements Module {
|
|
|
6131
6361
|
}
|
|
6132
6362
|
}
|
|
6133
6363
|
|
|
6364
|
+
const named = input.path !== undefined && input.path !== '' ? targets[0]! : null;
|
|
6365
|
+
const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
|
|
6366
|
+
&& allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
|
|
6367
|
+
&& allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
|
|
6368
|
+
&& !this.holdsAnythingAt(named.mount, named.relativePath)
|
|
6369
|
+
&& await this.absentOnDisk(named.mount, named.relativePath);
|
|
6134
6370
|
return {
|
|
6135
6371
|
success: true,
|
|
6136
6372
|
data: {
|
|
6137
6373
|
results: allResults,
|
|
6374
|
+
...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
|
|
6138
6375
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
6139
6376
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
6140
6377
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
@@ -6408,13 +6645,13 @@ export async function inOrder<T, R>(items: readonly T[], width: number, work: (i
|
|
|
6408
6645
|
}
|
|
6409
6646
|
|
|
6410
6647
|
/** read_image's answer for an image, wherever it was read from. */
|
|
6411
|
-
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
|
|
6648
|
+
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult {
|
|
6412
6649
|
return {
|
|
6413
6650
|
success: true,
|
|
6414
6651
|
data: [
|
|
6415
6652
|
{
|
|
6416
6653
|
type: 'text',
|
|
6417
|
-
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
6654
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
|
|
6418
6655
|
},
|
|
6419
6656
|
{
|
|
6420
6657
|
type: 'image',
|
|
@@ -6475,13 +6712,16 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
|
|
|
6475
6712
|
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6476
6713
|
* the next start once none is held runs it direct.
|
|
6477
6714
|
*/
|
|
6478
|
-
function writeRefusal(mount: MountState): string | null {
|
|
6715
|
+
function writeRefusal(mount: MountState, writable: readonly string[] = []): string | null {
|
|
6479
6716
|
const name = mount.config.name;
|
|
6480
|
-
if (mount.config.mode === 'read-only')
|
|
6717
|
+
if (mount.config.mode === 'read-only') {
|
|
6718
|
+
return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
|
|
6719
|
+
+ (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
|
|
6720
|
+
}
|
|
6481
6721
|
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6482
|
-
return `
|
|
6722
|
+
return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6483
6723
|
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
6484
|
-
+ `(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
|
|
6724
|
+
+ `(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.`;
|
|
6485
6725
|
}
|
|
6486
6726
|
return null;
|
|
6487
6727
|
}
|
|
@@ -6581,6 +6821,21 @@ function directReason(error: unknown): string {
|
|
|
6581
6821
|
return error instanceof Error ? error.message : String(error);
|
|
6582
6822
|
}
|
|
6583
6823
|
|
|
6824
|
+
function directWayOn(error: unknown): string | null {
|
|
6825
|
+
if (error instanceof FileChanged) return 'Calling again reads it afresh.';
|
|
6826
|
+
if (error instanceof WriteRefused) return error.wayOn;
|
|
6827
|
+
if (error instanceof EffectFailed) {
|
|
6828
|
+
if (error.detail.errno !== undefined) return errnoWayOn(error.detail.errno);
|
|
6829
|
+
if (error.detail.again === true) return 'Calling again checks it afresh.';
|
|
6830
|
+
}
|
|
6831
|
+
return null;
|
|
6832
|
+
}
|
|
6833
|
+
|
|
6834
|
+
function directDidNothing(tool: string, path: string, error: unknown): string {
|
|
6835
|
+
const on = directWayOn(error);
|
|
6836
|
+
return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
|
|
6837
|
+
}
|
|
6838
|
+
|
|
6584
6839
|
/** "a", "a and b", or "a, b and c". */
|
|
6585
6840
|
function listed(items: readonly string[]): string {
|
|
6586
6841
|
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
@@ -6604,7 +6859,130 @@ function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): stri
|
|
|
6604
6859
|
*/
|
|
6605
6860
|
function containedRefusal(err: unknown, path: string): string {
|
|
6606
6861
|
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
6607
|
-
return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
|
|
6862
|
+
return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
|
|
6863
|
+
}
|
|
6864
|
+
|
|
6865
|
+
function deletedOnDisk(path: string): string {
|
|
6866
|
+
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.`;
|
|
6867
|
+
}
|
|
6868
|
+
|
|
6869
|
+
function uncheckedFile(shown: string, note: string | undefined, cause: Cause): string {
|
|
6870
|
+
if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6871
|
+
const on = note === ROOT_REPLACED || cause.ownStep === true ? null
|
|
6872
|
+
: (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';
|
|
6873
|
+
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))}` : ''}`;
|
|
6874
|
+
}
|
|
6875
|
+
|
|
6876
|
+
function namesRoot(path: string, mount: string): string {
|
|
6877
|
+
return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
|
|
6878
|
+
}
|
|
6879
|
+
|
|
6880
|
+
function tooLargeToWrite(path: string, bytes: number, maxSize: number): string {
|
|
6881
|
+
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.');
|
|
6882
|
+
}
|
|
6883
|
+
|
|
6884
|
+
function pastSizeLimit(what: string, maxSize: number, outcome: string, within: string | null): string {
|
|
6885
|
+
return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
|
|
6886
|
+
+ "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
|
|
6887
|
+
}
|
|
6888
|
+
|
|
6889
|
+
function sentence(text: string): string {
|
|
6890
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
6891
|
+
}
|
|
6892
|
+
|
|
6893
|
+
function unreadable(shown: string, errno: string | undefined): string {
|
|
6894
|
+
return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
function readRefusal(err: WorkspaceReadError, path: string): string {
|
|
6898
|
+
const shown = JSON.stringify(path);
|
|
6899
|
+
if (err instanceof RootUnavailable) {
|
|
6900
|
+
if (err.refusal) return err.refusal.message;
|
|
6901
|
+
const mount = JSON.stringify(err.mount);
|
|
6902
|
+
if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
|
|
6903
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
|
|
6904
|
+
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")}`;
|
|
6905
|
+
}
|
|
6906
|
+
if (err.errno !== undefined) {
|
|
6907
|
+
return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
|
|
6908
|
+
+ errnoWayOn(err.errno, ROOT_RECHECK, true);
|
|
6909
|
+
}
|
|
6910
|
+
return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
|
|
6911
|
+
}
|
|
6912
|
+
return `${shown} can't be read: ${sentence(err.reason)}`;
|
|
6913
|
+
}
|
|
6914
|
+
if (err instanceof LinkOnTheWay) return `${shown} can't be reached: ${sentence(err.reason)}`;
|
|
6915
|
+
switch (err.code) {
|
|
6916
|
+
case 'directory':
|
|
6917
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6918
|
+
case 'symlink':
|
|
6919
|
+
return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
|
|
6920
|
+
+ `Calling again won't change that; ${UNFOLLOWED_STEP}.`;
|
|
6921
|
+
case 'escape':
|
|
6922
|
+
return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
|
|
6923
|
+
+ `Calling again won't change that; ${MOUNT_IT}.`;
|
|
6924
|
+
case 'changed':
|
|
6925
|
+
return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
|
|
6926
|
+
case 'not_found':
|
|
6927
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
6928
|
+
if (err instanceof NotRegularFile) {
|
|
6929
|
+
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')}`;
|
|
6930
|
+
}
|
|
6931
|
+
if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
|
|
6932
|
+
return unreadable(shown, err.errno);
|
|
6933
|
+
default:
|
|
6934
|
+
return sentence(err.message);
|
|
6935
|
+
}
|
|
6936
|
+
}
|
|
6937
|
+
|
|
6938
|
+
type LostContents = {
|
|
6939
|
+
path: string;
|
|
6940
|
+
store: Exclude<StoredBytes, { bytes: Buffer }>;
|
|
6941
|
+
disk: 'other' | 'none' | { unreadable: string };
|
|
6942
|
+
writable: boolean;
|
|
6943
|
+
reopen: boolean;
|
|
6944
|
+
};
|
|
6945
|
+
|
|
6946
|
+
function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string {
|
|
6947
|
+
return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
|
|
6948
|
+
}
|
|
6949
|
+
|
|
6950
|
+
function inTheStore(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
|
|
6951
|
+
return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6954
|
+
function lostInStore(lost: Pick<LostContents, 'store'>): string {
|
|
6955
|
+
return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
|
|
6956
|
+
}
|
|
6957
|
+
|
|
6958
|
+
function lostOnDisk(lost: LostContents): string {
|
|
6959
|
+
return lost.disk === 'other' ? "disk's copy holds other contents"
|
|
6960
|
+
: lost.disk === 'none' ? 'disk has no copy'
|
|
6961
|
+
: `disk's copy can't be read (${lost.disk.unreadable})`;
|
|
6962
|
+
}
|
|
6963
|
+
|
|
6964
|
+
function lostText(lost: LostContents, the: 'The' | 'the' = 'The'): string {
|
|
6965
|
+
return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
|
|
6966
|
+
}
|
|
6967
|
+
|
|
6968
|
+
function nothingToRead(lost: LostContents): string {
|
|
6969
|
+
return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
|
|
6970
|
+
}
|
|
6971
|
+
|
|
6972
|
+
function lostRemedy(lost: LostContents): string {
|
|
6973
|
+
const after = remedyOfLost(lost);
|
|
6974
|
+
if (!lost.reopen) return after;
|
|
6975
|
+
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. "
|
|
6976
|
+
+ `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
|
|
6977
|
+
}
|
|
6978
|
+
|
|
6979
|
+
function remedyOfLost(lost: LostContents): string {
|
|
6980
|
+
const rewrite = lost.writable && 'missing' in lost.store;
|
|
6981
|
+
if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
|
|
6982
|
+
if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
|
|
6983
|
+
return lost.disk === 'none'
|
|
6984
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy."
|
|
6985
|
+
: "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.";
|
|
6608
6986
|
}
|
|
6609
6987
|
|
|
6610
6988
|
/**
|
|
@@ -6616,7 +6994,7 @@ function containedRefusal(err: unknown, path: string): string {
|
|
|
6616
6994
|
*/
|
|
6617
6995
|
function headMoveText(mounts: readonly string[]): string {
|
|
6618
6996
|
const one = mounts.length === 1;
|
|
6619
|
-
return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6997
|
+
return `[${NOTICE_TAG.workspace}] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6620
6998
|
+ `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
|
|
6621
6999
|
+ 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
|
|
6622
7000
|
}
|
|
@@ -6683,9 +7061,8 @@ function planTargetEdits(
|
|
|
6683
7061
|
kind: 'file',
|
|
6684
7062
|
edits: target.edits,
|
|
6685
7063
|
path: target.path,
|
|
6686
|
-
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)},
|
|
6687
|
-
|
|
6688
|
-
+ "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.",
|
|
7064
|
+
text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize,
|
|
7065
|
+
"the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
|
|
6689
7066
|
whole: false,
|
|
6690
7067
|
});
|
|
6691
7068
|
return null;
|
|
@@ -6728,17 +7105,36 @@ function changesLanded(
|
|
|
6728
7105
|
* change is stored isn't known. Its message says so after both errors.
|
|
6729
7106
|
*/
|
|
6730
7107
|
class CommitUnknownError extends Error {
|
|
6731
|
-
readonly
|
|
6732
|
-
readonly readError: string;
|
|
6733
|
-
constructor(setError: unknown, readError: unknown) {
|
|
7108
|
+
constructor(readonly setCause: unknown, readonly readCause: unknown) {
|
|
6734
7109
|
const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
6735
|
-
super(`${text(
|
|
7110
|
+
super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
|
|
6736
7111
|
this.name = 'CommitUnknownError';
|
|
6737
|
-
this.setError = text(setError);
|
|
6738
|
-
this.readError = text(readError);
|
|
6739
7112
|
}
|
|
6740
7113
|
}
|
|
6741
7114
|
|
|
7115
|
+
function storeWords(error: unknown): string {
|
|
7116
|
+
const code = errnoCode(error);
|
|
7117
|
+
return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
|
|
7118
|
+
}
|
|
7119
|
+
|
|
7120
|
+
class NotStored extends Error {
|
|
7121
|
+
constructor(readonly thrown: unknown) {
|
|
7122
|
+
super('stopped before the change was stored');
|
|
7123
|
+
}
|
|
7124
|
+
}
|
|
7125
|
+
|
|
7126
|
+
class AlreadyHeld extends Error {
|
|
7127
|
+
constructor(readonly thrown: unknown) {
|
|
7128
|
+
super(thrown instanceof Error ? thrown.message : String(thrown));
|
|
7129
|
+
}
|
|
7130
|
+
}
|
|
7131
|
+
|
|
7132
|
+
function notStored(error: unknown): unknown {
|
|
7133
|
+
return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
|
|
7134
|
+
? error : new NotStored(error);
|
|
7135
|
+
}
|
|
7136
|
+
|
|
7137
|
+
|
|
6742
7138
|
/**
|
|
6743
7139
|
* A store's set that threw after recording the change (changesLanded): the
|
|
6744
7140
|
* entries are set and their intents follow, and the store's error goes on in
|
|
@@ -6752,6 +7148,14 @@ class StoredThenFailed extends Error {
|
|
|
6752
7148
|
}
|
|
6753
7149
|
}
|
|
6754
7150
|
|
|
7151
|
+
const ONCE_REOPENED = ', once the store is reopened';
|
|
7152
|
+
|
|
7153
|
+
const DISK_ORDER: DiskState[] = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
|
|
7154
|
+
|
|
7155
|
+
function furthestFromDisk(values: DiskState[]): DiskState {
|
|
7156
|
+
return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
|
|
7157
|
+
}
|
|
7158
|
+
|
|
6755
7159
|
/**
|
|
6756
7160
|
* The answer to a write, edit or delete that the workspace stored but
|
|
6757
7161
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -6768,6 +7172,7 @@ function storedButUnfinished(
|
|
|
6768
7172
|
path: string,
|
|
6769
7173
|
outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
6770
7174
|
note: string | null = null,
|
|
7175
|
+
reopen = false,
|
|
6771
7176
|
): ToolResult {
|
|
6772
7177
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
6773
7178
|
const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
|
|
@@ -6777,10 +7182,10 @@ function storedButUnfinished(
|
|
|
6777
7182
|
: 'storeFailed' in outcome
|
|
6778
7183
|
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
6779
7184
|
: tool === 'delete'
|
|
6780
|
-
?
|
|
6781
|
-
:
|
|
6782
|
-
: ` The rest of the call failed: ${outcome.threw}
|
|
6783
|
-
return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
|
|
7185
|
+
? ` 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 : ''}.`
|
|
7186
|
+
: ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
|
|
7187
|
+
: ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
|
|
7188
|
+
return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
|
|
6784
7189
|
}
|
|
6785
7190
|
|
|
6786
7191
|
/**
|
|
@@ -6810,19 +7215,24 @@ function whichCopyRead(report: PathReport | undefined, tookChange: boolean, read
|
|
|
6810
7215
|
// can hold it (not binary or too large). A read-only mount can hold a
|
|
6811
7216
|
// copy disk doesn't, such as one a branch switch selected, but a
|
|
6812
7217
|
// materialize, forced or not, writes nothing to it.
|
|
6813
|
-
const
|
|
6814
|
-
|
|
6815
|
-
: 'Sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6816
|
-
const force = readOnly
|
|
6817
|
-
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6818
|
-
: report.diskNow === 'absent'
|
|
6819
|
-
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6820
|
-
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6821
|
-
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
|
|
7218
|
+
const settle = settleConflict(report.diskNow, readOnly);
|
|
7219
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0]!.toUpperCase()}${settle.slice(1)}`];
|
|
6822
7220
|
}
|
|
6823
7221
|
return [];
|
|
6824
7222
|
}
|
|
6825
7223
|
|
|
7224
|
+
function settleConflict(diskNow: PathReport['diskNow'], readOnly: boolean): string {
|
|
7225
|
+
const sync = diskNow === 'absent'
|
|
7226
|
+
? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
7227
|
+
: 'sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
7228
|
+
const force = readOnly
|
|
7229
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
7230
|
+
: diskNow === 'absent'
|
|
7231
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
7232
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
7233
|
+
return `${sync}${force}`;
|
|
7234
|
+
}
|
|
7235
|
+
|
|
6826
7236
|
/**
|
|
6827
7237
|
* The least each tool's whole-number fields may be, beside their schemas,
|
|
6828
7238
|
* whose type (membrane's ToolParameter) has no `minimum`.
|
|
@@ -7020,13 +7430,13 @@ function globToRegex(pattern: string): RegExp {
|
|
|
7020
7430
|
if (pattern[j] === '{') depth++;
|
|
7021
7431
|
else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
|
|
7022
7432
|
}
|
|
7023
|
-
if (closeIdx < 0) throw new
|
|
7433
|
+
if (closeIdx < 0) throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
|
|
7024
7434
|
const group = pattern.slice(i + 1, closeIdx);
|
|
7025
7435
|
if (group.includes('{')) {
|
|
7026
|
-
throw new
|
|
7436
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
|
|
7027
7437
|
}
|
|
7028
7438
|
if (!group.includes(',')) {
|
|
7029
|
-
throw new
|
|
7439
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
|
|
7030
7440
|
}
|
|
7031
7441
|
// A `/` right after the group goes into each alternative, as `{a,b}/x`
|
|
7032
7442
|
// and `{a/,b/}x` name the same paths, so an alternative's own `**`
|