@dbx-tools/appkit-mastra 0.9.118 → 0.9.120

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/sandbox.ts DELETED
@@ -1,556 +0,0 @@
1
- /**
2
- * Databricks Sandbox adapter for Mastra workspaces.
3
- *
4
- * Implements Mastra's foreground command surface over the Databricks Sandbox
5
- * REST API. Sandboxes are created lazily, retain their home directory across
6
- * inactivity stops, and execute untrusted shell commands outside the App
7
- * container.
8
- *
9
- * @module
10
- */
11
-
12
- import type { WorkspaceClient } from "@databricks/appkit";
13
- import { databricks } from "@dbx-tools/appkit";
14
- import { asyncUtils, errorUtils, log, stringUtils } from "@dbx-tools/shared-core";
15
- import type { RequestContext } from "@mastra/core/request-context";
16
- import type {
17
- CommandResult,
18
- ExecuteCommandOptions,
19
- ProviderStatus,
20
- SandboxInfo,
21
- WorkspaceSandbox,
22
- } from "@mastra/core/workspace";
23
- import { z } from "zod";
24
- import { MontySandbox, type MontySandboxOptions } from "./monty-sandbox.ts";
25
-
26
- const SANDBOX_API_PATH = "/api/2.0/sandboxes";
27
- const SANDBOX_EXEC_API_PATH = "/api/2.0/sandbox-exec/sandboxes";
28
- const DEFAULT_INACTIVITY_TIMEOUT = "900s";
29
- const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
30
- const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
31
- const SANDBOX_POLL_INTERVAL_MS = 500;
32
- const logger = log.logger("mastra/sandbox");
33
-
34
- const SandboxStateSchema = z.object({
35
- state: z.string().optional(),
36
- });
37
-
38
- const SandboxResponseSchema = z.object({
39
- name: z.string(),
40
- display_name: z.string().optional(),
41
- create_time: z.string().optional(),
42
- update_time: z.string().optional(),
43
- status: SandboxStateSchema.optional(),
44
- });
45
-
46
- const ExecuteResponseSchema = z.object({
47
- exit_code: z.number().int().optional(),
48
- status: z.string(),
49
- stdout: z.string().optional(),
50
- stderr: z.string().optional(),
51
- command_id: z.string().optional(),
52
- truncated: z.boolean().optional(),
53
- });
54
-
55
- /** Options for one Databricks-backed Mastra sandbox. */
56
- export interface DatabricksSandboxOptions {
57
- /** AppKit workspace client used for every sandbox API call. */
58
- client: WorkspaceClient;
59
- /** Resource id, with or without the `sandboxes/` prefix. */
60
- sandboxId: string;
61
- /** Human-readable Databricks label. */
62
- displayName?: string;
63
- /** Server-side idle timeout in protobuf duration form. Defaults to `900s`. */
64
- inactivityTimeout?: string;
65
- /** Maximum time to wait for the sandbox to become runnable. */
66
- startupTimeoutMs?: number;
67
- /** Default command timeout. Per-call `options.timeout` wins. */
68
- commandTimeoutMs?: number;
69
- /**
70
- * Provider used only when Databricks Sandbox is definitively unavailable.
71
- * Disabled by default. Pass `"monty"`, Monty options, or another Mastra
72
- * sandbox to enable a fallback explicitly.
73
- */
74
- fallback?: false | "monty" | MontySandboxOptions | WorkspaceSandbox;
75
- }
76
-
77
- /** Databricks options accepted by the higher-level workspace factory. */
78
- export interface DatabricksWorkspaceSandboxOptions extends Omit<
79
- DatabricksSandboxOptions,
80
- "client" | "sandboxId"
81
- > {
82
- /** Provider discriminator. Defaults to `databricks`. */
83
- provider?: "databricks";
84
- /**
85
- * Credential source. Defaults to the active request's AppKit client, which
86
- * uses OBO credentials when the turn runs in a user context.
87
- */
88
- client?: WorkspaceClient | ((context: { requestContext: RequestContext }) => WorkspaceClient);
89
- /**
90
- * Fixed id or per-request resolver. Omit to derive a stable, opaque id from
91
- * the workspace and attributed user.
92
- */
93
- sandboxId?: string | ((context: { requestContext: RequestContext }) => string);
94
- }
95
-
96
- /** Mastra sandbox backed by Databricks serverless Sandbox compute. */
97
- export class DatabricksSandbox implements WorkspaceSandbox {
98
- readonly id: string;
99
- readonly name: string;
100
- status: ProviderStatus = "pending";
101
- error?: string;
102
-
103
- private readonly client: WorkspaceClient;
104
- private readonly displayName: string;
105
- private readonly inactivityTimeout: string;
106
- private readonly startupTimeoutMs: number;
107
- private readonly commandTimeoutMs: number;
108
- private readonly fallbackConfig: Exclude<DatabricksSandboxOptions["fallback"], undefined>;
109
- private readonly createdAt = new Date();
110
- private remoteCreatedAt?: Date;
111
- private lastUsedAt?: Date;
112
- private activeFallback?: WorkspaceSandbox;
113
- private fallbackPromise?: Promise<void>;
114
- private startPromise?: Promise<void>;
115
-
116
- constructor(options: DatabricksSandboxOptions) {
117
- const id = stringUtils.trimToNull(options.sandboxId.replace(/^sandboxes\//, ""));
118
- if (!id) throw new TypeError("Databricks sandbox id must not be blank");
119
- this.id = id;
120
- this.name = options.displayName?.trim() || `Mastra sandbox ${id}`;
121
- this.client = options.client;
122
- this.displayName = this.name;
123
- this.inactivityTimeout = options.inactivityTimeout ?? DEFAULT_INACTIVITY_TIMEOUT;
124
- this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
125
- this.commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
126
- this.fallbackConfig = options.fallback ?? false;
127
- }
128
-
129
- get provider(): string {
130
- return this.activeFallback?.provider ?? "databricks";
131
- }
132
-
133
- /** Ensure the remote sandbox exists and has reached its running state. */
134
- async start(): Promise<void> {
135
- await this.startWithSignal();
136
- }
137
-
138
- private async startWithSignal(signal?: AbortSignal): Promise<void> {
139
- if (this.status === "running") return;
140
- if (this.startPromise) {
141
- await this.startPromise;
142
- return;
143
- }
144
- const pending = this.performStart(signal);
145
- this.startPromise = pending;
146
- try {
147
- await pending;
148
- } finally {
149
- if (this.startPromise === pending) this.startPromise = undefined;
150
- }
151
- }
152
-
153
- private async performStart(signal?: AbortSignal): Promise<void> {
154
- if (this.activeFallback) {
155
- await this.activeFallback.start?.();
156
- this.status = this.activeFallback.status;
157
- return;
158
- }
159
- this.status = "starting";
160
- this.error = undefined;
161
- try {
162
- let sandbox = await this.getOrCreate(signal);
163
- if (sandbox.status?.state === "SANDBOX_STATE_STOPPED") {
164
- sandbox = await this.mutate("start", signal);
165
- }
166
- if (sandbox.status?.state === "SANDBOX_STATE_STOPPING") {
167
- await this.waitForState("SANDBOX_STATE_STOPPED", signal);
168
- sandbox = await this.mutate("start", signal);
169
- }
170
- if (sandbox.status?.state !== "SANDBOX_STATE_RUNNING") {
171
- await this.waitForState("SANDBOX_STATE_RUNNING", signal);
172
- }
173
- this.status = "running";
174
- this.lastUsedAt = new Date();
175
- } catch (caught) {
176
- if (this.fallbackConfig !== false && isDatabricksSandboxUnavailable(caught)) {
177
- await this.activateFallback(caught);
178
- return;
179
- }
180
- this.status = "error";
181
- this.error = errorUtils.errorMessage(caught);
182
- throw caught;
183
- }
184
- }
185
-
186
- /** Databricks home persistence is not a cloneable Mastra checkpoint. */
187
- async snapshot(): Promise<void> {
188
- await this.activeFallback?.snapshot();
189
- }
190
-
191
- get supportsCheckpoints(): boolean {
192
- return this.activeFallback?.supportsCheckpoints ?? false;
193
- }
194
-
195
- /** Stop compute while preserving the sandbox home directory. */
196
- async stop(): Promise<void> {
197
- if (this.activeFallback) {
198
- await this.activeFallback.stop?.();
199
- this.status = this.activeFallback.status;
200
- return;
201
- }
202
- if (this.status === "stopped" || this.status === "destroyed") return;
203
- this.status = "stopping";
204
- try {
205
- await this.mutate("stop");
206
- this.status = "stopped";
207
- } catch (caught) {
208
- if (isStatus(caught, 404)) {
209
- this.status = "stopped";
210
- return;
211
- }
212
- this.status = "error";
213
- this.error = errorUtils.errorMessage(caught);
214
- throw caught;
215
- }
216
- }
217
-
218
- /** Permanently delete the sandbox and its persisted home directory. */
219
- async destroy(): Promise<void> {
220
- if (this.activeFallback) {
221
- await this.activeFallback.destroy?.();
222
- this.status = this.activeFallback.status;
223
- return;
224
- }
225
- if (this.status === "destroyed") return;
226
- this.status = "destroying";
227
- try {
228
- await this.request(`${this.resourcePath}`, "DELETE", z.object({}).passthrough());
229
- this.status = "destroyed";
230
- } catch (caught) {
231
- if (isStatus(caught, 404)) {
232
- this.status = "destroyed";
233
- return;
234
- }
235
- this.status = "error";
236
- this.error = errorUtils.errorMessage(caught);
237
- throw caught;
238
- }
239
- }
240
-
241
- /** Execute one foreground shell command in the remote sandbox. */
242
- async executeCommand(
243
- command: string,
244
- args: string[] = [],
245
- options: ExecuteCommandOptions = {},
246
- ): Promise<CommandResult> {
247
- await this.startWithSignal(options.abortSignal);
248
- if (this.activeFallback) {
249
- return this.executeFallback(command, args, options);
250
- }
251
- const timeout = options.timeout ?? this.commandTimeoutMs;
252
- const script = commandScript(command, args, options.cwd);
253
- const started = performance.now();
254
- let response: z.infer<typeof ExecuteResponseSchema>;
255
- try {
256
- response = await this.request(
257
- `${SANDBOX_EXEC_API_PATH}/${encodeURIComponent(this.id)}/exec-sync`,
258
- "POST",
259
- ExecuteResponseSchema,
260
- {
261
- payload: {
262
- cmd: "/bin/bash",
263
- args: ["-lc", script],
264
- envs: definedEnvironment(options.env),
265
- execution_timeout: `${Math.max(1, Math.ceil(timeout / 1_000))}s`,
266
- },
267
- signal: options.abortSignal,
268
- },
269
- );
270
- } catch (caught) {
271
- if (this.fallbackConfig === false || !isDatabricksSandboxUnavailable(caught)) throw caught;
272
- await this.activateFallback(caught);
273
- return this.executeFallback(command, args, options);
274
- }
275
- const stdout = response.stdout ?? "";
276
- const stderr = response.stderr ?? "";
277
- options.onStdout?.(stdout);
278
- options.onStderr?.(stderr);
279
- this.lastUsedAt = new Date();
280
- const timedOut = response.status === "EXECUTE_COMMAND_STATUS_TIMED_OUT";
281
- const exitCode = response.exit_code ?? -1;
282
- return {
283
- command,
284
- args,
285
- success: response.status === "EXECUTE_COMMAND_STATUS_COMPLETED" && exitCode === 0,
286
- exitCode,
287
- stdout,
288
- stderr,
289
- executionTimeMs: performance.now() - started,
290
- ...(timedOut ? { timedOut: true, killed: true } : {}),
291
- ...(response.truncated
292
- ? {
293
- stdoutTruncated: true,
294
- stderrTruncated: true,
295
- }
296
- : {}),
297
- };
298
- }
299
-
300
- /** Activate the configured fallback once across concurrent command failures. */
301
- private async activateFallback(caught: unknown): Promise<void> {
302
- if (this.activeFallback) return;
303
- if (this.fallbackPromise) {
304
- await this.fallbackPromise;
305
- return;
306
- }
307
- const pending = this.startFallback(caught);
308
- this.fallbackPromise = pending;
309
- try {
310
- await pending;
311
- } finally {
312
- if (this.fallbackPromise === pending) this.fallbackPromise = undefined;
313
- }
314
- }
315
-
316
- /** Start and publish the configured fallback after rechecking active state. */
317
- private async startFallback(caught: unknown): Promise<void> {
318
- if (this.activeFallback) return;
319
- if (this.fallbackConfig === false) throw caught;
320
- const fallback = resolveFallback(this.fallbackConfig);
321
- logger.warn("Databricks Sandbox unavailable; using fallback", {
322
- sandboxId: this.id,
323
- fallback: fallback.provider,
324
- error: errorUtils.errorMessage(caught),
325
- });
326
- await fallback.start?.();
327
- this.activeFallback = fallback;
328
- this.status = fallback.status;
329
- this.error = fallback.error;
330
- }
331
-
332
- /** Execute through the active fallback or return an unsupported result. */
333
- private executeFallback(
334
- command: string,
335
- args: string[],
336
- options: ExecuteCommandOptions,
337
- ): Promise<CommandResult> {
338
- const fallback = this.activeFallback;
339
- if (!fallback) throw new Error("Fallback sandbox is not active");
340
- if (fallback.executeCommand) return fallback.executeCommand(command, args, options);
341
- return Promise.resolve({
342
- command,
343
- args,
344
- success: false,
345
- exitCode: 1,
346
- stdout: "",
347
- stderr: `Fallback sandbox ${fallback.provider} does not support command execution.`,
348
- executionTimeMs: 0,
349
- });
350
- }
351
-
352
- /** Whether the adapter currently knows the remote sandbox to be running. */
353
- async isReady(): Promise<boolean> {
354
- if (this.activeFallback) {
355
- return this.activeFallback.isReady?.() ?? this.activeFallback.status === "running";
356
- }
357
- if (this.status === "running") return true;
358
- try {
359
- const sandbox = await this.get();
360
- this.status =
361
- sandbox.status?.state === "SANDBOX_STATE_RUNNING" ? "running" : remoteStatus(sandbox);
362
- return this.status === "running";
363
- } catch (caught) {
364
- if (isStatus(caught, 404)) return false;
365
- throw caught;
366
- }
367
- }
368
-
369
- /** Report Mastra-facing sandbox metadata. */
370
- async getInfo(): Promise<SandboxInfo> {
371
- if (this.activeFallback) {
372
- return (
373
- (await this.activeFallback.getInfo?.()) ?? {
374
- id: this.activeFallback.id,
375
- name: this.activeFallback.name,
376
- provider: this.activeFallback.provider,
377
- status: this.activeFallback.status,
378
- createdAt: this.createdAt,
379
- }
380
- );
381
- }
382
- const sandbox = await this.get();
383
- this.status = remoteStatus(sandbox);
384
- return {
385
- id: this.id,
386
- name: sandbox.display_name ?? this.name,
387
- provider: this.provider,
388
- status: this.status,
389
- createdAt: this.remoteCreatedAt ?? this.createdAt,
390
- ...(this.lastUsedAt ? { lastUsedAt: this.lastUsedAt } : {}),
391
- metadata: {
392
- resourceName: sandbox.name,
393
- remoteState: sandbox.status?.state,
394
- updateTime: sandbox.update_time,
395
- },
396
- };
397
- }
398
-
399
- /** Explain the Databricks execution boundary to the agent. */
400
- getInstructions(): string {
401
- return [
402
- "Commands run in a persistent Databricks serverless sandbox, not in the App container.",
403
- "Use shell commands normally. The sandbox home directory survives inactivity stops.",
404
- "If Databricks Sandbox is unavailable, commands fall back to Pydantic Monty and must be Python source.",
405
- "Databricks Workspace filesystem mounts remain separate unless a command copies data explicitly.",
406
- ].join(" ");
407
- }
408
-
409
- private get resourcePath(): string {
410
- return `${SANDBOX_API_PATH}/${encodeURIComponent(this.id)}`;
411
- }
412
-
413
- private async getOrCreate(signal?: AbortSignal) {
414
- try {
415
- return await this.get(signal);
416
- } catch (caught) {
417
- if (!isStatus(caught, 404)) throw caught;
418
- }
419
- try {
420
- const sandbox = await this.request(SANDBOX_API_PATH, "POST", SandboxResponseSchema, {
421
- query: { sandbox_id: this.id },
422
- payload: {
423
- display_name: this.displayName,
424
- spec: {
425
- compute: {
426
- inactivity_timeout: this.inactivityTimeout,
427
- },
428
- },
429
- },
430
- signal,
431
- });
432
- this.rememberCreatedAt(sandbox.create_time);
433
- return sandbox;
434
- } catch (caught) {
435
- if (!isStatus(caught, 409)) throw caught;
436
- return this.get(signal);
437
- }
438
- }
439
-
440
- private async get(signal?: AbortSignal) {
441
- const sandbox = await this.request(this.resourcePath, "GET", SandboxResponseSchema, {
442
- signal,
443
- });
444
- this.rememberCreatedAt(sandbox.create_time);
445
- return sandbox;
446
- }
447
-
448
- private mutate(operation: "start" | "stop", signal?: AbortSignal) {
449
- return this.request(`${this.resourcePath}/${operation}`, "POST", SandboxResponseSchema, {
450
- signal,
451
- });
452
- }
453
-
454
- private async waitForState(expected: string, signal?: AbortSignal) {
455
- const deadline = Date.now() + this.startupTimeoutMs;
456
- while (Date.now() < deadline) {
457
- const sandbox = await this.get(signal);
458
- if (sandbox.status?.state === expected) return sandbox;
459
- await asyncUtils.sleep(SANDBOX_POLL_INTERVAL_MS, signal);
460
- }
461
- throw new Error(
462
- `Databricks sandbox ${this.id} did not reach ${expected} within ${this.startupTimeoutMs}ms`,
463
- );
464
- }
465
-
466
- private rememberCreatedAt(value: string | undefined): void {
467
- if (!value) return;
468
- const parsed = new Date(value);
469
- if (!Number.isNaN(parsed.getTime())) this.remoteCreatedAt = parsed;
470
- }
471
-
472
- private async request<T>(
473
- path: string,
474
- method: "GET" | "POST" | "DELETE",
475
- schema: z.ZodType<T>,
476
- options: {
477
- payload?: unknown;
478
- query?: Record<string, string>;
479
- signal?: AbortSignal;
480
- } = {},
481
- ): Promise<T> {
482
- const response = await this.client.apiClient.request(
483
- {
484
- path,
485
- method,
486
- query: options.query,
487
- headers: new Headers({
488
- Accept: "application/json",
489
- ...(options.payload === undefined ? {} : { "Content-Type": "application/json" }),
490
- }),
491
- raw: false,
492
- ...(options.payload === undefined ? {} : { payload: options.payload }),
493
- },
494
- options.signal ? databricks.toContext(options.signal) : undefined,
495
- );
496
- return schema.parse(response);
497
- }
498
- }
499
-
500
- function commandScript(command: string, args: readonly string[], cwd: string | undefined): string {
501
- const invocation = args.length ? [command, ...args].map(shellQuote).join(" ") : command;
502
- return cwd ? `cd -- ${shellQuote(cwd)} && ${invocation}` : invocation;
503
- }
504
-
505
- function shellQuote(value: string): string {
506
- return `'${value.replaceAll("'", "'\\''")}'`;
507
- }
508
-
509
- function definedEnvironment(input: NodeJS.ProcessEnv | undefined): Record<string, string> {
510
- return Object.fromEntries(
511
- Object.entries(input ?? {}).filter(
512
- (entry): entry is [string, string] => entry[1] !== undefined,
513
- ),
514
- );
515
- }
516
-
517
- function isStatus(caught: unknown, status: number): boolean {
518
- return errorUtils.errorContext(caught).statusCode === status;
519
- }
520
-
521
- function remoteStatus(sandbox: z.infer<typeof SandboxResponseSchema>): ProviderStatus {
522
- switch (sandbox.status?.state) {
523
- case "SANDBOX_STATE_RUNNING":
524
- return "running";
525
- case "SANDBOX_STATE_STOPPED":
526
- return "stopped";
527
- case "SANDBOX_STATE_STOPPING":
528
- return "stopping";
529
- case "SANDBOX_STATE_PENDING":
530
- return "starting";
531
- default:
532
- return "pending";
533
- }
534
- }
535
-
536
- function resolveFallback(
537
- configured: Exclude<DatabricksSandboxOptions["fallback"], false | undefined>,
538
- ): WorkspaceSandbox {
539
- if (typeof configured === "object" && "provider" in configured && "status" in configured) {
540
- return configured;
541
- }
542
- return new MontySandbox(configured === "monty" ? {} : configured);
543
- }
544
-
545
- function isDatabricksSandboxUnavailable(caught: unknown): boolean {
546
- const context = errorUtils.errorContext(caught);
547
- return (
548
- context.statusCode === 404 ||
549
- context.hasMessage("requested", "api", "not", "available") ||
550
- context.hasMessage("feature", "disabled") ||
551
- context.hasMessage("sandbox", "not", "enabled") ||
552
- context.hasMessage("preview", "not", "enabled") ||
553
- context.hasMessage("preview", "unavailable") ||
554
- context.hasMessage("preview", "disabled")
555
- );
556
- }