@dbx-tools/appkit-mastra 0.6.14 → 0.6.27

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
@@ -2,10 +2,14 @@
2
2
  * Mastra workspace factory for Databricks Apps.
3
3
  *
4
4
  * Builds a per-request {@link Workspace} whose filesystem is a
5
- * {@link CompositeFilesystem} over Databricks paths resolved from the
6
- * OBO client on {@link MASTRA_USER_KEY}. Optional mount resolver
7
- * contributions merge extra filesystems and skill scan roots; built-in
8
- * Assistant skill trees are toggled with `assistantSkills` (on by default).
5
+ * {@link CompositeFilesystem} over the NAMED skill folders resolved for that
6
+ * request. A skill folder maps a name to a location plus its readable /
7
+ * writable policy: a Databricks path mounted through the OBO client on
8
+ * {@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.
9
13
  *
10
14
  * Databricks mounts use `@dbx-tools/databricks` {@link DatabricksFileSystem}
11
15
  * wrapped by {@link filesystems}; missing roots fall back to
@@ -16,7 +20,7 @@
16
20
 
17
21
  import type { WorkspaceClient } from "@databricks/sdk-experimental";
18
22
  import { DatabricksFileSystem } from "@dbx-tools/databricks";
19
- import { log, string, token } from "@dbx-tools/shared-core";
23
+ import { error, log, string, token } from "@dbx-tools/shared-core";
20
24
  import type { RequestContext } from "@mastra/core/request-context";
21
25
  import {
22
26
  CompositeFilesystem,
@@ -28,18 +32,10 @@ import {
28
32
 
29
33
  import { MASTRA_SCOPES_KEY, MASTRA_USER_EMAIL_KEY, MASTRA_USER_KEY, type User } from "./config.ts";
30
34
  import { scratchFilesystem, filesystems } from "./filesystems.ts";
35
+ import { ASSISTANT_SHARED_SKILLS_PATH, userAssistantSkillsPath } from "./skill-paths.ts";
31
36
 
32
37
  /* ------------------------------ constants ------------------------------ */
33
38
 
34
- /** Shared Assistant skills tree in the workspace namespace. */
35
- const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
36
-
37
- /** Composite mount for {@link ASSISTANT_SHARED_SKILLS_PATH}. */
38
- const ASSISTANT_WORKSPACE_SKILLS_MOUNT = "/workspace_skills";
39
-
40
- /** Composite mount for the caller's `/.assistant/skills` tree. */
41
- const ASSISTANT_USER_SKILLS_MOUNT = "/workspace_user_skills";
42
-
43
39
  /** OAuth scopes that gate Databricks workspace file mounts. */
44
40
  const WORKSPACE_FILE_SCOPES = ["workspace", "workspace.workspace", "all-apis"] as const;
45
41
 
@@ -47,23 +43,60 @@ const logger = log.logger("mastra/workspaces");
47
43
 
48
44
  /* -------------------------------- types -------------------------------- */
49
45
 
50
- /** Per-request context for mount resolvers. */
51
- interface WorkspaceMountContext {
46
+ /** Per-request context for mount and skill-folder resolvers. */
47
+ export interface WorkspaceMountContext {
52
48
  requestContext?: RequestContext;
53
49
  }
54
50
 
51
+ /**
52
+ * A skill-folder field given either directly or as a per-request resolver.
53
+ * A resolver returning `undefined` skips the folder for that request.
54
+ */
55
+ export type SkillFolderValue<T> =
56
+ T | ((context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>);
57
+
58
+ /**
59
+ * One named skill-folder location and its read / write policy.
60
+ *
61
+ * Give {@link path} for a Databricks workspace tree (mounted through the
62
+ * request's OBO client), or {@link filesystem} for a mount the consumer builds
63
+ * itself. {@link filesystem} wins when both are set.
64
+ */
65
+ export interface SkillFolderOptions {
66
+ /** Absolute Databricks workspace path for this folder. */
67
+ path?: SkillFolderValue<string>;
68
+ /** Ready-made mount, for locations the OBO client cannot reach. */
69
+ filesystem?: SkillFolderValue<WorkspaceFilesystem>;
70
+ /**
71
+ * Scan this mount for `SKILL.md` files. Defaults to `true`; `false` mounts
72
+ * the location for file tools without adding it to skill discovery.
73
+ */
74
+ readable?: boolean;
75
+ /**
76
+ * Allow writes to a {@link path} mount (and create the root when missing).
77
+ * Defaults to `false`. A supplied {@link filesystem} carries its own
78
+ * read-only flag instead.
79
+ */
80
+ writable?: boolean;
81
+ /** Mount point in the composite namespace. Defaults to `/<name>`. */
82
+ mount?: string;
83
+ }
84
+
55
85
  /** Mount map plus optional Mastra skill scan roots for one resolver. */
56
- interface WorkspaceMountContribution {
86
+ export interface WorkspaceMountContribution {
57
87
  mounts: Record<string, WorkspaceFilesystem>;
58
88
  /** Paths within the composite namespace where `SKILL.md` files are scanned. */
59
89
  skillPaths?: string[];
60
90
  }
61
91
 
62
92
  /** Contributes filesystem mounts (and optional skill paths) for one request. */
63
- type WorkspaceMountResolver = (
93
+ export type WorkspaceMountResolver = (
64
94
  context: WorkspaceMountContext,
65
95
  ) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
66
96
 
97
+ /** Names carried by {@link DEFAULT_SKILL_FOLDERS}. */
98
+ export type DefaultSkillFolderName = "workspace-team" | "workspace-team-app";
99
+
67
100
  /** Options for {@link createWorkspace}. */
68
101
  export interface CreateWorkspaceOptions {
69
102
  /** Workspace id; derived from `name` or `"workspace"` when omitted. */
@@ -71,11 +104,17 @@ export interface CreateWorkspaceOptions {
71
104
  /** Display name; derived from `id` when omitted. */
72
105
  name?: string;
73
106
  /**
74
- * Mount read-only Assistant skill trees from `/Workspace/.assistant/skills`
75
- * and `/Users/<email>/.assistant/skills`. Defaults to `true`.
107
+ * Start from {@link DEFAULT_SKILL_FOLDERS}. Defaults to `true`; `false`
108
+ * starts from an empty map, leaving only the {@link skillFolders} given here.
76
109
  */
77
110
  assistantSkills?: boolean;
78
- /** Extra per-request mount resolvers (run after built-in options). */
111
+ /**
112
+ * Named skill folders merged over {@link DEFAULT_SKILL_FOLDERS}: a matching
113
+ * name overrides that default, `false` disables it, and any other name adds
114
+ * a folder.
115
+ */
116
+ skillFolders?: Record<string, SkillFolderOptions | false>;
117
+ /** Extra per-request mount resolvers (run after the skill-folder mounts). */
79
118
  mounts?: WorkspaceMountResolver[];
80
119
  /** Replace the auto-built dynamic skills resolver. */
81
120
  skills?: SkillsResolver;
@@ -93,18 +132,56 @@ export interface CreateWorkspaceOptions {
93
132
  extraSkillPaths?: string[];
94
133
  }
95
134
 
135
+ /* ------------------------------- defaults ------------------------------- */
136
+
137
+ /**
138
+ * The skill folders every workspace starts with.
139
+ *
140
+ * - `workspace-team` - the shared workspace Assistant tree, read-only because
141
+ * writing it is a workspace-admin action.
142
+ * - `workspace-team-app` - the requesting user's own Assistant tree, writable
143
+ * so the app can save skills back to it. Skipped when the request carries no
144
+ * user email.
145
+ */
146
+ export const DEFAULT_SKILL_FOLDERS: Readonly<Record<DefaultSkillFolderName, SkillFolderOptions>> = {
147
+ "workspace-team": {
148
+ path: ASSISTANT_SHARED_SKILLS_PATH,
149
+ readable: true,
150
+ writable: false,
151
+ },
152
+ "workspace-team-app": {
153
+ path: ({ requestContext }) => {
154
+ const email = resolveScopedEmail(requestContext);
155
+ return email ? userAssistantSkillsPath(email) : undefined;
156
+ },
157
+ readable: true,
158
+ writable: true,
159
+ },
160
+ };
161
+
96
162
  /**
97
163
  * Create a Mastra {@link Workspace} with per-request Databricks mounts.
98
164
  *
99
- * @example Assistant skills only (default for agents in this plugin)
165
+ * @example Default skill folders only
100
166
  * ```ts
101
167
  * createWorkspace()
102
168
  * ```
103
169
  *
104
- * @example Assistant skills plus a custom mount resolver
170
+ * @example Override a default, drop another, and add a location of your own
171
+ * ```ts
172
+ * createWorkspace({
173
+ * skillFolders: {
174
+ * "workspace-team": { path: "/Workspace/Shared/team-skills" },
175
+ * "workspace-team-app": false,
176
+ * runbooks: { path: "/Workspace/Shared/runbooks/skills", writable: true },
177
+ * volume: { filesystem: myVolumeFilesystem },
178
+ * },
179
+ * })
180
+ * ```
181
+ *
182
+ * @example Skill folders plus a custom mount resolver
105
183
  * ```ts
106
184
  * createWorkspace({
107
- * assistantSkills: true,
108
185
  * mounts: [
109
186
  * async ({ requestContext }) => ({
110
187
  * mounts: { "/data": myFilesystem },
@@ -116,20 +193,22 @@ export interface CreateWorkspaceOptions {
116
193
  */
117
194
  export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
118
195
  const { id, name } = resolveWorkspaceIdentity(options);
119
- const resolvers = buildMountResolvers(options);
196
+ const skillFolders = resolveSkillFolders(options);
197
+ const folderNames = Object.keys(skillFolders);
198
+ const resolvers = buildMountResolvers(skillFolders, options.mounts);
120
199
  const extraSkillPaths = options.extraSkillPaths ?? [];
121
200
  const skills =
122
201
  options.skills ??
123
202
  (resolvers.length > 0 || extraSkillPaths.length > 0
124
203
  ? buildWorkspaceSkillsResolver(resolvers, extraSkillPaths)
125
204
  : undefined);
126
- const checkSkillFileMtime = options.checkSkillFileMtime ?? options.assistantSkills !== false;
205
+ const checkSkillFileMtime = options.checkSkillFileMtime ?? folderNames.length > 0;
127
206
  const bm25 = options.bm25 !== false;
128
207
  logger.debug("workspace:create", {
129
208
  id,
130
209
  name,
131
210
  resolverCount: resolvers.length,
132
- assistantSkills: options.assistantSkills !== false,
211
+ skillFolders: folderNames,
133
212
  customMountResolvers: options.mounts?.length ?? 0,
134
213
  customSkillsResolver: Boolean(options.skills),
135
214
  checkSkillFileMtime,
@@ -151,16 +230,29 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
151
230
  });
152
231
  }
153
232
 
154
- /* ---------------------------- private helpers ---------------------------- */
155
-
156
233
  /**
157
- * Map an OBO user email to their Assistant skills directory in the
158
- * workspace namespace.
234
+ * Merge the configured skill folders over {@link DEFAULT_SKILL_FOLDERS}.
235
+ *
236
+ * `assistantSkills: false` drops the defaults, and a `false` value removes one
237
+ * entry by name.
159
238
  */
160
- function userAssistantSkillsPath(userEmail: string): string {
161
- return `/Users/${userEmail.trim()}/.assistant/skills`;
239
+ export function resolveSkillFolders(
240
+ options: Pick<CreateWorkspaceOptions, "assistantSkills" | "skillFolders"> = {},
241
+ ): Record<string, SkillFolderOptions> {
242
+ const merged: Record<string, SkillFolderOptions> =
243
+ options.assistantSkills === false ? {} : { ...DEFAULT_SKILL_FOLDERS };
244
+ for (const [name, folder] of Object.entries(options.skillFolders ?? {})) {
245
+ if (folder === false) {
246
+ delete merged[name];
247
+ } else {
248
+ merged[name] = folder;
249
+ }
250
+ }
251
+ return merged;
162
252
  }
163
253
 
254
+ /* ---------------------------- private helpers ---------------------------- */
255
+
164
256
  /**
165
257
  * Return whether the request token carries a scope that allows workspace
166
258
  * file API access (`workspace` or `all-apis` on {@link MASTRA_SCOPES_KEY}).
@@ -173,61 +265,81 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
173
265
  }
174
266
 
175
267
  /**
176
- * Built-in mount resolver for Assistant `SKILL.md` trees.
268
+ * Mount resolver for the named skill folders.
177
269
  *
178
- * Mounts {@link ASSISTANT_SHARED_SKILLS_PATH} when scope checks pass and
179
- * `/Users/<email>/.assistant/skills` when {@link MASTRA_USER_EMAIL_KEY} is
180
- * set. Returns empty mounts when the OBO user or client is missing.
181
- * Mastra owns filesystem initialization.
270
+ * Gates on workspace file scope (or development mode), then mounts every
271
+ * folder whose location resolves for this request.
182
272
  */
183
- async function resolveAssistantSkillsMounts(
273
+ async function resolveSkillFolderMounts(
274
+ skillFolders: Record<string, SkillFolderOptions>,
184
275
  context: WorkspaceMountContext,
185
276
  ): Promise<WorkspaceMountContribution> {
186
277
  const mounts: Record<string, WorkspaceFilesystem> = {};
278
+ const skillPaths: string[] = [];
187
279
  const requestContext = context.requestContext;
188
280
 
189
- if (!shouldMountAssistantSkills(requestContext)) {
190
- logger.debug("assistant-skills:skipped", {
281
+ if (!requestContext || !shouldMountSkillFolders(requestContext)) {
282
+ logger.debug("skill-folders:skipped", {
191
283
  reason: !requestContext ? "no-request-context" : "missing-workspace-scope",
192
284
  nodeEnv: process.env.NODE_ENV,
193
- scopes: context.requestContext?.get(MASTRA_SCOPES_KEY),
285
+ scopes: requestContext?.get(MASTRA_SCOPES_KEY),
194
286
  });
195
- return { mounts, skillPaths: [] };
287
+ return { mounts, skillPaths };
196
288
  }
197
289
 
198
- const user = requestContext!.get(MASTRA_USER_KEY) as User | undefined;
290
+ const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
199
291
  const client = user?.executionContext.client as WorkspaceClient | undefined;
200
- if (!client) {
201
- logger.debug("assistant-skills:skipped", {
202
- reason: "missing-obo-client",
203
- userId: user?.id,
204
- });
205
- return { mounts, skillPaths: [] };
206
- }
207
-
208
- mounts[ASSISTANT_WORKSPACE_SKILLS_MOUNT] = await databricksFilesystem(
209
- client,
210
- ASSISTANT_SHARED_SKILLS_PATH,
211
- );
212
292
 
213
- const email = resolveScopedEmail(requestContext);
214
- if (email) {
215
- mounts[ASSISTANT_USER_SKILLS_MOUNT] = await databricksFilesystem(
216
- client,
217
- userAssistantSkillsPath(email),
218
- false,
219
- );
293
+ for (const [name, folder] of Object.entries(skillFolders)) {
294
+ const filesystem = await resolveSkillFolderFilesystem(name, folder, context, client);
295
+ if (!filesystem) continue;
296
+ const mount = folder.mount ?? `/${name}`;
297
+ mounts[mount] = filesystem;
298
+ if (folder.readable !== false) skillPaths.push(mount);
220
299
  }
221
300
 
222
- logger.debug("assistant-skills:mounted", {
223
- sharedPath: ASSISTANT_SHARED_SKILLS_PATH,
224
- sharedMount: ASSISTANT_WORKSPACE_SKILLS_MOUNT,
225
- userMount: email ? ASSISTANT_USER_SKILLS_MOUNT : undefined,
226
- userPath: email ? userAssistantSkillsPath(email) : undefined,
301
+ logger.debug("skill-folders:mounted", {
227
302
  mountKeys: Object.keys(mounts),
303
+ skillPaths,
228
304
  });
229
305
 
230
- return { mounts, skillPaths: Object.keys(mounts) };
306
+ return { mounts, skillPaths };
307
+ }
308
+
309
+ /**
310
+ * Resolve one skill folder to a Mastra filesystem, or `undefined` to skip it
311
+ * for this request.
312
+ */
313
+ async function resolveSkillFolderFilesystem(
314
+ name: string,
315
+ folder: SkillFolderOptions,
316
+ context: WorkspaceMountContext,
317
+ client: WorkspaceClient | undefined,
318
+ ): Promise<WorkspaceFilesystem | undefined> {
319
+ if (folder.filesystem !== undefined) {
320
+ return resolveSkillFolderValue(folder.filesystem, context);
321
+ }
322
+ // A path mount needs the request's OBO client to reach the workspace.
323
+ if (folder.path === undefined || !client) {
324
+ logger.debug("skill-folder:skipped", {
325
+ name,
326
+ reason: folder.path === undefined ? "no-location" : "missing-obo-client",
327
+ });
328
+ return undefined;
329
+ }
330
+ const root = string.trimToNull(await resolveSkillFolderValue(folder.path, context));
331
+ if (!root) return undefined;
332
+ return databricksFilesystem(client, root, folder.writable !== true);
333
+ }
334
+
335
+ /** Read a {@link SkillFolderValue}, calling it when it is a per-request resolver. */
336
+ function resolveSkillFolderValue<T>(
337
+ value: SkillFolderValue<T>,
338
+ context: WorkspaceMountContext,
339
+ ): T | undefined | Promise<T | undefined> {
340
+ return typeof value === "function"
341
+ ? (value as (context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>)(context)
342
+ : value;
231
343
  }
232
344
 
233
345
  /**
@@ -249,19 +361,21 @@ function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
249
361
  return { id, name };
250
362
  }
251
363
 
252
- /** Collect built-in and caller-supplied mount resolvers for one workspace. */
253
- function buildMountResolvers(options: CreateWorkspaceOptions): WorkspaceMountResolver[] {
364
+ /** Collect the skill-folder resolver and any caller-supplied ones. */
365
+ function buildMountResolvers(
366
+ skillFolders: Record<string, SkillFolderOptions>,
367
+ mounts: WorkspaceMountResolver[] | undefined,
368
+ ): WorkspaceMountResolver[] {
254
369
  const resolvers: WorkspaceMountResolver[] = [];
255
- const { assistantSkills = true, mounts } = options;
256
- if (assistantSkills) {
257
- resolvers.push(resolveAssistantSkillsMounts);
370
+ const folderCount = Object.keys(skillFolders).length;
371
+ if (folderCount > 0) {
372
+ resolvers.push((context) => resolveSkillFolderMounts(skillFolders, context));
258
373
  }
259
374
  if (mounts?.length) {
260
375
  resolvers.push(...mounts);
261
376
  }
262
377
  logger.debug("mounts:resolvers", {
263
- assistantSkills,
264
- builtInResolver: assistantSkills,
378
+ skillFolderCount: folderCount,
265
379
  customResolverCount: mounts?.length ?? 0,
266
380
  totalResolverCount: resolvers.length,
267
381
  });
@@ -269,23 +383,19 @@ function buildMountResolvers(options: CreateWorkspaceOptions): WorkspaceMountRes
269
383
  }
270
384
 
271
385
  /**
272
- * Gate Assistant skill mounts on request context.
386
+ * Gate skill-folder mounts on the request's token.
273
387
  *
274
388
  * Always allows mounts in development; in other environments requires
275
389
  * {@link hasWorkspaceFileScope}.
276
390
  */
277
- function shouldMountAssistantSkills(
278
- requestContext: RequestContext | undefined,
279
- ): requestContext is RequestContext {
280
- if (!requestContext) return false;
391
+ function shouldMountSkillFolders(requestContext: RequestContext): boolean {
281
392
  if (process.env.NODE_ENV === "development") return true;
282
393
  return hasWorkspaceFileScope(requestContext);
283
394
  }
284
395
 
285
396
  /** Read the trimmed OBO user email stamped on {@link MASTRA_USER_EMAIL_KEY}. */
286
397
  function resolveScopedEmail(requestContext: RequestContext | undefined): string | undefined {
287
- const email = requestContext?.get(MASTRA_USER_EMAIL_KEY) as string | undefined;
288
- return email?.trim() || undefined;
398
+ return string.trimToNull(requestContext?.get(MASTRA_USER_EMAIL_KEY)) ?? undefined;
289
399
  }
290
400
 
291
401
  /**
@@ -313,7 +423,7 @@ async function databricksFilesystem(
313
423
  logger.debug("databricks-mount:scratch-fallback", {
314
424
  root,
315
425
  readOnly,
316
- error: err instanceof Error ? err.message : String(err),
426
+ error: error.errorMessage(err),
317
427
  });
318
428
  }
319
429
  return scratchFilesystem();