@dbx-tools/appkit-mastra 0.9.95 → 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,16 +1,10 @@
1
1
  /**
2
2
  * Mastra workspace factory for Databricks Apps.
3
3
  *
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
7
- * resolved for that request. A skill folder maps a name to a location plus its
8
- * readable / writable policy: a Databricks path mounted through the OBO client
9
- * on {@link MASTRA_USER_KEY}, or any {@link WorkspaceFilesystem} a consuming
10
- * library already owns. {@link DEFAULT_SKILL_FOLDERS} supplies the Assistant
11
- * trees, and `skillFolders` merges over it - same name overrides, `false`
12
- * disables, a new name adds. Optional mount resolvers contribute further
13
- * 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.
14
8
  *
15
9
  * Databricks mounts use `@dbx-tools/databricks` {@link DatabricksFileSystem}
16
10
  * wrapped by {@link filesystems}; inaccessible roots are skipped independently.
@@ -19,6 +13,8 @@
19
13
  */
20
14
 
21
15
  import { createHash } from "node:crypto";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
22
18
  import { ConfigurationError, createWorkspaceClient, getExecutionContext } from "@databricks/appkit";
23
19
  import type { WorkspaceClient } from "@databricks/appkit";
24
20
  import { pluginRegistry } from "@dbx-tools/appkit";
@@ -29,17 +25,27 @@ import {
29
25
  } from "@dbx-tools/appkit/files-cache";
30
26
  import { DatabricksFileSystem, workspaceClient } from "@dbx-tools/databricks";
31
27
  import { LocalFileSystem } from "@dbx-tools/fs";
28
+ import { match, type PathMatchInput } from "@dbx-tools/path";
32
29
  import { errorUtils, log, object, stringUtils, token } from "@dbx-tools/shared-core";
33
- import { fs as sharedFS, posixPath, type FileSystemCache } from "@dbx-tools/shared-fs";
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";
34
39
  import type { RequestContext } from "@mastra/core/request-context";
35
40
  import {
36
41
  WORKSPACE_TOOLS,
37
42
  Workspace,
38
43
  type SkillsResolver,
44
+ type ToolConfigWithArgsContext,
45
+ type WorkspaceConfig,
39
46
  type WorkspaceFilesystem,
40
47
  type WorkspaceSandbox,
41
48
  type WorkspaceSandboxResolver,
42
- type ToolConfigWithArgsContext,
43
49
  type WorkspaceToolConfig,
44
50
  type WorkspaceToolsConfig,
45
51
  } from "@mastra/core/workspace";
