@dbx-tools/appkit-mastra 0.9.93 → 0.9.99

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/src/workspaces.ts CHANGED
@@ -1,39 +1,53 @@
1
1
  /**
2
2
  * Mastra workspace factory for Databricks Apps.
3
3
  *
4
- * Builds a per-request {@link Workspace} with Databricks Sandbox command
5
- * execution plus a {@link CompositeFilesystem} over the NAMED skill folders
6
- * resolved for that request. A skill folder maps a name to a location plus its
7
- * readable / writable policy: a Databricks path mounted through the OBO client
8
- * on {@link MASTRA_USER_KEY}, or any {@link WorkspaceFilesystem} a consuming
9
- * library already owns. {@link DEFAULT_SKILL_FOLDERS} supplies the Assistant
10
- * trees, and `skillFolders` merges over it - same name overrides, `false`
11
- * disables, a new name adds. Optional mount resolvers contribute further
12
- * filesystems and skill scan roots on top.
4
+ * Builds native Mastra workspace configuration from Databricks paths. Paths
5
+ * mount at the same path by default, `~` resolves to the current user's
6
+ * Databricks workspace home, and `/tmp[/subpath]` resolves to stable
7
+ * user-scoped storage under the host's ephemeral temp directory.
13
8
  *
14
9
  * Databricks mounts use `@dbx-tools/databricks` {@link DatabricksFileSystem}
15
- * wrapped by {@link filesystems}; missing roots fall back to
16
- * {@link scratchFilesystem}.
10
+ * wrapped by {@link filesystems}; inaccessible roots are skipped independently.
17
11
  *
18
12
  * @module
19
13
  */
20
14
 
21
15
  import { createHash } from "node:crypto";
22
- import { ConfigurationError, createWorkspaceClient } from "@databricks/appkit";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { ConfigurationError, createWorkspaceClient, getExecutionContext } from "@databricks/appkit";
23
19
  import type { WorkspaceClient } from "@databricks/appkit";
20
+ import { pluginRegistry } from "@dbx-tools/appkit";
21
+ import {
22
+ filesCache as filesCachePlugin,
23
+ type FilesCacheExports,
24
+ type FilesCacheScope,
25
+ } from "@dbx-tools/appkit/files-cache";
24
26
  import { DatabricksFileSystem, workspaceClient } from "@dbx-tools/databricks";
25
27
  import { LocalFileSystem } from "@dbx-tools/fs";
28
+ import { match, type PathMatchInput } from "@dbx-tools/path";
26
29
  import { errorUtils, log, object, stringUtils, token } from "@dbx-tools/shared-core";
27
- import type { FileSystem } from "@dbx-tools/shared-fs";
28
- import { LRUCache } from "lru-cache";
30
+ import {
31
+ fs as sharedFS,
32
+ posixPath,
33
+ type CacheableFileSystemOperation,
34
+ type FileSystem,
35
+ type FileSystemCache,
36
+ type FileSystemCacheOptions,
37
+ MemoryFileSystem,
38
+ } from "@dbx-tools/shared-fs";
29
39
  import type { RequestContext } from "@mastra/core/request-context";
30
40
  import {
31
- CompositeFilesystem,
41
+ WORKSPACE_TOOLS,
32
42
  Workspace,
33
43
  type SkillsResolver,
44
+ type ToolConfigWithArgsContext,
45
+ type WorkspaceConfig,
34
46
  type WorkspaceFilesystem,
35
47
  type WorkspaceSandbox,
36
48
  type WorkspaceSandboxResolver,
49
+ type WorkspaceToolConfig,
50
+ type WorkspaceToolsConfig,
37
51
  } from "@mastra/core/workspace";
38
52
 
