@dbx-tools/appkit-mastra 0.9.58 → 0.9.60

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/mlflow.ts CHANGED
@@ -21,9 +21,11 @@
21
21
  * @module
22
22
  */
23
23
 
24
+ import { ConfigurationError } from "@databricks/appkit";
24
25
  import { appkit } from "@dbx-tools/appkit";
25
26
  import { asyncUtils, errorUtils, log } from "@dbx-tools/shared-core";
26
27
  import { feedback } from "@dbx-tools/shared-mastra";
28
+ import { z } from "zod";
27
29
  import { databricksFetch, readResponseJson, readResponseText } from "./rest.ts";
28
30
 
29
31
  const logger = log.logger("mastra/mlflow");
@@ -40,6 +42,78 @@ const NOT_FOUND_RETRIES = 3;
40
42
  /** Base backoff between "trace not found" retries, in ms (grows linearly). */
41
43
  const NOT_FOUND_BACKOFF_MS = 1200;
42
44
 
45
+ const MlflowExperimentResponseSchema = z
46
+ .object({
47
+ experiment: z
48
+ .object({
49
+ experiment_id: z.string().min(1).describe("Workspace MLflow experiment identifier."),
50
+ })
51
+ .passthrough()
52
+ .describe("Configured MLflow experiment."),
53
+ })
54
+ .passthrough()
55
+ .describe("MLflow get-experiment response.");
56
+
57
+ const CurrentUserResponseSchema = z
58
+ .object({
59
+ userName: z.string().optional().describe("Current workspace user name."),
60
+ applicationId: z.string().optional().describe("Current service principal application id."),
61
+ displayName: z.string().optional().describe("Current principal display name."),
62
+ groups: z
63
+ .array(
64
+ z
65
+ .object({
66
+ value: z.string().optional().describe("Workspace group identifier."),
67
+ display: z.string().optional().describe("Workspace group display name."),
68
+ })
69
+ .passthrough(),
70
+ )
71
+ .optional()
72
+ .describe("Groups assigned to the current principal."),
73
+ })
74
+ .passthrough()
75
+ .describe("Databricks Current User API response.");
76
+
77
+ const ExperimentPermissionsResponseSchema = z
78
+ .object({
79
+ access_control_list: z
80
+ .array(
81
+ z
82
+ .object({
83
+ user_name: z.string().optional().describe("User principal name."),
84
+ group_name: z.string().optional().describe("Group principal name."),
85
+ service_principal_name: z
86
+ .string()
87
+ .optional()
88
+ .describe("Service principal application id."),
89
+ all_permissions: z
90
+ .array(
91
+ z
92
+ .object({
93
+ permission_level: z.string().describe("Effective experiment permission level."),
94
+ })
95
+ .passthrough(),
96
+ )
97
+ .optional()
98
+ .describe("Direct and inherited permissions for this principal."),
99
+ })
100
+ .passthrough(),
101
+ )
102
+ .optional()
103
+ .describe("Experiment access control entries."),
104
+ })
105
+ .passthrough()
106
+ .describe("Databricks experiment permissions response.");
107
+
108
+ function configuredExperiment(): { id?: string; name?: string } {
109
+ const id = process.env.MLFLOW_EXPERIMENT_ID?.trim();
110
+ const name = process.env.MLFLOW_EXPERIMENT_NAME?.trim();
111
+ return {
112
+ ...(id ? { id } : {}),
113
+ ...(name ? { name } : {}),
114
+ };
115
+ }
116
+
43
117
  /**
44
118
  * Whether MLflow feedback logging is available for this deployment.
45
119
  *
@@ -71,6 +145,101 @@ export function resolveFeedbackEnabled(explicit: boolean | undefined): boolean {
71
145
  return explicit ?? mlflowEnabled();
72
146
  }
73
147
 
148
+ /**
149
+ * Reject an explicitly enabled feedback surface when no MLflow experiment is
150
+ * configured. Auto mode remains non-fatal and simply disables feedback.
151
+ */
152
+ export function validateFeedbackConfig(explicit: boolean | undefined): void {
153
+ if (explicit !== true) return;
154
+ const experiment = configuredExperiment();
155
+ if (experiment.id || experiment.name) return;
156
+ throw new ConfigurationError(
157
+ "mastra: feedback is enabled but no MLflow experiment is configured. " +
158
+ "Set MLFLOW_EXPERIMENT_ID or MLFLOW_EXPERIMENT_NAME, use feedback: false, " +
159
+ "or omit feedback to use automatic detection.",
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Return the configured experiment URL only when the active Databricks
165
+ * principal has effective `CAN_MANAGE` permission. Permission lookup is
166
+ * intentionally best-effort so Apps environments that do not expose the
167
+ * required APIs simply omit the debug affordance.
168
+ */
169
+ export async function mlflowExperimentManagerUrl(
170
+ client: WorkspaceClient,
171
+ ): Promise<string | undefined> {
172
+ try {
173
+ return await resolveMlflowExperimentManagerUrl(client);
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ async function resolveMlflowExperimentManagerUrl(
180
+ client: WorkspaceClient,
181
+ ): Promise<string | undefined> {
182
+ const experimentId = await resolveExperimentId(client);
183
+ if (!experimentId) return undefined;
184
+
185
+ const [currentResponse, permissionsResponse] = await Promise.all([
186
+ databricksFetch(client, "/api/2.0/preview/scim/v2/Me", { method: "GET" }),
187
+ databricksFetch(
188
+ client,
189
+ `/api/2.0/permissions/experiments/${encodeURIComponent(experimentId)}`,
190
+ { method: "GET" },
191
+ ),
192
+ ]);
193
+ if (!currentResponse.ok || !permissionsResponse.ok) return undefined;
194
+
195
+ const current = CurrentUserResponseSchema.safeParse(await readResponseJson(currentResponse));
196
+ const permissions = ExperimentPermissionsResponseSchema.safeParse(
197
+ await readResponseJson(permissionsResponse),
198
+ );
199
+ if (!current.success || !permissions.success) return undefined;
200
+
201
+ const userNames = new Set(
202
+ [current.data.userName, current.data.applicationId, current.data.displayName].filter(
203
+ (value): value is string => Boolean(value),
204
+ ),
205
+ );
206
+ const groupNames = new Set(
207
+ (current.data.groups ?? []).flatMap(({ value, display }) =>
208
+ [value, display].filter((entry): entry is string => Boolean(entry)),
209
+ ),
210
+ );
211
+ const canManage = (permissions.data.access_control_list ?? []).some((entry) => {
212
+ const matches =
213
+ (entry.user_name !== undefined && userNames.has(entry.user_name)) ||
214
+ (entry.service_principal_name !== undefined && userNames.has(entry.service_principal_name)) ||
215
+ (entry.group_name !== undefined && groupNames.has(entry.group_name));
216
+ return (
217
+ matches &&
218
+ (entry.all_permissions ?? []).some(
219
+ (permission) => permission.permission_level === "CAN_MANAGE",
220
+ )
221
+ );
222
+ });
223
+ if (!canManage) return undefined;
224
+
225
+ const host = (await client.config.getHost()).toString();
226
+ return new URL(`/ml/experiments/${encodeURIComponent(experimentId)}`, host).toString();
227
+ }
228
+
229
+ async function resolveExperimentId(client: WorkspaceClient): Promise<string | undefined> {
230
+ const experiment = configuredExperiment();
231
+ if (experiment.id) return experiment.id;
232
+ if (!experiment.name) return undefined;
233
+
234
+ const path = `/api/2.0/mlflow/experiments/get-by-name?experiment_name=${encodeURIComponent(
235
+ experiment.name,
236
+ )}`;
237
+ const response = await databricksFetch(client, path, { method: "GET" });
238
+ if (!response.ok) return undefined;
239
+ const parsed = MlflowExperimentResponseSchema.safeParse(await readResponseJson(response));
240
+ return parsed.success ? parsed.data.experiment.experiment_id : undefined;
241
+ }
242
+
74
243
  /** Parameters for {@link logFeedback}. */
75
244
  export interface LogFeedbackParams {
76
245
  /** MLflow trace id the assessment attaches to (`tr-<hex>`). */
package/src/plugin.ts CHANGED
@@ -99,7 +99,12 @@ import {
99
99
  } from "./identity.ts";
100
100
  import { buildMcpServer, type ResolvedMcp } from "./mcp.ts";
101
101
  import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory.ts";
102
- import { logFeedback, resolveFeedbackEnabled } from "./mlflow.ts";
102
+ import {
103
+ logFeedback,
104
+ mlflowExperimentManagerUrl,
105
+ resolveFeedbackEnabled,
106
+ validateFeedbackConfig,
107
+ } from "./mlflow.ts";
103
108
  import { resolveDefaultModelId } from "./model.ts";
104
109
  import { buildObservability, configureOtelPropagation } from "./observability.ts";
105
110
  import { provisionRemoteSkills } from "./remote-skills.ts";
@@ -249,6 +254,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
249
254
  // Resolve the identity mode up front so an invalid `genieIdentity` fails the
250
255
  // app boot with a clear message rather than 500-ing the first chat request.
251
256
  this.identityMode = resolveIdentityMode(this.config.genieIdentity);
257
+ validateFeedbackConfig(this.config.feedback);
252
258
  // Wait until sibling plugins (e.g. `lakebase`) finish `setup()` so
253
259
  // the lakebase pool is valid when storage/memory are enabled.
254
260
  this.context?.onLifecycle("setup:complete", async () => {
@@ -466,6 +472,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
466
472
  this.registerEmbedRoutes(router);
467
473
  this.registerSuggestionRoutes(router);
468
474
  this.registerFeedbackRoutes(router);
475
+ this.registerMlflowExperimentRoute(router);
469
476
  this.registerAgentRoutes(router);
470
477
  }
471
478
 
@@ -770,6 +777,23 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
770
777
  });
771
778
  }
772
779
 
780
+ /** Register the best-effort per-viewer MLflow experiment debug link. */
781
+ private registerMlflowExperimentRoute(router: IAppRouter): void {
782
+ this.route(router, {
783
+ name: "mlflowExperiment",
784
+ method: "get",
785
+ path: routes.MASTRA_ROUTES.mlflowExperiment,
786
+ handler: async (req, res) => {
787
+ if (!this.feedbackEnabled()) {
788
+ res.json({ url: null });
789
+ return;
790
+ }
791
+ const result = await this.scopedSelf(req).fetchMlflowExperimentUrl();
792
+ res.json({ url: result.ok ? (result.data ?? null) : null });
793
+ },
794
+ });
795
+ }
796
+
773
797
  /** Register the gated Mastra catch-all after every explicit route. */
774
798
  private registerAgentRoutes(router: IAppRouter): void {
775
799
  // Middleware rather than `this.route`: this is the catch-all that hands
@@ -917,6 +941,15 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
917
941
  );
918
942
  }
919
943
 
944
+ /**
945
+ * Resolve an experiment link under the active request identity. The helper
946
+ * returns no URL unless the viewer has effective `CAN_MANAGE` permission.
947
+ */
948
+ private async fetchMlflowExperimentUrl(): Promise<ExecutionResult<string | undefined>> {
949
+ const client = getExecutionContext().client;
950
+ return this.execute(() => mlflowExperimentManagerUrl(client), feedbackWriteDefaults);
951
+ }
952
+
920
953
  /**
921
954
  * Implementation backing the `data` embed resolver
922
955
  * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
@@ -567,7 +567,7 @@ function resolveDatabricksBasePath(
567
567
  client: WorkspaceClientLike | undefined,
568
568
  ): string | undefined {
569
569
  if (!client) return undefined;
570
- if (options.databricksBasePath) return options.databricksBasePath.trim() || undefined;
570
+ if (options.databricksBasePath) return stringUtils.trimToUndefined(options.databricksBasePath);
571
571
  const email = stringUtils.trimToNull(options.userEmail);
572
572
  // A named user targets their personal Assistant tree (the "save a skill"
573
573
  // target); otherwise the shared workspace Assistant tree, which the built-in
package/src/validation.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * @module
11
11
  */
12
+ import { object } from "@dbx-tools/shared-core";
12
13
 
13
14
  /** The subset of a failed parse result these helpers read. */
14
15
  export interface SchemaIssues {
@@ -17,6 +18,11 @@ export interface SchemaIssues {
17
18
 
18
19
  /** Distinct dot-joined field paths a failed parse flagged. */
19
20
  export function invalidFields(error: SchemaIssues): string[] {
20
- const fields = error.issues.map((issue) => issue.path.map(String).join(".")).filter(Boolean);
21
- return [...new Set(fields)];
21
+ return [
22
+ ...object
23
+ .sequence(error.issues)
24
+ .map((issue) => issue.path.map(String).join("."))
25
+ .filter(Boolean)
26
+ .distinct(),
27
+ ];
22
28
  }