@@ -54,7 +60,6 @@ import {
54
60
  import {
55
61
  filesystems,
56
62
  MountedCompositeFilesystem,
57
- scratchFilesystem,
58
63
  type MastraFileSystemAdapterOptions,
59
64
  } from "./filesystems.ts";
60
65
  import { MontySandbox } from "./monty-sandbox.ts";
@@ -66,35 +71,30 @@ import { ORGANIZATION_ASSISTANT_PATH, personalWorkspacePath } from "./skill-path
66
71
  /** OAuth scopes that gate Databricks workspace file mounts. */
67
72
  const WORKSPACE_FILE_SCOPES = ["workspace", "workspace.workspace", "all-apis"] as const;
68
73
  const SCRATCH_MOUNT = "/tmp";
74
+ const CACHEABLE_FILESYSTEM_OPERATIONS = [
75
+ "exists",
76
+ "readFile",
77
+ "readdir",
78
+ "stat",
79
+ ] as const satisfies readonly CacheableFileSystemOperation[];
69
80
 
70
81
  const logger = log.logger("mastra/workspaces");
82
+ let workspaceSourceSequence = 0;
83
+ const databricksWorkspaceOptions = new WeakMap<Workspace, DatabricksWorkspaceOptions>();
71
84
 
72
85
  /* -------------------------------- types -------------------------------- */
73
86
 
74
87
  /** Per-request context for mount and skill-folder resolvers. */
75
- export interface WorkspaceMountContext {
88
+ export interface DatabricksWorkspaceContext {
76
89
  requestContext?: RequestContext;
77
90
  }
78
91
 
79
92
  /**
80
- * A skill-folder field given either directly or as a per-request resolver.
81
- * A resolver returning `undefined` skips the folder for that request.
82
- */
83
- export type SkillFolderValue<T> =
84
- T | ((context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>);
85
-
86
- /**
87
- * One named skill-folder location and its read / write policy.
88
- *
89
- * Give {@link path} for a Databricks workspace tree (mounted through the
90
- * request's OBO client), or {@link filesystem} for a mount the consumer builds
91
- * itself. {@link filesystem} wins when both are set.
93
+ * One Databricks path mounted into a Mastra workspace.
92
94
  */
93
- export interface SkillFolderOptions {
94
- /** Absolute Databricks workspace root mounted at its actual path. */
95
- path?: SkillFolderValue<string>;
96
- /** Ready-made mount, for locations the OBO client cannot reach. */
97
- filesystem?: SkillFolderValue<WorkspaceFilesystem>;
95
+ export interface DatabricksWorkspacePathOptions {
96
+ /** Absolute Databricks path or `~` shortcut for the current user's workspace home. */
97
+ path: string;
98
98
  /** Human-friendly mount name shown in filesystem listings. */
99
99
  displayName?: string;
100
100
  /** Mount description shown in filesystem listings and workspace instructions. */
@@ -124,23 +124,62 @@ export interface SkillFolderOptions {
124
124
  mount?: string;
125
125
  }
126
126
 
127
- /** Mount map plus optional Mastra skill scan roots for one resolver. */
128
- 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 {
129
171
  mounts: Record<string, WorkspaceFilesystem>;
130
172
  /** Paths within the composite namespace where `SKILL.md` files are scanned. */
131
173
  skillPaths?: string[];
132
174
  }
133
175
 
134
- /** Contributes filesystem mounts (and optional skill paths) for one request. */
135
- export type WorkspaceMountResolver = (
136
- context: WorkspaceMountContext,
176
+ /** Internal filesystem contribution resolver. */
177
+ type WorkspaceMountResolver = (
178
+ context: DatabricksWorkspaceContext,
137
179
  ) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
138
180
 
139
- /** Names carried by {@link DEFAULT_SKILL_FOLDERS}. */
140
- export type DefaultSkillFolderName = "organization-skills" | "personal-skills";
141
-
142
181
  /**
143
- * Sandbox selection for {@link createWorkspace}. Databricks is the default;
182
+ * Sandbox selection for {@link databricksWorkspace}. Monty is the default;
144
183
  * `false` disables command execution, and a Mastra provider or resolver is an
145
184
  * explicit replacement.
146
185
  */
@@ -152,35 +191,26 @@ export type WorkspaceSandboxSelection =
152
191
  | WorkspaceSandbox
153
192
  | WorkspaceSandboxResolver;
154
193
 
155
- /** Options for {@link createWorkspace}. */
156
- export interface CreateWorkspaceOptions {
157
- /** Workspace id; derived from `name` or `"workspace"` when omitted. */
158
- id?: string;
159
- /** Display name; derived from `id` when omitted. */
160
- name?: string;
194
+ /** Native Mastra workspace options plus Databricks path shortcuts. */
195
+ export interface DatabricksWorkspaceOptions
196
+ extends Omit<WorkspaceConfig, "filesystem" | "mounts" | "sandbox" | "skills"> {
161
197
  /**
162
- * Start from {@link DEFAULT_SKILL_FOLDERS}. Defaults to `true`; `false`
163
- * starts from an empty map, leaving only the {@link skillFolders} given here.
198
+ * Start from {@link DEFAULT_DATABRICKS_WORKSPACE_PATHS}. Defaults to `true`.
164
199
  */
165
- assistantSkills?: boolean;
200
+ assistantPaths?: boolean;
166
201
  /**
167
- * Named skill folders merged over {@link DEFAULT_SKILL_FOLDERS}: a matching
168
- * name overrides that default, `false` disables it, and any other name adds
169
- * a folder.
202
+ * Databricks paths appended after the defaults. A string is shorthand for
203
+ * `{ path }`; a resolver may return either form per request.
170
204
  */
171
- skillFolders?: Record<string, SkillFolderOptions | false>;
172
- /** Extra per-request mount resolvers (run after the skill-folder mounts). */
173
- mounts?: WorkspaceMountResolver[];
205
+ paths?: readonly DatabricksWorkspacePathValue[];
206
+ /** Native Mastra filesystems composed beside the Databricks paths. */
207
+ mounts?: Record<string, WorkspaceFilesystem>;
174
208
  /** Replace the auto-built dynamic skills resolver. */
175
209
  skills?: SkillsResolver;
176
- /** Forwarded to Mastra when skill discovery is enabled. */
177
- checkSkillFileMtime?: boolean;
178
- /** Additional native Mastra workspace tool configuration and overrides. */
179
- tools?: WorkspaceToolsConfig;
210
+ /** Filesystem cache filters. Ignored when the AppKit files-cache plugin is absent. */
211
+ cache?: DatabricksWorkspaceCache;
180
212
  /** AppKit plugin context used to discover optional sibling capabilities. */
181
213
  pluginContext?: pluginRegistry.PluginContextLike;
182
- /** Enable BM25 keyword search over indexed workspace content. */
183
- bm25?: boolean;
184
214
  /**
185
215
  * Command sandbox. Defaults to Monty. Pass `"databricks"` or an options
186
216
  * object for Databricks Sandbox, `false` to disable command execution, or an
@@ -200,97 +230,78 @@ export interface CreateWorkspaceOptions {
200
230
  /* ------------------------------- defaults ------------------------------- */
201
231
 
202
232
  /**
203
- * The skill folders every workspace starts with.
204
- *
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.
233
+ * Default Databricks paths for shared and current-user Agent Skills.
209
234
  */
210
- export const DEFAULT_SKILL_FOLDERS: Readonly<Record<DefaultSkillFolderName, SkillFolderOptions>> = {
211
- "organization-skills": {
212
- description: "Organization skills shared with everyone in this Databricks workspace.",
213
- displayName: "Organization Skills",
235
+ export const DEFAULT_DATABRICKS_WORKSPACE_PATHS: readonly DatabricksWorkspacePathOptions[] = [
236
+ {
214
237
  path: ORGANIZATION_ASSISTANT_PATH,
215
238
  readable: true,
216
239
  skills: ["skills"],
217
240
  writable: true,
218
241
  createRoot: false,
219
242
  },
220
- "personal-skills": {
221
- description: "Your Databricks workspace home directory for personal files and skills.",
222
- displayName: "Home",
223
- path: ({ requestContext }) => {
224
- const email = resolveScopedEmail(requestContext);
225
- return email ? personalWorkspacePath(email) : undefined;
226
- },
243
+ {
244
+ path: "~",
227
245
  readable: true,
228
246
  skills: [".assistant/skills"],
229
247
  writable: true,
230
248
  createRoot: false,
231
249
  },
232
- };
250
+ ];
233
251
 
234
252
  /**
235
253
  * Create a Mastra {@link Workspace} with per-request Databricks mounts.
236
254
  *
237
- * @example Default skill folders only
255
+ * @example Default Databricks paths
238
256
  * ```ts
239
- * createWorkspace()
257
+ * databricksWorkspace()
240
258
  * ```
241
259
  *
242
- * @example Override a default, drop another, and add a location of your own
260
+ * @example Use only explicit paths
243
261
  * ```ts
244
- * createWorkspace({
245
- * skillFolders: {
246
- * "organization-skills": {
247
- * path: "/Workspace/Shared/.assistant",
248
- * skills: ["skills"],
249
- * },
250
- * "personal-skills": false,
251
- * runbooks: { path: "/Workspace/Shared/runbooks/skills", writable: true },
252
- * volume: { filesystem: myVolumeFilesystem },
253
- * },
262
+ * databricksWorkspace({
263
+ * assistantPaths: false,
264
+ * paths: ["~/project", "/tmp/project", "/Volumes/main/default/data"],
254
265
  * })
255
266
  * ```
256
267
  *
257
- * @example Skill folders plus a custom mount resolver
268
+ * @example Build a standard Mastra workspace yourself
258
269
  * ```ts
259
- * createWorkspace({
260
- * mounts: [
261
- * async ({ requestContext }) => ({
262
- * mounts: { "/data": myFilesystem },
263
- * skillPaths: [],
264
- * }),
265
- * ],
270
+ * new Workspace({
271
+ * ...databricksWorkspaceConfig({ paths: ["~/project"] }),
272
+ * sandbox: mySandbox,
266
273
  * })
267
274
  * ```
268
275
  */
269
- export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
276
+ export function databricksWorkspaceConfig(
277
+ options: DatabricksWorkspaceOptions = {},
278
+ ): WorkspaceConfig {
270
279
  const { id, name } = resolveWorkspaceIdentity(options);
271
- const skillFolders = resolveSkillFolders(options);
272
- const folderNames = Object.keys(skillFolders);
280
+ const filesystemSourceKey = `${id}:${++workspaceSourceSequence}`;
281
+ const paths = resolveDatabricksWorkspacePaths(options);
282
+ const configuredPaths = paths.length;
273
283
  const extraSkillPaths = options.extraSkillPaths ?? [];
274
284
  const filesCache = pluginRegistry.instance(options.pluginContext, filesCachePlugin)?.exports();
285
+ const configuredCache = options.cache;
286
+ const mountFilesCache = configuredCache === false ? undefined : filesCache;
275
287
  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
- );
288
+ mountFilesCache !== undefined &&
289
+ typeof configuredCache !== "function" &&
290
+ paths.every((path) => typeof path !== "function");
283
291
  const resolvers = buildMountResolvers(
284
- skillFolders,
285
- [...(options.mounts ?? []), ...localSkillMountResolvers(extraSkillPaths)],
286
- filesCache,
292
+ paths,
293
+ options.mounts,
294
+ localSkillMountResolvers(extraSkillPaths),
295
+ mountFilesCache,
296
+ configuredCache,
287
297
  );
288
298
  const resolveContribution = contributionResolver(resolvers);
289
- const resolveFilesystem = (context: WorkspaceMountContext) =>
299
+ const resolveFilesystem = (context: DatabricksWorkspaceContext) =>
290
300
  resolveWorkspaceFilesystem(
291
301
  resolveContribution,
292
302
  context,
293
- retainFilesystemSource ? filesCache : undefined,
303
+ retainFilesystemSource ? mountFilesCache : undefined,
304
+ filesystemSourceKey,
294
305
  );
295
306
  const skills: SkillsResolver =
296
307
  options.skills ??
@@ -303,15 +314,15 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
303
314
  );
304
315
  });
305
316
  const checkSkillFileMtime = options.checkSkillFileMtime ?? false;
306
- const bm25 = options.bm25 !== false;
317
+ const bm25 = options.bm25 ?? true;
307
318
  const sandbox = resolveWorkspaceSandbox(options.sandbox, id, name);
308
319
  const tools = workspaceTools(options.tools);
309
320
  logger.debug("workspace:create", {
310
321
  id,
311
322
  name,
312
323
  resolverCount: resolvers.length,
313
- skillFolders: folderNames,
314
- customMountResolvers: options.mounts?.length ?? 0,
324
+ configuredPaths,
325
+ customMounts: Object.keys(options.mounts ?? {}).length,
315
326
  customSkillsResolver: Boolean(options.skills),
316
327
  checkSkillFileMtime,
317
328
  bm25,
@@ -319,7 +330,19 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
319
330
  sandbox: sandbox ? sandboxName(options.sandbox) : "disabled",
320
331
  });
321
332
 
322
- 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,
323
346
  id,
324
347
  name,
325
348
  filesystem: resolveFilesystem,
@@ -337,29 +360,38 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
337
360
  : {}),
338
361
  bm25,
339
362
  tools,
340
- });
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);
341
370
  return workspace;
342
371
  }
343
372
 
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 });
383
+ }
384
+
344
385
  /**
345
- * Merge the configured skill folders over {@link DEFAULT_SKILL_FOLDERS}.
346
- *
347
- * `assistantSkills: false` drops the defaults, and a `false` value removes one
348
- * entry by name.
386
+ * Resolve default and caller-provided Databricks paths in priority order.
349
387
  */
