@eum-agent/file-explorer 0.0.0-stage → 1.0.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/dist/index.js +1327 -0
- package/dist/schema.js +146 -0
- package/package.json +51 -3
- package/ui/ui.js +8463 -0
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
import { AccessErrorSchema, FileBytesParamsSchema, FileEntrySchema, FileTargetSchema, ListParamsSchema, ListResultSchema, MkdirParamsSchema, MoveParamsSchema, OperationResultSchema, OperationStatusParamsSchema, PathSchema, RemoveParamsSchema, RenameParamsSchema, RootsParamsSchema, RootsResultSchema, SegmentSchema, StatParamsSchema, StatResultSchema } from "./schema.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import nodePath, { join } from "node:path";
|
|
6
|
+
//#region src/errors.ts
|
|
7
|
+
/** Bounded extension errors safe for transport and operation records. */
|
|
8
|
+
var FileAccessError = class extends Error {
|
|
9
|
+
/** Stable business code, never raw host exception text. */
|
|
10
|
+
code;
|
|
11
|
+
/** HTTP status used by the generic resource boundary. */
|
|
12
|
+
status;
|
|
13
|
+
/** Context validated by the provider's ErrorDetailsSchema before publication. */
|
|
14
|
+
details;
|
|
15
|
+
/** Carries a business code and JSON context without raw host exceptions or content. */
|
|
16
|
+
constructor(code, status, details) {
|
|
17
|
+
super(`File access failed: ${code}`);
|
|
18
|
+
this.name = "FileAccessError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.details = details;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Preserves known business errors and maps host failures without exposing paths or file bytes. */
|
|
25
|
+
function accessError(error) {
|
|
26
|
+
if (error instanceof FileAccessError) return error;
|
|
27
|
+
if (error instanceof Error && error.name === "AbortError") return new FileAccessError("unavailable", 503);
|
|
28
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
29
|
+
const business = {
|
|
30
|
+
ENOENT: "missing",
|
|
31
|
+
EEXIST: "exists",
|
|
32
|
+
EACCES: "io-failed",
|
|
33
|
+
EPERM: "io-failed",
|
|
34
|
+
EXDEV: "cross-device",
|
|
35
|
+
"too-large": "too-large-directory"
|
|
36
|
+
}[code] ?? code;
|
|
37
|
+
const status = {
|
|
38
|
+
missing: 404,
|
|
39
|
+
exists: 409,
|
|
40
|
+
stale: 409,
|
|
41
|
+
sealed: 403,
|
|
42
|
+
"outside-root": 403,
|
|
43
|
+
"cross-device": 409,
|
|
44
|
+
unavailable: 503,
|
|
45
|
+
"too-large-directory": 413,
|
|
46
|
+
"unsupported-name": 415,
|
|
47
|
+
"unsupported-type": 415,
|
|
48
|
+
"unsupported-platform": 503
|
|
49
|
+
};
|
|
50
|
+
return new FileAccessError(business in status ? business : "io-failed", status[business] ?? 500);
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/path-semantics.ts
|
|
54
|
+
/** Host path syntax only; resolved object identity remains the platform's responsibility. */
|
|
55
|
+
/** Requires an independent absolute root, excluding Windows drive-relative and root-relative forms. */
|
|
56
|
+
function isFullAbsolute(value, paths = nodePath) {
|
|
57
|
+
if (value.includes("\0") || !paths.isAbsolute(value)) return false;
|
|
58
|
+
if (paths.sep !== "\\") return true;
|
|
59
|
+
if (/^[A-Za-z]:[\\/]/.test(value)) return true;
|
|
60
|
+
const unc = /^[\\/]{2}([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/.exec(value);
|
|
61
|
+
const server = unc?.[1];
|
|
62
|
+
const share = unc?.[2];
|
|
63
|
+
return server !== void 0 && share !== void 0 && ![
|
|
64
|
+
".",
|
|
65
|
+
"..",
|
|
66
|
+
"?"
|
|
67
|
+
].includes(server) && ![".", ".."].includes(share);
|
|
68
|
+
}
|
|
69
|
+
/** Compares canonical ancestor segments exactly; drive-letter syntax alone is case-insensitive. */
|
|
70
|
+
function containsHostPath(root, value, paths = nodePath) {
|
|
71
|
+
if (!isFullAbsolute(root, paths) || !isFullAbsolute(value, paths)) return false;
|
|
72
|
+
const parts = (path) => {
|
|
73
|
+
const normalized = paths.normalize(path);
|
|
74
|
+
const { root } = paths.parse(normalized);
|
|
75
|
+
return {
|
|
76
|
+
root: paths.sep === "\\" && /^[A-Za-z]:\\$/.test(root) ? root.toUpperCase() : root,
|
|
77
|
+
segments: normalized.slice(root.length).split(paths.sep).filter(Boolean)
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const parent = parts(root);
|
|
81
|
+
const child = parts(value);
|
|
82
|
+
return parent.root === child.root && parent.segments.length <= child.segments.length && parent.segments.every((segment, index) => segment === child.segments[index]);
|
|
83
|
+
}
|
|
84
|
+
/** Converts a contained host path to validated wire segments without changing filename content. */
|
|
85
|
+
function toWirePath(root, value, paths = nodePath) {
|
|
86
|
+
if (!containsHostPath(root, value, paths)) throw new FileAccessError("outside-root", 403);
|
|
87
|
+
const relative = paths.relative(root, value);
|
|
88
|
+
const wire = relative === "" ? "" : relative.split(paths.sep).join("/");
|
|
89
|
+
if (!PathSchema.safeParse(wire).success) throw new FileAccessError("outside-root", 403);
|
|
90
|
+
return wire;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/scope.ts
|
|
94
|
+
/** Revalidates explicit workspace scope while all data/namespace operations remain anchored. */
|
|
95
|
+
/** Tests lexical containment of canonical host paths, retaining exact object segment case. */
|
|
96
|
+
function contains(parent, child, paths = nodePath) {
|
|
97
|
+
return containsHostPath(parent, child, paths);
|
|
98
|
+
}
|
|
99
|
+
/** Current root and managed-media policy, deliberately independent of Agent approvals. */
|
|
100
|
+
var FileScope = class {
|
|
101
|
+
roots;
|
|
102
|
+
files;
|
|
103
|
+
signal;
|
|
104
|
+
instanceId;
|
|
105
|
+
/** Consistent host path operations throughout selection, links and mutations. */
|
|
106
|
+
referenceFormatter;
|
|
107
|
+
/** Host path operations retain exact observed segment spelling. */
|
|
108
|
+
paths;
|
|
109
|
+
/** Binds this policy to one owner and Gateway startup. */
|
|
110
|
+
constructor(roots, files, signal, instanceId, paths = nodePath, referenceFormatter) {
|
|
111
|
+
this.roots = roots;
|
|
112
|
+
this.files = files;
|
|
113
|
+
this.signal = signal;
|
|
114
|
+
this.instanceId = instanceId;
|
|
115
|
+
this.paths = paths;
|
|
116
|
+
this.referenceFormatter = referenceFormatter;
|
|
117
|
+
}
|
|
118
|
+
/** Uses the reader owner's sole reference revision semantics for ordinary files and links. */
|
|
119
|
+
fingerprint(observation, referent) {
|
|
120
|
+
if (!this.referenceFormatter) throw new FileAccessError("unavailable", 503);
|
|
121
|
+
return this.referenceFormatter.fingerprint(observation, referent);
|
|
122
|
+
}
|
|
123
|
+
/** Returns a current explicit snapshot, refusing unavailable/mismatched startup identity. */
|
|
124
|
+
async snapshot(workspaceId, request) {
|
|
125
|
+
try {
|
|
126
|
+
this.signal.throwIfAborted();
|
|
127
|
+
request?.throwIfAborted();
|
|
128
|
+
if (!this.roots.availability().available || !this.files.availability().available) throw new FileAccessError("unavailable", 503);
|
|
129
|
+
const snapshot = await this.roots.read(workspaceId);
|
|
130
|
+
this.signal.throwIfAborted();
|
|
131
|
+
request?.throwIfAborted();
|
|
132
|
+
snapshot.signal.throwIfAborted();
|
|
133
|
+
if (snapshot.instanceId !== this.instanceId) throw new FileAccessError("instance-mismatch", 409);
|
|
134
|
+
await snapshot.assertCurrent();
|
|
135
|
+
this.signal.throwIfAborted();
|
|
136
|
+
request?.throwIfAborted();
|
|
137
|
+
snapshot.signal.throwIfAborted();
|
|
138
|
+
return snapshot;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw accessError(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Pins a parent and observes the literal final entry; missing final entries may be admitted for writes. */
|
|
144
|
+
async select(target, request, missing = false) {
|
|
145
|
+
FileTargetSchema.parse(target);
|
|
146
|
+
const snapshot = await this.snapshot(target.workspaceId, request);
|
|
147
|
+
const selected = snapshot.roots.find((root) => root.id === target.rootId);
|
|
148
|
+
if (!selected) throw new FileAccessError("missing", 404);
|
|
149
|
+
const signal = AbortSignal.any([
|
|
150
|
+
this.signal,
|
|
151
|
+
snapshot.signal,
|
|
152
|
+
...request ? [request] : []
|
|
153
|
+
]);
|
|
154
|
+
const root = await this.files.anchor(selected.path, {
|
|
155
|
+
sealedDirectories: snapshot.sealedDirectories,
|
|
156
|
+
signal
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const segments = target.path === "" ? [] : target.path.split("/");
|
|
160
|
+
let name = segments.pop() ?? "";
|
|
161
|
+
const parent = segments.length ? await root.openDirectory(segments) : root;
|
|
162
|
+
let observation;
|
|
163
|
+
let referent;
|
|
164
|
+
try {
|
|
165
|
+
if (name) {
|
|
166
|
+
const entry = await parent.entry(name);
|
|
167
|
+
name = entry.name;
|
|
168
|
+
observation = entry.observation;
|
|
169
|
+
referent = entry.referent;
|
|
170
|
+
} else observation = await parent.stat();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (!missing || accessError(error).code !== "missing") throw error;
|
|
173
|
+
}
|
|
174
|
+
const path = name ? this.paths.join(parent.path, name) : parent.path;
|
|
175
|
+
let effectivePath = path;
|
|
176
|
+
let referentObservation;
|
|
177
|
+
if (observation?.kind === "symlink") {
|
|
178
|
+
if (!referent) throw new FileAccessError("outside-root", 403);
|
|
179
|
+
effectivePath = referent.path;
|
|
180
|
+
referentObservation = referent.observation;
|
|
181
|
+
}
|
|
182
|
+
const current = async () => {
|
|
183
|
+
signal.throwIfAborted();
|
|
184
|
+
await snapshot.assertCurrent();
|
|
185
|
+
signal.throwIfAborted();
|
|
186
|
+
};
|
|
187
|
+
await current();
|
|
188
|
+
return {
|
|
189
|
+
target,
|
|
190
|
+
snapshot,
|
|
191
|
+
root,
|
|
192
|
+
parent,
|
|
193
|
+
name,
|
|
194
|
+
path,
|
|
195
|
+
effectivePath,
|
|
196
|
+
observation,
|
|
197
|
+
referentObservation,
|
|
198
|
+
signal,
|
|
199
|
+
current,
|
|
200
|
+
close: () => root.close()
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
await root.close();
|
|
204
|
+
throw accessError(error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Protects managed media itself, descendants, aliases and ancestors affected by mutation. */
|
|
208
|
+
mutation(selection) {
|
|
209
|
+
if (!selection.name) throw new FileAccessError("outside-root", 403);
|
|
210
|
+
for (const sealed of selection.snapshot.sealedDirectories) for (const path of [selection.path, selection.effectivePath]) if (contains(path, sealed, this.paths) || contains(sealed, path, this.paths)) throw new FileAccessError("sealed", 403);
|
|
211
|
+
for (const protectedPath of selection.snapshot.protectedDirectories) for (const path of [selection.path, selection.effectivePath]) if (contains(protectedPath, path, this.paths) || contains(path, protectedPath, this.paths)) throw new FileAccessError("managed-storage", 403);
|
|
212
|
+
}
|
|
213
|
+
/** Keeps literal link kind/size while fingerprinting both its revision and selected referent. */
|
|
214
|
+
entry(selection, observation = selection.observation) {
|
|
215
|
+
if (!observation) throw new FileAccessError("missing", 404);
|
|
216
|
+
if (selection.name && !SegmentSchema.safeParse(selection.name).success) throw new FileAccessError("unsupported-name", 415);
|
|
217
|
+
const size = Number(observation.size);
|
|
218
|
+
if (!Number.isSafeInteger(size)) throw new FileAccessError("too-large", 413);
|
|
219
|
+
let mutable = true;
|
|
220
|
+
try {
|
|
221
|
+
this.mutation(selection);
|
|
222
|
+
} catch {
|
|
223
|
+
mutable = false;
|
|
224
|
+
}
|
|
225
|
+
const revision = this.fingerprint(observation, selection.referentObservation);
|
|
226
|
+
const name = selection.name || selection.snapshot.roots.find((root) => root.id === selection.target.rootId)?.name;
|
|
227
|
+
if (name === void 0) throw new FileAccessError("missing", 404);
|
|
228
|
+
return {
|
|
229
|
+
name,
|
|
230
|
+
path: selection.target.path,
|
|
231
|
+
kind: observation.kind,
|
|
232
|
+
size,
|
|
233
|
+
modifiedAt: Number(BigInt(observation.modifiedNs) / 1000000n),
|
|
234
|
+
fingerprint: revision,
|
|
235
|
+
uri: this.referenceUri(selection),
|
|
236
|
+
referenceKind: "current",
|
|
237
|
+
mutable
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
referenceUri(selection) {
|
|
241
|
+
const formatted = this.referenceFormatter?.formatCurrent?.({
|
|
242
|
+
gatewayId: selection.snapshot.gatewayId,
|
|
243
|
+
...selection.target
|
|
244
|
+
});
|
|
245
|
+
if (!formatted) throw new FileAccessError("unavailable", 503);
|
|
246
|
+
return formatted.uri;
|
|
247
|
+
}
|
|
248
|
+
/** Reobserves a published destination, resolving links at their new location. */
|
|
249
|
+
async freshEntry(target, signal) {
|
|
250
|
+
const selection = await this.select(target, signal);
|
|
251
|
+
try {
|
|
252
|
+
return this.entry(selection);
|
|
253
|
+
} finally {
|
|
254
|
+
await selection.close();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/listings.ts
|
|
260
|
+
/** Owner-local bounded immutable directory snapshots and opaque page cursors. */
|
|
261
|
+
/** Keeps at most eight sixty-second snapshots; every page still revalidates current scope. */
|
|
262
|
+
var Listings = class {
|
|
263
|
+
scope;
|
|
264
|
+
now;
|
|
265
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
266
|
+
/** Shares the provider's scope and clock without retaining host descriptors. */
|
|
267
|
+
constructor(scope, now) {
|
|
268
|
+
this.scope = scope;
|
|
269
|
+
this.now = now;
|
|
270
|
+
}
|
|
271
|
+
/** Returns a 200-entry page of one immutable generation or refuses an expired/cross-target cursor. */
|
|
272
|
+
async list(params, signal) {
|
|
273
|
+
const selection = await this.scope.select(params.target, signal);
|
|
274
|
+
try {
|
|
275
|
+
const directory = selection.name ? await selection.parent.openDirectory([selection.name]) : selection.parent;
|
|
276
|
+
const current = this.scope.fingerprint(await directory.stat());
|
|
277
|
+
const now = this.now();
|
|
278
|
+
for (const [id, snapshot] of this.snapshots) if (now - snapshot.snapshotAt >= 6e4) this.snapshots.delete(id);
|
|
279
|
+
let snapshot;
|
|
280
|
+
let offset = 0;
|
|
281
|
+
if (params.cursor !== void 0) {
|
|
282
|
+
for (const candidate of this.snapshots.values()) for (const [position, cursor] of candidate.cursors) if (cursor === params.cursor) {
|
|
283
|
+
snapshot = candidate;
|
|
284
|
+
offset = position;
|
|
285
|
+
}
|
|
286
|
+
if (!snapshot || snapshot.target !== JSON.stringify(params.target)) throw new FileAccessError("stale-cursor", 409);
|
|
287
|
+
} else {
|
|
288
|
+
const observed = await directory.list(1e4);
|
|
289
|
+
const entries = [];
|
|
290
|
+
for (const item of observed) {
|
|
291
|
+
const path = [params.target.path, item.name].filter(Boolean).join("/");
|
|
292
|
+
if (item.observation.kind === "symlink") {
|
|
293
|
+
const linked = await this.scope.select({
|
|
294
|
+
...params.target,
|
|
295
|
+
path
|
|
296
|
+
}, signal);
|
|
297
|
+
try {
|
|
298
|
+
entries.push(this.scope.entry(linked));
|
|
299
|
+
} finally {
|
|
300
|
+
await linked.close();
|
|
301
|
+
}
|
|
302
|
+
} else entries.push(this.scope.entry({
|
|
303
|
+
...selection,
|
|
304
|
+
parent: directory,
|
|
305
|
+
name: item.name,
|
|
306
|
+
path: join(directory.path, item.name),
|
|
307
|
+
effectivePath: join(directory.path, item.name),
|
|
308
|
+
target: {
|
|
309
|
+
...params.target,
|
|
310
|
+
path
|
|
311
|
+
},
|
|
312
|
+
observation: item.observation
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
if (this.scope.fingerprint(await directory.stat()) !== current) throw new FileAccessError("stale", 409);
|
|
316
|
+
const order = {
|
|
317
|
+
directory: 0,
|
|
318
|
+
file: 1,
|
|
319
|
+
symlink: 2,
|
|
320
|
+
other: 3
|
|
321
|
+
};
|
|
322
|
+
entries.sort((a, b) => order[a.kind] - order[b.kind] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
323
|
+
if (this.snapshots.size === 8) this.snapshots.delete(this.snapshots.keys().next().value);
|
|
324
|
+
snapshot = {
|
|
325
|
+
target: JSON.stringify(params.target),
|
|
326
|
+
entries,
|
|
327
|
+
listingId: randomUUID(),
|
|
328
|
+
snapshotAt: now,
|
|
329
|
+
fingerprint: current,
|
|
330
|
+
cursors: /* @__PURE__ */ new Map()
|
|
331
|
+
};
|
|
332
|
+
this.snapshots.set(snapshot.listingId, snapshot);
|
|
333
|
+
}
|
|
334
|
+
await selection.current();
|
|
335
|
+
const next = offset + 200;
|
|
336
|
+
let nextCursor;
|
|
337
|
+
if (next < snapshot.entries.length) {
|
|
338
|
+
nextCursor = snapshot.cursors.get(next) ?? randomUUID();
|
|
339
|
+
snapshot.cursors.set(next, nextCursor);
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
entries: snapshot.entries.slice(offset, next),
|
|
343
|
+
nextCursor,
|
|
344
|
+
fingerprint: snapshot.fingerprint,
|
|
345
|
+
listingId: snapshot.listingId,
|
|
346
|
+
snapshotAt: snapshot.snapshotAt,
|
|
347
|
+
stale: snapshot.fingerprint !== current
|
|
348
|
+
};
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw accessError(error);
|
|
351
|
+
} finally {
|
|
352
|
+
await selection.close();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Releases in-memory generations on owner shutdown. */
|
|
356
|
+
close() {
|
|
357
|
+
this.snapshots.clear();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/locks.ts
|
|
362
|
+
/** Atomic acquisition of ordered canonical path sets prevents opposing moves from deadlocking. */
|
|
363
|
+
/** Serializes overlapping selected paths, including recursive ancestors. */
|
|
364
|
+
var PathLocks = class {
|
|
365
|
+
/** Binds lock overlap to the same host syntax as its selections. */
|
|
366
|
+
constructor(paths = nodePath) {
|
|
367
|
+
this.paths = paths;
|
|
368
|
+
}
|
|
369
|
+
paths;
|
|
370
|
+
active = /* @__PURE__ */ new Set();
|
|
371
|
+
waiting = [];
|
|
372
|
+
/** Acquires all deduplicated paths in fixed order, refusing cancelled queued work. */
|
|
373
|
+
acquire(paths, signal) {
|
|
374
|
+
signal.throwIfAborted();
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
const item = {
|
|
377
|
+
paths: [...new Set(paths)].sort(),
|
|
378
|
+
signal,
|
|
379
|
+
resolve,
|
|
380
|
+
reject
|
|
381
|
+
};
|
|
382
|
+
const aborted = () => {
|
|
383
|
+
const index = this.waiting.indexOf(item);
|
|
384
|
+
if (index >= 0) this.waiting.splice(index, 1);
|
|
385
|
+
reject(new FileAccessError("unavailable", 503));
|
|
386
|
+
this.wake();
|
|
387
|
+
};
|
|
388
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
389
|
+
const originalResolve = item.resolve;
|
|
390
|
+
item.resolve = (release) => {
|
|
391
|
+
signal.removeEventListener("abort", aborted);
|
|
392
|
+
originalResolve(release);
|
|
393
|
+
};
|
|
394
|
+
this.waiting.push(item);
|
|
395
|
+
this.wake();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
wake() {
|
|
399
|
+
for (const item of [...this.waiting]) {
|
|
400
|
+
if (item.signal.aborted) continue;
|
|
401
|
+
if ([...this.active].some((paths) => paths.some((a) => item.paths.some((b) => contains(a, b, this.paths) || contains(b, a, this.paths))))) continue;
|
|
402
|
+
this.waiting.splice(this.waiting.indexOf(item), 1);
|
|
403
|
+
this.active.add(item.paths);
|
|
404
|
+
let released = false;
|
|
405
|
+
item.resolve(() => {
|
|
406
|
+
if (!released) {
|
|
407
|
+
released = true;
|
|
408
|
+
this.active.delete(item.paths);
|
|
409
|
+
this.wake();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/mutations.ts
|
|
417
|
+
/** Observed namespace operations serialized across canonical physical selections. */
|
|
418
|
+
/** Compares object identity without treating an observation as permanent permission. */
|
|
419
|
+
function sameObject(a, b) {
|
|
420
|
+
return a.device === b.device && a.inode === b.inode;
|
|
421
|
+
}
|
|
422
|
+
/** Acquires all canonical targets atomically and rejects alias changes while waiting. */
|
|
423
|
+
async function withSelections(scope, locks, targets, signal, work) {
|
|
424
|
+
const preliminary = [];
|
|
425
|
+
const selections = [];
|
|
426
|
+
let release;
|
|
427
|
+
try {
|
|
428
|
+
try {
|
|
429
|
+
for (const target of targets) {
|
|
430
|
+
const selected = await scope.select(target, signal, true);
|
|
431
|
+
preliminary.push(selected);
|
|
432
|
+
scope.mutation(selected);
|
|
433
|
+
}
|
|
434
|
+
release = await locks.acquire(preliminary.flatMap((selected) => [selected.path, selected.effectivePath]), AbortSignal.any(preliminary.map((selected) => selected.signal)));
|
|
435
|
+
} finally {
|
|
436
|
+
await Promise.all(preliminary.map((selected) => selected.close()));
|
|
437
|
+
}
|
|
438
|
+
for (const [index, target] of targets.entries()) {
|
|
439
|
+
const selected = await scope.select(target, signal, true);
|
|
440
|
+
selections.push(selected);
|
|
441
|
+
if (selected.path !== preliminary[index]?.path || selected.effectivePath !== preliminary[index]?.effectivePath) throw new FileAccessError("stale", 409);
|
|
442
|
+
scope.mutation(selected);
|
|
443
|
+
}
|
|
444
|
+
return await work(selections);
|
|
445
|
+
} finally {
|
|
446
|
+
try {
|
|
447
|
+
await Promise.all(selections.map((selected) => selected.close()));
|
|
448
|
+
} finally {
|
|
449
|
+
release?.();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Rejects lexical containment and physical aliases/mount-root ancestry before any staging. */
|
|
454
|
+
async function rejectOverlap(source, destination, paths = nodePath) {
|
|
455
|
+
const observed = source.observation;
|
|
456
|
+
if (!observed) throw new FileAccessError("missing", 404);
|
|
457
|
+
if (source.path === destination.path || source.effectivePath === destination.effectivePath || destination.observation && sameObject(observed, destination.observation) || observed.kind === "directory" && contains(source.effectivePath, destination.effectivePath, paths) || destination.observation?.kind === "directory" && contains(destination.effectivePath, source.effectivePath, paths)) throw new FileAccessError("path-overlap", 409);
|
|
458
|
+
const ancestorIncludes = async (selection, observation) => {
|
|
459
|
+
const parts = paths.relative(selection.root.path, selection.parent.path).split(paths.sep).filter(Boolean);
|
|
460
|
+
for (let length = 0; length <= parts.length; length++) {
|
|
461
|
+
const directory = await selection.root.openDirectory(parts.slice(0, length));
|
|
462
|
+
try {
|
|
463
|
+
if (sameObject(await directory.stat(), observation)) return true;
|
|
464
|
+
} finally {
|
|
465
|
+
await directory.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
469
|
+
};
|
|
470
|
+
if (observed.kind === "directory" && await ancestorIncludes(destination, observed) || destination.observation?.kind === "directory" && await ancestorIncludes(source, destination.observation)) throw new FileAccessError("path-overlap", 409);
|
|
471
|
+
}
|
|
472
|
+
/** Requires the source revision and explicit, current replacement observation. */
|
|
473
|
+
function observations(scope, source, expected, destination, replace = false, targetFingerprint) {
|
|
474
|
+
if (!source.observation) throw new FileAccessError("missing", 404);
|
|
475
|
+
if (scope.entry(source).fingerprint !== expected) throw new FileAccessError("stale", 409);
|
|
476
|
+
if (!destination) return void 0;
|
|
477
|
+
if (replace) {
|
|
478
|
+
if (!targetFingerprint || !destination.observation || scope.entry(destination).fingerprint !== targetFingerprint) throw new FileAccessError("stale", 409, destination.observation ? { existing: scope.entry(destination) } : void 0);
|
|
479
|
+
return destination.observation;
|
|
480
|
+
}
|
|
481
|
+
if (destination.observation) throw new FileAccessError("exists", 409, { existing: scope.entry(destination) });
|
|
482
|
+
}
|
|
483
|
+
/** Basic observed mutations; recursive and cross-device behavior compose through the same lock set. */
|
|
484
|
+
var Mutations = class {
|
|
485
|
+
scope;
|
|
486
|
+
records;
|
|
487
|
+
locks;
|
|
488
|
+
trees;
|
|
489
|
+
/** Shares owner-local records and fixed-order locks with uploads. */
|
|
490
|
+
constructor(scope, records, locks, trees) {
|
|
491
|
+
this.scope = scope;
|
|
492
|
+
this.records = records;
|
|
493
|
+
this.locks = locks;
|
|
494
|
+
this.trees = trees;
|
|
495
|
+
}
|
|
496
|
+
/** Creates one exclusive directory and records the actual commit before cleanup. */
|
|
497
|
+
mkdir(params, signal) {
|
|
498
|
+
return this.records.run(params.operationId, {
|
|
499
|
+
method: "mkdir",
|
|
500
|
+
...params
|
|
501
|
+
}, (commit) => withSelections(this.scope, this.locks, [params.target], signal, async ([target]) => {
|
|
502
|
+
const selected = target;
|
|
503
|
+
if (selected.observation) throw new FileAccessError("exists", 409, { existing: this.scope.entry(selected) });
|
|
504
|
+
await selected.current();
|
|
505
|
+
this.scope.mutation(selected);
|
|
506
|
+
const observed = await selected.parent.mkdir(selected.name);
|
|
507
|
+
const entry = this.scope.entry(selected, observed);
|
|
508
|
+
commit(entry);
|
|
509
|
+
return entry;
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
/** Renames within one root, refusing stale source/target or physical containment. */
|
|
513
|
+
rename(params, signal) {
|
|
514
|
+
const segments = params.source.path.split("/");
|
|
515
|
+
segments.pop();
|
|
516
|
+
const destination = {
|
|
517
|
+
...params.source,
|
|
518
|
+
path: [...segments, params.name].join("/")
|
|
519
|
+
};
|
|
520
|
+
return this.records.run(params.operationId, {
|
|
521
|
+
method: "rename",
|
|
522
|
+
...params,
|
|
523
|
+
replace: params.replace ?? false
|
|
524
|
+
}, (commit) => withSelections(this.scope, this.locks, [params.source, destination], signal, async ([from, to]) => {
|
|
525
|
+
await rejectOverlap(from, to, this.scope.paths);
|
|
526
|
+
const replacement = observations(this.scope, from, params.sourceFingerprint, to, params.replace, params.targetFingerprint);
|
|
527
|
+
const tree = await this.trees.scan(from);
|
|
528
|
+
await from.current();
|
|
529
|
+
await to.current();
|
|
530
|
+
this.scope.mutation(from);
|
|
531
|
+
this.scope.mutation(to);
|
|
532
|
+
await this.trees.verify(from, tree);
|
|
533
|
+
await from.parent.rename(from.name, from.observation, to.parent, to.name, replacement);
|
|
534
|
+
commit();
|
|
535
|
+
const entry = await this.scope.freshEntry(to.target, to.signal);
|
|
536
|
+
commit(entry);
|
|
537
|
+
return entry;
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
/** Removes exactly one selected object unless explicit recursive behavior is requested. */
|
|
541
|
+
remove(params, signal) {
|
|
542
|
+
return this.records.run(params.operationId, {
|
|
543
|
+
method: "remove",
|
|
544
|
+
...params
|
|
545
|
+
}, (commit) => withSelections(this.scope, this.locks, [params.target], signal, async ([target]) => {
|
|
546
|
+
const selected = target;
|
|
547
|
+
observations(this.scope, selected, params.fingerprint);
|
|
548
|
+
if (params.recursive && selected.observation?.kind === "directory") {
|
|
549
|
+
const records = await this.trees.scan(selected);
|
|
550
|
+
await this.trees.remove(selected, records);
|
|
551
|
+
commit();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await selected.current();
|
|
555
|
+
this.scope.mutation(selected);
|
|
556
|
+
await selected.parent.remove(selected.name, selected.observation);
|
|
557
|
+
commit();
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/moves.ts
|
|
563
|
+
/** Moves within one workspace, preferring anchored rename and explicitly handling EXDEV. */
|
|
564
|
+
var Moves = class {
|
|
565
|
+
scope;
|
|
566
|
+
records;
|
|
567
|
+
locks;
|
|
568
|
+
trees;
|
|
569
|
+
/** Shares operation records and ordered locks with all other writes. */
|
|
570
|
+
constructor(scope, records, locks, trees) {
|
|
571
|
+
this.scope = scope;
|
|
572
|
+
this.records = records;
|
|
573
|
+
this.locks = locks;
|
|
574
|
+
this.trees = trees;
|
|
575
|
+
}
|
|
576
|
+
/** Rejects overlap and stale selections before changing either namespace. */
|
|
577
|
+
move(params, signal) {
|
|
578
|
+
return this.records.run(params.operationId, {
|
|
579
|
+
method: "move",
|
|
580
|
+
...params,
|
|
581
|
+
replace: params.replace ?? false
|
|
582
|
+
}, (commit) => withSelections(this.scope, this.locks, [params.source, params.destination], signal, async ([source, destination]) => {
|
|
583
|
+
if (params.source.workspaceId !== params.destination.workspaceId) throw new FileAccessError("outside-root", 403);
|
|
584
|
+
const from = source, to = destination;
|
|
585
|
+
await rejectOverlap(from, to, this.scope.paths);
|
|
586
|
+
const replacement = observations(this.scope, from, params.sourceFingerprint, to, params.replace, params.targetFingerprint);
|
|
587
|
+
const copied = await this.trees.scan(from);
|
|
588
|
+
await from.current();
|
|
589
|
+
await to.current();
|
|
590
|
+
this.scope.mutation(from);
|
|
591
|
+
this.scope.mutation(to);
|
|
592
|
+
await this.trees.verify(from, copied);
|
|
593
|
+
try {
|
|
594
|
+
await from.parent.rename(from.name, from.observation, to.parent, to.name, replacement);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (accessError(error).code !== "cross-device") throw error;
|
|
597
|
+
let destinationCommitted = false;
|
|
598
|
+
let entry;
|
|
599
|
+
try {
|
|
600
|
+
await this.copy(from, to, copied, replacement, () => {
|
|
601
|
+
destinationCommitted = true;
|
|
602
|
+
});
|
|
603
|
+
entry = await this.scope.freshEntry(to.target, to.signal);
|
|
604
|
+
await from.current();
|
|
605
|
+
await to.current();
|
|
606
|
+
await this.trees.remove(from, copied);
|
|
607
|
+
} catch (failure) {
|
|
608
|
+
if (!destinationCommitted) throw failure;
|
|
609
|
+
let retained = true;
|
|
610
|
+
try {
|
|
611
|
+
await from.parent.stat(from.name);
|
|
612
|
+
} catch (failure) {
|
|
613
|
+
if (accessError(failure).code === "missing") retained = false;
|
|
614
|
+
}
|
|
615
|
+
throw new FileAccessError("move-incomplete", 500, {
|
|
616
|
+
destinationCommitted: true,
|
|
617
|
+
sourceRetained: retained,
|
|
618
|
+
retainedPaths: retained ? [from.target.path] : []
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
commit(entry);
|
|
622
|
+
return entry;
|
|
623
|
+
}
|
|
624
|
+
commit();
|
|
625
|
+
const entry = await this.scope.freshEntry(to.target, to.signal);
|
|
626
|
+
commit(entry);
|
|
627
|
+
return entry;
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
async copyFile(from, parts, observation, stage) {
|
|
631
|
+
const reader = await from.root.openRead([...from.target.path.split("/"), ...parts]);
|
|
632
|
+
try {
|
|
633
|
+
if (this.scope.fingerprint(await reader.stat()) !== this.scope.fingerprint(observation)) throw new FileAccessError("stale", 409);
|
|
634
|
+
const size = Number(observation.size);
|
|
635
|
+
if (!Number.isSafeInteger(size)) throw new FileAccessError("too-large", 413);
|
|
636
|
+
for (let position = 0; position < size;) {
|
|
637
|
+
await from.current();
|
|
638
|
+
const bytes = await reader.read(position, Math.min(65536, size - position));
|
|
639
|
+
if (!bytes.byteLength) throw new FileAccessError("stale", 409);
|
|
640
|
+
await stage.write(bytes);
|
|
641
|
+
position += bytes.byteLength;
|
|
642
|
+
}
|
|
643
|
+
if (this.scope.fingerprint(await reader.stat()) !== this.scope.fingerprint(observation)) throw new FileAccessError("stale", 409);
|
|
644
|
+
if (observation.mode !== void 0) await stage.setMode(observation.mode);
|
|
645
|
+
await stage.sync();
|
|
646
|
+
} finally {
|
|
647
|
+
await reader.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async copiedLink(from, parent, parts) {
|
|
651
|
+
const sourceParent = await from.root.openDirectory([...from.target.path.split("/"), ...parts.slice(0, -1)]);
|
|
652
|
+
const name = parts.at(-1);
|
|
653
|
+
try {
|
|
654
|
+
const link = await sourceParent.readLink(name);
|
|
655
|
+
await parent.symlink(name, link);
|
|
656
|
+
} finally {
|
|
657
|
+
await sourceParent.close();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
async copy(from, to, records, replacement, onPublished) {
|
|
661
|
+
const kind = from.observation?.kind;
|
|
662
|
+
if (kind === "other") throw new FileAccessError("unsupported-type", 415);
|
|
663
|
+
if (kind === "file") {
|
|
664
|
+
const stage = await to.parent.stage();
|
|
665
|
+
try {
|
|
666
|
+
await this.copyFile(from, [], from.observation, stage);
|
|
667
|
+
await this.trees.verify(from, records);
|
|
668
|
+
await from.current();
|
|
669
|
+
await to.current();
|
|
670
|
+
this.scope.mutation(from);
|
|
671
|
+
this.scope.mutation(to);
|
|
672
|
+
const observation = await stage.publish(to.name, replacement);
|
|
673
|
+
onPublished();
|
|
674
|
+
return observation;
|
|
675
|
+
} finally {
|
|
676
|
+
if (stage.published) onPublished();
|
|
677
|
+
await stage.close();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (kind === "symlink") {
|
|
681
|
+
const link = await from.parent.readLink(from.name);
|
|
682
|
+
const stage = await to.parent.stageSymlink(link);
|
|
683
|
+
try {
|
|
684
|
+
const linked = await this.scope.select({
|
|
685
|
+
...to.target,
|
|
686
|
+
path: toWirePath(to.root.path, stage.path, this.scope.paths)
|
|
687
|
+
}, to.signal);
|
|
688
|
+
try {
|
|
689
|
+
this.scope.mutation(linked);
|
|
690
|
+
} finally {
|
|
691
|
+
await linked.close();
|
|
692
|
+
}
|
|
693
|
+
await this.trees.verify(from, records);
|
|
694
|
+
await from.current();
|
|
695
|
+
await to.current();
|
|
696
|
+
this.scope.mutation(from);
|
|
697
|
+
this.scope.mutation(to);
|
|
698
|
+
const observation = await stage.publish(to.name, replacement);
|
|
699
|
+
onPublished();
|
|
700
|
+
return observation;
|
|
701
|
+
} finally {
|
|
702
|
+
if (stage.published) onPublished();
|
|
703
|
+
await stage.close();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const stage = await to.parent.stageDirectory();
|
|
707
|
+
try {
|
|
708
|
+
for (const record of records.filter((item) => item.parts.length && item.observation.kind === "directory")) {
|
|
709
|
+
const parent = await stage.directory.openDirectory(record.parts.slice(0, -1));
|
|
710
|
+
try {
|
|
711
|
+
await parent.mkdir(record.parts.at(-1));
|
|
712
|
+
} finally {
|
|
713
|
+
await parent.close();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const nonDirectories = records.filter((item) => item.observation.kind !== "directory").sort((a, b) => Number(a.observation.kind === "symlink") - Number(b.observation.kind === "symlink"));
|
|
717
|
+
for (const record of nonDirectories) {
|
|
718
|
+
await from.current();
|
|
719
|
+
await to.current();
|
|
720
|
+
const parent = await stage.directory.openDirectory(record.parts.slice(0, -1));
|
|
721
|
+
try {
|
|
722
|
+
if (record.observation.kind === "symlink") await this.copiedLink(from, parent, record.parts);
|
|
723
|
+
else if (record.observation.kind === "file") {
|
|
724
|
+
const file = await parent.stage();
|
|
725
|
+
try {
|
|
726
|
+
await this.copyFile(from, record.parts, record.observation, file);
|
|
727
|
+
await file.publish(record.parts.at(-1));
|
|
728
|
+
} finally {
|
|
729
|
+
await file.close();
|
|
730
|
+
}
|
|
731
|
+
} else throw new FileAccessError("unsupported-type", 415);
|
|
732
|
+
} finally {
|
|
733
|
+
await parent.close();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
for (const record of records.filter((item) => item.observation.kind === "symlink")) {
|
|
737
|
+
const linked = await this.scope.select({
|
|
738
|
+
...to.target,
|
|
739
|
+
path: toWirePath(to.root.path, this.scope.paths.join(stage.directory.path, ...record.parts), this.scope.paths)
|
|
740
|
+
}, to.signal);
|
|
741
|
+
try {
|
|
742
|
+
this.scope.mutation(linked);
|
|
743
|
+
} finally {
|
|
744
|
+
await linked.close();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
await this.trees.verify(from, records);
|
|
748
|
+
await from.current();
|
|
749
|
+
await to.current();
|
|
750
|
+
this.scope.mutation(from);
|
|
751
|
+
this.scope.mutation(to);
|
|
752
|
+
for (const record of records.filter((item) => item.observation.kind === "directory").sort((a, b) => b.parts.length - a.parts.length)) if (record.observation.mode !== void 0) await stage.setDirectoryMode(record.parts, record.observation.mode);
|
|
753
|
+
await from.current();
|
|
754
|
+
await to.current();
|
|
755
|
+
this.scope.mutation(from);
|
|
756
|
+
this.scope.mutation(to);
|
|
757
|
+
const observation = await stage.publish(to.name, replacement);
|
|
758
|
+
onPublished();
|
|
759
|
+
return observation;
|
|
760
|
+
} finally {
|
|
761
|
+
if (stage.published) onPublished();
|
|
762
|
+
await stage.close();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/operation-records.ts
|
|
768
|
+
/** Short-lived facts and in-flight deduplication; eviction never implies replay safety. */
|
|
769
|
+
/** Canonical JSON makes equivalent property ordering share one operation key. */
|
|
770
|
+
function canonicalRequest(value) {
|
|
771
|
+
if (Array.isArray(value)) return `[${value.map(canonicalRequest).join(",")}]`;
|
|
772
|
+
if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalRequest(item)}`).join(",")}}`;
|
|
773
|
+
return JSON.stringify(value);
|
|
774
|
+
}
|
|
775
|
+
/** Retains at most 1000 records, with ten-minute settled facts and no eviction of active work. */
|
|
776
|
+
var OperationRecords = class {
|
|
777
|
+
instanceId;
|
|
778
|
+
now;
|
|
779
|
+
records = /* @__PURE__ */ new Map();
|
|
780
|
+
/** Uses actual Gateway startup identity and a clock shared with snapshots. */
|
|
781
|
+
constructor(instanceId, now) {
|
|
782
|
+
this.instanceId = instanceId;
|
|
783
|
+
this.now = now;
|
|
784
|
+
}
|
|
785
|
+
expire() {
|
|
786
|
+
for (const [id, record] of this.records) if (!record.pending && record.settledAt !== void 0 && this.now() - record.settledAt >= 6e5) this.records.delete(id);
|
|
787
|
+
}
|
|
788
|
+
/** Queries an existing fact only; cross-startup, expired and evicted records return unknown. */
|
|
789
|
+
status(instanceId, operationId) {
|
|
790
|
+
this.expire();
|
|
791
|
+
return instanceId === this.instanceId ? this.records.get(operationId)?.result ?? {
|
|
792
|
+
operationId,
|
|
793
|
+
instanceId,
|
|
794
|
+
status: "unknown"
|
|
795
|
+
} : {
|
|
796
|
+
operationId,
|
|
797
|
+
instanceId,
|
|
798
|
+
status: "unknown"
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** Executes once while retained; an explicit commit records a fact before asynchronous release. */
|
|
802
|
+
run(operationId, request, work) {
|
|
803
|
+
this.expire();
|
|
804
|
+
const key = canonicalRequest(request);
|
|
805
|
+
const existing = this.records.get(operationId);
|
|
806
|
+
if (existing) {
|
|
807
|
+
if (existing.key !== key) throw new FileAccessError("operation-conflict", 409);
|
|
808
|
+
return existing.promise;
|
|
809
|
+
}
|
|
810
|
+
if (this.records.size >= 1e3) {
|
|
811
|
+
const settled = [...this.records.entries()].filter(([, record]) => !record.pending).sort(([, a], [, b]) => (a.settledAt ?? 0) - (b.settledAt ?? 0))[0];
|
|
812
|
+
if (!settled) throw new FileAccessError("capacity", 429);
|
|
813
|
+
this.records.delete(settled[0]);
|
|
814
|
+
}
|
|
815
|
+
const record = {
|
|
816
|
+
key,
|
|
817
|
+
result: {
|
|
818
|
+
operationId,
|
|
819
|
+
instanceId: this.instanceId,
|
|
820
|
+
status: "pending"
|
|
821
|
+
},
|
|
822
|
+
pending: true,
|
|
823
|
+
promise: Promise.resolve({
|
|
824
|
+
operationId,
|
|
825
|
+
instanceId: this.instanceId,
|
|
826
|
+
status: "pending"
|
|
827
|
+
})
|
|
828
|
+
};
|
|
829
|
+
this.records.set(operationId, record);
|
|
830
|
+
const commit = (entry) => {
|
|
831
|
+
record.result = {
|
|
832
|
+
operationId,
|
|
833
|
+
instanceId: this.instanceId,
|
|
834
|
+
status: "succeeded",
|
|
835
|
+
...entry ? { entry } : {}
|
|
836
|
+
};
|
|
837
|
+
};
|
|
838
|
+
record.promise = Promise.resolve().then(async () => {
|
|
839
|
+
try {
|
|
840
|
+
const entry = await work(commit);
|
|
841
|
+
if (record.result.status === "pending") commit(entry);
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (record.result.status !== "succeeded") {
|
|
844
|
+
const failure = accessError(error);
|
|
845
|
+
const wire = AccessErrorSchema.parse({
|
|
846
|
+
code: failure.code,
|
|
847
|
+
message: failure.message,
|
|
848
|
+
...failure.details ? { details: failure.details } : {}
|
|
849
|
+
});
|
|
850
|
+
record.result = {
|
|
851
|
+
operationId,
|
|
852
|
+
instanceId: this.instanceId,
|
|
853
|
+
status: "failed",
|
|
854
|
+
error: wire
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
} finally {
|
|
858
|
+
record.pending = false;
|
|
859
|
+
record.settledAt = this.now();
|
|
860
|
+
}
|
|
861
|
+
return record.result;
|
|
862
|
+
});
|
|
863
|
+
return record.promise;
|
|
864
|
+
}
|
|
865
|
+
/** Waits for active work; cancellation is supplied by the owning provider. */
|
|
866
|
+
async drain() {
|
|
867
|
+
await Promise.allSettled([...this.records.values()].filter((record) => record.pending).map((record) => record.promise));
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/transfers.ts
|
|
872
|
+
async function nextChunk(iterator, signal) {
|
|
873
|
+
if (signal.aborted) throw new FileAccessError("unavailable", 503);
|
|
874
|
+
return new Promise((resolve, reject) => {
|
|
875
|
+
const aborted = () => {
|
|
876
|
+
signal.removeEventListener("abort", aborted);
|
|
877
|
+
reject(new FileAccessError("unavailable", 503));
|
|
878
|
+
};
|
|
879
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
880
|
+
Promise.resolve().then(() => iterator.next()).then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted)).catch(() => {});
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
/** Resolves one inclusive range from the opened object's actual safe-integer length. */
|
|
884
|
+
function byteRange(total, request) {
|
|
885
|
+
if (!request) return void 0;
|
|
886
|
+
const start = request.kind === "suffix" ? Math.max(0, total - request.length) : request.start;
|
|
887
|
+
const end = request.kind === "suffix" ? total - 1 : Math.min(request.end ?? total - 1, total - 1);
|
|
888
|
+
if (total === 0 || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= total || end < start || request.kind === "suffix" && (!Number.isSafeInteger(request.length) || request.length <= 0)) throw new FileAccessError("invalid-range", 416, { contentLength: total });
|
|
889
|
+
return {
|
|
890
|
+
start,
|
|
891
|
+
end,
|
|
892
|
+
total
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/** Reads the current selected regular file without buffering the full response. */
|
|
896
|
+
async function readFileResource(scope, params, call) {
|
|
897
|
+
const selection = await scope.select(params.target, call.signal);
|
|
898
|
+
try {
|
|
899
|
+
const entry = scope.entry(selection);
|
|
900
|
+
if (params.expectedFingerprint && params.expectedFingerprint !== entry.fingerprint) throw new FileAccessError("stale", 409);
|
|
901
|
+
const reader = await selection.root.openRead(params.target.path.split("/").filter(Boolean));
|
|
902
|
+
const observation = await reader.stat();
|
|
903
|
+
if (scope.fingerprint(observation) !== scope.fingerprint(selection.referentObservation ?? selection.observation) || scope.fingerprint(await selection.parent.stat(selection.name)) !== scope.fingerprint(selection.observation)) throw new FileAccessError("stale", 409);
|
|
904
|
+
const total = Number(observation.size);
|
|
905
|
+
if (!Number.isSafeInteger(total)) throw new FileAccessError("too-large", 413);
|
|
906
|
+
const range = call.method === "GET" ? byteRange(total, call.range) : void 0;
|
|
907
|
+
const bytes = {
|
|
908
|
+
contentType: "application/octet-stream",
|
|
909
|
+
contentLength: range ? range.end - range.start + 1 : total,
|
|
910
|
+
contentDisposition: {
|
|
911
|
+
filename: entry.name,
|
|
912
|
+
disposition: "attachment"
|
|
913
|
+
},
|
|
914
|
+
range
|
|
915
|
+
};
|
|
916
|
+
let closed = false;
|
|
917
|
+
const close = async () => {
|
|
918
|
+
if (!closed) {
|
|
919
|
+
closed = true;
|
|
920
|
+
await selection.close();
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
if (call.method === "HEAD") {
|
|
924
|
+
await close();
|
|
925
|
+
return {
|
|
926
|
+
kind: "head",
|
|
927
|
+
bytes,
|
|
928
|
+
metadata: entry
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
kind: "bytes",
|
|
933
|
+
bytes,
|
|
934
|
+
metadata: entry,
|
|
935
|
+
body: (async function* () {
|
|
936
|
+
try {
|
|
937
|
+
let position = range?.start ?? 0;
|
|
938
|
+
const end = range ? range.end + 1 : total;
|
|
939
|
+
while (position < end) {
|
|
940
|
+
if (closed) throw new FileAccessError("unavailable", 503);
|
|
941
|
+
await selection.current();
|
|
942
|
+
const chunk = await reader.read(position, Math.min(65536, end - position));
|
|
943
|
+
await selection.current();
|
|
944
|
+
if (chunk.byteLength === 0) throw new FileAccessError("stale", 409);
|
|
945
|
+
position += chunk.byteLength;
|
|
946
|
+
yield chunk;
|
|
947
|
+
}
|
|
948
|
+
} catch (error) {
|
|
949
|
+
throw accessError(error);
|
|
950
|
+
} finally {
|
|
951
|
+
await close();
|
|
952
|
+
}
|
|
953
|
+
})(),
|
|
954
|
+
close
|
|
955
|
+
};
|
|
956
|
+
} catch (error) {
|
|
957
|
+
await selection.close();
|
|
958
|
+
throw accessError(error);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/** Streams a bounded upload into same-directory staging, recording publication before release. */
|
|
962
|
+
async function uploadResource(scope, records, locks, params, call) {
|
|
963
|
+
if (!params.operationId || !params.source) throw new FileAccessError("invalid-params", 400);
|
|
964
|
+
const source = params.source;
|
|
965
|
+
const result = await records.run(params.operationId, {
|
|
966
|
+
method: "upload",
|
|
967
|
+
...params,
|
|
968
|
+
replace: params.replace ?? false
|
|
969
|
+
}, async (commit) => {
|
|
970
|
+
if (source.size > 268435456) throw new FileAccessError("too-large", 413);
|
|
971
|
+
const preliminary = await scope.select(params.target, call.signal, true);
|
|
972
|
+
const selectedPath = preliminary.path;
|
|
973
|
+
let release;
|
|
974
|
+
try {
|
|
975
|
+
try {
|
|
976
|
+
scope.mutation(preliminary);
|
|
977
|
+
release = await locks.acquire([preliminary.path], preliminary.signal);
|
|
978
|
+
} finally {
|
|
979
|
+
await preliminary.close();
|
|
980
|
+
}
|
|
981
|
+
const selection = await scope.select(params.target, call.signal, true);
|
|
982
|
+
try {
|
|
983
|
+
if (selection.path !== selectedPath) throw new FileAccessError("stale", 409);
|
|
984
|
+
scope.mutation(selection);
|
|
985
|
+
const existing = selection.observation;
|
|
986
|
+
if (params.replace) {
|
|
987
|
+
if (!params.targetFingerprint || !existing || scope.entry(selection).fingerprint !== params.targetFingerprint) throw new FileAccessError("stale", 409, existing ? { existing: scope.entry(selection) } : void 0);
|
|
988
|
+
} else if (existing) throw new FileAccessError("exists", 409, { existing: scope.entry(selection) });
|
|
989
|
+
const stage = await selection.parent.stage();
|
|
990
|
+
let publishedEntry;
|
|
991
|
+
const iterator = call.body[Symbol.asyncIterator]();
|
|
992
|
+
try {
|
|
993
|
+
let total = 0;
|
|
994
|
+
for (;;) {
|
|
995
|
+
const next = await nextChunk(iterator, selection.signal);
|
|
996
|
+
if (next.done) break;
|
|
997
|
+
const chunk = next.value;
|
|
998
|
+
await selection.current();
|
|
999
|
+
total += chunk.byteLength;
|
|
1000
|
+
if (total > 268435456) throw new FileAccessError("too-large", 413);
|
|
1001
|
+
if (total > source.size) throw new FileAccessError("size-mismatch", 400);
|
|
1002
|
+
for (let offset = 0; offset < chunk.byteLength; offset += 65536) {
|
|
1003
|
+
await stage.write(chunk.subarray(offset, offset + 65536));
|
|
1004
|
+
await selection.current();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (total !== source.size) throw new FileAccessError("size-mismatch", 400);
|
|
1008
|
+
if (existing?.mode !== void 0) await stage.setMode(existing.mode);
|
|
1009
|
+
await stage.sync();
|
|
1010
|
+
await selection.current();
|
|
1011
|
+
scope.mutation(selection);
|
|
1012
|
+
const observation = await stage.publish(selection.name, params.replace ? existing : void 0);
|
|
1013
|
+
const entry = scope.entry(selection, observation);
|
|
1014
|
+
publishedEntry = entry;
|
|
1015
|
+
commit(entry);
|
|
1016
|
+
return entry;
|
|
1017
|
+
} finally {
|
|
1018
|
+
if (stage.published) commit(publishedEntry);
|
|
1019
|
+
try {
|
|
1020
|
+
const returned = iterator.return?.();
|
|
1021
|
+
if (selection.signal.aborted) Promise.resolve(returned).catch(() => {});
|
|
1022
|
+
else await returned;
|
|
1023
|
+
} finally {
|
|
1024
|
+
await stage.close();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
} finally {
|
|
1028
|
+
await selection.close();
|
|
1029
|
+
}
|
|
1030
|
+
} finally {
|
|
1031
|
+
release?.();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
return {
|
|
1035
|
+
kind: "json",
|
|
1036
|
+
result,
|
|
1037
|
+
...result.entry ? { metadata: result.entry } : {}
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/trees.ts
|
|
1042
|
+
/** Records all selected objects before side effects, then checks each pending deletion separately. */
|
|
1043
|
+
var Trees = class {
|
|
1044
|
+
scope;
|
|
1045
|
+
budget;
|
|
1046
|
+
/** Test budgets may be smaller; production cannot exceed the declared maximum. */
|
|
1047
|
+
constructor(scope, budget = {
|
|
1048
|
+
maxObjects: 1e4,
|
|
1049
|
+
maxDepth: 256
|
|
1050
|
+
}) {
|
|
1051
|
+
if (!Number.isInteger(budget.maxObjects) || budget.maxObjects < 1 || budget.maxObjects > 1e4 || !Number.isInteger(budget.maxDepth) || budget.maxDepth < 0 || budget.maxDepth > 256) throw new Error("Invalid recursive tree budget");
|
|
1052
|
+
this.scope = scope;
|
|
1053
|
+
this.budget = budget;
|
|
1054
|
+
}
|
|
1055
|
+
/** Scans an entire tree before staging/deletion, refusing excess, protected entries or changes. */
|
|
1056
|
+
async scan(selection) {
|
|
1057
|
+
const result = [];
|
|
1058
|
+
const visit = async (parent, name, observation, parts) => {
|
|
1059
|
+
if (!SegmentSchema.safeParse(name).success) throw new FileAccessError("unsupported-name", 415);
|
|
1060
|
+
if (parts.length > this.budget.maxDepth || result.length >= this.budget.maxObjects) throw new FileAccessError("too-large-directory", 413);
|
|
1061
|
+
await selection.current();
|
|
1062
|
+
const path = [selection.target.path, ...parts].filter(Boolean).join("/");
|
|
1063
|
+
if (observation.kind === "symlink") {
|
|
1064
|
+
const linked = await this.scope.select({
|
|
1065
|
+
...selection.target,
|
|
1066
|
+
path
|
|
1067
|
+
}, selection.signal);
|
|
1068
|
+
try {
|
|
1069
|
+
this.scope.mutation(linked);
|
|
1070
|
+
} finally {
|
|
1071
|
+
await linked.close();
|
|
1072
|
+
}
|
|
1073
|
+
} else this.scope.mutation({
|
|
1074
|
+
...selection,
|
|
1075
|
+
name,
|
|
1076
|
+
path: this.scope.paths.join(parent.path, name),
|
|
1077
|
+
effectivePath: this.scope.paths.join(parent.path, name),
|
|
1078
|
+
target: {
|
|
1079
|
+
...selection.target,
|
|
1080
|
+
path
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
if (observation.kind !== "directory") {
|
|
1084
|
+
result.push({
|
|
1085
|
+
parts,
|
|
1086
|
+
observation
|
|
1087
|
+
});
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const directory = await parent.openDirectory([name]);
|
|
1091
|
+
try {
|
|
1092
|
+
if (this.scope.fingerprint(await directory.stat()) !== this.scope.fingerprint(observation)) throw new FileAccessError("stale", 409);
|
|
1093
|
+
const entries = [...await directory.list(1e4, { strict: true })].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1094
|
+
result.push({
|
|
1095
|
+
parts,
|
|
1096
|
+
observation,
|
|
1097
|
+
children: entries.map((entry) => entry.name)
|
|
1098
|
+
});
|
|
1099
|
+
for (const entry of entries) await visit(directory, entry.name, entry.observation, [...parts, entry.name]);
|
|
1100
|
+
if (this.scope.fingerprint(await directory.stat()) !== this.scope.fingerprint(observation)) throw new FileAccessError("stale", 409);
|
|
1101
|
+
} finally {
|
|
1102
|
+
await directory.close();
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
if (!selection.observation) throw new FileAccessError("missing", 404);
|
|
1106
|
+
await visit(selection.parent, selection.name, selection.observation, []);
|
|
1107
|
+
return result;
|
|
1108
|
+
}
|
|
1109
|
+
/** Rechecks every selected object and directory child set before publication or source cleanup. */
|
|
1110
|
+
async verify(selection, records) {
|
|
1111
|
+
const current = await this.scan(selection);
|
|
1112
|
+
if (current.length !== records.length || current.some((item, index) => item.parts.join("/") !== records[index]?.parts.join("/") || this.scope.fingerprint(item.observation) !== this.scope.fingerprint(records[index]?.observation))) throw new FileAccessError("stale", 409);
|
|
1113
|
+
}
|
|
1114
|
+
/** Deletes copied versions in postorder, stopping immediately on new or changed pending content. */
|
|
1115
|
+
async remove(selection, records) {
|
|
1116
|
+
await this.verify(selection, records);
|
|
1117
|
+
const byPath = new Map(records.map((record) => [record.parts.join("/"), record]));
|
|
1118
|
+
const visit = async (parent, name, record) => {
|
|
1119
|
+
await selection.current();
|
|
1120
|
+
this.scope.mutation(selection);
|
|
1121
|
+
if (this.scope.fingerprint(await parent.stat(name)) !== this.scope.fingerprint(record.observation)) throw new FileAccessError("stale", 409);
|
|
1122
|
+
if (record.observation.kind !== "directory") {
|
|
1123
|
+
await parent.remove(name, record.observation);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const directory = await parent.openDirectory([name]);
|
|
1127
|
+
try {
|
|
1128
|
+
let expected = record.observation;
|
|
1129
|
+
const remaining = [...record.children];
|
|
1130
|
+
for (const child of record.children) {
|
|
1131
|
+
if (this.scope.fingerprint(await directory.stat()) !== this.scope.fingerprint(expected)) throw new FileAccessError("stale", 409);
|
|
1132
|
+
const actual = (await directory.list(1e4, { strict: true })).map((entry) => entry.name).sort();
|
|
1133
|
+
if (JSON.stringify(actual) !== JSON.stringify(remaining)) throw new FileAccessError("stale", 409);
|
|
1134
|
+
await visit(directory, child, byPath.get([...record.parts, child].join("/")));
|
|
1135
|
+
remaining.splice(remaining.indexOf(child), 1);
|
|
1136
|
+
expected = await directory.stat();
|
|
1137
|
+
}
|
|
1138
|
+
if ((await directory.list(1e4, { strict: true })).length !== 0 || this.scope.fingerprint(await directory.stat()) !== this.scope.fingerprint(expected)) throw new FileAccessError("stale", 409);
|
|
1139
|
+
await selection.current();
|
|
1140
|
+
this.scope.mutation(selection);
|
|
1141
|
+
await parent.remove(name, expected);
|
|
1142
|
+
} finally {
|
|
1143
|
+
await directory.close();
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
await visit(selection.parent, selection.name, records[0]);
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
//#endregion
|
|
1150
|
+
//#region src/provider.ts
|
|
1151
|
+
/** Creates an owner-local provider with bounded transient state. */
|
|
1152
|
+
function createFileExplorer(dependencies) {
|
|
1153
|
+
const abort = new AbortController();
|
|
1154
|
+
const signal = AbortSignal.any([abort.signal, dependencies.signal]);
|
|
1155
|
+
const scope = new FileScope(dependencies.roots, dependencies.files, signal, dependencies.instanceId, void 0, dependencies.referenceFormatter);
|
|
1156
|
+
const listings = new Listings(scope, dependencies.now ?? Date.now);
|
|
1157
|
+
const records = new OperationRecords(dependencies.instanceId, dependencies.now ?? Date.now);
|
|
1158
|
+
const locks = new PathLocks();
|
|
1159
|
+
const trees = new Trees(scope, dependencies.treeBudget);
|
|
1160
|
+
const mutations = new Mutations(scope, records, locks, trees);
|
|
1161
|
+
const moves = new Moves(scope, records, locks, trees);
|
|
1162
|
+
const current = () => {
|
|
1163
|
+
if (signal.aborted) throw new FileAccessError("unavailable", 503);
|
|
1164
|
+
};
|
|
1165
|
+
const active = /* @__PURE__ */ new Set();
|
|
1166
|
+
let closing;
|
|
1167
|
+
const track = (work) => {
|
|
1168
|
+
try {
|
|
1169
|
+
current();
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
return Promise.reject(error);
|
|
1172
|
+
}
|
|
1173
|
+
const promise = Promise.resolve().then(() => {
|
|
1174
|
+
current();
|
|
1175
|
+
return work();
|
|
1176
|
+
});
|
|
1177
|
+
active.add(promise);
|
|
1178
|
+
promise.then(() => active.delete(promise), () => active.delete(promise));
|
|
1179
|
+
return promise;
|
|
1180
|
+
};
|
|
1181
|
+
return {
|
|
1182
|
+
/** Returns explicit root display metadata for the named workspace. */
|
|
1183
|
+
roots: (workspaceId, request) => track(async () => {
|
|
1184
|
+
return { roots: (await scope.snapshot(workspaceId, request)).roots };
|
|
1185
|
+
}),
|
|
1186
|
+
/** Observes one current object and its resolved host display path. */
|
|
1187
|
+
stat: (target, request) => track(async () => {
|
|
1188
|
+
const selection = await scope.select(target, request);
|
|
1189
|
+
try {
|
|
1190
|
+
return {
|
|
1191
|
+
entry: scope.entry(selection),
|
|
1192
|
+
realPath: selection.effectivePath
|
|
1193
|
+
};
|
|
1194
|
+
} finally {
|
|
1195
|
+
await selection.close();
|
|
1196
|
+
}
|
|
1197
|
+
}),
|
|
1198
|
+
/** Pages immutable bounded snapshots while rechecking current scope. */
|
|
1199
|
+
list: (params, request) => track(() => listings.list(params, request)),
|
|
1200
|
+
/** Streams current GET/HEAD bytes or a staged bounded PUT upload. */
|
|
1201
|
+
fileBytes: (params, call) => track(() => call.method === "PUT" ? uploadResource(scope, records, locks, params, call) : readFileResource(scope, params, call)),
|
|
1202
|
+
/** Queries retained execution facts without replaying any work. */
|
|
1203
|
+
operationStatus: (params) => {
|
|
1204
|
+
current();
|
|
1205
|
+
return records.status(params.instanceId, params.operationId);
|
|
1206
|
+
},
|
|
1207
|
+
/** Creates one directory exclusively with operation deduplication. */
|
|
1208
|
+
mkdir: (params, request) => track(() => mutations.mkdir(params, request)),
|
|
1209
|
+
/** Renames an observed object within its current root. */
|
|
1210
|
+
rename: (params, request) => track(() => mutations.rename(params, request)),
|
|
1211
|
+
/** Removes only the selected revision; recursive removal scans every descendant. */
|
|
1212
|
+
remove: (params, request) => track(() => mutations.remove(params, request)),
|
|
1213
|
+
/** Moves within one workspace, preserving committed-destination facts on cleanup failure. */
|
|
1214
|
+
move: (params, request) => track(() => moves.move(params, request)),
|
|
1215
|
+
/** Rejects new work and joins cleanup; failed physical cleanup remains retryable. */
|
|
1216
|
+
close() {
|
|
1217
|
+
closing ??= (async () => {
|
|
1218
|
+
abort.abort();
|
|
1219
|
+
listings.close();
|
|
1220
|
+
await Promise.allSettled([...active]);
|
|
1221
|
+
await records.drain();
|
|
1222
|
+
await dependencies.files.close();
|
|
1223
|
+
})().catch((error) => {
|
|
1224
|
+
closing = void 0;
|
|
1225
|
+
throw error;
|
|
1226
|
+
});
|
|
1227
|
+
return closing;
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/index.ts
|
|
1233
|
+
/** Workspace browsing provider registration; never contributes Agent tools or approvals. */
|
|
1234
|
+
/** Registers one activation's provider and awaited shutdown drain. */
|
|
1235
|
+
function registerFileExplorer(ctx) {
|
|
1236
|
+
const roots = ctx.services.get("workspace-roots");
|
|
1237
|
+
const identity = roots.availability();
|
|
1238
|
+
if (!identity.available) throw new FileAccessError("unavailable", 503);
|
|
1239
|
+
const provider = createFileExplorer({
|
|
1240
|
+
files: ctx.services.get("client-files"),
|
|
1241
|
+
referenceFormatter: ctx.services.get("file-reference-formatter"),
|
|
1242
|
+
roots,
|
|
1243
|
+
signal: ctx.signal,
|
|
1244
|
+
instanceId: identity.instanceId
|
|
1245
|
+
});
|
|
1246
|
+
const drain = () => provider.close();
|
|
1247
|
+
const unbind = ctx.services.get("work-lifecycle").register(drain);
|
|
1248
|
+
ctx.onDispose(async () => {
|
|
1249
|
+
await drain();
|
|
1250
|
+
unbind();
|
|
1251
|
+
});
|
|
1252
|
+
ctx.client.registerMethod({
|
|
1253
|
+
name: "roots",
|
|
1254
|
+
params: RootsParamsSchema,
|
|
1255
|
+
result: RootsResultSchema,
|
|
1256
|
+
handle: (params, call) => provider.roots(params.workspaceId, call.signal)
|
|
1257
|
+
});
|
|
1258
|
+
ctx.client.registerMethod({
|
|
1259
|
+
name: "list",
|
|
1260
|
+
params: ListParamsSchema,
|
|
1261
|
+
result: ListResultSchema,
|
|
1262
|
+
handle: (params, call) => provider.list(params, call.signal)
|
|
1263
|
+
});
|
|
1264
|
+
ctx.client.registerMethod({
|
|
1265
|
+
name: "stat",
|
|
1266
|
+
params: StatParamsSchema,
|
|
1267
|
+
result: StatResultSchema,
|
|
1268
|
+
handle: (params, call) => provider.stat(params.target, call.signal)
|
|
1269
|
+
});
|
|
1270
|
+
ctx.client.registerMethod({
|
|
1271
|
+
name: "mkdir",
|
|
1272
|
+
params: MkdirParamsSchema,
|
|
1273
|
+
result: OperationResultSchema,
|
|
1274
|
+
handle: (params, call) => provider.mkdir(params, call.signal)
|
|
1275
|
+
});
|
|
1276
|
+
ctx.client.registerMethod({
|
|
1277
|
+
name: "rename",
|
|
1278
|
+
params: RenameParamsSchema,
|
|
1279
|
+
result: OperationResultSchema,
|
|
1280
|
+
handle: (params, call) => provider.rename(params, call.signal)
|
|
1281
|
+
});
|
|
1282
|
+
ctx.client.registerMethod({
|
|
1283
|
+
name: "move",
|
|
1284
|
+
params: MoveParamsSchema,
|
|
1285
|
+
result: OperationResultSchema,
|
|
1286
|
+
handle: (params, call) => provider.move(params, call.signal)
|
|
1287
|
+
});
|
|
1288
|
+
ctx.client.registerMethod({
|
|
1289
|
+
name: "remove",
|
|
1290
|
+
params: RemoveParamsSchema,
|
|
1291
|
+
result: OperationResultSchema,
|
|
1292
|
+
handle: (params, call) => provider.remove(params, call.signal)
|
|
1293
|
+
});
|
|
1294
|
+
ctx.client.registerMethod({
|
|
1295
|
+
name: "operationStatus",
|
|
1296
|
+
params: OperationStatusParamsSchema,
|
|
1297
|
+
result: OperationResultSchema,
|
|
1298
|
+
handle: (params) => provider.operationStatus(params)
|
|
1299
|
+
});
|
|
1300
|
+
ctx.client.registerResource({
|
|
1301
|
+
name: "fileBytes",
|
|
1302
|
+
methods: [
|
|
1303
|
+
"GET",
|
|
1304
|
+
"HEAD",
|
|
1305
|
+
"PUT"
|
|
1306
|
+
],
|
|
1307
|
+
params: FileBytesParamsSchema,
|
|
1308
|
+
result: OperationResultSchema,
|
|
1309
|
+
metadata: FileEntrySchema.optional(),
|
|
1310
|
+
handle: (params, call) => provider.fileBytes(params, call)
|
|
1311
|
+
});
|
|
1312
|
+
const module = fileURLToPath(new URL("../ui/ui.js", import.meta.url));
|
|
1313
|
+
if (existsSync(module)) ctx.client.registerUi({ module });
|
|
1314
|
+
}
|
|
1315
|
+
const plugin = {
|
|
1316
|
+
name: "@eum-agent/file-explorer",
|
|
1317
|
+
dependencies: { "@eum-agent/file-references": "1.0.0" },
|
|
1318
|
+
requires: [
|
|
1319
|
+
"client-files",
|
|
1320
|
+
"workspace-roots",
|
|
1321
|
+
"work-lifecycle",
|
|
1322
|
+
"file-reference-formatter"
|
|
1323
|
+
],
|
|
1324
|
+
apply: registerFileExplorer
|
|
1325
|
+
};
|
|
1326
|
+
//#endregion
|
|
1327
|
+
export { plugin as default, registerFileExplorer };
|