@dbx-tools/appkit-mastra 0.3.29 → 0.3.30

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
@@ -24,10 +24,13 @@
24
24
  * - HTTP: AppKit mounts this plugin under `/api/mastra`. Alongside the
25
25
  * Mastra agent routes, the plugin registers `/route/history`
26
26
  * (load + clear a thread's messages), `/route/threads` (list the
27
- * caller's conversations + delete one), `/models`, `/suggestions`,
28
- * `/route/feedback` (log a thumbs / comment to MLflow when feedback
29
- * is enabled), and the generic `/embed/:type/:id` resolver for inline
30
- * chart / data markers. The stock `@mastra/express` surface is gated
27
+ * caller's conversations + delete one), `/models`, `/default-model`,
28
+ * `/suggestions`, `/route/feedback` (log a thumbs / comment to MLflow
29
+ * when feedback is enabled), and the generic `/embed/:type/:id`
30
+ * resolver for inline chart / data markers. Each is registered through
31
+ * AppKit's `route()` so it lands in the plugin's endpoint map, and
32
+ * every outbound call one makes runs through `execute()` (see
33
+ * `./defaults.js`). The stock `@mastra/express` surface is gated
31
34
  * by `config.apiAccess` (default `"scoped"`): only agent inference,
32
35
  * read-only agent metadata, the `/route/*` routes, and (when enabled)
33
36
  * MCP are dispatched to Mastra; admin / mutating / bulk-export routes
@@ -42,21 +45,24 @@
42
45
  */
43
46
 
44
47
  import {
48
+ ExecutionError,
45
49
  genie,
46
50
  getExecutionContext,
47
51
  lakebase,
48
52
  Plugin,
49
53
  toPlugin,
54
+ type ExecutionResult,
50
55
  type IAppRouter,
51
56
  type PluginManifest,
52
57
  type ResourceRequirement,
53
58
  } from "@databricks/appkit";
54
59
  import { plugin } from "@dbx-tools/appkit";
55
60
  import { serving as nodeServing } from "@dbx-tools/model";
56
- import { error, log, string } from "@dbx-tools/shared-core";
61
+ import { async, error, log, string } from "@dbx-tools/shared-core";
57
62
  import {
58
63
  feedback,
59
64
  routes,
65
+ type Chart,
60
66
  type MastraClientConfig,
61
67
  type MastraFeedbackRequest,
62
68
  type StatementData,
@@ -69,7 +75,14 @@ import type { Pool } from "pg";
69
75
 
70
76
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents";
71
77
  import { fetchChart } from "./chart";
72
- import type { MastraPluginConfig } from "./config";
78
+ import { MASTRA_CONFIG_SCHEMA, resolveUserKey, type MastraPluginConfig } from "./config";
79
+ import {
80
+ chartFetchDefaults,
81
+ feedbackWriteDefaults,
82
+ genieSuggestionDefaults,
83
+ modelCatalogueDefaults,
84
+ statementDataDefaults,
85
+ } from "./defaults";
73
86
  import { collectSpaceSuggestions, resolveGenieSpaces } from "./genie";
74
87
  import { historyRoute } from "./history";
75
88
  import { buildMcpServer, type ResolvedMcp } from "./mcp";
@@ -80,17 +93,71 @@ import { attachRoutePatchMiddleware, isMastraRequestAllowed, MastraServer } from
80
93
  import { resolveServingConfig } from "./serving";
81
94
  import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement";
82
95
  import { threadsRoute } from "./threads";
96
+ import { invalidFields } from "./validation";
83
97
 
84
98
  const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
85
99
  const LAKEBASE_MANIFEST = plugin.data(lakebase).plugin.manifest;
86
100
 
101
+ /**
102
+ * Budget for draining the memory service-principal pool on shutdown. Well
103
+ * inside AppKit's 15s graceful-shutdown window, so a stuck connection can
104
+ * never be what holds the process open.
105
+ */
106
+ const POOL_DRAIN_TIMEOUT_MS = 5_000;
107
+
108
+ /** Ceiling on the `?timeoutMs=` long-poll budget a client may request. */
109
+ const MAX_EMBED_POLL_TIMEOUT_MS = 5 * 60_000;
110
+
111
+ /** Stable client message when the workspace's model catalogue can't be read. */
112
+ const MODEL_CATALOGUE_FAILED_MESSAGE = "Could not read the workspace's model catalogue";
113
+
114
+ /** Stable client message for a feedback body that fails schema validation. */
115
+ const INVALID_FEEDBACK_MESSAGE = "Invalid feedback request";
116
+
117
+ /** Cache namespace for statement result sets, matching the chart cache's form. */
118
+ const STATEMENT_CACHE_NAMESPACE = "mastra:statement";
119
+
87
120
  /**
88
121
  * AppKit plugin (registered name: `mastra`) that hosts Mastra agents
89
122
  * with optional Lakebase-backed memory and AI SDK chat routes under
90
123
  * the plugin mount (typically `/api/mastra`).
124
+ *
125
+ * @example Register the plugin
126
+ * ```ts
127
+ * import { createApp, lakebase } from "@databricks/appkit";
128
+ * import { createAgent, mastra } from "@dbx-tools/appkit-mastra";
129
+ *
130
+ * // `lakebase` first: registering it auto-enables Mastra storage + memory.
131
+ * const app = await createApp({
132
+ * plugins: [
133
+ * lakebase(),
134
+ * mastra({
135
+ * genieSpaces: { default: process.env.DATABRICKS_GENIE_SPACE_ID! },
136
+ * agents: createAgent({
137
+ * name: "analyst",
138
+ * instructions: "You answer questions about revenue and returns.",
139
+ * tools(plugins) {
140
+ * return { ...plugins.genie?.toolkit() };
141
+ * },
142
+ * }),
143
+ * }),
144
+ * ],
145
+ * });
146
+ * ```
147
+ *
148
+ * @example Read the agents back off the AppKit instance
149
+ * ```ts
150
+ * const agentIds = app.mastra.list();
151
+ * const endpoints = await app.mastra.listModels();
152
+ * ```
91
153
  */
92
154
  export class MastraPlugin extends Plugin<MastraPluginConfig> {
93
- static manifest = {
155
+ // Annotated rather than left to `satisfies`: the config schema's type comes
156
+ // from `@types/json-schema`, which this package does not depend on, so an
157
+ // inferred manifest type cannot be named in the emitted declaration. The
158
+ // `PluginManifest<"mastra">` parameter still carries the literal name into
159
+ // `toPlugin()`'s factory type.
160
+ static manifest: PluginManifest<"mastra"> = {
94
161
  name: "mastra",
95
162
  displayName: "Mastra",
96
163
  description:
@@ -100,19 +167,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
100
167
  resources: {
101
168
  required: [],
102
169
  optional: [
103
- // Surface the Genie resource binding (space id) declared by
104
- // AppKit's `genie` plugin manifest. The Mastra plugin no
105
- // longer uses the genie plugin's tools at runtime - the
106
- // built-in Genie agent talks to Genie directly via
107
- // `@dbx-tools/genie` - but reusing the manifest keeps the
108
- // resource-binding shape identical to AppKit's so existing
109
- // `app.yaml` configs and `genie({ spaces })` wiring keep
110
- // working without change.
170
+ // Reuse the Genie space-id binding declared by AppKit's `genie`
171
+ // manifest so the resource-binding shape is identical to AppKit's and
172
+ // an existing `app.yaml` / `genie({ spaces })` wiring keeps working.
173
+ // The built-in Genie tools talk to Genie directly through
174
+ // `@dbx-tools/genie`, so only the binding is shared, not the plugin's
175
+ // tools.
111
176
  ...GENIE_MANIFEST.resources.required,
112
177
  ...LAKEBASE_MANIFEST.resources.required,
113
178
  ],
114
179
  },
115
- } satisfies PluginManifest<"mastra">;
180
+ config: { schema: MASTRA_CONFIG_SCHEMA },
181
+ };
116
182
 
117
183
  /**
118
184
  * Tighten resource requirements based on which features are enabled.
@@ -128,7 +194,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
128
194
  }
129
195
  for (const m of enabledManifests) {
130
196
  for (const resource of m.resources.required) {
131
- resources.push({ ...resource, required: true } as ResourceRequirement);
197
+ resources.push({ ...resource, required: true });
132
198
  }
133
199
  }
134
200
  return resources;
@@ -149,8 +215,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
149
215
  * Dedicated service-principal Lakebase pool backing Mastra memory /
150
216
  * storage. Built once in {@link buildAgentAndServer} (outside any
151
217
  * `asUser` scope, so it never inherits a request's OBO identity) and
152
- * drained in {@link abortActiveOperations}. `null` until setup runs
153
- * or when Lakebase isn't needed.
218
+ * drained in {@link shutdown}. `null` until setup runs or when
219
+ * Lakebase isn't needed.
154
220
  */
155
221
  private servicePrincipalPool: Pool | null = null;
156
222
 
@@ -159,7 +225,6 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
159
225
  // the lakebase pool is valid when storage/memory are enabled.
160
226
  this.context?.onLifecycle("setup:complete", async () => {
161
227
  this.applyLakebaseAutoDefaults();
162
- this.logger.info("setup:complete");
163
228
  await this.buildAgentAndServer();
164
229
  });
165
230
  }
@@ -179,25 +244,42 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
179
244
  }
180
245
 
181
246
  /**
182
- * Drain the memory service-principal pool on shutdown. AppKit calls
183
- * this during teardown; the lakebase plugin closes its own SP / OBO
184
- * pools the same way. Fire-and-forget so shutdown isn't blocked on a
185
- * slow drain, and clear the handle so a re-`setup()` rebuilds it.
247
+ * Drain the memory service-principal pool. Idempotent: the handle is
248
+ * cleared before the drain starts, so a second call is a no-op and a
249
+ * later `setup()` rebuilds the pool. Bounded by
250
+ * {@link POOL_DRAIN_TIMEOUT_MS} to stay well inside the 15s graceful
251
+ * shutdown budget.
186
252
  */
187
- override abortActiveOperations(): void {
188
- super.abortActiveOperations();
189
- if (this.servicePrincipalPool) {
190
- this.logger.info("closing memory SP pool");
191
- const pool = this.servicePrincipalPool;
192
- this.servicePrincipalPool = null;
193
- pool.end().catch((err) => {
194
- this.logger.error("error closing memory SP pool", {
195
- error: error.errorMessage(err),
196
- });
253
+ async shutdown(): Promise<void> {
254
+ const pool = this.servicePrincipalPool;
255
+ if (!pool) return;
256
+ this.servicePrincipalPool = null;
257
+ this.logger.info("closing memory SP pool");
258
+ // The budget timer is cancelled on the way out so a drain that finished
259
+ // early cannot hold the event loop open for the rest of the window.
260
+ const budget = new AbortController();
261
+ try {
262
+ await Promise.race([pool.end(), async.sleep(POOL_DRAIN_TIMEOUT_MS, budget.signal)]);
263
+ } catch (err) {
264
+ this.logger.error("error closing memory SP pool", {
265
+ error: error.errorMessage(err),
197
266
  });
267
+ } finally {
268
+ budget.abort();
198
269
  }
199
270
  }
200
271
 
272
+ /**
273
+ * Abort in-flight work. AppKit's graceful shutdown calls this hook
274
+ * synchronously and never awaits {@link shutdown}, so the pool drain is
275
+ * started here too; it cannot be awaited from a `void` hook, and
276
+ * {@link shutdown} is idempotent so the duplicate call is free.
277
+ */
278
+ override abortActiveOperations(): void {
279
+ super.abortActiveOperations();
280
+ void this.shutdown();
281
+ }
282
+
201
283
  override exports() {
202
284
  return {
203
285
  /**
@@ -223,12 +305,20 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
223
305
  getMastra: () => this.mastra,
224
306
  /**
225
307
  * MCP endpoint info when `config.mcp` is enabled, else `null`.
226
- * Paths are absolute (under the plugin mount), ready to hand to an
227
- * MCP client. Streamable HTTP is `http`; the SSE pair is the
228
- * legacy transport.
308
+ * Streamable HTTP is `http`; the SSE pair is the legacy transport.
309
+ *
310
+ * Each path is given twice: mount-relative (`httpPath`, `ssePath`,
311
+ * `messagePath`) for anything that already knows where the plugin is
312
+ * mounted, and absolute (`http`, `sse`, `messages`) for MCP clients,
313
+ * which take a single URL and cannot compose one. The absolute form is
314
+ * built from {@link basePath}, so it honors a `config.name` override but
315
+ * still assumes AppKit's default `/api/<name>` mount.
229
316
  */
230
317
  getMcp: (): {
231
318
  serverId: string;
319
+ httpPath: string;
320
+ ssePath: string;
321
+ messagePath: string;
232
322
  http: string;
233
323
  sse: string;
234
324
  messages: string;
@@ -236,9 +326,12 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
236
326
  this.mcp
237
327
  ? {
238
328
  serverId: this.mcp.serverId,
239
- http: `/api/${this.name}${this.mcp.httpPath}`,
240
- sse: `/api/${this.name}${this.mcp.ssePath}`,
241
- messages: `/api/${this.name}${this.mcp.messagePath}`,
329
+ httpPath: this.mcp.httpPath,
330
+ ssePath: this.mcp.ssePath,
331
+ messagePath: this.mcp.messagePath,
332
+ http: `${this.basePath}${this.mcp.httpPath}`,
333
+ sse: `${this.basePath}${this.mcp.ssePath}`,
334
+ messages: `${this.basePath}${this.mcp.messagePath}`,
242
335
  }
243
336
  : null,
244
337
  /** Express subapp Mastra is mounted on; mostly for tests. */
@@ -248,7 +341,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
248
341
  * payload the `GET /models` route returns; surfaced here so
249
342
  * other plugins / scripts can introspect the catalogue without
250
343
  * an HTTP round-trip. AppKit wraps this with `asUser(req)` for
251
- * OBO scoping automatically.
344
+ * OBO scoping automatically. Throws when the listing fails, since
345
+ * there is no status code to hand back on this surface.
252
346
  */
253
347
  listModels: (): Promise<ServingEndpointSummary[]> => this.listModels(),
254
348
  /**
@@ -262,18 +356,25 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
262
356
  };
263
357
  }
264
358
 
359
+ /**
360
+ * Absolute mount this plugin's routes answer on. AppKit mounts every plugin
361
+ * at `/api/<plugin.name>` and `this.name` honors a `config.name` override,
362
+ * so the per-route segments (`routes.MASTRA_ROUTES`) hang off this.
363
+ */
364
+ private get basePath(): string {
365
+ return `/api/${this.name}`;
366
+ }
367
+
265
368
  override clientConfig(): Record<string, unknown> {
266
- // AppKit mounts every plugin at `/api/<plugin.name>`. `this.name`
267
- // honors `config.name` overrides, so publishing `basePath` is
268
- // enough for the client to stay correct under a custom mount id -
269
- // the per-route segments are fixed (`routes.MASTRA_ROUTES`) and the
270
- // client (`MastraPluginClient`) derives every endpoint from
271
- // `basePath`.
369
+ // Publishing `basePath` is enough for the client to stay correct under a
370
+ // custom mount id: the per-route segments are fixed
371
+ // (`routes.MASTRA_ROUTES`) and the client (`MastraPluginClient`) derives
372
+ // every endpoint from `basePath`.
272
373
  // Return widens to `Record<string, unknown>` to satisfy the
273
374
  // base-class signature; consumers read it through the typed
274
375
  // `MastraClientConfig` shape via `usePluginClientConfig<...>(...)`.
275
376
  const config: MastraClientConfig = {
276
- basePath: `/api/${this.name}`,
377
+ basePath: this.basePath,
277
378
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
278
379
  agents: Object.keys(this.built?.agents ?? {}),
279
380
  feedbackEnabled: this.feedbackEnabled(),
@@ -299,6 +400,10 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
299
400
  // the catch-all and rewrites the alias to the underlying route, so
300
401
  // the public path is just `/api/<plugin>/mcp`; the query string
301
402
  // (e.g. the SSE `sessionId`) is preserved.
403
+ //
404
+ // Middleware rather than `this.route`: it rewrites the URL for routes
405
+ // `@mastra/express` owns further down the chain instead of answering the
406
+ // request, which `RouteConfig` has no shape for.
302
407
  router.use((req, _res, next) => {
303
408
  const target = this.mcpRouteAlias(req.path);
304
409
  if (target) {
@@ -311,13 +416,19 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
311
416
  // `GET /models` exposes the cached endpoint list so clients can
312
417
  // populate model pickers, validate `?model=` choices, etc. Must
313
418
  // be registered before the catch-all that forwards everything to
314
- // the Mastra subapp. Errors propagate to Express's default error
315
- // handler via `next(err)` so callers see the real SDK message.
316
- router.get(routes.MASTRA_ROUTES.models, (req, res, next) => {
317
- this.userScopedSelf(req)
318
- .listModels()
319
- .then((endpoints) => res.json({ endpoints }))
320
- .catch(next);
419
+ // the Mastra subapp.
420
+ this.route(router, {
421
+ name: "models",
422
+ method: "get",
423
+ path: routes.MASTRA_ROUTES.models,
424
+ handler: async (req, res) => {
425
+ const result = await this.asUser(req).listModelsResult();
426
+ if (!result.ok) {
427
+ this.sendFailure(res, result, "models", MODEL_CATALOGUE_FAILED_MESSAGE);
428
+ return;
429
+ }
430
+ res.json({ endpoints: result.data });
431
+ },
321
432
  });
322
433
 
323
434
  // `GET /default-model` (and `/default-model/:agentId`) reports the static
@@ -326,11 +437,19 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
326
437
  // name. Agent-scoped via the optional `/:agentId` suffix (URL symmetry
327
438
  // with the history / threads / suggestions routes), defaulting to the
328
439
  // default agent. `model` / `displayName` are null when the agent has no
329
- // static default (a dynamic, call-time model) or the agent id is unknown.
330
- // Reads only in-memory build state, so it's synchronous and needs no OBO
331
- // scoping. Registered before the catch-all, same as `/models`.
332
- const handleDefaultModel = (req: express.Request, res: express.Response): void => {
440
+ // static default (a dynamic, call-time model); an unknown agent id is a
441
+ // 404, matching the history / threads routes. Reads only in-memory build
442
+ // state, so it needs no OBO scoping. Registered before the catch-all,
443
+ // same as `/models`.
444
+ const handleDefaultModel = async (
445
+ req: express.Request,
446
+ res: express.Response,
447
+ ): Promise<void> => {
333
448
  const requested = string.firstNonEmpty(req.params.agentId);
449
+ if (requested !== null && !this.built?.agents[requested]) {
450
+ res.status(404).json({ error: this.unknownAgentMessage(requested) });
451
+ return;
452
+ }
334
453
  const agentId = requested ?? this.built?.defaultAgentId ?? FALLBACK_AGENT_ID;
335
454
  const raw = this.built?.defaultModels[agentId];
336
455
  // `"<dynamic>"` (a call-time function) has no fixed id to advertise.
@@ -343,8 +462,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
343
462
  displayName: model ? display.toModelDisplayName(model) : null,
344
463
  });
345
464
  };
346
- router.get(routes.MASTRA_ROUTES.defaultModel, handleDefaultModel);
347
- router.get(`${routes.MASTRA_ROUTES.defaultModel}/:agentId`, handleDefaultModel);
465
+ this.route(router, {
466
+ name: "defaultModel",
467
+ method: "get",
468
+ path: routes.MASTRA_ROUTES.defaultModel,
469
+ handler: handleDefaultModel,
470
+ });
471
+ this.route(router, {
472
+ name: "defaultModelByAgent",
473
+ method: "get",
474
+ path: `${routes.MASTRA_ROUTES.defaultModel}/:agentId`,
475
+ handler: handleDefaultModel,
476
+ });
348
477
 
349
478
  // `GET /embed/:type/:id` is the single resolver for every embed
350
479
  // marker the agent emits in prose (`[chart:<id>]`,
@@ -379,48 +508,55 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
379
508
  const embedResolvers: Record<string, EmbedResolver> = {
380
509
  chart: (req, id, signal) => {
381
510
  const timeoutMs = parseTimeoutMs(req.query.timeoutMs);
382
- return fetchChart(id, {
511
+ return this.asUser(req).fetchChartEntry(id, {
383
512
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
384
513
  signal,
385
514
  });
386
515
  },
387
516
  data: (req, id, signal) => {
388
517
  const limit = parseStatementLimit(req.query.limit);
389
- return this.userScopedSelf(req).fetchStatement(id, {
518
+ return this.asUser(req).fetchStatement(id, {
390
519
  ...(limit !== undefined ? { limit } : {}),
391
520
  signal,
392
521
  });
393
522
  },
394
523
  };
395
524
 
396
- router.get(`${routes.MASTRA_ROUTES.embed}/:type/:id`, (req, res, next) => {
397
- const type = req.params.type ?? "";
398
- const id = req.params.id;
399
- const resolve = embedResolvers[type];
400
- if (!resolve) {
401
- res.status(404).json({ error: `unsupported embed type: ${type}` });
402
- return;
403
- }
404
- if (!id) {
405
- res.status(400).json({ error: "id is required" });
406
- return;
407
- }
408
- // Express's `req` predates `AbortSignal`; bridge the `close`
409
- // event onto an `AbortController` so a closed connection
410
- // unblocks any long-poll immediately and frees the request
411
- // thread. The listener is GC'd with the request on normal
412
- // completion.
413
- const controller = new AbortController();
414
- req.on("close", () => controller.abort());
415
- resolve(req, id, controller.signal)
416
- .then((entry) => {
417
- if (entry === undefined) {
418
- res.status(404).json({ error: `${type} not found` });
419
- return;
420
- }
421
- res.json(entry);
422
- })
423
- .catch(next);
525
+ this.route(router, {
526
+ name: "embed",
527
+ method: "get",
528
+ path: `${routes.MASTRA_ROUTES.embed}/:type/:id`,
529
+ handler: async (req, res) => {
530
+ const type = string.firstNonEmpty(req.params.type) ?? "";
531
+ const id = string.firstNonEmpty(req.params.id);
532
+ const resolve = embedResolvers[type];
533
+ if (!resolve) {
534
+ res.status(404).json({ error: `unsupported embed type: ${type}` });
535
+ return;
536
+ }
537
+ if (!id) {
538
+ res.status(400).json({ error: "id is required" });
539
+ return;
540
+ }
541
+ // Express's `req` predates `AbortSignal`; bridge the `close`
542
+ // event onto an `AbortController` so a closed connection
543
+ // unblocks any long-poll immediately and frees the request
544
+ // thread. The listener is GC'd with the request on normal
545
+ // completion.
546
+ const controller = new AbortController();
547
+ req.on("close", () => controller.abort());
548
+ const result = await resolve(req, id, controller.signal);
549
+ if (controller.signal.aborted) return;
550
+ if (!result.ok) {
551
+ this.sendFailure(res, result, `embed:${type}`, `Could not resolve the ${type} embed`);
552
+ return;
553
+ }
554
+ if (result.data === undefined) {
555
+ res.status(404).json({ error: `${type} not found` });
556
+ return;
557
+ }
558
+ res.json(result.data);
559
+ },
424
560
  });
425
561
 
426
562
  // `GET /suggestions` (and `/suggestions/:agentId`) returns the
@@ -433,24 +569,39 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
433
569
  // routes; Genie spaces are resolved per-plugin, not per-agent,
434
570
  // so it doesn't change the result. OBO-scoped like the other
435
571
  // data routes so the space lookup runs as the calling user.
436
- const handleSuggestions = (req: express.Request, res: express.Response): void => {
572
+ const handleSuggestions = async (
573
+ req: express.Request,
574
+ res: express.Response,
575
+ ): Promise<void> => {
437
576
  const controller = new AbortController();
438
577
  req.on("close", () => controller.abort());
439
- this.userScopedSelf(req)
440
- .fetchSuggestions(controller.signal)
441
- .then((questions) => res.json({ questions }))
442
- .catch((err: unknown) => {
443
- // Suggestions are a non-critical enhancement; a lookup
444
- // failure should leave the chat usable with a bare empty
445
- // state rather than surfacing a 500. Log and degrade.
446
- this.logger.warn("suggestions:error", {
447
- error: error.errorMessage(err),
448
- });
449
- res.json({ questions: [] });
578
+ const result = await this.asUser(req).fetchSuggestions(controller.signal);
579
+ if (controller.signal.aborted) return;
580
+ if (!result.ok) {
581
+ // Suggestions are a non-critical enhancement; a lookup failure should
582
+ // leave the chat usable with a bare empty state rather than surfacing
583
+ // the upstream status. Log and degrade.
584
+ this.logger.warn("suggestions:error", {
585
+ status: result.status,
586
+ error: result.message,
450
587
  });
588
+ res.json({ questions: [] });
589
+ return;
590
+ }
591
+ res.json({ questions: result.data });
451
592
  };
452
- router.get(routes.MASTRA_ROUTES.suggestions, handleSuggestions);
453
- router.get(`${routes.MASTRA_ROUTES.suggestions}/:agentId`, handleSuggestions);
593
+ this.route(router, {
594
+ name: "suggestions",
595
+ method: "get",
596
+ path: routes.MASTRA_ROUTES.suggestions,
597
+ handler: handleSuggestions,
598
+ });
599
+ this.route(router, {
600
+ name: "suggestionsByAgent",
601
+ method: "get",
602
+ path: `${routes.MASTRA_ROUTES.suggestions}/:agentId`,
603
+ handler: handleSuggestions,
604
+ });
454
605
 
455
606
  // `POST /route/feedback` logs a thumbs / comment assessment against
456
607
  // a turn's MLflow trace (the `traceId` the client captured from the
@@ -462,27 +613,43 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
462
613
  // A recorded assessment yields `{ ok: true }`; a soft failure (most
463
614
  // often the trace hasn't finished exporting to MLflow yet) yields
464
615
  // `{ ok: false }` without a 5xx so the UI can prompt a retry.
465
- router.post(routes.MASTRA_ROUTES.feedback, (req, res, next) => {
466
- if (!this.feedbackEnabled()) {
467
- res.status(404).json({ ok: false });
468
- return;
469
- }
470
- const parsed = feedback.MastraFeedbackRequestSchema.safeParse(req.body);
471
- if (!parsed.success) {
472
- res.status(400).json({ ok: false, error: parsed.error.message });
473
- return;
474
- }
475
- this.userScopedSelf(req)
476
- .logFeedback(parsed.data)
477
- .then((assessmentId) =>
478
- res.json({
479
- ok: assessmentId !== undefined,
480
- ...(assessmentId ? { assessmentId } : {}),
481
- }),
482
- )
483
- .catch(next);
616
+ this.route(router, {
617
+ name: "feedback",
618
+ method: "post",
619
+ path: routes.MASTRA_ROUTES.feedback,
620
+ handler: async (req, res) => {
621
+ if (!this.feedbackEnabled()) {
622
+ res.status(404).json({ ok: false });
623
+ return;
624
+ }
625
+ const parsed = feedback.MastraFeedbackRequestSchema.safeParse(req.body);
626
+ if (!parsed.success) {
627
+ // A Zod issue string quotes the received body back, so only the
628
+ // offending field names travel; the full issue list is logged.
629
+ this.logger.warn("feedback:invalid", { error: parsed.error.message });
630
+ res.status(400).json({
631
+ ok: false,
632
+ error: INVALID_FEEDBACK_MESSAGE,
633
+ fields: invalidFields(parsed.error),
634
+ });
635
+ return;
636
+ }
637
+ const result = await this.asUser(req).logFeedback(parsed.data);
638
+ if (!result.ok) {
639
+ this.sendFailure(res, result, "feedback", "Feedback could not be recorded");
640
+ return;
641
+ }
642
+ const assessmentId = result.data;
643
+ res.json({
644
+ ok: assessmentId !== undefined,
645
+ ...(assessmentId ? { assessmentId } : {}),
646
+ });
647
+ },
484
648
  });
485
649
 
650
+ // Middleware rather than `this.route`: this is the catch-all that hands
651
+ // every remaining method / path pair to the Mastra sub-app, so it has no
652
+ // single method or path to register under.
486
653
  router.use((req, res, next) => {
487
654
  if (!this.mastraApp) return res.status(503).end();
488
655
  // Gate the stock Mastra surface before dispatch. In the default
@@ -512,10 +679,36 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
512
679
  // an OBO token makes `userScopedSelf` return the proxy. `dispatchMastra`
513
680
  // is a plain method (its `.bind` is the normal one) and invokes
514
681
  // `this.mastraApp` off the real target, keeping the OBO scope active.
515
- return this.userScopedSelf(req).dispatchMastra(req, res, next);
682
+ return this.asUser(req).dispatchMastra(req, res, next);
516
683
  });
517
684
  }
518
685
 
686
+ /**
687
+ * Map a failed {@link ExecutionResult} onto the response: the status the
688
+ * execution pipeline resolved, paired with `clientMessage` rather than the
689
+ * result's own text, which can carry upstream detail. The full message is
690
+ * logged under `<operation>:error`.
691
+ */
692
+ private sendFailure(
693
+ res: express.Response,
694
+ result: { status: number; message: string },
695
+ operation: string,
696
+ clientMessage: string,
697
+ ): void {
698
+ this.logger.warn(`${operation}:error`, {
699
+ status: result.status,
700
+ error: result.message,
701
+ });
702
+ if (res.headersSent) return;
703
+ res.status(result.status).json({ error: clientMessage });
704
+ }
705
+
706
+ /** 404 body for a request naming an agent that isn't registered. */
707
+ private unknownAgentMessage(agentId: string): string {
708
+ const registered = Object.keys(this.built?.agents ?? {});
709
+ return `Unknown agent "${agentId}". Registered agents: ${registered.join(", ") || "none"}`;
710
+ }
711
+
519
712
  /**
520
713
  * Invoke the Mastra express sub-app. Exists as a method (instead of reading
521
714
  * `this.mastraApp` through the `asUser(req)` proxy at the call site) so the
@@ -557,15 +750,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
557
750
  * Returns `[]` when no Genie space is configured so the client
558
751
  * shows a bare empty state instead of built-in example prompts.
559
752
  */
560
- private async fetchSuggestions(signal?: AbortSignal): Promise<string[]> {
753
+ private async fetchSuggestions(signal?: AbortSignal): Promise<ExecutionResult<string[]>> {
561
754
  const spaces = resolveGenieSpaces(this.config, this.context);
562
- if (Object.keys(spaces).length === 0) return [];
755
+ if (Object.keys(spaces).length === 0) return { ok: true, data: [] };
563
756
  const client = getExecutionContext().client;
564
- return collectSpaceSuggestions({
565
- spaces,
566
- client,
567
- ...(signal ? { signal } : {}),
568
- });
757
+ return this.execute((executeSignal) => {
758
+ const combined = async.combineAbortSignals(signal, executeSignal);
759
+ return collectSpaceSuggestions({
760
+ spaces,
761
+ client,
762
+ ...(combined ? { signal: combined } : {}),
763
+ });
764
+ }, genieSuggestionDefaults);
569
765
  }
570
766
 
571
767
  /**
@@ -576,7 +772,9 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
576
772
  * created assessment id on success, or `undefined` on a soft failure
577
773
  * (see {@link logFeedback} in `./mlflow.js`).
578
774
  */
579
- private async logFeedback(feedback: MastraFeedbackRequest): Promise<string | undefined> {
775
+ private async logFeedback(
776
+ feedback: MastraFeedbackRequest,
777
+ ): Promise<ExecutionResult<string | undefined>> {
580
778
  const ctx = getExecutionContext();
581
779
  const sourceId =
582
780
  "userEmail" in ctx && ctx.userEmail
@@ -584,10 +782,14 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
584
782
  : "userId" in ctx
585
783
  ? ctx.userId
586
784
  : ctx.serviceUserId;
587
- return logFeedback(ctx.client, {
588
- ...feedback,
589
- ...(sourceId ? { sourceId } : {}),
590
- });
785
+ return this.execute(
786
+ () =>
787
+ logFeedback(ctx.client, {
788
+ ...feedback,
789
+ ...(sourceId ? { sourceId } : {}),
790
+ }),
791
+ feedbackWriteDefaults,
792
+ );
591
793
  }
592
794
 
593
795
  /**
@@ -598,57 +800,101 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
598
800
  * the `get_statement` tool runs so the LLM and the UI see the
599
801
  * exact same shape for the same statement.
600
802
  *
601
- * Returns `undefined` for upstream 404s so the route can map
602
- * them to a clean HTTP 404; any other failure bubbles up.
803
+ * Resolves to `undefined` data for upstream 404s so the route can map them
804
+ * to a clean HTTP 404; any other failure comes back as a non-`ok` result
805
+ * carrying the status to answer with.
603
806
  */
604
807
  private async fetchStatement(
605
808
  statementId: string,
606
809
  options: { limit?: number; signal?: AbortSignal } = {},
607
- ): Promise<StatementData | undefined> {
810
+ ): Promise<ExecutionResult<StatementData | undefined>> {
608
811
  const client = getExecutionContext().client;
609
812
  const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
610
- try {
611
- const data = await fetchStatementData(client, statementId, {
612
- limit,
613
- ...(options.signal ? { signal: options.signal } : {}),
614
- });
615
- return {
616
- columns: data.columns,
617
- rows: data.rows,
618
- rowCount: data.rowCount,
619
- truncated: data.rows.length < data.rowCount,
620
- };
621
- } catch (err) {
622
- // The Databricks SDK throws on 404; surface as `undefined`
623
- // so the route maps to a clean HTTP 404 instead of a 500.
624
- if (error.errorContext(err).notAccessible) return undefined;
625
- throw err;
626
- }
813
+ return this.execute(
814
+ async (executeSignal) => {
815
+ const combined = async.combineAbortSignals(options.signal, executeSignal);
816
+ try {
817
+ const data = await fetchStatementData(client, statementId, {
818
+ limit,
819
+ ...(combined ? { signal: combined } : {}),
820
+ });
821
+ return {
822
+ columns: data.columns,
823
+ rows: data.rows,
824
+ rowCount: data.rowCount,
825
+ truncated: data.rows.length < data.rowCount,
826
+ };
827
+ } catch (err) {
828
+ // The Databricks SDK throws on 404; surface as `undefined`
829
+ // so the route maps to a clean HTTP 404 instead of a 500.
830
+ if (error.errorContext(err).notAccessible) return undefined;
831
+ throw err;
832
+ }
833
+ },
834
+ {
835
+ default: {
836
+ ...statementDataDefaults.default,
837
+ // Namespaced so the key can't collide with another cache sharing the
838
+ // manager, and keyed on `limit` because the slice is part of the
839
+ // payload.
840
+ cache: {
841
+ ...statementDataDefaults.default.cache,
842
+ cacheKey: [STATEMENT_CACHE_NAMESPACE, statementId, limit],
843
+ },
844
+ },
845
+ },
846
+ );
627
847
  }
628
848
 
629
849
  /**
630
- * Return `this.asUser(req)` when the request carries an OBO token,
631
- * otherwise return `this` directly. Prevents the noisy AppKit warn
632
- * (`asUser() called without user token in development mode. Skipping
633
- * user impersonation.`) on every request in local dev where the
634
- * browser never sends `x-forwarded-access-token`. Behavior is
635
- * unchanged in production: a missing token always means a real OBO
636
- * proxy call (and AppKit will throw upstream if that's wrong).
850
+ * Implementation backing the `chart` embed resolver
851
+ * (`GET /embed/chart/:id`). Runs inside the AppKit user-context proxy so the
852
+ * lookup is namespaced to the calling identity: a chart minted for another
853
+ * user is indistinguishable from an unknown id, and the route answers 404.
637
854
  */
638
- private userScopedSelf(req: express.Request): this {
639
- return req.header("x-forwarded-access-token") ? (this.asUser(req) as this) : this;
855
+ private async fetchChartEntry(
856
+ chartId: string,
857
+ options: { timeoutMs?: number; signal: AbortSignal },
858
+ ): Promise<ExecutionResult<Chart | undefined>> {
859
+ const userKey = resolveUserKey();
860
+ return this.execute(
861
+ (executeSignal) =>
862
+ fetchChart(chartId, {
863
+ userKey,
864
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
865
+ signal: async.combineAbortSignals(options.signal, executeSignal) ?? options.signal,
866
+ }),
867
+ chartFetchDefaults,
868
+ );
640
869
  }
641
870
 
642
871
  /**
643
- * Implementation backing both the `/models` route and the
644
- * `listModels` export. Runs inside the AppKit user-context proxy so
645
- * `getExecutionContext()` returns the OBO-scoped client.
872
+ * Implementation backing the `/models` route. Runs inside the AppKit
873
+ * user-context proxy so `getExecutionContext()` returns the OBO-scoped
874
+ * client and the catalogue reflects what the caller can invoke.
875
+ */
876
+ private async listModelsResult(): Promise<ExecutionResult<ServingEndpointSummary[]>> {
877
+ return this.execute(async () => {
878
+ const client = getExecutionContext().client;
879
+ const host = (await client.config.getHost()).toString();
880
+ const serving = resolveServingConfig(this.config);
881
+ return nodeServing.listServingEndpoints(client, host, { ttlMs: serving.ttlMs });
882
+ }, modelCatalogueDefaults);
883
+ }
884
+
885
+ /**
886
+ * Implementation backing the `listModels` export. The programmatic surface
887
+ * carries no status code, so a failed execution becomes a throw with a
888
+ * stable message; the routes branch on the {@link ExecutionResult} instead.
646
889
  */
647
890
  private async listModels(): Promise<ServingEndpointSummary[]> {
648
- const client = getExecutionContext().client;
649
- const host = (await client.config.getHost()).toString();
650
- const serving = resolveServingConfig(this.config);
651
- return nodeServing.listServingEndpoints(client, host, { ttlMs: serving.ttlMs });
891
+ const result = await this.listModelsResult();
892
+ if (result.ok) return result.data;
893
+ this.logger.warn("listModels:error", {
894
+ status: result.status,
895
+ error: result.message,
896
+ });
897
+ throw new ExecutionError(MODEL_CATALOGUE_FAILED_MESSAGE);
652
898
  }
653
899
 
654
900
  private async buildAgentAndServer(): Promise<void> {
@@ -673,11 +919,6 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
673
919
  ? createMemoryBuilder(this.config, this.servicePrincipalPool)
674
920
  : undefined;
675
921
 
676
- this.logger.debug("build:start", {
677
- lakebase: memoryBuilder !== undefined,
678
- stripStaleCharts: this.config.stripStaleCharts !== false,
679
- });
680
-
681
922
  // Build every agent declared in `config.agents` (or the built-in
682
923
  // fallback when none are declared). Each agent's `model` resolves
683
924
  // workspace URL + bearer at call time so concurrent requests get
@@ -767,21 +1008,14 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
767
1008
  ],
768
1009
  });
769
1010
  await this.mastraServer.init();
770
- this.logger.debug("build:done", {
1011
+ this.logger.info("ready", {
771
1012
  agents: Object.keys(this.built.agents),
772
1013
  defaultAgent: this.built.defaultAgentId,
773
- routes: [
774
- "/route/history",
775
- "/route/threads",
776
- "/models",
777
- "/default-model",
778
- "/suggestions",
779
- "/route/feedback",
780
- "/embed/:type/:id",
781
- ],
782
- instanceStorage: instanceStorage !== undefined,
1014
+ apiAccess: this.config.apiAccess ?? "scoped",
1015
+ mcp: this.mcp ? `${this.basePath}${this.mcp.httpPath}` : "off",
1016
+ lakebase: memoryBuilder !== undefined,
1017
+ feedback: this.feedbackEnabled(),
783
1018
  observability: observability !== undefined ? "mlflow" : "off",
784
- mcp: this.mcp ? `/api/${this.name}${this.mcp.httpPath}` : "off",
785
1019
  });
786
1020
  }
787
1021
  }
@@ -797,13 +1031,13 @@ type EmbedResolver = (
797
1031
  req: express.Request,
798
1032
  id: string,
799
1033
  signal: AbortSignal,
800
- ) => Promise<unknown | undefined>;
1034
+ ) => Promise<ExecutionResult<unknown>>;
801
1035
 
802
1036
  /**
803
1037
  * Parse the optional `?timeoutMs=<n>` query parameter from a
804
1038
  * `GET /embed/chart/:id` request. Accepts a positive integer up
805
- * to 5 minutes (clamped) and rejects everything else as
806
- * `undefined` so {@link fetchChart} falls back to its default.
1039
+ * to {@link MAX_EMBED_POLL_TIMEOUT_MS} (clamped) and rejects everything else
1040
+ * as `undefined` so {@link fetchChart} falls back to its default.
807
1041
  * Express produces `string | string[] | undefined`; we normalize
808
1042
  * to the first scalar before parsing.
809
1043
  */
@@ -812,7 +1046,7 @@ function parseTimeoutMs(raw: unknown): number | undefined {
812
1046
  if (typeof v !== "string") return undefined;
813
1047
  const n = Number(v);
814
1048
  if (!Number.isFinite(n) || n <= 0) return undefined;
815
- return Math.min(Math.floor(n), 5 * 60_000);
1049
+ return Math.min(Math.floor(n), MAX_EMBED_POLL_TIMEOUT_MS);
816
1050
  }
817
1051
 
818
1052
  /**
@@ -830,4 +1064,45 @@ function parseStatementLimit(raw: unknown): number | undefined {
830
1064
  return Math.floor(n);
831
1065
  }
832
1066
 
1067
+ /**
1068
+ * Register the Mastra plugin on an AppKit app. Mounts the agents, the
1069
+ * `/route/*` chat surface, and (unless disabled) the MCP transport under
1070
+ * `/api/mastra`.
1071
+ *
1072
+ * @example Minimal app
1073
+ * ```ts
1074
+ * import { createApp } from "@databricks/appkit";
1075
+ * import { mastra } from "@dbx-tools/appkit-mastra";
1076
+ *
1077
+ * // No `agents`: a single built-in `default` analyst is registered, so the
1078
+ * // chat endpoint works out of the box.
1079
+ * const app = await createApp({ plugins: [mastra()] });
1080
+ * ```
1081
+ *
1082
+ * @example Two agents, a pinned model, and Genie
1083
+ * ```ts
1084
+ * import { createApp, lakebase } from "@databricks/appkit";
1085
+ * import { createAgent, mastra } from "@dbx-tools/appkit-mastra";
1086
+ *
1087
+ * const app = await createApp({
1088
+ * plugins: [
1089
+ * lakebase(),
1090
+ * mastra({
1091
+ * defaultModel: "databricks-claude-sonnet-4-6",
1092
+ * defaultAgent: "analyst",
1093
+ * genieSpaces: { sales: { spaceId: "01ef...", hint: "orders, returns" } },
1094
+ * agents: {
1095
+ * analyst: createAgent({
1096
+ * instructions: "You answer questions about sales.",
1097
+ * tools(plugins) {
1098
+ * return { ...plugins.genie?.toolkit() };
1099
+ * },
1100
+ * }),
1101
+ * helper: createAgent({ instructions: "You explain the analyst's answers." }),
1102
+ * },
1103
+ * }),
1104
+ * ],
1105
+ * });
1106
+ * ```
1107
+ */
833
1108
  export const mastra = toPlugin(MastraPlugin);