350
- export function resolveSkillFolders(
351
- options: Pick<CreateWorkspaceOptions, "assistantSkills" | "skillFolders"> = {},
352
- ): Record<string, SkillFolderOptions> {
353
- const merged: Record<string, SkillFolderOptions> =
354
- options.assistantSkills === false ? {} : { ...DEFAULT_SKILL_FOLDERS };
355
- for (const [name, folder] of Object.entries(options.skillFolders ?? {})) {
356
- if (folder === false) {
357
- delete merged[name];
358
- } else {
359
- merged[name] = folder;
360
- }
361
- }
362
- 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
+ ];
363
395
  }
364
396
 
365
397
  /* ---------------------------- private helpers ---------------------------- */
@@ -484,7 +516,7 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
484
516
  }
485
517
 
486
518
  /** Resolve the request client and authorization-safe files-cache scope. */
487
- async function resolveFilesCacheScope(context: WorkspaceMountContext): Promise<
519
+ async function resolveFilesCacheScope(context: DatabricksWorkspaceContext): Promise<
488
520
  | {
489
521
  client: WorkspaceClient;
490
522
  scope: FilesCacheScope;
@@ -532,38 +564,54 @@ function isWorkspaceRoot(root: string): boolean {
532
564
  * folder whose location resolves for this request.
533
565
  */
534
566
  async function resolveSkillFolderMounts(
535
- skillFolders: Record<string, SkillFolderOptions>,
536
- context: WorkspaceMountContext,
567
+ paths: readonly DatabricksWorkspacePathValue[],
568
+ context: DatabricksWorkspaceContext,
537
569
  filesCache: FilesCacheExports | undefined,
570
+ cacheConfig: DatabricksWorkspaceCache | undefined,
538
571
  ): Promise<WorkspaceMountContribution> {
539
572
  const mounts: Record<string, WorkspaceFilesystem> = {};
540
573
  const skillPaths: string[] = [];
541
574
  const requestContext = context.requestContext;
542
575
 
543
- if (!requestContext || !shouldMountSkillFolders(requestContext)) {
544
- logger.debug("skill-folders:skipped", {
545
- reason: !requestContext ? "no-request-context" : "missing-workspace-scope",
546
- nodeEnv: process.env.NODE_ENV,
547
- scopes: requestContext?.get(MASTRA_SCOPES_KEY),
548
- });
549
- return { mounts, skillPaths };
550
- }
551
-
552
- const scoped = await resolveFilesCacheScope(context);
576
+ const canMountDatabricks = Boolean(requestContext && shouldMountSkillFolders(requestContext));
577
+ const scoped = canMountDatabricks ? await resolveFilesCacheScope(context) : undefined;
553
578
  const client = scoped?.client;
554
579
  const fileSystemCache =
555
580
  filesCache && scoped ? await filesCache.forScope(scoped.scope) : undefined;
556
581
 
557
- for (const [name, folder] of Object.entries(skillFolders)) {
558
- 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, {
559
603
  client,
560
604
  fileSystemCache,
561
605
  });
562
606
  if (!resolved) continue;
563
- const mount = folder.mount ?? resolved.root ?? `/${name}`;
607
+ const mount = configured.mount ?? resolved.root;
608
+ if (!mount) {
609
+ logger.debug("workspace-path:skipped", { index, reason: "missing-mount" });
610
+ continue;
611
+ }
564
612
  mounts[mount] = resolved.filesystem;
565
- if (folder.readable !== false) {
566
- skillPaths.push(...(folder.skills ?? ["."]).map((path) => mountedSkillPath(mount, path)));
613
+ if (configured.readable !== false) {
614
+ skillPaths.push(...(configured.skills ?? ["."]).map((path) => mountedSkillPath(mount, path)));
567
615
  }
568
616
  }
569
617
 
@@ -578,14 +626,43 @@ async function resolveSkillFolderMounts(
578
626
  };
579
627
  }
580
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 } : {}),
655
+ };
656
+ }
657
+
581
658
  /**
582
659
  * Resolve one skill folder to a Mastra filesystem, or `undefined` to skip it
583
660
  * for this request.
584
661
  */
