@dbx-tools/appkit-mastra 0.9.93 → 0.9.95

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,8 +1,9 @@
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
4
+ * Builds a {@link Workspace} with Databricks Sandbox command execution, an
5
+ * isolated `/tmp` scratch mount, and a {@link CompositeFilesystem} over the
6
+ * named skill folders
6
7
  * resolved for that request. A skill folder maps a name to a location plus its
7
8
  * readable / writable policy: a Databricks path mounted through the OBO client
8
9
  * on {@link MASTRA_USER_KEY}, or any {@link WorkspaceFilesystem} a consuming
@@ -12,28 +13,35 @@
12
13
  * filesystems and skill scan roots on top.
13
14
  *
14
15
  * Databricks mounts use `@dbx-tools/databricks` {@link DatabricksFileSystem}
15
- * wrapped by {@link filesystems}; missing roots fall back to
16
- * {@link scratchFilesystem}.
16
+ * wrapped by {@link filesystems}; inaccessible roots are skipped independently.
17
17
  *
18
18
  * @module
19
19
  */
20
20
 
21
21
  import { createHash } from "node:crypto";
22
- import { ConfigurationError, createWorkspaceClient } from "@databricks/appkit";
22
+ import { ConfigurationError, createWorkspaceClient, getExecutionContext } from "@databricks/appkit";
23
23
  import type { WorkspaceClient } from "@databricks/appkit";
24
+ import { pluginRegistry } from "@dbx-tools/appkit";
25
+ import {
26
+ filesCache as filesCachePlugin,
27
+ type FilesCacheExports,
28
+ type FilesCacheScope,
29
+ } from "@dbx-tools/appkit/files-cache";
24
30
  import { DatabricksFileSystem, workspaceClient } from "@dbx-tools/databricks";
25
31
  import { LocalFileSystem } from "@dbx-tools/fs";
26
32
  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";
33
+ import { fs as sharedFS, posixPath, type FileSystemCache } from "@dbx-tools/shared-fs";
29
34
  import type { RequestContext } from "@mastra/core/request-context";
30
35
  import {
31
- CompositeFilesystem,
36
+ WORKSPACE_TOOLS,
32
37
  Workspace,
33
38
  type SkillsResolver,
34
39
  type WorkspaceFilesystem,
35
40
  type WorkspaceSandbox,
36
41
  type WorkspaceSandboxResolver,
42
+ type ToolConfigWithArgsContext,
43
+ type WorkspaceToolConfig,
44
+ type WorkspaceToolsConfig,
37
45
  } from "@mastra/core/workspace";
38
46
 
