@dbx-tools/appkit-mastra 0.6.9 → 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.
@@ -1,22 +1,21 @@
1
1
  /**
2
- * Mastra {@link WorkspaceFilesystem} implementations for Databricks Apps.
2
+ * Mastra workspace filesystem adapters.
3
3
  *
4
- * {@link DatabricksWorkspaceFilesystem} maps a Mastra workspace namespace onto
5
- * an absolute Databricks path (Unity Catalog volume, workspace object tree, or
6
- * DBFS). {@link emptyFilesystem} is a read-only no-op mount used when no
7
- * dynamic mounts resolve for a request.
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 { posix as path } from "node:path";
17
- import { ExecutionError, getExecutionContext, ValidationError } from "@databricks/appkit";
18
- import { WorkspaceClient } from "@databricks/sdk-experimental";
19
- import { error, functionModule, hash, log } from "@dbx-tools/shared-core";
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
- /* ------------------------------ constants ------------------------------ */
46
-
47
- const DBFS_READ_CHUNK_BYTES = 1024 * 1024;
48
- const DBFS_PUT_MAX_BYTES = 1024 * 1024;
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
- /** How {@link DatabricksWorkspaceFilesystem.init} handles a missing {@link basePath}. */
71
- export type DatabricksMkdirsMode = boolean | "try";
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
- * Absolute Databricks path that roots the workspace namespace, e.g.
81
- * `/Volumes/catalog/schema/volume` or `/dbfs/FileStore/shared`.
53
+ * Override the Mastra provider id. Defaults to the source
54
+ * {@link FileSystem.backend}.
82
55
  */
