@dbx-tools/appkit-mastra 0.3.28 → 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/rest.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { appkit } from "@dbx-tools/appkit";
2
1
  /**
3
2
  * Minimal authenticated Databricks REST helper. Pulls the workspace
4
3
  * host and a fresh bearer header off an OBO-scoped `WorkspaceClient`
@@ -7,8 +6,13 @@ import { appkit } from "@dbx-tools/appkit";
7
6
  * method (e.g. the MLflow assessments API); returns the raw `Response`
8
7
  * so callers decide how to treat status codes. Also carries the
9
8
  * defensive `Response` body readers those callers share.
9
+ *
10
+ * @module
10
11
  */
11
12
 
13
+ import { appkit } from "@dbx-tools/appkit";
14
+ import { json } from "@dbx-tools/shared-core";
15
+
12
16
  /** Workspace client carried on an AppKit execution context. */
13
17
  type WorkspaceClient = appkit.WorkspaceClientLike;
14
18
 
@@ -58,10 +62,5 @@ export async function readResponseText(res: Response): Promise<string> {
58
62
  /** Parse a response body as JSON, returning `{}` on empty / invalid bodies. */
59
63
  export async function readResponseJson(res: Response): Promise<unknown> {
60
64
  const text = await readResponseText(res);
61
- if (!text) return {};
62
- try {
63
- return JSON.parse(text);
64
- } catch {
65
- return {};
66
- }
65
+ return json.parse<unknown>(text, {});
67
66
  }
package/src/server.ts CHANGED
@@ -7,9 +7,8 @@
7
7
  * @module
8
8
  */
9
9
 
10
- import { randomUUID } from "node:crypto";
11
10
  import { getExecutionContext } from "@databricks/appkit";
12
- import { http, log, object, string, token } from "@dbx-tools/shared-core";
11
+ import { hash, http, log, object, string, token } from "@dbx-tools/shared-core";
13
12
  import { feedback, thread } from "@dbx-tools/shared-mastra";
14
13
  import {
15
14
  MASTRA_RESOURCE_ID_KEY,
@@ -21,6 +20,7 @@ import { trace } from "@opentelemetry/api";
21
20
  import type express from "express";
22
21
 
23
22
  import {
23
+ executionContextUserId,
24
24
  MASTRA_REQUEST_ID_KEY,
25
25
  MASTRA_SCOPES_KEY,
26
26
  MASTRA_USER_EMAIL_KEY,
@@ -95,7 +95,7 @@ export class MastraServer extends MastraServerExpress {
95
95
  if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
96
96
  const executionContext = getExecutionContext();
97
97
  const user: User = {
98
- id: "userId" in executionContext ? executionContext.userId : executionContext.serviceUserId,
98
+ id: executionContextUserId(executionContext),
99
99
  executionContext,
100
100
  };
101
101
  requestContext.set(MASTRA_USER_KEY, user);
@@ -142,7 +142,7 @@ export class MastraServer extends MastraServerExpress {
142
142
  if (requestContext.get(MASTRA_REQUEST_ID_KEY)) return;
143
143
  const headerValue = req.headers["x-request-id"];
144
144
  const upstream = Array.isArray(headerValue) ? headerValue[0] : headerValue;
145
- const requestId = upstream?.trim() || randomUUID();
145
+ const requestId = upstream?.trim() || hash.id();
146
146
  requestContext.set(MASTRA_REQUEST_ID_KEY, requestId);
147
147
  res.setHeader("X-Request-Id", requestId);
148
148
  }
@@ -218,7 +218,7 @@ export class MastraServer extends MastraServerExpress {
218
218
  );
219
219
  let sessionId = cookies[cookieName];
220
220
  if (!sessionId) {
221
- sessionId = randomUUID();
221
+ sessionId = hash.id();
222
222
  res.cookie(cookieName, sessionId, {
223
223
  httpOnly: true,
224
224
  sameSite: "lax",
@@ -1,11 +1,26 @@
1
- import { string } from "@dbx-tools/shared-core";
2
- import { type ChatMessage, type ChatRole, openaiChat } from "@dbx-tools/shared-model";
3
1
  /**
4
2
  * Repairs Mastra / AI SDK message replays sent to Databricks Model
5
3
  * Serving before they hit the OpenAI-compatible `/chat/completions`
6
4
  * route.
5
+ *
6
+ * Exists because the transcript Mastra persists is not always a transcript the
7
+ * provider will accept back: Databricks-hosted Claude rejects replayed
8
+ * extended-thinking blocks and reads a trailing assistant message as a prefill
9
+ * request. Both repairs are provider quirks, not schema violations, so they are
10
+ * applied on the wire (see the `globalThis.fetch` wrapper in `model.ts`) rather
11
+ * than by changing what the agent stores or what the UI shows.
12
+ *
13
+ * @module
7
14
  */
8
15
 
16
+ import { json, string } from "@dbx-tools/shared-core";
17
+ import {
18
+ type ChatMessage,
19
+ type ChatRole,
20
+ openaiChat,
21
+ openaiResponses,
22
+ } from "@dbx-tools/shared-model";
23
+
9
24
  /**
10
25
  * A chat message as it arrives on the serving wire, plus the extended-thinking
11
26
  * fields Databricks-hosted Claude adds. The OpenAI-standard part of the shape
@@ -19,7 +34,9 @@ export interface ServingChatMessage extends ChatMessage {
19
34
  reasoning_content?: unknown;
20
35
  }
21
36
 
22
- const REASONING_PART_TYPES = new Set(["reasoning", "thinking", "redacted_thinking"]);
37
+ // Shared with the Responses sanitize path so both wire surfaces agree on what
38
+ // counts as a signed reasoning block.
39
+ const REASONING_PART_TYPES = openaiResponses.REASONING_TYPES;
23
40
 
24
41
  /**
25
42
  * Parse, sanitize, and re-serialize a `/serving-endpoints/...` POST
@@ -27,12 +44,8 @@ const REASONING_PART_TYPES = new Set(["reasoning", "thinking", "redacted_thinkin
27
44
  * JSON or no rewrite was needed.
28
45
  */
29
46
  export function rewriteServingBody(body: string): string {
30
- let parsed: Record<string, unknown>;
31
- try {
32
- parsed = JSON.parse(body) as Record<string, unknown>;
33
- } catch {
34
- return body;
35
- }
47
+ const parsed = json.parseRecord(body);
48
+ if (!parsed) return body;
36
49
 
37
50
  // Runs regardless of `messages`: Databricks refuses to parse a body carrying
38
51
  // an unknown top-level field, so this failure is not specific to a transcript.
@@ -1,12 +1,15 @@
1
- import { string } from "@dbx-tools/shared-core";
2
1
  /**
3
2
  * Derive a Postgres-safe schema name for per-agent Mastra storage.
4
3
  *
5
4
  * Agent ids are often kebab-case route segments (`data-mesh-book-assistant`)
6
5
  * but {@link PostgresStore} validates `schemaName` with Mastra's
7
6
  * `parseSqlIdentifier` (letter/underscore start, `[A-Za-z0-9_]` only, max 63).
7
+ *
8
+ * @module
8
9
  */
9
10
 
11
+ import { string } from "@dbx-tools/shared-core";
12
+
10
13
  const SCHEMA_PREFIX = "mastra_";
11
14
  const MAX_PG_IDENTIFIER_LEN = 63;
12
15
 
package/src/threads.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * @module
22
22
  */
23
23
 
24
+ import { ValidationError } from "@databricks/appkit";
24
25
  import { log } from "@dbx-tools/shared-core";
25
26
  import {
26
27
  wire,
@@ -36,6 +37,7 @@ import type { ContextWithMastra } from "@mastra/core/server";
36
37
  import { registerApiRoute } from "@mastra/core/server";
37
38
 
38
39
  import { clampPerPage, parseIntParam } from "./pagination";
40
+ import { invalidFields } from "./validation";
39
41
 
40
42
  const logger = log.logger("mastra/threads");
41
43
 
@@ -43,6 +45,8 @@ const logger = log.logger("mastra/threads");
43
45
  const DEFAULT_PER_PAGE = 30;
44
46
  /** Hard cap so a misbehaving client can't fetch every thread at once. */
45
47
  const MAX_PER_PAGE = 200;
48
+ /** Stable client message for a rename body that fails schema validation. */
49
+ const INVALID_RENAME_MESSAGE = "Invalid thread rename request";
46
50
 
47
51
  /** Inputs accepted by {@link listThreads}. */
48
52
  export interface ListThreadsOptions {
@@ -220,8 +224,10 @@ export function threadsRoute(options: ThreadsRouteOptions) {
220
224
  const { path } = options;
221
225
  const fixedAgent = "agent" in options ? options.agent : undefined;
222
226
  if (!fixedAgent && !path.includes(":agentId")) {
223
- throw new Error(
224
- "threadsRoute path must include `:agentId` or `agent` must be passed explicitly",
227
+ throw ValidationError.invalidValue(
228
+ "threadsRoute.path",
229
+ path,
230
+ "a path containing `:agentId`, or an explicit `agent`",
225
231
  );
226
232
  }
227
233
  // Shared by GET / DELETE: resolve the active agent and the caller's
@@ -298,7 +304,8 @@ export function threadsRoute(options: ThreadsRouteOptions) {
298
304
  }
299
305
  const body = wire.MastraUpdateThreadRequestSchema.safeParse(await c.req.json());
300
306
  if (!body.success) {
301
- return c.json({ error: body.error.message }, 400);
307
+ logger.warn("rename:invalid", { error: body.error.message });
308
+ return c.json({ error: INVALID_RENAME_MESSAGE, fields: invalidFields(body.error) }, 400);
302
309
  }
303
310
  const thread = await renameThread({
304
311
  agent: ctx.agent,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Request-body validation helpers shared by every route that parses a JSON
3
+ * body with a schema from `@dbx-tools/shared-mastra`.
4
+ *
5
+ * A schema library's own issue text quotes the received body back, so it is
6
+ * logged rather than returned. What a client gets instead is a stable message
7
+ * plus the field paths that failed, which is enough to highlight the offending
8
+ * inputs.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** The subset of a failed parse result these helpers read. */
14
+ export interface SchemaIssues {
15
+ issues: ReadonlyArray<{ path: PropertyKey[] }>;
16
+ }
17
+
18
+ /** Distinct dot-joined field paths a failed parse flagged. */
19
+ 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)];
22
+ }
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ConfigurationError } from "@databricks/appkit";
4
+
5
+ import { MASTRA_CONFIG_SCHEMA } from "../src/config";
6
+ import { normalizeGenieSpaces } from "../src/genie";
7
+ import { invalidFields } from "../src/validation";
8
+
9
+ describe("mastra config schema", () => {
10
+ it("describes every published property", () => {
11
+ const properties = Object.entries(MASTRA_CONFIG_SCHEMA.properties ?? {});
12
+ assert.ok(properties.length > 0);
13
+ for (const [name, schema] of properties) {
14
+ assert.equal(typeof schema, "object", `${name} must be a schema object`);
15
+ const description = (schema as { description?: unknown }).description;
16
+ assert.equal(typeof description, "string", `${name} must carry a description`);
17
+ assert.ok((description as string).length > 0, `${name} description must be non-empty`);
18
+ }
19
+ });
20
+ });
21
+
22
+ describe("genie space normalization", () => {
23
+ it("wraps bare space ids and passes objects through", () => {
24
+ assert.deepEqual(normalizeGenieSpaces({ default: "01ef", sales: { spaceId: "02ef" } }), {
25
+ default: { spaceId: "01ef" },
26
+ sales: { spaceId: "02ef" },
27
+ });
28
+ });
29
+
30
+ it("treats no config as no spaces", () => {
31
+ assert.deepEqual(normalizeGenieSpaces(undefined), {});
32
+ assert.deepEqual(normalizeGenieSpaces({}), {});
33
+ });
34
+
35
+ it("fails loudly on an alias with no space id", () => {
36
+ for (const spaces of [
37
+ { default: undefined },
38
+ { default: "" },
39
+ { sales: { spaceId: "" } },
40
+ ] as Parameters<typeof normalizeGenieSpaces>[0][]) {
41
+ assert.throws(() => normalizeGenieSpaces(spaces), ConfigurationError);
42
+ }
43
+ });
44
+
45
+ it("names the env var only for the default alias", () => {
46
+ assert.throws(() => normalizeGenieSpaces({ default: undefined }), /DATABRICKS_GENIE_SPACE_ID/);
47
+ assert.throws(() => normalizeGenieSpaces({ sales: undefined }), /genieSpaces\.sales/);
48
+ });
49
+ });
50
+
51
+ describe("request body validation", () => {
52
+ it("reports distinct dot-joined field paths", () => {
53
+ assert.deepEqual(
54
+ invalidFields({
55
+ issues: [{ path: ["traceId"] }, { path: ["value", 0] }, { path: ["traceId"] }],
56
+ }),
57
+ ["traceId", "value.0"],
58
+ );
59
+ });
60
+
61
+ it("drops the root path, which names no field", () => {
62
+ assert.deepEqual(invalidFields({ issues: [{ path: [] }] }), []);
63
+ });
64
+ });