@animalabs/agent-framework 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -15
- package/dist/src/agent.d.ts +29 -2
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +69 -11
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +4 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +3 -2
- package/dist/src/auto-continue.d.ts.map +1 -1
- package/dist/src/auto-continue.js +4 -3
- package/dist/src/auto-continue.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 +304 -89
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +2577 -678
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +99 -3
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +538 -13
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +2 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -0
- 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 +161 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/heartbeat/heartbeat.d.ts +184 -0
- package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat/heartbeat.js +752 -0
- package/dist/src/heartbeat/heartbeat.js.map +1 -0
- package/dist/src/heartbeat/reminder-time.d.ts +5 -0
- package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
- package/dist/src/heartbeat/reminder-time.js +107 -0
- package/dist/src/heartbeat/reminder-time.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
- package/dist/src/mcpl/capability-mask.js +3 -2
- package/dist/src/mcpl/capability-mask.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +2 -1
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +26 -10
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +40 -69
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -8
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +50 -10
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +9 -2
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +12 -2
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +66 -14
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
- package/dist/src/mcpl/tool-presence.js +134 -65
- 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 +47 -18
- package/dist/src/mcpl/tool-signature.js.map +1 -1
- package/dist/src/mcpl/toolset-host.d.ts +88 -37
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
- package/dist/src/mcpl/toolset-host.js +207 -108
- package/dist/src/mcpl/toolset-host.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts +14 -11
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +141 -49
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +3 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +4 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +9 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +22 -11
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/workspace/answers.d.ts +22 -0
- package/dist/src/modules/workspace/answers.d.ts.map +1 -0
- package/dist/src/modules/workspace/answers.js +123 -0
- package/dist/src/modules/workspace/answers.js.map +1 -0
- package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +6 -2
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +3 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
- package/dist/src/modules/workspace/direct-write.js +57 -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.map +1 -1
- package/dist/src/modules/workspace/effects.js +20 -13
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +62 -51
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +934 -531
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +75 -63
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +366 -180
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts.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.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +26 -16
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/result-text.d.ts +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/prose-drafts.d.ts +4 -3
- package/dist/src/prose-drafts.d.ts.map +1 -1
- package/dist/src/prose-drafts.js +3 -2
- package/dist/src/prose-drafts.js.map +1 -1
- package/dist/src/reasoning-loss-notice.d.ts +23 -0
- package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
- package/dist/src/reasoning-loss-notice.js +50 -0
- package/dist/src/reasoning-loss-notice.js.map +1 -0
- package/dist/src/reasoning-loss.d.ts +24 -0
- package/dist/src/reasoning-loss.d.ts.map +1 -0
- package/dist/src/reasoning-loss.js +102 -0
- package/dist/src/reasoning-loss.js.map +1 -0
- package/dist/src/send-tools.d.ts +3 -2
- package/dist/src/send-tools.d.ts.map +1 -1
- package/dist/src/send-tools.js +3 -2
- package/dist/src/send-tools.js.map +1 -1
- package/dist/src/sent-rows.js +1 -1
- 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 +35 -1
- package/dist/src/tool-args.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/tools.d.ts +4 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +40 -9
- 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 +15 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +5 -2
- 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 +72 -11
- package/src/api/server.ts +4 -2
- package/src/auto-continue.ts +4 -3
- package/src/cut-off-round.ts +98 -0
- package/src/framework.ts +2694 -700
- package/src/gate/event-gate.ts +579 -15
- package/src/gate/index.ts +8 -0
- package/src/gate/reply-judge.ts +512 -0
- package/src/gate/types.ts +164 -1
- package/src/heartbeat/heartbeat.ts +804 -0
- package/src/heartbeat/reminder-time.ts +107 -0
- package/src/index.ts +4 -0
- package/src/mcpl/capability-mask.ts +3 -2
- package/src/mcpl/channel-registry.ts +2 -1
- package/src/mcpl/inbound-source.ts +42 -69
- package/src/mcpl/server-connection.ts +56 -16
- package/src/mcpl/tags.ts +13 -2
- package/src/mcpl/tool-presence.ts +133 -67
- package/src/mcpl/tool-signature.ts +51 -20
- package/src/mcpl/toolset-host.ts +197 -107
- package/src/mcpl/toolsets-module.ts +143 -49
- package/src/mcpl/types.ts +3 -1
- package/src/module-registry.ts +8 -2
- package/src/modules/history/index.ts +22 -12
- package/src/modules/workspace/answers.ts +133 -0
- package/src/modules/workspace/branch-intent.ts +10 -5
- package/src/modules/workspace/direct-write.ts +58 -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 +21 -12
- package/src/modules/workspace/index.ts +927 -541
- package/src/modules/workspace/observe.ts +381 -177
- package/src/modules/workspace/read-page.ts +7 -1
- package/src/modules/workspace/reconcile.ts +27 -15
- 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/prose-drafts.ts +4 -3
- package/src/reasoning-loss-notice.ts +62 -0
- package/src/reasoning-loss.ts +120 -0
- package/src/send-tools.ts +3 -2
- package/src/sent-rows.ts +1 -1
- package/src/thinking-drops.ts +10 -16
- package/src/tool-args.ts +34 -1
- package/src/tool-result-guard.ts +26 -11
- package/src/tune-out/tools.ts +4 -1
- package/src/types/agent.ts +40 -9
- package/src/types/events.ts +18 -8
- package/src/types/framework.ts +15 -0
- package/src/types/module.ts +5 -2
- package/src/types/trace.ts +36 -1
|
@@ -37,17 +37,17 @@ import type {
|
|
|
37
37
|
} from './types.js';
|
|
38
38
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
39
39
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
40
|
-
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
41
|
-
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
40
|
+
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
|
|
41
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
42
42
|
import { type BranchIntent, BranchIntents } from './branch-intent.js';
|
|
43
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE,
|
|
43
|
+
import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
|
|
44
44
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
45
45
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
46
46
|
import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
47
47
|
import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
|
|
48
48
|
import { type FileText, fileText } from './text.js';
|
|
49
49
|
import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
|
|
50
|
-
import { type GrepAnswer, grepText, textAnswer } from './result-text.js';
|
|
50
|
+
import { type DiskState, type GrepAnswer, grepText, textAnswer } from './result-text.js';
|
|
51
51
|
import {
|
|
52
52
|
type MountRuntime,
|
|
53
53
|
type PassOptions,
|
|
@@ -65,6 +65,9 @@ import {
|
|
|
65
65
|
settleAdoptionBeforeMutation,
|
|
66
66
|
} from './reconcile.js';
|
|
67
67
|
import { EffectFailed, unlinkContained } from './effects.js';
|
|
68
|
+
import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
|
|
69
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
70
|
+
import { closestNames } from '../../closest-names.js';
|
|
68
71
|
import { RecordJournal } from '../../record-journal.js';
|
|
69
72
|
import {
|
|
70
73
|
type FileIdentity,
|
|
@@ -100,6 +103,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
|
100
103
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
101
104
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
102
105
|
const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
106
|
+
const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
|
|
107
|
+
+ 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
|
|
108
|
+
+ 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
|
|
103
109
|
/**
|
|
104
110
|
* How many files a direct mount's grep reads, and its listings look at, at
|
|
105
111
|
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
@@ -134,30 +140,23 @@ export type {
|
|
|
134
140
|
|
|
135
141
|
type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
136
142
|
|
|
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);
|
|
143
|
+
class WorkspaceImageReadError extends Refusal {
|
|
144
|
+
constructor(readonly reason: string, wayOn: string | null) {
|
|
145
|
+
super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
|
|
157
146
|
this.name = 'WorkspaceImageReadError';
|
|
158
147
|
}
|
|
159
148
|
}
|
|
160
149
|
|
|
150
|
+
const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
|
|
151
|
+
|
|
152
|
+
function truncatedImage(mountPrefixedPath: string): WorkspaceImageReadError {
|
|
153
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function emptyImage(mountPrefixedPath: string): WorkspaceImageReadError {
|
|
157
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
|
|
158
|
+
}
|
|
159
|
+
|
|
161
160
|
/**
|
|
162
161
|
* Why a workspace-owned filesystem read was refused. Distinguishes the cases a
|
|
163
162
|
* peer needs to react to differently: a path that never resolved inside the
|
|
@@ -175,10 +174,12 @@ class WorkspaceImageReadError extends Error {
|
|
|
175
174
|
* directories leading out of it (`escape`), a file swapped underneath the read
|
|
176
175
|
* (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
|
|
177
176
|
* 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
|
|
177
|
+
* (`other_mount`), whose message names the mount that holds it, or may hold
|
|
178
|
+
* it, and the path to use there. A symlink leading into such a directory is
|
|
179
|
+
* `other_mount` only where its refusal names that mount: a directory, or a
|
|
180
|
+
* file that mount's include covers, and so does the include of each other
|
|
181
|
+
* nested mount whose directory may hold it, out to the first that surely
|
|
182
|
+
* does. Anywhere else, on a mount that doesn't follow
|
|
182
183
|
* symlinks, it gets the code of a link where it stands, `symlink` at the
|
|
183
184
|
* path itself and `parent_symlink` on its way, and on one that does,
|
|
184
185
|
* `escape`, as a path that leaves the mount. On a mount with an include, a
|
|
@@ -220,7 +221,7 @@ export type WorkspaceReadStage =
|
|
|
220
221
|
| 'read';
|
|
221
222
|
|
|
222
223
|
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
223
|
-
class OutsideIncludeError extends
|
|
224
|
+
class OutsideIncludeError extends Refusal {
|
|
224
225
|
constructor(shownPath: string, mount: MountConfig) {
|
|
225
226
|
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
226
227
|
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
@@ -232,9 +233,16 @@ class OutsideIncludeError extends Error {
|
|
|
232
233
|
* mount it names. The message names the mount that holds it and the path to
|
|
233
234
|
* use there.
|
|
234
235
|
*/
|
|
235
|
-
class NestedMountError extends
|
|
236
|
-
constructor(path: string, mount: string, holder: string
|
|
237
|
-
|
|
236
|
+
class NestedMountError extends Refusal {
|
|
237
|
+
constructor(path: string, mount: string, holder: { name: string; use: string; guess?: NestedGuess }, how: 'lies in' | 'leads into' = 'lies in') {
|
|
238
|
+
const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
|
|
239
|
+
super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
class PathRefusal extends Refusal {
|
|
244
|
+
constructor(readonly code: 'unknown_mount' | 'traversal', message: string) {
|
|
245
|
+
super(message);
|
|
238
246
|
}
|
|
239
247
|
}
|
|
240
248
|
|
|
@@ -256,6 +264,18 @@ export class WorkspaceReadError extends Error {
|
|
|
256
264
|
}
|
|
257
265
|
}
|
|
258
266
|
|
|
267
|
+
class NotRegularFile extends WorkspaceReadError {
|
|
268
|
+
constructor(path: string, stage: WorkspaceReadStage) {
|
|
269
|
+
super('not_found', `Not a regular file: ${path}`, stage);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
class RootUnavailable extends WorkspaceReadError {
|
|
274
|
+
constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
|
|
275
|
+
super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
259
279
|
/** Successful `readFileFromDisk()` result. */
|
|
260
280
|
export interface WorkspaceDiskReadResult {
|
|
261
281
|
/**
|
|
@@ -308,23 +328,10 @@ function errnoOf(err: unknown): string | undefined {
|
|
|
308
328
|
: undefined;
|
|
309
329
|
}
|
|
310
330
|
|
|
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
331
|
|
|
325
332
|
/** 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(
|
|
333
|
+
function imageTooLarge(maxSize: number, mountPrefixedPath: string, size: string): WorkspaceImageReadError {
|
|
334
|
+
return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
|
|
328
335
|
}
|
|
329
336
|
|
|
330
337
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -342,10 +349,6 @@ const JPEG_EOI = 0xd9;
|
|
|
342
349
|
const JPEG_SOS = 0xda;
|
|
343
350
|
const JPEG_TEM = 0x01;
|
|
344
351
|
|
|
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
352
|
function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
|
|
350
353
|
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
351
354
|
}
|
|
@@ -388,7 +391,7 @@ async function linkReadError(
|
|
|
388
391
|
const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
|
|
389
392
|
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
390
393
|
const code = leads?.kind === 'outside' ? 'escape'
|
|
391
|
-
: leads?.kind === 'inside' && holder && namesHolder(leads,
|
|
394
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
|
|
392
395
|
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
393
396
|
return new WorkspaceReadError(code, reason, 'readlink');
|
|
394
397
|
}
|
|
@@ -420,32 +423,30 @@ function ancestorsOf(path: string): string[] {
|
|
|
420
423
|
* directory as configured beneath the outer mount's configured path
|
|
421
424
|
* (`outerPath`), beneath each directory above its configured path that is
|
|
422
425
|
* 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.
|
|
426
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`).
|
|
427
|
+
* Its real root is claimed where it doesn't hold the outer root, which it
|
|
428
|
+
* would otherwise claim whole. Undefined where it's nested nowhere.
|
|
427
429
|
*/
|
|
428
|
-
|
|
430
|
+
function placementOf(
|
|
429
431
|
outerPath: string,
|
|
430
432
|
outerReal: string,
|
|
431
433
|
innerPath: string,
|
|
432
434
|
real: string,
|
|
433
435
|
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> {
|
|
436
|
+
): { configured: string | undefined; fromRoots: NestedMountEntry['fromRoots']; claims: boolean } | undefined {
|
|
436
437
|
const configured = nestedDir(outerPath, innerPath);
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return { configured, fromRoots,
|
|
438
|
+
const dirs = new Map<string, boolean>();
|
|
439
|
+
const place = (dir: string | undefined, sure: boolean) => { if (dir !== undefined) dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
|
|
440
|
+
place(configured, true);
|
|
441
|
+
for (const a of above) {
|
|
442
|
+
if (sameRoot(a.real, outerReal)) place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
|
|
443
|
+
}
|
|
444
|
+
const below = beneathRoot(real, outerReal);
|
|
445
|
+
place(below, below !== undefined && surelyAtRoot(real, outerReal));
|
|
446
|
+
if (dirs.size === 0) return undefined;
|
|
447
|
+
const fromRoots: NestedMountEntry['fromRoots'] = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true as const }) }));
|
|
448
|
+
const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
|
|
449
|
+
return { configured, fromRoots, claims };
|
|
449
450
|
}
|
|
450
451
|
|
|
451
452
|
/** A mount nested in another, as the outer mount's otherMounts holds it. */
|
|
@@ -458,9 +459,7 @@ interface NestedMountEntry {
|
|
|
458
459
|
*/
|
|
459
460
|
real?: string;
|
|
460
461
|
/** 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 }>;
|
|
462
|
+
fromRoots: Array<{ dir: string; guessed?: true }>;
|
|
464
463
|
}
|
|
465
464
|
|
|
466
465
|
/**
|
|
@@ -539,7 +538,7 @@ function isContainedPath(root: string, candidate: string): boolean {
|
|
|
539
538
|
}
|
|
540
539
|
|
|
541
540
|
function invalidImage(format: string, mountPrefixedPath: string): never {
|
|
542
|
-
throw new WorkspaceImageReadError(
|
|
541
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
|
|
543
542
|
}
|
|
544
543
|
|
|
545
544
|
function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
|
|
@@ -915,7 +914,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
915
914
|
return 'image/png';
|
|
916
915
|
}
|
|
917
916
|
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
918
|
-
throw
|
|
917
|
+
throw truncatedImage(mountPrefixedPath);
|
|
919
918
|
}
|
|
920
919
|
|
|
921
920
|
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
@@ -923,7 +922,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
923
922
|
return 'image/jpeg';
|
|
924
923
|
}
|
|
925
924
|
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
926
|
-
throw
|
|
925
|
+
throw truncatedImage(mountPrefixedPath);
|
|
927
926
|
}
|
|
928
927
|
|
|
929
928
|
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
@@ -931,7 +930,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
931
930
|
return 'image/gif';
|
|
932
931
|
}
|
|
933
932
|
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
934
|
-
throw
|
|
933
|
+
throw truncatedImage(mountPrefixedPath);
|
|
935
934
|
}
|
|
936
935
|
|
|
937
936
|
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
@@ -942,10 +941,10 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
|
|
|
942
941
|
(bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
943
942
|
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
|
|
944
943
|
) {
|
|
945
|
-
throw
|
|
944
|
+
throw truncatedImage(mountPrefixedPath);
|
|
946
945
|
}
|
|
947
946
|
|
|
948
|
-
throw new WorkspaceImageReadError('
|
|
947
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows`, "read shows it, if it's text.");
|
|
949
948
|
}
|
|
950
949
|
|
|
951
950
|
export class WorkspaceModule implements Module {
|
|
@@ -973,14 +972,11 @@ export class WorkspaceModule implements Module {
|
|
|
973
972
|
private ctimeTrust = new Map<string, string | null>();
|
|
974
973
|
/**
|
|
975
974
|
* 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).
|
|
975
|
+
* each mount's name and real root: as configured, and as the real roots
|
|
976
|
+
* place it, where that differs (`fromRoots`). Each is mount-relative and
|
|
977
|
+
* `/`-separated. The constructor nests by configured path, with each root
|
|
978
|
+
* as resolved standing for its real one, and each start reads it all again
|
|
979
|
+
* with the roots placed where disk has them (readNesting).
|
|
984
980
|
*/
|
|
985
981
|
private otherMounts = new Map<string, NestedMountEntry[]>();
|
|
986
982
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
@@ -1045,24 +1041,18 @@ export class WorkspaceModule implements Module {
|
|
|
1045
1041
|
if (refused.length > 0) {
|
|
1046
1042
|
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
1047
1043
|
}
|
|
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
|
-
);
|
|
1044
|
+
for (const list of ['ignore', 'include'] as const) {
|
|
1045
|
+
const patterns: unknown = mount[list];
|
|
1046
|
+
if (patterns === undefined) continue;
|
|
1047
|
+
if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
|
|
1048
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
|
|
1056
1049
|
}
|
|
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
|
-
);
|
|
1050
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
1051
|
+
const pattern: unknown = patterns[i];
|
|
1052
|
+
const why = patternRefusal(pattern, list);
|
|
1053
|
+
if (why !== undefined) {
|
|
1054
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
|
|
1055
|
+
}
|
|
1066
1056
|
}
|
|
1067
1057
|
}
|
|
1068
1058
|
}
|
|
@@ -1081,8 +1071,8 @@ export class WorkspaceModule implements Module {
|
|
|
1081
1071
|
const innerPath = resolve(inner.path);
|
|
1082
1072
|
const dir = nestedDir(outerPath, innerPath);
|
|
1083
1073
|
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
|
|
1074
|
+
const configured = [{ dir }];
|
|
1075
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
|
|
1086
1076
|
console.warn(
|
|
1087
1077
|
`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
1088
1078
|
`at ${dir}/ — passing over it as another mount's directory`,
|
|
@@ -1372,7 +1362,7 @@ export class WorkspaceModule implements Module {
|
|
|
1372
1362
|
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
1373
1363
|
let start = base;
|
|
1374
1364
|
for (const segment of beneath.split('/')) {
|
|
1375
|
-
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
|
|
1365
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined)) break;
|
|
1376
1366
|
const here = await checkDir(view, rootReal, start);
|
|
1377
1367
|
if (here.kind !== 'checked') break;
|
|
1378
1368
|
const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
|
|
@@ -1425,11 +1415,10 @@ export class WorkspaceModule implements Module {
|
|
|
1425
1415
|
|
|
1426
1416
|
async start(ctx: ModuleContext): Promise<void> {
|
|
1427
1417
|
// Which mounts are nested where, with the roots placed where disk has
|
|
1428
|
-
// them and each
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
// sets nothing.
|
|
1418
|
+
// them, and whether each mount's root folds case, read where disk has
|
|
1419
|
+
// it, before this start sets anything, so a restart takes no call
|
|
1420
|
+
// and walks or watches nothing before they're known. A stop begun
|
|
1421
|
+
// meanwhile stands, and this start then sets nothing.
|
|
1433
1422
|
const stops = this.stopsBegun;
|
|
1434
1423
|
const { nesting, reals, above } = await this.readNesting();
|
|
1435
1424
|
const rootFoldsCase = new Map<string, boolean>();
|
|
@@ -1545,7 +1534,8 @@ export class WorkspaceModule implements Module {
|
|
|
1545
1534
|
return (
|
|
1546
1535
|
`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
1547
1536
|
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
1548
|
-
`never had.
|
|
1537
|
+
`never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
|
|
1538
|
+
'if the current branch is what disk should hold, pass force: true to overwrite disk from it.'
|
|
1549
1539
|
);
|
|
1550
1540
|
}
|
|
1551
1541
|
|
|
@@ -2184,7 +2174,13 @@ export class WorkspaceModule implements Module {
|
|
|
2184
2174
|
return work;
|
|
2185
2175
|
}
|
|
2186
2176
|
|
|
2187
|
-
private
|
|
2177
|
+
private runToolCall(call: ToolCall): Promise<ToolResult> {
|
|
2178
|
+
const gate = { passed: false };
|
|
2179
|
+
return callGate.run(gate, () => this.answerToolCall(call, gate));
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
private async answerToolCall(call: ToolCall, gate: { passed: boolean }): Promise<ToolResult> {
|
|
2183
|
+
const reopenAtStart = this.agreement?.awaitsReopen === true;
|
|
2188
2184
|
try {
|
|
2189
2185
|
// Every handler reads its input as its schema describes it; a call
|
|
2190
2186
|
// that sends something else is refused here, before anything is read.
|
|
@@ -2199,12 +2195,12 @@ export class WorkspaceModule implements Module {
|
|
|
2199
2195
|
// ResultTooLarge in its place. A call made in process carries none and
|
|
2200
2196
|
// takes the data itself, so its read pages by its line limit alone.
|
|
2201
2197
|
const resultMaxChars = call.resultMaxChars;
|
|
2202
|
-
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
2198
|
+
const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
2203
2199
|
if (generated) {
|
|
2204
2200
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
2205
2201
|
return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
|
|
2206
2202
|
}
|
|
2207
|
-
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
2203
|
+
return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path as string, generated, call.callerAgentName)) };
|
|
2208
2204
|
}
|
|
2209
2205
|
|
|
2210
2206
|
switch (call.name) {
|
|
@@ -2221,13 +2217,41 @@ export class WorkspaceModule implements Module {
|
|
|
2221
2217
|
case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
|
|
2222
2218
|
case 'sync': return await this.handleSync(input as unknown as SyncInput);
|
|
2223
2219
|
default:
|
|
2224
|
-
return { success: false, error:
|
|
2220
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
2225
2221
|
}
|
|
2226
2222
|
} catch (err) {
|
|
2227
|
-
return { success: false, error: err
|
|
2223
|
+
return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
private failureAnswer(call: ToolCall, err: unknown, reopenAtStart: boolean, passedGate: boolean): string {
|
|
2228
|
+
const input = (typeof call.input === 'object' && call.input !== null ? call.input : {}) as { path?: unknown; mount?: unknown };
|
|
2229
|
+
const path = typeof input.path === 'string' ? input.path : undefined;
|
|
2230
|
+
const mountName = path !== undefined ? path.split('/')[0]! : typeof input.mount === 'string' ? input.mount : undefined;
|
|
2231
|
+
const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
|
|
2232
|
+
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
|
|
2233
|
+
if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
|
|
2234
|
+
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
|
|
2235
|
+
}
|
|
2236
|
+
if (err instanceof Refusal) return didNothing(call.name, err.message);
|
|
2237
|
+
if (err instanceof WorkspaceReadError) return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
|
|
2238
|
+
const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
|
|
2239
|
+
: mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
|
|
2240
|
+
: 'ls of each mount, with recursive: true, shows how each file stands against disk now';
|
|
2241
|
+
if (err instanceof NotStored || err instanceof AlreadyHeld) {
|
|
2242
|
+
console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
|
|
2243
|
+
return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
|
|
2244
|
+
}
|
|
2245
|
+
console.error(`[workspace] ${call.name} failed:`, err);
|
|
2246
|
+
if (err instanceof CommitUnknownError) {
|
|
2247
|
+
return unexpectedAnswer(call.name, subject, err.setCause, {
|
|
2248
|
+
said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
|
|
2249
|
+
});
|
|
2228
2250
|
}
|
|
2251
|
+
return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
|
|
2229
2252
|
}
|
|
2230
2253
|
|
|
2254
|
+
|
|
2231
2255
|
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
2232
2256
|
// Wake agents on filesystem events when the originating mount opted in.
|
|
2233
2257
|
// The EventGate still has final say — gate policies can further filter by
|
|
@@ -2272,7 +2296,7 @@ export class WorkspaceModule implements Module {
|
|
|
2272
2296
|
text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
2273
2297
|
}
|
|
2274
2298
|
if (conflicts && conflicts.length > 0) {
|
|
2275
|
-
text += `\n[conflicts: ${conflicts.join(', ')}]`;
|
|
2299
|
+
text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
2276
2300
|
}
|
|
2277
2301
|
|
|
2278
2302
|
return {
|
|
@@ -2323,16 +2347,15 @@ export class WorkspaceModule implements Module {
|
|
|
2323
2347
|
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
2324
2348
|
try {
|
|
2325
2349
|
// 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
|
|
2350
|
+
// in any spelling a disk that folds names may fold to the nested
|
|
2351
|
+
// mount's directory, erring toward that mount (holderOf). The include
|
|
2352
|
+
// judges it as given, so on a disk that
|
|
2330
2353
|
// folds names a covered name whose file disk spells otherwise returns
|
|
2331
2354
|
// that file's path, which the tools refuse, and a covered file named in
|
|
2332
2355
|
// another case is null, which the tools read. A path the mount doesn't
|
|
2333
2356
|
// hold, outside its include or in another mount's directory nested in
|
|
2334
2357
|
// 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
|
|
2358
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
|
|
2336
2359
|
return resolve(mount.config.path, relativePath);
|
|
2337
2360
|
} catch {
|
|
2338
2361
|
return null;
|
|
@@ -2360,6 +2383,9 @@ export class WorkspaceModule implements Module {
|
|
|
2360
2383
|
* `agentName` names the agent whose write it is for the head-move notice
|
|
2361
2384
|
* (a write naming none tells no agent).
|
|
2362
2385
|
*
|
|
2386
|
+
* A success's `disk` says where the file stands on disk as the workspace
|
|
2387
|
+
* last saw it, as the write tool's answer does.
|
|
2388
|
+
*
|
|
2363
2389
|
* A write whose tree entry is set but that then fails returns success
|
|
2364
2390
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2365
2391
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -2396,11 +2422,12 @@ export class WorkspaceModule implements Module {
|
|
|
2396
2422
|
isError: true,
|
|
2397
2423
|
};
|
|
2398
2424
|
}
|
|
2399
|
-
|
|
2425
|
+
if (relativePath === '') return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
|
|
2426
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
2400
2427
|
if (refusal) return { success: false, error: refusal, isError: true };
|
|
2401
2428
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2402
2429
|
if (data.byteLength > maxSize) {
|
|
2403
|
-
return { success: false, error:
|
|
2430
|
+
return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
|
|
2404
2431
|
}
|
|
2405
2432
|
const store = this.getStore();
|
|
2406
2433
|
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
@@ -2414,7 +2441,7 @@ export class WorkspaceModule implements Module {
|
|
|
2414
2441
|
}
|
|
2415
2442
|
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2416
2443
|
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 } : {}) } };
|
|
2444
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
2418
2445
|
}
|
|
2419
2446
|
return this.withMount(mount, async () => {
|
|
2420
2447
|
await this.settleBeforeMutation(mount, relativePath);
|
|
@@ -2429,20 +2456,24 @@ export class WorkspaceModule implements Module {
|
|
|
2429
2456
|
let committed = false;
|
|
2430
2457
|
let failure: string | null;
|
|
2431
2458
|
let modeKept: string | null = null;
|
|
2459
|
+
let disk: DiskState | null = null;
|
|
2432
2460
|
try {
|
|
2433
2461
|
const answer = await this.commitToolChange(mount, relativePath,
|
|
2434
2462
|
{ kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
|
|
2435
2463
|
modeKept = answer.modeKept;
|
|
2464
|
+
disk = answer.disk;
|
|
2436
2465
|
failure = answer.refused === null ? null
|
|
2437
2466
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
2438
2467
|
} catch (error) {
|
|
2439
2468
|
if (!committed) throw error;
|
|
2440
2469
|
// As write words it: a store that failed after recording the file
|
|
2441
2470
|
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2471
|
+
const reopen = this.agreement?.awaitsReopen === true;
|
|
2442
2472
|
failure = error instanceof StoredThenFailed
|
|
2443
2473
|
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
2444
|
-
+ (error.pushes ?
|
|
2474
|
+
+ (error.pushes ? ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
2445
2475
|
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
2476
|
+
if (reopen) failure += ` ${AWAITS_REOPEN}`;
|
|
2446
2477
|
}
|
|
2447
2478
|
if (failure !== null) {
|
|
2448
2479
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -2452,7 +2483,7 @@ export class WorkspaceModule implements Module {
|
|
|
2452
2483
|
}
|
|
2453
2484
|
return {
|
|
2454
2485
|
success: true,
|
|
2455
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
|
|
2486
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
|
|
2456
2487
|
};
|
|
2457
2488
|
});
|
|
2458
2489
|
}
|
|
@@ -2491,10 +2522,12 @@ export class WorkspaceModule implements Module {
|
|
|
2491
2522
|
return this.withMount(mount, async () => {
|
|
2492
2523
|
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
2493
2524
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2494
|
-
if (!entry)
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2525
|
+
if (!entry) {
|
|
2526
|
+
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
|
|
2527
|
+
}
|
|
2528
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2529
|
+
if ('lost' in contents) return { error: nothingToRead(contents.lost) };
|
|
2530
|
+
return { data: contents.bytes };
|
|
2498
2531
|
});
|
|
2499
2532
|
}
|
|
2500
2533
|
|
|
@@ -2577,9 +2610,9 @@ export class WorkspaceModule implements Module {
|
|
|
2577
2610
|
}
|
|
2578
2611
|
}
|
|
2579
2612
|
|
|
2580
|
-
/** The directories of the mounts nested inside a mount, mount-relative
|
|
2613
|
+
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
2581
2614
|
private othersOf(mountName: string): string[] {
|
|
2582
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2615
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
|
|
2583
2616
|
}
|
|
2584
2617
|
|
|
2585
2618
|
/**
|
|
@@ -2595,13 +2628,8 @@ export class WorkspaceModule implements Module {
|
|
|
2595
2628
|
* alone, with no real root, since through it it would claim the other's
|
|
2596
2629
|
* every path.
|
|
2597
2630
|
*
|
|
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
|
|
2631
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`).
|
|
2632
|
+
* Each mount's real root comes back beside the nesting (`reals`), for what
|
|
2605
2633
|
* start reads there, and each directory above each configured path with
|
|
2606
2634
|
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2607
2635
|
* (movedNesting).
|
|
@@ -2626,16 +2654,15 @@ export class WorkspaceModule implements Module {
|
|
|
2626
2654
|
for (const inner of this.config.mounts) {
|
|
2627
2655
|
if (outer === inner) continue;
|
|
2628
2656
|
const real = reals.get(inner.name)!;
|
|
2629
|
-
const at =
|
|
2657
|
+
const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!);
|
|
2630
2658
|
if (at === undefined) continue;
|
|
2631
|
-
const { configured, fromRoots,
|
|
2632
|
-
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots
|
|
2659
|
+
const { configured, fromRoots, claims } = at;
|
|
2660
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
|
|
2633
2661
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2634
2662
|
if (placed.length > 0) {
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
);
|
|
2663
|
+
const at = (sure: boolean) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
|
|
2664
|
+
const where = [at(true) && `where it lies on disk, at ${at(true)}`, at(false) && `where it may lie on disk, at ${at(false)}`].filter(Boolean).join(', and ');
|
|
2665
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
|
|
2639
2666
|
}
|
|
2640
2667
|
}
|
|
2641
2668
|
}
|
|
@@ -2644,11 +2671,11 @@ export class WorkspaceModule implements Module {
|
|
|
2644
2671
|
|
|
2645
2672
|
/**
|
|
2646
2673
|
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2647
|
-
* has one, and include (MountView's `nested`)
|
|
2674
|
+
* has one, and include (MountView's `nested`).
|
|
2648
2675
|
*/
|
|
2649
2676
|
private nestedOf(mountName: string): NestedMount[] {
|
|
2650
2677
|
return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
|
|
2651
|
-
o.
|
|
2678
|
+
o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
|
|
2652
2679
|
}
|
|
2653
2680
|
|
|
2654
2681
|
/**
|
|
@@ -2747,10 +2774,12 @@ export class WorkspaceModule implements Module {
|
|
|
2747
2774
|
if (real === undefined) continue;
|
|
2748
2775
|
above = placedAbove.get(inner.name) ?? [];
|
|
2749
2776
|
}
|
|
2750
|
-
const at =
|
|
2777
|
+
const at = placementOf(outerPath, rootReal, innerPath, real, above);
|
|
2751
2778
|
if (at === undefined) continue;
|
|
2752
2779
|
const include = this.mounts.get(inner.name)?.config.include;
|
|
2753
|
-
for (const s of at.
|
|
2780
|
+
for (const s of at.fromRoots) {
|
|
2781
|
+
nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2782
|
+
}
|
|
2754
2783
|
}
|
|
2755
2784
|
return nested;
|
|
2756
2785
|
}
|
|
@@ -2760,33 +2789,22 @@ export class WorkspaceModule implements Module {
|
|
|
2760
2789
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2761
2790
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2762
2791
|
* 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.
|
|
2792
|
+
* no nested mount's directory holds it.
|
|
2770
2793
|
*/
|
|
2771
|
-
private holderOf(mountName: string, relativePath: string,
|
|
2794
|
+
private holderOf(mountName: string, relativePath: string, moved: NestedMount[] | null = null): { name: string; use: string; guess?: NestedGuess } | undefined {
|
|
2772
2795
|
const mount = this.mounts.get(mountName);
|
|
2773
2796
|
if (!mount) return undefined;
|
|
2774
2797
|
const root = resolve(mount.config.path);
|
|
2775
2798
|
const resolved = resolve(root, relativePath);
|
|
2776
2799
|
if (!isContainedPath(root, resolved)) return undefined;
|
|
2777
|
-
const
|
|
2800
|
+
const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
|
|
2778
2801
|
// A direct mount whose root a call found moved is judged by that call's
|
|
2779
2802
|
// 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];
|
|
2803
|
+
const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
|
|
2787
2804
|
if (!holder) return undefined;
|
|
2788
|
-
const rest =
|
|
2789
|
-
|
|
2805
|
+
const rest = restBeneath(path, holder);
|
|
2806
|
+
const guess = nestedGuess(mountName, path.split('/'), holder);
|
|
2807
|
+
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
|
|
2790
2808
|
}
|
|
2791
2809
|
|
|
2792
2810
|
/**
|
|
@@ -2795,10 +2813,10 @@ export class WorkspaceModule implements Module {
|
|
|
2795
2813
|
* it; for a listing, whose the path is.
|
|
2796
2814
|
*/
|
|
2797
2815
|
private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
|
|
2798
|
-
const holder = this.holderOf(mountName, relativePath,
|
|
2816
|
+
const holder = this.holderOf(mountName, relativePath, moved);
|
|
2799
2817
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2800
2818
|
return holder
|
|
2801
|
-
? `it lies in mount "${holder.name}", nested in this one${what}:
|
|
2819
|
+
? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
|
|
2802
2820
|
: `it lies in another mount's directory${what}`;
|
|
2803
2821
|
}
|
|
2804
2822
|
|
|
@@ -2818,7 +2836,7 @@ export class WorkspaceModule implements Module {
|
|
|
2818
2836
|
// Both checks judge by the nesting the parse found, and the second reads
|
|
2819
2837
|
// disk beneath the root the parse resolved.
|
|
2820
2838
|
const { mount, root, moved } = spelled;
|
|
2821
|
-
const parsed = this.refuseNotHeld(spelled, path,
|
|
2839
|
+
const parsed = this.refuseNotHeld(spelled, path, moved);
|
|
2822
2840
|
// A path can also reach a nested mount's directory, or a file this
|
|
2823
2841
|
// mount's own include leaves out, through a symlink on its way. No push
|
|
2824
2842
|
// of this mount writes it there and no read reads it, so it's refused
|
|
@@ -2858,7 +2876,8 @@ export class WorkspaceModule implements Module {
|
|
|
2858
2876
|
// operation, in the words a disk read gives a path it can't resolve,
|
|
2859
2877
|
// whatever the place realpath stopped short of would say.
|
|
2860
2878
|
if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2861
|
-
|
|
2879
|
+
let holder = await nestedAtReal(real, nested);
|
|
2880
|
+
if (holder?.guess === null) holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
|
|
2862
2881
|
if (!holder) {
|
|
2863
2882
|
if (!judged) return null;
|
|
2864
2883
|
const rootReal = await realBeneath(root);
|
|
@@ -2874,11 +2893,11 @@ export class WorkspaceModule implements Module {
|
|
|
2874
2893
|
// on the place's way as that mount's, so a place beneath anything else
|
|
2875
2894
|
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2876
2895
|
// a directory not there yet is.
|
|
2877
|
-
const onItsWay = holder.
|
|
2896
|
+
const onItsWay = !holder.judgedByInclude
|
|
2878
2897
|
|| 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
|
|
2898
|
+
const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2899
|
+
if (!onItsWay || !holder.names(directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2900
|
+
return new NestedMountError(path, mount.config.name, holder, 'leads into');
|
|
2882
2901
|
}
|
|
2883
2902
|
|
|
2884
2903
|
/**
|
|
@@ -2886,13 +2905,12 @@ export class WorkspaceModule implements Module {
|
|
|
2886
2905
|
* mount's directory first, naming the mount that holds it, then outside
|
|
2887
2906
|
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2888
2907
|
* 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
|
|
2908
|
+
* which is synchronous. The include's refusal shows the path as given
|
|
2891
2909
|
* (`given`), since it answers the path the caller asked about.
|
|
2892
2910
|
*/
|
|
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
|
|
2911
|
+
private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, moved: NestedMount[] | null = null): P {
|
|
2912
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
|
|
2913
|
+
if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
|
|
2896
2914
|
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2897
2915
|
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2898
2916
|
}
|
|
@@ -2942,7 +2960,7 @@ export class WorkspaceModule implements Module {
|
|
|
2942
2960
|
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2943
2961
|
*/
|
|
2944
2962
|
private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
|
|
2945
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
2963
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) return false;
|
|
2946
2964
|
if (insideMount(relativePath, mount.config.include)) return false;
|
|
2947
2965
|
const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
|
|
2948
2966
|
if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
|
|
@@ -3015,18 +3033,12 @@ export class WorkspaceModule implements Module {
|
|
|
3015
3033
|
|
|
3016
3034
|
/**
|
|
3017
3035
|
* How many segments the innermost nested mount's directory that holds
|
|
3018
|
-
* `relativePath` has,
|
|
3019
|
-
*
|
|
3036
|
+
* `relativePath` has, or, for a direct mount whose root has moved, in the
|
|
3037
|
+
* nesting there (`moved`);
|
|
3020
3038
|
* undefined when no nested mount's directory holds it.
|
|
3021
3039
|
*/
|
|
3022
3040
|
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;
|
|
3041
|
+
return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
|
|
3030
3042
|
}
|
|
3031
3043
|
|
|
3032
3044
|
/**
|
|
@@ -3049,11 +3061,7 @@ export class WorkspaceModule implements Module {
|
|
|
3049
3061
|
// Only an entry this mount's include covers spells a draft's path
|
|
3050
3062
|
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
3051
3063
|
// 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.
|
|
3064
|
+
// respell a covered draft into a name the include leaves out.
|
|
3057
3065
|
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
3058
3066
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
3059
3067
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
@@ -3079,25 +3087,24 @@ export class WorkspaceModule implements Module {
|
|
|
3079
3087
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
3080
3088
|
// store would keep one name and disk another.
|
|
3081
3089
|
if (LONE_SURROGATE.test(path)) {
|
|
3082
|
-
throw new
|
|
3090
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.`);
|
|
3083
3091
|
}
|
|
3084
3092
|
const slashIdx = path.indexOf('/');
|
|
3085
3093
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
3086
3094
|
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
3087
3095
|
|
|
3088
3096
|
const mount = this.mounts.get(mountName);
|
|
3089
|
-
if (!mount)
|
|
3090
|
-
throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
|
|
3091
|
-
}
|
|
3097
|
+
if (!mount) throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
|
|
3092
3098
|
const segments = pathSegments(given);
|
|
3093
3099
|
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
3094
|
-
throw new
|
|
3100
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root, as "${mountName}/<path>".`);
|
|
3095
3101
|
}
|
|
3096
3102
|
const kept: string[] = [];
|
|
3097
3103
|
for (const segment of segments) {
|
|
3098
3104
|
if (segment !== '..') kept.push(segment);
|
|
3099
3105
|
else if (kept.pop() === undefined) {
|
|
3100
|
-
throw new
|
|
3106
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
|
|
3107
|
+
+ `Name a file from its own mount's name; ${this.mountList()}.`);
|
|
3101
3108
|
}
|
|
3102
3109
|
}
|
|
3103
3110
|
const relativePath = kept.join('/');
|
|
@@ -3112,11 +3119,41 @@ export class WorkspaceModule implements Module {
|
|
|
3112
3119
|
const root = resolve(mount.config.path);
|
|
3113
3120
|
const resolved = resolve(root, relativePath);
|
|
3114
3121
|
if (!isContainedPath(root, resolved)) {
|
|
3115
|
-
throw new
|
|
3122
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} resolves outside mount "${mountName}", as a drive or root named in it does: give the path from the mount's root, as "${mountName}/<path>".`);
|
|
3116
3123
|
}
|
|
3117
3124
|
return { mount, relativePath };
|
|
3118
3125
|
}
|
|
3119
3126
|
|
|
3127
|
+
private writableNames(): string[] {
|
|
3128
|
+
return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
private mountList(): string {
|
|
3132
|
+
const names = [...this.mounts.keys()];
|
|
3133
|
+
return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
private noSuchMountField(given: string, bare: string): string {
|
|
3137
|
+
const [close] = closestNames(given, [...this.mounts.keys()], 1);
|
|
3138
|
+
return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
|
|
3139
|
+
+ (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
|
|
3140
|
+
+ ` Leaving "mount" out ${bare}.`;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
private noSuchMount(path: string, mountName: string): string {
|
|
3144
|
+
const names = [...this.mounts.keys()];
|
|
3145
|
+
const rest = path.slice(mountName.length);
|
|
3146
|
+
const unslashed = path.replace(/^[/\\]+/, '');
|
|
3147
|
+
if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
|
|
3148
|
+
return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
|
|
3149
|
+
}
|
|
3150
|
+
if (mountName === '') return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
3151
|
+
const [close] = closestNames(mountName, names, 1);
|
|
3152
|
+
const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
|
|
3153
|
+
: `${JSON.stringify(path)} starts with ${JSON.stringify(mountName)}, which is no mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
3154
|
+
return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3120
3157
|
/**
|
|
3121
3158
|
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
3122
3159
|
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
@@ -3140,31 +3177,26 @@ export class WorkspaceModule implements Module {
|
|
|
3140
3177
|
maxSize: number,
|
|
3141
3178
|
): { bytes: Buffer; mimeType: SupportedImageMimeType } {
|
|
3142
3179
|
if (bytes.byteLength === 0) {
|
|
3143
|
-
throw
|
|
3180
|
+
throw emptyImage(mountPrefixedPath);
|
|
3144
3181
|
}
|
|
3145
|
-
if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3182
|
+
if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
|
|
3146
3183
|
return {
|
|
3147
3184
|
bytes,
|
|
3148
3185
|
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
3149
3186
|
};
|
|
3150
3187
|
}
|
|
3151
3188
|
|
|
3152
|
-
private
|
|
3189
|
+
private async readImageFromTree(
|
|
3153
3190
|
mount: MountState,
|
|
3154
3191
|
relativePath: string,
|
|
3155
3192
|
mountPrefixedPath: string,
|
|
3156
3193
|
maxSize: number,
|
|
3157
|
-
): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
|
|
3158
|
-
const
|
|
3159
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3194
|
+
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> {
|
|
3195
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
3160
3196
|
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);
|
|
3197
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
3198
|
+
if ('lost' in contents) return contents;
|
|
3199
|
+
return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
|
|
3168
3200
|
}
|
|
3169
3201
|
|
|
3170
3202
|
/**
|
|
@@ -3181,19 +3213,14 @@ export class WorkspaceModule implements Module {
|
|
|
3181
3213
|
* another directory carries none. The identity is rootIdentityForRead's,
|
|
3182
3214
|
* which answers while the evidence awaits reopening; where even it can't
|
|
3183
3215
|
* be read, as when the evidence was never read back whole, the read is
|
|
3184
|
-
* refused
|
|
3216
|
+
* refused before its root is resolved, with an errno only where no
|
|
3217
|
+
* directory is at the root's path.
|
|
3185
3218
|
* A direct mount's root is checked against no identity, and its read reads
|
|
3186
3219
|
* no evidence: it promises what's at its root now (diskView).
|
|
3187
3220
|
*/
|
|
3188
3221
|
private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
|
|
3189
3222
|
const name = mount.config.name;
|
|
3190
3223
|
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
3224
|
let rootIdentity: MountView['rootIdentity'];
|
|
3198
3225
|
if (!mount.direct) {
|
|
3199
3226
|
try {
|
|
@@ -3201,17 +3228,22 @@ export class WorkspaceModule implements Module {
|
|
|
3201
3228
|
} catch (err) {
|
|
3202
3229
|
// The evidence can't be read back, so neither can the check be made.
|
|
3203
3230
|
// 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));
|
|
3231
|
+
throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
|
|
3206
3232
|
}
|
|
3207
3233
|
}
|
|
3234
|
+
let real: string;
|
|
3235
|
+
try {
|
|
3236
|
+
real = await realpath(root);
|
|
3237
|
+
} catch (err) {
|
|
3238
|
+
throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
|
|
3239
|
+
}
|
|
3208
3240
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
3209
3241
|
if ('dir' in checked) return { real, dir: checked.dir };
|
|
3210
3242
|
// Another directory is no absence. For any other refusal, what is at the
|
|
3211
3243
|
// root's path now decides: nothing, something that isn't a directory, or
|
|
3212
3244
|
// a directory the path has come to name since it was resolved (no errno).
|
|
3213
3245
|
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
3214
|
-
throw new
|
|
3246
|
+
throw new RootUnavailable(name, checked.reason, 'root', errno);
|
|
3215
3247
|
}
|
|
3216
3248
|
|
|
3217
3249
|
/**
|
|
@@ -3226,7 +3258,7 @@ export class WorkspaceModule implements Module {
|
|
|
3226
3258
|
private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
|
|
3227
3259
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
3228
3260
|
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
3229
|
-
return new
|
|
3261
|
+
return new RootUnavailable(mount.config.name, reason, 'root', errno);
|
|
3230
3262
|
}
|
|
3231
3263
|
|
|
3232
3264
|
/**
|
|
@@ -3328,7 +3360,7 @@ export class WorkspaceModule implements Module {
|
|
|
3328
3360
|
}
|
|
3329
3361
|
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
3330
3362
|
// A FIFO, socket or device is never opened.
|
|
3331
|
-
throw new
|
|
3363
|
+
throw new NotRegularFile(mountPrefixedPath, 'lstat');
|
|
3332
3364
|
}
|
|
3333
3365
|
|
|
3334
3366
|
let handle: Awaited<ReturnType<typeof open>>;
|
|
@@ -3364,7 +3396,7 @@ export class WorkspaceModule implements Module {
|
|
|
3364
3396
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
3365
3397
|
}
|
|
3366
3398
|
if (!fileStat.isFile()) {
|
|
3367
|
-
throw new
|
|
3399
|
+
throw new NotRegularFile(mountPrefixedPath, 'fstat');
|
|
3368
3400
|
}
|
|
3369
3401
|
|
|
3370
3402
|
if (!follow && !useNoFollow) {
|
|
@@ -3403,13 +3435,13 @@ export class WorkspaceModule implements Module {
|
|
|
3403
3435
|
if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
3404
3436
|
|
|
3405
3437
|
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
3406
|
-
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3438
|
+
const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
|
|
3407
3439
|
// A place in a nested mount's directory that its include leaves out is
|
|
3408
3440
|
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
3409
3441
|
// does one reaching a file this mount's own include leaves out
|
|
3410
3442
|
// (coversLanding).
|
|
3411
3443
|
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
3412
|
-
if (!inside || own || (holder !== undefined && !
|
|
3444
|
+
if (!inside || own || (holder !== undefined && !holder.names(false))) {
|
|
3413
3445
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
3414
3446
|
}
|
|
3415
3447
|
if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
|
|
@@ -3477,7 +3509,9 @@ export class WorkspaceModule implements Module {
|
|
|
3477
3509
|
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
3478
3510
|
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
3479
3511
|
* directory, policy-denied symlink, outside-mount target, and
|
|
3480
|
-
* file-changed-during-read.
|
|
3512
|
+
* file-changed-during-read. An error met while parsing the path that is
|
|
3513
|
+
* none of these refusals, such as the store's, is thrown as it is, and a
|
|
3514
|
+
* `maxBytes` that isn't a non-negative integer throws a `RangeError`.
|
|
3481
3515
|
*/
|
|
3482
3516
|
async readFileFromDisk(
|
|
3483
3517
|
mountPrefixedPath: string,
|
|
@@ -3493,11 +3527,11 @@ export class WorkspaceModule implements Module {
|
|
|
3493
3527
|
} catch (err) {
|
|
3494
3528
|
// A link's refusal (parseFilePath) carries its own code.
|
|
3495
3529
|
if (err instanceof WorkspaceReadError) throw err;
|
|
3496
|
-
const
|
|
3497
|
-
const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3530
|
+
const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3498
3531
|
: err instanceof NestedMountError ? 'other_mount'
|
|
3499
|
-
:
|
|
3500
|
-
|
|
3532
|
+
: err instanceof PathRefusal ? err.code : null;
|
|
3533
|
+
if (code === null) throw err;
|
|
3534
|
+
throw new WorkspaceReadError(code, (err as Error).message, 'parse');
|
|
3501
3535
|
}
|
|
3502
3536
|
if (!relativePath) {
|
|
3503
3537
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
@@ -3525,92 +3559,25 @@ export class WorkspaceModule implements Module {
|
|
|
3525
3559
|
}
|
|
3526
3560
|
}
|
|
3527
3561
|
|
|
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
3562
|
private async readImageFromFilesystem(
|
|
3580
3563
|
mount: MountState,
|
|
3581
3564
|
relativePath: string,
|
|
3582
3565
|
mountPrefixedPath: string,
|
|
3583
3566
|
maxSize: number,
|
|
3584
3567
|
): 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;
|
|
3568
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
3569
|
+
if (fileStat.size === 0) throw emptyImage(mountPrefixedPath);
|
|
3570
|
+
if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
|
|
3571
|
+
});
|
|
3602
3572
|
try {
|
|
3603
3573
|
// No more than maxSize is loaded: a file grown past it since the fstat
|
|
3604
3574
|
// above is refused here, as one already over it was there.
|
|
3605
3575
|
const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
|
|
3606
|
-
if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3576
|
+
if (more) throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
|
|
3607
3577
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
3608
3578
|
} catch (err) {
|
|
3609
3579
|
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}`);
|
|
3580
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
3614
3581
|
} finally {
|
|
3615
3582
|
await handle.close();
|
|
3616
3583
|
}
|
|
@@ -3643,18 +3610,19 @@ export class WorkspaceModule implements Module {
|
|
|
3643
3610
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
3644
3611
|
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
3645
3612
|
* deletion that cleared a conflict naming a disk file the workspace never
|
|
3646
|
-
* took in, that nothing was unlinked (`note`).
|
|
3647
|
-
*
|
|
3648
|
-
* the path,
|
|
3613
|
+
* took in, that nothing was unlinked (`note`). And the answer's `disk` for
|
|
3614
|
+
* the path, unless it was refused. The caller holds the mount's turn and has
|
|
3615
|
+
* settled the path first (settleBeforeMutation, or a pass over the path, as
|
|
3616
|
+
* an edit makes).
|
|
3649
3617
|
*/
|
|
3650
3618
|
private async commitToolChange(
|
|
3651
3619
|
mount: MountState,
|
|
3652
3620
|
relativePath: string,
|
|
3653
3621
|
change: ToolChange,
|
|
3654
3622
|
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 };
|
|
3623
|
+
): Promise<{ refused: string | null; modeKept: string | null; note: string | null; disk: DiskState | null }> {
|
|
3624
|
+
const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3625
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
|
|
3658
3626
|
}
|
|
3659
3627
|
|
|
3660
3628
|
/**
|
|
@@ -3670,15 +3638,16 @@ export class WorkspaceModule implements Module {
|
|
|
3670
3638
|
* (changesLanded).
|
|
3671
3639
|
* Answers, by path, the reason for each that reached the workspace but not
|
|
3672
3640
|
* 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
|
-
*
|
|
3641
|
+
* left the file's mode as disk had it (`modeKept`), that a deletion
|
|
3642
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3643
|
+
* and the answer's `disk` for each path not refused, as commitToolChange
|
|
3644
|
+
* answers them for one path.
|
|
3676
3645
|
*/
|
|
3677
3646
|
private async commitToolChanges(
|
|
3678
3647
|
mount: MountState,
|
|
3679
3648
|
changes: Array<{ path: string; change: ToolChange }>,
|
|
3680
3649
|
onCommitted?: (path: string) => void,
|
|
3681
|
-
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
|
|
3650
|
+
): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string>; disk: Map<string, DiskState> }> {
|
|
3682
3651
|
const store = this.getStore();
|
|
3683
3652
|
const agreement = this.agreementOrThrow();
|
|
3684
3653
|
const intents = this.intents.get(mount.config.name)!;
|
|
@@ -3817,7 +3786,7 @@ export class WorkspaceModule implements Module {
|
|
|
3817
3786
|
// nothing.
|
|
3818
3787
|
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3819
3788
|
putBack();
|
|
3820
|
-
throw error;
|
|
3789
|
+
throw changing.length === 0 ? new AlreadyHeld(error) : error;
|
|
3821
3790
|
}
|
|
3822
3791
|
landedThenThrew = { error };
|
|
3823
3792
|
}
|
|
@@ -3831,7 +3800,9 @@ export class WorkspaceModule implements Module {
|
|
|
3831
3800
|
if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3832
3801
|
const refused = new Map<string, string>();
|
|
3833
3802
|
const modeKept = new Map<string, string>();
|
|
3834
|
-
if (!this.pushesOnCommit(mount) || pushing.length === 0)
|
|
3803
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) {
|
|
3804
|
+
return { refused, modeKept, notes, disk: new Map(changes.map(({ path }): [string, DiskState] => [path, this.diskOf(mount, path)])) };
|
|
3805
|
+
}
|
|
3835
3806
|
|
|
3836
3807
|
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3837
3808
|
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
@@ -3853,7 +3824,7 @@ export class WorkspaceModule implements Module {
|
|
|
3853
3824
|
error: skipped.reason,
|
|
3854
3825
|
} as ProcessEvent);
|
|
3855
3826
|
}
|
|
3856
|
-
return { refused, modeKept, notes };
|
|
3827
|
+
return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }): [string, DiskState] => [path, 'done'])) };
|
|
3857
3828
|
}
|
|
3858
3829
|
|
|
3859
3830
|
// ==========================================================================
|
|
@@ -3904,26 +3875,30 @@ export class WorkspaceModule implements Module {
|
|
|
3904
3875
|
* call made again checks it.
|
|
3905
3876
|
*/
|
|
3906
3877
|
private notCheckedYet(path: string, reason: string): string {
|
|
3907
|
-
return `${
|
|
3878
|
+
return `${JSON.stringify(path)} isn't in the workspace, and disk couldn't be checked: ${reason}. Make the same call again, which checks disk first.`;
|
|
3908
3879
|
}
|
|
3909
3880
|
|
|
3910
3881
|
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
3911
|
-
private notInWorkspace(path: string, report: PathReport | undefined): string {
|
|
3882
|
+
private notInWorkspace(path: string, report: PathReport | undefined, mount: MountState, missing?: string): string {
|
|
3883
|
+
const shown = JSON.stringify(path);
|
|
3912
3884
|
switch (report?.state) {
|
|
3913
3885
|
case 'workspace-deleted':
|
|
3914
|
-
return `${
|
|
3886
|
+
return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
3915
3887
|
case 'not-in-branch':
|
|
3916
|
-
return `${
|
|
3917
|
-
case 'disk-only':
|
|
3918
|
-
|
|
3919
|
-
|
|
3888
|
+
return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
3889
|
+
case 'disk-only': {
|
|
3890
|
+
const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
3891
|
+
return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
|
|
3892
|
+
+ 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
|
|
3893
|
+
+ (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
|
|
3894
|
+
}
|
|
3920
3895
|
case 'conflict':
|
|
3921
|
-
return `${
|
|
3896
|
+
return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
3922
3897
|
'Sync this path to take the disk version.';
|
|
3923
3898
|
case 'unverified':
|
|
3924
|
-
return
|
|
3899
|
+
return uncheckedFile(shown, report.note);
|
|
3925
3900
|
default:
|
|
3926
|
-
return `
|
|
3901
|
+
return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
3927
3902
|
}
|
|
3928
3903
|
}
|
|
3929
3904
|
|
|
@@ -3966,14 +3941,14 @@ export class WorkspaceModule implements Module {
|
|
|
3966
3941
|
// A pass also drops a copy disk made binary or too large, which
|
|
3967
3942
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3968
3943
|
return {
|
|
3969
|
-
error: took('deleted') && report?.state !== 'disk-only'
|
|
3970
|
-
|
|
3971
|
-
|
|
3944
|
+
error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
|
|
3945
|
+
: undecided !== null ? this.notCheckedYet(path, undecided)
|
|
3946
|
+
: this.notInWorkspace(path, report, mount),
|
|
3972
3947
|
};
|
|
3973
3948
|
}
|
|
3974
|
-
const
|
|
3975
|
-
if (
|
|
3976
|
-
return { bytes:
|
|
3949
|
+
const contents = await this.contentsOf(mount, relativePath, path, entry);
|
|
3950
|
+
if ('lost' in contents) return { error: nothingToRead(contents.lost) };
|
|
3951
|
+
return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3977
3952
|
});
|
|
3978
3953
|
}
|
|
3979
3954
|
|
|
@@ -3987,33 +3962,144 @@ export class WorkspaceModule implements Module {
|
|
|
3987
3962
|
*/
|
|
3988
3963
|
private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3989
3964
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3990
|
-
const over = `${path} is
|
|
3965
|
+
const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
|
|
3991
3966
|
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
3992
3967
|
try {
|
|
3993
3968
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3994
|
-
if (fileStat.size > maxSize) throw new
|
|
3969
|
+
if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
3995
3970
|
});
|
|
3996
3971
|
} catch (err) {
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
3972
|
+
if (err instanceof Refusal) return { error: err.message };
|
|
3973
|
+
if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
|
|
3974
|
+
throw err;
|
|
4000
3975
|
}
|
|
4001
3976
|
try {
|
|
4002
3977
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
4003
3978
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4004
|
-
if (more) return { error: over };
|
|
3979
|
+
if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4005
3980
|
if (looksBinary(bytes)) {
|
|
4006
|
-
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '
|
|
3981
|
+
return { error: `${JSON.stringify(path)} is binary, so read doesn't show it as text. ${sniffImageMime(bytes) ? 'read_image shows it, if its data holds together as an image.' : 'No tool shows it as text, so calling again won\'t change that.'}` };
|
|
4007
3982
|
}
|
|
4008
3983
|
return { bytes };
|
|
4009
3984
|
} catch (err) {
|
|
4010
|
-
|
|
4011
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
3985
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4012
3986
|
} finally {
|
|
4013
3987
|
await opened.handle.close();
|
|
4014
3988
|
}
|
|
4015
3989
|
}
|
|
4016
3990
|
|
|
3991
|
+
private async contentsOf(
|
|
3992
|
+
mount: MountState,
|
|
3993
|
+
relativePath: string,
|
|
3994
|
+
path: string,
|
|
3995
|
+
version: { blobHash: string; size: number },
|
|
3996
|
+
what: 'saved' | 'conflict-disk' = 'saved',
|
|
3997
|
+
tell: 'now' | 'later' = 'now',
|
|
3998
|
+
): Promise<{ bytes: Buffer } | { lost: LostContents }> {
|
|
3999
|
+
const store = this.getStore();
|
|
4000
|
+
const stored = storedBytes(store, version.blobHash);
|
|
4001
|
+
if ('bytes' in stored) return stored;
|
|
4002
|
+
const otherSize = new Error('other size');
|
|
4003
|
+
let found: { bytes: Buffer } | { disk: LostContents['disk'] };
|
|
4004
|
+
try {
|
|
4005
|
+
const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4006
|
+
if (fileStat.size !== version.size) throw otherSize;
|
|
4007
|
+
});
|
|
4008
|
+
try {
|
|
4009
|
+
const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
|
|
4010
|
+
found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
|
|
4011
|
+
} finally {
|
|
4012
|
+
await opened.handle.close();
|
|
4013
|
+
}
|
|
4014
|
+
} catch (err) {
|
|
4015
|
+
found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
|
|
4016
|
+
}
|
|
4017
|
+
const subject = contentsSubject(what, path);
|
|
4018
|
+
const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
|
|
4019
|
+
if ('disk' in found) {
|
|
4020
|
+
const now = storedBytes(store, version.blobHash);
|
|
4021
|
+
if ('bytes' in now) return now;
|
|
4022
|
+
const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
|
|
4023
|
+
if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost);
|
|
4024
|
+
return { lost };
|
|
4025
|
+
}
|
|
4026
|
+
const { bytes } = found;
|
|
4027
|
+
if ('bytes' in storedBytes(store, version.blobHash)) return { bytes };
|
|
4028
|
+
let thrown: string | null = null;
|
|
4029
|
+
try {
|
|
4030
|
+
store.storeBlob(bytes, 'application/octet-stream');
|
|
4031
|
+
} catch (error) {
|
|
4032
|
+
thrown = error instanceof Error ? error.message : String(error);
|
|
4033
|
+
}
|
|
4034
|
+
// Chronicle's storeBlob takes any file already at the hash's path as
|
|
4035
|
+
// stored, so whether the bytes took is what a read of them gives now.
|
|
4036
|
+
const now = storedBytes(store, version.blobHash);
|
|
4037
|
+
if ('bytes' in now) {
|
|
4038
|
+
this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace',
|
|
4039
|
+
`Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
|
|
4040
|
+
} else {
|
|
4041
|
+
const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
|
|
4042
|
+
this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace',
|
|
4043
|
+
`Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
|
|
4044
|
+
+ ('unreadable' in now
|
|
4045
|
+
? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them."
|
|
4046
|
+
: 'Each read does this again until the store takes them.'),
|
|
4047
|
+
{ mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
|
|
4048
|
+
}
|
|
4049
|
+
return { bytes };
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void {
|
|
4053
|
+
this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace',
|
|
4054
|
+
`Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
|
|
4055
|
+
+ "Putting back the store's blob file for that hash, from a copy of the store, brings them back.",
|
|
4056
|
+
{ mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
|
|
4060
|
+
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
4061
|
+
if (!absent) return { unreadable: containedRefusal(err, path) };
|
|
4062
|
+
const root = resolve(mount.config.path);
|
|
4063
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
4064
|
+
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
4065
|
+
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
4066
|
+
if (typeof proof !== 'string') return 'none';
|
|
4067
|
+
return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
|
|
4071
|
+
mount: MountState,
|
|
4072
|
+
versions: readonly T[],
|
|
4073
|
+
each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void,
|
|
4074
|
+
): Promise<void> {
|
|
4075
|
+
const byHash = new Map<string, T[]>();
|
|
4076
|
+
for (const version of versions) {
|
|
4077
|
+
const sharing = byHash.get(version.hash);
|
|
4078
|
+
if (sharing) sharing.push(version);
|
|
4079
|
+
else byHash.set(version.hash, [version]);
|
|
4080
|
+
}
|
|
4081
|
+
for (const [hash, sharing] of byHash) {
|
|
4082
|
+
let bytes: Buffer | null = null;
|
|
4083
|
+
const lost: Array<[T, LostContents]> = [];
|
|
4084
|
+
for (const version of sharing) {
|
|
4085
|
+
const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
|
|
4086
|
+
if ('bytes' in contents) {
|
|
4087
|
+
bytes = contents.bytes;
|
|
4088
|
+
break;
|
|
4089
|
+
}
|
|
4090
|
+
lost.push([version, contents.lost]);
|
|
4091
|
+
}
|
|
4092
|
+
if (bytes === null) {
|
|
4093
|
+
for (const [version, found] of lost) {
|
|
4094
|
+
this.tellLost(mount.config.name, version.what, hash, found);
|
|
4095
|
+
each(version, { lost: found });
|
|
4096
|
+
}
|
|
4097
|
+
} else {
|
|
4098
|
+
for (const version of sharing) each(version, { bytes });
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
|
|
4017
4103
|
// ==========================================================================
|
|
4018
4104
|
// Tool Handlers
|
|
4019
4105
|
// ==========================================================================
|
|
@@ -4029,15 +4115,16 @@ export class WorkspaceModule implements Module {
|
|
|
4029
4115
|
// Each number is a whole number of at least its schema's minimum by now
|
|
4030
4116
|
// (inputProblem).
|
|
4031
4117
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
4032
|
-
return { success: false, isError: true, error: '
|
|
4118
|
+
return { success: false, isError: true, error: didNothing('read', 'it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.') };
|
|
4033
4119
|
}
|
|
4034
4120
|
let file: FileText;
|
|
4035
4121
|
let notes: string[] = [];
|
|
4036
4122
|
if (generatedContent === undefined) {
|
|
4037
4123
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4124
|
+
if (relativePath === '') return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4038
4125
|
input = { ...input, path };
|
|
4039
4126
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
4040
|
-
if ('error' in found) return { success: false, error: found.error, isError: true };
|
|
4127
|
+
if ('error' in found) return { success: false, error: didNothing('read', found.error), isError: true };
|
|
4041
4128
|
file = fileText(found.bytes);
|
|
4042
4129
|
notes = found.notes;
|
|
4043
4130
|
} else {
|
|
@@ -4053,32 +4140,23 @@ export class WorkspaceModule implements Module {
|
|
|
4053
4140
|
}
|
|
4054
4141
|
|
|
4055
4142
|
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
|
-
}
|
|
4143
|
+
const given = input.path;
|
|
4144
|
+
const refused = (body: string): ToolResult => ({ success: false, isError: true, error: didNothing('read_image', body) });
|
|
4145
|
+
const { mount, relativePath, path } = await this.parseFilePath(given);
|
|
4146
|
+
if (!relativePath) return refused(namesRoot(given, mount.config.name));
|
|
4147
|
+
input = { ...input, path };
|
|
4072
4148
|
|
|
4073
4149
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4074
4150
|
let treeError: WorkspaceImageReadError | null = null;
|
|
4151
|
+
let lost: LostContents | null = null;
|
|
4075
4152
|
|
|
4076
4153
|
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
4077
4154
|
// mount holds none, so its image is disk's.
|
|
4078
4155
|
if (!mount.direct) {
|
|
4079
4156
|
try {
|
|
4080
|
-
const image = this.
|
|
4081
|
-
if (image)
|
|
4157
|
+
const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
|
|
4158
|
+
if (image && 'lost' in image) lost = image.lost;
|
|
4159
|
+
else if (image) return imageResult(input.path, image);
|
|
4082
4160
|
} catch (err) {
|
|
4083
4161
|
if (err instanceof WorkspaceImageReadError) {
|
|
4084
4162
|
treeError = err;
|
|
@@ -4089,31 +4167,37 @@ export class WorkspaceModule implements Module {
|
|
|
4089
4167
|
}
|
|
4090
4168
|
|
|
4091
4169
|
try {
|
|
4092
|
-
|
|
4170
|
+
const note = lost === null ? undefined
|
|
4171
|
+
: `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
|
|
4172
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
|
|
4093
4173
|
} catch (err) {
|
|
4094
|
-
if (err instanceof WorkspaceImageReadError)
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
return
|
|
4174
|
+
if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError)) throw err;
|
|
4175
|
+
const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
|
|
4176
|
+
if (lost) {
|
|
4177
|
+
const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
|
|
4178
|
+
return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
|
|
4179
|
+
: `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
|
|
4099
4180
|
}
|
|
4100
|
-
|
|
4181
|
+
if (noFile && treeError) return refused(treeError.message);
|
|
4182
|
+
return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
|
|
4101
4183
|
}
|
|
4102
4184
|
}
|
|
4103
4185
|
|
|
4104
4186
|
private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
4105
4187
|
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.' };
|
|
4188
|
+
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store. Give it without the surrogate, or with the other half of its pair.' };
|
|
4107
4189
|
}
|
|
4108
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4190
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
|
|
4191
|
+
if (relativePath === '') return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4109
4192
|
input = { ...input, path };
|
|
4110
|
-
const refusal = writeRefusal(mount);
|
|
4111
|
-
if (refusal) return { success: false, error: refusal, isError: true };
|
|
4193
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
4194
|
+
if (refusal) return { success: false, error: didNothing('write', refusal), isError: true };
|
|
4112
4195
|
|
|
4113
4196
|
const store = this.getStore();
|
|
4114
4197
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4115
|
-
|
|
4116
|
-
|
|
4198
|
+
const bytes = Buffer.byteLength(input.content);
|
|
4199
|
+
if (bytes > maxSize) {
|
|
4200
|
+
return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
|
|
4117
4201
|
}
|
|
4118
4202
|
|
|
4119
4203
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
@@ -4122,13 +4206,14 @@ export class WorkspaceModule implements Module {
|
|
|
4122
4206
|
if (mount.direct) {
|
|
4123
4207
|
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
4124
4208
|
if ('error' in written) return { success: false, isError: true, error: written.error };
|
|
4125
|
-
return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
|
|
4209
|
+
return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
4126
4210
|
}
|
|
4127
4211
|
let committed = false;
|
|
4128
4212
|
let refused: string | null;
|
|
4129
4213
|
let modeKept: string | null;
|
|
4214
|
+
let disk: DiskState | null;
|
|
4130
4215
|
try {
|
|
4131
|
-
({ refused, modeKept } = await this.withMount(mount, async () => {
|
|
4216
|
+
({ refused, modeKept, disk } = await this.withMount(mount, async () => {
|
|
4132
4217
|
await this.settleBeforeMutation(mount, relativePath);
|
|
4133
4218
|
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
4134
4219
|
// An overwrite keeps the file's mode, as an edit does.
|
|
@@ -4137,14 +4222,14 @@ export class WorkspaceModule implements Module {
|
|
|
4137
4222
|
() => { committed = true; });
|
|
4138
4223
|
}));
|
|
4139
4224
|
} catch (error) {
|
|
4140
|
-
if (!committed) throw error;
|
|
4225
|
+
if (!committed) throw notStored(error);
|
|
4141
4226
|
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
4142
4227
|
? { storeFailed: error.message, pushes: error.pushes }
|
|
4143
|
-
: { threw: error instanceof Error ? error.message : String(error) });
|
|
4228
|
+
: { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
4144
4229
|
}
|
|
4145
4230
|
if (refused !== null) return storedButUnfinished('write', input.path, { refused });
|
|
4146
4231
|
|
|
4147
|
-
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
4232
|
+
return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
|
|
4148
4233
|
}
|
|
4149
4234
|
|
|
4150
4235
|
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
|
|
@@ -4191,12 +4276,14 @@ export class WorkspaceModule implements Module {
|
|
|
4191
4276
|
* stored where its record landed, and that it can't tell where they
|
|
4192
4277
|
* couldn't be read back.
|
|
4193
4278
|
* 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
|
|
4279
|
+
* rule a listing uses, so the edits apply to what disk now holds. Each
|
|
4280
|
+
* file's stored contents are then read; where the store has lost some,
|
|
4281
|
+
* they're restored from disk's matching copy (contentsOf) and the pass runs
|
|
4282
|
+
* again, so the plan reads a pass taken after every await. From there
|
|
4283
|
+
* nothing awaits before the commit: every edit is planned and every entry
|
|
4284
|
+
* set (commitToolChanges) with no turn of the event loop between, so no
|
|
4285
|
+
* pass, branch change or other call moves the tree between a file's read
|
|
4286
|
+
* and its set. Disk then takes what it can of the stored change.
|
|
4200
4287
|
*
|
|
4201
4288
|
* The answer is held to `maxChars`, the length the call's result may have
|
|
4202
4289
|
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
@@ -4237,7 +4324,7 @@ export class WorkspaceModule implements Module {
|
|
|
4237
4324
|
}
|
|
4238
4325
|
for (const field of ['oldString', 'newString'] as const) {
|
|
4239
4326
|
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.` });
|
|
4327
|
+
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.` });
|
|
4241
4328
|
}
|
|
4242
4329
|
}
|
|
4243
4330
|
if (problems.length === found) specs.set(index, spec);
|
|
@@ -4291,7 +4378,26 @@ export class WorkspaceModule implements Module {
|
|
|
4291
4378
|
return await this.withMount(mount, async (): Promise<ToolResult> => {
|
|
4292
4379
|
// As every file tool's pass does, it names the paths it acts on, so a
|
|
4293
4380
|
// file the mount passes over by design is observed (see PassOptions.named).
|
|
4294
|
-
|
|
4381
|
+
let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4382
|
+
let ops = pass.ops;
|
|
4383
|
+
const contents = new Map<string, Buffer>();
|
|
4384
|
+
const lost = new Map<string, { blobHash: string; lost: LostContents }>();
|
|
4385
|
+
const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = [];
|
|
4386
|
+
for (const target of targets.values()) {
|
|
4387
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4388
|
+
if (!entry || contents.has(entry.blobHash)) continue;
|
|
4389
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
4390
|
+
if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes);
|
|
4391
|
+
else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
|
|
4392
|
+
}
|
|
4393
|
+
if (toRecover.length > 0) {
|
|
4394
|
+
await this.restoreEach(mount, toRecover, (version, found) => {
|
|
4395
|
+
if ('bytes' in found) contents.set(version.hash, found.bytes);
|
|
4396
|
+
else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
|
|
4397
|
+
});
|
|
4398
|
+
pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4399
|
+
ops = [...ops, ...pass.ops];
|
|
4400
|
+
}
|
|
4295
4401
|
// A pass that decided nothing checked no file's disk copy.
|
|
4296
4402
|
const undecided = undecidedOf(pass);
|
|
4297
4403
|
// Nothing below awaits until the commit's push.
|
|
@@ -4310,7 +4416,7 @@ export class WorkspaceModule implements Module {
|
|
|
4310
4416
|
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
4311
4417
|
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
4312
4418
|
// then write at the mode disk has, so it leaves nothing to say.
|
|
4313
|
-
const took = (op: TreeOp) =>
|
|
4419
|
+
const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
4314
4420
|
// Which copy the edits used, when it isn't the one the workspace
|
|
4315
4421
|
// last agreed on with disk: disk's newer text, or the workspace's
|
|
4316
4422
|
// copy because disk's couldn't be read or disk has none. A file the
|
|
@@ -4375,18 +4481,28 @@ export class WorkspaceModule implements Module {
|
|
|
4375
4481
|
// A pass also drops a copy disk made binary or too large, which
|
|
4376
4482
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
4377
4483
|
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:
|
|
4484
|
+
? deletedOnDisk(target.path)
|
|
4485
|
+
: this.notInWorkspace(target.path, report, mount);
|
|
4486
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
|
|
4381
4487
|
continue;
|
|
4382
4488
|
}
|
|
4383
|
-
|
|
4384
|
-
const
|
|
4385
|
-
if (!
|
|
4386
|
-
|
|
4489
|
+
const known = contents.get(entry.blobHash);
|
|
4490
|
+
const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
|
|
4491
|
+
if (!('bytes' in stored)) {
|
|
4492
|
+
const found = lost.get(target.relativePath);
|
|
4493
|
+
problems.push({
|
|
4494
|
+
kind: 'file',
|
|
4495
|
+
edits: target.edits,
|
|
4496
|
+
path: target.path,
|
|
4497
|
+
text: found?.blobHash === entry.blobHash
|
|
4498
|
+
? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
|
|
4499
|
+
: `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`,
|
|
4500
|
+
whole: true,
|
|
4501
|
+
});
|
|
4387
4502
|
continue;
|
|
4388
4503
|
}
|
|
4389
|
-
|
|
4504
|
+
notes.push(...lookedIn);
|
|
4505
|
+
const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
|
|
4390
4506
|
if (plan === null) continue;
|
|
4391
4507
|
plans.push({
|
|
4392
4508
|
target,
|
|
@@ -4400,7 +4516,7 @@ export class WorkspaceModule implements Module {
|
|
|
4400
4516
|
}
|
|
4401
4517
|
if (problems.length) return refuse(problems, notes);
|
|
4402
4518
|
|
|
4403
|
-
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4519
|
+
const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4404
4520
|
path: target.relativePath,
|
|
4405
4521
|
change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4406
4522
|
})), () => { committed = true; });
|
|
@@ -4410,19 +4526,19 @@ export class WorkspaceModule implements Module {
|
|
|
4410
4526
|
plan,
|
|
4411
4527
|
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
|
|
4412
4528
|
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4413
|
-
})), given.length, maxChars);
|
|
4529
|
+
})), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath)!)));
|
|
4414
4530
|
});
|
|
4415
4531
|
} catch (error) {
|
|
4416
4532
|
const message = error instanceof Error ? error.message : String(error);
|
|
4417
4533
|
if (committed) {
|
|
4418
4534
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
4419
4535
|
? { storeFailed: message, pushes: error.pushes }
|
|
4420
|
-
: { threw: message }, maxChars);
|
|
4536
|
+
: { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
|
|
4421
4537
|
}
|
|
4422
4538
|
// A set that threw and couldn't be read back may have stored the edits,
|
|
4423
4539
|
// and a second call could then apply them twice.
|
|
4424
4540
|
if (error instanceof CommitUnknownError) {
|
|
4425
|
-
const [set, read] = [
|
|
4541
|
+
const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
|
|
4426
4542
|
return {
|
|
4427
4543
|
success: false,
|
|
4428
4544
|
isError: true,
|
|
@@ -4435,9 +4551,8 @@ export class WorkspaceModule implements Module {
|
|
|
4435
4551
|
}
|
|
4436
4552
|
// The commit sets every entry in one record or none, and a set that
|
|
4437
4553
|
// threw was read back, so nothing is stored; or every change was one
|
|
4438
|
-
// the tree already held
|
|
4439
|
-
|
|
4440
|
-
throw error;
|
|
4554
|
+
// the tree already held (AlreadyHeld).
|
|
4555
|
+
throw notStored(error);
|
|
4441
4556
|
}
|
|
4442
4557
|
}
|
|
4443
4558
|
|
|
@@ -4455,7 +4570,7 @@ export class WorkspaceModule implements Module {
|
|
|
4455
4570
|
path: string,
|
|
4456
4571
|
callerAgentName: string | undefined,
|
|
4457
4572
|
): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
|
|
4458
|
-
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole:
|
|
4573
|
+
const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: false });
|
|
4459
4574
|
try {
|
|
4460
4575
|
// In the one spelling every file tool reads, as disk spells it, so two
|
|
4461
4576
|
// spellings of a file are one entry and, in one call, one plan
|
|
@@ -4464,14 +4579,16 @@ export class WorkspaceModule implements Module {
|
|
|
4464
4579
|
const where = await this.parseFilePath(path);
|
|
4465
4580
|
// One that names the mount's root, its bare name included, names no
|
|
4466
4581
|
// file.
|
|
4467
|
-
if (where.relativePath === '') return unusable(
|
|
4468
|
-
const refusal = writeRefusal(where.mount);
|
|
4582
|
+
if (where.relativePath === '') return unusable(namesRoot(path, where.mount.config.name));
|
|
4583
|
+
const refusal = writeRefusal(where.mount, this.writableNames());
|
|
4469
4584
|
if (refusal) return unusable(refusal);
|
|
4470
4585
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4471
|
-
if (generated) return unusable(
|
|
4586
|
+
if (generated) return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
|
|
4472
4587
|
return where;
|
|
4473
4588
|
} catch (error) {
|
|
4474
|
-
|
|
4589
|
+
if (error instanceof Refusal) return unusable(error.message);
|
|
4590
|
+
if (error instanceof WorkspaceReadError) return unusable(readRefusal(error, path));
|
|
4591
|
+
throw notStored(error);
|
|
4475
4592
|
}
|
|
4476
4593
|
}
|
|
4477
4594
|
|
|
@@ -4480,10 +4597,10 @@ export class WorkspaceModule implements Module {
|
|
|
4480
4597
|
* and the agent's own is read-only, since it shows the agent's tool
|
|
4481
4598
|
* presentation, which set_tool_visibility and set_tool_description change.
|
|
4482
4599
|
*/
|
|
4483
|
-
private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4600
|
+
private generatedFileRefusal(path: string, generated: { agentName: string }, callerAgentName: string | undefined): string {
|
|
4484
4601
|
return callerAgentName !== generated.agentName
|
|
4485
|
-
?
|
|
4486
|
-
:
|
|
4602
|
+
? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
|
|
4603
|
+
: `${JSON.stringify(path)} is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.`;
|
|
4487
4604
|
}
|
|
4488
4605
|
|
|
4489
4606
|
/**
|
|
@@ -4507,12 +4624,13 @@ export class WorkspaceModule implements Module {
|
|
|
4507
4624
|
conflicts: Map<EditTarget, string>,
|
|
4508
4625
|
outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
4509
4626
|
maxChars: number,
|
|
4627
|
+
reopen = false,
|
|
4510
4628
|
): ToolResult {
|
|
4511
4629
|
const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4512
4630
|
if (tool === 'edit') {
|
|
4513
4631
|
const [target] = targets;
|
|
4514
4632
|
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);
|
|
4633
|
+
return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null, reopen);
|
|
4516
4634
|
}
|
|
4517
4635
|
const stored = "Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.";
|
|
4518
4636
|
// The head is never cut, so what stands for the whole call goes in it,
|
|
@@ -4533,9 +4651,10 @@ export class WorkspaceModule implements Module {
|
|
|
4533
4651
|
}
|
|
4534
4652
|
} else if ('storeFailed' in outcome) {
|
|
4535
4653
|
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4536
|
-
+ (outcome.pushes ?
|
|
4654
|
+
+ (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
4655
|
+
+ (reopen ? ` ${AWAITS_REOPEN}` : '');
|
|
4537
4656
|
} else {
|
|
4538
|
-
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}
|
|
4657
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
|
|
4539
4658
|
}
|
|
4540
4659
|
const kept = 'modeKept' in outcome
|
|
4541
4660
|
? targets.flatMap((target) => {
|
|
@@ -4577,7 +4696,7 @@ export class WorkspaceModule implements Module {
|
|
|
4577
4696
|
try {
|
|
4578
4697
|
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4579
4698
|
} catch (error) {
|
|
4580
|
-
return { error:
|
|
4699
|
+
return { error: directDidNothing('write', path, error) };
|
|
4581
4700
|
}
|
|
4582
4701
|
try {
|
|
4583
4702
|
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
@@ -4591,7 +4710,7 @@ export class WorkspaceModule implements Module {
|
|
|
4591
4710
|
renameInto(staged);
|
|
4592
4711
|
} catch (error) {
|
|
4593
4712
|
await discard(staged);
|
|
4594
|
-
return { error:
|
|
4713
|
+
return { error: directDidNothing('write', path, error) };
|
|
4595
4714
|
}
|
|
4596
4715
|
this.recordDirectWrite(mount, agentName);
|
|
4597
4716
|
return { note: linksNote(path, staged, 'write') };
|
|
@@ -4668,7 +4787,7 @@ export class WorkspaceModule implements Module {
|
|
|
4668
4787
|
changedMeanwhile.add(target);
|
|
4669
4788
|
return 'changed';
|
|
4670
4789
|
}
|
|
4671
|
-
if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole:
|
|
4790
|
+
if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
|
|
4672
4791
|
else read.set(target, got);
|
|
4673
4792
|
}
|
|
4674
4793
|
// Two paths that reach one file, through a link this mount follows, or
|
|
@@ -4733,7 +4852,8 @@ export class WorkspaceModule implements Module {
|
|
|
4733
4852
|
edits: target.edits,
|
|
4734
4853
|
path: target.path,
|
|
4735
4854
|
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.' : '')
|
|
4855
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4856
|
+
+ (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
|
|
4737
4857
|
whole: true,
|
|
4738
4858
|
}]);
|
|
4739
4859
|
for (const { target, plan } of plans) {
|
|
@@ -4798,7 +4918,7 @@ export class WorkspaceModule implements Module {
|
|
|
4798
4918
|
plan: plans.find((p) => p.target === target)!.plan,
|
|
4799
4919
|
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
|
|
4800
4920
|
modeKept: null,
|
|
4801
|
-
})), editCount, maxChars);
|
|
4921
|
+
})), editCount, maxChars, 'done');
|
|
4802
4922
|
}
|
|
4803
4923
|
|
|
4804
4924
|
/**
|
|
@@ -4811,7 +4931,8 @@ export class WorkspaceModule implements Module {
|
|
|
4811
4931
|
private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
|
|
4812
4932
|
// The failed file is named among the outcomes, so the head stays as
|
|
4813
4933
|
// long as its reason, whatever the paths' lengths.
|
|
4814
|
-
const
|
|
4934
|
+
const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
|
|
4935
|
+
const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}.${can !== undefined ? ` Whoever runs this host can ${can}.` : ''} `
|
|
4815
4936
|
+ "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
|
|
4816
4937
|
const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
|
|
4817
4938
|
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 +4954,29 @@ export class WorkspaceModule implements Module {
|
|
|
4833
4954
|
path: string,
|
|
4834
4955
|
): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
|
|
4835
4956
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4836
|
-
const over = `${path} is
|
|
4957
|
+
const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
|
|
4837
4958
|
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
4838
4959
|
try {
|
|
4839
4960
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4840
|
-
if (fileStat.size > maxSize) throw new
|
|
4961
|
+
if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
4841
4962
|
});
|
|
4842
4963
|
} catch (err) {
|
|
4843
4964
|
if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
|
|
4844
|
-
return { error:
|
|
4965
|
+
if (err instanceof Refusal) return { error: err.message };
|
|
4966
|
+
if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
|
|
4967
|
+
throw err;
|
|
4845
4968
|
}
|
|
4846
4969
|
try {
|
|
4847
4970
|
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4848
4971
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4849
|
-
if (more) return { error: over };
|
|
4850
|
-
if (looksBinary(bytes))
|
|
4972
|
+
if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4973
|
+
if (looksBinary(bytes)) {
|
|
4974
|
+
return { error: `${JSON.stringify(path)} is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.` };
|
|
4975
|
+
}
|
|
4851
4976
|
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
|
|
4852
4977
|
return { bytes, identity: before };
|
|
4853
4978
|
} catch (err) {
|
|
4854
|
-
|
|
4855
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
4979
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4856
4980
|
} finally {
|
|
4857
4981
|
await opened.handle.close();
|
|
4858
4982
|
}
|
|
@@ -4880,11 +5004,9 @@ export class WorkspaceModule implements Module {
|
|
|
4880
5004
|
} catch (err) {
|
|
4881
5005
|
const code = errnoOf(err);
|
|
4882
5006
|
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}.` };
|
|
5007
|
+
? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
|
|
5008
|
+
: `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
|
|
5009
|
+
return { success: false, isError: true, error: didNothing('delete', why, path) };
|
|
4888
5010
|
}
|
|
4889
5011
|
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
4890
5012
|
const { rootReal } = disk;
|
|
@@ -4893,10 +5015,11 @@ export class WorkspaceModule implements Module {
|
|
|
4893
5015
|
await opened.handle.close();
|
|
4894
5016
|
} catch (err) {
|
|
4895
5017
|
// The open's own words for a replaced root speak of reading.
|
|
4896
|
-
if (err instanceof
|
|
4897
|
-
return { success: false, isError: true, error:
|
|
5018
|
+
if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
|
|
5019
|
+
return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
|
|
4898
5020
|
}
|
|
4899
|
-
return { success: false, isError: true, error:
|
|
5021
|
+
if (err instanceof WorkspaceReadError) return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
|
|
5022
|
+
throw err;
|
|
4900
5023
|
}
|
|
4901
5024
|
// The unlink is awaited, unlike a write's rename, so where the head
|
|
4902
5025
|
// was just before it says whether the head moved while it ran.
|
|
@@ -4910,10 +5033,10 @@ export class WorkspaceModule implements Module {
|
|
|
4910
5033
|
this.recordDirectWrite(mount, agentName, since);
|
|
4911
5034
|
return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
|
|
4912
5035
|
}
|
|
4913
|
-
return { success: false, isError: true, error:
|
|
5036
|
+
return { success: false, isError: true, error: directDidNothing('delete', path, error) };
|
|
4914
5037
|
}
|
|
4915
5038
|
this.recordDirectWrite(mount, agentName, since);
|
|
4916
|
-
return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
5039
|
+
return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
|
|
4917
5040
|
});
|
|
4918
5041
|
}
|
|
4919
5042
|
|
|
@@ -5052,10 +5175,11 @@ export class WorkspaceModule implements Module {
|
|
|
5052
5175
|
}
|
|
5053
5176
|
|
|
5054
5177
|
private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
5055
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
5178
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
|
|
5179
|
+
if (relativePath === '') return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
|
|
5056
5180
|
input = { ...input, path };
|
|
5057
|
-
const refusal = writeRefusal(mount);
|
|
5058
|
-
if (refusal) return { success: false, error: refusal, isError: true };
|
|
5181
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
5182
|
+
if (refusal) return { success: false, error: didNothing('delete', refusal), isError: true };
|
|
5059
5183
|
if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
5060
5184
|
|
|
5061
5185
|
const store = this.getStore();
|
|
@@ -5066,11 +5190,11 @@ export class WorkspaceModule implements Module {
|
|
|
5066
5190
|
await this.settleBeforeMutation(mount, relativePath);
|
|
5067
5191
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
5068
5192
|
if (!entry) {
|
|
5069
|
-
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
|
|
5070
|
-
return { success: false, error, isError: true };
|
|
5193
|
+
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
|
|
5194
|
+
return { success: false, error: didNothing('delete', error), isError: true };
|
|
5071
5195
|
}
|
|
5072
5196
|
|
|
5073
|
-
const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
|
|
5197
|
+
const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
|
|
5074
5198
|
() => { committed = true; });
|
|
5075
5199
|
if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
|
|
5076
5200
|
|
|
@@ -5078,20 +5202,20 @@ export class WorkspaceModule implements Module {
|
|
|
5078
5202
|
// withholds the result (agent-framework #277).
|
|
5079
5203
|
return {
|
|
5080
5204
|
success: true,
|
|
5081
|
-
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
5082
|
-
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
5205
|
+
data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
|
|
5206
|
+
keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
|
|
5083
5207
|
};
|
|
5084
5208
|
});
|
|
5085
5209
|
} catch (error) {
|
|
5086
|
-
if (!committed) throw error;
|
|
5210
|
+
if (!committed) throw notStored(error);
|
|
5087
5211
|
if (error instanceof StoredThenFailed) {
|
|
5088
5212
|
// Its note, as the success answer's: a deletion that cleared a
|
|
5089
5213
|
// conflict naming a disk file unlinked nothing.
|
|
5090
5214
|
const note = error.notes.get(relativePath);
|
|
5091
5215
|
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
|
|
5092
|
-
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}
|
|
5216
|
+
note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
|
|
5093
5217
|
}
|
|
5094
|
-
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
|
|
5218
|
+
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
5095
5219
|
}
|
|
5096
5220
|
}
|
|
5097
5221
|
|
|
@@ -5117,7 +5241,7 @@ export class WorkspaceModule implements Module {
|
|
|
5117
5241
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
5118
5242
|
// is refused, as glob refuses it, and shown as given.
|
|
5119
5243
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5120
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5244
|
+
return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5121
5245
|
}
|
|
5122
5246
|
input = { ...input, path };
|
|
5123
5247
|
const recursive = input.recursive === true;
|
|
@@ -5127,6 +5251,9 @@ export class WorkspaceModule implements Module {
|
|
|
5127
5251
|
// shows disk's.
|
|
5128
5252
|
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
5129
5253
|
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
5254
|
+
const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
|
|
5255
|
+
const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
|
|
5256
|
+
const noted = note !== null ? { note } : {};
|
|
5130
5257
|
if (recursive) {
|
|
5131
5258
|
return {
|
|
5132
5259
|
success: true,
|
|
@@ -5135,6 +5262,7 @@ export class WorkspaceModule implements Module {
|
|
|
5135
5262
|
entries: listing.files,
|
|
5136
5263
|
count: listing.files.length,
|
|
5137
5264
|
...incomplete,
|
|
5265
|
+
...noted,
|
|
5138
5266
|
},
|
|
5139
5267
|
};
|
|
5140
5268
|
}
|
|
@@ -5152,10 +5280,34 @@ export class WorkspaceModule implements Module {
|
|
|
5152
5280
|
entries: children,
|
|
5153
5281
|
count: children.length,
|
|
5154
5282
|
...incomplete,
|
|
5283
|
+
...noted,
|
|
5155
5284
|
},
|
|
5156
5285
|
};
|
|
5157
5286
|
}
|
|
5158
5287
|
|
|
5288
|
+
private async emptyScopeNote(mount: MountState, relativePath: string, path: string): Promise<string | null> {
|
|
5289
|
+
const shown = JSON.stringify(path);
|
|
5290
|
+
const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
|
|
5291
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
5292
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
5293
|
+
if (seen.kind === 'file') return isFile;
|
|
5294
|
+
if (seen.kind !== 'absent') return null;
|
|
5295
|
+
if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) return isFile;
|
|
5296
|
+
return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
private async absentOnDisk(mount: MountState, relativePath: string): Promise<boolean> {
|
|
5300
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
5301
|
+
return (await observePath(view, rootReal, relativePath)).kind === 'absent';
|
|
5302
|
+
}
|
|
5303
|
+
|
|
5304
|
+
private holdsAnythingAt(mount: MountState, relativePath: string): boolean {
|
|
5305
|
+
if (mount.direct) return false;
|
|
5306
|
+
const store = this.getStore();
|
|
5307
|
+
const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree): tree is string => tree !== undefined);
|
|
5308
|
+
return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
|
|
5309
|
+
}
|
|
5310
|
+
|
|
5159
5311
|
/**
|
|
5160
5312
|
* What a listing shows of a directory scope, from where the mount keeps
|
|
5161
5313
|
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
@@ -5259,7 +5411,7 @@ export class WorkspaceModule implements Module {
|
|
|
5259
5411
|
if (input.path) {
|
|
5260
5412
|
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
5261
5413
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5262
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5414
|
+
return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5263
5415
|
}
|
|
5264
5416
|
}
|
|
5265
5417
|
|
|
@@ -5281,7 +5433,7 @@ export class WorkspaceModule implements Module {
|
|
|
5281
5433
|
if (prefixed) {
|
|
5282
5434
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
5283
5435
|
if (dir === null) {
|
|
5284
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5436
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5285
5437
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5286
5438
|
continue;
|
|
5287
5439
|
}
|
|
@@ -5316,8 +5468,16 @@ export class WorkspaceModule implements Module {
|
|
|
5316
5468
|
try {
|
|
5317
5469
|
regex = new RegExp(input.pattern);
|
|
5318
5470
|
} catch (e) {
|
|
5319
|
-
//
|
|
5320
|
-
|
|
5471
|
+
// V8's message ends with the parser's reason, after the pattern: "Invalid
|
|
5472
|
+
// regular expression: /foo(/: Unterminated group".
|
|
5473
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5474
|
+
const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
|
|
5475
|
+
return {
|
|
5476
|
+
success: false,
|
|
5477
|
+
isError: true,
|
|
5478
|
+
error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
|
|
5479
|
+
+ 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
|
|
5480
|
+
};
|
|
5321
5481
|
}
|
|
5322
5482
|
|
|
5323
5483
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
@@ -5345,7 +5505,7 @@ export class WorkspaceModule implements Module {
|
|
|
5345
5505
|
if (input.path) {
|
|
5346
5506
|
const { mount, relativePath, moved, root } = mountsToSearch[0]!;
|
|
5347
5507
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5348
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5508
|
+
return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5349
5509
|
}
|
|
5350
5510
|
}
|
|
5351
5511
|
|
|
@@ -5369,11 +5529,9 @@ export class WorkspaceModule implements Module {
|
|
|
5369
5529
|
// read doesn't show it.
|
|
5370
5530
|
const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
|
|
5371
5531
|
const bomFiles = new Map<string, GrepText['version']>();
|
|
5372
|
-
const search = (text: GrepText): Searched => {
|
|
5532
|
+
const search = (text: GrepText): Searched & { bom: boolean } => {
|
|
5373
5533
|
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5374
5534
|
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
5535
|
const found: GrepMatch[] = [];
|
|
5378
5536
|
for (let i = 0; i < lines.length; i++) {
|
|
5379
5537
|
if (regex.test(lines[i])) {
|
|
@@ -5386,7 +5544,7 @@ export class WorkspaceModule implements Module {
|
|
|
5386
5544
|
found.push(match);
|
|
5387
5545
|
}
|
|
5388
5546
|
}
|
|
5389
|
-
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
|
|
5547
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
|
|
5390
5548
|
};
|
|
5391
5549
|
|
|
5392
5550
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
@@ -5394,7 +5552,7 @@ export class WorkspaceModule implements Module {
|
|
|
5394
5552
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
5395
5553
|
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
5396
5554
|
// found where its root lies now (movedNesting).
|
|
5397
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5555
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5398
5556
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5399
5557
|
continue;
|
|
5400
5558
|
}
|
|
@@ -5412,16 +5570,21 @@ export class WorkspaceModule implements Module {
|
|
|
5412
5570
|
return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
|
|
5413
5571
|
}
|
|
5414
5572
|
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
|
-
|
|
5573
|
+
const searched = (text: GrepText): (() => void) => {
|
|
5574
|
+
const { bom, ...hits } = search(text);
|
|
5575
|
+
const { file, state, version } = text;
|
|
5576
|
+
return () => {
|
|
5577
|
+
if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version });
|
|
5578
|
+
if (bom && !bomFiles.has(file)) bomFiles.set(file, version);
|
|
5579
|
+
if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
|
|
5580
|
+
};
|
|
5418
5581
|
};
|
|
5419
5582
|
const kept = prefixed
|
|
5420
5583
|
? (path: string): boolean => prefixed.regex.test(path)
|
|
5421
5584
|
: (path: string): boolean => fileGlob === null || fileGlob.test(path)
|
|
5422
5585
|
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
5423
5586
|
const found = mount.direct
|
|
5424
|
-
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
5587
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
|
|
5425
5588
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
5426
5589
|
skipped.push(...found.skipped);
|
|
5427
5590
|
incomplete.push(...found.incomplete);
|
|
@@ -5471,8 +5634,9 @@ export class WorkspaceModule implements Module {
|
|
|
5471
5634
|
/**
|
|
5472
5635
|
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
5473
5636
|
* single file `relativePath` names, those `keep` lets through, each handed
|
|
5474
|
-
* to `
|
|
5475
|
-
* workspace copy, labelled with its state, and a
|
|
5637
|
+
* to `search`, whose answers are recorded in path order: a pass over the
|
|
5638
|
+
* scope, then each path's workspace copy, labelled with its state, and a
|
|
5639
|
+
* conflict's disk side.
|
|
5476
5640
|
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
5477
5641
|
* `incomplete`, each mount-prefixed.
|
|
5478
5642
|
*/
|
|
@@ -5481,7 +5645,7 @@ export class WorkspaceModule implements Module {
|
|
|
5481
5645
|
relativePath: string,
|
|
5482
5646
|
start: string,
|
|
5483
5647
|
keep: (path: string) => boolean,
|
|
5484
|
-
|
|
5648
|
+
search: (text: GrepText) => () => void,
|
|
5485
5649
|
): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
|
|
5486
5650
|
const store = this.getStore();
|
|
5487
5651
|
const skipped: GrepSkip[] = [];
|
|
@@ -5534,16 +5698,18 @@ export class WorkspaceModule implements Module {
|
|
|
5534
5698
|
}
|
|
5535
5699
|
|
|
5536
5700
|
const intents = this.intents.get(mount.config.name)!;
|
|
5701
|
+
type Lost = Omit<GrepText, 'bytes'> & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' };
|
|
5702
|
+
const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = [];
|
|
5537
5703
|
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
5538
5704
|
if (!keep(report.path)) continue;
|
|
5539
5705
|
const file = `${mount.config.name}/${report.path}`;
|
|
5540
5706
|
if (report.state === 'disk-only') {
|
|
5541
|
-
|
|
5707
|
+
found.push({ skip: {
|
|
5542
5708
|
file,
|
|
5543
5709
|
reason: 'disk-only: binary or over the size limit, never stored',
|
|
5544
5710
|
...(report.size !== undefined ? { size: report.size } : {}),
|
|
5545
5711
|
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
5546
|
-
});
|
|
5712
|
+
} });
|
|
5547
5713
|
continue;
|
|
5548
5714
|
}
|
|
5549
5715
|
// A path in another mount's directory is that mount's: grep gives
|
|
@@ -5552,16 +5718,16 @@ export class WorkspaceModule implements Module {
|
|
|
5552
5718
|
if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
|
|
5553
5719
|
const refusal = refused.get(report.path);
|
|
5554
5720
|
if (refusal !== undefined) {
|
|
5555
|
-
|
|
5721
|
+
found.push({ skip: { file, reason: refusal } });
|
|
5556
5722
|
continue;
|
|
5557
5723
|
}
|
|
5558
5724
|
// The workspace's version, labelled with the path's state.
|
|
5559
5725
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5726
|
+
if (entry) {
|
|
5727
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
5728
|
+
found.push('bytes' in stored
|
|
5729
|
+
? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
|
|
5730
|
+
: { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
|
|
5565
5731
|
}
|
|
5566
5732
|
if (report.state !== 'conflict') continue;
|
|
5567
5733
|
// A conflict's disk side. The version recorded with the conflict is
|
|
@@ -5571,21 +5737,37 @@ export class WorkspaceModule implements Module {
|
|
|
5571
5737
|
// disk version was never stored either.
|
|
5572
5738
|
const disk = intents.get(report.path)?.conflict?.disk;
|
|
5573
5739
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5740
|
+
if (disk?.stored) {
|
|
5741
|
+
const version = changed ? 'recorded-disk' : 'conflicting-disk';
|
|
5742
|
+
const stored = storedBytes(store, disk.stored);
|
|
5743
|
+
found.push('bytes' in stored
|
|
5744
|
+
? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
|
|
5745
|
+
: { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
|
|
5580
5746
|
}
|
|
5581
5747
|
if (changed) {
|
|
5582
5748
|
if (report.diskNow === 'file') {
|
|
5583
|
-
|
|
5749
|
+
found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } });
|
|
5584
5750
|
}
|
|
5585
5751
|
} else if (disk && !disk.stored) {
|
|
5586
|
-
|
|
5752
|
+
found.push({ skip: { file, reason: `conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; ${settleConflict('file', mount.config.mode === 'read-only').replace(/\.$/, '')}`, size: disk.size } });
|
|
5587
5753
|
}
|
|
5588
5754
|
}
|
|
5755
|
+
const answers = new Map<Lost, { searched: () => void } | { skip: GrepSkip }>();
|
|
5756
|
+
await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
|
|
5757
|
+
const { file, state, version, what } = lost;
|
|
5758
|
+
answers.set(lost, 'bytes' in contents
|
|
5759
|
+
? { searched: search({ file, state, version, bytes: contents.bytes }) }
|
|
5760
|
+
: { skip: {
|
|
5761
|
+
file,
|
|
5762
|
+
reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
|
|
5763
|
+
+ `and ${lostOnDisk(contents.lost)}, so it was not searched`,
|
|
5764
|
+
} });
|
|
5765
|
+
});
|
|
5766
|
+
for (const item of found) {
|
|
5767
|
+
const answer = 'lost' in item ? answers.get(item.lost)! : item;
|
|
5768
|
+
if ('searched' in answer) answer.searched();
|
|
5769
|
+
else skipped.push(answer.skip);
|
|
5770
|
+
}
|
|
5589
5771
|
});
|
|
5590
5772
|
return { skipped, incomplete };
|
|
5591
5773
|
}
|
|
@@ -5635,7 +5817,7 @@ export class WorkspaceModule implements Module {
|
|
|
5635
5817
|
const store = this.getStore();
|
|
5636
5818
|
const status: Record<string, unknown> = {};
|
|
5637
5819
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
5638
|
-
return { success: false, error:
|
|
5820
|
+
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5639
5821
|
}
|
|
5640
5822
|
|
|
5641
5823
|
for (const [name, mount] of this.mounts) {
|
|
@@ -5778,9 +5960,6 @@ export class WorkspaceModule implements Module {
|
|
|
5778
5960
|
*/
|
|
5779
5961
|
private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
|
|
5780
5962
|
const store = this.getStore();
|
|
5781
|
-
const agreement = this.agreementOrThrow();
|
|
5782
|
-
const name = mount.config.name;
|
|
5783
|
-
const intents = this.intents.get(name)!;
|
|
5784
5963
|
const owed: string[] = [];
|
|
5785
5964
|
const unchecked: string[] = [];
|
|
5786
5965
|
let agreed = 0;
|
|
@@ -5789,17 +5968,32 @@ export class WorkspaceModule implements Module {
|
|
|
5789
5968
|
// Outside the mount's include, or in another mount's directory nested
|
|
5790
5969
|
// in it: neither owed to disk nor counted as on it.
|
|
5791
5970
|
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);
|
|
5971
|
+
const kind = this.evidenceOf(mount, e);
|
|
5972
|
+
if (kind === 'owed') owed.push(e.path);
|
|
5973
|
+
else if (kind === 'unchecked') unchecked.push(e.path);
|
|
5798
5974
|
else agreed++;
|
|
5799
5975
|
}
|
|
5800
5976
|
return { owed, unchecked, agreed };
|
|
5801
5977
|
}
|
|
5802
5978
|
|
|
5979
|
+
private evidenceOf(mount: MountState, e: { path: string; blobHash: string; mode: number }): 'owed' | 'unchecked' | 'agreed' {
|
|
5980
|
+
const p = this.agreementOrThrow().get(mount.config.name, e.path);
|
|
5981
|
+
if (p === undefined) return this.intents.get(mount.config.name)!.get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
|
|
5982
|
+
if (p.kind !== 'content' || p.hash !== e.blobHash) return 'owed';
|
|
5983
|
+
// A mode the store records, other than the one disk last agreed with,
|
|
5984
|
+
// where modes are observed: elsewhere no push applies one.
|
|
5985
|
+
if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) return 'owed';
|
|
5986
|
+
return 'agreed';
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
private diskOf(mount: MountState, relativePath: string): DiskState {
|
|
5990
|
+
const intent = this.intents.get(mount.config.name)!.get(relativePath);
|
|
5991
|
+
if (intent?.conflict) return 'in conflict with disk';
|
|
5992
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
5993
|
+
if (!entry) return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
|
|
5994
|
+
return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5803
5997
|
private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
|
|
5804
5998
|
const store = this.getStore();
|
|
5805
5999
|
|
|
@@ -5816,7 +6010,7 @@ export class WorkspaceModule implements Module {
|
|
|
5816
6010
|
// is refused; beneath a directory, the push leaves out what the include
|
|
5817
6011
|
// doesn't cover.
|
|
5818
6012
|
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 };
|
|
6013
|
+
return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
|
|
5820
6014
|
}
|
|
5821
6015
|
}
|
|
5822
6016
|
|
|
@@ -5826,7 +6020,7 @@ export class WorkspaceModule implements Module {
|
|
|
5826
6020
|
} else if (input.mount) {
|
|
5827
6021
|
const m = this.mounts.get(input.mount);
|
|
5828
6022
|
if (!m) {
|
|
5829
|
-
return { success: false, error:
|
|
6023
|
+
return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
|
|
5830
6024
|
}
|
|
5831
6025
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
5832
6026
|
} else {
|
|
@@ -5934,16 +6128,21 @@ export class WorkspaceModule implements Module {
|
|
|
5934
6128
|
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
5935
6129
|
return {
|
|
5936
6130
|
success: false,
|
|
5937
|
-
error:
|
|
6131
|
+
error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
|
|
5938
6132
|
isError: true,
|
|
5939
6133
|
};
|
|
5940
6134
|
}
|
|
5941
6135
|
|
|
6136
|
+
const held = explicit;
|
|
6137
|
+
const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
|
|
6138
|
+
&& allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
|
|
6139
|
+
&& !this.holdsAnythingAt(held.mount, held.relativePath);
|
|
5942
6140
|
return {
|
|
5943
6141
|
success: true,
|
|
5944
6142
|
data: {
|
|
5945
6143
|
materialized: allWritten,
|
|
5946
6144
|
count: allWritten.length,
|
|
6145
|
+
...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
|
|
5947
6146
|
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
5948
6147
|
...(pendingDeletions.length > 0
|
|
5949
6148
|
? {
|
|
@@ -6014,7 +6213,13 @@ export class WorkspaceModule implements Module {
|
|
|
6014
6213
|
const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
|
|
6015
6214
|
const rootsAccepted: string[] = [];
|
|
6016
6215
|
if (input.acceptRoot && input.path) {
|
|
6017
|
-
|
|
6216
|
+
const named = input.path.split('/')[0]!;
|
|
6217
|
+
return {
|
|
6218
|
+
success: false,
|
|
6219
|
+
isError: true,
|
|
6220
|
+
error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
|
|
6221
|
+
+ `${this.mounts.has(named) ? `Give "mount" (${JSON.stringify(named)}) with acceptRoot to take that mount's root` : 'Give "mount", naming the mount whose root to take, with acceptRoot'}, or leave acceptRoot out to sync the path.`),
|
|
6222
|
+
};
|
|
6018
6223
|
}
|
|
6019
6224
|
|
|
6020
6225
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
@@ -6025,13 +6230,13 @@ export class WorkspaceModule implements Module {
|
|
|
6025
6230
|
// is refused; beneath a directory, the walk passes over what the include
|
|
6026
6231
|
// doesn't cover.
|
|
6027
6232
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
6028
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
6233
|
+
return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
6029
6234
|
}
|
|
6030
6235
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
6031
6236
|
} else if (input.mount) {
|
|
6032
6237
|
const m = this.mounts.get(input.mount);
|
|
6033
6238
|
if (!m) {
|
|
6034
|
-
return { success: false, error:
|
|
6239
|
+
return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
6035
6240
|
}
|
|
6036
6241
|
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
6037
6242
|
} else {
|
|
@@ -6045,6 +6250,7 @@ export class WorkspaceModule implements Module {
|
|
|
6045
6250
|
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
6046
6251
|
continue;
|
|
6047
6252
|
}
|
|
6253
|
+
let namedDirectory = false;
|
|
6048
6254
|
const pass = await this.withMount(mount, async () => {
|
|
6049
6255
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
|
|
6050
6256
|
if (!relativePath) {
|
|
@@ -6059,6 +6265,7 @@ export class WorkspaceModule implements Module {
|
|
|
6059
6265
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
6060
6266
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
6061
6267
|
this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
6268
|
+
namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
|
|
6062
6269
|
// named: ignore patterns covering the path asked for don't apply
|
|
6063
6270
|
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
6064
6271
|
const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
@@ -6117,7 +6324,7 @@ export class WorkspaceModule implements Module {
|
|
|
6117
6324
|
// without a path passes over it, so nothing else keeps it current
|
|
6118
6325
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
6119
6326
|
// synced at all, and the note says so.
|
|
6120
|
-
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
6327
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
|
|
6121
6328
|
if (passedOver !== undefined) {
|
|
6122
6329
|
allIgnored.push({
|
|
6123
6330
|
mount: name,
|
|
@@ -6131,10 +6338,17 @@ export class WorkspaceModule implements Module {
|
|
|
6131
6338
|
}
|
|
6132
6339
|
}
|
|
6133
6340
|
|
|
6341
|
+
const named = input.path !== undefined && input.path !== '' ? targets[0]! : null;
|
|
6342
|
+
const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
|
|
6343
|
+
&& allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
|
|
6344
|
+
&& allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
|
|
6345
|
+
&& !this.holdsAnythingAt(named.mount, named.relativePath)
|
|
6346
|
+
&& await this.absentOnDisk(named.mount, named.relativePath);
|
|
6134
6347
|
return {
|
|
6135
6348
|
success: true,
|
|
6136
6349
|
data: {
|
|
6137
6350
|
results: allResults,
|
|
6351
|
+
...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
|
|
6138
6352
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
6139
6353
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
6140
6354
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
@@ -6408,13 +6622,13 @@ export async function inOrder<T, R>(items: readonly T[], width: number, work: (i
|
|
|
6408
6622
|
}
|
|
6409
6623
|
|
|
6410
6624
|
/** read_image's answer for an image, wherever it was read from. */
|
|
6411
|
-
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
|
|
6625
|
+
function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult {
|
|
6412
6626
|
return {
|
|
6413
6627
|
success: true,
|
|
6414
6628
|
data: [
|
|
6415
6629
|
{
|
|
6416
6630
|
type: 'text',
|
|
6417
|
-
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
6631
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
|
|
6418
6632
|
},
|
|
6419
6633
|
{
|
|
6420
6634
|
type: 'image',
|
|
@@ -6475,13 +6689,16 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
|
|
|
6475
6689
|
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6476
6690
|
* the next start once none is held runs it direct.
|
|
6477
6691
|
*/
|
|
6478
|
-
function writeRefusal(mount: MountState): string | null {
|
|
6692
|
+
function writeRefusal(mount: MountState, writable: readonly string[] = []): string | null {
|
|
6479
6693
|
const name = mount.config.name;
|
|
6480
|
-
if (mount.config.mode === 'read-only')
|
|
6694
|
+
if (mount.config.mode === 'read-only') {
|
|
6695
|
+
return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
|
|
6696
|
+
+ (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
|
|
6697
|
+
}
|
|
6481
6698
|
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6482
|
-
return `
|
|
6699
|
+
return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6483
6700
|
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
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
|
|
6701
|
+
+ `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct.`;
|
|
6485
6702
|
}
|
|
6486
6703
|
return null;
|
|
6487
6704
|
}
|
|
@@ -6581,6 +6798,15 @@ function directReason(error: unknown): string {
|
|
|
6581
6798
|
return error instanceof Error ? error.message : String(error);
|
|
6582
6799
|
}
|
|
6583
6800
|
|
|
6801
|
+
function directWayOn(error: unknown): string | null {
|
|
6802
|
+
return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
|
|
6803
|
+
}
|
|
6804
|
+
|
|
6805
|
+
function directDidNothing(tool: string, path: string, error: unknown): string {
|
|
6806
|
+
const on = directWayOn(error);
|
|
6807
|
+
return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
|
|
6808
|
+
}
|
|
6809
|
+
|
|
6584
6810
|
/** "a", "a and b", or "a, b and c". */
|
|
6585
6811
|
function listed(items: readonly string[]): string {
|
|
6586
6812
|
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
@@ -6604,7 +6830,135 @@ function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): stri
|
|
|
6604
6830
|
*/
|
|
6605
6831
|
function containedRefusal(err: unknown, path: string): string {
|
|
6606
6832
|
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);
|
|
6833
|
+
return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
|
|
6834
|
+
}
|
|
6835
|
+
|
|
6836
|
+
function deletedOnDisk(path: string): string {
|
|
6837
|
+
return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
|
|
6838
|
+
}
|
|
6839
|
+
|
|
6840
|
+
function uncheckedFile(shown: string, note: string | undefined): string {
|
|
6841
|
+
if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6842
|
+
const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
|
|
6843
|
+
: note === ROOT_REPLACED ? null
|
|
6844
|
+
: note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
|
|
6845
|
+
: note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
|
|
6846
|
+
: note === NOT_REGULAR ? onlyTheHost('put a regular file there')
|
|
6847
|
+
: note === CHANGED ? 'Calling again checks it afresh.'
|
|
6848
|
+
: 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
|
|
6849
|
+
return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
|
|
6850
|
+
}
|
|
6851
|
+
|
|
6852
|
+
function namesRoot(path: string, mount: string): string {
|
|
6853
|
+
return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
|
|
6854
|
+
}
|
|
6855
|
+
|
|
6856
|
+
function tooLargeToWrite(path: string, bytes: number, maxSize: number): string {
|
|
6857
|
+
return pastSizeLimit(`${JSON.stringify(path)} would be ${bytes.toLocaleString('en-US')} bytes`, maxSize, "the workspace couldn't keep it", 'Content within the limit is written.');
|
|
6858
|
+
}
|
|
6859
|
+
|
|
6860
|
+
function pastSizeLimit(what: string, maxSize: number, outcome: string, within: string | null): string {
|
|
6861
|
+
return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
|
|
6862
|
+
+ "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
|
|
6863
|
+
}
|
|
6864
|
+
|
|
6865
|
+
function sentence(text: string): string {
|
|
6866
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
6867
|
+
}
|
|
6868
|
+
|
|
6869
|
+
function unreadable(shown: string, errno: string | undefined): string {
|
|
6870
|
+
return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
|
|
6871
|
+
}
|
|
6872
|
+
|
|
6873
|
+
function readRefusal(err: WorkspaceReadError, path: string): string {
|
|
6874
|
+
const shown = JSON.stringify(path);
|
|
6875
|
+
if (err instanceof RootUnavailable) {
|
|
6876
|
+
if (err.refusal) return err.refusal.message;
|
|
6877
|
+
const mount = JSON.stringify(err.mount);
|
|
6878
|
+
if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
|
|
6879
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
|
|
6880
|
+
return `the directory of mount ${mount} isn't on disk, so nothing in it can be read. ${onlyTheHost("put the mount's directory back at its path")}`;
|
|
6881
|
+
}
|
|
6882
|
+
if (err.errno !== undefined) {
|
|
6883
|
+
return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
|
|
6884
|
+
+ errnoWayOn(err.errno, ROOT_RECHECK, true);
|
|
6885
|
+
}
|
|
6886
|
+
return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
|
|
6887
|
+
}
|
|
6888
|
+
return `${shown} can't be read: ${sentence(err.reason)}`;
|
|
6889
|
+
}
|
|
6890
|
+
if (err.stage === 'readlink') return sentence(err.message);
|
|
6891
|
+
switch (err.code) {
|
|
6892
|
+
case 'directory':
|
|
6893
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6894
|
+
case 'symlink':
|
|
6895
|
+
return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
|
|
6896
|
+
+ 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
|
|
6897
|
+
case 'escape':
|
|
6898
|
+
return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
|
|
6899
|
+
+ 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
|
|
6900
|
+
case 'changed':
|
|
6901
|
+
return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
|
|
6902
|
+
case 'not_found':
|
|
6903
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
6904
|
+
if (err instanceof NotRegularFile) {
|
|
6905
|
+
return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
|
|
6906
|
+
}
|
|
6907
|
+
if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
|
|
6908
|
+
return unreadable(shown, err.errno);
|
|
6909
|
+
default:
|
|
6910
|
+
return sentence(err.message);
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
6913
|
+
|
|
6914
|
+
type LostContents = {
|
|
6915
|
+
path: string;
|
|
6916
|
+
store: Exclude<StoredBytes, { bytes: Buffer }>;
|
|
6917
|
+
disk: 'other' | 'none' | { unreadable: string };
|
|
6918
|
+
writable: boolean;
|
|
6919
|
+
reopen: boolean;
|
|
6920
|
+
};
|
|
6921
|
+
|
|
6922
|
+
function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string {
|
|
6923
|
+
return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
|
|
6924
|
+
}
|
|
6925
|
+
|
|
6926
|
+
function inTheStore(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
|
|
6927
|
+
return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
|
|
6928
|
+
}
|
|
6929
|
+
|
|
6930
|
+
function lostInStore(lost: Pick<LostContents, 'store'>): string {
|
|
6931
|
+
return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
|
|
6932
|
+
}
|
|
6933
|
+
|
|
6934
|
+
function lostOnDisk(lost: LostContents): string {
|
|
6935
|
+
return lost.disk === 'other' ? "disk's copy holds other contents"
|
|
6936
|
+
: lost.disk === 'none' ? 'disk has no copy'
|
|
6937
|
+
: `disk's copy can't be read (${lost.disk.unreadable})`;
|
|
6938
|
+
}
|
|
6939
|
+
|
|
6940
|
+
function lostText(lost: LostContents, the: 'The' | 'the' = 'The'): string {
|
|
6941
|
+
return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
|
|
6942
|
+
}
|
|
6943
|
+
|
|
6944
|
+
function nothingToRead(lost: LostContents): string {
|
|
6945
|
+
return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
|
|
6946
|
+
}
|
|
6947
|
+
|
|
6948
|
+
function lostRemedy(lost: LostContents): string {
|
|
6949
|
+
const after = remedyOfLost(lost);
|
|
6950
|
+
if (!lost.reopen) return after;
|
|
6951
|
+
return "Nothing you do clears this until the workspace's store is reopened, and calling again won't change that; whoever runs this host can reopen it with a restart. "
|
|
6952
|
+
+ `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
|
|
6953
|
+
}
|
|
6954
|
+
|
|
6955
|
+
function remedyOfLost(lost: LostContents): string {
|
|
6956
|
+
const rewrite = lost.writable && 'missing' in lost.store;
|
|
6957
|
+
if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
|
|
6958
|
+
if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
|
|
6959
|
+
return lost.disk === 'none'
|
|
6960
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy."
|
|
6961
|
+
: "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's.";
|
|
6608
6962
|
}
|
|
6609
6963
|
|
|
6610
6964
|
/**
|
|
@@ -6683,9 +7037,8 @@ function planTargetEdits(
|
|
|
6683
7037
|
kind: 'file',
|
|
6684
7038
|
edits: target.edits,
|
|
6685
7039
|
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.",
|
|
7040
|
+
text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize,
|
|
7041
|
+
"the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
|
|
6689
7042
|
whole: false,
|
|
6690
7043
|
});
|
|
6691
7044
|
return null;
|
|
@@ -6728,17 +7081,36 @@ function changesLanded(
|
|
|
6728
7081
|
* change is stored isn't known. Its message says so after both errors.
|
|
6729
7082
|
*/
|
|
6730
7083
|
class CommitUnknownError extends Error {
|
|
6731
|
-
readonly
|
|
6732
|
-
readonly readError: string;
|
|
6733
|
-
constructor(setError: unknown, readError: unknown) {
|
|
7084
|
+
constructor(readonly setCause: unknown, readonly readCause: unknown) {
|
|
6734
7085
|
const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
6735
|
-
super(`${text(
|
|
7086
|
+
super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
|
|
6736
7087
|
this.name = 'CommitUnknownError';
|
|
6737
|
-
this.setError = text(setError);
|
|
6738
|
-
this.readError = text(readError);
|
|
6739
7088
|
}
|
|
6740
7089
|
}
|
|
6741
7090
|
|
|
7091
|
+
function storeWords(error: unknown): string {
|
|
7092
|
+
const code = errnoCode(error);
|
|
7093
|
+
return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
|
|
7094
|
+
}
|
|
7095
|
+
|
|
7096
|
+
class NotStored extends Error {
|
|
7097
|
+
constructor(readonly thrown: unknown) {
|
|
7098
|
+
super('stopped before the change was stored');
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
|
|
7102
|
+
class AlreadyHeld extends Error {
|
|
7103
|
+
constructor(readonly thrown: unknown) {
|
|
7104
|
+
super(thrown instanceof Error ? thrown.message : String(thrown));
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
7107
|
+
|
|
7108
|
+
function notStored(error: unknown): unknown {
|
|
7109
|
+
return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
|
|
7110
|
+
? error : new NotStored(error);
|
|
7111
|
+
}
|
|
7112
|
+
|
|
7113
|
+
|
|
6742
7114
|
/**
|
|
6743
7115
|
* A store's set that threw after recording the change (changesLanded): the
|
|
6744
7116
|
* entries are set and their intents follow, and the store's error goes on in
|
|
@@ -6752,6 +7124,14 @@ class StoredThenFailed extends Error {
|
|
|
6752
7124
|
}
|
|
6753
7125
|
}
|
|
6754
7126
|
|
|
7127
|
+
const ONCE_REOPENED = ', once the store is reopened';
|
|
7128
|
+
|
|
7129
|
+
const DISK_ORDER: DiskState[] = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
|
|
7130
|
+
|
|
7131
|
+
function furthestFromDisk(values: DiskState[]): DiskState {
|
|
7132
|
+
return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
|
|
7133
|
+
}
|
|
7134
|
+
|
|
6755
7135
|
/**
|
|
6756
7136
|
* The answer to a write, edit or delete that the workspace stored but
|
|
6757
7137
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -6768,6 +7148,7 @@ function storedButUnfinished(
|
|
|
6768
7148
|
path: string,
|
|
6769
7149
|
outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
|
|
6770
7150
|
note: string | null = null,
|
|
7151
|
+
reopen = false,
|
|
6771
7152
|
): ToolResult {
|
|
6772
7153
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
6773
7154
|
const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
|
|
@@ -6777,10 +7158,10 @@ function storedButUnfinished(
|
|
|
6777
7158
|
: 'storeFailed' in outcome
|
|
6778
7159
|
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
6779
7160
|
: 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}` : '') };
|
|
7161
|
+
? ` Its push to disk didn't run, so disk keeps the file until a materialize of the path with applyDeletions deletes it${reopen ? ONCE_REOPENED : ''}.`
|
|
7162
|
+
: ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
|
|
7163
|
+
: ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
|
|
7164
|
+
return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
|
|
6784
7165
|
}
|
|
6785
7166
|
|
|
6786
7167
|
/**
|
|
@@ -6810,19 +7191,24 @@ function whichCopyRead(report: PathReport | undefined, tookChange: boolean, read
|
|
|
6810
7191
|
// can hold it (not binary or too large). A read-only mount can hold a
|
|
6811
7192
|
// copy disk doesn't, such as one a branch switch selected, but a
|
|
6812
7193
|
// 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}`];
|
|
7194
|
+
const settle = settleConflict(report.diskNow, readOnly);
|
|
7195
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0]!.toUpperCase()}${settle.slice(1)}`];
|
|
6822
7196
|
}
|
|
6823
7197
|
return [];
|
|
6824
7198
|
}
|
|
6825
7199
|
|
|
7200
|
+
function settleConflict(diskNow: PathReport['diskNow'], readOnly: boolean): string {
|
|
7201
|
+
const sync = diskNow === 'absent'
|
|
7202
|
+
? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
7203
|
+
: 'sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
7204
|
+
const force = readOnly
|
|
7205
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
7206
|
+
: diskNow === 'absent'
|
|
7207
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
7208
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
7209
|
+
return `${sync}${force}`;
|
|
7210
|
+
}
|
|
7211
|
+
|
|
6826
7212
|
/**
|
|
6827
7213
|
* The least each tool's whole-number fields may be, beside their schemas,
|
|
6828
7214
|
* whose type (membrane's ToolParameter) has no `minimum`.
|
|
@@ -7020,13 +7406,13 @@ function globToRegex(pattern: string): RegExp {
|
|
|
7020
7406
|
if (pattern[j] === '{') depth++;
|
|
7021
7407
|
else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
|
|
7022
7408
|
}
|
|
7023
|
-
if (closeIdx < 0) throw new
|
|
7409
|
+
if (closeIdx < 0) throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
|
|
7024
7410
|
const group = pattern.slice(i + 1, closeIdx);
|
|
7025
7411
|
if (group.includes('{')) {
|
|
7026
|
-
throw new
|
|
7412
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
|
|
7027
7413
|
}
|
|
7028
7414
|
if (!group.includes(',')) {
|
|
7029
|
-
throw new
|
|
7415
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
|
|
7030
7416
|
}
|
|
7031
7417
|
// A `/` right after the group goes into each alternative, as `{a,b}/x`
|
|
7032
7418
|
// and `{a/,b/}x` name the same paths, so an alternative's own `**`
|