@dbx-tools/appkit-mastra 0.1.111 → 0.3.1
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/README.md +309 -344
- package/index.ts +46 -0
- package/package.json +50 -28
- package/src/agents.ts +858 -0
- package/src/chart.ts +695 -0
- package/src/config.ts +443 -0
- package/src/filesystems.ts +1090 -0
- package/src/genie.ts +1091 -0
- package/src/history.ts +297 -0
- package/src/mcp.ts +105 -0
- package/src/memory.ts +300 -0
- package/src/mlflow.ts +149 -0
- package/src/model.ts +163 -0
- package/src/observability.ts +144 -0
- package/src/pagination.ts +34 -0
- package/src/plugin.ts +804 -0
- package/src/processors.ts +168 -0
- package/src/rest.ts +67 -0
- package/src/server.ts +343 -0
- package/src/serving-sanitize.ts +167 -0
- package/src/serving.ts +97 -0
- package/src/statement.ts +89 -0
- package/src/storage-schema.ts +41 -0
- package/src/summarize.ts +176 -0
- package/src/threads.ts +338 -0
- package/src/workspaces.ts +346 -0
- package/src/writer.ts +44 -0
- package/tsconfig.json +41 -0
- package/dist/index.d.ts +0 -1676
- package/dist/index.js +0 -5337
package/src/threads.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation-thread listing exposed as a Mastra custom API route.
|
|
3
|
+
*
|
|
4
|
+
* Backed entirely by native Mastra: looks up the active agent by id,
|
|
5
|
+
* asks its `Memory` instance to `listThreads` filtered to the caller's
|
|
6
|
+
* resource, and shapes each into the JSON-safe {@link MastraThread}
|
|
7
|
+
* wire type. A sibling `DELETE` removes a single named thread.
|
|
8
|
+
*
|
|
9
|
+
* A sibling `DELETE` removes a single named thread; a `PATCH` renames
|
|
10
|
+
* one ({@link renameThread}).
|
|
11
|
+
*
|
|
12
|
+
* Like {@link historyRoute} this registers through Mastra's
|
|
13
|
+
* `registerApiRoute` so it shares the `MastraServer` auth-middleware
|
|
14
|
+
* pipeline (in `./server.ts`), which has already stamped the resource
|
|
15
|
+
* id (`MASTRA_RESOURCE_ID_KEY`) and the targeted thread id
|
|
16
|
+
* (`MASTRA_THREAD_ID_KEY`, resolved from the thread-selection header /
|
|
17
|
+
* cookie) on `RequestContext` by the time a handler runs. Resource
|
|
18
|
+
* scoping lives here so a caller can only ever see, rename, or delete
|
|
19
|
+
* its own threads; no cookie or user lookups happen in this module.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
wire,
|
|
24
|
+
type MastraDeleteThreadResponse,
|
|
25
|
+
type MastraThread,
|
|
26
|
+
type MastraThreadsResponse,
|
|
27
|
+
type MastraUpdateThreadResponse,
|
|
28
|
+
} from "@dbx-tools/shared-mastra";
|
|
29
|
+
import type { Agent } from "@mastra/core/agent";
|
|
30
|
+
import type { StorageThreadType } from "@mastra/core/memory";
|
|
31
|
+
import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
|
|
32
|
+
import type { ContextWithMastra } from "@mastra/core/server";
|
|
33
|
+
import { registerApiRoute } from "@mastra/core/server";
|
|
34
|
+
|
|
35
|
+
import { clampPerPage, parseIntParam } from "./pagination";
|
|
36
|
+
import { log } from "@dbx-tools/shared-core";
|
|
37
|
+
|
|
38
|
+
const logger = log.logger("mastra/threads");
|
|
39
|
+
|
|
40
|
+
/** Default threads page size. */
|
|
41
|
+
const DEFAULT_PER_PAGE = 30;
|
|
42
|
+
/** Hard cap so a misbehaving client can't fetch every thread at once. */
|
|
43
|
+
const MAX_PER_PAGE = 200;
|
|
44
|
+
|
|
45
|
+
/** Inputs accepted by {@link listThreads}. */
|
|
46
|
+
export interface ListThreadsOptions {
|
|
47
|
+
agent: Agent;
|
|
48
|
+
resourceId: string;
|
|
49
|
+
page?: number;
|
|
50
|
+
perPage?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch a page of the resource's conversation threads, newest
|
|
55
|
+
* (`updatedAt` DESC) first.
|
|
56
|
+
*
|
|
57
|
+
* Uses the agent's resolved `Memory` (`getMemory()`) so the per-agent
|
|
58
|
+
* storage namespace (`mastra_<agentId>` schema) applies automatically.
|
|
59
|
+
* When the agent has no memory configured the response is a successful
|
|
60
|
+
* empty page so callers don't have to special-case stateless agents.
|
|
61
|
+
*/
|
|
62
|
+
export async function listThreads(opts: ListThreadsOptions): Promise<MastraThreadsResponse> {
|
|
63
|
+
const perPage = clampPerPage(opts.perPage, {
|
|
64
|
+
fallback: DEFAULT_PER_PAGE,
|
|
65
|
+
max: MAX_PER_PAGE,
|
|
66
|
+
});
|
|
67
|
+
const page = Math.max(0, Math.trunc(opts.page ?? 0));
|
|
68
|
+
const memory = await opts.agent.getMemory();
|
|
69
|
+
if (!memory) {
|
|
70
|
+
logger.debug("list:no-memory", { agentId: opts.agent.id });
|
|
71
|
+
return { threads: [], page, perPage, total: 0, hasMore: false };
|
|
72
|
+
}
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const result = await memory.listThreads({
|
|
75
|
+
filter: { resourceId: opts.resourceId },
|
|
76
|
+
page,
|
|
77
|
+
perPage,
|
|
78
|
+
orderBy: { field: "updatedAt", direction: "DESC" },
|
|
79
|
+
});
|
|
80
|
+
const threads = result.threads.map(toWireThread);
|
|
81
|
+
logger.debug("list:done", {
|
|
82
|
+
agentId: opts.agent.id,
|
|
83
|
+
resourceId: opts.resourceId,
|
|
84
|
+
page,
|
|
85
|
+
perPage,
|
|
86
|
+
returned: threads.length,
|
|
87
|
+
total: result.total,
|
|
88
|
+
hasMore: result.hasMore,
|
|
89
|
+
elapsedMs: Date.now() - startedAt,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
threads,
|
|
93
|
+
page,
|
|
94
|
+
perPage,
|
|
95
|
+
total: result.total,
|
|
96
|
+
hasMore: result.hasMore,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Inputs accepted by {@link deleteThread}. */
|
|
101
|
+
export interface DeleteThreadOptions {
|
|
102
|
+
agent: Agent;
|
|
103
|
+
threadId: string;
|
|
104
|
+
resourceId: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Delete a single named thread (and every message on it).
|
|
109
|
+
*
|
|
110
|
+
* Ownership is enforced: the thread is only removed when it belongs to
|
|
111
|
+
* the calling resource, so a client can't delete another user's
|
|
112
|
+
* conversation by guessing its id. A thread that doesn't exist (or
|
|
113
|
+
* isn't owned by the caller) is a successful no-op (`deleted: false`)
|
|
114
|
+
* so the UI can fire-and-forget.
|
|
115
|
+
*/
|
|
116
|
+
export async function deleteThread(opts: DeleteThreadOptions): Promise<{ deleted: boolean }> {
|
|
117
|
+
const memory = await opts.agent.getMemory();
|
|
118
|
+
if (!memory) {
|
|
119
|
+
logger.debug("delete:no-memory", { agentId: opts.agent.id });
|
|
120
|
+
return { deleted: false };
|
|
121
|
+
}
|
|
122
|
+
// Confirm the thread exists and is owned by the caller before
|
|
123
|
+
// deleting; never let a guessed id touch another resource's data.
|
|
124
|
+
const existing = await memory.getThreadById({ threadId: opts.threadId });
|
|
125
|
+
if (!existing || existing.resourceId !== opts.resourceId) {
|
|
126
|
+
logger.debug("delete:not-owned", {
|
|
127
|
+
agentId: opts.agent.id,
|
|
128
|
+
threadId: opts.threadId,
|
|
129
|
+
found: existing !== null,
|
|
130
|
+
});
|
|
131
|
+
return { deleted: false };
|
|
132
|
+
}
|
|
133
|
+
const startedAt = Date.now();
|
|
134
|
+
await memory.deleteThread(opts.threadId);
|
|
135
|
+
logger.info("delete:done", {
|
|
136
|
+
agentId: opts.agent.id,
|
|
137
|
+
threadId: opts.threadId,
|
|
138
|
+
elapsedMs: Date.now() - startedAt,
|
|
139
|
+
});
|
|
140
|
+
return { deleted: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Inputs accepted by {@link renameThread}. */
|
|
144
|
+
export interface RenameThreadOptions {
|
|
145
|
+
agent: Agent;
|
|
146
|
+
threadId: string;
|
|
147
|
+
resourceId: string;
|
|
148
|
+
title: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Rename a single thread, returning the updated wire thread.
|
|
153
|
+
*
|
|
154
|
+
* Ownership is enforced the same way {@link deleteThread} enforces it:
|
|
155
|
+
* the title is only changed when the thread belongs to the calling
|
|
156
|
+
* resource, so a client can't rename another user's conversation by
|
|
157
|
+
* guessing its id. Existing thread `metadata` is preserved untouched
|
|
158
|
+
* (Mastra's `updateThread` replaces the row, so it must be passed back
|
|
159
|
+
* in). Returns `null` when the thread doesn't exist, isn't owned by the
|
|
160
|
+
* caller, or the agent has no memory configured, letting the route map
|
|
161
|
+
* that to a 404.
|
|
162
|
+
*/
|
|
163
|
+
export async function renameThread(opts: RenameThreadOptions): Promise<MastraThread | null> {
|
|
164
|
+
const memory = await opts.agent.getMemory();
|
|
165
|
+
if (!memory) {
|
|
166
|
+
logger.debug("rename:no-memory", { agentId: opts.agent.id });
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const existing = await memory.getThreadById({ threadId: opts.threadId });
|
|
170
|
+
if (!existing || existing.resourceId !== opts.resourceId) {
|
|
171
|
+
logger.debug("rename:not-owned", {
|
|
172
|
+
agentId: opts.agent.id,
|
|
173
|
+
threadId: opts.threadId,
|
|
174
|
+
found: existing !== null,
|
|
175
|
+
});
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const startedAt = Date.now();
|
|
179
|
+
const updated = await memory.updateThread({
|
|
180
|
+
id: opts.threadId,
|
|
181
|
+
title: opts.title,
|
|
182
|
+
metadata: existing.metadata ?? {},
|
|
183
|
+
});
|
|
184
|
+
logger.info("rename:done", {
|
|
185
|
+
agentId: opts.agent.id,
|
|
186
|
+
threadId: opts.threadId,
|
|
187
|
+
elapsedMs: Date.now() - startedAt,
|
|
188
|
+
});
|
|
189
|
+
return toWireThread(updated);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Options accepted by {@link threadsRoute}. */
|
|
193
|
+
export type ThreadsRouteOptions =
|
|
194
|
+
{ path: `${string}:agentId${string}`; agent?: never } | { path: string; agent: string };
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Register the `<path>` Mastra custom API route. Handles three methods
|
|
198
|
+
* on the same mount:
|
|
199
|
+
*
|
|
200
|
+
* - `GET`: a page of the resource's conversation threads
|
|
201
|
+
* ({@link listThreads}).
|
|
202
|
+
* - `DELETE`: remove the thread named by the thread-selection header
|
|
203
|
+
* / `?threadId=` query ({@link deleteThread}). The id is read from
|
|
204
|
+
* `RequestContext` (the auth middleware resolves it the same way it
|
|
205
|
+
* does for streaming and history), so the client deletes any of its
|
|
206
|
+
* threads by stamping the target id - no separate path param.
|
|
207
|
+
* - `PATCH`: rename the thread named by the thread-selection header /
|
|
208
|
+
* `?threadId=` query to the `{ title }` in the JSON body
|
|
209
|
+
* ({@link renameThread}). Targets a thread the same way `DELETE`
|
|
210
|
+
* does; 404s when the thread isn't owned by the caller.
|
|
211
|
+
*
|
|
212
|
+
* Follows the `@mastra/ai-sdk` agent-binding convention: pass `agent`
|
|
213
|
+
* for a fixed-agent mount, or include `:agentId` in the path for
|
|
214
|
+
* dynamic routing. The plugin registers both `/route/threads` (default
|
|
215
|
+
* agent) and `/route/threads/:agentId`.
|
|
216
|
+
*/
|
|
217
|
+
export function threadsRoute(options: ThreadsRouteOptions) {
|
|
218
|
+
const { path } = options;
|
|
219
|
+
const fixedAgent = "agent" in options ? options.agent : undefined;
|
|
220
|
+
if (!fixedAgent && !path.includes(":agentId")) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"threadsRoute path must include `:agentId` or `agent` must be passed explicitly",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
// Shared by GET / DELETE: resolve the active agent and the caller's
|
|
226
|
+
// resource id, returning a JSON error response when either is
|
|
227
|
+
// missing. Keeps both handlers thin with identical validation.
|
|
228
|
+
const resolveContext = (c: ContextWithMastra) => {
|
|
229
|
+
const mastra = c.get("mastra");
|
|
230
|
+
const requestContext = c.get("requestContext");
|
|
231
|
+
const agentId = fixedAgent ?? c.req.param("agentId");
|
|
232
|
+
if (!agentId) {
|
|
233
|
+
return { error: c.json({ error: "agentId is required" }, 400) } as const;
|
|
234
|
+
}
|
|
235
|
+
const agent = mastra.getAgentById(agentId);
|
|
236
|
+
if (!agent) {
|
|
237
|
+
return {
|
|
238
|
+
error: c.json({ error: `Unknown agent "${agentId}"` }, 404),
|
|
239
|
+
} as const;
|
|
240
|
+
}
|
|
241
|
+
const resourceId = requestContext.get(MASTRA_RESOURCE_ID_KEY) as string | undefined;
|
|
242
|
+
if (!resourceId) {
|
|
243
|
+
return {
|
|
244
|
+
error: c.json({ error: "resource id missing from request context" }, 400),
|
|
245
|
+
} as const;
|
|
246
|
+
}
|
|
247
|
+
return { agentId, agent, requestContext, resourceId } as const;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
registerApiRoute(path, {
|
|
252
|
+
method: "GET",
|
|
253
|
+
handler: async (c: ContextWithMastra) => {
|
|
254
|
+
const ctx = resolveContext(c);
|
|
255
|
+
if ("error" in ctx) return ctx.error;
|
|
256
|
+
const payload = await listThreads({
|
|
257
|
+
agent: ctx.agent,
|
|
258
|
+
resourceId: ctx.resourceId,
|
|
259
|
+
page: parseIntParam(c.req.query("page")),
|
|
260
|
+
perPage: parseIntParam(c.req.query("perPage")),
|
|
261
|
+
});
|
|
262
|
+
return c.json(payload);
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
registerApiRoute(path, {
|
|
266
|
+
method: "DELETE",
|
|
267
|
+
handler: async (c: ContextWithMastra) => {
|
|
268
|
+
const ctx = resolveContext(c);
|
|
269
|
+
if ("error" in ctx) return ctx.error;
|
|
270
|
+
const threadId = ctx.requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
|
|
271
|
+
if (!threadId) {
|
|
272
|
+
return c.json({ error: "thread id missing from request context" }, 400);
|
|
273
|
+
}
|
|
274
|
+
const { deleted } = await deleteThread({
|
|
275
|
+
agent: ctx.agent,
|
|
276
|
+
threadId,
|
|
277
|
+
resourceId: ctx.resourceId,
|
|
278
|
+
});
|
|
279
|
+
const payload: MastraDeleteThreadResponse = {
|
|
280
|
+
ok: true,
|
|
281
|
+
agentId: ctx.agentId,
|
|
282
|
+
threadId,
|
|
283
|
+
deleted,
|
|
284
|
+
};
|
|
285
|
+
return c.json(payload);
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
registerApiRoute(path, {
|
|
289
|
+
method: "PATCH",
|
|
290
|
+
handler: async (c: ContextWithMastra) => {
|
|
291
|
+
const ctx = resolveContext(c);
|
|
292
|
+
if ("error" in ctx) return ctx.error;
|
|
293
|
+
const threadId = ctx.requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
|
|
294
|
+
if (!threadId) {
|
|
295
|
+
return c.json({ error: "thread id missing from request context" }, 400);
|
|
296
|
+
}
|
|
297
|
+
const body = wire.MastraUpdateThreadRequestSchema.safeParse(await c.req.json());
|
|
298
|
+
if (!body.success) {
|
|
299
|
+
return c.json({ error: body.error.message }, 400);
|
|
300
|
+
}
|
|
301
|
+
const thread = await renameThread({
|
|
302
|
+
agent: ctx.agent,
|
|
303
|
+
threadId,
|
|
304
|
+
resourceId: ctx.resourceId,
|
|
305
|
+
title: body.data.title,
|
|
306
|
+
});
|
|
307
|
+
if (!thread) {
|
|
308
|
+
return c.json({ error: `Unknown thread "${threadId}"` }, 404);
|
|
309
|
+
}
|
|
310
|
+
const payload: MastraUpdateThreadResponse = {
|
|
311
|
+
ok: true,
|
|
312
|
+
agentId: ctx.agentId,
|
|
313
|
+
thread,
|
|
314
|
+
};
|
|
315
|
+
return c.json(payload);
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Coerce a Mastra `StorageThreadType` into the JSON-safe wire shape. */
|
|
322
|
+
function toWireThread(thread: StorageThreadType): MastraThread {
|
|
323
|
+
return {
|
|
324
|
+
id: thread.id,
|
|
325
|
+
...(thread.title ? { title: thread.title } : {}),
|
|
326
|
+
resourceId: thread.resourceId,
|
|
327
|
+
createdAt: toIso(thread.createdAt),
|
|
328
|
+
updatedAt: toIso(thread.updatedAt),
|
|
329
|
+
...(thread.metadata ? { metadata: thread.metadata } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Render a storage timestamp as an ISO-8601 string for the wire. */
|
|
334
|
+
function toIso(value: Date | string | number): string {
|
|
335
|
+
if (value instanceof Date) return value.toISOString();
|
|
336
|
+
const parsed = new Date(value);
|
|
337
|
+
return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString();
|
|
338
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastra workspace factory for Databricks Apps.
|
|
3
|
+
*
|
|
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).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
12
|
+
import type { RequestContext } from "@mastra/core/request-context";
|
|
13
|
+
import {
|
|
14
|
+
CompositeFilesystem,
|
|
15
|
+
Workspace,
|
|
16
|
+
type SkillsContext,
|
|
17
|
+
type SkillsResolver,
|
|
18
|
+
type WorkspaceFilesystem,
|
|
19
|
+
} from "@mastra/core/workspace";
|
|
20
|
+
|
|
21
|
+
import { MASTRA_SCOPES_KEY, MASTRA_USER_EMAIL_KEY, MASTRA_USER_KEY, type User } from "./config";
|
|
22
|
+
import { DatabricksWorkspaceFilesystem, emptyFilesystem } from "./filesystems";
|
|
23
|
+
import { log, string, token } from "@dbx-tools/shared-core";
|
|
24
|
+
|
|
25
|
+
/* ------------------------------ constants ------------------------------ */
|
|
26
|
+
|
|
27
|
+
/** Shared Assistant skills tree in the workspace namespace. */
|
|
28
|
+
const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
|
|
29
|
+
|
|
30
|
+
/** Composite mount for {@link ASSISTANT_SHARED_SKILLS_PATH}. */
|
|
31
|
+
const ASSISTANT_WORKSPACE_SKILLS_MOUNT = "/workspace_skills";
|
|
32
|
+
|
|
33
|
+
/** Composite mount for the caller's `/.assistant/skills` tree. */
|
|
34
|
+
const ASSISTANT_USER_SKILLS_MOUNT = "/workspace_user_skills";
|
|
35
|
+
|
|
36
|
+
/** OAuth scopes that gate Databricks workspace file mounts. */
|
|
37
|
+
const WORKSPACE_FILE_SCOPES = ["workspace", "workspace.workspace", "all-apis"] as const;
|
|
38
|
+
|
|
39
|
+
const logger = log.logger("mastra/workspaces");
|
|
40
|
+
|
|
41
|
+
/* -------------------------------- types -------------------------------- */
|
|
42
|
+
|
|
43
|
+
/** Per-request context for mount resolvers. */
|
|
44
|
+
interface WorkspaceMountContext {
|
|
45
|
+
requestContext?: RequestContext;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mount map plus optional Mastra skill scan roots for one resolver. */
|
|
49
|
+
interface WorkspaceMountContribution {
|
|
50
|
+
mounts: Record<string, WorkspaceFilesystem>;
|
|
51
|
+
/** Paths within the composite namespace where `SKILL.md` files are scanned. */
|
|
52
|
+
skillPaths?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Contributes filesystem mounts (and optional skill paths) for one request. */
|
|
56
|
+
type WorkspaceMountResolver = (
|
|
57
|
+
context: WorkspaceMountContext,
|
|
58
|
+
) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
|
|
59
|
+
|
|
60
|
+
/** Options for {@link createWorkspace}. */
|
|
61
|
+
export interface CreateWorkspaceOptions {
|
|
62
|
+
/** Workspace id; derived from `name` or `"workspace"` when omitted. */
|
|
63
|
+
id?: string;
|
|
64
|
+
/** Display name; derived from `id` when omitted. */
|
|
65
|
+
name?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Mount read-only Assistant skill trees from `/Workspace/.assistant/skills`
|
|
68
|
+
* and `/Users/<email>/.assistant/skills`. Defaults to `true`.
|
|
69
|
+
*/
|
|
70
|
+
assistantSkills?: boolean;
|
|
71
|
+
/** Extra per-request mount resolvers (run after built-in options). */
|
|
72
|
+
mounts?: WorkspaceMountResolver[];
|
|
73
|
+
/** Replace the auto-built dynamic skills resolver. */
|
|
74
|
+
skills?: SkillsResolver;
|
|
75
|
+
/** Forwarded to Mastra when skill discovery is enabled. */
|
|
76
|
+
checkSkillFileMtime?: boolean;
|
|
77
|
+
/** Enable BM25 keyword search over indexed workspace content. */
|
|
78
|
+
bm25?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a Mastra {@link Workspace} with per-request Databricks mounts.
|
|
83
|
+
*
|
|
84
|
+
* @example Assistant skills only (default for agents in this plugin)
|
|
85
|
+
* ```ts
|
|
86
|
+
* createWorkspace()
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @example Assistant skills plus a custom mount resolver
|
|
90
|
+
* ```ts
|
|
91
|
+
* createWorkspace({
|
|
92
|
+
* assistantSkills: true,
|
|
93
|
+
* mounts: [
|
|
94
|
+
* async ({ requestContext }) => ({
|
|
95
|
+
* mounts: { "/data": myFilesystem },
|
|
96
|
+
* skillPaths: [],
|
|
97
|
+
* }),
|
|
98
|
+
* ],
|
|
99
|
+
* })
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
|
|
103
|
+
const { id, name } = resolveWorkspaceIdentity(options);
|
|
104
|
+
const resolvers = buildMountResolvers(options);
|
|
105
|
+
const skills =
|
|
106
|
+
options.skills ?? (resolvers.length > 0 ? buildWorkspaceSkillsResolver(resolvers) : undefined);
|
|
107
|
+
const checkSkillFileMtime = options.checkSkillFileMtime ?? options.assistantSkills !== false;
|
|
108
|
+
const bm25 = options.bm25 !== false;
|
|
109
|
+
logger.debug("workspace:create", {
|
|
110
|
+
id,
|
|
111
|
+
name,
|
|
112
|
+
resolverCount: resolvers.length,
|
|
113
|
+
assistantSkills: options.assistantSkills !== false,
|
|
114
|
+
customMountResolvers: options.mounts?.length ?? 0,
|
|
115
|
+
customSkillsResolver: Boolean(options.skills),
|
|
116
|
+
checkSkillFileMtime,
|
|
117
|
+
bm25,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return new Workspace({
|
|
121
|
+
id,
|
|
122
|
+
name,
|
|
123
|
+
filesystem: (context) => resolveWorkspaceFilesystem(resolvers, context),
|
|
124
|
+
...(skills
|
|
125
|
+
? {
|
|
126
|
+
skills,
|
|
127
|
+
checkSkillFileMtime,
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
bm25,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ---------------------------- private helpers ---------------------------- */
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Map an OBO user email to their Assistant skills directory in the
|
|
138
|
+
* workspace namespace.
|
|
139
|
+
*/
|
|
140
|
+
function userAssistantSkillsPath(userEmail: string): string {
|
|
141
|
+
return `/Users/${userEmail.trim()}/.assistant/skills`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return whether the request token carries a scope that allows workspace
|
|
146
|
+
* file API access (`workspace` or `all-apis` on {@link MASTRA_SCOPES_KEY}).
|
|
147
|
+
*/
|
|
148
|
+
function hasWorkspaceFileScope(requestContext: RequestContext | undefined): boolean {
|
|
149
|
+
return token.includesAccessTokenScope(
|
|
150
|
+
requestContext?.get(MASTRA_SCOPES_KEY),
|
|
151
|
+
WORKSPACE_FILE_SCOPES,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Built-in mount resolver for Assistant `SKILL.md` trees.
|
|
157
|
+
*
|
|
158
|
+
* Mounts {@link ASSISTANT_SHARED_SKILLS_PATH} when scope checks pass and
|
|
159
|
+
* `/Users/<email>/.assistant/skills` when {@link MASTRA_USER_EMAIL_KEY} is
|
|
160
|
+
* set. Returns empty mounts when the OBO user or client is missing.
|
|
161
|
+
* Mastra owns filesystem initialization.
|
|
162
|
+
*/
|
|
163
|
+
function resolveAssistantSkillsMounts(context: WorkspaceMountContext): WorkspaceMountContribution {
|
|
164
|
+
const mounts: Record<string, DatabricksWorkspaceFilesystem> = {};
|
|
165
|
+
const requestContext = context.requestContext;
|
|
166
|
+
|
|
167
|
+
if (!shouldMountAssistantSkills(requestContext)) {
|
|
168
|
+
logger.debug("assistant-skills:skipped", {
|
|
169
|
+
reason: !requestContext ? "no-request-context" : "missing-workspace-scope",
|
|
170
|
+
nodeEnv: process.env.NODE_ENV,
|
|
171
|
+
scopes: context.requestContext?.get(MASTRA_SCOPES_KEY),
|
|
172
|
+
});
|
|
173
|
+
return { mounts, skillPaths: [] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const user = requestContext!.get(MASTRA_USER_KEY) as User | undefined;
|
|
177
|
+
const client = user?.executionContext.client;
|
|
178
|
+
if (!client) {
|
|
179
|
+
logger.debug("assistant-skills:skipped", {
|
|
180
|
+
reason: "missing-obo-client",
|
|
181
|
+
userId: user?.id,
|
|
182
|
+
});
|
|
183
|
+
return { mounts, skillPaths: [] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
mounts[ASSISTANT_WORKSPACE_SKILLS_MOUNT] = databricksFilesystem(
|
|
187
|
+
client,
|
|
188
|
+
ASSISTANT_SHARED_SKILLS_PATH,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const email = resolveScopedEmail(requestContext);
|
|
192
|
+
if (email) {
|
|
193
|
+
mounts[ASSISTANT_USER_SKILLS_MOUNT] = databricksFilesystem(
|
|
194
|
+
client,
|
|
195
|
+
userAssistantSkillsPath(email),
|
|
196
|
+
false,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logger.debug("assistant-skills:mounted", {
|
|
201
|
+
sharedPath: ASSISTANT_SHARED_SKILLS_PATH,
|
|
202
|
+
sharedMount: ASSISTANT_WORKSPACE_SKILLS_MOUNT,
|
|
203
|
+
userMount: email ? ASSISTANT_USER_SKILLS_MOUNT : undefined,
|
|
204
|
+
userPath: email ? userAssistantSkillsPath(email) : undefined,
|
|
205
|
+
mountKeys: Object.keys(mounts),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { mounts, skillPaths: Object.keys(mounts) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Fill in `id` and `name` when either is omitted on {@link CreateWorkspaceOptions}.
|
|
213
|
+
* Slugifies `name` into `id`; tokenizes `id` into a display `name`.
|
|
214
|
+
*/
|
|
215
|
+
function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
|
|
216
|
+
id: string;
|
|
217
|
+
name: string;
|
|
218
|
+
} {
|
|
219
|
+
let id = options.id;
|
|
220
|
+
let name = options.name;
|
|
221
|
+
if (!id) {
|
|
222
|
+
id = name ? string.toSlug(name) : "workspace";
|
|
223
|
+
}
|
|
224
|
+
if (!name) {
|
|
225
|
+
name = Array.from(string.tokenize(id)).join(" ");
|
|
226
|
+
}
|
|
227
|
+
return { id, name };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Collect built-in and caller-supplied mount resolvers for one workspace. */
|
|
231
|
+
function buildMountResolvers(options: CreateWorkspaceOptions): WorkspaceMountResolver[] {
|
|
232
|
+
const resolvers: WorkspaceMountResolver[] = [];
|
|
233
|
+
const { assistantSkills = true, mounts } = options;
|
|
234
|
+
if (assistantSkills) {
|
|
235
|
+
resolvers.push(resolveAssistantSkillsMounts);
|
|
236
|
+
}
|
|
237
|
+
if (mounts?.length) {
|
|
238
|
+
resolvers.push(...mounts);
|
|
239
|
+
}
|
|
240
|
+
logger.debug("mounts:resolvers", {
|
|
241
|
+
assistantSkills,
|
|
242
|
+
builtInResolver: assistantSkills,
|
|
243
|
+
customResolverCount: mounts?.length ?? 0,
|
|
244
|
+
totalResolverCount: resolvers.length,
|
|
245
|
+
});
|
|
246
|
+
return resolvers;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Gate Assistant skill mounts on request context.
|
|
251
|
+
*
|
|
252
|
+
* Always allows mounts in development; in other environments requires
|
|
253
|
+
* {@link hasWorkspaceFileScope}.
|
|
254
|
+
*/
|
|
255
|
+
function shouldMountAssistantSkills(
|
|
256
|
+
requestContext: RequestContext | undefined,
|
|
257
|
+
): requestContext is RequestContext {
|
|
258
|
+
if (!requestContext) return false;
|
|
259
|
+
if (process.env.NODE_ENV === "development") return true;
|
|
260
|
+
return hasWorkspaceFileScope(requestContext);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Read the trimmed OBO user email stamped on {@link MASTRA_USER_EMAIL_KEY}. */
|
|
264
|
+
function resolveScopedEmail(requestContext: RequestContext | undefined): string | undefined {
|
|
265
|
+
const email = requestContext?.get(MASTRA_USER_EMAIL_KEY) as string | undefined;
|
|
266
|
+
return email?.trim() || undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Construct a read-only {@link DatabricksWorkspaceFilesystem} for `basePath`. */
|
|
270
|
+
function databricksFilesystem(
|
|
271
|
+
client: WorkspaceClient,
|
|
272
|
+
basePath: string,
|
|
273
|
+
readOnly: boolean = true,
|
|
274
|
+
): DatabricksWorkspaceFilesystem {
|
|
275
|
+
return new DatabricksWorkspaceFilesystem({
|
|
276
|
+
client,
|
|
277
|
+
basePath,
|
|
278
|
+
readOnly,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run every mount resolver for one request and merge mounts plus skill paths.
|
|
284
|
+
* Later resolvers overwrite mount keys from earlier ones.
|
|
285
|
+
*/
|
|
286
|
+
async function resolveWorkspaceContribution(
|
|
287
|
+
resolvers: WorkspaceMountResolver[],
|
|
288
|
+
context: WorkspaceMountContext,
|
|
289
|
+
): Promise<WorkspaceMountContribution> {
|
|
290
|
+
const mounts: Record<string, WorkspaceFilesystem> = {};
|
|
291
|
+
const skillPaths: string[] = [];
|
|
292
|
+
|
|
293
|
+
for (const [index, resolver] of resolvers.entries()) {
|
|
294
|
+
const contribution = await resolver(context);
|
|
295
|
+
logger.debug("mounts:resolver", {
|
|
296
|
+
index,
|
|
297
|
+
mountKeys: Object.keys(contribution.mounts),
|
|
298
|
+
skillPaths: contribution.skillPaths ?? [],
|
|
299
|
+
});
|
|
300
|
+
Object.assign(mounts, contribution.mounts);
|
|
301
|
+
if (contribution.skillPaths?.length) {
|
|
302
|
+
skillPaths.push(...contribution.skillPaths);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
logger.debug("mounts:merged", {
|
|
307
|
+
mountKeys: Object.keys(mounts),
|
|
308
|
+
skillPaths,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return { mounts, skillPaths };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Dynamic filesystem resolver passed to Mastra {@link Workspace}.
|
|
316
|
+
*
|
|
317
|
+
* Returns a {@link CompositeFilesystem} when any mount resolved; otherwise
|
|
318
|
+
* {@link emptyFilesystem}.
|
|
319
|
+
*/
|
|
320
|
+
async function resolveWorkspaceFilesystem(
|
|
321
|
+
resolvers: WorkspaceMountResolver[],
|
|
322
|
+
context: WorkspaceMountContext,
|
|
323
|
+
): Promise<WorkspaceFilesystem> {
|
|
324
|
+
const { mounts } = await resolveWorkspaceContribution(resolvers, context);
|
|
325
|
+
const mountKeys = Object.keys(mounts);
|
|
326
|
+
if (mountKeys.length === 0) {
|
|
327
|
+
logger.debug("filesystem:empty", {
|
|
328
|
+
hasRequestContext: Boolean(context.requestContext),
|
|
329
|
+
});
|
|
330
|
+
return emptyFilesystem();
|
|
331
|
+
}
|
|
332
|
+
logger.debug("filesystem:composite", { mountKeys });
|
|
333
|
+
return new CompositeFilesystem({ mounts });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Build the dynamic {@link SkillsResolver} that collects `skillPaths` from
|
|
338
|
+
* every mount resolver on each request.
|
|
339
|
+
*/
|
|
340
|
+
function buildWorkspaceSkillsResolver(resolvers: WorkspaceMountResolver[]): SkillsResolver {
|
|
341
|
+
return async (context: SkillsContext) => {
|
|
342
|
+
const { skillPaths } = await resolveWorkspaceContribution(resolvers, context);
|
|
343
|
+
logger.debug("skills:resolved", { skillPaths });
|
|
344
|
+
return skillPaths ?? [];
|
|
345
|
+
};
|
|
346
|
+
}
|