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