83
- basePath: string;
56
+ provider?: string;
57
+
84
58
  /**
85
- * When the {@link basePath} is missing at {@link init}, create it with the
86
- * matching Databricks `mkdirs` API. `"try"` (default) logs at debug and
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
- * Resolve a workspace-relative path to an absolute Databricks path under
139
- * `basePath`.
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 resolveDatabricksAbsolutePath(basePath: string, inputPath: string): string {
142
- const root = normalizeDatabricksBasePath(basePath);
143
- const trimmed = inputPath.trim();
144
- if (!trimmed || trimmed === "/") return root;
145
- const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
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 filesystem provider that reads and writes through a Databricks
236
- * {@link WorkspaceClient}.
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 DatabricksWorkspaceFilesystem extends MastraFilesystem {
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 client: WorkspaceClient;
250
- private readonly mkdirs: DatabricksMkdirsMode;
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
- /* --- path resolution --- */
273
-
274
- /** Resolve and sandbox a workspace-relative path under {@link basePath}. */
275
- private resolvePath(inputPath: string): string {
276
- const resolved = resolveDatabricksAbsolutePath(this.basePath, inputPath);
277
- if (resolved !== this.basePath && !resolved.startsWith(`${this.basePath}/`)) {
278
- throw new PermissionError(inputPath, "access");
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
- /** Map a Databricks absolute path back to the workspace namespace. */
284
- private workspacePath(absolutePath: string): string {
285
- return toDatabricksWorkspacePath(this.basePath, absolutePath);
108
+ get id(): string {
109
+ return this.options.id ?? this.fs.id;
286
110
  }
287
111
 
288
- /** Throw when the filesystem is read-only. */
289
- private assertWritable(operation: string): void {
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
- /** Map SDK / HTTP errors to Mastra workspace filesystem errors. */
296
- private rethrow(err: unknown, inputPath: string): never {
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
- /* --- lifecycle --- */
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
- /** Delegate to {@link emptyFilesystem} when the base path is missing. */
337
- private async emptyFallback(): Promise<ReturnType<typeof emptyFilesystem> | undefined> {
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.resolveBasePathStatus();
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
- // Remote filesystem; nothing to tear down locally.
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
- /** Delete a single file at `absolutePath` (non-recursive). */
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: { basePath: this.basePath },
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 live in Databricks under ${this.basePath}.`,
574
- "Workspace paths are absolute within this root (for example `/notes/report.md`).",
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
- await this.ensureReady();
588
- const empty = await this.emptyFallback();
589
- if (empty) return empty.readFile(inputPath, options);
590
- const absolutePath = this.resolvePath(inputPath);
591
- try {
592
- const buffer = await this.readAbsolute(absolutePath);
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
- await this.ensureReady();
602
- const empty = await this.emptyFallback();
603
- if (empty) return empty.writeFile(inputPath, content, options);
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.writeAbsolute(absolutePath, buffer, overwrite);
613
- } catch (err) {
614
- if (err instanceof FileExistsError) throw err;
615
- this.rethrow(err, inputPath);
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
- await this.ensureReady();
622
- const empty = await this.emptyFallback();
623
- if (empty) return empty.appendFile(inputPath, content);
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
- await this.ensureReady();
638
- const empty = await this.emptyFallback();
639
- if (empty) return empty.deleteFile(inputPath, options);
640
- this.assertWritable("deleteFile");
641
- const absolutePath = this.resolvePath(inputPath);
642
- try {
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
- await this.ensureReady();
661
- const empty = await this.emptyFallback();
662
- if (empty) return empty.copyFile(src, dest, options);
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
- await this.ensureReady();
675
- const empty = await this.emptyFallback();
676
- if (empty) return empty.moveFile(src, dest, options);
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
- await this.ensureReady();
706
- const empty = await this.emptyFallback();
707
- if (empty) return empty.mkdir(inputPath, options);
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
- await this.ensureReady();
723
- const empty = await this.emptyFallback();
724
- if (empty) return empty.rmdir(inputPath, options);
725
- this.assertWritable("rmdir");
726
- const absolutePath = this.resolvePath(inputPath);
727
- try {
728
- if (!(await this.exists(inputPath))) {
729
- if (options?.force) return;
730
- throw new DirectoryNotFoundError(inputPath);
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
- await this.ensureReady();
772
- const empty = await this.emptyFallback();
773
- if (empty) return empty.readdir(inputPath, options);
774
- const absolutePath = this.resolvePath(inputPath);
775
- try {
776
- const entries = await this.listAbsoluteDirectory(absolutePath);
777
- const filtered = this.filterEntries(entries, options);
778
- if (!options?.recursive) return filtered;
779
- return await this.readDirectoryRecursive(absolutePath, options, 0);
780
- } catch (err) {
781
- this.rethrow(err, inputPath);
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
- const empty = await this.emptyFallback();
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
- await this.ensureReady();
882
- const empty = await this.emptyFallback();
883
- if (empty) return empty.stat(inputPath);
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
- override async readFile(inputPath: string, _options?: ReadOptions): Promise<string | Buffer> {
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
- const normalized = normalizeEmptyFilesystemPath(inputPath);
1025
- if (normalized === "/") {
1026
- throw new IsDirectoryError(normalized);
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
- override async writeFile(
1032
- _inputPath: string,
1033
- _content: FileContent,
1034
- _options?: WriteOptions,
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
- await this.ensureReady();
1037
- this.assertWritable("writeFile");
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
- if (options?.force) return;
1052
- throw new FileNotFoundError(normalized);
1053
- }
1054
-
1055
- override async copyFile(src: string, _dest: string, _options?: CopyOptions): Promise<void> {
1056
- await this.ensureReady();
1057
- const normalizedSrc = normalizeEmptyFilesystemPath(src);
1058
- if (normalizedSrc !== "/") {
1059
- throw new FileNotFoundError(normalizedSrc);
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
- override async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
1065
- await this.copyFile(src, dest, options);
1066
- }
293
+ if (err instanceof FileSystemError) {
294
+ throw mapSharedError(err, inputPath, options?.preferDirectory === true);
295
+ }
1067
296
 
1068
- override async mkdir(_inputPath: string, _options?: { recursive?: boolean }): Promise<void> {
1069
- await this.ensureReady();
1070
- this.assertWritable("mkdir");
297
+ throw err;
1071
298
  }
299
+ }
1072
300
 
1073
- override async rmdir(inputPath: string, options?: RemoveOptions): Promise<void> {
1074
- await this.ensureReady();
1075
- const normalized = normalizeEmptyFilesystemPath(inputPath);
1076
- if (normalized === "/") {
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
- override async readdir(inputPath: string, _options?: ListOptions): Promise<FileEntry[]> {
1084
- await this.ensureReady();
1085
- const normalized = normalizeEmptyFilesystemPath(inputPath);
1086
- if (normalized === "/") return [];
1087
- throw new DirectoryNotFoundError(normalized);
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
- override async exists(inputPath: string): Promise<boolean> {
1091
- await this.ensureReady();
1092
- return normalizeEmptyFilesystemPath(inputPath) === "/";
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
- override async stat(inputPath: string): Promise<FileStat> {
1096
- await this.ensureReady();
1097
- const normalized = normalizeEmptyFilesystemPath(inputPath);
1098
- if (normalized === "/") return this.rootStat();
1099
- throw new FileNotFoundError(normalized);
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
- /** Memoized singleton empty read-only filesystem for no-op mounts. */
1104
- export const emptyFilesystem = functionModule.memoize(() => new EmptyFilesystem());
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
+ }