@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
|
@@ -9,10 +9,10 @@ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises
|
|
|
9
9
|
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
10
10
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
11
11
|
import { MountWatcher } from './watcher.js';
|
|
12
|
-
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
13
|
-
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
12
|
+
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
13
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
14
14
|
import { BranchIntents } from './branch-intent.js';
|
|
15
|
-
import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE,
|
|
15
|
+
import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes } from './observe.js';
|
|
16
16
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
17
17
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
18
18
|
import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
@@ -22,8 +22,11 @@ import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js
|
|
|
22
22
|
import { grepText, textAnswer } from './result-text.js';
|
|
23
23
|
import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
|
|
24
24
|
import { EffectFailed, unlinkContained } from './effects.js';
|
|
25
|
+
import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
|
|
26
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
27
|
+
import { closestNames } from '../../closest-names.js';
|
|
25
28
|
import { RecordJournal } from '../../record-journal.js';
|
|
26
|
-
import { FileChanged, ROOT_MOVED, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
|
|
29
|
+
import { FileChanged, ROOT_MOVED, WriteRefused, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
|
|
27
30
|
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
28
31
|
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
29
32
|
/**
|
|
@@ -42,6 +45,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
|
42
45
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
43
46
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
44
47
|
const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
|
|
48
|
+
const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
|
|
49
|
+
+ 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
|
|
50
|
+
+ 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
|
|
45
51
|
/**
|
|
46
52
|
* How many files a direct mount's grep reads, and its listings look at, at
|
|
47
53
|
* once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
|
|
@@ -54,16 +60,23 @@ const DIRECT_READS_AT_ONCE = 16;
|
|
|
54
60
|
const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
|
|
55
61
|
/** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
|
|
56
62
|
const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
|
|
57
|
-
class WorkspaceImageReadError extends
|
|
58
|
-
|
|
59
|
-
constructor(
|
|
60
|
-
super(
|
|
61
|
-
this.
|
|
63
|
+
class WorkspaceImageReadError extends Refusal {
|
|
64
|
+
reason;
|
|
65
|
+
constructor(reason, wayOn) {
|
|
66
|
+
super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
|
|
67
|
+
this.reason = reason;
|
|
62
68
|
this.name = 'WorkspaceImageReadError';
|
|
63
69
|
}
|
|
64
70
|
}
|
|
71
|
+
const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
|
|
72
|
+
function truncatedImage(mountPrefixedPath) {
|
|
73
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
|
|
74
|
+
}
|
|
75
|
+
function emptyImage(mountPrefixedPath) {
|
|
76
|
+
return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
|
|
77
|
+
}
|
|
65
78
|
/** A path the mount's `include` doesn't cover: outside the mount. */
|
|
66
|
-
class OutsideIncludeError extends
|
|
79
|
+
class OutsideIncludeError extends Refusal {
|
|
67
80
|
constructor(shownPath, mount) {
|
|
68
81
|
super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
|
|
69
82
|
+ 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
|
|
@@ -74,9 +87,17 @@ class OutsideIncludeError extends Error {
|
|
|
74
87
|
* mount it names. The message names the mount that holds it and the path to
|
|
75
88
|
* use there.
|
|
76
89
|
*/
|
|
77
|
-
class NestedMountError extends
|
|
78
|
-
constructor(path, mount, holder,
|
|
79
|
-
|
|
90
|
+
class NestedMountError extends Refusal {
|
|
91
|
+
constructor(path, mount, holder, how = 'lies in') {
|
|
92
|
+
const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
|
|
93
|
+
super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
class PathRefusal extends Refusal {
|
|
97
|
+
code;
|
|
98
|
+
constructor(code, message) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.code = code;
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
103
|
/**
|
|
@@ -98,6 +119,22 @@ export class WorkspaceReadError extends Error {
|
|
|
98
119
|
this.name = 'WorkspaceReadError';
|
|
99
120
|
}
|
|
100
121
|
}
|
|
122
|
+
class NotRegularFile extends WorkspaceReadError {
|
|
123
|
+
constructor(path, stage) {
|
|
124
|
+
super('not_found', `Not a regular file: ${path}`, stage);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
class RootUnavailable extends WorkspaceReadError {
|
|
128
|
+
mount;
|
|
129
|
+
reason;
|
|
130
|
+
refusal;
|
|
131
|
+
constructor(mount, reason, stage, errno, refusal) {
|
|
132
|
+
super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
|
|
133
|
+
this.mount = mount;
|
|
134
|
+
this.reason = reason;
|
|
135
|
+
this.refusal = refusal;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
101
138
|
/**
|
|
102
139
|
* Why a pass decided nothing, or null where it decided its paths. A pass gives
|
|
103
140
|
* up deciding, and reports no path, when the selected branch kept changing
|
|
@@ -112,23 +149,9 @@ function errnoOf(err) {
|
|
|
112
149
|
? err.code
|
|
113
150
|
: undefined;
|
|
114
151
|
}
|
|
115
|
-
/**
|
|
116
|
-
* What is at a mount root's path now, followed as the root is: for a
|
|
117
|
-
* directory, undefined; for anything else, ENOTDIR; and where the path
|
|
118
|
-
* can't be followed to anything, the errno its stat failed with, ENOENT
|
|
119
|
-
* when nothing is there.
|
|
120
|
-
*/
|
|
121
|
-
async function rootAbsence(root) {
|
|
122
|
-
try {
|
|
123
|
-
return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
return errnoOf(err);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
152
|
/** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
|
|
130
|
-
function imageTooLarge(maxSize, mountPrefixedPath) {
|
|
131
|
-
return new WorkspaceImageReadError(
|
|
153
|
+
function imageTooLarge(maxSize, mountPrefixedPath, size) {
|
|
154
|
+
return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
|
|
132
155
|
}
|
|
133
156
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
134
157
|
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
|
|
@@ -144,9 +167,6 @@ const JPEG_SOI = 0xd8;
|
|
|
144
167
|
const JPEG_EOI = 0xd9;
|
|
145
168
|
const JPEG_SOS = 0xda;
|
|
146
169
|
const JPEG_TEM = 0x01;
|
|
147
|
-
function isErrnoCode(err, code) {
|
|
148
|
-
return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
|
|
149
|
-
}
|
|
150
170
|
function startsWithBytes(buffer, prefix) {
|
|
151
171
|
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
152
172
|
}
|
|
@@ -180,7 +200,7 @@ async function linkReadError(mount, found, mountPrefixedPath, relativePath, nest
|
|
|
180
200
|
const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
|
|
181
201
|
const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
|
|
182
202
|
const code = leads?.kind === 'outside' ? 'escape'
|
|
183
|
-
: leads?.kind === 'inside' && holder && namesHolder(leads,
|
|
203
|
+
: leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
|
|
184
204
|
: relativePath === found.link ? 'symlink' : 'parent_symlink';
|
|
185
205
|
return new WorkspaceReadError(code, reason, 'readlink');
|
|
186
206
|
}
|
|
@@ -211,28 +231,27 @@ function ancestorsOf(path) {
|
|
|
211
231
|
* directory as configured beneath the outer mount's configured path
|
|
212
232
|
* (`outerPath`), beneath each directory above its configured path that is
|
|
213
233
|
* the outer root on disk (`above`, each with where it lies on disk), and
|
|
214
|
-
* where its real root (`real`) lies beneath the outer root (`outerReal`)
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* whole. Undefined where it's nested nowhere.
|
|
234
|
+
* where its real root (`real`) lies beneath the outer root (`outerReal`).
|
|
235
|
+
* Its real root is claimed where it doesn't hold the outer root, which it
|
|
236
|
+
* would otherwise claim whole. Undefined where it's nested nowhere.
|
|
218
237
|
*/
|
|
219
|
-
|
|
238
|
+
function placementOf(outerPath, outerReal, innerPath, real, above) {
|
|
220
239
|
const configured = nestedDir(outerPath, innerPath);
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
240
|
+
const dirs = new Map();
|
|
241
|
+
const place = (dir, sure) => { if (dir !== undefined)
|
|
242
|
+
dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
|
|
243
|
+
place(configured, true);
|
|
244
|
+
for (const a of above) {
|
|
245
|
+
if (sameRoot(a.real, outerReal))
|
|
246
|
+
place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
|
|
247
|
+
}
|
|
248
|
+
const below = beneathRoot(real, outerReal);
|
|
249
|
+
place(below, below !== undefined && surelyAtRoot(real, outerReal));
|
|
250
|
+
if (dirs.size === 0)
|
|
228
251
|
return undefined;
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (!spellings.some((s) => s.dir === dir))
|
|
233
|
-
spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
234
|
-
}
|
|
235
|
-
return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
|
|
252
|
+
const fromRoots = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true }) }));
|
|
253
|
+
const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
|
|
254
|
+
return { configured, fromRoots, claims };
|
|
236
255
|
}
|
|
237
256
|
/** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
|
|
238
257
|
const LINKS_FOLLOWED = 40;
|
|
@@ -298,7 +317,7 @@ function isContainedPath(root, candidate) {
|
|
|
298
317
|
return candidate.startsWith(normalizedRoot);
|
|
299
318
|
}
|
|
300
319
|
function invalidImage(format, mountPrefixedPath) {
|
|
301
|
-
throw new WorkspaceImageReadError(
|
|
320
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
|
|
302
321
|
}
|
|
303
322
|
function requireBufferRange(bytes, start, length, format, mountPrefixedPath) {
|
|
304
323
|
if (start < 0 || length < 0 || start + length > bytes.length) {
|
|
@@ -634,21 +653,21 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
|
|
|
634
653
|
return 'image/png';
|
|
635
654
|
}
|
|
636
655
|
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
637
|
-
throw
|
|
656
|
+
throw truncatedImage(mountPrefixedPath);
|
|
638
657
|
}
|
|
639
658
|
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
640
659
|
validateJpeg(bytes, mountPrefixedPath);
|
|
641
660
|
return 'image/jpeg';
|
|
642
661
|
}
|
|
643
662
|
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
644
|
-
throw
|
|
663
|
+
throw truncatedImage(mountPrefixedPath);
|
|
645
664
|
}
|
|
646
665
|
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
647
666
|
validateGif(bytes, mountPrefixedPath);
|
|
648
667
|
return 'image/gif';
|
|
649
668
|
}
|
|
650
669
|
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
651
|
-
throw
|
|
670
|
+
throw truncatedImage(mountPrefixedPath);
|
|
652
671
|
}
|
|
653
672
|
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
654
673
|
validateWebp(bytes, mountPrefixedPath);
|
|
@@ -656,9 +675,9 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
|
|
|
656
675
|
}
|
|
657
676
|
if ((bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
658
677
|
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)) {
|
|
659
|
-
throw
|
|
678
|
+
throw truncatedImage(mountPrefixedPath);
|
|
660
679
|
}
|
|
661
|
-
throw new WorkspaceImageReadError('
|
|
680
|
+
throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows`, "read shows it, if it's text.");
|
|
662
681
|
}
|
|
663
682
|
export class WorkspaceModule {
|
|
664
683
|
name = 'workspace';
|
|
@@ -684,14 +703,11 @@ export class WorkspaceModule {
|
|
|
684
703
|
ctimeTrust = new Map();
|
|
685
704
|
/**
|
|
686
705
|
* Each mount's directories that are other mounts nested inside it, with
|
|
687
|
-
* each mount's name and real root,
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
* the
|
|
692
|
-
* with each root as resolved standing for its real one, and each start
|
|
693
|
-
* reads it all again with the roots placed where disk has them
|
|
694
|
-
* (readNesting).
|
|
706
|
+
* each mount's name and real root: as configured, and as the real roots
|
|
707
|
+
* place it, where that differs (`fromRoots`). Each is mount-relative and
|
|
708
|
+
* `/`-separated. The constructor nests by configured path, with each root
|
|
709
|
+
* as resolved standing for its real one, and each start reads it all again
|
|
710
|
+
* with the roots placed where disk has them (readNesting).
|
|
695
711
|
*/
|
|
696
712
|
otherMounts = new Map();
|
|
697
713
|
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
@@ -748,20 +764,19 @@ export class WorkspaceModule {
|
|
|
748
764
|
if (refused.length > 0) {
|
|
749
765
|
throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
|
|
750
766
|
}
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
if (!Array.isArray(
|
|
756
|
-
throw new Error(`[workspace] Mount "${mount.name}":
|
|
767
|
+
for (const list of ['ignore', 'include']) {
|
|
768
|
+
const patterns = mount[list];
|
|
769
|
+
if (patterns === undefined)
|
|
770
|
+
continue;
|
|
771
|
+
if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
|
|
772
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
|
|
757
773
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
+ `${shownValue(unreadable)} is none of these`);
|
|
774
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
775
|
+
const pattern = patterns[i];
|
|
776
|
+
const why = patternRefusal(pattern, list);
|
|
777
|
+
if (why !== undefined) {
|
|
778
|
+
throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
|
|
779
|
+
}
|
|
765
780
|
}
|
|
766
781
|
}
|
|
767
782
|
}
|
|
@@ -782,8 +797,8 @@ export class WorkspaceModule {
|
|
|
782
797
|
const dir = nestedDir(outerPath, innerPath);
|
|
783
798
|
if (dir === undefined)
|
|
784
799
|
continue;
|
|
785
|
-
const configured = [{ dir
|
|
786
|
-
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured
|
|
800
|
+
const configured = [{ dir }];
|
|
801
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
|
|
787
802
|
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
788
803
|
`at ${dir}/ — passing over it as another mount's directory`);
|
|
789
804
|
}
|
|
@@ -1067,7 +1082,7 @@ export class WorkspaceModule {
|
|
|
1067
1082
|
const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
|
|
1068
1083
|
let start = base;
|
|
1069
1084
|
for (const segment of beneath.split('/')) {
|
|
1070
|
-
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
|
|
1085
|
+
if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined))
|
|
1071
1086
|
break;
|
|
1072
1087
|
const here = await checkDir(view, rootReal, start);
|
|
1073
1088
|
if (here.kind !== 'checked')
|
|
@@ -1125,11 +1140,10 @@ export class WorkspaceModule {
|
|
|
1125
1140
|
}
|
|
1126
1141
|
async start(ctx) {
|
|
1127
1142
|
// Which mounts are nested where, with the roots placed where disk has
|
|
1128
|
-
// them and each
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
// sets nothing.
|
|
1143
|
+
// them, and whether each mount's root folds case, read where disk has
|
|
1144
|
+
// it, before this start sets anything, so a restart takes no call
|
|
1145
|
+
// and walks or watches nothing before they're known. A stop begun
|
|
1146
|
+
// meanwhile stands, and this start then sets nothing.
|
|
1133
1147
|
const stops = this.stopsBegun;
|
|
1134
1148
|
const { nesting, reals, above } = await this.readNesting();
|
|
1135
1149
|
const rootFoldsCase = new Map();
|
|
@@ -1242,7 +1256,8 @@ export class WorkspaceModule {
|
|
|
1242
1256
|
const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
|
|
1243
1257
|
return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
1244
1258
|
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
1245
|
-
`never had.
|
|
1259
|
+
`never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
|
|
1260
|
+
'if the current branch is what disk should hold, pass force: true to overwrite disk from it.');
|
|
1246
1261
|
}
|
|
1247
1262
|
/**
|
|
1248
1263
|
* Re-pin mounts whose last-materialized branch is a proven ancestor of the
|
|
@@ -1866,7 +1881,12 @@ export class WorkspaceModule {
|
|
|
1866
1881
|
work.then(done, done);
|
|
1867
1882
|
return work;
|
|
1868
1883
|
}
|
|
1869
|
-
|
|
1884
|
+
runToolCall(call) {
|
|
1885
|
+
const gate = { passed: false };
|
|
1886
|
+
return callGate.run(gate, () => this.answerToolCall(call, gate));
|
|
1887
|
+
}
|
|
1888
|
+
async answerToolCall(call, gate) {
|
|
1889
|
+
const reopenAtStart = this.agreement?.awaitsReopen === true;
|
|
1870
1890
|
try {
|
|
1871
1891
|
// Every handler reads its input as its schema describes it; a call
|
|
1872
1892
|
// that sends something else is refused here, before anything is read.
|
|
@@ -1881,12 +1901,12 @@ export class WorkspaceModule {
|
|
|
1881
1901
|
// ResultTooLarge in its place. A call made in process carries none and
|
|
1882
1902
|
// takes the data itself, so its read pages by its line limit alone.
|
|
1883
1903
|
const resultMaxChars = call.resultMaxChars;
|
|
1884
|
-
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1904
|
+
const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1885
1905
|
if (generated) {
|
|
1886
1906
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
1887
1907
|
return await this.handleRead(input, resultMaxChars, generated.read());
|
|
1888
1908
|
}
|
|
1889
|
-
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
1909
|
+
return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path, generated, call.callerAgentName)) };
|
|
1890
1910
|
}
|
|
1891
1911
|
switch (call.name) {
|
|
1892
1912
|
case 'read': return await this.handleRead(input, resultMaxChars);
|
|
@@ -1902,12 +1922,40 @@ export class WorkspaceModule {
|
|
|
1902
1922
|
case 'materialize': return await this.handleMaterialize(input);
|
|
1903
1923
|
case 'sync': return await this.handleSync(input);
|
|
1904
1924
|
default:
|
|
1905
|
-
return { success: false, error:
|
|
1925
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
1906
1926
|
}
|
|
1907
1927
|
}
|
|
1908
1928
|
catch (err) {
|
|
1909
|
-
return { success: false, error: err
|
|
1929
|
+
return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
failureAnswer(call, err, reopenAtStart, passedGate) {
|
|
1933
|
+
const input = (typeof call.input === 'object' && call.input !== null ? call.input : {});
|
|
1934
|
+
const path = typeof input.path === 'string' ? input.path : undefined;
|
|
1935
|
+
const mountName = path !== undefined ? path.split('/')[0] : typeof input.mount === 'string' ? input.mount : undefined;
|
|
1936
|
+
const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
|
|
1937
|
+
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
|
|
1938
|
+
if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
|
|
1939
|
+
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
|
|
1940
|
+
}
|
|
1941
|
+
if (err instanceof Refusal)
|
|
1942
|
+
return didNothing(call.name, err.message);
|
|
1943
|
+
if (err instanceof WorkspaceReadError)
|
|
1944
|
+
return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
|
|
1945
|
+
const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
|
|
1946
|
+
: mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
|
|
1947
|
+
: 'ls of each mount, with recursive: true, shows how each file stands against disk now';
|
|
1948
|
+
if (err instanceof NotStored || err instanceof AlreadyHeld) {
|
|
1949
|
+
console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
|
|
1950
|
+
return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
|
|
1951
|
+
}
|
|
1952
|
+
console.error(`[workspace] ${call.name} failed:`, err);
|
|
1953
|
+
if (err instanceof CommitUnknownError) {
|
|
1954
|
+
return unexpectedAnswer(call.name, subject, err.setCause, {
|
|
1955
|
+
said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
|
|
1956
|
+
});
|
|
1910
1957
|
}
|
|
1958
|
+
return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
|
|
1911
1959
|
}
|
|
1912
1960
|
async onProcess(event, _state) {
|
|
1913
1961
|
// Wake agents on filesystem events when the originating mount opted in.
|
|
@@ -1955,7 +2003,7 @@ export class WorkspaceModule {
|
|
|
1955
2003
|
text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
1956
2004
|
}
|
|
1957
2005
|
if (conflicts && conflicts.length > 0) {
|
|
1958
|
-
text += `\n[conflicts: ${conflicts.join(', ')}]`;
|
|
2006
|
+
text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
1959
2007
|
}
|
|
1960
2008
|
return {
|
|
1961
2009
|
requestInference: true,
|
|
@@ -2003,16 +2051,15 @@ export class WorkspaceModule {
|
|
|
2003
2051
|
resolveAbsolutePath(mountPrefixedPath) {
|
|
2004
2052
|
try {
|
|
2005
2053
|
// Synchronous, so the path as given. The nested-mount check takes it
|
|
2006
|
-
// in
|
|
2007
|
-
//
|
|
2008
|
-
//
|
|
2009
|
-
// include judges it as given, so on a disk that
|
|
2054
|
+
// in any spelling a disk that folds names may fold to the nested
|
|
2055
|
+
// mount's directory, erring toward that mount (holderOf). The include
|
|
2056
|
+
// judges it as given, so on a disk that
|
|
2010
2057
|
// folds names a covered name whose file disk spells otherwise returns
|
|
2011
2058
|
// that file's path, which the tools refuse, and a covered file named in
|
|
2012
2059
|
// another case is null, which the tools read. A path the mount doesn't
|
|
2013
2060
|
// hold, outside its include or in another mount's directory nested in
|
|
2014
2061
|
// it, is null, as for a path that leaves the mount's directory.
|
|
2015
|
-
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath
|
|
2062
|
+
const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
|
|
2016
2063
|
return resolve(mount.config.path, relativePath);
|
|
2017
2064
|
}
|
|
2018
2065
|
catch {
|
|
@@ -2040,6 +2087,9 @@ export class WorkspaceModule {
|
|
|
2040
2087
|
* `agentName` names the agent whose write it is for the head-move notice
|
|
2041
2088
|
* (a write naming none tells no agent).
|
|
2042
2089
|
*
|
|
2090
|
+
* A success's `disk` says where the file stands on disk as the workspace
|
|
2091
|
+
* last saw it, as the write tool's answer does.
|
|
2092
|
+
*
|
|
2043
2093
|
* A write whose tree entry is set but that then fails returns success
|
|
2044
2094
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2045
2095
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -2067,12 +2117,14 @@ export class WorkspaceModule {
|
|
|
2067
2117
|
isError: true,
|
|
2068
2118
|
};
|
|
2069
2119
|
}
|
|
2070
|
-
|
|
2120
|
+
if (relativePath === '')
|
|
2121
|
+
return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
|
|
2122
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
2071
2123
|
if (refusal)
|
|
2072
2124
|
return { success: false, error: refusal, isError: true };
|
|
2073
2125
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2074
2126
|
if (data.byteLength > maxSize) {
|
|
2075
|
-
return { success: false, error:
|
|
2127
|
+
return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
|
|
2076
2128
|
}
|
|
2077
2129
|
const store = this.getStore();
|
|
2078
2130
|
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
@@ -2087,7 +2139,7 @@ export class WorkspaceModule {
|
|
|
2087
2139
|
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2088
2140
|
if ('error' in written)
|
|
2089
2141
|
return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
|
|
2090
|
-
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
|
|
2142
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
2091
2143
|
}
|
|
2092
2144
|
return this.withMount(mount, async () => {
|
|
2093
2145
|
await this.settleBeforeMutation(mount, relativePath);
|
|
@@ -2102,9 +2154,11 @@ export class WorkspaceModule {
|
|
|
2102
2154
|
let committed = false;
|
|
2103
2155
|
let failure;
|
|
2104
2156
|
let modeKept = null;
|
|
2157
|
+
let disk = null;
|
|
2105
2158
|
try {
|
|
2106
2159
|
const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
|
|
2107
2160
|
modeKept = answer.modeKept;
|
|
2161
|
+
disk = answer.disk;
|
|
2108
2162
|
failure = answer.refused === null ? null
|
|
2109
2163
|
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
|
|
2110
2164
|
}
|
|
@@ -2113,10 +2167,13 @@ export class WorkspaceModule {
|
|
|
2113
2167
|
throw error;
|
|
2114
2168
|
// As write words it: a store that failed after recording the file
|
|
2115
2169
|
// skipped its push, so on a mount that pushes, disk lacks it.
|
|
2170
|
+
const reopen = this.agreement?.awaitsReopen === true;
|
|
2116
2171
|
failure = error instanceof StoredThenFailed
|
|
2117
2172
|
? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
|
|
2118
|
-
+ (error.pushes ?
|
|
2173
|
+
+ (error.pushes ? ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
2119
2174
|
: `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
2175
|
+
if (reopen)
|
|
2176
|
+
failure += ` ${AWAITS_REOPEN}`;
|
|
2120
2177
|
}
|
|
2121
2178
|
if (failure !== null) {
|
|
2122
2179
|
// The file is in the Chronicle tree, where the workspace's tools read
|
|
@@ -2126,7 +2183,7 @@ export class WorkspaceModule {
|
|
|
2126
2183
|
}
|
|
2127
2184
|
return {
|
|
2128
2185
|
success: true,
|
|
2129
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
|
|
2186
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
|
|
2130
2187
|
};
|
|
2131
2188
|
});
|
|
2132
2189
|
}
|
|
@@ -2162,12 +2219,13 @@ export class WorkspaceModule {
|
|
|
2162
2219
|
return this.withMount(mount, async () => {
|
|
2163
2220
|
const { report, undecided } = await this.ensureSynced(mount, relativePath);
|
|
2164
2221
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2165
|
-
if (!entry)
|
|
2166
|
-
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2222
|
+
if (!entry) {
|
|
2223
|
+
return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
|
|
2224
|
+
}
|
|
2225
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2226
|
+
if ('lost' in contents)
|
|
2227
|
+
return { error: nothingToRead(contents.lost) };
|
|
2228
|
+
return { data: contents.bytes };
|
|
2171
2229
|
});
|
|
2172
2230
|
}
|
|
2173
2231
|
/**
|
|
@@ -2245,9 +2303,9 @@ export class WorkspaceModule {
|
|
|
2245
2303
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
2246
2304
|
}
|
|
2247
2305
|
}
|
|
2248
|
-
/** The directories of the mounts nested inside a mount, mount-relative
|
|
2306
|
+
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
2249
2307
|
othersOf(mountName) {
|
|
2250
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2308
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
|
|
2251
2309
|
}
|
|
2252
2310
|
/**
|
|
2253
2311
|
* Which mounts are nested in which, read afresh at each start. A mount is
|
|
@@ -2262,13 +2320,8 @@ export class WorkspaceModule {
|
|
|
2262
2320
|
* alone, with no real root, since through it it would claim the other's
|
|
2263
2321
|
* every path.
|
|
2264
2322
|
*
|
|
2265
|
-
* Each way it lies there is a spelling of its directory (`fromRoots`)
|
|
2266
|
-
*
|
|
2267
|
-
* disk that folds names, the outer mount's walks name the directory as
|
|
2268
|
-
* disk does, and so does a path parsePath has respelled, while a path given
|
|
2269
|
-
* as configured still names it as configured (resolveAbsolutePath). A
|
|
2270
|
-
* directory not on disk keeps the spellings the roots give alone. Each
|
|
2271
|
-
* mount's real root comes back beside the nesting (`reals`), for what
|
|
2323
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`).
|
|
2324
|
+
* Each mount's real root comes back beside the nesting (`reals`), for what
|
|
2272
2325
|
* start reads there, and each directory above each configured path with
|
|
2273
2326
|
* where it lies on disk (`above`), for a direct mount whose root moves
|
|
2274
2327
|
* (movedNesting).
|
|
@@ -2295,15 +2348,16 @@ export class WorkspaceModule {
|
|
|
2295
2348
|
if (outer === inner)
|
|
2296
2349
|
continue;
|
|
2297
2350
|
const real = reals.get(inner.name);
|
|
2298
|
-
const at =
|
|
2351
|
+
const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name));
|
|
2299
2352
|
if (at === undefined)
|
|
2300
2353
|
continue;
|
|
2301
|
-
const { configured, fromRoots,
|
|
2302
|
-
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots
|
|
2354
|
+
const { configured, fromRoots, claims } = at;
|
|
2355
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
|
|
2303
2356
|
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
2304
2357
|
if (placed.length > 0) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2358
|
+
const at = (sure) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
|
|
2359
|
+
const where = [at(true) && `where it lies on disk, at ${at(true)}`, at(false) && `where it may lie on disk, at ${at(false)}`].filter(Boolean).join(', and ');
|
|
2360
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
|
|
2307
2361
|
}
|
|
2308
2362
|
}
|
|
2309
2363
|
}
|
|
@@ -2311,10 +2365,10 @@ export class WorkspaceModule {
|
|
|
2311
2365
|
}
|
|
2312
2366
|
/**
|
|
2313
2367
|
* The mounts nested in `mountName`, by directory, name, real root where it
|
|
2314
|
-
* has one, and include (MountView's `nested`)
|
|
2368
|
+
* has one, and include (MountView's `nested`).
|
|
2315
2369
|
*/
|
|
2316
2370
|
nestedOf(mountName) {
|
|
2317
|
-
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.
|
|
2371
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
|
|
2318
2372
|
}
|
|
2319
2373
|
/**
|
|
2320
2374
|
* The mounts nested in `mount` as a call of it that resolved its root to
|
|
@@ -2416,12 +2470,13 @@ export class WorkspaceModule {
|
|
|
2416
2470
|
continue;
|
|
2417
2471
|
above = placedAbove.get(inner.name) ?? [];
|
|
2418
2472
|
}
|
|
2419
|
-
const at =
|
|
2473
|
+
const at = placementOf(outerPath, rootReal, innerPath, real, above);
|
|
2420
2474
|
if (at === undefined)
|
|
2421
2475
|
continue;
|
|
2422
2476
|
const include = this.mounts.get(inner.name)?.config.include;
|
|
2423
|
-
for (const s of at.
|
|
2424
|
-
nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2477
|
+
for (const s of at.fromRoots) {
|
|
2478
|
+
nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
|
|
2479
|
+
}
|
|
2425
2480
|
}
|
|
2426
2481
|
return nested;
|
|
2427
2482
|
}
|
|
@@ -2430,15 +2485,9 @@ export class WorkspaceModule {
|
|
|
2430
2485
|
* of that mount, judged lexically where the path lands (`.`, `..` and
|
|
2431
2486
|
* repeated separators resolved, as for the traversal guard; a link of the
|
|
2432
2487
|
* outer mount isn't followed), with the path to use there; undefined when
|
|
2433
|
-
* no nested mount's directory holds it.
|
|
2434
|
-
* compared with the directory's spellings exactly. One judged as given
|
|
2435
|
-
* (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
|
|
2436
|
-
* folds names may compare it (beginsWithDir), so it can't reach that
|
|
2437
|
-
* mount's directory by spelling a segment otherwise. On a disk that tells
|
|
2438
|
-
* names apart, that takes a directory of this mount's own spelled like the
|
|
2439
|
-
* nested one in another way as that mount's too.
|
|
2488
|
+
* no nested mount's directory holds it.
|
|
2440
2489
|
*/
|
|
2441
|
-
holderOf(mountName, relativePath,
|
|
2490
|
+
holderOf(mountName, relativePath, moved = null) {
|
|
2442
2491
|
const mount = this.mounts.get(mountName);
|
|
2443
2492
|
if (!mount)
|
|
2444
2493
|
return undefined;
|
|
@@ -2446,20 +2495,15 @@ export class WorkspaceModule {
|
|
|
2446
2495
|
const resolved = resolve(root, relativePath);
|
|
2447
2496
|
if (!isContainedPath(root, resolved))
|
|
2448
2497
|
return undefined;
|
|
2449
|
-
const
|
|
2498
|
+
const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
|
|
2450
2499
|
// A direct mount whose root a call found moved is judged by that call's
|
|
2451
2500
|
// nesting (movedNesting), each directory beneath its configured path.
|
|
2452
|
-
const
|
|
2453
|
-
? moved.map((n) => ({ name: n.name, dir: n.dir }))
|
|
2454
|
-
: (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
|
|
2455
|
-
const holder = dirs
|
|
2456
|
-
.filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
|
|
2457
|
-
.map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
|
|
2458
|
-
.sort((a, b) => b.depth - a.depth)[0];
|
|
2501
|
+
const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
|
|
2459
2502
|
if (!holder)
|
|
2460
2503
|
return undefined;
|
|
2461
|
-
const rest =
|
|
2462
|
-
|
|
2504
|
+
const rest = restBeneath(path, holder);
|
|
2505
|
+
const guess = nestedGuess(mountName, path.split('/'), holder);
|
|
2506
|
+
return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
|
|
2463
2507
|
}
|
|
2464
2508
|
/**
|
|
2465
2509
|
* Why a mount leaves a path in a nested mount's directory, naming that
|
|
@@ -2467,10 +2511,10 @@ export class WorkspaceModule {
|
|
|
2467
2511
|
* it; for a listing, whose the path is.
|
|
2468
2512
|
*/
|
|
2469
2513
|
otherMountNote(mountName, relativePath, by, moved = null) {
|
|
2470
|
-
const holder = this.holderOf(mountName, relativePath,
|
|
2514
|
+
const holder = this.holderOf(mountName, relativePath, moved);
|
|
2471
2515
|
const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
|
|
2472
2516
|
return holder
|
|
2473
|
-
? `it lies in mount "${holder.name}", nested in this one${what}:
|
|
2517
|
+
? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
|
|
2474
2518
|
: `it lies in another mount's directory${what}`;
|
|
2475
2519
|
}
|
|
2476
2520
|
/**
|
|
@@ -2489,7 +2533,7 @@ export class WorkspaceModule {
|
|
|
2489
2533
|
// Both checks judge by the nesting the parse found, and the second reads
|
|
2490
2534
|
// disk beneath the root the parse resolved.
|
|
2491
2535
|
const { mount, root, moved } = spelled;
|
|
2492
|
-
const parsed = this.refuseNotHeld(spelled, path,
|
|
2536
|
+
const parsed = this.refuseNotHeld(spelled, path, moved);
|
|
2493
2537
|
// A path can also reach a nested mount's directory, or a file this
|
|
2494
2538
|
// mount's own include leaves out, through a symlink on its way. No push
|
|
2495
2539
|
// of this mount writes it there and no read reads it, so it's refused
|
|
@@ -2531,7 +2575,9 @@ export class WorkspaceModule {
|
|
|
2531
2575
|
// whatever the place realpath stopped short of would say.
|
|
2532
2576
|
if (judged && unresolved.code !== undefined)
|
|
2533
2577
|
return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
|
|
2534
|
-
|
|
2578
|
+
let holder = await nestedAtReal(real, nested);
|
|
2579
|
+
if (holder?.guess === null)
|
|
2580
|
+
holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
|
|
2535
2581
|
if (!holder) {
|
|
2536
2582
|
if (!judged)
|
|
2537
2583
|
return null;
|
|
@@ -2550,26 +2596,25 @@ export class WorkspaceModule {
|
|
|
2550
2596
|
// on the place's way as that mount's, so a place beneath anything else
|
|
2551
2597
|
// there, such as a file the include leaves out, isn't named; one beneath
|
|
2552
2598
|
// a directory not there yet is.
|
|
2553
|
-
const onItsWay = holder.
|
|
2599
|
+
const onItsWay = !holder.judgedByInclude
|
|
2554
2600
|
|| await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
|
|
2555
|
-
const directory = holder.
|
|
2556
|
-
if (!onItsWay || !
|
|
2601
|
+
const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
|
|
2602
|
+
if (!onItsWay || !holder.names(directory))
|
|
2557
2603
|
return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
|
|
2558
|
-
return new NestedMountError(path, mount.config.name, holder
|
|
2604
|
+
return new NestedMountError(path, mount.config.name, holder, 'leads into');
|
|
2559
2605
|
}
|
|
2560
2606
|
/**
|
|
2561
2607
|
* A parsed path, refused where the mount doesn't hold it: in a nested
|
|
2562
2608
|
* mount's directory first, naming the mount that holds it, then outside
|
|
2563
2609
|
* the mount's include. Each is judged on the path as the caller parsed it:
|
|
2564
2610
|
* as disk spells it for parseFilePath, as given for resolveAbsolutePath,
|
|
2565
|
-
* which is synchronous
|
|
2566
|
-
* folds where disk does). The include's refusal shows the path as given
|
|
2611
|
+
* which is synchronous. The include's refusal shows the path as given
|
|
2567
2612
|
* (`given`), since it answers the path the caller asked about.
|
|
2568
2613
|
*/
|
|
2569
|
-
refuseNotHeld(parsed, given,
|
|
2570
|
-
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath,
|
|
2614
|
+
refuseNotHeld(parsed, given, moved = null) {
|
|
2615
|
+
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
|
|
2571
2616
|
if (holder)
|
|
2572
|
-
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder
|
|
2617
|
+
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
|
|
2573
2618
|
if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
|
|
2574
2619
|
throw new OutsideIncludeError(given, parsed.mount.config);
|
|
2575
2620
|
}
|
|
@@ -2615,7 +2660,7 @@ export class WorkspaceModule {
|
|
|
2615
2660
|
* parse resolved (`root`), by the nesting there (`moved`, movedNesting).
|
|
2616
2661
|
*/
|
|
2617
2662
|
async refusesAsOutside(mount, relativePath, moved, root) {
|
|
2618
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
2663
|
+
if (this.holderOf(mount.config.name, relativePath, moved))
|
|
2619
2664
|
return false;
|
|
2620
2665
|
if (insideMount(relativePath, mount.config.include))
|
|
2621
2666
|
return false;
|
|
@@ -2690,20 +2735,12 @@ export class WorkspaceModule {
|
|
|
2690
2735
|
}
|
|
2691
2736
|
/**
|
|
2692
2737
|
* How many segments the innermost nested mount's directory that holds
|
|
2693
|
-
* `relativePath` has,
|
|
2694
|
-
*
|
|
2738
|
+
* `relativePath` has, or, for a direct mount whose root has moved, in the
|
|
2739
|
+
* nesting there (`moved`);
|
|
2695
2740
|
* undefined when no nested mount's directory holds it.
|
|
2696
2741
|
*/
|
|
2697
2742
|
nestedDepth(mountName, relativePath, moved = null) {
|
|
2698
|
-
|
|
2699
|
-
for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
|
|
2700
|
-
if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
|
|
2701
|
-
continue;
|
|
2702
|
-
const segments = dir.split('/').length;
|
|
2703
|
-
if (depth === undefined || segments > depth)
|
|
2704
|
-
depth = segments;
|
|
2705
|
-
}
|
|
2706
|
-
return depth;
|
|
2743
|
+
return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
|
|
2707
2744
|
}
|
|
2708
2745
|
/**
|
|
2709
2746
|
* A mount's entries on the current branch, and the paths it records an
|
|
@@ -2725,11 +2762,7 @@ export class WorkspaceModule {
|
|
|
2725
2762
|
// Only an entry this mount's include covers spells a draft's path
|
|
2726
2763
|
// (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
|
|
2727
2764
|
// from an earlier version, nor one the include leaves out, which would
|
|
2728
|
-
// respell a covered draft into a name the include leaves out.
|
|
2729
|
-
// in a nested mount's directory that this mount's include covers
|
|
2730
|
-
// spells one whatever that mount's include says, so on a disk that
|
|
2731
|
-
// folds names, a draft naming the directory in another spelling takes
|
|
2732
|
-
// the entry's spelling and is judged as in it.
|
|
2765
|
+
// respell a covered draft into a name the include leaves out.
|
|
2733
2766
|
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
|
|
2734
2767
|
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
2735
2768
|
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
@@ -2754,25 +2787,25 @@ export class WorkspaceModule {
|
|
|
2754
2787
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
2755
2788
|
// store would keep one name and disk another.
|
|
2756
2789
|
if (LONE_SURROGATE.test(path)) {
|
|
2757
|
-
throw new
|
|
2790
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.`);
|
|
2758
2791
|
}
|
|
2759
2792
|
const slashIdx = path.indexOf('/');
|
|
2760
2793
|
const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
|
|
2761
2794
|
const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
|
|
2762
2795
|
const mount = this.mounts.get(mountName);
|
|
2763
|
-
if (!mount)
|
|
2764
|
-
throw new
|
|
2765
|
-
}
|
|
2796
|
+
if (!mount)
|
|
2797
|
+
throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
|
|
2766
2798
|
const segments = pathSegments(given);
|
|
2767
2799
|
if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
|
|
2768
|
-
throw new
|
|
2800
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root, as "${mountName}/<path>".`);
|
|
2769
2801
|
}
|
|
2770
2802
|
const kept = [];
|
|
2771
2803
|
for (const segment of segments) {
|
|
2772
2804
|
if (segment !== '..')
|
|
2773
2805
|
kept.push(segment);
|
|
2774
2806
|
else if (kept.pop() === undefined) {
|
|
2775
|
-
throw new
|
|
2807
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
|
|
2808
|
+
+ `Name a file from its own mount's name; ${this.mountList()}.`);
|
|
2776
2809
|
}
|
|
2777
2810
|
}
|
|
2778
2811
|
const relativePath = kept.join('/');
|
|
@@ -2786,10 +2819,37 @@ export class WorkspaceModule {
|
|
|
2786
2819
|
const root = resolve(mount.config.path);
|
|
2787
2820
|
const resolved = resolve(root, relativePath);
|
|
2788
2821
|
if (!isContainedPath(root, resolved)) {
|
|
2789
|
-
throw new
|
|
2822
|
+
throw new PathRefusal('traversal', `${JSON.stringify(path)} resolves outside mount "${mountName}", as a drive or root named in it does: give the path from the mount's root, as "${mountName}/<path>".`);
|
|
2790
2823
|
}
|
|
2791
2824
|
return { mount, relativePath };
|
|
2792
2825
|
}
|
|
2826
|
+
writableNames() {
|
|
2827
|
+
return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
|
|
2828
|
+
}
|
|
2829
|
+
mountList() {
|
|
2830
|
+
const names = [...this.mounts.keys()];
|
|
2831
|
+
return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
|
|
2832
|
+
}
|
|
2833
|
+
noSuchMountField(given, bare) {
|
|
2834
|
+
const [close] = closestNames(given, [...this.mounts.keys()], 1);
|
|
2835
|
+
return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
|
|
2836
|
+
+ (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
|
|
2837
|
+
+ ` Leaving "mount" out ${bare}.`;
|
|
2838
|
+
}
|
|
2839
|
+
noSuchMount(path, mountName) {
|
|
2840
|
+
const names = [...this.mounts.keys()];
|
|
2841
|
+
const rest = path.slice(mountName.length);
|
|
2842
|
+
const unslashed = path.replace(/^[/\\]+/, '');
|
|
2843
|
+
if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
|
|
2844
|
+
return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
|
|
2845
|
+
}
|
|
2846
|
+
if (mountName === '')
|
|
2847
|
+
return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
2848
|
+
const [close] = closestNames(mountName, names, 1);
|
|
2849
|
+
const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
|
|
2850
|
+
: `${JSON.stringify(path)} starts with ${JSON.stringify(mountName)}, which is no mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
|
|
2851
|
+
return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
|
|
2852
|
+
}
|
|
2793
2853
|
/**
|
|
2794
2854
|
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
2795
2855
|
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
@@ -2808,25 +2868,23 @@ export class WorkspaceModule {
|
|
|
2808
2868
|
}
|
|
2809
2869
|
validateImageBytes(bytes, mountPrefixedPath, maxSize) {
|
|
2810
2870
|
if (bytes.byteLength === 0) {
|
|
2811
|
-
throw
|
|
2871
|
+
throw emptyImage(mountPrefixedPath);
|
|
2812
2872
|
}
|
|
2813
2873
|
if (bytes.byteLength > maxSize)
|
|
2814
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
2874
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
|
|
2815
2875
|
return {
|
|
2816
2876
|
bytes,
|
|
2817
2877
|
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
2818
2878
|
};
|
|
2819
2879
|
}
|
|
2820
|
-
|
|
2821
|
-
const
|
|
2822
|
-
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2880
|
+
async readImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
2881
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
2823
2882
|
if (!entry)
|
|
2824
2883
|
return null;
|
|
2825
|
-
const
|
|
2826
|
-
if (
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
2884
|
+
const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
|
|
2885
|
+
if ('lost' in contents)
|
|
2886
|
+
return contents;
|
|
2887
|
+
return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
|
|
2830
2888
|
}
|
|
2831
2889
|
/**
|
|
2832
2890
|
* The mount root a disk read reads beneath: resolved, then checked as
|
|
@@ -2842,20 +2900,14 @@ export class WorkspaceModule {
|
|
|
2842
2900
|
* another directory carries none. The identity is rootIdentityForRead's,
|
|
2843
2901
|
* which answers while the evidence awaits reopening; where even it can't
|
|
2844
2902
|
* be read, as when the evidence was never read back whole, the read is
|
|
2845
|
-
* refused
|
|
2903
|
+
* refused before its root is resolved, with an errno only where no
|
|
2904
|
+
* directory is at the root's path.
|
|
2846
2905
|
* A direct mount's root is checked against no identity, and its read reads
|
|
2847
2906
|
* no evidence: it promises what's at its root now (diskView).
|
|
2848
2907
|
*/
|
|
2849
2908
|
async readRoot(mount) {
|
|
2850
2909
|
const name = mount.config.name;
|
|
2851
2910
|
const root = resolve(mount.config.path);
|
|
2852
|
-
let real;
|
|
2853
|
-
try {
|
|
2854
|
-
real = await realpath(root);
|
|
2855
|
-
}
|
|
2856
|
-
catch (err) {
|
|
2857
|
-
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
2858
|
-
}
|
|
2859
2911
|
let rootIdentity;
|
|
2860
2912
|
if (!mount.direct) {
|
|
2861
2913
|
try {
|
|
@@ -2864,9 +2916,16 @@ export class WorkspaceModule {
|
|
|
2864
2916
|
catch (err) {
|
|
2865
2917
|
// The evidence can't be read back, so neither can the check be made.
|
|
2866
2918
|
// Where no directory is at the root's path, its errno still says so.
|
|
2867
|
-
throw new
|
|
2919
|
+
throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
|
|
2868
2920
|
}
|
|
2869
2921
|
}
|
|
2922
|
+
let real;
|
|
2923
|
+
try {
|
|
2924
|
+
real = await realpath(root);
|
|
2925
|
+
}
|
|
2926
|
+
catch (err) {
|
|
2927
|
+
throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
|
|
2928
|
+
}
|
|
2870
2929
|
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
2871
2930
|
if ('dir' in checked)
|
|
2872
2931
|
return { real, dir: checked.dir };
|
|
@@ -2874,7 +2933,7 @@ export class WorkspaceModule {
|
|
|
2874
2933
|
// root's path now decides: nothing, something that isn't a directory, or
|
|
2875
2934
|
// a directory the path has come to name since it was resolved (no errno).
|
|
2876
2935
|
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
2877
|
-
throw new
|
|
2936
|
+
throw new RootUnavailable(name, checked.reason, 'root', errno);
|
|
2878
2937
|
}
|
|
2879
2938
|
/**
|
|
2880
2939
|
* The refusal for a root that stopped being the directory a read checked,
|
|
@@ -2888,7 +2947,7 @@ export class WorkspaceModule {
|
|
|
2888
2947
|
async rootMoved(mount) {
|
|
2889
2948
|
const errno = await rootAbsence(resolve(mount.config.path));
|
|
2890
2949
|
const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
|
|
2891
|
-
return new
|
|
2950
|
+
return new RootUnavailable(mount.config.name, reason, 'root', errno);
|
|
2892
2951
|
}
|
|
2893
2952
|
/**
|
|
2894
2953
|
* Open a mount-relative file for reading with the mount boundary enforced on
|
|
@@ -2978,7 +3037,7 @@ export class WorkspaceModule {
|
|
|
2978
3037
|
}
|
|
2979
3038
|
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
2980
3039
|
// A FIFO, socket or device is never opened.
|
|
2981
|
-
throw new
|
|
3040
|
+
throw new NotRegularFile(mountPrefixedPath, 'lstat');
|
|
2982
3041
|
}
|
|
2983
3042
|
let handle;
|
|
2984
3043
|
try {
|
|
@@ -3009,7 +3068,7 @@ export class WorkspaceModule {
|
|
|
3009
3068
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
3010
3069
|
}
|
|
3011
3070
|
if (!fileStat.isFile()) {
|
|
3012
|
-
throw new
|
|
3071
|
+
throw new NotRegularFile(mountPrefixedPath, 'fstat');
|
|
3013
3072
|
}
|
|
3014
3073
|
if (!follow && !useNoFollow) {
|
|
3015
3074
|
let postOpenInfo;
|
|
@@ -3047,13 +3106,13 @@ export class WorkspaceModule {
|
|
|
3047
3106
|
if (resolvedThrough !== null)
|
|
3048
3107
|
throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
|
|
3049
3108
|
const inside = isContainedPath(realMountRoot, realFilePath);
|
|
3050
|
-
const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
|
|
3109
|
+
const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
|
|
3051
3110
|
// A place in a nested mount's directory that its include leaves out is
|
|
3052
3111
|
// no mount's, so the path leaves this one (namesNestedPlace), and so
|
|
3053
3112
|
// does one reaching a file this mount's own include leaves out
|
|
3054
3113
|
// (coversLanding).
|
|
3055
3114
|
const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
|
|
3056
|
-
if (!inside || own || (holder !== undefined && !
|
|
3115
|
+
if (!inside || own || (holder !== undefined && !holder.names(false))) {
|
|
3057
3116
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
3058
3117
|
}
|
|
3059
3118
|
if (holder)
|
|
@@ -3122,7 +3181,9 @@ export class WorkspaceModule {
|
|
|
3122
3181
|
* (`outside_include`), a path in another mount's directory nested in the one named
|
|
3123
3182
|
* (`other_mount`), unavailable mount, missing or non-regular file,
|
|
3124
3183
|
* directory, policy-denied symlink, outside-mount target, and
|
|
3125
|
-
* file-changed-during-read.
|
|
3184
|
+
* file-changed-during-read. An error met while parsing the path that is
|
|
3185
|
+
* none of these refusals, such as the store's, is thrown as it is, and a
|
|
3186
|
+
* `maxBytes` that isn't a non-negative integer throws a `RangeError`.
|
|
3126
3187
|
*/
|
|
3127
3188
|
async readFileFromDisk(mountPrefixedPath, options = {}) {
|
|
3128
3189
|
if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
|
|
@@ -3137,11 +3198,12 @@ export class WorkspaceModule {
|
|
|
3137
3198
|
// A link's refusal (parseFilePath) carries its own code.
|
|
3138
3199
|
if (err instanceof WorkspaceReadError)
|
|
3139
3200
|
throw err;
|
|
3140
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3141
3201
|
const code = err instanceof OutsideIncludeError ? 'outside_include'
|
|
3142
3202
|
: err instanceof NestedMountError ? 'other_mount'
|
|
3143
|
-
:
|
|
3144
|
-
|
|
3203
|
+
: err instanceof PathRefusal ? err.code : null;
|
|
3204
|
+
if (code === null)
|
|
3205
|
+
throw err;
|
|
3206
|
+
throw new WorkspaceReadError(code, err.message, 'parse');
|
|
3145
3207
|
}
|
|
3146
3208
|
if (!relativePath) {
|
|
3147
3209
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
@@ -3171,93 +3233,25 @@ export class WorkspaceModule {
|
|
|
3171
3233
|
await handle.close();
|
|
3172
3234
|
}
|
|
3173
3235
|
}
|
|
3174
|
-
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
3175
|
-
imageErrorFromRead(err, mount, mountPrefixedPath) {
|
|
3176
|
-
const p = mountPrefixedPath;
|
|
3177
|
-
switch (err.code) {
|
|
3178
|
-
case 'mount_unavailable':
|
|
3179
|
-
// With why, where the root's check says, such as a directory that
|
|
3180
|
-
// isn't the mount's.
|
|
3181
|
-
return new WorkspaceImageReadError('mount_unavailable', err.message);
|
|
3182
|
-
case 'directory':
|
|
3183
|
-
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
3184
|
-
case 'symlink':
|
|
3185
|
-
// The core reader's own words for a link at the path: "Symlinks are not
|
|
3186
|
-
// allowed: <path>".
|
|
3187
|
-
return new WorkspaceImageReadError('symlink', err.message);
|
|
3188
|
-
case 'parent_symlink':
|
|
3189
|
-
// The core reader's own words for a link on its way: which link and
|
|
3190
|
-
// where it leads, with the setting for a link to a directory.
|
|
3191
|
-
return new WorkspaceImageReadError('parent_symlink', err.message);
|
|
3192
|
-
case 'other_mount':
|
|
3193
|
-
// The core reader's own words for a path a link takes into a nested
|
|
3194
|
-
// mount's directory: that mount and the path to use there. (#460's
|
|
3195
|
-
// own other_mount, a path in that directory, is refused at parse,
|
|
3196
|
-
// and so is a link found there before the read.)
|
|
3197
|
-
return new WorkspaceImageReadError('other_mount', err.message);
|
|
3198
|
-
case 'escape':
|
|
3199
|
-
// The core reader's own words: "Symlink escape detected: <path>" for a
|
|
3200
|
-
// followed link that resolves outside the mount, and for a link on the
|
|
3201
|
-
// way that leads outside a mount that doesn't follow links, which link.
|
|
3202
|
-
return new WorkspaceImageReadError('escape', err.message);
|
|
3203
|
-
case 'changed':
|
|
3204
|
-
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
3205
|
-
case 'not_found':
|
|
3206
|
-
default:
|
|
3207
|
-
// The core reader's own words for a file that isn't regular, and for
|
|
3208
|
-
// a directory on the way it couldn't ask about.
|
|
3209
|
-
if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
|
|
3210
|
-
return new WorkspaceImageReadError('not_found', err.message);
|
|
3211
|
-
}
|
|
3212
|
-
if (err.message.startsWith('File not found')) {
|
|
3213
|
-
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
3214
|
-
}
|
|
3215
|
-
if (err.stage === 'realpath') {
|
|
3216
|
-
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
3217
|
-
}
|
|
3218
|
-
if (err.stage === 'stat') {
|
|
3219
|
-
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
3220
|
-
}
|
|
3221
|
-
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
3222
|
-
}
|
|
3223
|
-
}
|
|
3224
3236
|
async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
if (fileStat.size > maxSize)
|
|
3232
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3233
|
-
});
|
|
3234
|
-
}
|
|
3235
|
-
catch (err) {
|
|
3236
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3237
|
-
throw err;
|
|
3238
|
-
if (err instanceof WorkspaceReadError)
|
|
3239
|
-
throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
3240
|
-
if (isErrnoCode(err, 'ENOENT')) {
|
|
3241
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3242
|
-
}
|
|
3243
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3244
|
-
}
|
|
3245
|
-
const { handle, fileStat } = opened;
|
|
3237
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
3238
|
+
if (fileStat.size === 0)
|
|
3239
|
+
throw emptyImage(mountPrefixedPath);
|
|
3240
|
+
if (fileStat.size > maxSize)
|
|
3241
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
|
|
3242
|
+
});
|
|
3246
3243
|
try {
|
|
3247
3244
|
// No more than maxSize is loaded: a file grown past it since the fstat
|
|
3248
3245
|
// above is refused here, as one already over it was there.
|
|
3249
3246
|
const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
|
|
3250
3247
|
if (more)
|
|
3251
|
-
throw imageTooLarge(maxSize, mountPrefixedPath);
|
|
3248
|
+
throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
|
|
3252
3249
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
3253
3250
|
}
|
|
3254
3251
|
catch (err) {
|
|
3255
3252
|
if (err instanceof WorkspaceImageReadError)
|
|
3256
3253
|
throw err;
|
|
3257
|
-
|
|
3258
|
-
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
3259
|
-
}
|
|
3260
|
-
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
3254
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
3261
3255
|
}
|
|
3262
3256
|
finally {
|
|
3263
3257
|
await handle.close();
|
|
@@ -3289,13 +3283,14 @@ export class WorkspaceModule {
|
|
|
3289
3283
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
3290
3284
|
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
3291
3285
|
* deletion that cleared a conflict naming a disk file the workspace never
|
|
3292
|
-
* took in, that nothing was unlinked (`note`).
|
|
3293
|
-
*
|
|
3294
|
-
* the path,
|
|
3286
|
+
* took in, that nothing was unlinked (`note`). And the answer's `disk` for
|
|
3287
|
+
* the path, unless it was refused. The caller holds the mount's turn and has
|
|
3288
|
+
* settled the path first (settleBeforeMutation, or a pass over the path, as
|
|
3289
|
+
* an edit makes).
|
|
3295
3290
|
*/
|
|
3296
3291
|
async commitToolChange(mount, relativePath, change, onCommitted) {
|
|
3297
|
-
const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3298
|
-
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
|
|
3292
|
+
const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
|
|
3293
|
+
return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
|
|
3299
3294
|
}
|
|
3300
3295
|
/**
|
|
3301
3296
|
* commitToolChange for several paths of one mount at once, as edit_many
|
|
@@ -3310,9 +3305,10 @@ export class WorkspaceModule {
|
|
|
3310
3305
|
* (changesLanded).
|
|
3311
3306
|
* Answers, by path, the reason for each that reached the workspace but not
|
|
3312
3307
|
* disk (`refused`), and, apart from it, why one whose bytes reached disk
|
|
3313
|
-
* left the file's mode as disk had it (`modeKept`),
|
|
3314
|
-
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3315
|
-
*
|
|
3308
|
+
* left the file's mode as disk had it (`modeKept`), that a deletion
|
|
3309
|
+
* clearing a conflict that named a disk file unlinked nothing (`notes`),
|
|
3310
|
+
* and the answer's `disk` for each path not refused, as commitToolChange
|
|
3311
|
+
* answers them for one path.
|
|
3316
3312
|
*/
|
|
3317
3313
|
async commitToolChanges(mount, changes, onCommitted) {
|
|
3318
3314
|
const store = this.getStore();
|
|
@@ -3461,7 +3457,7 @@ export class WorkspaceModule {
|
|
|
3461
3457
|
// nothing.
|
|
3462
3458
|
if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
|
|
3463
3459
|
putBack();
|
|
3464
|
-
throw error;
|
|
3460
|
+
throw changing.length === 0 ? new AlreadyHeld(error) : error;
|
|
3465
3461
|
}
|
|
3466
3462
|
landedThenThrew = { error };
|
|
3467
3463
|
}
|
|
@@ -3479,8 +3475,9 @@ export class WorkspaceModule {
|
|
|
3479
3475
|
throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
|
|
3480
3476
|
const refused = new Map();
|
|
3481
3477
|
const modeKept = new Map();
|
|
3482
|
-
if (!this.pushesOnCommit(mount) || pushing.length === 0)
|
|
3483
|
-
return { refused, modeKept, notes };
|
|
3478
|
+
if (!this.pushesOnCommit(mount) || pushing.length === 0) {
|
|
3479
|
+
return { refused, modeKept, notes, disk: new Map(changes.map(({ path }) => [path, this.diskOf(mount, path)])) };
|
|
3480
|
+
}
|
|
3484
3481
|
const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
|
|
3485
3482
|
applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
|
|
3486
3483
|
branchId: store.currentBranch().id,
|
|
@@ -3502,7 +3499,7 @@ export class WorkspaceModule {
|
|
|
3502
3499
|
error: skipped.reason,
|
|
3503
3500
|
});
|
|
3504
3501
|
}
|
|
3505
|
-
return { refused, modeKept, notes };
|
|
3502
|
+
return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }) => [path, 'done'])) };
|
|
3506
3503
|
}
|
|
3507
3504
|
// ==========================================================================
|
|
3508
3505
|
// Lazy Sync
|
|
@@ -3551,25 +3548,29 @@ export class WorkspaceModule {
|
|
|
3551
3548
|
* call made again checks it.
|
|
3552
3549
|
*/
|
|
3553
3550
|
notCheckedYet(path, reason) {
|
|
3554
|
-
return `${
|
|
3551
|
+
return `${JSON.stringify(path)} isn't in the workspace, and disk couldn't be checked: ${reason}. Make the same call again, which checks disk first.`;
|
|
3555
3552
|
}
|
|
3556
3553
|
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
3557
|
-
notInWorkspace(path, report) {
|
|
3554
|
+
notInWorkspace(path, report, mount, missing) {
|
|
3555
|
+
const shown = JSON.stringify(path);
|
|
3558
3556
|
switch (report?.state) {
|
|
3559
3557
|
case 'workspace-deleted':
|
|
3560
|
-
return `${
|
|
3558
|
+
return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
3561
3559
|
case 'not-in-branch':
|
|
3562
|
-
return `${
|
|
3563
|
-
case 'disk-only':
|
|
3564
|
-
|
|
3565
|
-
|
|
3560
|
+
return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
3561
|
+
case 'disk-only': {
|
|
3562
|
+
const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
|
|
3563
|
+
return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
|
|
3564
|
+
+ 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
|
|
3565
|
+
+ (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
|
|
3566
|
+
}
|
|
3566
3567
|
case 'conflict':
|
|
3567
|
-
return `${
|
|
3568
|
+
return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
3568
3569
|
'Sync this path to take the disk version.';
|
|
3569
3570
|
case 'unverified':
|
|
3570
|
-
return
|
|
3571
|
+
return uncheckedFile(shown, report.note);
|
|
3571
3572
|
default:
|
|
3572
|
-
return `
|
|
3573
|
+
return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
3573
3574
|
}
|
|
3574
3575
|
}
|
|
3575
3576
|
/**
|
|
@@ -3611,15 +3612,15 @@ export class WorkspaceModule {
|
|
|
3611
3612
|
// A pass also drops a copy disk made binary or too large, which
|
|
3612
3613
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3613
3614
|
return {
|
|
3614
|
-
error: took('deleted') && report?.state !== 'disk-only'
|
|
3615
|
-
|
|
3616
|
-
|
|
3615
|
+
error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
|
|
3616
|
+
: undecided !== null ? this.notCheckedYet(path, undecided)
|
|
3617
|
+
: this.notInWorkspace(path, report, mount),
|
|
3617
3618
|
};
|
|
3618
3619
|
}
|
|
3619
|
-
const
|
|
3620
|
-
if (
|
|
3621
|
-
return { error:
|
|
3622
|
-
return { bytes:
|
|
3620
|
+
const contents = await this.contentsOf(mount, relativePath, path, entry);
|
|
3621
|
+
if ('lost' in contents)
|
|
3622
|
+
return { error: nothingToRead(contents.lost) };
|
|
3623
|
+
return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3623
3624
|
});
|
|
3624
3625
|
}
|
|
3625
3626
|
/**
|
|
@@ -3632,37 +3633,145 @@ export class WorkspaceModule {
|
|
|
3632
3633
|
*/
|
|
3633
3634
|
async bytesOnDisk(mount, relativePath, path) {
|
|
3634
3635
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3635
|
-
const over = `${path} is
|
|
3636
|
+
const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
|
|
3636
3637
|
let opened;
|
|
3637
3638
|
try {
|
|
3638
3639
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3639
3640
|
if (fileStat.size > maxSize)
|
|
3640
|
-
throw new
|
|
3641
|
+
throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
3641
3642
|
});
|
|
3642
3643
|
}
|
|
3643
3644
|
catch (err) {
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3645
|
+
if (err instanceof Refusal)
|
|
3646
|
+
return { error: err.message };
|
|
3647
|
+
if (err instanceof WorkspaceReadError)
|
|
3648
|
+
return { error: readRefusal(err, path) };
|
|
3649
|
+
throw err;
|
|
3647
3650
|
}
|
|
3648
3651
|
try {
|
|
3649
3652
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
3650
3653
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
3651
3654
|
if (more)
|
|
3652
|
-
return { error: over };
|
|
3655
|
+
return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
3653
3656
|
if (looksBinary(bytes)) {
|
|
3654
|
-
return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '
|
|
3657
|
+
return { error: `${JSON.stringify(path)} is binary, so read doesn't show it as text. ${sniffImageMime(bytes) ? 'read_image shows it, if its data holds together as an image.' : 'No tool shows it as text, so calling again won\'t change that.'}` };
|
|
3655
3658
|
}
|
|
3656
3659
|
return { bytes };
|
|
3657
3660
|
}
|
|
3658
3661
|
catch (err) {
|
|
3659
|
-
|
|
3660
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
3662
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
3661
3663
|
}
|
|
3662
3664
|
finally {
|
|
3663
3665
|
await opened.handle.close();
|
|
3664
3666
|
}
|
|
3665
3667
|
}
|
|
3668
|
+
async contentsOf(mount, relativePath, path, version, what = 'saved', tell = 'now') {
|
|
3669
|
+
const store = this.getStore();
|
|
3670
|
+
const stored = storedBytes(store, version.blobHash);
|
|
3671
|
+
if ('bytes' in stored)
|
|
3672
|
+
return stored;
|
|
3673
|
+
const otherSize = new Error('other size');
|
|
3674
|
+
let found;
|
|
3675
|
+
try {
|
|
3676
|
+
const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
3677
|
+
if (fileStat.size !== version.size)
|
|
3678
|
+
throw otherSize;
|
|
3679
|
+
});
|
|
3680
|
+
try {
|
|
3681
|
+
const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
|
|
3682
|
+
found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
|
|
3683
|
+
}
|
|
3684
|
+
finally {
|
|
3685
|
+
await opened.handle.close();
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
catch (err) {
|
|
3689
|
+
found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
|
|
3690
|
+
}
|
|
3691
|
+
const subject = contentsSubject(what, path);
|
|
3692
|
+
const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
|
|
3693
|
+
if ('disk' in found) {
|
|
3694
|
+
const now = storedBytes(store, version.blobHash);
|
|
3695
|
+
if ('bytes' in now)
|
|
3696
|
+
return now;
|
|
3697
|
+
const lost = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
|
|
3698
|
+
if (tell === 'now')
|
|
3699
|
+
this.tellLost(mount.config.name, what, version.blobHash, lost);
|
|
3700
|
+
return { lost };
|
|
3701
|
+
}
|
|
3702
|
+
const { bytes } = found;
|
|
3703
|
+
if ('bytes' in storedBytes(store, version.blobHash))
|
|
3704
|
+
return { bytes };
|
|
3705
|
+
let thrown = null;
|
|
3706
|
+
try {
|
|
3707
|
+
store.storeBlob(bytes, 'application/octet-stream');
|
|
3708
|
+
}
|
|
3709
|
+
catch (error) {
|
|
3710
|
+
thrown = error instanceof Error ? error.message : String(error);
|
|
3711
|
+
}
|
|
3712
|
+
// Chronicle's storeBlob takes any file already at the hash's path as
|
|
3713
|
+
// stored, so whether the bytes took is what a read of them gives now.
|
|
3714
|
+
const now = storedBytes(store, version.blobHash);
|
|
3715
|
+
if ('bytes' in now) {
|
|
3716
|
+
this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
|
|
3717
|
+
}
|
|
3718
|
+
else {
|
|
3719
|
+
const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
|
|
3720
|
+
this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', `Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
|
|
3721
|
+
+ ('unreadable' in now
|
|
3722
|
+
? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them."
|
|
3723
|
+
: 'Each read does this again until the store takes them.'), { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
|
|
3724
|
+
}
|
|
3725
|
+
return { bytes };
|
|
3726
|
+
}
|
|
3727
|
+
tellLost(mountName, what, blobHash, lost) {
|
|
3728
|
+
this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', `Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
|
|
3729
|
+
+ "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
|
|
3730
|
+
}
|
|
3731
|
+
async diskLacks(mount, relativePath, path, err) {
|
|
3732
|
+
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
3733
|
+
if (!absent)
|
|
3734
|
+
return { unreadable: containedRefusal(err, path) };
|
|
3735
|
+
const root = resolve(mount.config.path);
|
|
3736
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
3737
|
+
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
3738
|
+
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
3739
|
+
if (typeof proof !== 'string')
|
|
3740
|
+
return 'none';
|
|
3741
|
+
return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
|
|
3742
|
+
}
|
|
3743
|
+
async restoreEach(mount, versions, each) {
|
|
3744
|
+
const byHash = new Map();
|
|
3745
|
+
for (const version of versions) {
|
|
3746
|
+
const sharing = byHash.get(version.hash);
|
|
3747
|
+
if (sharing)
|
|
3748
|
+
sharing.push(version);
|
|
3749
|
+
else
|
|
3750
|
+
byHash.set(version.hash, [version]);
|
|
3751
|
+
}
|
|
3752
|
+
for (const [hash, sharing] of byHash) {
|
|
3753
|
+
let bytes = null;
|
|
3754
|
+
const lost = [];
|
|
3755
|
+
for (const version of sharing) {
|
|
3756
|
+
const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
|
|
3757
|
+
if ('bytes' in contents) {
|
|
3758
|
+
bytes = contents.bytes;
|
|
3759
|
+
break;
|
|
3760
|
+
}
|
|
3761
|
+
lost.push([version, contents.lost]);
|
|
3762
|
+
}
|
|
3763
|
+
if (bytes === null) {
|
|
3764
|
+
for (const [version, found] of lost) {
|
|
3765
|
+
this.tellLost(mount.config.name, version.what, hash, found);
|
|
3766
|
+
each(version, { lost: found });
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
else {
|
|
3770
|
+
for (const version of sharing)
|
|
3771
|
+
each(version, { bytes });
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3666
3775
|
// ==========================================================================
|
|
3667
3776
|
// Tool Handlers
|
|
3668
3777
|
// ==========================================================================
|
|
@@ -3677,16 +3786,18 @@ export class WorkspaceModule {
|
|
|
3677
3786
|
// Each number is a whole number of at least its schema's minimum by now
|
|
3678
3787
|
// (inputProblem).
|
|
3679
3788
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
3680
|
-
return { success: false, isError: true, error: '
|
|
3789
|
+
return { success: false, isError: true, error: didNothing('read', 'it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.') };
|
|
3681
3790
|
}
|
|
3682
3791
|
let file;
|
|
3683
3792
|
let notes = [];
|
|
3684
3793
|
if (generatedContent === undefined) {
|
|
3685
3794
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3795
|
+
if (relativePath === '')
|
|
3796
|
+
return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3686
3797
|
input = { ...input, path };
|
|
3687
3798
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
3688
3799
|
if ('error' in found)
|
|
3689
|
-
return { success: false, error: found.error, isError: true };
|
|
3800
|
+
return { success: false, error: didNothing('read', found.error), isError: true };
|
|
3690
3801
|
file = fileText(found.bytes);
|
|
3691
3802
|
notes = found.notes;
|
|
3692
3803
|
}
|
|
@@ -3702,31 +3813,23 @@ export class WorkspaceModule {
|
|
|
3702
3813
|
: linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
|
|
3703
3814
|
}
|
|
3704
3815
|
async handleReadImage(input) {
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
(
|
|
3710
|
-
|
|
3711
|
-
}
|
|
3712
|
-
catch (err) {
|
|
3713
|
-
return {
|
|
3714
|
-
success: false,
|
|
3715
|
-
error: err instanceof Error ? err.message : String(err),
|
|
3716
|
-
isError: true,
|
|
3717
|
-
};
|
|
3718
|
-
}
|
|
3719
|
-
if (!relativePath) {
|
|
3720
|
-
return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
|
|
3721
|
-
}
|
|
3816
|
+
const given = input.path;
|
|
3817
|
+
const refused = (body) => ({ success: false, isError: true, error: didNothing('read_image', body) });
|
|
3818
|
+
const { mount, relativePath, path } = await this.parseFilePath(given);
|
|
3819
|
+
if (!relativePath)
|
|
3820
|
+
return refused(namesRoot(given, mount.config.name));
|
|
3821
|
+
input = { ...input, path };
|
|
3722
3822
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3723
3823
|
let treeError = null;
|
|
3824
|
+
let lost = null;
|
|
3724
3825
|
// The workspace's copy first, where it holds one, then disk's. A direct
|
|
3725
3826
|
// mount holds none, so its image is disk's.
|
|
3726
3827
|
if (!mount.direct) {
|
|
3727
3828
|
try {
|
|
3728
|
-
const image = this.
|
|
3729
|
-
if (image)
|
|
3829
|
+
const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
|
|
3830
|
+
if (image && 'lost' in image)
|
|
3831
|
+
lost = image.lost;
|
|
3832
|
+
else if (image)
|
|
3730
3833
|
return imageResult(input.path, image);
|
|
3731
3834
|
}
|
|
3732
3835
|
catch (err) {
|
|
@@ -3739,31 +3842,40 @@ export class WorkspaceModule {
|
|
|
3739
3842
|
}
|
|
3740
3843
|
}
|
|
3741
3844
|
try {
|
|
3742
|
-
|
|
3845
|
+
const note = lost === null ? undefined
|
|
3846
|
+
: `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
|
|
3847
|
+
return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
|
|
3743
3848
|
}
|
|
3744
3849
|
catch (err) {
|
|
3745
|
-
if (err instanceof WorkspaceImageReadError)
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3850
|
+
if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError))
|
|
3851
|
+
throw err;
|
|
3852
|
+
const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
|
|
3853
|
+
if (lost) {
|
|
3854
|
+
const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
|
|
3855
|
+
return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
|
|
3856
|
+
: `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
|
|
3750
3857
|
}
|
|
3751
|
-
|
|
3858
|
+
if (noFile && treeError)
|
|
3859
|
+
return refused(treeError.message);
|
|
3860
|
+
return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
|
|
3752
3861
|
}
|
|
3753
3862
|
}
|
|
3754
3863
|
async handleWrite(input, callerAgentName) {
|
|
3755
3864
|
if (LONE_SURROGATE.test(input.content)) {
|
|
3756
|
-
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
3865
|
+
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store. Give it without the surrogate, or with the other half of its pair.' };
|
|
3757
3866
|
}
|
|
3758
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3867
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
|
|
3868
|
+
if (relativePath === '')
|
|
3869
|
+
return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
|
|
3759
3870
|
input = { ...input, path };
|
|
3760
|
-
const refusal = writeRefusal(mount);
|
|
3871
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
3761
3872
|
if (refusal)
|
|
3762
|
-
return { success: false, error: refusal, isError: true };
|
|
3873
|
+
return { success: false, error: didNothing('write', refusal), isError: true };
|
|
3763
3874
|
const store = this.getStore();
|
|
3764
3875
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
3765
|
-
|
|
3766
|
-
|
|
3876
|
+
const bytes = Buffer.byteLength(input.content);
|
|
3877
|
+
if (bytes > maxSize) {
|
|
3878
|
+
return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
|
|
3767
3879
|
}
|
|
3768
3880
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
3769
3881
|
const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
|
|
@@ -3772,13 +3884,14 @@ export class WorkspaceModule {
|
|
|
3772
3884
|
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
3773
3885
|
if ('error' in written)
|
|
3774
3886
|
return { success: false, isError: true, error: written.error };
|
|
3775
|
-
return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
|
|
3887
|
+
return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
|
|
3776
3888
|
}
|
|
3777
3889
|
let committed = false;
|
|
3778
3890
|
let refused;
|
|
3779
3891
|
let modeKept;
|
|
3892
|
+
let disk;
|
|
3780
3893
|
try {
|
|
3781
|
-
({ refused, modeKept } = await this.withMount(mount, async () => {
|
|
3894
|
+
({ refused, modeKept, disk } = await this.withMount(mount, async () => {
|
|
3782
3895
|
await this.settleBeforeMutation(mount, relativePath);
|
|
3783
3896
|
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
3784
3897
|
// An overwrite keeps the file's mode, as an edit does.
|
|
@@ -3788,14 +3901,14 @@ export class WorkspaceModule {
|
|
|
3788
3901
|
}
|
|
3789
3902
|
catch (error) {
|
|
3790
3903
|
if (!committed)
|
|
3791
|
-
throw error;
|
|
3904
|
+
throw notStored(error);
|
|
3792
3905
|
return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
|
|
3793
3906
|
? { storeFailed: error.message, pushes: error.pushes }
|
|
3794
|
-
: { threw: error instanceof Error ? error.message : String(error) });
|
|
3907
|
+
: { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
3795
3908
|
}
|
|
3796
3909
|
if (refused !== null)
|
|
3797
3910
|
return storedButUnfinished('write', input.path, { refused });
|
|
3798
|
-
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
3911
|
+
return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
|
|
3799
3912
|
}
|
|
3800
3913
|
async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
|
|
3801
3914
|
return this.applyEdits('edit', [{
|
|
@@ -3835,12 +3948,14 @@ export class WorkspaceModule {
|
|
|
3835
3948
|
* stored where its record landed, and that it can't tell where they
|
|
3836
3949
|
* couldn't be read back.
|
|
3837
3950
|
* Under the mount's turn, one pass settles every path with disk, by the
|
|
3838
|
-
* rule a listing uses, so the edits apply to what disk now holds.
|
|
3839
|
-
*
|
|
3840
|
-
*
|
|
3841
|
-
*
|
|
3842
|
-
*
|
|
3843
|
-
* of the
|
|
3951
|
+
* rule a listing uses, so the edits apply to what disk now holds. Each
|
|
3952
|
+
* file's stored contents are then read; where the store has lost some,
|
|
3953
|
+
* they're restored from disk's matching copy (contentsOf) and the pass runs
|
|
3954
|
+
* again, so the plan reads a pass taken after every await. From there
|
|
3955
|
+
* nothing awaits before the commit: every edit is planned and every entry
|
|
3956
|
+
* set (commitToolChanges) with no turn of the event loop between, so no
|
|
3957
|
+
* pass, branch change or other call moves the tree between a file's read
|
|
3958
|
+
* and its set. Disk then takes what it can of the stored change.
|
|
3844
3959
|
*
|
|
3845
3960
|
* The answer is held to `maxChars`, the length the call's result may have
|
|
3846
3961
|
* (ToolCall.resultMaxChars), as edit-text.ts describes.
|
|
@@ -3875,7 +3990,7 @@ export class WorkspaceModule {
|
|
|
3875
3990
|
}
|
|
3876
3991
|
for (const field of ['oldString', 'newString']) {
|
|
3877
3992
|
if (LONE_SURROGATE.test(edit[field])) {
|
|
3878
|
-
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
|
|
3993
|
+
problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.` });
|
|
3879
3994
|
}
|
|
3880
3995
|
}
|
|
3881
3996
|
if (problems.length === found)
|
|
@@ -3937,7 +4052,31 @@ export class WorkspaceModule {
|
|
|
3937
4052
|
return await this.withMount(mount, async () => {
|
|
3938
4053
|
// As every file tool's pass does, it names the paths it acts on, so a
|
|
3939
4054
|
// file the mount passes over by design is observed (see PassOptions.named).
|
|
3940
|
-
|
|
4055
|
+
let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4056
|
+
let ops = pass.ops;
|
|
4057
|
+
const contents = new Map();
|
|
4058
|
+
const lost = new Map();
|
|
4059
|
+
const toRecover = [];
|
|
4060
|
+
for (const target of targets.values()) {
|
|
4061
|
+
const entry = store.treeGet(mount.treeStateId, target.relativePath);
|
|
4062
|
+
if (!entry || contents.has(entry.blobHash))
|
|
4063
|
+
continue;
|
|
4064
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
4065
|
+
if ('bytes' in stored)
|
|
4066
|
+
contents.set(entry.blobHash, stored.bytes);
|
|
4067
|
+
else
|
|
4068
|
+
toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
|
|
4069
|
+
}
|
|
4070
|
+
if (toRecover.length > 0) {
|
|
4071
|
+
await this.restoreEach(mount, toRecover, (version, found) => {
|
|
4072
|
+
if ('bytes' in found)
|
|
4073
|
+
contents.set(version.hash, found.bytes);
|
|
4074
|
+
else
|
|
4075
|
+
lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
|
|
4076
|
+
});
|
|
4077
|
+
pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
|
|
4078
|
+
ops = [...ops, ...pass.ops];
|
|
4079
|
+
}
|
|
3941
4080
|
// A pass that decided nothing checked no file's disk copy.
|
|
3942
4081
|
const undecided = undecidedOf(pass);
|
|
3943
4082
|
// Nothing below awaits until the commit's push.
|
|
@@ -3957,7 +4096,7 @@ export class WorkspaceModule {
|
|
|
3957
4096
|
// A mode taken in alone isn't a change to the text: it brings nothing
|
|
3958
4097
|
// new to read, as it makes no event (emitFsEvents), and the edits
|
|
3959
4098
|
// then write at the mode disk has, so it leaves nothing to say.
|
|
3960
|
-
const took = (op) =>
|
|
4099
|
+
const took = (op) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
|
|
3961
4100
|
// Which copy the edits used, when it isn't the one the workspace
|
|
3962
4101
|
// last agreed on with disk: disk's newer text, or the workspace's
|
|
3963
4102
|
// copy because disk's couldn't be read or disk has none. A file the
|
|
@@ -4021,18 +4160,28 @@ export class WorkspaceModule {
|
|
|
4021
4160
|
// A pass also drops a copy disk made binary or too large, which
|
|
4022
4161
|
// the disk-only answer describes; any other drop is disk's deletion.
|
|
4023
4162
|
const text = took('deleted') && report?.state !== 'disk-only'
|
|
4024
|
-
?
|
|
4025
|
-
: this.notInWorkspace(target.path, report);
|
|
4026
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole:
|
|
4163
|
+
? deletedOnDisk(target.path)
|
|
4164
|
+
: this.notInWorkspace(target.path, report, mount);
|
|
4165
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
|
|
4027
4166
|
continue;
|
|
4028
4167
|
}
|
|
4029
|
-
|
|
4030
|
-
const
|
|
4031
|
-
if (!
|
|
4032
|
-
|
|
4168
|
+
const known = contents.get(entry.blobHash);
|
|
4169
|
+
const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
|
|
4170
|
+
if (!('bytes' in stored)) {
|
|
4171
|
+
const found = lost.get(target.relativePath);
|
|
4172
|
+
problems.push({
|
|
4173
|
+
kind: 'file',
|
|
4174
|
+
edits: target.edits,
|
|
4175
|
+
path: target.path,
|
|
4176
|
+
text: found?.blobHash === entry.blobHash
|
|
4177
|
+
? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
|
|
4178
|
+
: `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`,
|
|
4179
|
+
whole: true,
|
|
4180
|
+
});
|
|
4033
4181
|
continue;
|
|
4034
4182
|
}
|
|
4035
|
-
|
|
4183
|
+
notes.push(...lookedIn);
|
|
4184
|
+
const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
|
|
4036
4185
|
if (plan === null)
|
|
4037
4186
|
continue;
|
|
4038
4187
|
plans.push({
|
|
@@ -4048,7 +4197,7 @@ export class WorkspaceModule {
|
|
|
4048
4197
|
}
|
|
4049
4198
|
if (problems.length)
|
|
4050
4199
|
return refuse(problems, notes);
|
|
4051
|
-
const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4200
|
+
const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
|
|
4052
4201
|
path: target.relativePath,
|
|
4053
4202
|
change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4054
4203
|
})), () => { committed = true; });
|
|
@@ -4059,7 +4208,7 @@ export class WorkspaceModule {
|
|
|
4059
4208
|
plan,
|
|
4060
4209
|
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
|
|
4061
4210
|
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4062
|
-
})), given.length, maxChars);
|
|
4211
|
+
})), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath))));
|
|
4063
4212
|
});
|
|
4064
4213
|
}
|
|
4065
4214
|
catch (error) {
|
|
@@ -4067,12 +4216,12 @@ export class WorkspaceModule {
|
|
|
4067
4216
|
if (committed) {
|
|
4068
4217
|
return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
|
|
4069
4218
|
? { storeFailed: message, pushes: error.pushes }
|
|
4070
|
-
: { threw: message }, maxChars);
|
|
4219
|
+
: { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
|
|
4071
4220
|
}
|
|
4072
4221
|
// A set that threw and couldn't be read back may have stored the edits,
|
|
4073
4222
|
// and a second call could then apply them twice.
|
|
4074
4223
|
if (error instanceof CommitUnknownError) {
|
|
4075
|
-
const [set, read] = [
|
|
4224
|
+
const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
|
|
4076
4225
|
return {
|
|
4077
4226
|
success: false,
|
|
4078
4227
|
isError: true,
|
|
@@ -4085,10 +4234,8 @@ export class WorkspaceModule {
|
|
|
4085
4234
|
}
|
|
4086
4235
|
// The commit sets every entry in one record or none, and a set that
|
|
4087
4236
|
// threw was read back, so nothing is stored; or every change was one
|
|
4088
|
-
// the tree already held
|
|
4089
|
-
|
|
4090
|
-
return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
|
|
4091
|
-
throw error;
|
|
4237
|
+
// the tree already held (AlreadyHeld).
|
|
4238
|
+
throw notStored(error);
|
|
4092
4239
|
}
|
|
4093
4240
|
}
|
|
4094
4241
|
/**
|
|
@@ -4101,7 +4248,7 @@ export class WorkspaceModule {
|
|
|
4101
4248
|
* (handleToolCall checks a call's `path`, which edit_many's input lacks).
|
|
4102
4249
|
*/
|
|
4103
4250
|
async editablePath(tool, path, callerAgentName) {
|
|
4104
|
-
const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole:
|
|
4251
|
+
const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: false });
|
|
4105
4252
|
try {
|
|
4106
4253
|
// In the one spelling every file tool reads, as disk spells it, so two
|
|
4107
4254
|
// spellings of a file are one entry and, in one call, one plan
|
|
@@ -4111,17 +4258,21 @@ export class WorkspaceModule {
|
|
|
4111
4258
|
// One that names the mount's root, its bare name included, names no
|
|
4112
4259
|
// file.
|
|
4113
4260
|
if (where.relativePath === '')
|
|
4114
|
-
return unusable(
|
|
4115
|
-
const refusal = writeRefusal(where.mount);
|
|
4261
|
+
return unusable(namesRoot(path, where.mount.config.name));
|
|
4262
|
+
const refusal = writeRefusal(where.mount, this.writableNames());
|
|
4116
4263
|
if (refusal)
|
|
4117
4264
|
return unusable(refusal);
|
|
4118
4265
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4119
4266
|
if (generated)
|
|
4120
|
-
return unusable(
|
|
4267
|
+
return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
|
|
4121
4268
|
return where;
|
|
4122
4269
|
}
|
|
4123
4270
|
catch (error) {
|
|
4124
|
-
|
|
4271
|
+
if (error instanceof Refusal)
|
|
4272
|
+
return unusable(error.message);
|
|
4273
|
+
if (error instanceof WorkspaceReadError)
|
|
4274
|
+
return unusable(readRefusal(error, path));
|
|
4275
|
+
throw notStored(error);
|
|
4125
4276
|
}
|
|
4126
4277
|
}
|
|
4127
4278
|
/**
|
|
@@ -4129,10 +4280,10 @@ export class WorkspaceModule {
|
|
|
4129
4280
|
* and the agent's own is read-only, since it shows the agent's tool
|
|
4130
4281
|
* presentation, which set_tool_visibility and set_tool_description change.
|
|
4131
4282
|
*/
|
|
4132
|
-
generatedFileRefusal(generated, callerAgentName) {
|
|
4283
|
+
generatedFileRefusal(path, generated, callerAgentName) {
|
|
4133
4284
|
return callerAgentName !== generated.agentName
|
|
4134
|
-
?
|
|
4135
|
-
:
|
|
4285
|
+
? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
|
|
4286
|
+
: `${JSON.stringify(path)} is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.`;
|
|
4136
4287
|
}
|
|
4137
4288
|
/**
|
|
4138
4289
|
* The answer to edits stored but not finished: the commit set every file's
|
|
@@ -4148,12 +4299,12 @@ export class WorkspaceModule {
|
|
|
4148
4299
|
* which stand as on a success, it names each conflict the success answer
|
|
4149
4300
|
* would (`conflicts`, found only where the mount doesn't push).
|
|
4150
4301
|
*/
|
|
4151
|
-
editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
|
|
4302
|
+
editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars, reopen = false) {
|
|
4152
4303
|
const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
|
|
4153
4304
|
if (tool === 'edit') {
|
|
4154
4305
|
const [target] = targets;
|
|
4155
4306
|
const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
|
|
4156
|
-
return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
|
|
4307
|
+
return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null, reopen);
|
|
4157
4308
|
}
|
|
4158
4309
|
const stored = "Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.";
|
|
4159
4310
|
// The head is never cut, so what stands for the whole call goes in it,
|
|
@@ -4176,10 +4327,11 @@ export class WorkspaceModule {
|
|
|
4176
4327
|
}
|
|
4177
4328
|
else if ('storeFailed' in outcome) {
|
|
4178
4329
|
head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
|
|
4179
|
-
+ (outcome.pushes ?
|
|
4330
|
+
+ (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
|
|
4331
|
+
+ (reopen ? ` ${AWAITS_REOPEN}` : '');
|
|
4180
4332
|
}
|
|
4181
4333
|
else {
|
|
4182
|
-
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}
|
|
4334
|
+
head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
|
|
4183
4335
|
}
|
|
4184
4336
|
const kept = 'modeKept' in outcome
|
|
4185
4337
|
? targets.flatMap((target) => {
|
|
@@ -4213,7 +4365,7 @@ export class WorkspaceModule {
|
|
|
4213
4365
|
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4214
4366
|
}
|
|
4215
4367
|
catch (error) {
|
|
4216
|
-
return { error:
|
|
4368
|
+
return { error: directDidNothing('write', path, error) };
|
|
4217
4369
|
}
|
|
4218
4370
|
try {
|
|
4219
4371
|
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
@@ -4228,7 +4380,7 @@ export class WorkspaceModule {
|
|
|
4228
4380
|
}
|
|
4229
4381
|
catch (error) {
|
|
4230
4382
|
await discard(staged);
|
|
4231
|
-
return { error:
|
|
4383
|
+
return { error: directDidNothing('write', path, error) };
|
|
4232
4384
|
}
|
|
4233
4385
|
this.recordDirectWrite(mount, agentName);
|
|
4234
4386
|
return { note: linksNote(path, staged, 'write') };
|
|
@@ -4288,7 +4440,7 @@ export class WorkspaceModule {
|
|
|
4288
4440
|
return 'changed';
|
|
4289
4441
|
}
|
|
4290
4442
|
if ('error' in got)
|
|
4291
|
-
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole:
|
|
4443
|
+
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
|
|
4292
4444
|
else
|
|
4293
4445
|
read.set(target, got);
|
|
4294
4446
|
}
|
|
@@ -4358,7 +4510,8 @@ export class WorkspaceModule {
|
|
|
4358
4510
|
edits: target.edits,
|
|
4359
4511
|
path: target.path,
|
|
4360
4512
|
text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
|
|
4361
|
-
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4513
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
|
|
4514
|
+
+ (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
|
|
4362
4515
|
whole: true,
|
|
4363
4516
|
}]);
|
|
4364
4517
|
for (const { target, plan } of plans) {
|
|
@@ -4428,7 +4581,7 @@ export class WorkspaceModule {
|
|
|
4428
4581
|
plan: plans.find((p) => p.target === target).plan,
|
|
4429
4582
|
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
|
|
4430
4583
|
modeKept: null,
|
|
4431
|
-
})), editCount, maxChars);
|
|
4584
|
+
})), editCount, maxChars, 'done');
|
|
4432
4585
|
}
|
|
4433
4586
|
/**
|
|
4434
4587
|
* The answer to an edit_many call on a direct mount whose rename of its
|
|
@@ -4440,7 +4593,8 @@ export class WorkspaceModule {
|
|
|
4440
4593
|
directEditsPartway(staged, failed, error, maxChars) {
|
|
4441
4594
|
// The failed file is named among the outcomes, so the head stays as
|
|
4442
4595
|
// long as its reason, whatever the paths' lengths.
|
|
4443
|
-
const
|
|
4596
|
+
const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
|
|
4597
|
+
const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}.${can !== undefined ? ` Whoever runs this host can ${can}.` : ''} `
|
|
4444
4598
|
+ "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
|
|
4445
4599
|
const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
|
|
4446
4600
|
const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
|
|
@@ -4457,33 +4611,37 @@ export class WorkspaceModule {
|
|
|
4457
4611
|
*/
|
|
4458
4612
|
async directEditText(mount, relativePath, path) {
|
|
4459
4613
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4460
|
-
const over = `${path} is
|
|
4614
|
+
const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
|
|
4461
4615
|
let opened;
|
|
4462
4616
|
try {
|
|
4463
4617
|
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4464
4618
|
if (fileStat.size > maxSize)
|
|
4465
|
-
throw new
|
|
4619
|
+
throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
|
|
4466
4620
|
});
|
|
4467
4621
|
}
|
|
4468
4622
|
catch (err) {
|
|
4469
4623
|
if (err instanceof WorkspaceReadError && err.code === 'changed')
|
|
4470
4624
|
return { changed: true };
|
|
4471
|
-
|
|
4625
|
+
if (err instanceof Refusal)
|
|
4626
|
+
return { error: err.message };
|
|
4627
|
+
if (err instanceof WorkspaceReadError)
|
|
4628
|
+
return { error: readRefusal(err, path) };
|
|
4629
|
+
throw err;
|
|
4472
4630
|
}
|
|
4473
4631
|
try {
|
|
4474
4632
|
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4475
4633
|
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4476
4634
|
if (more)
|
|
4477
|
-
return { error: over };
|
|
4478
|
-
if (looksBinary(bytes))
|
|
4479
|
-
return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
|
|
4635
|
+
return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
|
|
4636
|
+
if (looksBinary(bytes)) {
|
|
4637
|
+
return { error: `${JSON.stringify(path)} is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.` };
|
|
4638
|
+
}
|
|
4480
4639
|
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
|
|
4481
4640
|
return { changed: true };
|
|
4482
4641
|
return { bytes, identity: before };
|
|
4483
4642
|
}
|
|
4484
4643
|
catch (err) {
|
|
4485
|
-
|
|
4486
|
-
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
4644
|
+
return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
|
|
4487
4645
|
}
|
|
4488
4646
|
finally {
|
|
4489
4647
|
await opened.handle.close();
|
|
@@ -4512,11 +4670,9 @@ export class WorkspaceModule {
|
|
|
4512
4670
|
catch (err) {
|
|
4513
4671
|
const code = errnoOf(err);
|
|
4514
4672
|
const why = code === 'ENOENT' || code === 'ENOTDIR'
|
|
4515
|
-
? `the mount root
|
|
4516
|
-
:
|
|
4517
|
-
|
|
4518
|
-
: `the mount root can't be read (${code ?? String(err)})`;
|
|
4519
|
-
return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
|
|
4673
|
+
? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
|
|
4674
|
+
: `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
|
|
4675
|
+
return { success: false, isError: true, error: didNothing('delete', why, path) };
|
|
4520
4676
|
}
|
|
4521
4677
|
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
4522
4678
|
const { rootReal } = disk;
|
|
@@ -4526,10 +4682,12 @@ export class WorkspaceModule {
|
|
|
4526
4682
|
}
|
|
4527
4683
|
catch (err) {
|
|
4528
4684
|
// The open's own words for a replaced root speak of reading.
|
|
4529
|
-
if (err instanceof
|
|
4530
|
-
return { success: false, isError: true, error:
|
|
4685
|
+
if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
|
|
4686
|
+
return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
|
|
4531
4687
|
}
|
|
4532
|
-
|
|
4688
|
+
if (err instanceof WorkspaceReadError)
|
|
4689
|
+
return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
|
|
4690
|
+
throw err;
|
|
4533
4691
|
}
|
|
4534
4692
|
// The unlink is awaited, unlike a write's rename, so where the head
|
|
4535
4693
|
// was just before it says whether the head moved while it ran.
|
|
@@ -4544,10 +4702,10 @@ export class WorkspaceModule {
|
|
|
4544
4702
|
this.recordDirectWrite(mount, agentName, since);
|
|
4545
4703
|
return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
|
|
4546
4704
|
}
|
|
4547
|
-
return { success: false, isError: true, error:
|
|
4705
|
+
return { success: false, isError: true, error: directDidNothing('delete', path, error) };
|
|
4548
4706
|
}
|
|
4549
4707
|
this.recordDirectWrite(mount, agentName, since);
|
|
4550
|
-
return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
4708
|
+
return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
|
|
4551
4709
|
});
|
|
4552
4710
|
}
|
|
4553
4711
|
/**
|
|
@@ -4695,11 +4853,13 @@ export class WorkspaceModule {
|
|
|
4695
4853
|
this.historyLookup = lookup;
|
|
4696
4854
|
}
|
|
4697
4855
|
async handleDelete(input, callerAgentName) {
|
|
4698
|
-
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4856
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
|
|
4857
|
+
if (relativePath === '')
|
|
4858
|
+
return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
|
|
4699
4859
|
input = { ...input, path };
|
|
4700
|
-
const refusal = writeRefusal(mount);
|
|
4860
|
+
const refusal = writeRefusal(mount, this.writableNames());
|
|
4701
4861
|
if (refusal)
|
|
4702
|
-
return { success: false, error: refusal, isError: true };
|
|
4862
|
+
return { success: false, error: didNothing('delete', refusal), isError: true };
|
|
4703
4863
|
if (mount.direct)
|
|
4704
4864
|
return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
4705
4865
|
const store = this.getStore();
|
|
@@ -4710,31 +4870,31 @@ export class WorkspaceModule {
|
|
|
4710
4870
|
await this.settleBeforeMutation(mount, relativePath);
|
|
4711
4871
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
4712
4872
|
if (!entry) {
|
|
4713
|
-
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
|
|
4714
|
-
return { success: false, error, isError: true };
|
|
4873
|
+
const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
|
|
4874
|
+
return { success: false, error: didNothing('delete', error), isError: true };
|
|
4715
4875
|
}
|
|
4716
|
-
const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
4876
|
+
const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
4717
4877
|
if (refused !== null)
|
|
4718
4878
|
return storedButUnfinished('delete', input.path, { refused });
|
|
4719
4879
|
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
4720
4880
|
// withholds the result (agent-framework #277).
|
|
4721
4881
|
return {
|
|
4722
4882
|
success: true,
|
|
4723
|
-
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
4724
|
-
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
4883
|
+
data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
|
|
4884
|
+
keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
|
|
4725
4885
|
};
|
|
4726
4886
|
});
|
|
4727
4887
|
}
|
|
4728
4888
|
catch (error) {
|
|
4729
4889
|
if (!committed)
|
|
4730
|
-
throw error;
|
|
4890
|
+
throw notStored(error);
|
|
4731
4891
|
if (error instanceof StoredThenFailed) {
|
|
4732
4892
|
// Its note, as the success answer's: a deletion that cleared a
|
|
4733
4893
|
// conflict naming a disk file unlinked nothing.
|
|
4734
4894
|
const note = error.notes.get(relativePath);
|
|
4735
|
-
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}
|
|
4895
|
+
return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
|
|
4736
4896
|
}
|
|
4737
|
-
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
|
|
4897
|
+
return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
|
|
4738
4898
|
}
|
|
4739
4899
|
}
|
|
4740
4900
|
async handleLs(input) {
|
|
@@ -4758,7 +4918,7 @@ export class WorkspaceModule {
|
|
|
4758
4918
|
// A path outside the mount's include that's no scope (refusesAsOutside)
|
|
4759
4919
|
// is refused, as glob refuses it, and shown as given.
|
|
4760
4920
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4761
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
4921
|
+
return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4762
4922
|
}
|
|
4763
4923
|
input = { ...input, path };
|
|
4764
4924
|
const recursive = input.recursive === true;
|
|
@@ -4767,6 +4927,9 @@ export class WorkspaceModule {
|
|
|
4767
4927
|
// shows disk's.
|
|
4768
4928
|
const listing = await this.listingOf(mount, relativePath, recursive);
|
|
4769
4929
|
const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
|
|
4930
|
+
const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
|
|
4931
|
+
const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
|
|
4932
|
+
const noted = note !== null ? { note } : {};
|
|
4770
4933
|
if (recursive) {
|
|
4771
4934
|
return {
|
|
4772
4935
|
success: true,
|
|
@@ -4775,6 +4938,7 @@ export class WorkspaceModule {
|
|
|
4775
4938
|
entries: listing.files,
|
|
4776
4939
|
count: listing.files.length,
|
|
4777
4940
|
...incomplete,
|
|
4941
|
+
...noted,
|
|
4778
4942
|
},
|
|
4779
4943
|
};
|
|
4780
4944
|
}
|
|
@@ -4791,9 +4955,34 @@ export class WorkspaceModule {
|
|
|
4791
4955
|
entries: children,
|
|
4792
4956
|
count: children.length,
|
|
4793
4957
|
...incomplete,
|
|
4958
|
+
...noted,
|
|
4794
4959
|
},
|
|
4795
4960
|
};
|
|
4796
4961
|
}
|
|
4962
|
+
async emptyScopeNote(mount, relativePath, path) {
|
|
4963
|
+
const shown = JSON.stringify(path);
|
|
4964
|
+
const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
|
|
4965
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4966
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
4967
|
+
if (seen.kind === 'file')
|
|
4968
|
+
return isFile;
|
|
4969
|
+
if (seen.kind !== 'absent')
|
|
4970
|
+
return null;
|
|
4971
|
+
if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null)
|
|
4972
|
+
return isFile;
|
|
4973
|
+
return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
|
|
4974
|
+
}
|
|
4975
|
+
async absentOnDisk(mount, relativePath) {
|
|
4976
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4977
|
+
return (await observePath(view, rootReal, relativePath)).kind === 'absent';
|
|
4978
|
+
}
|
|
4979
|
+
holdsAnythingAt(mount, relativePath) {
|
|
4980
|
+
if (mount.direct)
|
|
4981
|
+
return false;
|
|
4982
|
+
const store = this.getStore();
|
|
4983
|
+
const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree) => tree !== undefined);
|
|
4984
|
+
return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
|
|
4985
|
+
}
|
|
4797
4986
|
/**
|
|
4798
4987
|
* What a listing shows of a directory scope, from where the mount keeps
|
|
4799
4988
|
* what it reads: the store-backed kind's pass (listingFromStore), or, on a
|
|
@@ -4895,7 +5084,7 @@ export class WorkspaceModule {
|
|
|
4895
5084
|
if (input.path) {
|
|
4896
5085
|
const { mount, relativePath, moved, root } = mountsToSearch[0];
|
|
4897
5086
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4898
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5087
|
+
return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4899
5088
|
}
|
|
4900
5089
|
}
|
|
4901
5090
|
// Every match lies beneath the pattern's literal directory, so the walk
|
|
@@ -4916,7 +5105,7 @@ export class WorkspaceModule {
|
|
|
4916
5105
|
if (prefixed) {
|
|
4917
5106
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
4918
5107
|
if (dir === null) {
|
|
4919
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5108
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
4920
5109
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
4921
5110
|
continue;
|
|
4922
5111
|
}
|
|
@@ -4952,8 +5141,16 @@ export class WorkspaceModule {
|
|
|
4952
5141
|
regex = new RegExp(input.pattern);
|
|
4953
5142
|
}
|
|
4954
5143
|
catch (e) {
|
|
4955
|
-
//
|
|
4956
|
-
|
|
5144
|
+
// V8's message ends with the parser's reason, after the pattern: "Invalid
|
|
5145
|
+
// regular expression: /foo(/: Unterminated group".
|
|
5146
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
5147
|
+
const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
|
|
5148
|
+
return {
|
|
5149
|
+
success: false,
|
|
5150
|
+
isError: true,
|
|
5151
|
+
error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
|
|
5152
|
+
+ 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
|
|
5153
|
+
};
|
|
4957
5154
|
}
|
|
4958
5155
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
4959
5156
|
// The filter matches mount-relative paths, from the mount's root whatever
|
|
@@ -4979,7 +5176,7 @@ export class WorkspaceModule {
|
|
|
4979
5176
|
if (input.path) {
|
|
4980
5177
|
const { mount, relativePath, moved, root } = mountsToSearch[0];
|
|
4981
5178
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
4982
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5179
|
+
return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
4983
5180
|
}
|
|
4984
5181
|
}
|
|
4985
5182
|
const results = [];
|
|
@@ -5002,10 +5199,6 @@ export class WorkspaceModule {
|
|
|
5002
5199
|
const search = (text) => {
|
|
5003
5200
|
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5004
5201
|
const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
|
|
5005
|
-
if (lineEndings !== undefined && !crlfFiles.has(text.file))
|
|
5006
|
-
crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
|
|
5007
|
-
if (bom && !bomFiles.has(text.file))
|
|
5008
|
-
bomFiles.set(text.file, text.version);
|
|
5009
5202
|
const found = [];
|
|
5010
5203
|
for (let i = 0; i < lines.length; i++) {
|
|
5011
5204
|
if (regex.test(lines[i])) {
|
|
@@ -5018,14 +5211,14 @@ export class WorkspaceModule {
|
|
|
5018
5211
|
found.push(match);
|
|
5019
5212
|
}
|
|
5020
5213
|
}
|
|
5021
|
-
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
|
|
5214
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
|
|
5022
5215
|
};
|
|
5023
5216
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
5024
5217
|
// A path named in another mount's directory, as holderOf resolves it,
|
|
5025
5218
|
// is that mount's: grep searches nothing of this mount's there, and
|
|
5026
5219
|
// says whose it is. A direct mount's is judged by the nesting its parse
|
|
5027
5220
|
// found where its root lies now (movedNesting).
|
|
5028
|
-
if (this.holderOf(mount.config.name, relativePath,
|
|
5221
|
+
if (this.holderOf(mount.config.name, relativePath, moved)) {
|
|
5029
5222
|
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5030
5223
|
continue;
|
|
5031
5224
|
}
|
|
@@ -5044,16 +5237,23 @@ export class WorkspaceModule {
|
|
|
5044
5237
|
}
|
|
5045
5238
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
5046
5239
|
const searched = (text) => {
|
|
5047
|
-
const hits = search(text);
|
|
5048
|
-
|
|
5049
|
-
|
|
5240
|
+
const { bom, ...hits } = search(text);
|
|
5241
|
+
const { file, state, version } = text;
|
|
5242
|
+
return () => {
|
|
5243
|
+
if (hits.lineEndings !== undefined && !crlfFiles.has(file))
|
|
5244
|
+
crlfFiles.set(file, { ends: hits.lineEndings, version });
|
|
5245
|
+
if (bom && !bomFiles.has(file))
|
|
5246
|
+
bomFiles.set(file, version);
|
|
5247
|
+
if (hits.matches.length > 0)
|
|
5248
|
+
results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
|
|
5249
|
+
};
|
|
5050
5250
|
};
|
|
5051
5251
|
const kept = prefixed
|
|
5052
5252
|
? (path) => prefixed.regex.test(path)
|
|
5053
5253
|
: (path) => fileGlob === null || fileGlob.test(path)
|
|
5054
5254
|
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
5055
5255
|
const found = mount.direct
|
|
5056
|
-
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
5256
|
+
? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
|
|
5057
5257
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
5058
5258
|
skipped.push(...found.skipped);
|
|
5059
5259
|
incomplete.push(...found.incomplete);
|
|
@@ -5102,12 +5302,13 @@ export class WorkspaceModule {
|
|
|
5102
5302
|
/**
|
|
5103
5303
|
* The texts a 'chronicle' mount's grep searches beneath `start`, or in the
|
|
5104
5304
|
* single file `relativePath` names, those `keep` lets through, each handed
|
|
5105
|
-
* to `
|
|
5106
|
-
* workspace copy, labelled with its state, and a
|
|
5305
|
+
* to `search`, whose answers are recorded in path order: a pass over the
|
|
5306
|
+
* scope, then each path's workspace copy, labelled with its state, and a
|
|
5307
|
+
* conflict's disk side.
|
|
5107
5308
|
* What it doesn't search is in `skipped`, and what it couldn't see in
|
|
5108
5309
|
* `incomplete`, each mount-prefixed.
|
|
5109
5310
|
*/
|
|
5110
|
-
async textsFromStore(mount, relativePath, start, keep,
|
|
5311
|
+
async textsFromStore(mount, relativePath, start, keep, search) {
|
|
5111
5312
|
const store = this.getStore();
|
|
5112
5313
|
const skipped = [];
|
|
5113
5314
|
const incomplete = [];
|
|
@@ -5160,17 +5361,18 @@ export class WorkspaceModule {
|
|
|
5160
5361
|
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
5161
5362
|
}
|
|
5162
5363
|
const intents = this.intents.get(mount.config.name);
|
|
5364
|
+
const found = [];
|
|
5163
5365
|
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
5164
5366
|
if (!keep(report.path))
|
|
5165
5367
|
continue;
|
|
5166
5368
|
const file = `${mount.config.name}/${report.path}`;
|
|
5167
5369
|
if (report.state === 'disk-only') {
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5370
|
+
found.push({ skip: {
|
|
5371
|
+
file,
|
|
5372
|
+
reason: 'disk-only: binary or over the size limit, never stored',
|
|
5373
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
5374
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
5375
|
+
} });
|
|
5174
5376
|
continue;
|
|
5175
5377
|
}
|
|
5176
5378
|
// A path in another mount's directory is that mount's: grep gives
|
|
@@ -5180,17 +5382,16 @@ export class WorkspaceModule {
|
|
|
5180
5382
|
continue;
|
|
5181
5383
|
const refusal = refused.get(report.path);
|
|
5182
5384
|
if (refusal !== undefined) {
|
|
5183
|
-
|
|
5385
|
+
found.push({ skip: { file, reason: refusal } });
|
|
5184
5386
|
continue;
|
|
5185
5387
|
}
|
|
5186
5388
|
// The workspace's version, labelled with the path's state.
|
|
5187
5389
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
5390
|
+
if (entry) {
|
|
5391
|
+
const stored = storedBytes(store, entry.blobHash);
|
|
5392
|
+
found.push('bytes' in stored
|
|
5393
|
+
? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
|
|
5394
|
+
: { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
|
|
5194
5395
|
}
|
|
5195
5396
|
if (report.state !== 'conflict')
|
|
5196
5397
|
continue;
|
|
@@ -5201,22 +5402,40 @@ export class WorkspaceModule {
|
|
|
5201
5402
|
// disk version was never stored either.
|
|
5202
5403
|
const disk = intents.get(report.path)?.conflict?.disk;
|
|
5203
5404
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5405
|
+
if (disk?.stored) {
|
|
5406
|
+
const version = changed ? 'recorded-disk' : 'conflicting-disk';
|
|
5407
|
+
const stored = storedBytes(store, disk.stored);
|
|
5408
|
+
found.push('bytes' in stored
|
|
5409
|
+
? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
|
|
5410
|
+
: { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
|
|
5210
5411
|
}
|
|
5211
5412
|
if (changed) {
|
|
5212
5413
|
if (report.diskNow === 'file') {
|
|
5213
|
-
|
|
5414
|
+
found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } });
|
|
5214
5415
|
}
|
|
5215
5416
|
}
|
|
5216
5417
|
else if (disk && !disk.stored) {
|
|
5217
|
-
|
|
5418
|
+
found.push({ skip: { file, reason: `conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; ${settleConflict('file', mount.config.mode === 'read-only').replace(/\.$/, '')}`, size: disk.size } });
|
|
5218
5419
|
}
|
|
5219
5420
|
}
|
|
5421
|
+
const answers = new Map();
|
|
5422
|
+
await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
|
|
5423
|
+
const { file, state, version, what } = lost;
|
|
5424
|
+
answers.set(lost, 'bytes' in contents
|
|
5425
|
+
? { searched: search({ file, state, version, bytes: contents.bytes }) }
|
|
5426
|
+
: { skip: {
|
|
5427
|
+
file,
|
|
5428
|
+
reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
|
|
5429
|
+
+ `and ${lostOnDisk(contents.lost)}, so it was not searched`,
|
|
5430
|
+
} });
|
|
5431
|
+
});
|
|
5432
|
+
for (const item of found) {
|
|
5433
|
+
const answer = 'lost' in item ? answers.get(item.lost) : item;
|
|
5434
|
+
if ('searched' in answer)
|
|
5435
|
+
answer.searched();
|
|
5436
|
+
else
|
|
5437
|
+
skipped.push(answer.skip);
|
|
5438
|
+
}
|
|
5220
5439
|
});
|
|
5221
5440
|
return { skipped, incomplete };
|
|
5222
5441
|
}
|
|
@@ -5261,7 +5480,7 @@ export class WorkspaceModule {
|
|
|
5261
5480
|
const store = this.getStore();
|
|
5262
5481
|
const status = {};
|
|
5263
5482
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
5264
|
-
return { success: false, error:
|
|
5483
|
+
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5265
5484
|
}
|
|
5266
5485
|
for (const [name, mount] of this.mounts) {
|
|
5267
5486
|
if (input.mount !== undefined && name !== input.mount)
|
|
@@ -5403,9 +5622,6 @@ export class WorkspaceModule {
|
|
|
5403
5622
|
*/
|
|
5404
5623
|
entriesByEvidence(mount) {
|
|
5405
5624
|
const store = this.getStore();
|
|
5406
|
-
const agreement = this.agreementOrThrow();
|
|
5407
|
-
const name = mount.config.name;
|
|
5408
|
-
const intents = this.intents.get(name);
|
|
5409
5625
|
const owed = [];
|
|
5410
5626
|
const unchecked = [];
|
|
5411
5627
|
let agreed = 0;
|
|
@@ -5415,20 +5631,37 @@ export class WorkspaceModule {
|
|
|
5415
5631
|
// in it: neither owed to disk nor counted as on it.
|
|
5416
5632
|
if (!insideMount(e.path, mount.config.include) || !own(e.path))
|
|
5417
5633
|
continue;
|
|
5418
|
-
const
|
|
5419
|
-
if (
|
|
5420
|
-
(intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
|
|
5421
|
-
else if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
5422
|
-
owed.push(e.path);
|
|
5423
|
-
// A mode the store records, other than the one disk last agreed with,
|
|
5424
|
-
// where modes are observed: elsewhere no push applies one.
|
|
5425
|
-
else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
|
|
5634
|
+
const kind = this.evidenceOf(mount, e);
|
|
5635
|
+
if (kind === 'owed')
|
|
5426
5636
|
owed.push(e.path);
|
|
5637
|
+
else if (kind === 'unchecked')
|
|
5638
|
+
unchecked.push(e.path);
|
|
5427
5639
|
else
|
|
5428
5640
|
agreed++;
|
|
5429
5641
|
}
|
|
5430
5642
|
return { owed, unchecked, agreed };
|
|
5431
5643
|
}
|
|
5644
|
+
evidenceOf(mount, e) {
|
|
5645
|
+
const p = this.agreementOrThrow().get(mount.config.name, e.path);
|
|
5646
|
+
if (p === undefined)
|
|
5647
|
+
return this.intents.get(mount.config.name).get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
|
|
5648
|
+
if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
5649
|
+
return 'owed';
|
|
5650
|
+
// A mode the store records, other than the one disk last agreed with,
|
|
5651
|
+
// where modes are observed: elsewhere no push applies one.
|
|
5652
|
+
if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
|
|
5653
|
+
return 'owed';
|
|
5654
|
+
return 'agreed';
|
|
5655
|
+
}
|
|
5656
|
+
diskOf(mount, relativePath) {
|
|
5657
|
+
const intent = this.intents.get(mount.config.name).get(relativePath);
|
|
5658
|
+
if (intent?.conflict)
|
|
5659
|
+
return 'in conflict with disk';
|
|
5660
|
+
const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
|
|
5661
|
+
if (!entry)
|
|
5662
|
+
return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
|
|
5663
|
+
return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
|
|
5664
|
+
}
|
|
5432
5665
|
async handleMaterialize(input) {
|
|
5433
5666
|
const store = this.getStore();
|
|
5434
5667
|
const allWritten = [];
|
|
@@ -5443,7 +5676,7 @@ export class WorkspaceModule {
|
|
|
5443
5676
|
// is refused; beneath a directory, the push leaves out what the include
|
|
5444
5677
|
// doesn't cover.
|
|
5445
5678
|
if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
|
|
5446
|
-
return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
|
|
5679
|
+
return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
|
|
5447
5680
|
}
|
|
5448
5681
|
}
|
|
5449
5682
|
let mountsToMaterialize;
|
|
@@ -5453,7 +5686,7 @@ export class WorkspaceModule {
|
|
|
5453
5686
|
else if (input.mount) {
|
|
5454
5687
|
const m = this.mounts.get(input.mount);
|
|
5455
5688
|
if (!m) {
|
|
5456
|
-
return { success: false, error:
|
|
5689
|
+
return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
|
|
5457
5690
|
}
|
|
5458
5691
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
5459
5692
|
}
|
|
@@ -5567,15 +5800,20 @@ export class WorkspaceModule {
|
|
|
5567
5800
|
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
5568
5801
|
return {
|
|
5569
5802
|
success: false,
|
|
5570
|
-
error:
|
|
5803
|
+
error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
|
|
5571
5804
|
isError: true,
|
|
5572
5805
|
};
|
|
5573
5806
|
}
|
|
5807
|
+
const held = explicit;
|
|
5808
|
+
const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
|
|
5809
|
+
&& allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
|
|
5810
|
+
&& !this.holdsAnythingAt(held.mount, held.relativePath);
|
|
5574
5811
|
return {
|
|
5575
5812
|
success: true,
|
|
5576
5813
|
data: {
|
|
5577
5814
|
materialized: allWritten,
|
|
5578
5815
|
count: allWritten.length,
|
|
5816
|
+
...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
|
|
5579
5817
|
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
5580
5818
|
...(pendingDeletions.length > 0
|
|
5581
5819
|
? {
|
|
@@ -5637,7 +5875,13 @@ export class WorkspaceModule {
|
|
|
5637
5875
|
const allIgnored = [];
|
|
5638
5876
|
const rootsAccepted = [];
|
|
5639
5877
|
if (input.acceptRoot && input.path) {
|
|
5640
|
-
|
|
5878
|
+
const named = input.path.split('/')[0];
|
|
5879
|
+
return {
|
|
5880
|
+
success: false,
|
|
5881
|
+
isError: true,
|
|
5882
|
+
error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
|
|
5883
|
+
+ `${this.mounts.has(named) ? `Give "mount" (${JSON.stringify(named)}) with acceptRoot to take that mount's root` : 'Give "mount", naming the mount whose root to take, with acceptRoot'}, or leave acceptRoot out to sync the path.`),
|
|
5884
|
+
};
|
|
5641
5885
|
}
|
|
5642
5886
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
5643
5887
|
let targets;
|
|
@@ -5647,14 +5891,14 @@ export class WorkspaceModule {
|
|
|
5647
5891
|
// is refused; beneath a directory, the walk passes over what the include
|
|
5648
5892
|
// doesn't cover.
|
|
5649
5893
|
if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
|
|
5650
|
-
return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
|
|
5894
|
+
return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
|
|
5651
5895
|
}
|
|
5652
5896
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
5653
5897
|
}
|
|
5654
5898
|
else if (input.mount) {
|
|
5655
5899
|
const m = this.mounts.get(input.mount);
|
|
5656
5900
|
if (!m) {
|
|
5657
|
-
return { success: false, error:
|
|
5901
|
+
return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5658
5902
|
}
|
|
5659
5903
|
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
5660
5904
|
}
|
|
@@ -5668,6 +5912,7 @@ export class WorkspaceModule {
|
|
|
5668
5912
|
allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
|
|
5669
5913
|
continue;
|
|
5670
5914
|
}
|
|
5915
|
+
let namedDirectory = false;
|
|
5671
5916
|
const pass = await this.withMount(mount, async () => {
|
|
5672
5917
|
if (input.acceptRoot && await this.acceptRootUnlocked(mount))
|
|
5673
5918
|
rootsAccepted.push(name);
|
|
@@ -5682,6 +5927,7 @@ export class WorkspaceModule {
|
|
|
5682
5927
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
5683
5928
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
5684
5929
|
this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
5930
|
+
namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
|
|
5685
5931
|
// named: ignore patterns covering the path asked for don't apply
|
|
5686
5932
|
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
5687
5933
|
const opts = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
@@ -5745,7 +5991,7 @@ export class WorkspaceModule {
|
|
|
5745
5991
|
// without a path passes over it, so nothing else keeps it current
|
|
5746
5992
|
// (agent-framework #276). A path in another mount's directory isn't
|
|
5747
5993
|
// synced at all, and the note says so.
|
|
5748
|
-
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
|
|
5994
|
+
const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
|
|
5749
5995
|
if (passedOver !== undefined) {
|
|
5750
5996
|
allIgnored.push({
|
|
5751
5997
|
mount: name,
|
|
@@ -5758,10 +6004,17 @@ export class WorkspaceModule {
|
|
|
5758
6004
|
});
|
|
5759
6005
|
}
|
|
5760
6006
|
}
|
|
6007
|
+
const named = input.path !== undefined && input.path !== '' ? targets[0] : null;
|
|
6008
|
+
const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
|
|
6009
|
+
&& allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
|
|
6010
|
+
&& allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
|
|
6011
|
+
&& !this.holdsAnythingAt(named.mount, named.relativePath)
|
|
6012
|
+
&& await this.absentOnDisk(named.mount, named.relativePath);
|
|
5761
6013
|
return {
|
|
5762
6014
|
success: true,
|
|
5763
6015
|
data: {
|
|
5764
6016
|
results: allResults,
|
|
6017
|
+
...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
|
|
5765
6018
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
5766
6019
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
5767
6020
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
@@ -6007,13 +6260,13 @@ export async function inOrder(items, width, work, each) {
|
|
|
6007
6260
|
}
|
|
6008
6261
|
}
|
|
6009
6262
|
/** read_image's answer for an image, wherever it was read from. */
|
|
6010
|
-
function imageResult(path, image) {
|
|
6263
|
+
function imageResult(path, image, note) {
|
|
6011
6264
|
return {
|
|
6012
6265
|
success: true,
|
|
6013
6266
|
data: [
|
|
6014
6267
|
{
|
|
6015
6268
|
type: 'text',
|
|
6016
|
-
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
6269
|
+
text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
|
|
6017
6270
|
},
|
|
6018
6271
|
{
|
|
6019
6272
|
type: 'image',
|
|
@@ -6067,14 +6320,16 @@ function watchOf(config) {
|
|
|
6067
6320
|
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6068
6321
|
* the next start once none is held runs it direct.
|
|
6069
6322
|
*/
|
|
6070
|
-
function writeRefusal(mount) {
|
|
6323
|
+
function writeRefusal(mount, writable = []) {
|
|
6071
6324
|
const name = mount.config.name;
|
|
6072
|
-
if (mount.config.mode === 'read-only')
|
|
6073
|
-
return `
|
|
6325
|
+
if (mount.config.mode === 'read-only') {
|
|
6326
|
+
return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
|
|
6327
|
+
+ (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
|
|
6328
|
+
}
|
|
6074
6329
|
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6075
|
-
return `
|
|
6330
|
+
return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6076
6331
|
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
6077
|
-
+ `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct
|
|
6332
|
+
+ `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct.`;
|
|
6078
6333
|
}
|
|
6079
6334
|
return null;
|
|
6080
6335
|
}
|
|
@@ -6150,6 +6405,13 @@ function directReason(error) {
|
|
|
6150
6405
|
return ROOT_MOVED;
|
|
6151
6406
|
return error instanceof Error ? error.message : String(error);
|
|
6152
6407
|
}
|
|
6408
|
+
function directWayOn(error) {
|
|
6409
|
+
return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
|
|
6410
|
+
}
|
|
6411
|
+
function directDidNothing(tool, path, error) {
|
|
6412
|
+
const on = directWayOn(error);
|
|
6413
|
+
return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
|
|
6414
|
+
}
|
|
6153
6415
|
/** "a", "a and b", or "a, b and c". */
|
|
6154
6416
|
function listed(items) {
|
|
6155
6417
|
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
@@ -6172,7 +6434,119 @@ function linksNote(path, staged, tool) {
|
|
|
6172
6434
|
*/
|
|
6173
6435
|
function containedRefusal(err, path) {
|
|
6174
6436
|
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
6175
|
-
return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
|
|
6437
|
+
return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
|
|
6438
|
+
}
|
|
6439
|
+
function deletedOnDisk(path) {
|
|
6440
|
+
return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
|
|
6441
|
+
}
|
|
6442
|
+
function uncheckedFile(shown, note) {
|
|
6443
|
+
if (note === DIRECTORY)
|
|
6444
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6445
|
+
const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
|
|
6446
|
+
: note === ROOT_REPLACED ? null
|
|
6447
|
+
: note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
|
|
6448
|
+
: note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
|
|
6449
|
+
: note === NOT_REGULAR ? onlyTheHost('put a regular file there')
|
|
6450
|
+
: note === CHANGED ? 'Calling again checks it afresh.'
|
|
6451
|
+
: 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
|
|
6452
|
+
return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
|
|
6453
|
+
}
|
|
6454
|
+
function namesRoot(path, mount) {
|
|
6455
|
+
return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
|
|
6456
|
+
}
|
|
6457
|
+
function tooLargeToWrite(path, bytes, maxSize) {
|
|
6458
|
+
return pastSizeLimit(`${JSON.stringify(path)} would be ${bytes.toLocaleString('en-US')} bytes`, maxSize, "the workspace couldn't keep it", 'Content within the limit is written.');
|
|
6459
|
+
}
|
|
6460
|
+
function pastSizeLimit(what, maxSize, outcome, within) {
|
|
6461
|
+
return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
|
|
6462
|
+
+ "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
|
|
6463
|
+
}
|
|
6464
|
+
function sentence(text) {
|
|
6465
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
6466
|
+
}
|
|
6467
|
+
function unreadable(shown, errno) {
|
|
6468
|
+
return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
|
|
6469
|
+
}
|
|
6470
|
+
function readRefusal(err, path) {
|
|
6471
|
+
const shown = JSON.stringify(path);
|
|
6472
|
+
if (err instanceof RootUnavailable) {
|
|
6473
|
+
if (err.refusal)
|
|
6474
|
+
return err.refusal.message;
|
|
6475
|
+
const mount = JSON.stringify(err.mount);
|
|
6476
|
+
if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
|
|
6477
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
|
|
6478
|
+
return `the directory of mount ${mount} isn't on disk, so nothing in it can be read. ${onlyTheHost("put the mount's directory back at its path")}`;
|
|
6479
|
+
}
|
|
6480
|
+
if (err.errno !== undefined) {
|
|
6481
|
+
return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
|
|
6482
|
+
+ errnoWayOn(err.errno, ROOT_RECHECK, true);
|
|
6483
|
+
}
|
|
6484
|
+
return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
|
|
6485
|
+
}
|
|
6486
|
+
return `${shown} can't be read: ${sentence(err.reason)}`;
|
|
6487
|
+
}
|
|
6488
|
+
if (err.stage === 'readlink')
|
|
6489
|
+
return sentence(err.message);
|
|
6490
|
+
switch (err.code) {
|
|
6491
|
+
case 'directory':
|
|
6492
|
+
return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6493
|
+
case 'symlink':
|
|
6494
|
+
return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
|
|
6495
|
+
+ 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
|
|
6496
|
+
case 'escape':
|
|
6497
|
+
return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
|
|
6498
|
+
+ 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
|
|
6499
|
+
case 'changed':
|
|
6500
|
+
return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
|
|
6501
|
+
case 'not_found':
|
|
6502
|
+
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR')
|
|
6503
|
+
return `there's no file at ${shown}. ls or glob lists the files that exist.`;
|
|
6504
|
+
if (err instanceof NotRegularFile) {
|
|
6505
|
+
return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
|
|
6506
|
+
}
|
|
6507
|
+
if (err.stage === 'realpath')
|
|
6508
|
+
return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
|
|
6509
|
+
return unreadable(shown, err.errno);
|
|
6510
|
+
default:
|
|
6511
|
+
return sentence(err.message);
|
|
6512
|
+
}
|
|
6513
|
+
}
|
|
6514
|
+
function contentsSubject(what, path) {
|
|
6515
|
+
return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
|
|
6516
|
+
}
|
|
6517
|
+
function inTheStore(stored) {
|
|
6518
|
+
return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
|
|
6519
|
+
}
|
|
6520
|
+
function lostInStore(lost) {
|
|
6521
|
+
return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
|
|
6522
|
+
}
|
|
6523
|
+
function lostOnDisk(lost) {
|
|
6524
|
+
return lost.disk === 'other' ? "disk's copy holds other contents"
|
|
6525
|
+
: lost.disk === 'none' ? 'disk has no copy'
|
|
6526
|
+
: `disk's copy can't be read (${lost.disk.unreadable})`;
|
|
6527
|
+
}
|
|
6528
|
+
function lostText(lost, the = 'The') {
|
|
6529
|
+
return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
|
|
6530
|
+
}
|
|
6531
|
+
function nothingToRead(lost) {
|
|
6532
|
+
return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
|
|
6533
|
+
}
|
|
6534
|
+
function lostRemedy(lost) {
|
|
6535
|
+
const after = remedyOfLost(lost);
|
|
6536
|
+
if (!lost.reopen)
|
|
6537
|
+
return after;
|
|
6538
|
+
return "Nothing you do clears this until the workspace's store is reopened, and calling again won't change that; whoever runs this host can reopen it with a restart. "
|
|
6539
|
+
+ `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
|
|
6540
|
+
}
|
|
6541
|
+
function remedyOfLost(lost) {
|
|
6542
|
+
const rewrite = lost.writable && 'missing' in lost.store;
|
|
6543
|
+
if (lost.disk === 'other')
|
|
6544
|
+
return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
|
|
6545
|
+
if (lost.writable)
|
|
6546
|
+
return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
|
|
6547
|
+
return lost.disk === 'none'
|
|
6548
|
+
? "Sync the path to take disk's deletion, which removes the workspace's copy."
|
|
6549
|
+
: "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's.";
|
|
6176
6550
|
}
|
|
6177
6551
|
/**
|
|
6178
6552
|
* The notice that writes an agent made direct remain on disk from
|
|
@@ -6229,9 +6603,7 @@ function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems)
|
|
|
6229
6603
|
kind: 'file',
|
|
6230
6604
|
edits: target.edits,
|
|
6231
6605
|
path: target.path,
|
|
6232
|
-
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)},
|
|
6233
|
-
+ `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
|
|
6234
|
-
+ "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
|
|
6606
|
+
text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize, "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
|
|
6235
6607
|
whole: false,
|
|
6236
6608
|
});
|
|
6237
6609
|
return null;
|
|
@@ -6267,16 +6639,38 @@ function changesLanded(store, treeStateId, changes, setError) {
|
|
|
6267
6639
|
* change is stored isn't known. Its message says so after both errors.
|
|
6268
6640
|
*/
|
|
6269
6641
|
class CommitUnknownError extends Error {
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
constructor(
|
|
6642
|
+
setCause;
|
|
6643
|
+
readCause;
|
|
6644
|
+
constructor(setCause, readCause) {
|
|
6273
6645
|
const text = (error) => (error instanceof Error ? error.message : String(error));
|
|
6274
|
-
super(`${text(
|
|
6646
|
+
super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
|
|
6647
|
+
this.setCause = setCause;
|
|
6648
|
+
this.readCause = readCause;
|
|
6275
6649
|
this.name = 'CommitUnknownError';
|
|
6276
|
-
this.setError = text(setError);
|
|
6277
|
-
this.readError = text(readError);
|
|
6278
6650
|
}
|
|
6279
6651
|
}
|
|
6652
|
+
function storeWords(error) {
|
|
6653
|
+
const code = errnoCode(error);
|
|
6654
|
+
return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
|
|
6655
|
+
}
|
|
6656
|
+
class NotStored extends Error {
|
|
6657
|
+
thrown;
|
|
6658
|
+
constructor(thrown) {
|
|
6659
|
+
super('stopped before the change was stored');
|
|
6660
|
+
this.thrown = thrown;
|
|
6661
|
+
}
|
|
6662
|
+
}
|
|
6663
|
+
class AlreadyHeld extends Error {
|
|
6664
|
+
thrown;
|
|
6665
|
+
constructor(thrown) {
|
|
6666
|
+
super(thrown instanceof Error ? thrown.message : String(thrown));
|
|
6667
|
+
this.thrown = thrown;
|
|
6668
|
+
}
|
|
6669
|
+
}
|
|
6670
|
+
function notStored(error) {
|
|
6671
|
+
return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
|
|
6672
|
+
? error : new NotStored(error);
|
|
6673
|
+
}
|
|
6280
6674
|
/**
|
|
6281
6675
|
* A store's set that threw after recording the change (changesLanded): the
|
|
6282
6676
|
* entries are set and their intents follow, and the store's error goes on in
|
|
@@ -6293,6 +6687,11 @@ class StoredThenFailed extends Error {
|
|
|
6293
6687
|
this.name = 'StoredThenFailed';
|
|
6294
6688
|
}
|
|
6295
6689
|
}
|
|
6690
|
+
const ONCE_REOPENED = ', once the store is reopened';
|
|
6691
|
+
const DISK_ORDER = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
|
|
6692
|
+
function furthestFromDisk(values) {
|
|
6693
|
+
return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
|
|
6694
|
+
}
|
|
6296
6695
|
/**
|
|
6297
6696
|
* The answer to a write, edit or delete that the workspace stored but
|
|
6298
6697
|
* couldn't finish: its push to disk didn't complete, or what follows the tree
|
|
@@ -6304,7 +6703,7 @@ class StoredThenFailed extends Error {
|
|
|
6304
6703
|
* script. A note comes last: an edit's on which copy it applied to, or a
|
|
6305
6704
|
* deletion's that it unlinked nothing.
|
|
6306
6705
|
*/
|
|
6307
|
-
function storedButUnfinished(tool, path, outcome, note = null) {
|
|
6706
|
+
function storedButUnfinished(tool, path, outcome, note = null, reopen = false) {
|
|
6308
6707
|
const change = tool === 'delete' ? 'deletion' : tool;
|
|
6309
6708
|
const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
|
|
6310
6709
|
+ (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
|
|
@@ -6313,10 +6712,10 @@ function storedButUnfinished(tool, path, outcome, note = null) {
|
|
|
6313
6712
|
: 'storeFailed' in outcome
|
|
6314
6713
|
? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
|
|
6315
6714
|
: tool === 'delete'
|
|
6316
|
-
?
|
|
6317
|
-
:
|
|
6318
|
-
: ` The rest of the call failed: ${outcome.threw}
|
|
6319
|
-
return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
|
|
6715
|
+
? ` Its push to disk didn't run, so disk keeps the file until a materialize of the path with applyDeletions deletes it${reopen ? ONCE_REOPENED : ''}.`
|
|
6716
|
+
: ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
|
|
6717
|
+
: ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
|
|
6718
|
+
return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
|
|
6320
6719
|
}
|
|
6321
6720
|
/**
|
|
6322
6721
|
* What a read says about the copy it shows, when that isn't simply the one the
|
|
@@ -6345,18 +6744,22 @@ function whichCopyRead(report, tookChange, readOnly) {
|
|
|
6345
6744
|
// can hold it (not binary or too large). A read-only mount can hold a
|
|
6346
6745
|
// copy disk doesn't, such as one a branch switch selected, but a
|
|
6347
6746
|
// materialize, forced or not, writes nothing to it.
|
|
6348
|
-
const
|
|
6349
|
-
|
|
6350
|
-
: 'Sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6351
|
-
const force = readOnly
|
|
6352
|
-
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6353
|
-
: report.diskNow === 'absent'
|
|
6354
|
-
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6355
|
-
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6356
|
-
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
|
|
6747
|
+
const settle = settleConflict(report.diskNow, readOnly);
|
|
6748
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0].toUpperCase()}${settle.slice(1)}`];
|
|
6357
6749
|
}
|
|
6358
6750
|
return [];
|
|
6359
6751
|
}
|
|
6752
|
+
function settleConflict(diskNow, readOnly) {
|
|
6753
|
+
const sync = diskNow === 'absent'
|
|
6754
|
+
? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
6755
|
+
: 'sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6756
|
+
const force = readOnly
|
|
6757
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6758
|
+
: diskNow === 'absent'
|
|
6759
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6760
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6761
|
+
return `${sync}${force}`;
|
|
6762
|
+
}
|
|
6360
6763
|
/**
|
|
6361
6764
|
* The least each tool's whole-number fields may be, beside their schemas,
|
|
6362
6765
|
* whose type (membrane's ToolParameter) has no `minimum`.
|
|
@@ -6548,13 +6951,13 @@ function globToRegex(pattern) {
|
|
|
6548
6951
|
closeIdx = j;
|
|
6549
6952
|
}
|
|
6550
6953
|
if (closeIdx < 0)
|
|
6551
|
-
throw new
|
|
6954
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
|
|
6552
6955
|
const group = pattern.slice(i + 1, closeIdx);
|
|
6553
6956
|
if (group.includes('{')) {
|
|
6554
|
-
throw new
|
|
6957
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
|
|
6555
6958
|
}
|
|
6556
6959
|
if (!group.includes(',')) {
|
|
6557
|
-
throw new
|
|
6960
|
+
throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
|
|
6558
6961
|
}
|
|
6559
6962
|
// A `/` right after the group goes into each alternative, as `{a,b}/x`
|
|
6560
6963
|
// and `{a/,b/}x` name the same paths, so an alternative's own `**`
|