39
53
  import {
@@ -43,94 +57,129 @@ import {
43
57
  resolveUserKey,
44
58
  type User,
45
59
  } from "./config.ts";
46
- import { filesystems, scratchFilesystem } from "./filesystems.ts";
60
+ import {
61
+ filesystems,
62
+ MountedCompositeFilesystem,
63
+ type MastraFileSystemAdapterOptions,
64
+ } from "./filesystems.ts";
47
65
  import { MontySandbox } from "./monty-sandbox.ts";
48
66
  import { DatabricksSandbox, type DatabricksWorkspaceSandboxOptions } from "./sandbox.ts";
49
- import {
50
- DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS,
51
- workspaceSkillCatalogue,
52
- type SkillCatalogueResolver,
53
- type SkillCatalogueSource,
54
- } from "./skill-cache.ts";
55
- import { ASSISTANT_SHARED_SKILLS_PATH, userAssistantSkillsPath } from "./skill-paths.ts";
67
+ import { ORGANIZATION_ASSISTANT_PATH, personalWorkspacePath } from "./skill-paths.ts";
56
68
 
57
69
  /* ------------------------------ constants ------------------------------ */
58
70
 
59
71
  /** OAuth scopes that gate Databricks workspace file mounts. */
60
72
  const WORKSPACE_FILE_SCOPES = ["workspace", "workspace.workspace", "all-apis"] as const;
73
+ const SCRATCH_MOUNT = "/tmp";
74
+ const CACHEABLE_FILESYSTEM_OPERATIONS = [
75
+ "exists",
76
+ "readFile",
77
+ "readdir",
78
+ "stat",
79
+ ] as const satisfies readonly CacheableFileSystemOperation[];
61
80
 
62
81
  const logger = log.logger("mastra/workspaces");
63
- const MAX_SCOPED_WORKSPACE_FILESYSTEMS = 16;
64
- const scopedWorkspaceFilesystems = new LRUCache<string, WorkspaceFilesystem>({
65
- max: MAX_SCOPED_WORKSPACE_FILESYSTEMS,
66
- });
67
- const workspaceCatalogueResolvers = new WeakMap<Workspace, SkillCatalogueResolver>();
82
+ let workspaceSourceSequence = 0;
83
+ const databricksWorkspaceOptions = new WeakMap<Workspace, DatabricksWorkspaceOptions>();
68
84
 
69
85
  /* -------------------------------- types -------------------------------- */
70
86
 
71
87
  /** Per-request context for mount and skill-folder resolvers. */
72
- export interface WorkspaceMountContext {
88
+ export interface DatabricksWorkspaceContext {
73
89
  requestContext?: RequestContext;
74
90
  }
75
91
 
76
92
  /**
77
- * A skill-folder field given either directly or as a per-request resolver.
78
- * A resolver returning `undefined` skips the folder for that request.
93
+ * One Databricks path mounted into a Mastra workspace.
79
94
  */
80
- export type SkillFolderValue<T> =
81
- T | ((context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>);
82
-
83
- /**
84
- * One named skill-folder location and its read / write policy.
85
- *
86
- * Give {@link path} for a Databricks workspace tree (mounted through the
87
- * request's OBO client), or {@link filesystem} for a mount the consumer builds
88
- * itself. {@link filesystem} wins when both are set.
89
- */
90
- export interface SkillFolderOptions {
91
- /** Absolute Databricks workspace path for this folder. */
92
- path?: SkillFolderValue<string>;
93
- /** Ready-made mount, for locations the OBO client cannot reach. */
94
- filesystem?: SkillFolderValue<WorkspaceFilesystem>;
95
+ export interface DatabricksWorkspacePathOptions {
96
+ /** Absolute Databricks path or `~` shortcut for the current user's workspace home. */
97
+ path: string;
98
+ /** Human-friendly mount name shown in filesystem listings. */
99
+ displayName?: string;
100
+ /** Mount description shown in filesystem listings and workspace instructions. */
101
+ description?: string;
95
102
  /**
96
103
  * Scan this mount for `SKILL.md` files. Defaults to `true`; `false` mounts
97
104
  * the location for file tools without adding it to skill discovery.
98
105
  */
99
106
  readable?: boolean;
100
107
  /**
101
- * Allow writes to a {@link path} mount (and create the root when missing).
102
- * Defaults to `false`. A supplied {@link filesystem} carries its own
103
- * read-only flag instead.
108
+ * Skill roots relative to this filesystem root. Defaults to `["."]`.
109
+ * Each path is joined to the actual mount path before Mastra scans it.
110
+ */
111
+ skills?: readonly string[];
112
+ /**
113
+ * Allow write attempts to a {@link path} mount. Defaults to `true` for
114
+ * `/Workspace` roots and `false` elsewhere. Databricks permissions still
115
+ * determine whether each mutation succeeds.
104
116
  */
105
117
  writable?: boolean;
106
- /** Mount point in the composite namespace. Defaults to `/<name>`. */
118
+ /**
119
+ * Create a writable {@link path} root when missing. Defaults to
120
+ * {@link writable}; set `false` for pre-existing roots with conditional access.
121
+ */
122
+ createRoot?: boolean;
123
+ /** Mount point in the composite namespace. Databricks paths default to their actual root. */
107
124
  mount?: string;
108
125
  }
109
126
 
110
- /** Mount map plus optional Mastra skill scan roots for one resolver. */
111
- export interface WorkspaceMountContribution {
127
+ /** String shorthand or full options for one Databricks workspace path. */
128
+ export type DatabricksWorkspacePath = string | DatabricksWorkspacePathOptions;
129
+
130
+ /** Fixed or request-dependent Databricks path entry. */
131
+ export type DatabricksWorkspacePathValue =
132
+ | DatabricksWorkspacePath
133
+ | false
134
+ | ((
135
+ context: DatabricksWorkspaceContext,
136
+ ) =>
137
+ | DatabricksWorkspacePath
138
+ | false
139
+ | undefined
140
+ | Promise<DatabricksWorkspacePath | false | undefined>);
141
+
142
+ /** Operations and path globs cached by the Databricks filesystem shortcut. */
143
+ export interface DatabricksWorkspaceCacheFilter {
144
+ /** Cached filesystem operations. Omit for `exists`, `readFile`, `readdir`, and `stat`. */
145
+ operations?: CacheableFileSystemOperation | readonly CacheableFileSystemOperation[];
146
+ /** Absolute or root-relative globs and predicates. `~` expands per request. */
147
+ paths?: PathMatchInput | readonly PathMatchInput[];
148
+ }
149
+
150
+ /** Fixed or request-dependent filesystem cache selection. */
151
+ export type DatabricksWorkspaceCache =
152
+ | boolean
153
+ | DatabricksWorkspaceCacheFilter
154
+ | readonly DatabricksWorkspaceCacheFilter[]
155
+ | ((
156
+ context: DatabricksWorkspaceContext,
157
+ ) =>
158
+ | boolean
159
+ | DatabricksWorkspaceCacheFilter
160
+ | readonly DatabricksWorkspaceCacheFilter[]
161
+ | undefined
162
+ | Promise<
163
+ | boolean
164
+ | DatabricksWorkspaceCacheFilter
165
+ | readonly DatabricksWorkspaceCacheFilter[]
166
+ | undefined
167
+ >);
168
+
169
+ /** Mount map plus optional Mastra skill scan roots resolved for one request. */
170
+ interface WorkspaceMountContribution {
112
171
  mounts: Record<string, WorkspaceFilesystem>;
113
- /** Ordered source roots included in catalogue discovery. */
114
- skillSources?: SkillCatalogueSource[];
115
172
  /** Paths within the composite namespace where `SKILL.md` files are scanned. */
116
173
  skillPaths?: string[];
117
- /**
118
- * Stable identity for this contribution. When every non-empty contribution
119
- * supplies one, Mastra reuses the resolved source across requests.
120
- */
121
- cacheKey?: string;
122
174
  }
123
175
 
124
- /** Contributes filesystem mounts (and optional skill paths) for one request. */
125
- export type WorkspaceMountResolver = (
126
- context: WorkspaceMountContext,
176
+ /** Internal filesystem contribution resolver. */
177
+ type WorkspaceMountResolver = (
178
+ context: DatabricksWorkspaceContext,
127
179
  ) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
128
180
 
129
- /** Names carried by {@link DEFAULT_SKILL_FOLDERS}. */
130
- export type DefaultSkillFolderName = "workspace-team" | "workspace-team-app";
131
-
132
181
  /**
133
- * Sandbox selection for {@link createWorkspace}. Databricks is the default;
182
+ * Sandbox selection for {@link databricksWorkspace}. Monty is the default;
134
183
  * `false` disables command execution, and a Mastra provider or resolver is an
135
184
  * explicit replacement.
136
185
  */
@@ -142,36 +191,26 @@ export type WorkspaceSandboxSelection =
142
191
  | WorkspaceSandbox
143
192
  | WorkspaceSandboxResolver;
144
193
 
145
- /** Options for {@link createWorkspace}. */
146
- export interface CreateWorkspaceOptions {
147
- /** Workspace id; derived from `name` or `"workspace"` when omitted. */
148
- id?: string;
149
- /** Display name; derived from `id` when omitted. */
150
- name?: string;
194
+ /** Native Mastra workspace options plus Databricks path shortcuts. */
195
+ export interface DatabricksWorkspaceOptions
196
+ extends Omit<WorkspaceConfig, "filesystem" | "mounts" | "sandbox" | "skills"> {
151
197
  /**
152
- * Start from {@link DEFAULT_SKILL_FOLDERS}. Defaults to `true`; `false`
153
- * starts from an empty map, leaving only the {@link skillFolders} given here.
198
+ * Start from {@link DEFAULT_DATABRICKS_WORKSPACE_PATHS}. Defaults to `true`.
154
199
  */
155
- assistantSkills?: boolean;
200
+ assistantPaths?: boolean;
156
201
  /**
157
- * Named skill folders merged over {@link DEFAULT_SKILL_FOLDERS}: a matching
158
- * name overrides that default, `false` disables it, and any other name adds
159
- * a folder.
202
+ * Databricks paths appended after the defaults. A string is shorthand for
203
+ * `{ path }`; a resolver may return either form per request.
160
204
  */
161
- skillFolders?: Record<string, SkillFolderOptions | false>;
162
- /** Extra per-request mount resolvers (run after the skill-folder mounts). */
163
- mounts?: WorkspaceMountResolver[];
205
+ paths?: readonly DatabricksWorkspacePathValue[];
206
+ /** Native Mastra filesystems composed beside the Databricks paths. */
207
+ mounts?: Record<string, WorkspaceFilesystem>;
164
208
  /** Replace the auto-built dynamic skills resolver. */
165
209
  skills?: SkillsResolver;
166
- /** Forwarded to Mastra when skill discovery is enabled. */
167
- checkSkillFileMtime?: boolean;
168
- /**
169
- * Refresh TTL for the complete parsed skill catalogue. Auxiliary reads are
170
- * always fresh. Defaults to five minutes.
171
- */
172
- workspaceSkillRefreshTtlMs?: number;
173
- /** Enable BM25 keyword search over indexed workspace content. */
174
- bm25?: boolean;
210
+ /** Filesystem cache filters. Ignored when the AppKit files-cache plugin is absent. */
211
+ cache?: DatabricksWorkspaceCache;
212
+ /** AppKit plugin context used to discover optional sibling capabilities. */
213
+ pluginContext?: pluginRegistry.PluginContextLike;
175
214
  /**
176
215
  * Command sandbox. Defaults to Monty. Pass `"databricks"` or an options
177
216
  * object for Databricks Sandbox, `false` to disable command execution, or an
@@ -191,96 +230,123 @@ export interface CreateWorkspaceOptions {
191
230
  /* ------------------------------- defaults ------------------------------- */
192
231
 
193
232
  /**
194
- * The skill folders every workspace starts with.
195
- *
196
- * - `workspace-team` - the shared workspace Assistant tree, read-only because
197
- * writing it is a workspace-admin action.
198
- * - `workspace-team-app` - the requesting user's own Assistant tree, writable
199
- * so the app can save skills back to it. Skipped when the request carries no
200
- * user email.
233
+ * Default Databricks paths for shared and current-user Agent Skills.
201
234
  */
202
- export const DEFAULT_SKILL_FOLDERS: Readonly<Record<DefaultSkillFolderName, SkillFolderOptions>> = {
203
- "workspace-team": {
204
- path: ASSISTANT_SHARED_SKILLS_PATH,
235
+ export const DEFAULT_DATABRICKS_WORKSPACE_PATHS: readonly DatabricksWorkspacePathOptions[] = [
236
+ {
237
+ path: ORGANIZATION_ASSISTANT_PATH,
205
238
  readable: true,
206
- writable: false,
239
+ skills: ["skills"],
240
+ writable: true,
241
+ createRoot: false,
207
242
  },
208
- "workspace-team-app": {
209
- path: ({ requestContext }) => {
210
- const email = resolveScopedEmail(requestContext);
211
- return email ? userAssistantSkillsPath(email) : undefined;
212
- },
243
+ {
244
+ path: "~",
213
245
  readable: true,
246
+ skills: [".assistant/skills"],
214
247
  writable: true,
248
+ createRoot: false,
215
249
  },
216
- };
250
+ ];
217
251
 
218
252
  /**
219
253
  * Create a Mastra {@link Workspace} with per-request Databricks mounts.
220
254
  *
221
- * @example Default skill folders only
255
+ * @example Default Databricks paths
222
256
  * ```ts
223
- * createWorkspace()
257
+ * databricksWorkspace()
224
258
  * ```
225
259
  *
226
- * @example Override a default, drop another, and add a location of your own
260
+ * @example Use only explicit paths
227
261
  * ```ts
228
- * createWorkspace({
229
- * skillFolders: {
230
- * "workspace-team": { path: "/Workspace/Shared/team-skills" },
231
- * "workspace-team-app": false,
232
- * runbooks: { path: "/Workspace/Shared/runbooks/skills", writable: true },
233
- * volume: { filesystem: myVolumeFilesystem },
234
- * },
262
+ * databricksWorkspace({
263
+ * assistantPaths: false,
264
+ * paths: ["~/project", "/tmp/project", "/Volumes/main/default/data"],
235
265
  * })
236
266
  * ```
237
267
  *
238
- * @example Skill folders plus a custom mount resolver
268
+ * @example Build a standard Mastra workspace yourself
239
269
  * ```ts
240
- * createWorkspace({
241
- * mounts: [
242
- * async ({ requestContext }) => ({
243
- * mounts: { "/data": myFilesystem },
244
- * skillPaths: [],
245
- * }),
246
- * ],
270
+ * new Workspace({
271
+ * ...databricksWorkspaceConfig({ paths: ["~/project"] }),
272
+ * sandbox: mySandbox,
247
273
  * })
248
274
  * ```
249
275
  */
250
- export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
276
+ export function databricksWorkspaceConfig(
277
+ options: DatabricksWorkspaceOptions = {},
278
+ ): WorkspaceConfig {
251
279
  const { id, name } = resolveWorkspaceIdentity(options);
252
- const skillFolders = resolveSkillFolders(options);
253
- const folderNames = Object.keys(skillFolders);
280
+ const filesystemSourceKey = `${id}:${++workspaceSourceSequence}`;
281
+ const paths = resolveDatabricksWorkspacePaths(options);
282
+ const configuredPaths = paths.length;
254
283
  const extraSkillPaths = options.extraSkillPaths ?? [];
284
+ const filesCache = pluginRegistry.instance(options.pluginContext, filesCachePlugin)?.exports();
285
+ const configuredCache = options.cache;
286
+ const mountFilesCache = configuredCache === false ? undefined : filesCache;
287
+ const retainFilesystemSource =
288
+ mountFilesCache !== undefined &&
289
+ typeof configuredCache !== "function" &&
290
+ paths.every((path) => typeof path !== "function");
255
291
  const resolvers = buildMountResolvers(
256
- skillFolders,
257
- [...(options.mounts ?? []), ...localSkillMountResolvers(extraSkillPaths)],
292
+ paths,
293
+ options.mounts,
294
+ localSkillMountResolvers(extraSkillPaths),
295
+ mountFilesCache,
296
+ configuredCache,
258
297
  );
259
298
  const resolveContribution = contributionResolver(resolvers);
260
- const skills = options.skills;
299
+ const resolveFilesystem = (context: DatabricksWorkspaceContext) =>
300
+ resolveWorkspaceFilesystem(
301
+ resolveContribution,
302
+ context,
303
+ retainFilesystemSource ? mountFilesCache : undefined,
304
+ filesystemSourceKey,
305
+ );
306
+ const skills: SkillsResolver =
307
+ options.skills ??
308
+ (async ({ requestContext }) => {
309
+ const context = { requestContext };
310
+ const contribution = await resolveContribution(context);
311
+ return resolveDistinctSkillPaths(
312
+ await resolveFilesystem(context),
313
+ contribution.skillPaths ?? [],
314
+ );
315
+ });
261
316
  const checkSkillFileMtime = options.checkSkillFileMtime ?? false;
262
- const bm25 = options.bm25 !== false;
317
+ const bm25 = options.bm25 ?? true;
263
318
  const sandbox = resolveWorkspaceSandbox(options.sandbox, id, name);
319
+ const tools = workspaceTools(options.tools);
264
320
  logger.debug("workspace:create", {
265
321
  id,
266
322
  name,
267
323
  resolverCount: resolvers.length,
268
- skillFolders: folderNames,
269
- customMountResolvers: options.mounts?.length ?? 0,
324
+ configuredPaths,
325
+ customMounts: Object.keys(options.mounts ?? {}).length,
270
326
  customSkillsResolver: Boolean(options.skills),
271
327
  checkSkillFileMtime,
272
- workspaceSkillRefreshTtlMs:
273
- options.workspaceSkillRefreshTtlMs ?? DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS,
274
328
  bm25,
275
329
  extraSkillPaths: extraSkillPaths.length,
276
330
  sandbox: sandbox ? sandboxName(options.sandbox) : "disabled",
277
331
  });
278
332
 
279
- const workspace = new Workspace({
333
+ const {
334
+ assistantPaths: _assistantPaths,
335
+ paths: _paths,
336
+ mounts: _mounts,
337
+ skills: _skills,
338
+ cache: _cache,
339
+ pluginContext: _pluginContext,
340
+ sandbox: _sandbox,
341
+ extraSkillPaths: _extraSkillPaths,
342
+ ...workspaceOptions
343
+ } = options;
344
+ return {
345
+ ...workspaceOptions,
280
346
  id,
281
347
  name,
282
- filesystem: (context) => resolveWorkspaceFilesystem(resolveContribution, context),
283
- ...(skills
348
+ filesystem: resolveFilesystem,
349
+ ...(resolvers.length > 0 || options.skills
284
350
  ? {
285
351
  skills,
286
352
  checkSkillFileMtime,
@@ -293,46 +359,85 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
293
359
  }
294
360
  : {}),
295
361
  bm25,
296
- });
297
- if (!options.skills && resolvers.length > 0) {
298
- workspaceCatalogueResolvers.set(
299
- workspace,
300
- buildSkillCatalogueResolver(resolveContribution, options.workspaceSkillRefreshTtlMs),
301
- );
302
- }
362
+ tools,
363
+ };
364
+ }
365
+
366
+ /** Create a native Mastra {@link Workspace} from {@link databricksWorkspaceConfig}. */
367
+ export function databricksWorkspace(options: DatabricksWorkspaceOptions = {}): Workspace {
368
+ const workspace = new Workspace(databricksWorkspaceConfig(options));
369
+ databricksWorkspaceOptions.set(workspace, options);
303
370
  return workspace;
304
371
  }
305
372
 
306
- /** Return the dbx-tools catalogue resolver attached to one workspace. */
307
- export function workspaceSkillCatalogueResolver(
308
- workspace: Workspace | undefined,
309
- ): SkillCatalogueResolver | undefined {
310
- return workspace ? workspaceCatalogueResolvers.get(workspace) : undefined;
373
+ /** @internal Bind a caller-created Databricks workspace to its AppKit plugin context. */
374
+ export function bindDatabricksWorkspaceContext(
375
+ workspace: Workspace,
376
+ pluginContext: pluginRegistry.PluginContextLike | undefined,
377
+ ): Workspace {
378
+ const options = databricksWorkspaceOptions.get(workspace);
379
+ if (!options || options.pluginContext !== undefined || pluginContext === undefined) {
380
+ return workspace;
381
+ }
382
+ return databricksWorkspace({ ...options, pluginContext });
311
383
  }
312
384
 
313
385
  /**
314
- * Merge the configured skill folders over {@link DEFAULT_SKILL_FOLDERS}.
315
- *
316
- * `assistantSkills: false` drops the defaults, and a `false` value removes one
317
- * entry by name.
386
+ * Resolve default and caller-provided Databricks paths in priority order.
318
387
  */
319
- export function resolveSkillFolders(
320
- options: Pick<CreateWorkspaceOptions, "assistantSkills" | "skillFolders"> = {},
321
- ): Record<string, SkillFolderOptions> {
322
- const merged: Record<string, SkillFolderOptions> =
323
- options.assistantSkills === false ? {} : { ...DEFAULT_SKILL_FOLDERS };
324
- for (const [name, folder] of Object.entries(options.skillFolders ?? {})) {
325
- if (folder === false) {
326
- delete merged[name];
327
- } else {
328
- merged[name] = folder;
329
- }
330
- }
331
- return merged;
388
+ export function resolveDatabricksWorkspacePaths(
389
+ options: Pick<DatabricksWorkspaceOptions, "assistantPaths" | "paths"> = {},
390
+ ): DatabricksWorkspacePathValue[] {
391
+ return [
392
+ ...(options.assistantPaths === false ? [] : DEFAULT_DATABRICKS_WORKSPACE_PATHS),
393
+ ...(options.paths ?? []),
394
+ ];
332
395
  }
333
396
 
334
397
  /* ---------------------------- private helpers ---------------------------- */
335
398
 
399
+ /** Require approval unless a filesystem mutation stays inside a user-owned writable root. */
400
+ function requireApprovalOutsideWritableRoots({
401
+ args,
402
+ requestContext,
403
+ }: ToolConfigWithArgsContext): boolean {
404
+ const inputPath = args.path;
405
+ if (typeof inputPath !== "string" || !posixPath.isAbsolute(inputPath)) return true;
406
+ const normalized = posixPath.normalize(inputPath);
407
+ if (!normalized.ok) return true;
408
+ if (posixPath.isWithinRoot(SCRATCH_MOUNT, normalized.path)) return false;
409
+ const emailValue = requestContext[MASTRA_USER_EMAIL_KEY];
410
+ const email = typeof emailValue === "string" ? stringUtils.trimToNull(emailValue) : undefined;
411
+ if (!email) return true;
412
+ return !posixPath.isWithinRoot(personalWorkspacePath(email), normalized.path);
413
+ }
414
+
415
+ function workspaceTools(configured: WorkspaceToolsConfig | undefined): WorkspaceToolsConfig {
416
+ const requireApproval: WorkspaceToolConfig =
417
+ configured?.requireApproval === undefined
418
+ ? { requireApproval: requireApprovalOutsideWritableRoots }
419
+ : {};
420
+ const defaults = {
421
+ [WORKSPACE_TOOLS.FILESYSTEM.WRITE_FILE]: requireApproval,
422
+ [WORKSPACE_TOOLS.FILESYSTEM.EDIT_FILE]: requireApproval,
423
+ [WORKSPACE_TOOLS.FILESYSTEM.AST_EDIT]: requireApproval,
424
+ [WORKSPACE_TOOLS.FILESYSTEM.DELETE]: requireApproval,
425
+ [WORKSPACE_TOOLS.FILESYSTEM.MKDIR]: requireApproval,
426
+ } satisfies WorkspaceToolsConfig;
427
+ const configuredTools = configured as
428
+ Readonly<Record<string, WorkspaceToolConfig | undefined>> | undefined;
429
+ const mergedTools = Object.fromEntries(
430
+ Object.entries(defaults).map(([name, defaultConfig]) => [
431
+ name,
432
+ { ...defaultConfig, ...configuredTools?.[name] },
433
+ ]),
434
+ );
435
+ return {
436
+ ...defaults,
437
+ ...configured,
438
+ ...mergedTools,
439
+ };
440
+ }
336
441
  function resolveWorkspaceSandbox(
337
442
  selection: WorkspaceSandboxSelection | undefined,
338
443
  workspaceId: string,
@@ -410,6 +515,48 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
410
515
  );
411
516
  }
412
517
 
518
+ /** Resolve the request client and authorization-safe files-cache scope. */
519
+ async function resolveFilesCacheScope(context: DatabricksWorkspaceContext): Promise<
520
+ | {
521
+ client: WorkspaceClient;
522
+ scope: FilesCacheScope;
523
+ }
524
+ | undefined
525
+ > {
526
+ const requestContext = context.requestContext;
527
+ const user = requestContext?.get(MASTRA_USER_KEY) as User | undefined;
528
+ const client = user?.executionContext.client as WorkspaceClient | undefined;
529
+ if (!requestContext || !user || !client) return undefined;
530
+ return {
531
+ client,
532
+ scope: {
533
+ host: (await client.config.getHost()).toString(),
534
+ userKey: resolveUserKey(requestContext),
535
+ ...(process.env.DATABRICKS_WORKSPACE_ID?.trim()
536
+ ? { workspaceId: process.env.DATABRICKS_WORKSPACE_ID.trim() }
537
+ : {}),
538
+ },
539
+ };
540
+ }
541
+
542
+ /** Join one relative skill root to its actual composite mount path. */
543
+ function mountedSkillPath(mount: string, skillRoot: string): string {
544
+ if (posixPath.isAbsolute(skillRoot)) {
545
+ throw new TypeError(`Skill path must be relative to its mount: ${skillRoot}`);
546
+ }
547
+ const normalized = posixPath.normalize(skillRoot);
548
+ if (!normalized.ok) {
549
+ throw new TypeError(`Skill path escapes its mount: ${skillRoot}`);
550
+ }
551
+ return posixPath.join(posixPath.normalizeRoot(mount), normalized.path.slice(1));
552
+ }
553
+
554
+ /** Return whether a Databricks path belongs to the writable Workspace namespace. */
555
+ function isWorkspaceRoot(root: string): boolean {
556
+ const normalized = posixPath.normalizeRoot(root);
557
+ return normalized === "/Workspace" || normalized.startsWith("/Workspace/");
558
+ }
559
+
413
560
  /**
414
561
  * Mount resolver for the named skill folders.
415
562
  *
@@ -417,42 +564,54 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
417
564
  * folder whose location resolves for this request.
418
565
  */
419
566
  async function resolveSkillFolderMounts(
420
- skillFolders: Record<string, SkillFolderOptions>,
421
- context: WorkspaceMountContext,
567
+ paths: readonly DatabricksWorkspacePathValue[],
568
+ context: DatabricksWorkspaceContext,
569
+ filesCache: FilesCacheExports | undefined,
570
+ cacheConfig: DatabricksWorkspaceCache | undefined,
422
571
  ): Promise<WorkspaceMountContribution> {
423
572
  const mounts: Record<string, WorkspaceFilesystem> = {};
424
- const skillSources: SkillCatalogueSource[] = [];
425
573
  const skillPaths: string[] = [];
426
- const cacheKeys: string[] = [];
427
574
  const requestContext = context.requestContext;
428
575
 
429
- if (!requestContext || !shouldMountSkillFolders(requestContext)) {
430
- logger.debug("skill-folders:skipped", {
431
- reason: !requestContext ? "no-request-context" : "missing-workspace-scope",
432
- nodeEnv: process.env.NODE_ENV,
433
- scopes: requestContext?.get(MASTRA_SCOPES_KEY),
434
- });
435
- return { mounts, skillPaths };
436
- }
576
+ const canMountDatabricks = Boolean(requestContext && shouldMountSkillFolders(requestContext));
577
+ const scoped = canMountDatabricks ? await resolveFilesCacheScope(context) : undefined;
578
+ const client = scoped?.client;
579
+ const fileSystemCache =
580
+ filesCache && scoped ? await filesCache.forScope(scoped.scope) : undefined;
437
581
 
438
- const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
439
- const client = user?.executionContext.client as WorkspaceClient | undefined;
440
- const userKey = user ? resolveUserKey(requestContext) : undefined;
441
- const host = client ? (await client.config.getHost()).toString() : undefined;
442
-
443
- for (const [name, folder] of Object.entries(skillFolders)) {
444
- const resolved = await resolveSkillFolderFilesystem(name, folder, context, {
582
+ for (const [index, path] of paths.entries()) {
583
+ const configured = await resolveDatabricksWorkspacePath(path, context);
584
+ if (!configured) continue;
585
+ if (posixPath.isWithinRoot(SCRATCH_MOUNT, configured.path)) {
586
+ const mount = configured.mount ?? configured.path;
587
+ mounts[mount] = userTempFilesystem(context, configured.path);
588
+ if (configured.readable !== false) {
589
+ skillPaths.push(
590
+ ...(configured.skills ?? []).map((path) => mountedSkillPath(mount, path)),
591
+ );
592
+ }
593
+ continue;
594
+ }
595
+ if (!canMountDatabricks) {
596
+ logger.debug("workspace-path:skipped", {
597
+ path: configured.path,
598
+ reason: !requestContext ? "no-request-context" : "missing-workspace-scope",
599
+ });
600
+ continue;
601
+ }
602
+ const resolved = await resolveSkillFolderFilesystem(configured, context, cacheConfig, {
445
603
  client,
446
- host,
447
- userKey,
604
+ fileSystemCache,
448
605
  });
449
606
  if (!resolved) continue;
450
- const mount = folder.mount ?? `/${name}`;
607
+ const mount = configured.mount ?? resolved.root;
608
+ if (!mount) {
609
+ logger.debug("workspace-path:skipped", { index, reason: "missing-mount" });
610
+ continue;
611
+ }
451
612
  mounts[mount] = resolved.filesystem;
452
- if (resolved.cacheKey) cacheKeys.push(resolved.cacheKey);
453
- if (folder.readable !== false) {
454
- skillPaths.push(mount);
455
- skillSources.push({ ...resolved.skillSource, id: object.toStableKey({ mount, source: resolved.skillSource.id }) });
613
+ if (configured.readable !== false) {
614
+ skillPaths.push(...(configured.skills ?? ["."]).map((path) => mountedSkillPath(mount, path)));
456
615
  }
457
616
  }
458
617
 
@@ -463,11 +622,36 @@ async function resolveSkillFolderMounts(
463
622
 
464
623
  return {
465
624
  mounts,
466
- skillSources,
467
625
  skillPaths,
468
- ...(cacheKeys.length === Object.keys(mounts).length && cacheKeys.length > 0
469
- ? { cacheKey: object.toStableKey(cacheKeys.sort()) }
470
- : {}),
626
+ };
627
+ }
628
+
629
+ interface ResolvedDatabricksWorkspacePath {
630
+ path: string;
631
+ displayName?: string;
632
+ description?: string;
633
+ readable?: boolean;
634
+ skills?: readonly string[];
635
+ writable?: boolean;
636
+ createRoot?: boolean;
637
+ mount?: string;
638
+ }
639
+
640
+ async function resolveDatabricksWorkspacePath(
641
+ value: DatabricksWorkspacePathValue,
642
+ context: DatabricksWorkspaceContext,
643
+ ): Promise<ResolvedDatabricksWorkspacePath | undefined> {
644
+ const selected = typeof value === "function" ? await value(context) : value;
645
+ if (selected === false || selected === undefined) return undefined;
646
+ const configured = typeof selected === "string" ? { path: selected } : selected;
647
+ const requestContext = requestContextValues(context.requestContext);
648
+ const resolvedPath = resolveHomePath(configured.path, requestContext);
649
+ if (!resolvedPath) return undefined;
650
+ const resolvedMount = resolveHomePath(configured.mount, requestContext);
651
+ return {
652
+ ...configured,
653
+ path: resolvedPath,
654
+ ...(resolvedMount ? { mount: resolvedMount } : {}),
471
655
  };
472
656
  }
473
657
 
@@ -476,69 +660,164 @@ async function resolveSkillFolderMounts(
476
660
  * for this request.
477
661
  */
478
662
  async function resolveSkillFolderFilesystem(
479
- name: string,
480
- folder: SkillFolderOptions,
481
- context: WorkspaceMountContext,
663
+ folder: ResolvedDatabricksWorkspacePath,
664
+ context: DatabricksWorkspaceContext,
665
+ cacheConfig: DatabricksWorkspaceCache | undefined,
482
666
  cache: {
483
667
  client: WorkspaceClient | undefined;
484
- host: string | undefined;
485
- userKey: string | undefined;
668
+ fileSystemCache: FileSystemCache | undefined;
486
669
  },
487
670
  ): Promise<
488
671
  | {
489
- cacheKey?: string;
490
672
  filesystem: WorkspaceFilesystem;
491
- skillSource: SkillCatalogueSource;
673
+ root?: string;
492
674
  }
493
675
  | undefined
494
676
  > {
495
- if (folder.filesystem !== undefined) {
496
- const filesystem = await resolveSkillFolderValue(folder.filesystem, context);
497
- return filesystem
498
- ? {
499
- filesystem,
500
- skillSource: mastraSkillSource(
501
- object.toStableKey({
502
- id: filesystem.id,
503
- name,
504
- provider: filesystem.provider,
505
- }),
506
- filesystem,
507
- ),
508
- }
509
- : undefined;
510
- }
511
677
  // A path mount needs the request's OBO client to reach the workspace.
512
- if (folder.path === undefined || !cache.client || !cache.host || !cache.userKey) {
678
+ if (!cache.client) {
513
679
  logger.debug("skill-folder:skipped", {
514
- name,
515
- reason: folder.path === undefined ? "no-location" : "missing-obo-client",
680
+ path: folder.path,
681
+ reason: "missing-obo-client",
516
682
  });
517
683
  return undefined;
518
684
  }
519
- const root = stringUtils.trimToNull(await resolveSkillFolderValue(folder.path, context));
685
+ const root = stringUtils.trimToNull(folder.path);
520
686
  if (!root) return undefined;
521
- return databricksFilesystem(cache.client, root, folder.writable !== true, {
522
- host: cache.host,
523
- userKey: cache.userKey,
524
- });
687
+ const writable = folder.writable ?? isWorkspaceRoot(root);
688
+ const cacheOptions = cache.fileSystemCache
689
+ ? await resolveFileCacheOptions(
690
+ cacheConfig,
691
+ context,
692
+ root,
693
+ folder.readable === false ? [] : (folder.skills ?? ["."]),
694
+ )
695
+ : undefined;
696
+ const resolved = await databricksFilesystem(
697
+ cache.client,
698
+ root,
699
+ !writable,
700
+ folder.createRoot ?? folder.writable === true,
701
+ cache.fileSystemCache,
702
+ cacheOptions,
703
+ {
704
+ ...(folder.description ? { description: folder.description } : {}),
705
+ ...(folder.displayName ? { displayName: folder.displayName } : {}),
706
+ },
707
+ );
708
+ return resolved ? { ...resolved, root } : undefined;
525
709
  }
526
710
 
527
- /** Read a {@link SkillFolderValue}, calling it when it is a per-request resolver. */
528
- function resolveSkillFolderValue<T>(
529
- value: SkillFolderValue<T>,
530
- context: WorkspaceMountContext,
531
- ): T | undefined | Promise<T | undefined> {
711
+ async function resolveCacheConfig(
712
+ value: DatabricksWorkspaceCache | undefined,
713
+ context: DatabricksWorkspaceContext,
714
+ ): Promise<
715
+ | boolean
716
+ | DatabricksWorkspaceCacheFilter
717
+ | readonly DatabricksWorkspaceCacheFilter[]
718
+ | undefined
719
+ > {
532
720
  return typeof value === "function"
533
- ? (value as (context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>)(context)
721
+ ? value(context)
534
722
  : value;
535
723
  }
536
724
 
725
+ function requestContextValues(requestContext: RequestContext | undefined): Record<string, unknown> {
726
+ return requestContext?.toJSON() ?? {};
727
+ }
728
+
729
+ function resolveHomePath(
730
+ input: string | undefined,
731
+ requestContext: Record<string, unknown>,
732
+ ): string | undefined {
733
+ const value = stringUtils.trimToNull(input);
734
+ if (!value || (value !== "~" && !value.startsWith("~/"))) return value ?? undefined;
735
+ const emailValue = requestContext[MASTRA_USER_EMAIL_KEY];
736
+ const email = typeof emailValue === "string" ? stringUtils.trimToNull(emailValue) : undefined;
737
+ if (!email) return undefined;
738
+ const home = personalWorkspacePath(email);
739
+ return value === "~" ? home : `${home}/${value.slice(2)}`;
740
+ }
741
+
742
+ function toPolicyArray<T>(value: T | readonly T[] | undefined): readonly T[] {
743
+ if (value === undefined) return [];
744
+ return Array.isArray(value) ? (value as readonly T[]) : [value as T];
745
+ }
746
+
747
+ function cacheFilterMatches(
748
+ policy: DatabricksWorkspaceCacheFilter,
749
+ operation: CacheableFileSystemOperation,
750
+ path: string,
751
+ requestContext: Record<string, unknown>,
752
+ root?: string,
753
+ ): boolean {
754
+ const operations = toPolicyArray(policy.operations ?? CACHEABLE_FILESYSTEM_OPERATIONS);
755
+ if (!operations.includes(operation)) return false;
756
+ if (policy.paths === undefined) return true;
757
+ const inputs: PathMatchInput[] = [];
758
+ for (const input of toPolicyArray<PathMatchInput>(policy.paths)) {
759
+ if (typeof input !== "string") {
760
+ inputs.push(input);
761
+ continue;
762
+ }
763
+ const expanded = resolveHomePath(input, requestContext);
764
+ if (!expanded) continue;
765
+ inputs.push(
766
+ !root || posixPath.isAbsolute(expanded) || expanded.startsWith("**")
767
+ ? expanded
768
+ : `${posixPath.normalizeRoot(root)}/${expanded.replace(/^\.\//, "")}`,
769
+ );
770
+ }
771
+ return inputs.length > 0 && match.toPathMatcher(...inputs)(path);
772
+ }
773
+
774
+ async function resolveFileCacheOptions(
775
+ configured: DatabricksWorkspaceCache | undefined,
776
+ context: DatabricksWorkspaceContext,
777
+ root: string,
778
+ skillPaths: readonly string[],
779
+ ): Promise<FileSystemCacheOptions | undefined> {
780
+ const requestContext = requestContextValues(context.requestContext);
781
+ const resolved = await resolveCacheConfig(configured, context);
782
+ if (resolved === false) return undefined;
783
+ if (resolved === undefined || resolved === true) {
784
+ const roots = skillPaths.map((path) => mountedSkillPath(root, path));
785
+ return {
786
+ operations: CACHEABLE_FILESYSTEM_OPERATIONS,
787
+ filter: (operation, path) =>
788
+ operation !== "readFile" ||
789
+ roots.some((skillRoot) => posixPath.isWithinRoot(skillRoot, path)),
790
+ };
791
+ }
792
+ const policies = toPolicyArray(resolved);
793
+ if (policies.length === 0) return undefined;
794
+ const operations = [
795
+ ...new Set(
796
+ policies.flatMap((policy) =>
797
+ toPolicyArray(policy.operations ?? CACHEABLE_FILESYSTEM_OPERATIONS),
798
+ ),
799
+ ),
800
+ ];
801
+ return {
802
+ operations,
803
+ filter: (operation, path) =>
804
+ policies.some((policy) =>
805
+ cacheFilterMatches(
806
+ policy,
807
+ operation,
808
+ path,
809
+ requestContext,
810
+ root,
811
+ ),
812
+ ),
813
+ };
814
+ }
815
+
537
816
  /**
538
- * Fill in `id` and `name` when either is omitted on {@link CreateWorkspaceOptions}.
817
+ * Fill in `id` and `name` when either is omitted on {@link DatabricksWorkspaceOptions}.
539
818
  * Slugifies `name` into `id`; tokenizes `id` into a display `name`.
540
819
  */
541
- function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
820
+ function resolveWorkspaceIdentity(options: DatabricksWorkspaceOptions): {
542
821
  id: string;
543
822
  name: string;
544
823
  } {
@@ -555,29 +834,58 @@ function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
555
834
 
556
835
  /** Collect the skill-folder resolver and any caller-supplied ones. */
557
836
  function buildMountResolvers(
558
- skillFolders: Record<string, SkillFolderOptions>,
559
- mounts: WorkspaceMountResolver[] | undefined,
837
+ paths: readonly DatabricksWorkspacePathValue[],
838
+ mounts: Record<string, WorkspaceFilesystem> | undefined,
839
+ localMounts: WorkspaceMountResolver[],
840
+ filesCache: FilesCacheExports | undefined,
841
+ cacheConfig: DatabricksWorkspaceCache | undefined,
560
842
  ): WorkspaceMountResolver[] {
561
843
  const resolvers: WorkspaceMountResolver[] = [];
562
- const folderCount = Object.keys(skillFolders).length;
563
- if (folderCount > 0) {
564
- resolvers.push((context) => resolveSkillFolderMounts(skillFolders, context));
565
- }
566
- if (mounts?.length) {
567
- resolvers.push(...mounts);
844
+ const pathCount = paths.length;
845
+ if (pathCount > 0) {
846
+ resolvers.push((context) =>
847
+ resolveSkillFolderMounts(paths, context, filesCache, cacheConfig),
848
+ );
568
849
  }
850
+ if (mounts && Object.keys(mounts).length > 0) resolvers.push(() => ({ mounts }));
851
+ resolvers.push(...localMounts);
569
852
  logger.debug("mounts:resolvers", {
570
- skillFolderCount: folderCount,
571
- customResolverCount: mounts?.length ?? 0,
853
+ pathCount,
854
+ customMountCount: Object.keys(mounts ?? {}).length,
855
+ localMountCount: localMounts.length,
572
856
  totalResolverCount: resolvers.length,
573
857
  });
574
858
  return resolvers;
575
859
  }
576
860
 
861
+ /** Stable user-scoped local storage under the host operating system's temp directory. */
862
+ function userTempFilesystem(
863
+ context: DatabricksWorkspaceContext,
864
+ workspacePath: string,
865
+ ): WorkspaceFilesystem {
866
+ const userKey = resolveUserKey(context.requestContext);
867
+ const digest = createHash("sha256")
868
+ .update(object.toStableKey({ package: "@dbx-tools/appkit-mastra", userKey }))
869
+ .digest("hex")
870
+ .slice(0, 16);
871
+ const normalized = posixPath.normalizeRoot(workspacePath);
872
+ const relative = normalized === SCRATCH_MOUNT ? "" : normalized.slice(`${SCRATCH_MOUNT}/`.length);
873
+ const source = new LocalFileSystem({
874
+ root: join(tmpdir(), "dbx-tools", "mastra", digest, relative),
875
+ readOnly: false,
876
+ createRoot: true,
877
+ });
878
+ return filesystems(source, {
879
+ displayName: "Temporary Files",
880
+ description:
881
+ "User-scoped temporary files retained while the host keeps its ephemeral temp storage.",
882
+ });
883
+ }
884
+
577
885
  /** Mount startup-provisioned local skill roots into Mastra's workspace filesystem. */
578
886
  function localSkillMountResolvers(paths: readonly string[]): WorkspaceMountResolver[] {
579
- return [...new Set(paths.map((path) => path.trim()).filter(Boolean))].map((root, index) => {
580
- const mount = `/remote-skills/${index}`;
887
+ return [...new Set(paths.map((path) => path.trim()).filter(Boolean))].map((root) => {
888
+ const mount = posixPath.normalizeRoot(root);
581
889
  const source = new LocalFileSystem({
582
890
  root,
583
891
  readOnly: true,
@@ -586,51 +894,11 @@ function localSkillMountResolvers(paths: readonly string[]): WorkspaceMountResol
586
894
  const filesystem = filesystems(source, { readOnly: true });
587
895
  return () => ({
588
896
  mounts: { [mount]: filesystem },
589
- skillSources: [sharedSkillSource(object.toStableKey({ mount, root }), source)],
590
897
  skillPaths: [mount],
591
- cacheKey: object.toStableKey({ mount, root }),
592
898
  });
593
899
  });
594
900
  }
595
901
 
596
- /** Adapt a portable dbx-tools filesystem to catalogue discovery callbacks. */
597
- function sharedSkillSource(id: string, filesystem: FileSystem): SkillCatalogueSource {
598
- return {
599
- id,
600
- async list(path) {
601
- await filesystem.init();
602
- return (await filesystem.readdir(path)).flatMap((entry) =>
603
- entry.type === "file" || entry.type === "directory"
604
- ? [
605
- {
606
- name: entry.name,
607
- type: entry.type,
608
- ...(entry.size !== undefined ? { size: entry.size } : {}),
609
- ...(entry.metadata ? { metadata: entry.metadata } : {}),
610
- },
611
- ]
612
- : [],
613
- );
614
- },
615
- async read(path) {
616
- await filesystem.init();
617
- return filesystem.readFile(path, { encoding: "utf8" });
618
- },
619
- };
620
- }
621
-
622
- /** Adapt an arbitrary Mastra filesystem when no portable source is available. */
623
- function mastraSkillSource(id: string, filesystem: WorkspaceFilesystem): SkillCatalogueSource {
624
- return {
625
- id,
626
- list: (path) => filesystem.readdir(path),
627
- async read(path) {
628
- const value = await filesystem.readFile(path, { encoding: "utf8" });
629
- return typeof value === "string" ? value : value.toString("utf8");
630
- },
631
- };
632
- }
633
-
634
902
  /**
635
903
  * Gate skill-folder mounts on the request's token.
636
904
  *
@@ -642,53 +910,49 @@ function shouldMountSkillFolders(requestContext: RequestContext): boolean {
642
910
  return hasWorkspaceFileScope(requestContext);
643
911
  }
644
912
 
645
- /** Read the trimmed OBO user email stamped on {@link MASTRA_USER_EMAIL_KEY}. */
646
- function resolveScopedEmail(requestContext: RequestContext | undefined): string | undefined {
647
- return stringUtils.trimToNull(requestContext?.get(MASTRA_USER_EMAIL_KEY)) ?? undefined;
648
- }
649
-
650
913
  /**
651
- * Wrap a {@link DatabricksFileSystem} as a Mastra filesystem. When the root is
652
- * missing (and {@link readOnly} so we will not create it), fall back to
653
- * {@link scratchFilesystem} so skill scans still have a writable local mount.
914
+ * Wrap a {@link DatabricksFileSystem} as a Mastra filesystem. Missing or
915
+ * inaccessible roots return `undefined` so other accessible roots remain
916
+ * usable.
654
917
  */
655
918
  async function databricksFilesystem(
656
919
  client: WorkspaceClient,
657
920
  root: string,
658
921
  readOnly: boolean = true,
659
- cache: { host: string; userKey: string },
922
+ createRoot: boolean = !readOnly,
923
+ fileSystemCache?: FileSystemCache,
924
+ cacheOptions?: FileSystemCacheOptions,
925
+ adapterOptions: Pick<MastraFileSystemAdapterOptions, "description" | "displayName"> = {},
660
926
  ): Promise<
661
- {
662
- cacheKey: string;
663
- filesystem: WorkspaceFilesystem;
664
- skillSource: SkillCatalogueSource;
665
- }
927
+ | {
928
+ filesystem: WorkspaceFilesystem;
929
+ }
666
930
  | undefined
667
931
  > {
932
+ const initialClient = workspaceClient.toLegacyWorkspaceClient(client);
668
933
  const fs = new DatabricksFileSystem({
669
- client: workspaceClient.toLegacyWorkspaceClient(client),
934
+ client: () => {
935
+ try {
936
+ return workspaceClient.toLegacyWorkspaceClient(getExecutionContext().client);
937
+ } catch {
938
+ return initialClient;
939
+ }
940
+ },
670
941
  root,
671
942
  readOnly,
672
- createRoot: !readOnly,
673
- });
674
- const cacheKey = object.toStableKey({
675
- host: cache.host,
676
- readOnly,
677
- root,
678
- userKey: cache.userKey,
943
+ createRoot,
679
944
  });
680
- const filesystem = filesystems(fs, { readOnly });
945
+ const cached =
946
+ fileSystemCache && cacheOptions ? sharedFS.cache(fs, fileSystemCache, cacheOptions) : fs;
947
+ const source = verifyFirstFileSystemAccess(cached, () => fs.stat("."), root);
948
+ const filesystem = filesystems(source, { ...adapterOptions, readOnly });
681
949
  try {
682
950
  await filesystem.init();
683
951
  if (await filesystem.exists(".")) {
684
- return {
685
- cacheKey,
686
- filesystem,
687
- skillSource: sharedSkillSource(cacheKey, fs),
688
- };
952
+ return { filesystem };
689
953
  }
690
954
  } catch (err) {
691
- logger.debug("databricks-mount:scratch-fallback", {
955
+ logger.debug("databricks-mount:skipped", {
692
956
  root,
693
957
  readOnly,
694
958
  error: errorUtils.errorMessage(err),
@@ -697,19 +961,86 @@ async function databricksFilesystem(
697
961
  return undefined;
698
962
  }
699
963
 
964
+ function verifyFirstFileSystemAccess<TFileSystem extends FileSystem>(
965
+ filesystem: TFileSystem,
966
+ readProbe: () => Promise<unknown>,
967
+ root: string,
968
+ ): TFileSystem {
969
+ let readCheck: Promise<void> | undefined;
970
+ let writeVerified = false;
971
+ const boundMethods = new Map<PropertyKey, unknown>();
972
+ const readOperations = new Set<PropertyKey>(["exists", "readFile", "readdir", "stat"]);
973
+ const writeOperations = new Set<PropertyKey>([
974
+ "appendFile",
975
+ "copyFile",
976
+ "deleteFile",
977
+ "mkdir",
978
+ "moveFile",
979
+ "rmdir",
980
+ "writeFile",
981
+ ]);
982
+ const verifyRead = async (): Promise<void> => {
983
+ readCheck ??= Promise.resolve(readProbe()).then(() => undefined);
984
+ try {
985
+ await readCheck;
986
+ } catch (error) {
987
+ readCheck = undefined;
988
+ logger.debug("databricks-mount:read-check-failed", {
989
+ root,
990
+ error: errorUtils.errorMessage(error),
991
+ });
992
+ throw error;
993
+ }
994
+ };
995
+
996
+ return new Proxy(filesystem, {
997
+ get(target, property) {
998
+ const value = Reflect.get(target, property, target);
999
+ if (typeof value !== "function") return value;
1000
+ if (boundMethods.has(property)) return boundMethods.get(property);
1001
+ const delegated = value.bind(target) as (...args: unknown[]) => unknown;
1002
+ let method = delegated;
1003
+ if (readOperations.has(property)) {
1004
+ method = async (...args: unknown[]) => {
1005
+ await verifyRead();
1006
+ return delegated(...args);
1007
+ };
1008
+ } else if (writeOperations.has(property)) {
1009
+ method = async (...args: unknown[]) => {
1010
+ try {
1011
+ const result = await delegated(...args);
1012
+ if (!writeVerified) {
1013
+ writeVerified = true;
1014
+ logger.debug("databricks-mount:write-check-passed", { root });
1015
+ }
1016
+ return result;
1017
+ } catch (error) {
1018
+ if (!writeVerified) {
1019
+ logger.debug("databricks-mount:write-check-failed", {
1020
+ root,
1021
+ error: errorUtils.errorMessage(error),
1022
+ });
1023
+ }
1024
+ throw error;
1025
+ }
1026
+ };
1027
+ }
1028
+ boundMethods.set(property, method);
1029
+ return method;
1030
+ },
1031
+ });
1032
+ }
1033
+
700
1034
  /**
701
1035
  * Run every mount resolver for one request and merge mounts plus skill paths.
702
1036
  * Later resolvers overwrite mount keys from earlier ones.
703
1037
  */
704
1038
  async function resolveWorkspaceContribution(
705
1039
  resolvers: WorkspaceMountResolver[],
706
- context: WorkspaceMountContext,
1040
+ context: DatabricksWorkspaceContext,
707
1041
  ): Promise<WorkspaceMountContribution> {
708
1042
  const mounts: Record<string, WorkspaceFilesystem> = {};
709
- const skillSources: SkillCatalogueSource[] = [];
710
1043
  const skillPaths: string[] = [];
711
- const cacheKeys: string[] = [];
712
- let cacheable = true;
713
1044
 
714
1045
  for (const [index, resolver] of resolvers.entries()) {
715
1046
  const contribution = await resolver(context);
@@ -719,16 +1050,9 @@ async function resolveWorkspaceContribution(
719
1050
  skillPaths: contribution.skillPaths ?? [],
720
1051
  });
721
1052
  Object.assign(mounts, contribution.mounts);
722
- if (Object.keys(contribution.mounts).length > 0) {
723
- if (contribution.cacheKey) cacheKeys.push(contribution.cacheKey);
724
- else cacheable = false;
725
- }
726
1053
  if (contribution.skillPaths?.length) {
727
1054
  skillPaths.push(...contribution.skillPaths);
728
1055
  }
729
- if (contribution.skillSources?.length) {
730
- skillSources.push(...contribution.skillSources);
731
- }
732
1056
  }
733
1057
 
734
1058
  logger.debug("mounts:merged", {
@@ -738,45 +1062,76 @@ async function resolveWorkspaceContribution(
738
1062
 
739
1063
  return {
740
1064
  mounts,
741
- skillSources,
742
1065
  skillPaths,
743
- ...(cacheable && cacheKeys.length > 0
744
- ? { cacheKey: object.toStableKey({ mounts: Object.keys(mounts), sources: cacheKeys }) }
745
- : {}),
746
1066
  };
747
1067
  }
748
1068
 
749
1069
  /**
750
1070
  * Dynamic filesystem resolver passed to Mastra {@link Workspace}.
751
1071
  *
752
- * Returns a {@link CompositeFilesystem} when any mount resolved; otherwise a
753
- * fresh {@link scratchFilesystem} so Mastra always has a writable local root.
1072
+ * Returns a {@link CompositeFilesystem} over every path that resolved for the
1073
+ * request. An empty composite exposes no implicit local storage.
754
1074
  */
755
1075
  async function resolveWorkspaceFilesystem(
756
- resolveContribution: (context: WorkspaceMountContext) => Promise<WorkspaceMountContribution>,
757
- context: WorkspaceMountContext,
1076
+ resolveContribution: (context: DatabricksWorkspaceContext) => Promise<WorkspaceMountContribution>,
1077
+ context: DatabricksWorkspaceContext,
1078
+ filesCache?: FilesCacheExports,
1079
+ sourceKey?: string,
758
1080
  ): Promise<WorkspaceFilesystem> {
759
- const { cacheKey, mounts } = await resolveContribution(context);
1081
+ const { mounts } = await resolveContribution(context);
760
1082
  const mountKeys = Object.keys(mounts);
761
1083
  if (mountKeys.length === 0) {
762
- logger.debug("filesystem:scratch", {
1084
+ logger.debug("filesystem:empty", {
763
1085
  hasRequestContext: Boolean(context.requestContext),
764
1086
  });
765
- return scratchFilesystem();
1087
+ return filesystems(new MemoryFileSystem({ root: "/", readOnly: true }), {
1088
+ readOnly: true,
1089
+ });
766
1090
  }
767
1091
  logger.debug("filesystem:composite", { mountKeys });
768
- if (!cacheKey) return new CompositeFilesystem({ mounts });
769
- const existing = scopedWorkspaceFilesystems.get(cacheKey);
770
- if (existing) return existing;
771
- const filesystem = new CompositeFilesystem({ mounts });
772
- scopedWorkspaceFilesystems.set(cacheKey, filesystem);
773
- return filesystem;
1092
+ const load = () => new MountedCompositeFilesystem({ mounts });
1093
+ if (!filesCache) return load();
1094
+ const scoped = await resolveFilesCacheScope(context);
1095
+ if (!scoped) return load();
1096
+ return filesCache.forFileSystem(scoped.scope, { paths: mountKeys, key: sourceKey }, load);
1097
+ }
1098
+
1099
+ /**
1100
+ * Expand skill roots into concrete skill directories and keep the first
1101
+ * same-named directory. Mastra still owns parsing, indexing, and refreshes,
1102
+ * while earlier configured roots retain precedence over later roots.
1103
+ */
1104
+ async function resolveDistinctSkillPaths(
1105
+ filesystem: WorkspaceFilesystem,
1106
+ roots: readonly string[],
1107
+ ): Promise<string[]> {
1108
+ const selected = new Map<string, string>();
1109
+ for (const root of roots) {
1110
+ try {
1111
+ const entries = await filesystem.readdir(root);
1112
+ if (entries.some((entry) => entry.type === "file" && entry.name === "SKILL.md")) {
1113
+ const name = posixPath.basename(root);
1114
+ if (!selected.has(name)) selected.set(name, root);
1115
+ continue;
1116
+ }
1117
+ for (const entry of entries) {
1118
+ if (entry.type !== "directory" || selected.has(entry.name)) continue;
1119
+ selected.set(entry.name, posixPath.join(root, entry.name));
1120
+ }
1121
+ } catch (error) {
1122
+ logger.debug("skill-root:skipped", {
1123
+ root,
1124
+ error: errorUtils.errorMessage(error),
1125
+ });
1126
+ }
1127
+ }
1128
+ return [...selected.values()];
774
1129
  }
775
1130
 
776
1131
  /** Memoize one complete mount contribution for each request context. */
777
1132
  function contributionResolver(
778
1133
  resolvers: WorkspaceMountResolver[],
779
- ): (context: WorkspaceMountContext) => Promise<WorkspaceMountContribution> {
1134
+ ): (context: DatabricksWorkspaceContext) => Promise<WorkspaceMountContribution> {
780
1135
  const scoped = new WeakMap<RequestContext, Promise<WorkspaceMountContribution>>();
781
1136
  let unscoped: Promise<WorkspaceMountContribution> | undefined;
782
1137
  return (context) => {
@@ -791,22 +1146,3 @@ function contributionResolver(
791
1146
  return pending;
792
1147
  };
793
1148
  }
794
-
795
- /** Resolve one semantic catalogue owner from a request's ordered skill roots. */
796
- function buildSkillCatalogueResolver(
797
- resolveContribution: (context: WorkspaceMountContext) => Promise<WorkspaceMountContribution>,
798
- ttlMs: number | undefined,
799
- ): SkillCatalogueResolver {
800
- return async ({ requestContext }) => {
801
- const contribution = await resolveContribution({ requestContext });
802
- const user = requestContext?.get(MASTRA_USER_KEY) as User | undefined;
803
- const client = user?.executionContext.client as WorkspaceClient | undefined;
804
- const host = client ? (await client.config.getHost()).toString() : "local";
805
- return workspaceSkillCatalogue({
806
- host,
807
- sources: contribution.skillSources ?? [],
808
- ttlMs,
809
- userKey: user?.id ?? "service",
810
- });
811
- };
812
- }