@dbx-tools/appkit-mastra 0.6.8 → 0.6.10

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/plugin.ts CHANGED
@@ -77,6 +77,7 @@ import type { Pool } from "pg";
77
77
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.ts";
78
78
  import { fetchChart } from "./chart.ts";
79
79
  import { attributedUserId, MASTRA_CONFIG_SCHEMA, type MastraPluginConfig } from "./config.ts";
80
+ import { provisionDatabricksAITools } from "./databricks-aitools.ts";
80
81
  import {
81
82
  chartFetchDefaults,
82
83
  feedbackWriteDefaults,
@@ -1011,12 +1012,24 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1011
1012
  });
1012
1013
  }
1013
1014
 
1015
+ // Fold Databricks AI Tools skills (`databricks aitools`) in as extra local
1016
+ // skill scan paths: reuse the CLI's installed tree when present, else shell
1017
+ // out to the CLI to materialize a curated set.
1018
+ const aiTools = await provisionDatabricksAITools(this.config.databricksAITools);
1019
+ if (aiTools.localSkillPaths.length > 0) {
1020
+ this.logger.info("databricks ai tools provisioned", {
1021
+ source: aiTools.source,
1022
+ localSkillPaths: aiTools.localSkillPaths.length,
1023
+ });
1024
+ }
1025
+ const extraSkillPaths = [...provisioned.localSkillPaths, ...aiTools.localSkillPaths];
1026
+
1014
1027
  this.built = await buildAgents({
1015
1028
  config: this.config,
1016
1029
  context: this.context,
1017
1030
  memoryBuilder,
1018
1031
  log: this.logger,
1019
- extraSkillPaths: provisioned.localSkillPaths,
1032
+ extraSkillPaths,
1020
1033
  });
1021
1034
 
1022
1035
  // `mastra.server.apiRoutes` is only honored by Mastra's standalone
@@ -27,23 +27,24 @@
27
27
  * discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
28
28
  * explicit `databricksBasePath`) to target `/Users/<email>/.assistant/skills`
29
29
  * instead. When no Databricks client is resolvable at startup, the tree is
30
- * written to a local temp dir and returned as an extra local skill path for
31
- * the current process.
30
+ * written under {@link localFS.tmpFS} and returned as an extra local skill path
31
+ * for the current process.
32
32
  *
33
33
  * @module
34
34
  */
35
35
 
