@animalabs/agent-framework 0.22.3 → 0.23.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 +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- 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 +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { isUtf8 } from 'node:buffer';
|
|
8
8
|
import { constants as fsConstants } from 'node:fs';
|
|
9
|
-
import { open,
|
|
10
|
-
import { isAbsolute, join, resolve, relative, sep } from 'node:path';
|
|
9
|
+
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
10
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
11
11
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
12
12
|
import { MountWatcher } from './watcher.js';
|
|
13
13
|
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
14
14
|
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
15
15
|
import { BranchIntents } from './branch-intent.js';
|
|
16
|
-
import { IGNORED, OTHER_MOUNT, checkDir, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js';
|
|
16
|
+
import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix } from './observe.js';
|
|
17
17
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
18
18
|
import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
|
|
19
19
|
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
@@ -44,8 +44,8 @@ class WorkspaceImageReadError extends Error {
|
|
|
44
44
|
* use there.
|
|
45
45
|
*/
|
|
46
46
|
class NestedMountError extends Error {
|
|
47
|
-
constructor(path, mount, holder, use) {
|
|
48
|
-
super(`"${path}"
|
|
47
|
+
constructor(path, mount, holder, use, how = 'lies in') {
|
|
48
|
+
super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
@@ -72,6 +72,20 @@ function errnoOf(err) {
|
|
|
72
72
|
? err.code
|
|
73
73
|
: undefined;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* What is at a mount root's path now, followed as the root is: for a
|
|
77
|
+
* directory, undefined; for anything else, ENOTDIR; and where the path
|
|
78
|
+
* can't be followed to anything, the errno its stat failed with, ENOENT
|
|
79
|
+
* when nothing is there.
|
|
80
|
+
*/
|
|
81
|
+
async function rootAbsence(root) {
|
|
82
|
+
try {
|
|
83
|
+
return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return errnoOf(err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
75
89
|
/** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
|
|
76
90
|
function imageTooLarge(maxSize, mountPrefixedPath) {
|
|
77
91
|
return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
|
|
@@ -99,6 +113,77 @@ function startsWithBytes(buffer, prefix) {
|
|
|
99
113
|
function matchesPartialPrefix(buffer, prefix) {
|
|
100
114
|
return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer);
|
|
101
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* A disk reader's refusal for what linkOnTheWay found on a path's way: the
|
|
118
|
+
* link's reason, as the observations give it, after the path; or, for a
|
|
119
|
+
* directory the walk couldn't ask, a failure to read, coded `not_found` with
|
|
120
|
+
* the readlink's errno, as every failed lookup is. A link's code follows
|
|
121
|
+
* where it leads, so a caller choosing advice by code gets advice that fits:
|
|
122
|
+
* `escape` for a link leading outside the mount, the code a path resolving
|
|
123
|
+
* outside it gets too; `other_mount` for one into a nested mount's directory,
|
|
124
|
+
* the code that mount's own paths get; and `parent_symlink` for any other,
|
|
125
|
+
* which `symlink`, a file that is itself a link, leaves apart.
|
|
126
|
+
*/
|
|
127
|
+
async function linkReadError(mount, found, mountPrefixedPath, nested) {
|
|
128
|
+
if (!('link' in found)) {
|
|
129
|
+
return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
|
|
130
|
+
}
|
|
131
|
+
const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
|
|
132
|
+
const leads = await linkLeads(mount.config.path, rootReal, found.link);
|
|
133
|
+
const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
|
|
134
|
+
const code = leads.kind === 'outside' ? 'escape'
|
|
135
|
+
: leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
|
|
136
|
+
: 'parent_symlink';
|
|
137
|
+
return new WorkspaceReadError(code, reason, 'readlink');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* `to` beneath `from`, mount-relative and `/`-separated, whatever the
|
|
141
|
+
* platform's separator; undefined where it isn't beneath it. Outside,
|
|
142
|
+
* `relative` gives a path that climbs with a `..` segment, or an absolute one
|
|
143
|
+
* across Windows drives. A name that only begins with two dots, such as
|
|
144
|
+
* `..cache`, is inside.
|
|
145
|
+
*/
|
|
146
|
+
function nestedDir(from, to) {
|
|
147
|
+
const rel = relative(from, to);
|
|
148
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
149
|
+
return undefined;
|
|
150
|
+
return rel.split(sep).join('/');
|
|
151
|
+
}
|
|
152
|
+
/** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
|
|
153
|
+
const LINKS_FOLLOWED = 40;
|
|
154
|
+
/**
|
|
155
|
+
* An absolute path, a mount root's or one beneath it, as realpath resolves
|
|
156
|
+
* it, with the links on its way and in it taken out, read without holding
|
|
157
|
+
* the process. Where nothing is there yet, a link leads where its target
|
|
158
|
+
* would be, and anything else lies beneath its nearest existing directory's
|
|
159
|
+
* real path, with the rest as given. A link's target is read from the
|
|
160
|
+
* link's directory as it is on disk, and nothing is resolved lexically on
|
|
161
|
+
* the way, so a `..` is taken from where the links before it lead, as the
|
|
162
|
+
* kernel takes it. Where realpath fails for any other reason, such as a
|
|
163
|
+
* loop or a lack of permission, the whole path as path.resolve reads it.
|
|
164
|
+
*/
|
|
165
|
+
async function realBeneath(given, links = 0) {
|
|
166
|
+
// A trailing separator, as a link to a directory is often written, names
|
|
167
|
+
// the same place, but readlink would then follow the link it names.
|
|
168
|
+
const { root } = parse(given);
|
|
169
|
+
let absolute = given;
|
|
170
|
+
while (absolute.length > root.length && (absolute.endsWith('/') || absolute.endsWith(sep)))
|
|
171
|
+
absolute = absolute.slice(0, -1);
|
|
172
|
+
try {
|
|
173
|
+
return await realpath(absolute);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const code = errnoOf(err);
|
|
177
|
+
const parent = dirname(absolute);
|
|
178
|
+
if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute)
|
|
179
|
+
return resolve(absolute);
|
|
180
|
+
const place = await realBeneath(parent, links);
|
|
181
|
+
const target = await readlink(absolute).catch(() => null);
|
|
182
|
+
if (target !== null)
|
|
183
|
+
return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
|
|
184
|
+
return join(place, basename(absolute));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
102
187
|
function isContainedPath(root, candidate) {
|
|
103
188
|
if (candidate === root)
|
|
104
189
|
return true;
|
|
@@ -474,6 +559,8 @@ export class WorkspaceModule {
|
|
|
474
559
|
store = null;
|
|
475
560
|
/** Set once stop has drained and saved, cleared by start (running()). */
|
|
476
561
|
stopped = false;
|
|
562
|
+
/** Stops begun, so a start that read disk meanwhile can tell that one came. */
|
|
563
|
+
stopsBegun = 0;
|
|
477
564
|
config;
|
|
478
565
|
mounts = new Map();
|
|
479
566
|
watchers = new Map();
|
|
@@ -489,10 +576,19 @@ export class WorkspaceModule {
|
|
|
489
576
|
/** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
|
|
490
577
|
ctimeTrust = new Map();
|
|
491
578
|
/**
|
|
492
|
-
* Each mount's directories that are other mounts nested inside it
|
|
493
|
-
*
|
|
579
|
+
* Each mount's directories that are other mounts nested inside it, with
|
|
580
|
+
* each mount's name and real root, in each spelling that names the
|
|
581
|
+
* directory: as configured; as the real roots place it, where that differs
|
|
582
|
+
* (`fromRoots`); and as disk spells each of those where that differs.
|
|
583
|
+
* Each spelling is mount-relative and `/`-separated, with its root beneath
|
|
584
|
+
* the mount's resolved root. The constructor nests by configured path,
|
|
585
|
+
* with each root as resolved standing for its real one, and each start
|
|
586
|
+
* reads it all again with the roots placed where disk has them
|
|
587
|
+
* (readNesting).
|
|
494
588
|
*/
|
|
495
589
|
otherMounts = new Map();
|
|
590
|
+
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
|
|
591
|
+
rootFoldsCase = new Map();
|
|
496
592
|
/**
|
|
497
593
|
* Whether a file's mode is observed, taken in and applied (MountRuntime's
|
|
498
594
|
* `modesObserved`): not on Windows, which reports no POSIX modes. A test
|
|
@@ -518,26 +614,22 @@ export class WorkspaceModule {
|
|
|
518
614
|
// named or not, and in its watcher (OTHER_MOUNT). It's kept apart from
|
|
519
615
|
// the outer mount's ignore list, which a sync that names a path sets
|
|
520
616
|
// aside for what it names (#276), so no sync of the outer mount takes in
|
|
521
|
-
// the inner one's files.
|
|
617
|
+
// the inner one's files. Here by configured path, before any path is
|
|
618
|
+
// parsed; each start places the roots where disk has them too
|
|
619
|
+
// (readNesting).
|
|
522
620
|
for (const outer of config.mounts) {
|
|
523
621
|
for (const inner of config.mounts) {
|
|
524
622
|
if (outer === inner)
|
|
525
623
|
continue;
|
|
526
624
|
const outerPath = resolve(outer.path);
|
|
527
625
|
const innerPath = resolve(inner.path);
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
// platform's.
|
|
536
|
-
const relPath = rel.split(sep).join('/');
|
|
537
|
-
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { dir: relPath, name: inner.name, root: innerPath }]);
|
|
538
|
-
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
539
|
-
`at ${relPath}/ — passing over it as another mount's directory`);
|
|
540
|
-
}
|
|
626
|
+
const dir = nestedDir(outerPath, innerPath);
|
|
627
|
+
if (dir === undefined)
|
|
628
|
+
continue;
|
|
629
|
+
const configured = [{ dir, root: innerPath }];
|
|
630
|
+
this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
|
|
631
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
|
|
632
|
+
`at ${dir}/ — passing over it as another mount's directory`);
|
|
541
633
|
}
|
|
542
634
|
}
|
|
543
635
|
this.config = config;
|
|
@@ -617,6 +709,24 @@ export class WorkspaceModule {
|
|
|
617
709
|
throw new Error('Workspace store not initialized');
|
|
618
710
|
return this.agreement;
|
|
619
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* A mount as observation reads it, with the root identity the workspace
|
|
714
|
+
* recorded for it, if any: what a pass reads beneath (runtime), with
|
|
715
|
+
* rootIdentity's, and what a disk read checks its root against (readRoot),
|
|
716
|
+
* with rootIdentityForRead's.
|
|
717
|
+
*/
|
|
718
|
+
viewOf(mount, rootIdentity) {
|
|
719
|
+
return {
|
|
720
|
+
root: resolve(mount.config.path),
|
|
721
|
+
name: mount.config.name,
|
|
722
|
+
rootIdentity,
|
|
723
|
+
followSymlinks: mount.config.followSymlinks === true,
|
|
724
|
+
maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
725
|
+
ignore: mount.config.ignore ?? [],
|
|
726
|
+
others: this.othersOf(mount.config.name),
|
|
727
|
+
nested: this.nestedOf(mount.config.name),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
620
730
|
/** The view reconciliation needs of a mount, refreshed per pass. */
|
|
621
731
|
async runtime(mount) {
|
|
622
732
|
const root = resolve(mount.config.path);
|
|
@@ -629,14 +739,7 @@ export class WorkspaceModule {
|
|
|
629
739
|
}
|
|
630
740
|
return {
|
|
631
741
|
name: mount.config.name,
|
|
632
|
-
view:
|
|
633
|
-
root,
|
|
634
|
-
rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
|
|
635
|
-
followSymlinks: mount.config.followSymlinks === true,
|
|
636
|
-
maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
637
|
-
ignore: mount.config.ignore ?? [],
|
|
638
|
-
others: this.othersOf(mount.config.name),
|
|
639
|
-
},
|
|
742
|
+
view: this.viewOf(mount, this.agreementOrThrow().rootIdentity(mount.config.name)),
|
|
640
743
|
rootReal,
|
|
641
744
|
ctimeTrustedDev: trustedDev,
|
|
642
745
|
treeStateId: mount.treeStateId,
|
|
@@ -648,14 +751,14 @@ export class WorkspaceModule {
|
|
|
648
751
|
};
|
|
649
752
|
}
|
|
650
753
|
/**
|
|
651
|
-
* Where a search's walk starts: `base`, its path as
|
|
652
|
-
* segment of `beneath` while disk lists it, in the directory
|
|
653
|
-
* far, as a directory with exactly that spelling. The walk names
|
|
654
|
-
* finds from its start's spelling, and a case-insensitive disk
|
|
655
|
-
* it go on under a spelling disk doesn't list, taking files in
|
|
656
|
-
* spelling beside their own. So the start stops before a segment
|
|
657
|
-
* any other way, not there, or not a directory, and before a link,
|
|
658
|
-
* a walk of the whole mount doesn't go through either. It lists only
|
|
754
|
+
* Where a search's walk starts: `base`, its path as parsePath spells it,
|
|
755
|
+
* then each segment of `beneath` while disk lists it, in the directory
|
|
756
|
+
* reached so far, as a directory with exactly that spelling. The walk names
|
|
757
|
+
* what it finds from its start's spelling, and a case-insensitive disk
|
|
758
|
+
* would let it go on under a spelling disk doesn't list, taking files in
|
|
759
|
+
* under that spelling beside their own. So the start stops before a segment
|
|
760
|
+
* spelled any other way, not there, or not a directory, and before a link,
|
|
761
|
+
* which a walk of the whole mount doesn't go through either. It lists only
|
|
659
762
|
* directories it has checked lie inside the mount, and none the mount
|
|
660
763
|
* passes over by design, which the walk then answers for.
|
|
661
764
|
*/
|
|
@@ -707,6 +810,21 @@ export class WorkspaceModule {
|
|
|
707
810
|
});
|
|
708
811
|
}
|
|
709
812
|
async start(ctx) {
|
|
813
|
+
// Which mounts are nested where, with the roots placed where disk has
|
|
814
|
+
// them and each nested mount's directory as disk spells it, and whether
|
|
815
|
+
// each mount's root folds case, read where disk has it, before this start
|
|
816
|
+
// sets anything, so a restart takes no call and walks or watches nothing
|
|
817
|
+
// before they're known. A stop begun meanwhile stands, and this start then
|
|
818
|
+
// sets nothing.
|
|
819
|
+
const stops = this.stopsBegun;
|
|
820
|
+
const { nesting, reals } = await this.readNesting();
|
|
821
|
+
const rootFoldsCase = new Map();
|
|
822
|
+
for (const mount of this.config.mounts)
|
|
823
|
+
rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)));
|
|
824
|
+
if (this.stopsBegun !== stops)
|
|
825
|
+
return;
|
|
826
|
+
this.otherMounts = nesting;
|
|
827
|
+
this.rootFoldsCase = rootFoldsCase;
|
|
710
828
|
this.ctx = ctx;
|
|
711
829
|
this.stopped = false;
|
|
712
830
|
// Decode persisted state if restarting; application waits until mounts
|
|
@@ -944,6 +1062,7 @@ export class WorkspaceModule {
|
|
|
944
1062
|
}
|
|
945
1063
|
}
|
|
946
1064
|
async stop() {
|
|
1065
|
+
this.stopsBegun++;
|
|
947
1066
|
// No new watcher-driven passes, then let every pass and write finish
|
|
948
1067
|
// before the store goes away — including any queued behind the ones in
|
|
949
1068
|
// flight (a tool call, a scan continuing past its deadline): drained
|
|
@@ -954,11 +1073,13 @@ export class WorkspaceModule {
|
|
|
954
1073
|
for (const watcher of stopping) {
|
|
955
1074
|
await watcher.stop();
|
|
956
1075
|
}
|
|
1076
|
+
// A call under way can await disk (parsePath) before it takes its turn,
|
|
1077
|
+
// so the calls are waited for as well as the turns.
|
|
957
1078
|
for (;;) {
|
|
958
1079
|
const tails = [...this.mountTurns.values()];
|
|
959
|
-
await Promise.
|
|
1080
|
+
await Promise.allSettled([...this.callsUnderWay, ...tails]);
|
|
960
1081
|
const now = [...this.mountTurns.values()];
|
|
961
|
-
if (now.length === tails.length && now.every((tail, i) => tail === tails[i]))
|
|
1082
|
+
if (this.callsUnderWay.size === 0 && now.length === tails.length && now.every((tail, i) => tail === tails[i]))
|
|
962
1083
|
break;
|
|
963
1084
|
}
|
|
964
1085
|
// Nothing is queued now, and nothing more is taken: what follows saves
|
|
@@ -1233,6 +1354,22 @@ export class WorkspaceModule {
|
|
|
1233
1354
|
async handleToolCall(call) {
|
|
1234
1355
|
if (!this.running())
|
|
1235
1356
|
return { success: false, isError: true, error: NOT_RUNNING };
|
|
1357
|
+
return this.underWay(this.runToolCall(call));
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Tool calls, and the public reads and writes that take a mount's turn,
|
|
1361
|
+
* under way. Each can await disk (parsePath) before it takes its turn, so
|
|
1362
|
+
* stop waits for these as well as for the turns.
|
|
1363
|
+
*/
|
|
1364
|
+
callsUnderWay = new Set();
|
|
1365
|
+
/** `work`, recorded as under way until it settles (callsUnderWay). */
|
|
1366
|
+
underWay(work) {
|
|
1367
|
+
this.callsUnderWay.add(work);
|
|
1368
|
+
const done = () => { this.callsUnderWay.delete(work); };
|
|
1369
|
+
work.then(done, done);
|
|
1370
|
+
return work;
|
|
1371
|
+
}
|
|
1372
|
+
async runToolCall(call) {
|
|
1236
1373
|
try {
|
|
1237
1374
|
// Every handler reads its input as its schema describes it; a call
|
|
1238
1375
|
// that sends something else is refused here, before anything is read.
|
|
@@ -1356,7 +1493,10 @@ export class WorkspaceModule {
|
|
|
1356
1493
|
*/
|
|
1357
1494
|
resolveAbsolutePath(mountPrefixedPath) {
|
|
1358
1495
|
try {
|
|
1359
|
-
|
|
1496
|
+
// Synchronous, so the path as given: it reaches the same file, and
|
|
1497
|
+
// the nested-mount check matches it as configured or as disk spells
|
|
1498
|
+
// the nested mount's directory (readNesting).
|
|
1499
|
+
const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
|
|
1360
1500
|
return resolve(mount.config.path, relativePath);
|
|
1361
1501
|
}
|
|
1362
1502
|
catch {
|
|
@@ -1387,10 +1527,13 @@ export class WorkspaceModule {
|
|
|
1387
1527
|
async writeBinary(mountPrefixedPath, data, mimeType, options = {}) {
|
|
1388
1528
|
if (!this.running())
|
|
1389
1529
|
return { success: false, isError: true, error: NOT_RUNNING };
|
|
1530
|
+
return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
|
|
1531
|
+
}
|
|
1532
|
+
async writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options) {
|
|
1390
1533
|
let mount;
|
|
1391
1534
|
let relativePath;
|
|
1392
1535
|
try {
|
|
1393
|
-
({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
|
|
1536
|
+
({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
|
|
1394
1537
|
}
|
|
1395
1538
|
catch (error) {
|
|
1396
1539
|
return {
|
|
@@ -1451,10 +1594,13 @@ export class WorkspaceModule {
|
|
|
1451
1594
|
async readBinary(mountPrefixedPath) {
|
|
1452
1595
|
if (!this.running())
|
|
1453
1596
|
return { error: NOT_RUNNING };
|
|
1597
|
+
return this.underWay(this.readBinaryUnderWay(mountPrefixedPath));
|
|
1598
|
+
}
|
|
1599
|
+
async readBinaryUnderWay(mountPrefixedPath) {
|
|
1454
1600
|
let mount;
|
|
1455
1601
|
let relativePath;
|
|
1456
1602
|
try {
|
|
1457
|
-
({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
|
|
1603
|
+
({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
|
|
1458
1604
|
}
|
|
1459
1605
|
catch (error) {
|
|
1460
1606
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
@@ -1483,38 +1629,150 @@ export class WorkspaceModule {
|
|
|
1483
1629
|
* permanent cost to send something once is the wrong trade.
|
|
1484
1630
|
*
|
|
1485
1631
|
* Same mount boundary and traversal guard as every other path here: only
|
|
1486
|
-
* declared mounts, nothing above their root
|
|
1487
|
-
*
|
|
1632
|
+
* declared mounts, nothing above their root, and the file reached as
|
|
1633
|
+
* readFileFromDisk reaches it (openContainedFile), so the mount's
|
|
1634
|
+
* `followSymlinks` holds for the file and the directories on its way, and
|
|
1635
|
+
* what it reaches stays canonically inside the mount. Deliberately does NOT
|
|
1636
|
+
* sync, register, or hash anything — it reads and returns.
|
|
1637
|
+
*
|
|
1638
|
+
* `maxBytes` refuses a larger file as "File too large", having loaded at
|
|
1639
|
+
* most `maxBytes` bytes and one more: the reads decide where the file ends,
|
|
1640
|
+
* not the size fstat gave at the open, so the bound holds for a file that
|
|
1641
|
+
* grows while it's read.
|
|
1488
1642
|
*/
|
|
1489
1643
|
async readBinaryFromDisk(mountPrefixedPath, opts = {}) {
|
|
1490
1644
|
let mount;
|
|
1491
1645
|
let relativePath;
|
|
1492
1646
|
try {
|
|
1493
|
-
({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
|
|
1647
|
+
({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
|
|
1494
1648
|
}
|
|
1495
1649
|
catch (error) {
|
|
1496
1650
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
1497
1651
|
}
|
|
1498
1652
|
const absolutePath = resolve(mount.config.path, relativePath);
|
|
1653
|
+
const maxBytes = opts.maxBytes;
|
|
1654
|
+
let tooLarge;
|
|
1499
1655
|
try {
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1656
|
+
const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
1657
|
+
if (maxBytes !== undefined && fileStat.size > maxBytes) {
|
|
1658
|
+
tooLarge = `File too large: ${fileStat.size} > ${maxBytes} bytes`;
|
|
1659
|
+
throw new Error(tooLarge);
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
try {
|
|
1663
|
+
if (maxBytes === undefined)
|
|
1664
|
+
return { data: await handle.readFile(), absolutePath };
|
|
1665
|
+
// No more than maxBytes is loaded: a file grown past it since the
|
|
1666
|
+
// fstat above is refused here, as one already over it was there. Its
|
|
1667
|
+
// size is an fstat's after the read, and at least one more than the
|
|
1668
|
+
// bytes kept, so the refusal's size is always past maxBytes.
|
|
1669
|
+
const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
|
|
1670
|
+
if (more)
|
|
1671
|
+
return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
|
|
1672
|
+
return { data: bytes, absolutePath };
|
|
1673
|
+
}
|
|
1674
|
+
finally {
|
|
1675
|
+
await handle.close();
|
|
1505
1676
|
}
|
|
1506
|
-
return { data: await readFile(absolutePath), absolutePath };
|
|
1507
1677
|
}
|
|
1508
1678
|
catch (error) {
|
|
1509
|
-
|
|
1510
|
-
|
|
1679
|
+
if (tooLarge !== undefined)
|
|
1680
|
+
return { error: tooLarge };
|
|
1681
|
+
// A directory, or a file that isn't regular (the reader's refusal of
|
|
1682
|
+
// one carries no errno), and a missing file keep this reader's words.
|
|
1683
|
+
if (error instanceof WorkspaceReadError && (error.code === 'directory' || (error.code === 'not_found' && error.errno === undefined))) {
|
|
1684
|
+
return { error: `Not a file: ${mountPrefixedPath}` };
|
|
1685
|
+
}
|
|
1686
|
+
if (error instanceof WorkspaceReadError && error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) {
|
|
1511
1687
|
return { error: `File not found on disk: ${mountPrefixedPath}` };
|
|
1688
|
+
}
|
|
1512
1689
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
1513
1690
|
}
|
|
1514
1691
|
}
|
|
1515
|
-
/** The directories of the mounts nested inside a mount, mount-relative. */
|
|
1692
|
+
/** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
|
|
1516
1693
|
othersOf(mountName) {
|
|
1517
|
-
return (this.otherMounts.get(mountName) ?? []).
|
|
1694
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Which mounts are nested in which, read afresh at each start. A mount is
|
|
1698
|
+
* nested in another where its configured path lies beneath the other's
|
|
1699
|
+
* root, as configured or as it lies on disk, whatever links the path
|
|
1700
|
+
* reaches that root through, or where its real root lies beneath the
|
|
1701
|
+
* other's. Each root, and each directory above a configured path, is
|
|
1702
|
+
* placed by realBeneath, awaited, so a root on a share that stops
|
|
1703
|
+
* answering holds this start and not the process, and a root moved while
|
|
1704
|
+
* the module was stopped is placed where it is now. A mount whose real
|
|
1705
|
+
* root is the other's own, or holds it, is nested by its configured path
|
|
1706
|
+
* alone, with no real root, since through it it would claim the other's
|
|
1707
|
+
* every path.
|
|
1708
|
+
*
|
|
1709
|
+
* Each way it lies there is a spelling of its directory (`fromRoots`), and
|
|
1710
|
+
* so is the spelling disk lists each of those by, where that differs. On a
|
|
1711
|
+
* disk that folds names, the outer mount's walks name the directory as
|
|
1712
|
+
* disk does, and so does a path parsePath has respelled, while a path given
|
|
1713
|
+
* as configured still names it as configured (resolveAbsolutePath). A
|
|
1714
|
+
* directory not on disk keeps the spellings the roots give alone. Each
|
|
1715
|
+
* mount's real root comes back beside the nesting (`reals`), for what
|
|
1716
|
+
* start reads there.
|
|
1717
|
+
*/
|
|
1718
|
+
async readNesting() {
|
|
1719
|
+
const reals = new Map();
|
|
1720
|
+
// Each directory above a mount's configured path, with where it lies on
|
|
1721
|
+
// disk, so a path that reaches another mount's root through a link, as
|
|
1722
|
+
// one under macOS's /var reaches /private/var, is still read beneath it.
|
|
1723
|
+
const above = new Map();
|
|
1724
|
+
for (const mount of this.config.mounts) {
|
|
1725
|
+
const given = resolve(mount.path);
|
|
1726
|
+
reals.set(mount.name, await realBeneath(given));
|
|
1727
|
+
const dirs = [];
|
|
1728
|
+
for (let dir = dirname(given);; dir = dirname(dir)) {
|
|
1729
|
+
dirs.push({ dir, real: await realBeneath(dir) });
|
|
1730
|
+
if (dirname(dir) === dir)
|
|
1731
|
+
break;
|
|
1732
|
+
}
|
|
1733
|
+
above.set(mount.name, dirs);
|
|
1734
|
+
}
|
|
1735
|
+
const nesting = new Map();
|
|
1736
|
+
for (const outer of this.config.mounts) {
|
|
1737
|
+
const outerPath = resolve(outer.path);
|
|
1738
|
+
const outerReal = reals.get(outer.name);
|
|
1739
|
+
for (const inner of this.config.mounts) {
|
|
1740
|
+
if (outer === inner)
|
|
1741
|
+
continue;
|
|
1742
|
+
const real = reals.get(inner.name);
|
|
1743
|
+
const innerPath = resolve(inner.path);
|
|
1744
|
+
const configured = nestedDir(outerPath, innerPath);
|
|
1745
|
+
const fromRoots = [];
|
|
1746
|
+
// Its directory as configured, read against this mount's root as
|
|
1747
|
+
// configured and from each directory above it that is this mount's
|
|
1748
|
+
// root on disk, and its real root against this mount's.
|
|
1749
|
+
const throughRoot = above.get(inner.name).filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
|
|
1750
|
+
for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
|
|
1751
|
+
if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
|
|
1752
|
+
fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
1753
|
+
}
|
|
1754
|
+
if (fromRoots.length === 0)
|
|
1755
|
+
continue;
|
|
1756
|
+
const spellings = [...fromRoots];
|
|
1757
|
+
for (const found of fromRoots) {
|
|
1758
|
+
const dir = await diskSpelling(outerPath, found.dir);
|
|
1759
|
+
if (!spellings.some((s) => s.dir === dir))
|
|
1760
|
+
spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
|
|
1761
|
+
}
|
|
1762
|
+
const claims = !isContainedPath(real, outerReal);
|
|
1763
|
+
nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
|
|
1764
|
+
const placed = fromRoots.filter((s) => s.dir !== configured);
|
|
1765
|
+
if (placed.length > 0) {
|
|
1766
|
+
console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
|
|
1767
|
+
`at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return { nesting, reals };
|
|
1772
|
+
}
|
|
1773
|
+
/** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
|
|
1774
|
+
nestedOf(mountName) {
|
|
1775
|
+
return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
|
|
1518
1776
|
}
|
|
1519
1777
|
/**
|
|
1520
1778
|
* The innermost mount nested in `mountName` whose directory holds a path
|
|
@@ -1529,7 +1787,7 @@ export class WorkspaceModule {
|
|
|
1529
1787
|
return undefined;
|
|
1530
1788
|
const resolved = resolve(mount.config.path, relativePath);
|
|
1531
1789
|
const holder = (this.otherMounts.get(mountName) ?? [])
|
|
1532
|
-
.
|
|
1790
|
+
.flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
|
|
1533
1791
|
.sort((a, b) => b.root.length - a.root.length)[0];
|
|
1534
1792
|
if (!holder)
|
|
1535
1793
|
return undefined;
|
|
@@ -1555,17 +1813,98 @@ export class WorkspaceModule {
|
|
|
1555
1813
|
* observe it and its pushes don't write it, so its file operations refuse
|
|
1556
1814
|
* it, naming the innermost mount that holds it and the path to use there.
|
|
1557
1815
|
*/
|
|
1558
|
-
parseFilePath(path) {
|
|
1559
|
-
const parsed = this.parsePath(path);
|
|
1816
|
+
async parseFilePath(path) {
|
|
1817
|
+
const parsed = this.refuseNested(await this.parsePath(path));
|
|
1818
|
+
// A path can also reach a nested mount's directory through a symlink on
|
|
1819
|
+
// its way. No push of this mount writes it there and no read reads it,
|
|
1820
|
+
// so it's refused here, before anything is stored, and the checks where
|
|
1821
|
+
// disk is acted on refuse it again.
|
|
1822
|
+
const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
|
|
1823
|
+
if (refusal)
|
|
1824
|
+
throw refusal;
|
|
1825
|
+
return parsed;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* The refusal for a path of `mount` that reaches a nested mount's directory
|
|
1829
|
+
* through a symlink on its way (nestedAtReal), or null where it doesn't. On
|
|
1830
|
+
* a mount that doesn't follow the link, the refusal is the link's own
|
|
1831
|
+
* (linkWords), as the readers and pushes give it. A mount with nothing
|
|
1832
|
+
* nested has nothing to find, so it pays nothing.
|
|
1833
|
+
*/
|
|
1834
|
+
async leadsIntoNested(mount, relativePath, path) {
|
|
1835
|
+
const nested = this.nestedOf(mount.config.name);
|
|
1836
|
+
if (nested.length === 0)
|
|
1837
|
+
return null;
|
|
1838
|
+
const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
|
|
1839
|
+
if (!holder)
|
|
1840
|
+
return null;
|
|
1841
|
+
if (mount.config.followSymlinks !== true) {
|
|
1842
|
+
const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
|
|
1843
|
+
if (found !== null)
|
|
1844
|
+
return await linkReadError(mount, found, path, nested);
|
|
1845
|
+
}
|
|
1846
|
+
return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
|
|
1847
|
+
}
|
|
1848
|
+
/** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
|
|
1849
|
+
refuseNested(parsed) {
|
|
1560
1850
|
const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
|
|
1561
1851
|
if (holder)
|
|
1562
|
-
throw new NestedMountError(path, parsed.mount.config.name, holder.name, holder.use);
|
|
1852
|
+
throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
|
|
1563
1853
|
return parsed;
|
|
1564
1854
|
}
|
|
1565
1855
|
/**
|
|
1566
|
-
* Parse a mount-prefixed path into
|
|
1856
|
+
* Parse a mount-prefixed path into its mount and mount-relative path, as
|
|
1857
|
+
* disk spells it (diskSpelling), with `path` the mount-prefixed path so
|
|
1858
|
+
* spelled. On a disk that folds names, a path spelled otherwise reaches
|
|
1859
|
+
* disk's file, so every tool takes it by disk's name: one name per file,
|
|
1860
|
+
* and the names its answers give are disk's, except that a path that names
|
|
1861
|
+
* a workspace entry exactly, or does once its directories take disk's
|
|
1862
|
+
* spelling, keeps the entry's spelling. A path the workspace records an
|
|
1863
|
+
* intent at, such as a deletion disk hasn't been given yet, counts as an
|
|
1864
|
+
* entry here, so the calls that settle it reach it. The workspace's
|
|
1865
|
+
* entries (entriesOf) stand in for disk where it has nothing yet, so a
|
|
1866
|
+
* draft has one name too. Neither applies to a path that disk's spelling
|
|
1867
|
+
* puts in a nested mount's directory.
|
|
1868
|
+
* A handler that must read something at the moment it's asked, as
|
|
1869
|
+
* handleSync reads the branch a path sync adopts on, reads it before
|
|
1870
|
+
* calling this, since this awaits disk. The entries are read when the
|
|
1871
|
+
* step needs them, some after those awaits, from the branch current then.
|
|
1567
1872
|
*/
|
|
1568
|
-
parsePath(path) {
|
|
1873
|
+
async parsePath(path) {
|
|
1874
|
+
const given = this.parsePathAsGiven(path);
|
|
1875
|
+
const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
|
|
1876
|
+
return {
|
|
1877
|
+
mount: given.mount,
|
|
1878
|
+
relativePath,
|
|
1879
|
+
path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* A mount's entries on the current branch, and the paths it records an
|
|
1884
|
+
* intent at there, as diskSpelling reads them when it does, whether its
|
|
1885
|
+
* root folds case as the last start read it (false before the first), and
|
|
1886
|
+
* which nested mount's directory holds a path, by holderOf, as the
|
|
1887
|
+
* nested-mount checks read it. diskSpelling keeps disk's spelling of a
|
|
1888
|
+
* path that lies in a nested mount's directory as disk spells it, so the
|
|
1889
|
+
* checks refuse at least what they would refuse without the entries.
|
|
1890
|
+
*/
|
|
1891
|
+
entriesOf(mount) {
|
|
1892
|
+
const store = this.getStore();
|
|
1893
|
+
const intents = this.intents.get(mount.config.name);
|
|
1894
|
+
return {
|
|
1895
|
+
// An intent recorded at a path, such as a deletion disk hasn't been
|
|
1896
|
+
// given yet or a conflict, is the workspace's own name for the file
|
|
1897
|
+
// there, as an entry is, so the calls that settle it reach it.
|
|
1898
|
+
has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
|
|
1899
|
+
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
|
|
1900
|
+
holder: (path) => this.holderOf(mount.config.name, path)?.name,
|
|
1901
|
+
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Parse a mount-prefixed path into (mountName, relativePath), as spelled.
|
|
1906
|
+
*/
|
|
1907
|
+
parsePathAsGiven(path) {
|
|
1569
1908
|
// A file's name on disk is UTF-8, which can't hold a lone surrogate: the
|
|
1570
1909
|
// store would keep one name and disk another.
|
|
1571
1910
|
if (LONE_SURROGATE.test(path)) {
|
|
@@ -1629,29 +1968,125 @@ export class WorkspaceModule {
|
|
|
1629
1968
|
}
|
|
1630
1969
|
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
1631
1970
|
}
|
|
1971
|
+
/**
|
|
1972
|
+
* The mount root a disk read reads beneath: resolved, then checked as
|
|
1973
|
+
* observation checks it (checkRoot), so the read and the evidence agree on
|
|
1974
|
+
* which directory the mount is. Once the workspace has recorded the root's
|
|
1975
|
+
* identity, a root that resolves to any other directory is refused, in
|
|
1976
|
+
* ROOT_REPLACED's words; with none recorded yet, it need only be a
|
|
1977
|
+
* directory there. A refusal where no directory is at the root's path
|
|
1978
|
+
* carries the errno that gives (rootAbsence: ENOENT when nothing is there
|
|
1979
|
+
* and ENOTDIR when something else is, which a caller can read as no file,
|
|
1980
|
+
* or the errno the root's stat failed with otherwise, such as EACCES). A
|
|
1981
|
+
* root whose realpath fails carries realpath's errno, and one that is
|
|
1982
|
+
* another directory carries none. The identity is rootIdentityForRead's,
|
|
1983
|
+
* which answers while the evidence awaits reopening; where even it can't
|
|
1984
|
+
* be read, as when the evidence was never read back whole, the read is
|
|
1985
|
+
* refused too, with an errno only where no directory is at the root's path.
|
|
1986
|
+
*/
|
|
1987
|
+
async readRoot(mount) {
|
|
1988
|
+
const name = mount.config.name;
|
|
1989
|
+
const root = resolve(mount.config.path);
|
|
1990
|
+
let real;
|
|
1991
|
+
try {
|
|
1992
|
+
real = await realpath(root);
|
|
1993
|
+
}
|
|
1994
|
+
catch (err) {
|
|
1995
|
+
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
|
|
1996
|
+
}
|
|
1997
|
+
let rootIdentity;
|
|
1998
|
+
try {
|
|
1999
|
+
rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
|
|
2000
|
+
}
|
|
2001
|
+
catch (err) {
|
|
2002
|
+
// The evidence can't be read back, so neither can the check be made.
|
|
2003
|
+
// Where no directory is at the root's path, its errno still says so.
|
|
2004
|
+
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
|
|
2005
|
+
}
|
|
2006
|
+
const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
|
|
2007
|
+
if ('dir' in checked)
|
|
2008
|
+
return { real, dir: checked.dir };
|
|
2009
|
+
// Another directory is no absence. For any other refusal, what is at the
|
|
2010
|
+
// root's path now decides: nothing, something that isn't a directory, or
|
|
2011
|
+
// a directory the path has come to name since it was resolved (no errno).
|
|
2012
|
+
const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
|
|
2013
|
+
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* The refusal for a root that stopped being the directory a read checked,
|
|
2017
|
+
* with the code and errno readRoot gives what is at its path now (its stage
|
|
2018
|
+
* and words are this check's): gone, or not a directory, with ENOENT or
|
|
2019
|
+
* ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
|
|
2020
|
+
* another directory, with none.
|
|
2021
|
+
*/
|
|
2022
|
+
async rootMoved(mount) {
|
|
2023
|
+
const errno = await rootAbsence(resolve(mount.config.path));
|
|
2024
|
+
const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
|
|
2025
|
+
return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
|
|
2026
|
+
}
|
|
1632
2027
|
/**
|
|
1633
2028
|
* Open a mount-relative file for reading with the mount boundary enforced on
|
|
1634
2029
|
* the *filesystem*, not just the path string:
|
|
1635
2030
|
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
2031
|
+
* 0. Check the root (readRoot) before anything beneath it: under a root
|
|
2032
|
+
* that isn't the mount's directory, no path says anything about the
|
|
2033
|
+
* mount's files, an absence included.
|
|
2034
|
+
* 1. On a mount that does not `followSymlinks`, refuse a path with a symlink
|
|
2035
|
+
* among the directories on its way beneath the mount's root, wherever it
|
|
2036
|
+
* leads (linkOnTheWay), before anything resolves it: as `escape` for a
|
|
2037
|
+
* link leading outside the mount, as `other_mount` for one into a nested
|
|
2038
|
+
* mount's directory, and as `parent_symlink` for any other.
|
|
2039
|
+
* 2. `lstat` the lexical path — refuse directories; refuse a final-component
|
|
2040
|
+
* symlink when the mount does not `followSymlinks`; refuse anything else
|
|
2041
|
+
* that isn't a regular file, such as a FIFO, without opening it.
|
|
2042
|
+
* 3. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
|
|
1639
2043
|
* holds against a symlink swapped in between lstat and open; where it
|
|
1640
|
-
* doesn't (Windows), re-`lstat` after opening instead.
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
2044
|
+
* doesn't (Windows), re-`lstat` after opening instead. `O_NONBLOCK`, so a
|
|
2045
|
+
* FIFO swapped in meanwhile doesn't wait for a writer.
|
|
2046
|
+
* 4. `realpath` the opened path. On a mount that does not `followSymlinks`,
|
|
2047
|
+
* refuse it again for a symlinked directory on its way, which catches one
|
|
2048
|
+
* swapped in since step 1 (linkRefusal). Then require canonical
|
|
2049
|
+
* containment in the root step 0 resolved — this is what catches an
|
|
2050
|
+
* *intermediate* symlinked directory escaping the mount, and a followed
|
|
2051
|
+
* symlink whose target lands outside, on every platform (junctions
|
|
2052
|
+
* included).
|
|
2053
|
+
* 5. Confirm the descriptor and the canonical path are the same inode, and
|
|
2054
|
+
* the root still the directory step 0 checked.
|
|
1646
2055
|
*
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
2056
|
+
* Whatever steps 1 to 5 refuse, a missing file included, is said of the
|
|
2057
|
+
* mount's files only while the root is still that directory. If it isn't,
|
|
2058
|
+
* the read is refused with the code and errno step 0 gives what is at the
|
|
2059
|
+
* root's path then (rootMoved), as step 5's own check of the root does.
|
|
2060
|
+
*
|
|
2061
|
+
* `inspect` runs last, after the boundary's checks, so a caller's own check
|
|
2062
|
+
* of the file, such as a size limit, never answers for a file the boundary
|
|
2063
|
+
* refuses. The returned handle is the caller's to close.
|
|
1650
2064
|
*/
|
|
1651
2065
|
async openContainedFile(mount, relativePath, mountPrefixedPath, inspect) {
|
|
2066
|
+
const root = await this.readRoot(mount);
|
|
2067
|
+
try {
|
|
2068
|
+
return await this.openBeneathRoot(mount, root, relativePath, mountPrefixedPath, inspect);
|
|
2069
|
+
}
|
|
2070
|
+
catch (err) {
|
|
2071
|
+
if (err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && !(await root.dir.stillHere())) {
|
|
2072
|
+
throw await this.rootMoved(mount);
|
|
2073
|
+
}
|
|
2074
|
+
throw err;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
/** Steps 1 to 5 of openContainedFile, beneath the root that step 0 checked. */
|
|
2078
|
+
async openBeneathRoot(mount, { real: realMountRoot, dir: rootDir }, relativePath, mountPrefixedPath, inspect) {
|
|
1652
2079
|
const lexicalPath = resolve(mount.config.path, relativePath);
|
|
1653
2080
|
const follow = mount.config.followSymlinks === true;
|
|
1654
2081
|
const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
|
|
2082
|
+
// Before anything resolves the path, so a link's own answer comes first: a
|
|
2083
|
+
// mount that doesn't follow symlinks goes through none among the
|
|
2084
|
+
// directories on the way, wherever it leads. Asked again of the path's
|
|
2085
|
+
// resolution after the open, below.
|
|
2086
|
+
const linkView = { root: mount.config.path, followSymlinks: follow };
|
|
2087
|
+
const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
|
|
2088
|
+
if (onTheWay !== null)
|
|
2089
|
+
throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
|
|
1655
2090
|
let fileInfo;
|
|
1656
2091
|
try {
|
|
1657
2092
|
fileInfo = await lstat(lexicalPath);
|
|
@@ -1669,16 +2104,15 @@ export class WorkspaceModule {
|
|
|
1669
2104
|
if (fileInfo.isSymbolicLink() && !follow) {
|
|
1670
2105
|
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
|
|
1671
2106
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
}
|
|
1676
|
-
catch (err) {
|
|
1677
|
-
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
|
|
2107
|
+
if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
|
|
2108
|
+
// A FIFO, socket or device is never opened.
|
|
2109
|
+
throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
|
|
1678
2110
|
}
|
|
1679
2111
|
let handle;
|
|
1680
2112
|
try {
|
|
1681
|
-
|
|
2113
|
+
// O_NONBLOCK: a FIFO swapped in since the lstat opens at once instead of
|
|
2114
|
+
// waiting for a writer, and the regular-file check below refuses it.
|
|
2115
|
+
handle = await open(lexicalPath, fsConstants.O_RDONLY | NONBLOCK | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
|
|
1682
2116
|
}
|
|
1683
2117
|
catch (err) {
|
|
1684
2118
|
const code = errnoOf(err);
|
|
@@ -1703,9 +2137,8 @@ export class WorkspaceModule {
|
|
|
1703
2137
|
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
1704
2138
|
}
|
|
1705
2139
|
if (!fileStat.isFile()) {
|
|
1706
|
-
throw new WorkspaceReadError('not_found', `
|
|
2140
|
+
throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
|
|
1707
2141
|
}
|
|
1708
|
-
inspect?.(fileStat);
|
|
1709
2142
|
if (!follow && !useNoFollow) {
|
|
1710
2143
|
let postOpenInfo;
|
|
1711
2144
|
try {
|
|
@@ -1733,9 +2166,17 @@ export class WorkspaceModule {
|
|
|
1733
2166
|
}
|
|
1734
2167
|
throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
|
|
1735
2168
|
}
|
|
2169
|
+
// A directory swapped for a link since the walk above is caught here, by
|
|
2170
|
+
// what the path resolved to after the open.
|
|
2171
|
+
const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
|
|
2172
|
+
if (resolvedThrough !== null)
|
|
2173
|
+
throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
|
|
1736
2174
|
if (!isContainedPath(realMountRoot, realFilePath)) {
|
|
1737
2175
|
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
1738
2176
|
}
|
|
2177
|
+
const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
|
|
2178
|
+
if (holder)
|
|
2179
|
+
throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
|
|
1739
2180
|
let pathStat;
|
|
1740
2181
|
try {
|
|
1741
2182
|
pathStat = await stat(realFilePath);
|
|
@@ -1750,6 +2191,16 @@ export class WorkspaceModule {
|
|
|
1750
2191
|
if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
|
|
1751
2192
|
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
|
|
1752
2193
|
}
|
|
2194
|
+
// Containment compares paths, so it can't see another directory put at
|
|
2195
|
+
// the root's own path since step 0 (a drive unmounted mid-read): the
|
|
2196
|
+
// root is asked again, as an effect asks it (effects.ts moved()). This
|
|
2197
|
+
// sees the root as it is now, so a root replaced and put back before it
|
|
2198
|
+
// isn't seen (observe.ts's header says why no check can be sure).
|
|
2199
|
+
if (!(await rootDir.stillHere()))
|
|
2200
|
+
throw await this.rootMoved(mount);
|
|
2201
|
+
// The caller's checks of the file, such as a size limit, come after the
|
|
2202
|
+
// boundary's, so they never answer for a file the boundary refuses.
|
|
2203
|
+
inspect?.(fileStat);
|
|
1753
2204
|
return { handle, fileStat, realFilePath };
|
|
1754
2205
|
}
|
|
1755
2206
|
catch (err) {
|
|
@@ -1773,11 +2224,20 @@ export class WorkspaceModule {
|
|
|
1773
2224
|
* (`truncated`). The reads decide where the file ends, not the size fstat
|
|
1774
2225
|
* gave at the open, so the bound holds for a file that grows while it's
|
|
1775
2226
|
* read.
|
|
2227
|
+
* - Once the workspace has recorded the mount root's identity, with the
|
|
2228
|
+
* first evidence it gathers there, a root that is another directory is
|
|
2229
|
+
* an unavailable mount: a replaced directory, a link swapped in for it,
|
|
2230
|
+
* or an unmounted drive's mountpoint. In any mount, recorded or not, a
|
|
2231
|
+
* root that stops being the directory the read checked until the file
|
|
2232
|
+
* has been opened and checked is unavailable too: with ENOENT or
|
|
2233
|
+
* ENOTDIR where no directory is there, as before the read, and with no
|
|
2234
|
+
* errno for another directory. The file's bytes are then read through
|
|
2235
|
+
* the descriptor that was checked.
|
|
1776
2236
|
*
|
|
1777
2237
|
* Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
|
|
1778
2238
|
* mount, lexical traversal, a path in another mount's directory nested in
|
|
1779
|
-
* the one named (`other_mount`), unavailable mount, missing
|
|
1780
|
-
* directory, policy-denied symlink, outside-mount target, and
|
|
2239
|
+
* the one named (`other_mount`), unavailable mount, missing or non-regular
|
|
2240
|
+
* file, directory, policy-denied symlink, outside-mount target, and
|
|
1781
2241
|
* file-changed-during-read.
|
|
1782
2242
|
*/
|
|
1783
2243
|
async readFileFromDisk(mountPrefixedPath, options = {}) {
|
|
@@ -1787,9 +2247,12 @@ export class WorkspaceModule {
|
|
|
1787
2247
|
let mount;
|
|
1788
2248
|
let relativePath;
|
|
1789
2249
|
try {
|
|
1790
|
-
({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
|
|
2250
|
+
({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
|
|
1791
2251
|
}
|
|
1792
2252
|
catch (err) {
|
|
2253
|
+
// A link's refusal (parseFilePath) carries its own code.
|
|
2254
|
+
if (err instanceof WorkspaceReadError)
|
|
2255
|
+
throw err;
|
|
1793
2256
|
const message = err instanceof Error ? err.message : String(err);
|
|
1794
2257
|
const code = err instanceof NestedMountError ? 'other_mount'
|
|
1795
2258
|
: message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
|
|
@@ -1828,17 +2291,39 @@ export class WorkspaceModule {
|
|
|
1828
2291
|
const p = mountPrefixedPath;
|
|
1829
2292
|
switch (err.code) {
|
|
1830
2293
|
case 'mount_unavailable':
|
|
1831
|
-
|
|
2294
|
+
// With why, where the root's check says, such as a directory that
|
|
2295
|
+
// isn't the mount's.
|
|
2296
|
+
return new WorkspaceImageReadError('mount_unavailable', err.message);
|
|
1832
2297
|
case 'directory':
|
|
1833
2298
|
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
1834
2299
|
case 'symlink':
|
|
1835
|
-
|
|
2300
|
+
// The core reader's own words for a link at the path: "Symlinks are not
|
|
2301
|
+
// allowed: <path>".
|
|
2302
|
+
return new WorkspaceImageReadError('symlink', err.message);
|
|
2303
|
+
case 'parent_symlink':
|
|
2304
|
+
// The core reader's own words for a link on its way: which link and
|
|
2305
|
+
// where it leads, with the setting for a link to a directory.
|
|
2306
|
+
return new WorkspaceImageReadError('parent_symlink', err.message);
|
|
2307
|
+
case 'other_mount':
|
|
2308
|
+
// The core reader's own words for a path a link takes into a nested
|
|
2309
|
+
// mount's directory: that mount and the path to use there. (#460's
|
|
2310
|
+
// own other_mount, a path in that directory, is refused at parse,
|
|
2311
|
+
// and so is a link found there before the read.)
|
|
2312
|
+
return new WorkspaceImageReadError('other_mount', err.message);
|
|
1836
2313
|
case 'escape':
|
|
1837
|
-
|
|
2314
|
+
// The core reader's own words: "Symlink escape detected: <path>" for a
|
|
2315
|
+
// followed link that resolves outside the mount, and for a link on the
|
|
2316
|
+
// way that leads outside a mount that doesn't follow links, which link.
|
|
2317
|
+
return new WorkspaceImageReadError('escape', err.message);
|
|
1838
2318
|
case 'changed':
|
|
1839
2319
|
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
1840
2320
|
case 'not_found':
|
|
1841
2321
|
default:
|
|
2322
|
+
// The core reader's own words for a file that isn't regular, and for
|
|
2323
|
+
// a directory on the way it couldn't ask about.
|
|
2324
|
+
if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
|
|
2325
|
+
return new WorkspaceImageReadError('not_found', err.message);
|
|
2326
|
+
}
|
|
1842
2327
|
if (err.message.startsWith('File not found')) {
|
|
1843
2328
|
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
1844
2329
|
}
|
|
@@ -1902,8 +2387,10 @@ export class WorkspaceModule {
|
|
|
1902
2387
|
}
|
|
1903
2388
|
/**
|
|
1904
2389
|
* Commit a tool's write or delete to the workspace tree, with the branch
|
|
1905
|
-
* intent it implies
|
|
1906
|
-
*
|
|
2390
|
+
* intent it implies: store origin for a path disk never agreed with; for a
|
|
2391
|
+
* deletion, a tombstone, unless the evidence holds no agreed disk copy (P
|
|
2392
|
+
* absent, or no P and store origin), when it clears the path's intent and
|
|
2393
|
+
* pushes nothing. It pushes the change to disk when the mount opts in via
|
|
1907
2394
|
* `autoMaterialize` — required for cross-agent pipelines, since another
|
|
1908
2395
|
* agent's watcher on the same directory only sees real filesystem events.
|
|
1909
2396
|
* The push follows materialize's rule: a disk copy that changed since it
|
|
@@ -1913,9 +2400,11 @@ export class WorkspaceModule {
|
|
|
1913
2400
|
* change reached the workspace but not disk (`refused`): the
|
|
1914
2401
|
* autoMaterialize contract is that disk is the source of truth for
|
|
1915
2402
|
* downstream agents. And, apart from it, why a write whose bytes reached
|
|
1916
|
-
* disk left the file's mode as disk had it (`modeKept`)
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
2403
|
+
* disk left the file's mode as disk had it (`modeKept`), and, for a
|
|
2404
|
+
* deletion that cleared a conflict naming a disk file the workspace never
|
|
2405
|
+
* took in, that nothing was unlinked (`note`). The caller holds the mount's
|
|
2406
|
+
* turn and has settled the path first (settleBeforeMutation, or a pass over
|
|
2407
|
+
* the path, as an edit makes).
|
|
1919
2408
|
*/
|
|
1920
2409
|
async commitToolChange(mount, relativePath, change, onCommitted) {
|
|
1921
2410
|
const store = this.getStore();
|
|
@@ -1923,7 +2412,8 @@ export class WorkspaceModule {
|
|
|
1923
2412
|
// The mutation's precondition, enforced here, synchronously with the
|
|
1924
2413
|
// commit: whatever settleBeforeMutation's pass could or couldn't settle.
|
|
1925
2414
|
settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
|
|
1926
|
-
const
|
|
2415
|
+
const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
|
|
2416
|
+
const known = evidence !== undefined;
|
|
1927
2417
|
if (change.kind === 'set') {
|
|
1928
2418
|
store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
|
|
1929
2419
|
// A later intent update or disk push can throw; the tree entry is
|
|
@@ -1944,21 +2434,51 @@ export class WorkspaceModule {
|
|
|
1944
2434
|
else {
|
|
1945
2435
|
store.treeRemove(mount.treeStateId, relativePath);
|
|
1946
2436
|
onCommitted?.();
|
|
2437
|
+
// A deletion whose evidence holds no agreed disk copy (disk last agreed
|
|
2438
|
+
// it had nothing here, or has never agreed with this entry, which the
|
|
2439
|
+
// workspace created) puts the store back at the agreement. Nothing on
|
|
2440
|
+
// disk is the workspace's to unlink, so it pushes nothing, and it
|
|
2441
|
+
// clears the path's whole intent: no tombstone, so it needs no proof of
|
|
2442
|
+
// absence, which a path disk can't show, such as one beneath a symlink
|
|
2443
|
+
// the mount doesn't follow, would never give; and no conflict, which
|
|
2444
|
+
// has nothing left to be about once the workspace has given up its
|
|
2445
|
+
// side. Whatever disk holds there is a sync's to observe, a file the
|
|
2446
|
+
// workspace never saw included. Any other deletion keeps its
|
|
2447
|
+
// tombstone, which guards it: a copy disk agreed with, a push of ours
|
|
2448
|
+
// that may have landed (pending or interrupted), or an entry from
|
|
2449
|
+
// before the evidence.
|
|
2450
|
+
let agreed = true;
|
|
2451
|
+
let note = null;
|
|
1947
2452
|
intents.update(relativePath, (cur) => {
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2453
|
+
agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
|
|
2454
|
+
if (agreed) {
|
|
2455
|
+
const next = { ...cur, tombstone: { hash: change.previousHash } };
|
|
2456
|
+
delete next.origin;
|
|
2457
|
+
return next;
|
|
2458
|
+
}
|
|
2459
|
+
// A conflict it clears may name a file disk held there, which the
|
|
2460
|
+
// workspace never took in: the answer says that file wasn't unlinked,
|
|
2461
|
+
// where the deletion alone would read as having removed it. The
|
|
2462
|
+
// deletion doesn't look at disk, so a file nothing recorded gets no
|
|
2463
|
+
// note.
|
|
2464
|
+
if (cur.conflict?.disk) {
|
|
2465
|
+
note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
|
|
2466
|
+
`that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
|
|
2467
|
+
}
|
|
2468
|
+
return {};
|
|
1951
2469
|
});
|
|
2470
|
+
if (!agreed)
|
|
2471
|
+
return { refused: null, modeKept: null, note };
|
|
1952
2472
|
}
|
|
1953
2473
|
if (!this.pushesOnCommit(mount))
|
|
1954
|
-
return { refused: null, modeKept: null };
|
|
2474
|
+
return { refused: null, modeKept: null, note: null };
|
|
1955
2475
|
const pushed = await this.pushUnlocked(mount, [relativePath], {
|
|
1956
2476
|
applyDeletions: change.kind === 'remove',
|
|
1957
2477
|
branchId: store.currentBranch().id,
|
|
1958
2478
|
});
|
|
1959
2479
|
const refused = pushed.skipped.find((s) => s.path === relativePath);
|
|
1960
2480
|
if (!refused)
|
|
1961
|
-
return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null };
|
|
2481
|
+
return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
|
|
1962
2482
|
this.ctx?.pushEvent({
|
|
1963
2483
|
type: 'workspace:materialize-failed',
|
|
1964
2484
|
mount: mount.config.name,
|
|
@@ -1966,7 +2486,7 @@ export class WorkspaceModule {
|
|
|
1966
2486
|
op: change.kind === 'set' ? 'write' : 'delete',
|
|
1967
2487
|
error: refused.reason,
|
|
1968
2488
|
});
|
|
1969
|
-
return { refused: refused.reason, modeKept: null };
|
|
2489
|
+
return { refused: refused.reason, modeKept: null, note: null };
|
|
1970
2490
|
}
|
|
1971
2491
|
// ==========================================================================
|
|
1972
2492
|
// Lazy Sync
|
|
@@ -2036,7 +2556,8 @@ export class WorkspaceModule {
|
|
|
2036
2556
|
}
|
|
2037
2557
|
let content = generatedContent;
|
|
2038
2558
|
if (content === undefined) {
|
|
2039
|
-
const { mount, relativePath } = this.parseFilePath(input.path);
|
|
2559
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
2560
|
+
input = { ...input, path };
|
|
2040
2561
|
const store = this.getStore();
|
|
2041
2562
|
const found = await this.withMount(mount, async () => {
|
|
2042
2563
|
const report = await this.ensureSynced(mount, relativePath);
|
|
@@ -2123,7 +2644,9 @@ export class WorkspaceModule {
|
|
|
2123
2644
|
let mount;
|
|
2124
2645
|
let relativePath;
|
|
2125
2646
|
try {
|
|
2126
|
-
|
|
2647
|
+
let path;
|
|
2648
|
+
({ mount, relativePath, path } = await this.parseFilePath(input.path));
|
|
2649
|
+
input = { ...input, path };
|
|
2127
2650
|
}
|
|
2128
2651
|
catch (err) {
|
|
2129
2652
|
return {
|
|
@@ -2195,7 +2718,8 @@ export class WorkspaceModule {
|
|
|
2195
2718
|
if (LONE_SURROGATE.test(input.content)) {
|
|
2196
2719
|
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
2197
2720
|
}
|
|
2198
|
-
const { mount, relativePath } = this.parseFilePath(input.path);
|
|
2721
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
2722
|
+
input = { ...input, path };
|
|
2199
2723
|
if (mount.config.mode === 'read-only') {
|
|
2200
2724
|
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
2201
2725
|
}
|
|
@@ -2238,7 +2762,8 @@ export class WorkspaceModule {
|
|
|
2238
2762
|
return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
|
|
2239
2763
|
}
|
|
2240
2764
|
}
|
|
2241
|
-
const { mount, relativePath } = this.parseFilePath(input.path);
|
|
2765
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
2766
|
+
input = { ...input, path };
|
|
2242
2767
|
if (mount.config.mode === 'read-only') {
|
|
2243
2768
|
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
2244
2769
|
}
|
|
@@ -2376,7 +2901,8 @@ export class WorkspaceModule {
|
|
|
2376
2901
|
}
|
|
2377
2902
|
}
|
|
2378
2903
|
async handleDelete(input) {
|
|
2379
|
-
const { mount, relativePath } = this.parseFilePath(input.path);
|
|
2904
|
+
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
2905
|
+
input = { ...input, path };
|
|
2380
2906
|
if (mount.config.mode === 'read-only') {
|
|
2381
2907
|
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
2382
2908
|
}
|
|
@@ -2390,12 +2916,16 @@ export class WorkspaceModule {
|
|
|
2390
2916
|
if (!entry) {
|
|
2391
2917
|
return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
|
|
2392
2918
|
}
|
|
2393
|
-
const { refused } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
2919
|
+
const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
|
|
2394
2920
|
if (refused !== null)
|
|
2395
2921
|
return storedButUnfinished('delete', input.path, { refused });
|
|
2396
2922
|
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
2397
2923
|
// withholds the result (agent-framework #277).
|
|
2398
|
-
return {
|
|
2924
|
+
return {
|
|
2925
|
+
success: true,
|
|
2926
|
+
data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
|
|
2927
|
+
keepWhenWithheld: ['path', 'deleted', 'note'],
|
|
2928
|
+
};
|
|
2399
2929
|
});
|
|
2400
2930
|
}
|
|
2401
2931
|
catch (error) {
|
|
@@ -2415,7 +2945,8 @@ export class WorkspaceModule {
|
|
|
2415
2945
|
}));
|
|
2416
2946
|
return { success: true, data: { mounts } };
|
|
2417
2947
|
}
|
|
2418
|
-
const { mount, relativePath } = this.parsePath(input.path);
|
|
2948
|
+
const { mount, relativePath, path } = await this.parsePath(input.path);
|
|
2949
|
+
input = { ...input, path };
|
|
2419
2950
|
const recursive = input.recursive === true;
|
|
2420
2951
|
// Every listing checks the paths it shows against disk first, under the
|
|
2421
2952
|
// three-way rule, whatever the mount's watch mode.
|
|
@@ -2464,7 +2995,7 @@ export class WorkspaceModule {
|
|
|
2464
2995
|
const incomplete = [];
|
|
2465
2996
|
// Search across mounts
|
|
2466
2997
|
const mountsToSearch = input.path
|
|
2467
|
-
? [this.parsePath(input.path)]
|
|
2998
|
+
? [await this.parsePath(input.path)]
|
|
2468
2999
|
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
2469
3000
|
// Every match lies beneath the pattern's literal directory, so the walk
|
|
2470
3001
|
// starts there: past the walk's cap, a walk of the whole scope can stop
|
|
@@ -2511,7 +3042,7 @@ export class WorkspaceModule {
|
|
|
2511
3042
|
const contextBefore = input.contextBefore ?? 0;
|
|
2512
3043
|
const contextAfter = input.contextAfter ?? 0;
|
|
2513
3044
|
const mountsToSearch = input.path
|
|
2514
|
-
? [this.parsePath(input.path)]
|
|
3045
|
+
? [await this.parsePath(input.path)]
|
|
2515
3046
|
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
2516
3047
|
const results = [];
|
|
2517
3048
|
const skipped = [];
|
|
@@ -2557,13 +3088,31 @@ export class WorkspaceModule {
|
|
|
2557
3088
|
// on: one selected in between would label its content with the other
|
|
2558
3089
|
// branch's states. So a pass whose branch is no longer selected runs
|
|
2559
3090
|
// again, and a branch that keeps changing leaves the scope unsearched.
|
|
3091
|
+
//
|
|
3092
|
+
// A copy held at a path that now leads into a nested mount's directory
|
|
3093
|
+
// through a link is that mount's file too: read refuses it
|
|
3094
|
+
// (parseFilePath), so grep searches none of it and says why. The pass
|
|
3095
|
+
// can't check such a path's disk, so only an unverified one can be
|
|
3096
|
+
// one. Asking awaits disk, so it's done before the branch is
|
|
3097
|
+
// compared, and a branch selected meanwhile runs the pass again.
|
|
2560
3098
|
let pass = null;
|
|
3099
|
+
let refused = new Map();
|
|
2561
3100
|
for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
|
|
2562
3101
|
const next = await this.passUnlocked(mount, fileScope
|
|
2563
3102
|
? { kind: 'paths', paths: [relativePath] }
|
|
2564
3103
|
: { kind: 'dir', dir: start, recursive: true });
|
|
2565
|
-
|
|
3104
|
+
const asked = new Map();
|
|
3105
|
+
for (const report of next.reports.values()) {
|
|
3106
|
+
if (report.state !== 'unverified')
|
|
3107
|
+
continue;
|
|
3108
|
+
const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
|
|
3109
|
+
if (refusal)
|
|
3110
|
+
asked.set(report.path, refusal.message);
|
|
3111
|
+
}
|
|
3112
|
+
if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
|
|
2566
3113
|
pass = next;
|
|
3114
|
+
refused = asked;
|
|
3115
|
+
}
|
|
2567
3116
|
}
|
|
2568
3117
|
if (pass === null) {
|
|
2569
3118
|
incomplete.push({
|
|
@@ -2594,6 +3143,11 @@ export class WorkspaceModule {
|
|
|
2594
3143
|
// scope it's passed over by design, like the directory.
|
|
2595
3144
|
if (inOtherMount(report.path, this.othersOf(mount.config.name)))
|
|
2596
3145
|
continue;
|
|
3146
|
+
const refusal = refused.get(report.path);
|
|
3147
|
+
if (refusal !== undefined) {
|
|
3148
|
+
skipped.push({ file, reason: refusal });
|
|
3149
|
+
continue;
|
|
3150
|
+
}
|
|
2597
3151
|
// The workspace's version, labelled with the path's state.
|
|
2598
3152
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
2599
3153
|
const blob = entry ? store.getBlob(entry.blobHash) : null;
|
|
@@ -2780,7 +3334,7 @@ export class WorkspaceModule {
|
|
|
2780
3334
|
const modesKept = [];
|
|
2781
3335
|
let explicit = null;
|
|
2782
3336
|
if (input.path)
|
|
2783
|
-
explicit = this.parsePath(input.path);
|
|
3337
|
+
explicit = await this.parsePath(input.path);
|
|
2784
3338
|
let mountsToMaterialize;
|
|
2785
3339
|
if (explicit) {
|
|
2786
3340
|
mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
|
|
@@ -2957,7 +3511,7 @@ export class WorkspaceModule {
|
|
|
2957
3511
|
// A path names its own mount; otherwise the given mount, or every mount.
|
|
2958
3512
|
let targets;
|
|
2959
3513
|
if (input.path) {
|
|
2960
|
-
const { mount, relativePath } = this.parsePath(input.path);
|
|
3514
|
+
const { mount, relativePath } = await this.parsePath(input.path);
|
|
2961
3515
|
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
2962
3516
|
}
|
|
2963
3517
|
else if (input.mount) {
|