@dbx-tools/appkit-mastra 0.6.8 → 0.6.10
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 +49 -2
- package/index.ts +14 -26
- package/lib/index.d.ts +14 -26
- package/lib/index.js +13 -26
- package/lib/src/config.d.ts +21 -0
- package/lib/src/config.js +5 -1
- package/lib/src/databricks-aitools.d.ts +73 -0
- package/lib/src/databricks-aitools.js +152 -0
- package/lib/src/filesystems.d.ts +52 -170
- package/lib/src/filesystems.js +168 -888
- package/lib/src/plugin.js +14 -2
- package/lib/src/remote-skills.d.ts +4 -4
- package/lib/src/remote-skills.js +63 -35
- package/lib/src/workspaces.d.ts +4 -0
- package/lib/src/workspaces.js +37 -13
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -10
- package/src/config.ts +27 -0
- package/src/databricks-aitools.ts +216 -0
- package/src/filesystems.ts +225 -970
- package/src/plugin.ts +14 -1
- package/src/remote-skills.ts +69 -38
- package/src/workspaces.ts +41 -16
package/src/filesystems.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mastra
|
|
2
|
+
* Mastra workspace filesystem adapters.
|
|
3
3
|
*
|
|
4
|
-
* {@link
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Path helpers ({@link normalizeDatabricksBasePath}, {@link isDbfsPath}, …)
|
|
10
|
-
* are exported for tests and callers that need to reason about Databricks
|
|
11
|
-
* paths without constructing a filesystem.
|
|
4
|
+
* {@link filesystems} wraps any portable `@dbx-tools/shared-fs` {@link FileSystem}
|
|
5
|
+
* (local disk, Databricks, memory, …) as a Mastra {@link MastraFilesystem}.
|
|
6
|
+
* {@link scratchFilesystem} always returns a fresh {@link localFS.tmpFS} mount
|
|
7
|
+
* (random id root) when Mastra needs a filesystem and no other mount resolved.
|
|
12
8
|
*
|
|
13
9
|
* @module
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
12
|
+
import { localFS } from "@dbx-tools/fs";
|
|
13
|
+
import { FileSystemError, posixPath } from "@dbx-tools/shared-fs";
|
|
14
|
+
import type {
|
|
15
|
+
FileEntry as SharedFileEntry,
|
|
16
|
+
FileStat as SharedFileStat,
|
|
17
|
+
FileSystem,
|
|
18
|
+
} from "@dbx-tools/shared-fs";
|
|
20
19
|
import {
|
|
21
20
|
DirectoryNotEmptyError,
|
|
22
21
|
DirectoryNotFoundError,
|
|
@@ -42,1063 +41,319 @@ import type {
|
|
|
42
41
|
WriteOptions,
|
|
43
42
|
} from "@mastra/core/workspace";
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const EMPTY_FILESYSTEM_EPOCH = new Date(0);
|
|
50
|
-
|
|
51
|
-
/** Mastra error constructor for a known SDK filesystem failure, if any. */
|
|
52
|
-
function filesystemSdkErrorType(err: unknown): (new (path: string) => Error) | undefined {
|
|
53
|
-
if (err) {
|
|
54
|
-
const ctx = error.errorContext(err);
|
|
55
|
-
if (ctx.notAccessible) {
|
|
56
|
-
return FileNotFoundError;
|
|
57
|
-
} else if (ctx.hasMessage("already", "exists")) {
|
|
58
|
-
return FileExistsError;
|
|
59
|
-
} else if (ctx.hasMessage("not", "directory")) {
|
|
60
|
-
return NotDirectoryError;
|
|
61
|
-
} else if (ctx.hasMessage("not", "file")) {
|
|
62
|
-
return IsDirectoryError;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const logger = log.logger("mastra/filesystems");
|
|
44
|
+
/** Options for {@link filesystems} / {@link MastraFileSystemAdapter}. */
|
|
45
|
+
export interface MastraFileSystemAdapterOptions extends MastraFilesystemOptions {
|
|
46
|
+
/** Override the Mastra filesystem id. Defaults to the source {@link FileSystem.id}. */
|
|
47
|
+
id?: string;
|
|
69
48
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
49
|
+
/** Override the Mastra display name. Defaults to `MastraFileSystemAdapter`. */
|
|
50
|
+
name?: string;
|
|
72
51
|
|
|
73
|
-
/** Options for {@link DatabricksWorkspaceFilesystem}. */
|
|
74
|
-
export interface DatabricksWorkspaceFilesystemOptions extends MastraFilesystemOptions {
|
|
75
|
-
/** Unique identifier for this filesystem instance. */
|
|
76
|
-
id?: string;
|
|
77
|
-
/** Auth-scoped Databricks workspace client. */
|
|
78
|
-
client?: WorkspaceClient;
|
|
79
52
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
53
|
+
* Override the Mastra provider id. Defaults to the source
|
|
54
|
+
* {@link FileSystem.backend}.
|
|
82
55
|
*/
|
|
83
|
-
|
|
56
|
+
provider?: string;
|
|
57
|
+
|
|
84
58
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* falls back to an empty read-only namespace on failure; `true` fails init;
|
|
88
|
-
* `false` skips creation and uses the empty namespace.
|
|
89
|
-
*
|
|
90
|
-
* A successful mkdir also satisfies the write-access probe when
|
|
91
|
-
* {@link readOnly} is omitted.
|
|
59
|
+
* Force read-only mounts even when the source filesystem allows writes.
|
|
60
|
+
* The source {@link FileSystem.readOnly} flag still applies either way.
|
|
92
61
|
*/
|
|
93
|
-
mkdirs?: DatabricksMkdirsMode;
|
|
94
|
-
/** Block writes while still allowing reads. When omitted, {@link init} probes write access. */
|
|
95
62
|
readOnly?: boolean;
|
|
96
63
|
}
|
|
97
64
|
|
|
98
|
-
/* ------------------------- exported path helpers ------------------------- */
|
|
99
|
-
|
|
100
|
-
/** Normalize a Databricks base path (POSIX, no trailing slash). */
|
|
101
|
-
export function normalizeDatabricksBasePath(basePath: string): string {
|
|
102
|
-
const trimmed = basePath.trim();
|
|
103
|
-
if (!trimmed.startsWith("/")) {
|
|
104
|
-
throw ValidationError.invalidValue(
|
|
105
|
-
"basePath",
|
|
106
|
-
basePath,
|
|
107
|
-
"an absolute Databricks path, e.g. /Volumes/catalog/schema/volume",
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
return trimmed.replace(/\/+$/, "") || "/";
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** True when `absolutePath` is served by DBFS rather than the UC Files API. */
|
|
114
|
-
export function isDbfsPath(absolutePath: string): boolean {
|
|
115
|
-
return absolutePath === "/dbfs" || absolutePath.startsWith("/dbfs/");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** True when `absolutePath` is a Databricks workspace object path. */
|
|
119
|
-
export function isWorkspaceFilesPath(absolutePath: string): boolean {
|
|
120
|
-
return (
|
|
121
|
-
absolutePath === "/Workspace" ||
|
|
122
|
-
absolutePath.startsWith("/Workspace/") ||
|
|
123
|
-
absolutePath.startsWith("/Users/") ||
|
|
124
|
-
absolutePath.startsWith("/Repos/")
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
type FilesBackend = "dbfs" | "workspace" | "uc-files";
|
|
129
|
-
|
|
130
|
-
/** Per-backend async handler used by {@link dispatchFilesBackend}. */
|
|
131
|
-
type FilesBackendHandlers<T = unknown> = {
|
|
132
|
-
dbfs: () => Promise<T>;
|
|
133
|
-
workspace: () => Promise<T>;
|
|
134
|
-
ucFiles: () => Promise<T>;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
65
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
66
|
+
* Wrap a portable {@link FileSystem} as a Mastra {@link MastraFilesystem}.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import { filesystems } from "@dbx-tools/appkit-mastra";
|
|
71
|
+
* import { DatabricksFileSystem } from "@dbx-tools/databricks";
|
|
72
|
+
* import { localFS } from "@dbx-tools/fs";
|
|
73
|
+
*
|
|
74
|
+
* const volume = filesystems.filesystems(
|
|
75
|
+
* new DatabricksFileSystem({ root: "/Volumes/main/default/assets" }),
|
|
76
|
+
* );
|
|
77
|
+
* const scratch = filesystems.filesystems(localFS.tmpFS("agent-job"));
|
|
78
|
+
* ```
|
|
140
79
|
*/
|
|
141
|
-
export function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (normalized === root || normalized.startsWith(`${root}/`)) {
|
|
147
|
-
return path.normalize(normalized);
|
|
148
|
-
}
|
|
149
|
-
return path.normalize(path.join(root, normalized));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Map an absolute Databricks path back to the workspace namespace. */
|
|
153
|
-
export function toDatabricksWorkspacePath(basePath: string, absolutePath: string): string {
|
|
154
|
-
const root = normalizeDatabricksBasePath(basePath);
|
|
155
|
-
const normalized = path.normalize(absolutePath);
|
|
156
|
-
if (normalized === root) return "/";
|
|
157
|
-
if (normalized.startsWith(`${root}/`)) {
|
|
158
|
-
return normalized.slice(root.length) || "/";
|
|
159
|
-
}
|
|
160
|
-
return normalized;
|
|
80
|
+
export function filesystems(
|
|
81
|
+
fs: FileSystem,
|
|
82
|
+
options: MastraFileSystemAdapterOptions = {},
|
|
83
|
+
): MastraFileSystemAdapter {
|
|
84
|
+
return new MastraFileSystemAdapter(fs, options);
|
|
161
85
|
}
|
|
162
86
|
|
|
163
|
-
/* ---------------------------- private helpers ---------------------------- */
|
|
164
|
-
|
|
165
|
-
/** Pick the Databricks Files API backend for an absolute path. */
|
|
166
|
-
function resolveFilesBackend(absolutePath: string): FilesBackend {
|
|
167
|
-
if (isDbfsPath(absolutePath)) return "dbfs";
|
|
168
|
-
if (isWorkspaceFilesPath(absolutePath)) return "workspace";
|
|
169
|
-
return "uc-files";
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Run the handler that matches `absolutePath`'s Databricks backend. */
|
|
173
|
-
async function dispatchFilesBackend<T>(
|
|
174
|
-
absolutePath: string,
|
|
175
|
-
handlers: FilesBackendHandlers<T>,
|
|
176
|
-
): Promise<T> {
|
|
177
|
-
const backend = resolveFilesBackend(absolutePath);
|
|
178
|
-
if (backend === "dbfs") return handlers.dbfs();
|
|
179
|
-
if (backend === "workspace") return handlers.workspace();
|
|
180
|
-
return handlers.ucFiles();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Return `buffer` as a string when `encoding` is set, otherwise unchanged. */
|
|
184
|
-
function formatReadResult(buffer: Buffer, encoding?: BufferEncoding): string | Buffer {
|
|
185
|
-
return encoding ? buffer.toString(encoding) : buffer;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Drain a fetch `ReadableStream` into a single `Buffer`. */
|
|
189
|
-
async function readResponseBody(
|
|
190
|
-
contents: globalThis.ReadableStream<Uint8Array> | undefined,
|
|
191
|
-
): Promise<Buffer> {
|
|
192
|
-
if (!contents) return Buffer.alloc(0);
|
|
193
|
-
const reader = contents.getReader();
|
|
194
|
-
const chunks: Uint8Array[] = [];
|
|
195
|
-
while (true) {
|
|
196
|
-
const { done, value } = await reader.read();
|
|
197
|
-
if (done) break;
|
|
198
|
-
if (value) chunks.push(value);
|
|
199
|
-
}
|
|
200
|
-
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Coerce Mastra {@link FileContent} to a `Buffer`. */
|
|
204
|
-
function toBuffer(content: FileContent): Buffer {
|
|
205
|
-
if (typeof content === "string") return Buffer.from(content, "utf8");
|
|
206
|
-
return Buffer.from(content);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Decode a DBFS read payload from base64. */
|
|
210
|
-
function decodeDbfsPayload(data: string | undefined): Buffer {
|
|
211
|
-
if (!data) return Buffer.alloc(0);
|
|
212
|
-
return Buffer.from(data, "base64");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Wrap a `Buffer` as a one-shot `ReadableStream` for UC file uploads. */
|
|
216
|
-
function bufferToReadableStream(buffer: Buffer): globalThis.ReadableStream<Uint8Array> {
|
|
217
|
-
return new ReadableStream<Uint8Array>({
|
|
218
|
-
start(controller) {
|
|
219
|
-
controller.enqueue(new Uint8Array(buffer));
|
|
220
|
-
controller.close();
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Parse an HTTP date header; returns epoch when missing or invalid. */
|
|
226
|
-
function parseHttpDate(value: string | undefined): Date {
|
|
227
|
-
if (!value) return new Date(0);
|
|
228
|
-
const parsed = Date.parse(value);
|
|
229
|
-
return Number.isFinite(parsed) ? new Date(parsed) : new Date(0);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/* ---------------- DatabricksWorkspaceFilesystem ---------------- */
|
|
233
|
-
|
|
234
87
|
/**
|
|
235
|
-
* Mastra
|
|
236
|
-
* {@link
|
|
237
|
-
*
|
|
238
|
-
* Workspace paths are absolute within the namespace (`/notes.md` maps to
|
|
239
|
-
* `<basePath>/notes.md`). Unity Catalog volumes use the Files API;
|
|
240
|
-
* `/dbfs/...` paths use DBFS.
|
|
88
|
+
* Thin Mastra {@link MastraFilesystem} adapter over a `@dbx-tools/shared-fs`
|
|
89
|
+
* {@link FileSystem}. Identity fields are getters that read the source on
|
|
90
|
+
* access; construction does not snapshot them.
|
|
241
91
|
*/
|
|
242
|
-
export class
|
|
243
|
-
readonly id: string;
|
|
244
|
-
readonly name = "DatabricksWorkspaceFilesystem";
|
|
245
|
-
readonly provider = "databricks";
|
|
246
|
-
readonly basePath: string;
|
|
92
|
+
export class MastraFileSystemAdapter extends MastraFilesystem {
|
|
247
93
|
status: ProviderStatus = "pending";
|
|
248
94
|
|
|
249
|
-
private readonly
|
|
250
|
-
private readonly
|
|
251
|
-
private _readOnly: boolean | undefined;
|
|
252
|
-
private _basePathMissing: boolean | undefined;
|
|
253
|
-
|
|
254
|
-
get readOnly(): boolean | undefined {
|
|
255
|
-
return this._readOnly;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* @param options.client - Defaults to the AppKit execution-context client.
|
|
260
|
-
* @param options.mkdirs - Default `"try"`; see {@link DatabricksMkdirsMode}.
|
|
261
|
-
* @param options.readOnly - When omitted, {@link init} probes write access.
|
|
262
|
-
*/
|
|
263
|
-
constructor(options: DatabricksWorkspaceFilesystemOptions) {
|
|
264
|
-
super({ name: "DatabricksWorkspaceFilesystem", ...options });
|
|
265
|
-
this.id = options.id ?? `databricks-fs-${hash.fnvHash(options.basePath)}`;
|
|
266
|
-
this.client = options.client ?? getExecutionContext().client;
|
|
267
|
-
this.basePath = normalizeDatabricksBasePath(options.basePath);
|
|
268
|
-
this.mkdirs = options.mkdirs ?? "try";
|
|
269
|
-
this._readOnly = options.readOnly;
|
|
270
|
-
}
|
|
95
|
+
private readonly fs: FileSystem;
|
|
96
|
+
private readonly options: MastraFileSystemAdapterOptions;
|
|
271
97
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return resolved;
|
|
98
|
+
constructor(fs: FileSystem, options: MastraFileSystemAdapterOptions = {}) {
|
|
99
|
+
super({
|
|
100
|
+
name: options.name ?? "MastraFileSystemAdapter",
|
|
101
|
+
onInit: options.onInit,
|
|
102
|
+
onDestroy: options.onDestroy,
|
|
103
|
+
});
|
|
104
|
+
this.fs = fs;
|
|
105
|
+
this.options = options;
|
|
281
106
|
}
|
|
282
107
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return toDatabricksWorkspacePath(this.basePath, absolutePath);
|
|
108
|
+
get id(): string {
|
|
109
|
+
return this.options.id ?? this.fs.id;
|
|
286
110
|
}
|
|
287
111
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (this.readOnly) {
|
|
291
|
-
throw new WorkspaceReadOnlyError(operation);
|
|
292
|
-
}
|
|
112
|
+
get name(): string {
|
|
113
|
+
return this.options.name ?? "MastraFileSystemAdapter";
|
|
293
114
|
}
|
|
294
115
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const workspacePath = inputPath.startsWith("/")
|
|
298
|
-
? inputPath
|
|
299
|
-
: this.workspacePath(this.resolvePath(inputPath));
|
|
300
|
-
const ErrorType = filesystemSdkErrorType(err);
|
|
301
|
-
if (ErrorType) {
|
|
302
|
-
throw new ErrorType(workspacePath);
|
|
303
|
-
}
|
|
304
|
-
// The upstream message is logged rather than raised: it reaches the model
|
|
305
|
-
// (and from there the chat transcript) as the tool's failure text.
|
|
306
|
-
logger.warn("operation-failed", {
|
|
307
|
-
path: workspacePath,
|
|
308
|
-
error: error.errorMessage(err),
|
|
309
|
-
});
|
|
310
|
-
throw new ExecutionError(`Databricks filesystem operation failed for ${workspacePath}`, {
|
|
311
|
-
cause: error.toError(err),
|
|
312
|
-
context: { path: workspacePath },
|
|
313
|
-
});
|
|
116
|
+
get provider(): string {
|
|
117
|
+
return this.options.provider ?? this.fs.backend;
|
|
314
118
|
}
|
|
315
119
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Probe whether {@link basePath} exists and cache the result on
|
|
320
|
-
* {@link _basePathMissing}.
|
|
321
|
-
*/
|
|
322
|
-
private async resolveBasePathStatus(): Promise<void> {
|
|
323
|
-
if (this._basePathMissing !== undefined) return;
|
|
324
|
-
try {
|
|
325
|
-
await this.assertAbsoluteReadable(this.basePath);
|
|
326
|
-
this._basePathMissing = false;
|
|
327
|
-
} catch (err) {
|
|
328
|
-
if (error.errorContext(err).notAccessible) {
|
|
329
|
-
this._basePathMissing = true;
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
this.rethrow(err, "/");
|
|
333
|
-
}
|
|
120
|
+
get readOnly(): boolean {
|
|
121
|
+
return this.options.readOnly === true || this.fs.readOnly;
|
|
334
122
|
}
|
|
335
123
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
await this.resolveBasePathStatus();
|
|
339
|
-
return this._basePathMissing ? emptyFilesystem() : undefined;
|
|
124
|
+
get basePath(): string {
|
|
125
|
+
return this.fs.root;
|
|
340
126
|
}
|
|
341
127
|
|
|
342
128
|
override async init(): Promise<void> {
|
|
343
|
-
await this.
|
|
344
|
-
|
|
345
|
-
if (this._basePathMissing) {
|
|
346
|
-
if (this.mkdirs === false) {
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
try {
|
|
350
|
-
await this.mkdirAbsolute(this.basePath);
|
|
351
|
-
this._basePathMissing = false;
|
|
352
|
-
if (this._readOnly === undefined) {
|
|
353
|
-
this._readOnly = false;
|
|
354
|
-
}
|
|
355
|
-
} catch (err) {
|
|
356
|
-
if (this.mkdirs === true) {
|
|
357
|
-
this.rethrow(err, "/");
|
|
358
|
-
}
|
|
359
|
-
logger.debug("mkdirs:try-failed", {
|
|
360
|
-
basePath: this.basePath,
|
|
361
|
-
error: error.errorMessage(err),
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (this._readOnly === undefined) {
|
|
368
|
-
await this.probeReadOnly();
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Write and delete a ephemeral probe file to detect read-only access when
|
|
374
|
-
* {@link DatabricksWorkspaceFilesystemOptions.readOnly} was not set.
|
|
375
|
-
*
|
|
376
|
-
* @returns `true` when the probe write (and cleanup) succeeded.
|
|
377
|
-
*/
|
|
378
|
-
private async probeReadOnly(): Promise<boolean> {
|
|
379
|
-
const absolutePath = this.resolvePath(`/.__dbx_fs_probe_${hash.id()}`);
|
|
380
|
-
try {
|
|
381
|
-
await this.writeAbsolute(absolutePath, Buffer.from("probe\n"), true);
|
|
382
|
-
this._readOnly = false;
|
|
383
|
-
} catch {
|
|
384
|
-
this._readOnly = true;
|
|
385
|
-
return false;
|
|
386
|
-
}
|
|
387
|
-
try {
|
|
388
|
-
await this.deleteAbsoluteFile(absolutePath);
|
|
389
|
-
} catch {
|
|
390
|
-
// Writable; leave a hidden probe file rather than failing init.
|
|
391
|
-
}
|
|
392
|
-
return true;
|
|
129
|
+
await this.fs.init();
|
|
393
130
|
}
|
|
394
131
|
|
|
395
132
|
override async destroy(): Promise<void> {
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/* --- backend I/O --- */
|
|
400
|
-
|
|
401
|
-
/** Probe that `absolutePath` exists (file or directory metadata). */
|
|
402
|
-
private async assertAbsoluteReadable(absolutePath: string): Promise<void> {
|
|
403
|
-
await dispatchFilesBackend(absolutePath, {
|
|
404
|
-
dbfs: () => this.client.dbfs.getStatus({ path: absolutePath }),
|
|
405
|
-
workspace: () => this.client.workspace.getStatus({ path: absolutePath }),
|
|
406
|
-
ucFiles: () => this.client.files.getDirectoryMetadata({ directory_path: absolutePath }),
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Read the full contents of `absolutePath` from the matching backend. */
|
|
411
|
-
private async readAbsolute(absolutePath: string): Promise<Buffer> {
|
|
412
|
-
return dispatchFilesBackend(absolutePath, {
|
|
413
|
-
dbfs: () => this.readDbfsFile(absolutePath),
|
|
414
|
-
workspace: () => this.readWorkspaceFile(absolutePath),
|
|
415
|
-
ucFiles: async () => {
|
|
416
|
-
const response = await this.client.files.download({ file_path: absolutePath });
|
|
417
|
-
return readResponseBody(
|
|
418
|
-
response.contents as globalThis.ReadableStream<Uint8Array> | undefined,
|
|
419
|
-
);
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/** Write `buffer` to `absolutePath` on the matching backend. */
|
|
425
|
-
private async writeAbsolute(
|
|
426
|
-
absolutePath: string,
|
|
427
|
-
buffer: Buffer,
|
|
428
|
-
overwrite: boolean,
|
|
429
|
-
): Promise<void> {
|
|
430
|
-
await dispatchFilesBackend(absolutePath, {
|
|
431
|
-
dbfs: () => this.writeDbfsFile(absolutePath, buffer, overwrite),
|
|
432
|
-
workspace: () => this.writeWorkspaceFile(absolutePath, buffer, overwrite),
|
|
433
|
-
ucFiles: () => this.uploadUcFile(absolutePath, buffer, overwrite),
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** Upload a buffer to a Unity Catalog Files API path. */
|
|
438
|
-
private uploadUcFile(absolutePath: string, buffer: Buffer, overwrite: boolean): Promise<unknown> {
|
|
439
|
-
return this.client.files.upload({
|
|
440
|
-
file_path: absolutePath,
|
|
441
|
-
contents: bufferToReadableStream(buffer) as never,
|
|
442
|
-
overwrite,
|
|
443
|
-
});
|
|
133
|
+
await this.fs.close();
|
|
444
134
|
}
|
|
445
135
|
|
|
446
|
-
|
|
447
|
-
private async deleteAbsoluteFile(absolutePath: string): Promise<void> {
|
|
448
|
-
await dispatchFilesBackend(absolutePath, {
|
|
449
|
-
dbfs: () => this.client.dbfs.delete({ path: absolutePath, recursive: false }),
|
|
450
|
-
workspace: () => this.client.workspace.delete({ path: absolutePath, recursive: false }),
|
|
451
|
-
ucFiles: () => this.client.files.delete({ file_path: absolutePath }),
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Delete a file or directory at `absolutePath`.
|
|
457
|
-
*
|
|
458
|
-
* DBFS and workspace APIs accept `recursive`; UC volumes recurse manually.
|
|
459
|
-
*/
|
|
460
|
-
private async deleteAbsolutePath(absolutePath: string, recursive: boolean): Promise<void> {
|
|
461
|
-
await dispatchFilesBackend(absolutePath, {
|
|
462
|
-
dbfs: async () => {
|
|
463
|
-
await this.client.dbfs.delete({ path: absolutePath, recursive });
|
|
464
|
-
},
|
|
465
|
-
workspace: async () => {
|
|
466
|
-
await this.client.workspace.delete({ path: absolutePath, recursive });
|
|
467
|
-
},
|
|
468
|
-
ucFiles: async () => {
|
|
469
|
-
if (recursive) {
|
|
470
|
-
await this.deleteUcDirectoryRecursive(absolutePath);
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
const children = await this.listAbsoluteDirectory(absolutePath);
|
|
474
|
-
if (children.length > 0) {
|
|
475
|
-
throw new DirectoryNotEmptyError(this.workspacePath(absolutePath));
|
|
476
|
-
}
|
|
477
|
-
await this.client.files.deleteDirectory({ directory_path: absolutePath });
|
|
478
|
-
},
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** Create `absolutePath` and any missing parents on the matching backend. */
|
|
483
|
-
private async mkdirAbsolute(absolutePath: string): Promise<void> {
|
|
484
|
-
await dispatchFilesBackend(absolutePath, {
|
|
485
|
-
dbfs: () => this.client.dbfs.mkdirs({ path: absolutePath }),
|
|
486
|
-
workspace: () => this.client.workspace.mkdirs({ path: absolutePath }),
|
|
487
|
-
ucFiles: () => this.client.files.createDirectory({ directory_path: absolutePath }),
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
private async readDbfsFile(absolutePath: string): Promise<Buffer> {
|
|
492
|
-
const chunks: Buffer[] = [];
|
|
493
|
-
let offset = 0;
|
|
494
|
-
while (true) {
|
|
495
|
-
const response = await this.client.dbfs.read({
|
|
496
|
-
path: absolutePath,
|
|
497
|
-
offset,
|
|
498
|
-
length: DBFS_READ_CHUNK_BYTES,
|
|
499
|
-
});
|
|
500
|
-
const chunk = decodeDbfsPayload(response.data);
|
|
501
|
-
if (chunk.length === 0) break;
|
|
502
|
-
chunks.push(chunk);
|
|
503
|
-
offset += chunk.length;
|
|
504
|
-
if (chunk.length < DBFS_READ_CHUNK_BYTES) break;
|
|
505
|
-
}
|
|
506
|
-
return Buffer.concat(chunks);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
private async readWorkspaceFile(absolutePath: string): Promise<Buffer> {
|
|
510
|
-
const response = await this.client.workspace.export({
|
|
511
|
-
path: absolutePath,
|
|
512
|
-
format: "AUTO",
|
|
513
|
-
});
|
|
514
|
-
return decodeDbfsPayload(response.content);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
private async writeDbfsFile(
|
|
518
|
-
absolutePath: string,
|
|
519
|
-
buffer: Buffer,
|
|
520
|
-
overwrite: boolean,
|
|
521
|
-
): Promise<void> {
|
|
522
|
-
if (buffer.length <= DBFS_PUT_MAX_BYTES) {
|
|
523
|
-
await this.client.dbfs.put({
|
|
524
|
-
path: absolutePath,
|
|
525
|
-
contents: buffer.toString("base64"),
|
|
526
|
-
overwrite,
|
|
527
|
-
});
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const created = await this.client.dbfs.create({ path: absolutePath, overwrite });
|
|
531
|
-
const handle = created.handle;
|
|
532
|
-
if (handle === undefined) {
|
|
533
|
-
throw ExecutionError.missingData("DBFS upload handle");
|
|
534
|
-
}
|
|
535
|
-
for (let offset = 0; offset < buffer.length; offset += DBFS_PUT_MAX_BYTES) {
|
|
536
|
-
const slice = buffer.subarray(offset, offset + DBFS_PUT_MAX_BYTES);
|
|
537
|
-
await this.client.dbfs.addBlock({
|
|
538
|
-
handle,
|
|
539
|
-
data: slice.toString("base64"),
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
await this.client.dbfs.close({ handle });
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
private async writeWorkspaceFile(
|
|
546
|
-
absolutePath: string,
|
|
547
|
-
buffer: Buffer,
|
|
548
|
-
overwrite: boolean,
|
|
549
|
-
): Promise<void> {
|
|
550
|
-
await this.client.workspace.import({
|
|
551
|
-
path: absolutePath,
|
|
552
|
-
format: "AUTO",
|
|
553
|
-
content: buffer.toString("base64"),
|
|
554
|
-
overwrite,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/* --- MastraFilesystem API --- */
|
|
559
|
-
|
|
560
|
-
getInfo(): FilesystemInfo<{ basePath: string }> {
|
|
136
|
+
getInfo(): FilesystemInfo<{ root: string; backend: string }> {
|
|
561
137
|
return {
|
|
562
138
|
id: this.id,
|
|
563
139
|
name: this.name,
|
|
564
140
|
provider: this.provider,
|
|
565
141
|
status: this.status,
|
|
566
142
|
readOnly: this.readOnly,
|
|
567
|
-
metadata: {
|
|
143
|
+
metadata: { root: this.fs.root, backend: this.fs.backend },
|
|
568
144
|
};
|
|
569
145
|
}
|
|
570
146
|
|
|
571
147
|
getInstructions(): string {
|
|
572
148
|
return [
|
|
573
|
-
`Files
|
|
574
|
-
"Workspace paths are absolute within this
|
|
575
|
-
"Workspace paths use `/Workspace/...`, `/Users/...`, or `/Repos/...` base paths.",
|
|
576
|
-
"Unity Catalog volumes use `/Volumes/<catalog>/<schema>/<volume>/...` base paths.",
|
|
149
|
+
`Files are served by a ${this.fs.backend} filesystem rooted at ${this.fs.root}.`,
|
|
150
|
+
"Workspace paths are absolute within this mount (for example `/notes/report.md`).",
|
|
577
151
|
].join(" ");
|
|
578
152
|
}
|
|
579
153
|
|
|
580
|
-
/** Map a workspace-relative path to the backing Databricks absolute path. */
|
|
581
|
-
resolveAbsolutePath(inputPath: string): string | undefined {
|
|
582
|
-
return this.resolvePath(inputPath);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** Read file contents from the workspace namespace. */
|
|
586
154
|
async readFile(inputPath: string, options?: ReadOptions): Promise<string | Buffer> {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
return formatReadResult(buffer, options?.encoding);
|
|
594
|
-
} catch (err) {
|
|
595
|
-
this.rethrow(err, inputPath);
|
|
596
|
-
}
|
|
155
|
+
return this.delegate(inputPath, async () => {
|
|
156
|
+
if (options?.encoding) {
|
|
157
|
+
return this.fs.readFile(inputPath, { encoding: options.encoding });
|
|
158
|
+
}
|
|
159
|
+
return Buffer.from(await this.fs.readFile(inputPath));
|
|
160
|
+
});
|
|
597
161
|
}
|
|
598
162
|
|
|
599
|
-
/** Write file contents into the workspace namespace. */
|
|
600
163
|
async writeFile(inputPath: string, content: FileContent, options?: WriteOptions): Promise<void> {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
this.assertWritable("writeFile");
|
|
605
|
-
const absolutePath = this.resolvePath(inputPath);
|
|
606
|
-
const buffer = toBuffer(content);
|
|
607
|
-
const overwrite = options?.overwrite ?? true;
|
|
608
|
-
try {
|
|
609
|
-
if (!overwrite && (await this.exists(inputPath))) {
|
|
610
|
-
throw new FileExistsError(inputPath);
|
|
164
|
+
return this.delegateWrite("writeFile", inputPath, async () => {
|
|
165
|
+
if (options?.recursive === false) {
|
|
166
|
+
await this.assertParentExists(inputPath);
|
|
611
167
|
}
|
|
612
|
-
await this.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
168
|
+
await this.fs.writeFile(inputPath, content, {
|
|
169
|
+
overwrite: options?.overwrite ?? true,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
617
172
|
}
|
|
618
173
|
|
|
619
|
-
/** Append to an existing file, creating it when missing. */
|
|
620
174
|
async appendFile(inputPath: string, content: FileContent): Promise<void> {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
this.assertWritable("appendFile");
|
|
625
|
-
const existing = (await this.exists(inputPath))
|
|
626
|
-
? await this.readFile(inputPath)
|
|
627
|
-
: Buffer.alloc(0);
|
|
628
|
-
const merged = Buffer.concat([
|
|
629
|
-
Buffer.isBuffer(existing) ? existing : Buffer.from(existing, "utf8"),
|
|
630
|
-
toBuffer(content),
|
|
631
|
-
]);
|
|
632
|
-
await this.writeFile(inputPath, merged, { overwrite: true });
|
|
175
|
+
return this.delegateWrite("appendFile", inputPath, () =>
|
|
176
|
+
this.fs.appendFile(inputPath, content),
|
|
177
|
+
);
|
|
633
178
|
}
|
|
634
179
|
|
|
635
|
-
/** Delete a file in the workspace namespace. */
|
|
636
180
|
async deleteFile(inputPath: string, options?: RemoveOptions): Promise<void> {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (!(await this.exists(inputPath))) {
|
|
644
|
-
if (options?.force) return;
|
|
645
|
-
throw new FileNotFoundError(inputPath);
|
|
646
|
-
}
|
|
647
|
-
const entry = await this.stat(inputPath);
|
|
648
|
-
if (entry.type === "directory") {
|
|
649
|
-
throw new IsDirectoryError(inputPath);
|
|
650
|
-
}
|
|
651
|
-
await this.deleteAbsoluteFile(absolutePath);
|
|
652
|
-
} catch (err) {
|
|
653
|
-
if (err instanceof FileNotFoundError || err instanceof IsDirectoryError) throw err;
|
|
654
|
-
this.rethrow(err, inputPath);
|
|
655
|
-
}
|
|
181
|
+
return this.delegateWrite("deleteFile", inputPath, () =>
|
|
182
|
+
this.fs.deleteFile(inputPath, {
|
|
183
|
+
force: options?.force,
|
|
184
|
+
recursive: options?.recursive,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
656
187
|
}
|
|
657
188
|
|
|
658
|
-
/** Copy a file within the workspace namespace. */
|
|
659
189
|
async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
this.assertWritable("copyFile");
|
|
664
|
-
const overwrite = options?.overwrite ?? true;
|
|
665
|
-
if (!overwrite && (await this.exists(dest))) {
|
|
666
|
-
throw new FileExistsError(dest);
|
|
667
|
-
}
|
|
668
|
-
const content = await this.readFile(src);
|
|
669
|
-
await this.writeFile(dest, content, { overwrite: true });
|
|
190
|
+
return this.delegateWrite("copyFile", dest, () =>
|
|
191
|
+
this.fs.copyFile(src, dest, { overwrite: options?.overwrite ?? true }),
|
|
192
|
+
);
|
|
670
193
|
}
|
|
671
194
|
|
|
672
|
-
/** Move or rename a file within the workspace namespace. */
|
|
673
195
|
async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
this.assertWritable("moveFile");
|
|
678
|
-
const srcAbsolute = this.resolvePath(src);
|
|
679
|
-
const destAbsolute = this.resolvePath(dest);
|
|
680
|
-
const overwrite = options?.overwrite ?? true;
|
|
681
|
-
try {
|
|
682
|
-
if (!overwrite && (await this.exists(dest))) {
|
|
683
|
-
throw new FileExistsError(dest);
|
|
684
|
-
}
|
|
685
|
-
if (
|
|
686
|
-
resolveFilesBackend(srcAbsolute) === "dbfs" &&
|
|
687
|
-
resolveFilesBackend(destAbsolute) === "dbfs"
|
|
688
|
-
) {
|
|
689
|
-
await this.client.dbfs.move({
|
|
690
|
-
source_path: srcAbsolute,
|
|
691
|
-
destination_path: destAbsolute,
|
|
692
|
-
});
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
await this.copyFile(src, dest, { overwrite: true });
|
|
696
|
-
await this.deleteFile(src, { force: true });
|
|
697
|
-
} catch (err) {
|
|
698
|
-
if (err instanceof FileExistsError) throw err;
|
|
699
|
-
this.rethrow(err, dest);
|
|
700
|
-
}
|
|
196
|
+
return this.delegateWrite("moveFile", dest, () =>
|
|
197
|
+
this.fs.moveFile(src, dest, { overwrite: options?.overwrite ?? true }),
|
|
198
|
+
);
|
|
701
199
|
}
|
|
702
200
|
|
|
703
|
-
/** Create a directory in the workspace namespace. */
|
|
704
201
|
async mkdir(inputPath: string, options?: { recursive?: boolean }): Promise<void> {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
this.assertWritable("mkdir");
|
|
709
|
-
const absolutePath = this.resolvePath(inputPath);
|
|
710
|
-
try {
|
|
711
|
-
await this.mkdirAbsolute(absolutePath);
|
|
712
|
-
if (!options?.recursive) {
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
} catch (err) {
|
|
716
|
-
this.rethrow(err, inputPath);
|
|
717
|
-
}
|
|
202
|
+
return this.delegateWrite("mkdir", inputPath, () =>
|
|
203
|
+
this.fs.mkdir(inputPath, { recursive: options?.recursive }),
|
|
204
|
+
);
|
|
718
205
|
}
|
|
719
206
|
|
|
720
|
-
/** Remove a directory from the workspace namespace. */
|
|
721
207
|
async rmdir(inputPath: string, options?: RemoveOptions): Promise<void> {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const entry = await this.stat(inputPath);
|
|
733
|
-
if (entry.type !== "directory") {
|
|
734
|
-
throw new NotDirectoryError(inputPath);
|
|
735
|
-
}
|
|
736
|
-
await this.deleteAbsolutePath(absolutePath, options?.recursive ?? false);
|
|
737
|
-
} catch (err) {
|
|
738
|
-
if (
|
|
739
|
-
err instanceof DirectoryNotFoundError ||
|
|
740
|
-
err instanceof NotDirectoryError ||
|
|
741
|
-
err instanceof DirectoryNotEmptyError
|
|
742
|
-
) {
|
|
743
|
-
throw err;
|
|
744
|
-
}
|
|
745
|
-
this.rethrow(err, inputPath);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Recursively delete a Unity Catalog directory tree.
|
|
751
|
-
*
|
|
752
|
-
* DBFS and workspace trees use native recursive delete via
|
|
753
|
-
* {@link deleteAbsolutePath} instead.
|
|
754
|
-
*/
|
|
755
|
-
private async deleteUcDirectoryRecursive(absolutePath: string): Promise<void> {
|
|
756
|
-
for (const child of await this.listAbsoluteDirectory(absolutePath)) {
|
|
757
|
-
const childAbsolute = path.join(absolutePath, child.name);
|
|
758
|
-
if (child.type === "directory") {
|
|
759
|
-
await this.deleteUcDirectoryRecursive(childAbsolute);
|
|
760
|
-
} else {
|
|
761
|
-
await this.deleteAbsoluteFile(childAbsolute);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
await this.client.files.deleteDirectory({ directory_path: absolutePath });
|
|
208
|
+
return this.delegateWrite(
|
|
209
|
+
"rmdir",
|
|
210
|
+
inputPath,
|
|
211
|
+
() =>
|
|
212
|
+
this.fs.rmdir(inputPath, {
|
|
213
|
+
force: options?.force,
|
|
214
|
+
recursive: options?.recursive,
|
|
215
|
+
}),
|
|
216
|
+
{ preferDirectory: true },
|
|
217
|
+
);
|
|
765
218
|
}
|
|
766
219
|
|
|
767
|
-
/* --- directory listing --- */
|
|
768
|
-
|
|
769
|
-
/** List entries in a workspace directory. */
|
|
770
220
|
async readdir(inputPath: string, options?: ListOptions): Promise<FileEntry[]> {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
private async readDirectoryRecursive(
|
|
786
|
-
absolutePath: string,
|
|
787
|
-
options: ListOptions,
|
|
788
|
-
depth: number,
|
|
789
|
-
relativePrefix = "",
|
|
790
|
-
): Promise<FileEntry[]> {
|
|
791
|
-
const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
|
|
792
|
-
const entries = this.filterEntries(await this.listAbsoluteDirectory(absolutePath), options);
|
|
793
|
-
const collected: FileEntry[] = [];
|
|
794
|
-
for (const entry of entries) {
|
|
795
|
-
const relativeName = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name;
|
|
796
|
-
collected.push({ ...entry, name: relativeName });
|
|
797
|
-
if (entry.type !== "directory" || depth >= maxDepth) continue;
|
|
798
|
-
collected.push(
|
|
799
|
-
...(await this.readDirectoryRecursive(
|
|
800
|
-
path.join(absolutePath, entry.name),
|
|
801
|
-
options,
|
|
802
|
-
depth + 1,
|
|
803
|
-
relativeName,
|
|
804
|
-
)),
|
|
805
|
-
);
|
|
806
|
-
}
|
|
807
|
-
return collected;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
private async listAbsoluteDirectory(absolutePath: string): Promise<FileEntry[]> {
|
|
811
|
-
return dispatchFilesBackend(absolutePath, {
|
|
812
|
-
dbfs: async () => {
|
|
813
|
-
const entries: FileEntry[] = [];
|
|
814
|
-
for await (const info of this.client.dbfs.list({ path: absolutePath })) {
|
|
815
|
-
entries.push({
|
|
816
|
-
name: path.basename(info.path ?? ""),
|
|
817
|
-
type: info.is_dir ? "directory" : "file",
|
|
818
|
-
size: info.file_size,
|
|
819
|
-
});
|
|
820
|
-
}
|
|
821
|
-
return entries;
|
|
822
|
-
},
|
|
823
|
-
workspace: async () => {
|
|
824
|
-
const entries: FileEntry[] = [];
|
|
825
|
-
for await (const info of this.client.workspace.list({ path: absolutePath })) {
|
|
826
|
-
entries.push({
|
|
827
|
-
name: path.basename(info.path ?? ""),
|
|
828
|
-
type: info.object_type === "DIRECTORY" ? "directory" : "file",
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
return entries;
|
|
832
|
-
},
|
|
833
|
-
ucFiles: async () => {
|
|
834
|
-
const entries: FileEntry[] = [];
|
|
835
|
-
for await (const entry of this.client.files.listDirectoryContents({
|
|
836
|
-
directory_path: absolutePath,
|
|
837
|
-
})) {
|
|
838
|
-
entries.push({
|
|
839
|
-
name: entry.name ?? path.basename(entry.path ?? ""),
|
|
840
|
-
type: entry.is_directory ? "directory" : "file",
|
|
841
|
-
size: entry.file_size,
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
return entries;
|
|
845
|
-
},
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
/** Apply Mastra list filters (`extension`, etc.) to directory entries. */
|
|
850
|
-
private filterEntries(entries: FileEntry[], options?: ListOptions): FileEntry[] {
|
|
851
|
-
if (!options?.extension) return entries;
|
|
852
|
-
const extensions = Array.isArray(options.extension) ? options.extension : [options.extension];
|
|
853
|
-
const normalized = extensions.map((ext) =>
|
|
854
|
-
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`,
|
|
221
|
+
return this.delegate(
|
|
222
|
+
inputPath,
|
|
223
|
+
async () =>
|
|
224
|
+
(
|
|
225
|
+
await this.fs.readdir(inputPath, {
|
|
226
|
+
recursive: options?.recursive,
|
|
227
|
+
maxDepth: options?.maxDepth,
|
|
228
|
+
extension: options?.extension,
|
|
229
|
+
})
|
|
230
|
+
).map(toMastraEntry),
|
|
231
|
+
{ preferDirectory: true },
|
|
855
232
|
);
|
|
856
|
-
return entries.filter((entry) => {
|
|
857
|
-
if (entry.type !== "file") return true;
|
|
858
|
-
const lower = entry.name.toLowerCase();
|
|
859
|
-
return normalized.some((ext) => lower.endsWith(ext));
|
|
860
|
-
});
|
|
861
233
|
}
|
|
862
234
|
|
|
863
|
-
/* --- metadata --- */
|
|
864
|
-
|
|
865
|
-
/** Return whether `inputPath` exists in the workspace namespace. */
|
|
866
235
|
async exists(inputPath: string): Promise<boolean> {
|
|
867
236
|
await this.ensureReady();
|
|
868
|
-
|
|
869
|
-
if (empty) return empty.exists(inputPath);
|
|
870
|
-
try {
|
|
871
|
-
await this.stat(inputPath);
|
|
872
|
-
return true;
|
|
873
|
-
} catch (err) {
|
|
874
|
-
if (err instanceof FileNotFoundError) return false;
|
|
875
|
-
throw err;
|
|
876
|
-
}
|
|
237
|
+
return this.fs.exists(inputPath);
|
|
877
238
|
}
|
|
878
239
|
|
|
879
|
-
/** Return file or directory metadata for `inputPath`. */
|
|
880
240
|
async stat(inputPath: string): Promise<FileStat> {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const absolutePath = this.resolvePath(inputPath);
|
|
885
|
-
const workspacePath = this.workspacePath(absolutePath);
|
|
886
|
-
try {
|
|
887
|
-
return await dispatchFilesBackend(absolutePath, {
|
|
888
|
-
dbfs: async () => {
|
|
889
|
-
const info = await this.client.dbfs.getStatus({ path: absolutePath });
|
|
890
|
-
return {
|
|
891
|
-
name: path.basename(info.path ?? absolutePath),
|
|
892
|
-
path: workspacePath,
|
|
893
|
-
type: info.is_dir ? ("directory" as const) : ("file" as const),
|
|
894
|
-
size: info.file_size ?? 0,
|
|
895
|
-
createdAt: new Date(info.modification_time ?? 0),
|
|
896
|
-
modifiedAt: new Date(info.modification_time ?? 0),
|
|
897
|
-
};
|
|
898
|
-
},
|
|
899
|
-
workspace: async () => {
|
|
900
|
-
const info = await this.client.workspace.getStatus({ path: absolutePath });
|
|
901
|
-
return {
|
|
902
|
-
name: path.basename(info.path ?? absolutePath),
|
|
903
|
-
path: workspacePath,
|
|
904
|
-
type: info.object_type === "DIRECTORY" ? ("directory" as const) : ("file" as const),
|
|
905
|
-
size: 0,
|
|
906
|
-
createdAt: new Date(info.created_at ?? 0),
|
|
907
|
-
modifiedAt: new Date(info.modified_at ?? 0),
|
|
908
|
-
};
|
|
909
|
-
},
|
|
910
|
-
ucFiles: () => this.statUcAbsolute(absolutePath, workspacePath, inputPath),
|
|
911
|
-
});
|
|
912
|
-
} catch (err) {
|
|
913
|
-
this.rethrow(err, inputPath);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* Stat a Unity Catalog path by probing file metadata first, then
|
|
919
|
-
* directory metadata.
|
|
920
|
-
*/
|
|
921
|
-
private async statUcAbsolute(
|
|
922
|
-
absolutePath: string,
|
|
923
|
-
workspacePath: string,
|
|
924
|
-
inputPath: string,
|
|
925
|
-
): Promise<FileStat> {
|
|
926
|
-
try {
|
|
927
|
-
const metadata = await this.client.files.getMetadata({
|
|
928
|
-
file_path: absolutePath,
|
|
929
|
-
});
|
|
930
|
-
return {
|
|
931
|
-
name: path.basename(absolutePath),
|
|
932
|
-
path: workspacePath,
|
|
933
|
-
type: "file" as const,
|
|
934
|
-
size: Number(metadata["content-length"] ?? 0),
|
|
935
|
-
createdAt: parseHttpDate(metadata["last-modified"]),
|
|
936
|
-
modifiedAt: parseHttpDate(metadata["last-modified"]),
|
|
937
|
-
mimeType: metadata["content-type"],
|
|
938
|
-
};
|
|
939
|
-
} catch (fileErr) {
|
|
940
|
-
if (!error.errorContext(fileErr).notAccessible) {
|
|
941
|
-
this.rethrow(fileErr, inputPath);
|
|
942
|
-
}
|
|
943
|
-
await this.client.files.getDirectoryMetadata({
|
|
944
|
-
directory_path: absolutePath,
|
|
945
|
-
});
|
|
946
|
-
return {
|
|
947
|
-
name: path.basename(absolutePath),
|
|
948
|
-
path: workspacePath,
|
|
949
|
-
type: "directory" as const,
|
|
950
|
-
size: 0,
|
|
951
|
-
createdAt: new Date(0),
|
|
952
|
-
modifiedAt: new Date(0),
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
/* --------------------------- empty filesystem --------------------------- */
|
|
959
|
-
|
|
960
|
-
/** Normalize paths for the in-memory empty filesystem namespace. */
|
|
961
|
-
function normalizeEmptyFilesystemPath(inputPath: string): string {
|
|
962
|
-
const trimmed = inputPath.trim();
|
|
963
|
-
if (!trimmed || trimmed === ".") return "/";
|
|
964
|
-
const normalized = trimmed.startsWith("/")
|
|
965
|
-
? path.normalize(trimmed)
|
|
966
|
-
: path.normalize(`/${trimmed}`);
|
|
967
|
-
return normalized === "." ? "/" : normalized;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Read-only in-memory {@link WorkspaceFilesystem} with a single empty root.
|
|
972
|
-
* Use {@link emptyFilesystem} rather than constructing directly.
|
|
973
|
-
*/
|
|
974
|
-
class EmptyFilesystem extends MastraFilesystem {
|
|
975
|
-
readonly id = "empty-fs";
|
|
976
|
-
readonly name = "EmptyFilesystem";
|
|
977
|
-
readonly provider = "empty";
|
|
978
|
-
readonly readOnly = true;
|
|
979
|
-
status: ProviderStatus = "pending";
|
|
980
|
-
|
|
981
|
-
constructor() {
|
|
982
|
-
super({ name: "EmptyFilesystem" });
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
override async init(): Promise<void> {
|
|
986
|
-
// No remote or on-disk resources to provision.
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
override async destroy(): Promise<void> {
|
|
990
|
-
// Stateless; nothing to release.
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
getInfo(): FilesystemInfo {
|
|
994
|
-
return {
|
|
995
|
-
id: this.id,
|
|
996
|
-
name: this.name,
|
|
997
|
-
provider: this.provider,
|
|
998
|
-
status: this.status,
|
|
999
|
-
readOnly: this.readOnly,
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
getInstructions(): string {
|
|
1004
|
-
return "This filesystem is empty and read-only.";
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
private rootStat(): FileStat {
|
|
1008
|
-
return {
|
|
1009
|
-
name: "/",
|
|
1010
|
-
path: "/",
|
|
1011
|
-
type: "directory",
|
|
1012
|
-
size: 0,
|
|
1013
|
-
createdAt: EMPTY_FILESYSTEM_EPOCH,
|
|
1014
|
-
modifiedAt: EMPTY_FILESYSTEM_EPOCH,
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
private assertWritable(operation: string): void {
|
|
1019
|
-
throw new WorkspaceReadOnlyError(operation);
|
|
241
|
+
return this.delegate(inputPath, async () =>
|
|
242
|
+
toMastraStat(await this.fs.stat(inputPath), inputPath),
|
|
243
|
+
);
|
|
1020
244
|
}
|
|
1021
245
|
|
|
1022
|
-
|
|
246
|
+
private async delegate<T>(
|
|
247
|
+
path: string,
|
|
248
|
+
op: () => Promise<T>,
|
|
249
|
+
options?: { preferDirectory?: boolean },
|
|
250
|
+
): Promise<T> {
|
|
1023
251
|
await this.ensureReady();
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
252
|
+
try {
|
|
253
|
+
return await op();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.rethrow(err, path, options);
|
|
1027
256
|
}
|
|
1028
|
-
throw new FileNotFoundError(normalized);
|
|
1029
257
|
}
|
|
1030
258
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
259
|
+
/** {@link delegate} for a mutation: refuse a read-only mount before doing any work. */
|
|
260
|
+
private delegateWrite(
|
|
261
|
+
operation: string,
|
|
262
|
+
path: string,
|
|
263
|
+
op: () => Promise<void>,
|
|
264
|
+
options?: { preferDirectory?: boolean },
|
|
1035
265
|
): Promise<void> {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
override async appendFile(_inputPath: string, _content: FileContent): Promise<void> {
|
|
1041
|
-
await this.ensureReady();
|
|
1042
|
-
this.assertWritable("appendFile");
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
override async deleteFile(inputPath: string, options?: RemoveOptions): Promise<void> {
|
|
1046
|
-
await this.ensureReady();
|
|
1047
|
-
const normalized = normalizeEmptyFilesystemPath(inputPath);
|
|
1048
|
-
if (normalized === "/") {
|
|
1049
|
-
throw new IsDirectoryError(normalized);
|
|
266
|
+
if (this.readOnly) {
|
|
267
|
+
throw new WorkspaceReadOnlyError(operation);
|
|
1050
268
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
269
|
+
return this.delegate(path, op, options);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** When Mastra asks for non-recursive writes, require the parent directory. */
|
|
273
|
+
private async assertParentExists(inputPath: string): Promise<void> {
|
|
274
|
+
const parent = posixPath.dirname(normalizeWorkspacePath(inputPath));
|
|
275
|
+
if (parent === "/" || (await this.fs.exists(parent))) return;
|
|
276
|
+
throw new DirectoryNotFoundError(parent);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private rethrow(err: unknown, inputPath: string, options?: { preferDirectory?: boolean }): never {
|
|
280
|
+
if (
|
|
281
|
+
err instanceof FileNotFoundError ||
|
|
282
|
+
err instanceof DirectoryNotFoundError ||
|
|
283
|
+
err instanceof FileExistsError ||
|
|
284
|
+
err instanceof IsDirectoryError ||
|
|
285
|
+
err instanceof NotDirectoryError ||
|
|
286
|
+
err instanceof DirectoryNotEmptyError ||
|
|
287
|
+
err instanceof PermissionError ||
|
|
288
|
+
err instanceof WorkspaceReadOnlyError
|
|
289
|
+
) {
|
|
290
|
+
throw err;
|
|
1060
291
|
}
|
|
1061
|
-
throw new IsDirectoryError(normalizedSrc);
|
|
1062
|
-
}
|
|
1063
292
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
293
|
+
if (err instanceof FileSystemError) {
|
|
294
|
+
throw mapSharedError(err, inputPath, options?.preferDirectory === true);
|
|
295
|
+
}
|
|
1067
296
|
|
|
1068
|
-
|
|
1069
|
-
await this.ensureReady();
|
|
1070
|
-
this.assertWritable("mkdir");
|
|
297
|
+
throw err;
|
|
1071
298
|
}
|
|
299
|
+
}
|
|
1072
300
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
throw new DirectoryNotEmptyError(normalized);
|
|
1078
|
-
}
|
|
1079
|
-
if (options?.force) return;
|
|
1080
|
-
throw new DirectoryNotFoundError(normalized);
|
|
1081
|
-
}
|
|
301
|
+
/** Fresh writable local temp mount on a root unique to this call. */
|
|
302
|
+
export function scratchFilesystem(): MastraFileSystemAdapter {
|
|
303
|
+
return filesystems(localFS.scratchFS("mastra"));
|
|
304
|
+
}
|
|
1082
305
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
306
|
+
/** Map a shared-fs error code onto the matching Mastra filesystem error. */
|
|
307
|
+
function mapSharedError(err: FileSystemError, inputPath: string, preferDirectory: boolean): Error {
|
|
308
|
+
const path = err.path ?? inputPath;
|
|
309
|
+
switch (err.code) {
|
|
310
|
+
case "NOT_FOUND":
|
|
311
|
+
return preferDirectory ? new DirectoryNotFoundError(path) : new FileNotFoundError(path);
|
|
312
|
+
case "ALREADY_EXISTS":
|
|
313
|
+
return new FileExistsError(path);
|
|
314
|
+
case "NOT_DIRECTORY":
|
|
315
|
+
return new NotDirectoryError(path);
|
|
316
|
+
case "IS_DIRECTORY":
|
|
317
|
+
return new IsDirectoryError(path);
|
|
318
|
+
case "DIRECTORY_NOT_EMPTY":
|
|
319
|
+
return new DirectoryNotEmptyError(path);
|
|
320
|
+
case "PERMISSION_DENIED":
|
|
321
|
+
return new PermissionError(path, err.message);
|
|
322
|
+
case "READ_ONLY":
|
|
323
|
+
return new WorkspaceReadOnlyError(err.message);
|
|
324
|
+
default:
|
|
325
|
+
return err;
|
|
1088
326
|
}
|
|
327
|
+
}
|
|
1089
328
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
329
|
+
function toMastraEntry(entry: SharedFileEntry): FileEntry {
|
|
330
|
+
return {
|
|
331
|
+
name: entry.name,
|
|
332
|
+
type: entry.type === "directory" ? "directory" : "file",
|
|
333
|
+
size: entry.size,
|
|
334
|
+
isSymlink: entry.type === "symbolic-link",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
1094
337
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
338
|
+
function toMastraStat(stat: SharedFileStat, inputPath: string): FileStat {
|
|
339
|
+
const epoch = new Date(0);
|
|
340
|
+
return {
|
|
341
|
+
name: stat.name,
|
|
342
|
+
path: normalizeWorkspacePath(stat.path || inputPath),
|
|
343
|
+
type: stat.type === "directory" ? "directory" : "file",
|
|
344
|
+
size: stat.size ?? 0,
|
|
345
|
+
createdAt: stat.createdAt ?? epoch,
|
|
346
|
+
modifiedAt: stat.modifiedAt ?? epoch,
|
|
347
|
+
mimeType: stat.mimeType,
|
|
348
|
+
};
|
|
1101
349
|
}
|
|
1102
350
|
|
|
1103
|
-
/**
|
|
1104
|
-
|
|
351
|
+
/**
|
|
352
|
+
* Mastra namespace path (`/a/b`). A `..` that would escape clamps to the root
|
|
353
|
+
* rather than throwing - the wrapped filesystem enforces containment itself,
|
|
354
|
+
* and a mount should not fail a listing over a stray segment.
|
|
355
|
+
*/
|
|
356
|
+
function normalizeWorkspacePath(inputPath: string): string {
|
|
357
|
+
const result = posixPath.normalize(inputPath);
|
|
358
|
+
return result.ok ? result.path : "/";
|
|
359
|
+
}
|