36
- import { mkdtemp, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
36
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
37
37
  import { createRequire } from "node:module";
38
- import { tmpdir } from "node:os";
39
38
  import { join, posix } from "node:path";
39
+ import type { WorkspaceClient } from "@databricks/sdk-experimental";
40
40
  import { appkit } from "@dbx-tools/appkit";
41
41
  import type { WorkspaceClientLike } from "@dbx-tools/appkit";
42
- import { spawn } from "@dbx-tools/core";
43
- import { findFiles } from "@dbx-tools/path";
44
- import { error, log, net, string } from "@dbx-tools/shared-core";
45
-
46
- import { DatabricksWorkspaceFilesystem } from "./filesystems.ts";
42
+ import { exec } from "@dbx-tools/core";
43
+ import { DatabricksFileSystem } from "@dbx-tools/databricks";
44
+ import { localFS, type LocalFileSystem } from "@dbx-tools/fs";
45
+ import { find } from "@dbx-tools/path";
46
+ import { error, hash, log, net, string } from "@dbx-tools/shared-core";
47
+ import type { FileSystem } from "@dbx-tools/shared-fs";
47
48
 
48
49
  const logger = log.logger("mastra/remote-skills");
49
50
 
@@ -64,6 +65,9 @@ const SKILLS_CLI_LAYOUT = [".agents", "skills"] as const;
64
65
  /** Cap on a direct-fetch download body, matching the `skills` CLI default. */
65
66
  const DEFAULT_MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024;
66
67
 
68
+ /** Stable temp directory holding one rebuilt skill tree per remote source. */
69
+ const LOCAL_SKILLS_DIR = "mastra-local-skills";
70
+
67
71
  /* -------------------------------- types -------------------------------- */
68
72
 
69
73
  /**
@@ -169,8 +173,8 @@ function toSourceOptions(source: RemoteSkillSource): RemoteSkillSourceOptions {
169
173
  * Materialize every configured remote skill source at app startup.
170
174
  *
171
175
  * Writes each resolved `SKILL.md` tree to the Databricks user Assistant skills
172
- * folder when a writable workspace is available, else to a local temp dir
173
- * returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
176
+ * folder when a writable workspace is available, else under
177
+ * {@link localFS.tmpFS} returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
174
178
  */
175
179
  export async function provisionRemoteSkills(
176
180
  option: RemoteSkillsOption | undefined,
@@ -183,12 +187,17 @@ export async function provisionRemoteSkills(
183
187
  const client = options.client ?? appkit.tryGetExecutionContext()?.client;
184
188
  const databricksBasePath = resolveDatabricksBasePath(options, client);
185
189
  const destination = databricksBasePath
186
- ? new DatabricksWorkspaceFilesystem({ client, basePath: databricksBasePath, readOnly: false })
190
+ ? new DatabricksFileSystem({
191
+ client: client as WorkspaceClient,
192
+ root: databricksBasePath,
193
+ readOnly: false,
194
+ createRoot: true,
195
+ })
187
196
  : undefined;
188
197
 
189
198
  const localSkillPaths: string[] = [];
190
199
  const skillNames: string[] = [];
191
- let staging: string | undefined;
200
+ let staging: LocalFileSystem | undefined;
192
201
 
193
202
  try {
194
203
  const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
@@ -196,17 +205,17 @@ export async function provisionRemoteSkills(
196
205
  const sourceOptions = toSourceOptions(entry);
197
206
  const failOnError = sourceOptions.failOnError ?? failDefault;
198
207
  try {
199
- staging ??= await mkdtemp(join(tmpdir(), "mastra-remote-skills-"));
200
- const stagedDir = await stageSource(sourceOptions, staging, options);
208
+ staging ??= await initializedScratch("mastra-remote-skills");
209
+ const stagedDir = await stageSource(sourceOptions, staging.root, options);
201
210
  const staged = await collectSkillDirs(stagedDir);
202
211
  if (staged.length === 0) {
203
212
  throw new Error(`no SKILL.md found for source "${sourceOptions.source}"`);
204
213
  }
205
214
  if (destination && databricksBasePath) {
206
- await uploadSkillDirs(destination, staged);
215
+ await copySkillDirs(destination, staged);
207
216
  skillNames.push(...staged.map((dir) => dir.name));
208
217
  } else {
209
- const localDir = await persistLocally(staged);
218
+ const localDir = await persistLocally(sourceOptions.source, staged);
210
219
  localSkillPaths.push(localDir);
211
220
  skillNames.push(...staged.map((dir) => dir.name));
212
221
  }
@@ -229,7 +238,9 @@ export async function provisionRemoteSkills(
229
238
  }
230
239
  }
231
240
  } finally {
232
- if (staging) await rm(staging, { recursive: true, force: true }).catch(() => undefined);
241
+ if (staging) {
242
+ await rm(staging.root, { recursive: true, force: true }).catch(() => undefined);
243
+ }
233
244
  }
234
245
 
235
246
  return { localSkillPaths, databricksBasePath, skillNames };
@@ -250,15 +261,16 @@ function resolveDatabricksBasePath(
250
261
  }
251
262
 
252
263
  /**
253
- * Stage one source into a fresh dir under `staging`, preferring the optional
264
+ * Stage one source into a fresh dir under `stagingRoot`, preferring the optional
254
265
  * `skills` CLI and falling back to a direct fetch.
255
266
  */
256
267
  async function stageSource(
257
268
  sourceOptions: RemoteSkillSourceOptions,
258
- staging: string,
269
+ stagingRoot: string,
259
270
  options: ProvisionRemoteSkillsOptions,
260
271
  ): Promise<string> {
261
- const target = await mkdtemp(join(staging, "src-"));
272
+ const target = join(stagingRoot, `src-${hash.id()}`);
273
+ await mkdir(target, { recursive: true });
262
274
  const viaCli = await stageViaSkillsCli(sourceOptions, target);
263
275
  if (viaCli) return viaCli;
264
276
  return stageViaFetch(sourceOptions, target, options);
@@ -290,7 +302,7 @@ async function stageViaSkillsCli(
290
302
  }
291
303
  if (string.parseList(sourceOptions.skills).length === 0) args.push("--skill", "*");
292
304
 
293
- const result = await spawn(cli.command, args, {
305
+ const result = await exec.spawn(cli.command, args, {
294
306
  cwd: target,
295
307
  stdout: "capture",
296
308
  stderr: "capture",
@@ -389,28 +401,47 @@ async function collectSkillDirs(root: string): Promise<StagedSkillDir[]> {
389
401
  return dirs;
390
402
  }
391
403
 
392
- /** Upload each staged skill directory into the Databricks destination tree. */
393
- async function uploadSkillDirs(
394
- destination: DatabricksWorkspaceFilesystem,
395
- dirs: StagedSkillDir[],
396
- ): Promise<void> {
397
- await destination.init?.();
404
+ /**
405
+ * Copy every staged skill directory into {@link destination}, preserving the
406
+ * `<skill>/<relative>` layout. The destination is just a {@link FileSystem},
407
+ * so the Databricks Assistant tree and the local fallback share one copier.
408
+ */
409
+ async function copySkillDirs(destination: FileSystem, dirs: StagedSkillDir[]): Promise<void> {
410
+ await destination.init();
398
411
  for (const dir of dirs) {
399
- for (const relative of findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
412
+ for (const relative of find.findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
400
413
  const buffer = await readFile(join(dir.absolutePath, relative));
401
- const remotePath = posix.join("/", dir.name, relative.split(/[\\/]/).join("/"));
402
- await destination.writeFile(remotePath, buffer, { overwrite: true });
414
+ const skillPath = posix.join(dir.name, relative.split(/[\\/]/).join("/"));
415
+ await destination.writeFile(skillPath, buffer, { overwrite: true });
403
416
  }
404
417
  }
405
418
  }
406
419
 
407
- /** Copy staged skill dirs into a persistent local temp dir; return its path. */
408
- async function persistLocally(dirs: StagedSkillDir[]): Promise<string> {
409
- const base = await mkdtemp(join(tmpdir(), "mastra-local-skills-"));
410
- for (const dir of dirs) {
411
- await cp(dir.absolutePath, join(base, dir.name), { recursive: true });
412
- }
413
- return base;
420
+ /**
421
+ * Copy staged skill dirs into a STABLE local tree keyed by the source, and
422
+ * return its root path.
423
+ *
424
+ * A given source resolves to the same content on almost every boot, so each
425
+ * one owns a directory named for its {@link hash.fnvHash} rather than leaving
426
+ * a fresh scratch dir behind per restart. Keying on the source (not the
427
+ * content) is what keeps two different sources from overwriting each other.
428
+ */
429
+ async function persistLocally(source: string, dirs: StagedSkillDir[]): Promise<string> {
430
+ const stable = await localFS.rebuildFS(`${LOCAL_SKILLS_DIR}/${hash.fnvHash(source)}`, (scratch) =>
431
+ copySkillDirs(scratch, dirs),
432
+ );
433
+ return stable.root;
434
+ }
435
+
436
+ /**
437
+ * A scratch filesystem whose root exists on disk, for the paths that hand a
438
+ * real directory to `node:fs` or a child process rather than going through
439
+ * the {@link FileSystem} API.
440
+ */
441
+ async function initializedScratch(prefix: string): Promise<LocalFileSystem> {
442
+ const scratch = localFS.scratchFS(prefix);
443
+ await scratch.init();
444
+ return scratch;
414
445
  }
415
446
 
416
447
  /** Best-effort existence check. */
package/src/workspaces.ts CHANGED
@@ -7,10 +7,15 @@
7
7
  * contributions merge extra filesystems and skill scan roots; built-in
8
8
  * Assistant skill trees are toggled with `assistantSkills` (on by default).
9
9
  *
10
+ * Databricks mounts use `@dbx-tools/databricks` {@link DatabricksFileSystem}
11
+ * wrapped by {@link filesystems}; missing roots fall back to
12
+ * {@link scratchFilesystem}.
13
+ *
10
14
  * @module
11
15
  */
12
16
 
13
17
  import type { WorkspaceClient } from "@databricks/sdk-experimental";
18
+ import { DatabricksFileSystem } from "@dbx-tools/databricks";
14
19
  import { log, string, token } from "@dbx-tools/shared-core";
15
20
  import type { RequestContext } from "@mastra/core/request-context";
16
21
  import {
@@ -22,7 +27,7 @@ import {
22
27
  } from "@mastra/core/workspace";
23
28
 
24
29
  import { MASTRA_SCOPES_KEY, MASTRA_USER_EMAIL_KEY, MASTRA_USER_KEY, type User } from "./config.ts";
25
- import { DatabricksWorkspaceFilesystem, emptyFilesystem } from "./filesystems.ts";
30
+ import { scratchFilesystem, filesystems } from "./filesystems.ts";
26
31
 
27
32
  /* ------------------------------ constants ------------------------------ */
28
33
 
@@ -175,8 +180,10 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
175
180
  * set. Returns empty mounts when the OBO user or client is missing.
176
181
  * Mastra owns filesystem initialization.
177
182
  */
178
- function resolveAssistantSkillsMounts(context: WorkspaceMountContext): WorkspaceMountContribution {
179
- const mounts: Record<string, DatabricksWorkspaceFilesystem> = {};
183
+ async function resolveAssistantSkillsMounts(
184
+ context: WorkspaceMountContext,
185
+ ): Promise<WorkspaceMountContribution> {
186
+ const mounts: Record<string, WorkspaceFilesystem> = {};
180
187
  const requestContext = context.requestContext;
181
188
 
182
189
  if (!shouldMountAssistantSkills(requestContext)) {
@@ -189,7 +196,7 @@ function resolveAssistantSkillsMounts(context: WorkspaceMountContext): Workspace
189
196
  }
190
197
 
191
198
  const user = requestContext!.get(MASTRA_USER_KEY) as User | undefined;
192
- const client = user?.executionContext.client;
199
+ const client = user?.executionContext.client as WorkspaceClient | undefined;
193
200
  if (!client) {
194
201
  logger.debug("assistant-skills:skipped", {
195
202
  reason: "missing-obo-client",
@@ -198,14 +205,14 @@ function resolveAssistantSkillsMounts(context: WorkspaceMountContext): Workspace
198
205
  return { mounts, skillPaths: [] };
199
206
  }
200
207
 
201
- mounts[ASSISTANT_WORKSPACE_SKILLS_MOUNT] = databricksFilesystem(
208
+ mounts[ASSISTANT_WORKSPACE_SKILLS_MOUNT] = await databricksFilesystem(
202
209
  client,
203
210
  ASSISTANT_SHARED_SKILLS_PATH,
204
211
  );
205
212
 
206
213
  const email = resolveScopedEmail(requestContext);
207
214
  if (email) {
208
- mounts[ASSISTANT_USER_SKILLS_MOUNT] = databricksFilesystem(
215
+ mounts[ASSISTANT_USER_SKILLS_MOUNT] = await databricksFilesystem(
209
216
  client,
210
217
  userAssistantSkillsPath(email),
211
218
  false,
@@ -281,17 +288,35 @@ function resolveScopedEmail(requestContext: RequestContext | undefined): string
281
288
  return email?.trim() || undefined;
282
289
  }
283
290
 
284
- /** Construct a read-only {@link DatabricksWorkspaceFilesystem} for `basePath`. */
285
- function databricksFilesystem(
291
+ /**
292
+ * Wrap a {@link DatabricksFileSystem} as a Mastra filesystem. When the root is
293
+ * missing (and {@link readOnly} so we will not create it), fall back to
294
+ * {@link scratchFilesystem} so skill scans still have a writable local mount.
295
+ */
296
+ async function databricksFilesystem(
286
297
  client: WorkspaceClient,
287
- basePath: string,
298
+ root: string,
288
299
  readOnly: boolean = true,
289
- ): DatabricksWorkspaceFilesystem {
290
- return new DatabricksWorkspaceFilesystem({
300
+ ): Promise<WorkspaceFilesystem> {
301
+ const fs = new DatabricksFileSystem({
291
302
  client,
292
- basePath,
303
+ root,
293
304
  readOnly,
305
+ createRoot: !readOnly,
294
306
  });
307
+ try {
308
+ await fs.init();
309
+ if (await fs.exists(".")) {
310
+ return filesystems(fs, { readOnly });
311
+ }
312
+ } catch (err) {
313
+ logger.debug("databricks-mount:scratch-fallback", {
314
+ root,
315
+ readOnly,
316
+ error: err instanceof Error ? err.message : String(err),
317
+ });
318
+ }
319
+ return scratchFilesystem();
295
320
  }
296
321
 
297
322
  /**
@@ -329,8 +354,8 @@ async function resolveWorkspaceContribution(
329
354
  /**
330
355
  * Dynamic filesystem resolver passed to Mastra {@link Workspace}.
331
356
  *
332
- * Returns a {@link CompositeFilesystem} when any mount resolved; otherwise
333
- * {@link emptyFilesystem}.
357
+ * Returns a {@link CompositeFilesystem} when any mount resolved; otherwise a
358
+ * fresh {@link scratchFilesystem} so Mastra always has a writable local root.
334
359
  */
335
360
  async function resolveWorkspaceFilesystem(
336
361
  resolvers: WorkspaceMountResolver[],
@@ -339,10 +364,10 @@ async function resolveWorkspaceFilesystem(
339
364
  const { mounts } = await resolveWorkspaceContribution(resolvers, context);
340
365
  const mountKeys = Object.keys(mounts);
341
366
  if (mountKeys.length === 0) {
342
- logger.debug("filesystem:empty", {
367
+ logger.debug("filesystem:scratch", {
343
368
  hasRequestContext: Boolean(context.requestContext),
344
369
  });
345
- return emptyFilesystem();
370
+ return scratchFilesystem();
346
371
  }
347
372
  logger.debug("filesystem:composite", { mountKeys });
348
373
  return new CompositeFilesystem({ mounts });