39
47
  import {
@@ -43,28 +51,23 @@ import {
43
51
  resolveUserKey,
44
52
  type User,
45
53
  } from "./config.ts";
46
- import { filesystems, scratchFilesystem } from "./filesystems.ts";
54
+ import {
55
+ filesystems,
56
+ MountedCompositeFilesystem,
57
+ scratchFilesystem,
58
+ type MastraFileSystemAdapterOptions,
59
+ } from "./filesystems.ts";
47
60
  import { MontySandbox } from "./monty-sandbox.ts";
48
61
  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";
62
+ import { ORGANIZATION_ASSISTANT_PATH, personalWorkspacePath } from "./skill-paths.ts";
56
63
 
57
64
  /* ------------------------------ constants ------------------------------ */
58
65
 
59
66
  /** OAuth scopes that gate Databricks workspace file mounts. */
60
67
  const WORKSPACE_FILE_SCOPES = ["workspace", "workspace.workspace", "all-apis"] as const;
68
+ const SCRATCH_MOUNT = "/tmp";
61
69
 
62
70
  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>();
68
71
 
69
72
  /* -------------------------------- types -------------------------------- */
70
73
 
@@ -88,37 +91,44 @@ export type SkillFolderValue<T> =
88
91
  * itself. {@link filesystem} wins when both are set.
89
92
  */
90
93
  export interface SkillFolderOptions {
91
- /** Absolute Databricks workspace path for this folder. */
94
+ /** Absolute Databricks workspace root mounted at its actual path. */
92
95
  path?: SkillFolderValue<string>;
93
96
  /** Ready-made mount, for locations the OBO client cannot reach. */
94
97
  filesystem?: SkillFolderValue<WorkspaceFilesystem>;
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
127
  /** Mount map plus optional Mastra skill scan roots for one resolver. */
111
128
  export interface WorkspaceMountContribution {
112
129
  mounts: Record<string, WorkspaceFilesystem>;
113
- /** Ordered source roots included in catalogue discovery. */
114
- skillSources?: SkillCatalogueSource[];
115
130
  /** Paths within the composite namespace where `SKILL.md` files are scanned. */
116
131
  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
132
  }
123
133
 
124
134
  /** Contributes filesystem mounts (and optional skill paths) for one request. */
@@ -127,7 +137,7 @@ export type WorkspaceMountResolver = (
127
137
  ) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
128
138
 
129
139
  /** Names carried by {@link DEFAULT_SKILL_FOLDERS}. */
130
- export type DefaultSkillFolderName = "workspace-team" | "workspace-team-app";
140
+ export type DefaultSkillFolderName = "organization-skills" | "personal-skills";
131
141
 
132
142
  /**
133
143
  * Sandbox selection for {@link createWorkspace}. Databricks is the default;
@@ -165,11 +175,10 @@ export interface CreateWorkspaceOptions {
165
175
  skills?: SkillsResolver;
166
176
  /** Forwarded to Mastra when skill discovery is enabled. */
167
177
  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;
178
+ /** Additional native Mastra workspace tool configuration and overrides. */
179
+ tools?: WorkspaceToolsConfig;
180
+ /** AppKit plugin context used to discover optional sibling capabilities. */
181
+ pluginContext?: pluginRegistry.PluginContextLike;
173
182
  /** Enable BM25 keyword search over indexed workspace content. */
174
183
  bm25?: boolean;
175
184
  /**
@@ -193,25 +202,32 @@ export interface CreateWorkspaceOptions {
193
202
  /**
194
203
  * The skill folders every workspace starts with.
195
204
  *
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.
205
+ * - `organization-skills` - `/Workspace/.assistant` with `skills`; write
206
+ * attempts require approval and remain subject to Databricks permissions.
207
+ * - `personal-skills` - the requesting user's `/Workspace/Users/<email>` root
208
+ * with `.assistant/skills`, writable and skipped without an email.
201
209
  */
202
210
  export const DEFAULT_SKILL_FOLDERS: Readonly<Record<DefaultSkillFolderName, SkillFolderOptions>> = {
203
- "workspace-team": {
204
- path: ASSISTANT_SHARED_SKILLS_PATH,
211
+ "organization-skills": {
212
+ description: "Organization skills shared with everyone in this Databricks workspace.",
213
+ displayName: "Organization Skills",
214
+ path: ORGANIZATION_ASSISTANT_PATH,
205
215
  readable: true,
206
- writable: false,
216
+ skills: ["skills"],
217
+ writable: true,
218
+ createRoot: false,
207
219
  },
208
- "workspace-team-app": {
220
+ "personal-skills": {
221
+ description: "Your Databricks workspace home directory for personal files and skills.",
222
+ displayName: "Home",
209
223
  path: ({ requestContext }) => {
210
224
  const email = resolveScopedEmail(requestContext);
211
- return email ? userAssistantSkillsPath(email) : undefined;
225
+ return email ? personalWorkspacePath(email) : undefined;
212
226
  },
213
227
  readable: true,
228
+ skills: [".assistant/skills"],
214
229
  writable: true,
230
+ createRoot: false,
215
231
  },
216
232
  };
217
233
 
@@ -227,8 +243,11 @@ export const DEFAULT_SKILL_FOLDERS: Readonly<Record<DefaultSkillFolderName, Skil
227
243
  * ```ts
228
244
  * createWorkspace({
229
245
  * skillFolders: {
230
- * "workspace-team": { path: "/Workspace/Shared/team-skills" },
231
- * "workspace-team-app": false,
246
+ * "organization-skills": {
247
+ * path: "/Workspace/Shared/.assistant",
248
+ * skills: ["skills"],
249
+ * },
250
+ * "personal-skills": false,
232
251
  * runbooks: { path: "/Workspace/Shared/runbooks/skills", writable: true },
233
252
  * volume: { filesystem: myVolumeFilesystem },
234
253
  * },
@@ -252,15 +271,41 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
252
271
  const skillFolders = resolveSkillFolders(options);
253
272
  const folderNames = Object.keys(skillFolders);
254
273
  const extraSkillPaths = options.extraSkillPaths ?? [];
274
+ const filesCache = pluginRegistry.instance(options.pluginContext, filesCachePlugin)?.exports();
275
+ const retainFilesystemSource =
276
+ filesCache !== undefined &&
277
+ (options.mounts?.length ?? 0) === 0 &&
278
+ Object.values(skillFolders).every(
279
+ (folder) =>
280
+ typeof folder.filesystem !== "function" &&
281
+ !(typeof folder.path === "function" && folder.mount !== undefined),
282
+ );
255
283
  const resolvers = buildMountResolvers(
256
284
  skillFolders,
257
285
  [...(options.mounts ?? []), ...localSkillMountResolvers(extraSkillPaths)],
286
+ filesCache,
258
287
  );
259
288
  const resolveContribution = contributionResolver(resolvers);
260
- const skills = options.skills;
289
+ const resolveFilesystem = (context: WorkspaceMountContext) =>
290
+ resolveWorkspaceFilesystem(
291
+ resolveContribution,
292
+ context,
293
+ retainFilesystemSource ? filesCache : undefined,
294
+ );
295
+ const skills: SkillsResolver =
296
+ options.skills ??
297
+ (async ({ requestContext }) => {
298
+ const context = { requestContext };
299
+ const contribution = await resolveContribution(context);
300
+ return resolveDistinctSkillPaths(
301
+ await resolveFilesystem(context),
302
+ contribution.skillPaths ?? [],
303
+ );
304
+ });
261
305
  const checkSkillFileMtime = options.checkSkillFileMtime ?? false;
262
306
  const bm25 = options.bm25 !== false;
263
307
  const sandbox = resolveWorkspaceSandbox(options.sandbox, id, name);
308
+ const tools = workspaceTools(options.tools);
264
309
  logger.debug("workspace:create", {
265
310
  id,
266
311
  name,
@@ -269,8 +314,6 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
269
314
  customMountResolvers: options.mounts?.length ?? 0,
270
315
  customSkillsResolver: Boolean(options.skills),
271
316
  checkSkillFileMtime,
272
- workspaceSkillRefreshTtlMs:
273
- options.workspaceSkillRefreshTtlMs ?? DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS,
274
317
  bm25,
275
318
  extraSkillPaths: extraSkillPaths.length,
276
319
  sandbox: sandbox ? sandboxName(options.sandbox) : "disabled",
@@ -279,8 +322,8 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
279
322
  const workspace = new Workspace({
280
323
  id,
281
324
  name,
282
- filesystem: (context) => resolveWorkspaceFilesystem(resolveContribution, context),
283
- ...(skills
325
+ filesystem: resolveFilesystem,
326
+ ...(resolvers.length > 0 || options.skills
284
327
  ? {
285
328
  skills,
286
329
  checkSkillFileMtime,
@@ -293,23 +336,11 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
293
336
  }
294
337
  : {}),
295
338
  bm25,
339
+ tools,
296
340
  });
297
- if (!options.skills && resolvers.length > 0) {
298
- workspaceCatalogueResolvers.set(
299
- workspace,
300
- buildSkillCatalogueResolver(resolveContribution, options.workspaceSkillRefreshTtlMs),
301
- );
302
- }
303
341
  return workspace;
304
342
  }
305
343
 
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;
311
- }
312
-
313
344
  /**
314
345
  * Merge the configured skill folders over {@link DEFAULT_SKILL_FOLDERS}.
315
346
  *
@@ -333,6 +364,48 @@ export function resolveSkillFolders(
333
364
 
334
365
  /* ---------------------------- private helpers ---------------------------- */
335
366
 
367
+ /** Require approval unless a filesystem mutation stays inside a user-owned writable root. */
368
+ function requireApprovalOutsideWritableRoots({
369
+ args,
370
+ requestContext,
371
+ }: ToolConfigWithArgsContext): boolean {
372
+ const inputPath = args.path;
373
+ if (typeof inputPath !== "string" || !posixPath.isAbsolute(inputPath)) return true;
374
+ const normalized = posixPath.normalize(inputPath);
375
+ if (!normalized.ok) return true;
376
+ if (posixPath.isWithinRoot(SCRATCH_MOUNT, normalized.path)) return false;
377
+ const emailValue = requestContext[MASTRA_USER_EMAIL_KEY];
378
+ const email = typeof emailValue === "string" ? stringUtils.trimToNull(emailValue) : undefined;
379
+ if (!email) return true;
380
+ return !posixPath.isWithinRoot(personalWorkspacePath(email), normalized.path);
381
+ }
382
+
383
+ function workspaceTools(configured: WorkspaceToolsConfig | undefined): WorkspaceToolsConfig {
384
+ const requireApproval: WorkspaceToolConfig =
385
+ configured?.requireApproval === undefined
386
+ ? { requireApproval: requireApprovalOutsideWritableRoots }
387
+ : {};
388
+ const defaults = {
389
+ [WORKSPACE_TOOLS.FILESYSTEM.WRITE_FILE]: requireApproval,
390
+ [WORKSPACE_TOOLS.FILESYSTEM.EDIT_FILE]: requireApproval,
391
+ [WORKSPACE_TOOLS.FILESYSTEM.AST_EDIT]: requireApproval,
392
+ [WORKSPACE_TOOLS.FILESYSTEM.DELETE]: requireApproval,
393
+ [WORKSPACE_TOOLS.FILESYSTEM.MKDIR]: requireApproval,
394
+ } satisfies WorkspaceToolsConfig;
395
+ const configuredTools = configured as
396
+ Readonly<Record<string, WorkspaceToolConfig | undefined>> | undefined;
397
+ const mergedTools = Object.fromEntries(
398
+ Object.entries(defaults).map(([name, defaultConfig]) => [
399
+ name,
400
+ { ...defaultConfig, ...configuredTools?.[name] },
401
+ ]),
402
+ );
403
+ return {
404
+ ...defaults,
405
+ ...configured,
406
+ ...mergedTools,
407
+ };
408
+ }
336
409
  function resolveWorkspaceSandbox(
337
410
  selection: WorkspaceSandboxSelection | undefined,
338
411
  workspaceId: string,
@@ -410,6 +483,48 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
410
483
  );
411
484
  }
412
485
 
486
+ /** Resolve the request client and authorization-safe files-cache scope. */
487
+ async function resolveFilesCacheScope(context: WorkspaceMountContext): Promise<
488
+ | {
489
+ client: WorkspaceClient;
490
+ scope: FilesCacheScope;
491
+ }
492
+ | undefined
493
+ > {
494
+ const requestContext = context.requestContext;
495
+ const user = requestContext?.get(MASTRA_USER_KEY) as User | undefined;
496
+ const client = user?.executionContext.client as WorkspaceClient | undefined;
497
+ if (!requestContext || !user || !client) return undefined;
498
+ return {
499
+ client,
500
+ scope: {
501
+ host: (await client.config.getHost()).toString(),
502
+ userKey: resolveUserKey(requestContext),
503
+ ...(process.env.DATABRICKS_WORKSPACE_ID?.trim()
504
+ ? { workspaceId: process.env.DATABRICKS_WORKSPACE_ID.trim() }
505
+ : {}),
506
+ },
507
+ };
508
+ }
509
+
510
+ /** Join one relative skill root to its actual composite mount path. */
511
+ function mountedSkillPath(mount: string, skillRoot: string): string {
512
+ if (posixPath.isAbsolute(skillRoot)) {
513
+ throw new TypeError(`Skill path must be relative to its mount: ${skillRoot}`);
514
+ }
515
+ const normalized = posixPath.normalize(skillRoot);
516
+ if (!normalized.ok) {
517
+ throw new TypeError(`Skill path escapes its mount: ${skillRoot}`);
518
+ }
519
+ return posixPath.join(posixPath.normalizeRoot(mount), normalized.path.slice(1));
520
+ }
521
+
522
+ /** Return whether a Databricks path belongs to the writable Workspace namespace. */
523
+ function isWorkspaceRoot(root: string): boolean {
524
+ const normalized = posixPath.normalizeRoot(root);
525
+ return normalized === "/Workspace" || normalized.startsWith("/Workspace/");
526
+ }
527
+
413
528
  /**
414
529
  * Mount resolver for the named skill folders.
415
530
  *
@@ -419,11 +534,10 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
419
534
  async function resolveSkillFolderMounts(
420
535
  skillFolders: Record<string, SkillFolderOptions>,
421
536
  context: WorkspaceMountContext,
537
+ filesCache: FilesCacheExports | undefined,
422
538
  ): Promise<WorkspaceMountContribution> {
423
539
  const mounts: Record<string, WorkspaceFilesystem> = {};
424
- const skillSources: SkillCatalogueSource[] = [];
425
540
  const skillPaths: string[] = [];
426
- const cacheKeys: string[] = [];
427
541
  const requestContext = context.requestContext;
428
542
 
429
543
  if (!requestContext || !shouldMountSkillFolders(requestContext)) {
@@ -435,24 +549,21 @@ async function resolveSkillFolderMounts(
435
549
  return { mounts, skillPaths };
436
550
  }
437
551
 
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;
552
+ const scoped = await resolveFilesCacheScope(context);
553
+ const client = scoped?.client;
554
+ const fileSystemCache =
555
+ filesCache && scoped ? await filesCache.forScope(scoped.scope) : undefined;
442
556
 
443
557
  for (const [name, folder] of Object.entries(skillFolders)) {
444
558
  const resolved = await resolveSkillFolderFilesystem(name, folder, context, {
445
559
  client,
446
- host,
447
- userKey,
560
+ fileSystemCache,
448
561
  });
449
562
  if (!resolved) continue;
450
- const mount = folder.mount ?? `/${name}`;
563
+ const mount = folder.mount ?? resolved.root ?? `/${name}`;
451
564
  mounts[mount] = resolved.filesystem;
452
- if (resolved.cacheKey) cacheKeys.push(resolved.cacheKey);
453
565
  if (folder.readable !== false) {
454
- skillPaths.push(mount);
455
- skillSources.push({ ...resolved.skillSource, id: object.toStableKey({ mount, source: resolved.skillSource.id }) });
566
+ skillPaths.push(...(folder.skills ?? ["."]).map((path) => mountedSkillPath(mount, path)));
456
567
  }
457
568
  }
458
569
 
@@ -463,11 +574,7 @@ async function resolveSkillFolderMounts(
463
574
 
464
575
  return {
465
576
  mounts,
466
- skillSources,
467
577
  skillPaths,
468
- ...(cacheKeys.length === Object.keys(mounts).length && cacheKeys.length > 0
469
- ? { cacheKey: object.toStableKey(cacheKeys.sort()) }
470
- : {}),
471
578
  };
472
579
  }
473
580
 
@@ -481,14 +588,12 @@ async function resolveSkillFolderFilesystem(
481
588
  context: WorkspaceMountContext,
482
589
  cache: {
483
590
  client: WorkspaceClient | undefined;
484
- host: string | undefined;
485
- userKey: string | undefined;
591
+ fileSystemCache: FileSystemCache | undefined;
486
592
  },
487
593
  ): Promise<
488
594
  | {
489
- cacheKey?: string;
490
595
  filesystem: WorkspaceFilesystem;
491
- skillSource: SkillCatalogueSource;
596
+ root?: string;
492
597
  }
493
598
  | undefined
494
599
  > {
@@ -497,19 +602,11 @@ async function resolveSkillFolderFilesystem(
497
602
  return filesystem
498
603
  ? {
499
604
  filesystem,
500
- skillSource: mastraSkillSource(
501
- object.toStableKey({
502
- id: filesystem.id,
503
- name,
504
- provider: filesystem.provider,
505
- }),
506
- filesystem,
507
- ),
508
605
  }
509
606
  : undefined;
510
607
  }
511
608
  // A path mount needs the request's OBO client to reach the workspace.
512
- if (folder.path === undefined || !cache.client || !cache.host || !cache.userKey) {
609
+ if (folder.path === undefined || !cache.client) {
513
610
  logger.debug("skill-folder:skipped", {
514
611
  name,
515
612
  reason: folder.path === undefined ? "no-location" : "missing-obo-client",
@@ -518,10 +615,20 @@ async function resolveSkillFolderFilesystem(
518
615
  }
519
616
  const root = stringUtils.trimToNull(await resolveSkillFolderValue(folder.path, context));
520
617
  if (!root) return undefined;
521
- return databricksFilesystem(cache.client, root, folder.writable !== true, {
522
- host: cache.host,
523
- userKey: cache.userKey,
524
- });
618
+ const writable = folder.writable ?? isWorkspaceRoot(root);
619
+ const resolved = await databricksFilesystem(
620
+ cache.client,
621
+ root,
622
+ !writable,
623
+ folder.createRoot ?? folder.writable === true,
624
+ cache.fileSystemCache,
625
+ folder.readable === false ? [] : (folder.skills ?? ["."]),
626
+ {
627
+ ...(folder.description ? { description: folder.description } : {}),
628
+ ...(folder.displayName ? { displayName: folder.displayName } : {}),
629
+ },
630
+ );
631
+ return resolved ? { ...resolved, root } : undefined;
525
632
  }
526
633
 
527
634
  /** Read a {@link SkillFolderValue}, calling it when it is a per-request resolver. */
@@ -557,11 +664,12 @@ function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
557
664
  function buildMountResolvers(
558
665
  skillFolders: Record<string, SkillFolderOptions>,
559
666
  mounts: WorkspaceMountResolver[] | undefined,
667
+ filesCache: FilesCacheExports | undefined,
560
668
  ): WorkspaceMountResolver[] {
561
- const resolvers: WorkspaceMountResolver[] = [];
669
+ const resolvers: WorkspaceMountResolver[] = [scratchMountResolver()];
562
670
  const folderCount = Object.keys(skillFolders).length;
563
671
  if (folderCount > 0) {
564
- resolvers.push((context) => resolveSkillFolderMounts(skillFolders, context));
672
+ resolvers.push((context) => resolveSkillFolderMounts(skillFolders, context, filesCache));
565
673
  }
566
674
  if (mounts?.length) {
567
675
  resolvers.push(...mounts);
@@ -574,10 +682,17 @@ function buildMountResolvers(
574
682
  return resolvers;
575
683
  }
576
684
 
685
+ /** Contribute one isolated local scratch filesystem at `/tmp`. */
686
+ function scratchMountResolver(): WorkspaceMountResolver {
687
+ return () => ({
688
+ mounts: { [SCRATCH_MOUNT]: scratchFilesystem() },
689
+ });
690
+ }
691
+
577
692
  /** Mount startup-provisioned local skill roots into Mastra's workspace filesystem. */
578
693
  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}`;
694
+ return [...new Set(paths.map((path) => path.trim()).filter(Boolean))].map((root) => {
695
+ const mount = posixPath.normalizeRoot(root);
581
696
  const source = new LocalFileSystem({
582
697
  root,
583
698
  readOnly: true,
@@ -586,51 +701,11 @@ function localSkillMountResolvers(paths: readonly string[]): WorkspaceMountResol
586
701
  const filesystem = filesystems(source, { readOnly: true });
587
702
  return () => ({
588
703
  mounts: { [mount]: filesystem },
589
- skillSources: [sharedSkillSource(object.toStableKey({ mount, root }), source)],
590
704
  skillPaths: [mount],
591
- cacheKey: object.toStableKey({ mount, root }),
592
705
  });
593
706
  });
594
707
  }
595
708
 
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
709
  /**
635
710
  * Gate skill-folder mounts on the request's token.
636
711
  *
@@ -648,47 +723,48 @@ function resolveScopedEmail(requestContext: RequestContext | undefined): string
648
723
  }
649
724
 
650
725
  /**
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.
726
+ * Wrap a {@link DatabricksFileSystem} as a Mastra filesystem. Missing or
727
+ * inaccessible roots return `undefined` so the independent `/tmp` mount and
728
+ * any other accessible roots remain usable.
654
729
  */
655
730
  async function databricksFilesystem(
656
731
  client: WorkspaceClient,
657
732
  root: string,
658
733
  readOnly: boolean = true,
659
- cache: { host: string; userKey: string },
734
+ createRoot: boolean = !readOnly,
735
+ fileSystemCache?: FileSystemCache,
736
+ skillPaths: readonly string[] = [],
737
+ adapterOptions: Pick<MastraFileSystemAdapterOptions, "description" | "displayName"> = {},
660
738
  ): Promise<
661
- {
662
- cacheKey: string;
663
- filesystem: WorkspaceFilesystem;
664
- skillSource: SkillCatalogueSource;
665
- }
739
+ | {
740
+ filesystem: WorkspaceFilesystem;
741
+ }
666
742
  | undefined
667
743
  > {
744
+ const initialClient = workspaceClient.toLegacyWorkspaceClient(client);
668
745
  const fs = new DatabricksFileSystem({
669
- client: workspaceClient.toLegacyWorkspaceClient(client),
746
+ client: () => {
747
+ try {
748
+ return workspaceClient.toLegacyWorkspaceClient(getExecutionContext().client);
749
+ } catch {
750
+ return initialClient;
751
+ }
752
+ },
670
753
  root,
671
754
  readOnly,
672
- createRoot: !readOnly,
755
+ createRoot,
673
756
  });
674
- const cacheKey = object.toStableKey({
675
- host: cache.host,
676
- readOnly,
677
- root,
678
- userKey: cache.userKey,
679
- });
680
- const filesystem = filesystems(fs, { readOnly });
757
+ const source = fileSystemCache
758
+ ? sharedFS.cache(fs, fileSystemCache, { readFilePaths: skillPaths })
759
+ : fs;
760
+ const filesystem = filesystems(source, { ...adapterOptions, readOnly });
681
761
  try {
682
762
  await filesystem.init();
683
763
  if (await filesystem.exists(".")) {
684
- return {
685
- cacheKey,
686
- filesystem,
687
- skillSource: sharedSkillSource(cacheKey, fs),
688
- };
764
+ return { filesystem };
689
765
  }
690
766
  } catch (err) {
691
- logger.debug("databricks-mount:scratch-fallback", {
767
+ logger.debug("databricks-mount:skipped", {
692
768
  root,
693
769
  readOnly,
694
770
  error: errorUtils.errorMessage(err),
@@ -706,10 +782,7 @@ async function resolveWorkspaceContribution(
706
782
  context: WorkspaceMountContext,
707
783
  ): Promise<WorkspaceMountContribution> {
708
784
  const mounts: Record<string, WorkspaceFilesystem> = {};
709
- const skillSources: SkillCatalogueSource[] = [];
710
785
  const skillPaths: string[] = [];
711
- const cacheKeys: string[] = [];
712
- let cacheable = true;
713
786
 
714
787
  for (const [index, resolver] of resolvers.entries()) {
715
788
  const contribution = await resolver(context);
@@ -719,16 +792,9 @@ async function resolveWorkspaceContribution(
719
792
  skillPaths: contribution.skillPaths ?? [],
720
793
  });
721
794
  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
795
  if (contribution.skillPaths?.length) {
727
796
  skillPaths.push(...contribution.skillPaths);
728
797
  }
729
- if (contribution.skillSources?.length) {
730
- skillSources.push(...contribution.skillSources);
731
- }
732
798
  }
733
799
 
734
800
  logger.debug("mounts:merged", {
@@ -738,11 +804,7 @@ async function resolveWorkspaceContribution(
738
804
 
739
805
  return {
740
806
  mounts,
741
- skillSources,
742
807
  skillPaths,
743
- ...(cacheable && cacheKeys.length > 0
744
- ? { cacheKey: object.toStableKey({ mounts: Object.keys(mounts), sources: cacheKeys }) }
745
- : {}),
746
808
  };
747
809
  }
748
810
 
@@ -755,8 +817,9 @@ async function resolveWorkspaceContribution(
755
817
  async function resolveWorkspaceFilesystem(
756
818
  resolveContribution: (context: WorkspaceMountContext) => Promise<WorkspaceMountContribution>,
757
819
  context: WorkspaceMountContext,
820
+ filesCache?: FilesCacheExports,
758
821
  ): Promise<WorkspaceFilesystem> {
759
- const { cacheKey, mounts } = await resolveContribution(context);
822
+ const { mounts } = await resolveContribution(context);
760
823
  const mountKeys = Object.keys(mounts);
761
824
  if (mountKeys.length === 0) {
762
825
  logger.debug("filesystem:scratch", {
@@ -765,12 +828,43 @@ async function resolveWorkspaceFilesystem(
765
828
  return scratchFilesystem();
766
829
  }
767
830
  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;
831
+ const load = () => new MountedCompositeFilesystem({ mounts });
832
+ if (!filesCache) return load();
833
+ const scoped = await resolveFilesCacheScope(context);
834
+ if (!scoped) return load();
835
+ return filesCache.forFileSystem(scoped.scope, { paths: mountKeys }, load);
836
+ }
837
+
838
+ /**
839
+ * Expand skill roots into concrete skill directories and keep the first
840
+ * same-named directory. Mastra still owns parsing, indexing, and refreshes,
841
+ * while organization roots retain precedence over personal roots.
842
+ */
843
+ async function resolveDistinctSkillPaths(
844
+ filesystem: WorkspaceFilesystem,
845
+ roots: readonly string[],
846
+ ): Promise<string[]> {
847
+ const selected = new Map<string, string>();
848
+ for (const root of roots) {
849
+ try {
850
+ const entries = await filesystem.readdir(root);
851
+ if (entries.some((entry) => entry.type === "file" && entry.name === "SKILL.md")) {
852
+ const name = posixPath.basename(root);
853
+ if (!selected.has(name)) selected.set(name, root);
854
+ continue;
855
+ }
856
+ for (const entry of entries) {
857
+ if (entry.type !== "directory" || selected.has(entry.name)) continue;
858
+ selected.set(entry.name, posixPath.join(root, entry.name));
859
+ }
860
+ } catch (error) {
861
+ logger.debug("skill-root:skipped", {
862
+ root,
863
+ error: errorUtils.errorMessage(error),
864
+ });
865
+ }
866
+ }
867
+ return [...selected.values()];
774
868
  }
775
869
 
776
870
  /** Memoize one complete mount contribution for each request context. */
@@ -791,22 +885,3 @@ function contributionResolver(
791
885
  return pending;
792
886
  };
793
887
  }
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
- }