585
662
  async function resolveSkillFolderFilesystem(
586
- name: string,
587
- folder: SkillFolderOptions,
588
- context: WorkspaceMountContext,
663
+ folder: ResolvedDatabricksWorkspacePath,
664
+ context: DatabricksWorkspaceContext,
665
+ cacheConfig: DatabricksWorkspaceCache | undefined,
589
666
  cache: {
590
667
  client: WorkspaceClient | undefined;
591
668
  fileSystemCache: FileSystemCache | undefined;
@@ -597,32 +674,32 @@ async function resolveSkillFolderFilesystem(
597
674
  }
598
675
  | undefined
599
676
  > {
600
- if (folder.filesystem !== undefined) {
601
- const filesystem = await resolveSkillFolderValue(folder.filesystem, context);
602
- return filesystem
603
- ? {
604
- filesystem,
605
- }
606
- : undefined;
607
- }
608
677
  // A path mount needs the request's OBO client to reach the workspace.
609
- if (folder.path === undefined || !cache.client) {
678
+ if (!cache.client) {
610
679
  logger.debug("skill-folder:skipped", {
611
- name,
612
- reason: folder.path === undefined ? "no-location" : "missing-obo-client",
680
+ path: folder.path,
681
+ reason: "missing-obo-client",
613
682
  });
614
683
  return undefined;
615
684
  }
616
- const root = stringUtils.trimToNull(await resolveSkillFolderValue(folder.path, context));
685
+ const root = stringUtils.trimToNull(folder.path);
617
686
  if (!root) return undefined;
618
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;
619
696
  const resolved = await databricksFilesystem(
620
697
  cache.client,
621
698
  root,
622
699
  !writable,
623
700
  folder.createRoot ?? folder.writable === true,
624
701
  cache.fileSystemCache,
625
- folder.readable === false ? [] : (folder.skills ?? ["."]),
702
+ cacheOptions,
626
703
  {
627
704
  ...(folder.description ? { description: folder.description } : {}),
628
705
  ...(folder.displayName ? { displayName: folder.displayName } : {}),
@@ -631,21 +708,116 @@ async function resolveSkillFolderFilesystem(
631
708
  return resolved ? { ...resolved, root } : undefined;
632
709
  }
633
710
 
634
- /** Read a {@link SkillFolderValue}, calling it when it is a per-request resolver. */
635
- function resolveSkillFolderValue<T>(
636
- value: SkillFolderValue<T>,
637
- context: WorkspaceMountContext,
638
- ): 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
+ > {
639
720
  return typeof value === "function"
640
- ? (value as (context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>)(context)
721
+ ? value(context)
641
722
  : value;
642
723
  }
643
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
+
644
816
  /**
645
- * 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}.
646
818
  * Slugifies `name` into `id`; tokenizes `id` into a display `name`.
647
819
  */
648
- function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
820
+ function resolveWorkspaceIdentity(options: DatabricksWorkspaceOptions): {
649
821
  id: string;
650
822
  name: string;
651
823
  } {
@@ -662,30 +834,51 @@ function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
662
834
 
663
835
  /** Collect the skill-folder resolver and any caller-supplied ones. */
664
836
  function buildMountResolvers(
665
- skillFolders: Record<string, SkillFolderOptions>,
666
- mounts: WorkspaceMountResolver[] | undefined,
837
+ paths: readonly DatabricksWorkspacePathValue[],
838
+ mounts: Record<string, WorkspaceFilesystem> | undefined,
839
+ localMounts: WorkspaceMountResolver[],
667
840
  filesCache: FilesCacheExports | undefined,
841
+ cacheConfig: DatabricksWorkspaceCache | undefined,
668
842
  ): WorkspaceMountResolver[] {
669
- const resolvers: WorkspaceMountResolver[] = [scratchMountResolver()];
670
- const folderCount = Object.keys(skillFolders).length;
671
- if (folderCount > 0) {
672
- resolvers.push((context) => resolveSkillFolderMounts(skillFolders, context, filesCache));
673
- }
674
- if (mounts?.length) {
675
- resolvers.push(...mounts);
843
+ const resolvers: WorkspaceMountResolver[] = [];
844
+ const pathCount = paths.length;
845
+ if (pathCount > 0) {
846
+ resolvers.push((context) =>
847
+ resolveSkillFolderMounts(paths, context, filesCache, cacheConfig),
848
+ );
676
849
  }
850
+ if (mounts && Object.keys(mounts).length > 0) resolvers.push(() => ({ mounts }));
851
+ resolvers.push(...localMounts);
677
852
  logger.debug("mounts:resolvers", {
678
- skillFolderCount: folderCount,
679
- customResolverCount: mounts?.length ?? 0,
853
+ pathCount,
854
+ customMountCount: Object.keys(mounts ?? {}).length,
855
+ localMountCount: localMounts.length,
680
856
  totalResolverCount: resolvers.length,
681
857
  });
682
858
  return resolvers;
683
859
  }
684
860
 
685
- /** Contribute one isolated local scratch filesystem at `/tmp`. */
686
- function scratchMountResolver(): WorkspaceMountResolver {
687
- return () => ({
688
- mounts: { [SCRATCH_MOUNT]: scratchFilesystem() },
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.",
689
882
  });
690
883
  }
691
884
 
@@ -717,15 +910,10 @@ function shouldMountSkillFolders(requestContext: RequestContext): boolean {
717
910
  return hasWorkspaceFileScope(requestContext);
718
911
  }
719
912
 
720
- /** Read the trimmed OBO user email stamped on {@link MASTRA_USER_EMAIL_KEY}. */
721
- function resolveScopedEmail(requestContext: RequestContext | undefined): string | undefined {
722
- return stringUtils.trimToNull(requestContext?.get(MASTRA_USER_EMAIL_KEY)) ?? undefined;
723
- }
724
-
725
913
  /**
726
914
  * 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.
915
+ * inaccessible roots return `undefined` so other accessible roots remain
916
+ * usable.
729
917
  */
730
918
  async function databricksFilesystem(
731
919
  client: WorkspaceClient,
@@ -733,7 +921,7 @@ async function databricksFilesystem(
733
921
  readOnly: boolean = true,
734
922
  createRoot: boolean = !readOnly,
735
923
  fileSystemCache?: FileSystemCache,
736
- skillPaths: readonly string[] = [],
924
+ cacheOptions?: FileSystemCacheOptions,
737
925
  adapterOptions: Pick<MastraFileSystemAdapterOptions, "description" | "displayName"> = {},
738
926
  ): Promise<
739
927
  | {
@@ -754,9 +942,9 @@ async function databricksFilesystem(
754
942
  readOnly,
755
943
  createRoot,
756
944
  });
757
- const source = fileSystemCache
758
- ? sharedFS.cache(fs, fileSystemCache, { readFilePaths: skillPaths })
759
- : fs;
945
+ const cached =
946
+ fileSystemCache && cacheOptions ? sharedFS.cache(fs, fileSystemCache, cacheOptions) : fs;
947
+ const source = verifyFirstFileSystemAccess(cached, () => fs.stat("."), root);
760
948
  const filesystem = filesystems(source, { ...adapterOptions, readOnly });
761
949
  try {
762
950
  await filesystem.init();
@@ -773,13 +961,83 @@ async function databricksFilesystem(
773
961
  return undefined;
774
962
  }
775
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
+
776
1034
  /**
777
1035
  * Run every mount resolver for one request and merge mounts plus skill paths.
778
1036
  * Later resolvers overwrite mount keys from earlier ones.
779
1037
  */
780
1038
  async function resolveWorkspaceContribution(
781
1039
  resolvers: WorkspaceMountResolver[],
782
- context: WorkspaceMountContext,
1040
+ context: DatabricksWorkspaceContext,
783
1041
  ): Promise<WorkspaceMountContribution> {
784
1042
  const mounts: Record<string, WorkspaceFilesystem> = {};
785
1043
  const skillPaths: string[] = [];
@@ -811,34 +1069,37 @@ async function resolveWorkspaceContribution(
811
1069
  /**
812
1070
  * Dynamic filesystem resolver passed to Mastra {@link Workspace}.
813
1071
  *
814
- * Returns a {@link CompositeFilesystem} when any mount resolved; otherwise a
815
- * 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.
816
1074
  */
817
1075
  async function resolveWorkspaceFilesystem(
818
- resolveContribution: (context: WorkspaceMountContext) => Promise<WorkspaceMountContribution>,
819
- context: WorkspaceMountContext,
1076
+ resolveContribution: (context: DatabricksWorkspaceContext) => Promise<WorkspaceMountContribution>,
1077
+ context: DatabricksWorkspaceContext,
820
1078
  filesCache?: FilesCacheExports,
1079
+ sourceKey?: string,
821
1080
  ): Promise<WorkspaceFilesystem> {
822
1081
  const { mounts } = await resolveContribution(context);
823
1082
  const mountKeys = Object.keys(mounts);
824
1083
  if (mountKeys.length === 0) {
825
- logger.debug("filesystem:scratch", {
1084
+ logger.debug("filesystem:empty", {
826
1085
  hasRequestContext: Boolean(context.requestContext),
827
1086
  });
828
- return scratchFilesystem();
1087
+ return filesystems(new MemoryFileSystem({ root: "/", readOnly: true }), {
1088
+ readOnly: true,
1089
+ });
829
1090
  }
830
1091
  logger.debug("filesystem:composite", { mountKeys });
831
1092
  const load = () => new MountedCompositeFilesystem({ mounts });
832
1093
  if (!filesCache) return load();
833
1094
  const scoped = await resolveFilesCacheScope(context);
834
1095
  if (!scoped) return load();
835
- return filesCache.forFileSystem(scoped.scope, { paths: mountKeys }, load);
1096
+ return filesCache.forFileSystem(scoped.scope, { paths: mountKeys, key: sourceKey }, load);
836
1097
  }
837
1098
 
838
1099
  /**
839
1100
  * Expand skill roots into concrete skill directories and keep the first
840
1101
  * same-named directory. Mastra still owns parsing, indexing, and refreshes,
841
- * while organization roots retain precedence over personal roots.
1102
+ * while earlier configured roots retain precedence over later roots.
842
1103
  */
843
1104
  async function resolveDistinctSkillPaths(
844
1105
  filesystem: WorkspaceFilesystem,
@@ -870,7 +1131,7 @@ async function resolveDistinctSkillPaths(
870
1131
  /** Memoize one complete mount contribution for each request context. */
871
1132
  function contributionResolver(
872
1133
  resolvers: WorkspaceMountResolver[],
873
- ): (context: WorkspaceMountContext) => Promise<WorkspaceMountContribution> {
1134
+ ): (context: DatabricksWorkspaceContext) => Promise<WorkspaceMountContribution> {
874
1135
  const scoped = new WeakMap<RequestContext, Promise<WorkspaceMountContribution>>();
875
1136
  let unscoped: Promise<WorkspaceMountContribution> | undefined;
876
1137
  return (context) => {