@mastra/modal 0.4.0 → 0.5.0

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/dist/index.js CHANGED
@@ -1,416 +1,409 @@
1
- import { SandboxProcessManager, MastraSandbox, SandboxNotReadyError, ProcessHandle } from '@mastra/core/workspace';
2
- import { NotFoundError, ClientClosedError, ModalClient } from 'modal';
3
-
4
- // src/sandbox/index.ts
1
+ import { MastraSandbox, ProcessHandle, SandboxNotReadyError, SandboxProcessManager, UnsupportedStdinCloseError } from "@mastra/core/workspace";
2
+ import { ClientClosedError, ModalClient, NotFoundError } from "modal";
3
+ //#region src/sandbox/process-manager.ts
4
+ /**
5
+ * Modal process manager. Each spawn() creates a ContainerProcess via exec().
6
+ * kill() has no SDK equivalent — it cancels stream readers locally; the remote
7
+ * process runs until the sandbox timeout.
8
+ */
9
+ /**
10
+ * Wraps a Modal ContainerProcess to conform to Mastra's ProcessHandle.
11
+ */
5
12
  var ModalProcessHandle = class extends ProcessHandle {
6
- pid;
7
- _proc;
8
- _startTime;
9
- _timeout;
10
- _exitCode;
11
- _waitPromise = null;
12
- _streamingPromise = null;
13
- _killed = false;
14
- _stdoutReader = null;
15
- _stderrReader = null;
16
- constructor(pid, proc, startTime, options) {
17
- super(options);
18
- this.pid = pid;
19
- this._proc = proc;
20
- this._startTime = startTime;
21
- this._timeout = options?.timeout;
22
- }
23
- get exitCode() {
24
- return this._exitCode;
25
- }
26
- /** @internal Set by the process manager after streaming starts. */
27
- set streamingPromise(p) {
28
- this._streamingPromise = p;
29
- p.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
30
- }
31
- /** @internal Set by the process manager so kill() can cancel the readers. */
32
- setReaders(stdoutReader, stderrReader) {
33
- this._stdoutReader = stdoutReader;
34
- this._stderrReader = stderrReader;
35
- }
36
- /** Fetch the exit code from the Modal process. No-op if already set. */
37
- async _resolveExitCode() {
38
- if (this._exitCode !== void 0) return;
39
- try {
40
- this._exitCode = await this._proc.wait();
41
- } catch {
42
- if (this._exitCode === void 0) {
43
- this._exitCode = 1;
44
- }
45
- }
46
- }
47
- async wait() {
48
- if (!this._waitPromise) {
49
- this._waitPromise = this._doWait();
50
- }
51
- return this._waitPromise;
52
- }
53
- async _doWait() {
54
- const streamDone = this._streamingPromise ?? Promise.resolve();
55
- if (this._timeout) {
56
- let timeoutId;
57
- const timeoutPromise = new Promise((_, reject) => {
58
- timeoutId = setTimeout(() => reject(new Error(`Command timed out after ${this._timeout}ms`)), this._timeout);
59
- });
60
- try {
61
- await Promise.race([streamDone, timeoutPromise]);
62
- } catch (error) {
63
- if (error instanceof Error && error.message.includes("timed out")) {
64
- await this.kill();
65
- this._exitCode = 124;
66
- return {
67
- success: false,
68
- exitCode: 124,
69
- stdout: this.stdout,
70
- stderr: this.stderr || error.message,
71
- executionTimeMs: Date.now() - this._startTime,
72
- killed: true,
73
- timedOut: true
74
- };
75
- }
76
- throw error;
77
- } finally {
78
- clearTimeout(timeoutId);
79
- }
80
- } else {
81
- await streamDone.catch(() => {
82
- });
83
- }
84
- if (this._killed) {
85
- return {
86
- success: false,
87
- exitCode: this._exitCode ?? 137,
88
- stdout: this.stdout,
89
- stderr: this.stderr,
90
- executionTimeMs: Date.now() - this._startTime,
91
- killed: true,
92
- timedOut: false
93
- };
94
- }
95
- await this._resolveExitCode();
96
- return {
97
- success: this._exitCode === 0,
98
- exitCode: this._exitCode ?? 1,
99
- stdout: this.stdout,
100
- stderr: this.stderr,
101
- executionTimeMs: Date.now() - this._startTime
102
- };
103
- }
104
- async kill() {
105
- if (this._exitCode !== void 0) return false;
106
- this._killed = true;
107
- this._exitCode = 137;
108
- try {
109
- await this._stdoutReader?.cancel();
110
- } catch {
111
- }
112
- try {
113
- await this._stderrReader?.cancel();
114
- } catch {
115
- }
116
- return true;
117
- }
118
- async sendStdin(_data) {
119
- throw new Error("Modal JS SDK does not expose stdin on exec() \u2014 sendStdin() is not supported");
120
- }
13
+ pid;
14
+ _proc;
15
+ _startTime;
16
+ _timeout;
17
+ _exitCode;
18
+ _waitPromise = null;
19
+ _streamingPromise = null;
20
+ _killed = false;
21
+ _stdoutReader = null;
22
+ _stderrReader = null;
23
+ constructor(pid, proc, startTime, options) {
24
+ super(options);
25
+ this.pid = pid;
26
+ this._proc = proc;
27
+ this._startTime = startTime;
28
+ this._timeout = options?.timeout;
29
+ }
30
+ get exitCode() {
31
+ return this._exitCode;
32
+ }
33
+ /** @internal Set by the process manager after streaming starts. */
34
+ set streamingPromise(p) {
35
+ this._streamingPromise = p;
36
+ p.then(() => this._resolveExitCode()).catch(() => this._resolveExitCode());
37
+ }
38
+ /** @internal Set by the process manager so kill() can cancel the readers. */
39
+ setReaders(stdoutReader, stderrReader) {
40
+ this._stdoutReader = stdoutReader;
41
+ this._stderrReader = stderrReader;
42
+ }
43
+ /** Fetch the exit code from the Modal process. No-op if already set. */
44
+ async _resolveExitCode() {
45
+ if (this._exitCode !== void 0) return;
46
+ try {
47
+ this._exitCode = await this._proc.wait();
48
+ } catch {
49
+ if (this._exitCode === void 0) this._exitCode = 1;
50
+ }
51
+ }
52
+ async wait() {
53
+ if (!this._waitPromise) this._waitPromise = this._doWait();
54
+ return this._waitPromise;
55
+ }
56
+ async _doWait() {
57
+ const streamDone = this._streamingPromise ?? Promise.resolve();
58
+ if (this._timeout) {
59
+ let timeoutId;
60
+ const timeoutPromise = new Promise((_, reject) => {
61
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Command timed out after ${this._timeout}ms`)), this._timeout);
62
+ });
63
+ try {
64
+ await Promise.race([streamDone, timeoutPromise]);
65
+ } catch (error) {
66
+ if (error instanceof Error && error.message.includes("timed out")) {
67
+ await this.kill();
68
+ this._exitCode = 124;
69
+ return {
70
+ success: false,
71
+ exitCode: 124,
72
+ stdout: this.stdout,
73
+ stderr: this.stderr || error.message,
74
+ executionTimeMs: Date.now() - this._startTime,
75
+ killed: true,
76
+ timedOut: true
77
+ };
78
+ }
79
+ throw error;
80
+ } finally {
81
+ clearTimeout(timeoutId);
82
+ }
83
+ } else await streamDone.catch(() => {});
84
+ if (this._killed) return {
85
+ success: false,
86
+ exitCode: this._exitCode ?? 137,
87
+ stdout: this.stdout,
88
+ stderr: this.stderr,
89
+ executionTimeMs: Date.now() - this._startTime,
90
+ killed: true,
91
+ timedOut: false
92
+ };
93
+ await this._resolveExitCode();
94
+ return {
95
+ success: this._exitCode === 0,
96
+ exitCode: this._exitCode ?? 1,
97
+ stdout: this.stdout,
98
+ stderr: this.stderr,
99
+ executionTimeMs: Date.now() - this._startTime
100
+ };
101
+ }
102
+ async kill() {
103
+ if (this._exitCode !== void 0) return false;
104
+ this._killed = true;
105
+ this._exitCode = 137;
106
+ try {
107
+ await this._stdoutReader?.cancel();
108
+ } catch {}
109
+ try {
110
+ await this._stderrReader?.cancel();
111
+ } catch {}
112
+ return true;
113
+ }
114
+ async sendStdin(_data) {
115
+ throw new Error("Modal JS SDK does not expose stdin on exec() — sendStdin() is not supported");
116
+ }
117
+ async closeStdin() {
118
+ throw new UnsupportedStdinCloseError("Modal JS SDK does not expose stdin on exec() — closeStdin() is not supported");
119
+ }
121
120
  };
121
+ /**
122
+ * Modal implementation of SandboxProcessManager.
123
+ * Uses the Modal SDK's exec() API with one ContainerProcess per spawn.
124
+ */
122
125
  var ModalProcessManager = class extends SandboxProcessManager {
123
- _spawnCounter = 0;
124
- constructor(opts = {}) {
125
- super({ env: opts.env });
126
- }
127
- async spawn(command, options = {}) {
128
- return this.sandbox.retryOnDead(async () => {
129
- const sb = this.sandbox.modal;
130
- const mergedEnv = { ...this.env, ...options.env };
131
- const env = Object.fromEntries(
132
- Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0)
133
- );
134
- const argv = ["sh", "-c", command];
135
- const proc = await sb.exec(argv, {
136
- env: Object.keys(env).length > 0 ? env : void 0,
137
- workdir: options.cwd,
138
- timeoutMs: options.timeout
139
- });
140
- const pid = `modal-proc-${Date.now().toString(36)}-${++this._spawnCounter}`;
141
- const handle = new ModalProcessHandle(pid, proc, Date.now(), options);
142
- const stdoutReader = proc.stdout.getReader();
143
- const stderrReader = proc.stderr.getReader();
144
- handle.setReaders(stdoutReader, stderrReader);
145
- const streamingPromise = Promise.all([
146
- drainReader(stdoutReader, (chunk) => handle.emitStdout(chunk)),
147
- drainReader(stderrReader, (chunk) => handle.emitStderr(chunk))
148
- ]).then(() => {
149
- });
150
- handle.streamingPromise = streamingPromise;
151
- this._tracked.set(pid, handle);
152
- return handle;
153
- });
154
- }
155
- async list() {
156
- const result = [];
157
- for (const [pid, handle] of this._tracked) {
158
- result.push({
159
- pid,
160
- command: handle.command,
161
- running: handle.exitCode === void 0,
162
- exitCode: handle.exitCode
163
- });
164
- }
165
- return result;
166
- }
126
+ _spawnCounter = 0;
127
+ constructor(opts = {}) {
128
+ super({ env: opts.env });
129
+ }
130
+ async spawn(command, options = {}) {
131
+ return this.sandbox.retryOnDead(async () => {
132
+ const sb = this.sandbox.modal;
133
+ const mergedEnv = {
134
+ ...this.env,
135
+ ...options.env
136
+ };
137
+ const env = Object.fromEntries(Object.entries(mergedEnv).filter((entry) => entry[1] !== void 0));
138
+ const argv = [
139
+ "sh",
140
+ "-c",
141
+ command
142
+ ];
143
+ const proc = await sb.exec(argv, {
144
+ env: Object.keys(env).length > 0 ? env : void 0,
145
+ workdir: options.cwd,
146
+ timeoutMs: options.timeout
147
+ });
148
+ const pid = `modal-proc-${Date.now().toString(36)}-${++this._spawnCounter}`;
149
+ const handle = new ModalProcessHandle(pid, proc, Date.now(), options);
150
+ const stdoutReader = proc.stdout.getReader();
151
+ const stderrReader = proc.stderr.getReader();
152
+ handle.setReaders(stdoutReader, stderrReader);
153
+ handle.streamingPromise = Promise.all([drainReader(stdoutReader, (chunk) => handle.emitStdout(chunk)), drainReader(stderrReader, (chunk) => handle.emitStderr(chunk))]).then(() => {});
154
+ this._tracked.set(pid, handle);
155
+ return handle;
156
+ });
157
+ }
158
+ async list() {
159
+ const result = [];
160
+ for (const [pid, handle] of this._tracked) result.push({
161
+ pid,
162
+ command: handle.command,
163
+ running: handle.exitCode === void 0,
164
+ exitCode: handle.exitCode
165
+ });
166
+ return result;
167
+ }
167
168
  };
169
+ /** Reads chunks from a stream until done or cancelled. */
168
170
  async function drainReader(reader, emit) {
169
- try {
170
- while (true) {
171
- const { done, value } = await reader.read();
172
- if (done) break;
173
- emit(value);
174
- }
175
- } catch {
176
- }
171
+ try {
172
+ while (true) {
173
+ const { done, value } = await reader.read();
174
+ if (done) break;
175
+ emit(value);
176
+ }
177
+ } catch {}
177
178
  }
178
-
179
- // src/sandbox/index.ts
180
- var LOG_PREFIX = "[ModalSandbox]";
181
- var ModalSandbox = class _ModalSandbox extends MastraSandbox {
182
- id;
183
- name = "ModalSandbox";
184
- provider = "modal";
185
- status = "pending";
186
- _sb = null;
187
- _imageSnapshot = null;
188
- // for stop-and-resume
189
- _client = null;
190
- _createdAt = null;
191
- _isRetrying = false;
192
- appName;
193
- baseImage;
194
- timeoutMs;
195
- env;
196
- workdir;
197
- tokenId;
198
- tokenSecret;
199
- _instructionsOverride;
200
- _constructorOptions;
201
- constructor(options = {}) {
202
- super({
203
- ...options,
204
- name: "ModalSandbox",
205
- processes: new ModalProcessManager({ env: options.env ?? {} })
206
- });
207
- this.id = options.id ?? this._generateId();
208
- this.appName = options.appName ?? "mastra";
209
- this.baseImage = options.baseImage ?? "ubuntu:22.04";
210
- this.timeoutMs = options.timeoutMs ?? 3e5;
211
- this.env = options.env ?? {};
212
- this.workdir = options.workdir;
213
- this.tokenId = options.tokenId;
214
- this.tokenSecret = options.tokenSecret;
215
- this._instructionsOverride = options.instructions;
216
- this._constructorOptions = { ...options };
217
- }
218
- /**
219
- * Construct a sibling `ModalSandbox` that inherits this sandbox's
220
- * configuration (credentials, app, base image, workdir, instructions) with
221
- * per-instance overrides.
222
- *
223
- * Performs no I/O — the sandbox clone provisions (or reconnects to a
224
- * running Modal sandbox with the same logical `id`) on its own `start()`.
225
- * Use it when one configured sandbox acts as the template for a fleet of
226
- * independent sandboxes (e.g. one per project).
227
- *
228
- * `options.idleTimeoutMinutes` maps to Modal's `timeoutMs` (wall-clock
229
- * lifetime); `options.sandboxId` is ignored because Modal reconnects by
230
- * logical `id`.
231
- */
232
- clone(options = {}) {
233
- const { id: _id, ...base } = this._constructorOptions;
234
- return new _ModalSandbox({
235
- ...base,
236
- ...options.id !== void 0 && { id: options.id },
237
- ...options.env !== void 0 && { env: options.env },
238
- ...options.idleTimeoutMinutes !== void 0 && { timeoutMs: options.idleTimeoutMinutes * 6e4 }
239
- });
240
- }
241
- /**
242
- * Get the underlying Modal Sandbox instance for direct SDK access.
243
- *
244
- * @throws {SandboxNotReadyError} If the sandbox has not been started.
245
- */
246
- get modal() {
247
- if (!this._sb) {
248
- throw new SandboxNotReadyError(this.id);
249
- }
250
- return this._sb;
251
- }
252
- // ---------------------------------------------------------------------------
253
- // Lifecycle
254
- // ---------------------------------------------------------------------------
255
- /** Reconnects to a running sandbox with this id if one exists, otherwise creates a new one. */
256
- async start() {
257
- if (this._sb) {
258
- return;
259
- }
260
- const client = this._getClient();
261
- try {
262
- this._sb = await client.sandboxes.fromName(this.appName, this.id);
263
- this._createdAt = /* @__PURE__ */ new Date();
264
- this.logger.debug(`${LOG_PREFIX} Reconnected to running sandbox: ${this.id}`);
265
- return;
266
- } catch (error) {
267
- if (!(error instanceof NotFoundError)) {
268
- throw error;
269
- }
270
- }
271
- const app = await client.apps.fromName(this.appName, { createIfMissing: true });
272
- if (this._imageSnapshot) {
273
- this.logger.debug(`${LOG_PREFIX} Rebooting from snapshot: ${this.id}`);
274
- this._sb = await client.sandboxes.create(app, this._imageSnapshot, {
275
- name: this.id,
276
- timeoutMs: this.timeoutMs,
277
- env: Object.keys(this.env).length > 0 ? this.env : void 0,
278
- workdir: this.workdir
279
- });
280
- this._createdAt = /* @__PURE__ */ new Date();
281
- this.logger.debug(`${LOG_PREFIX} Created new sandbox from snapshot: ${this._sb?.sandboxId}`);
282
- return;
283
- }
284
- const image = client.images.fromRegistry(this.baseImage);
285
- this.logger.debug(`${LOG_PREFIX} Creating sandbox: ${this.id} (baseImage: ${this.baseImage})`);
286
- this._sb = await client.sandboxes.create(app, image, {
287
- name: this.id,
288
- timeoutMs: this.timeoutMs,
289
- env: Object.keys(this.env).length > 0 ? this.env : void 0,
290
- workdir: this.workdir
291
- });
292
- this._createdAt = /* @__PURE__ */ new Date();
293
- this.logger.debug(`${LOG_PREFIX} Created sandbox: ${this._sb.sandboxId}`);
294
- }
295
- /**
296
- * Snapshot the sandbox filesystem before terminating it.
297
- * Future starts will create net-new sandboxes from the snapshot.
298
- */
299
- async stop() {
300
- if (!this._sb) return;
301
- try {
302
- const procs = await this.processes.list();
303
- await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
304
- } catch {
305
- }
306
- try {
307
- this._imageSnapshot = await this._sb.snapshotFilesystem();
308
- this.logger.debug(`${LOG_PREFIX} Snapshot created: ${this._imageSnapshot.imageId}`);
309
- } catch (error) {
310
- this.logger.debug(`${LOG_PREFIX} Snapshot failed, terminating without snapshot:`, error);
311
- }
312
- try {
313
- await this._sb.terminate({ wait: true });
314
- this.logger.debug(`${LOG_PREFIX} Sandbox terminated: ${this._sb.sandboxId}`);
315
- this._sb = null;
316
- } catch (error) {
317
- if (this.isSandboxDeadError(error)) {
318
- this._sb = null;
319
- } else {
320
- throw error;
321
- }
322
- }
323
- }
324
- /** Terminates the sandbox, ending its lifetime. Unlike stop(), no snapshot is preserved. */
325
- async destroy() {
326
- if (this._sb) {
327
- try {
328
- const procs = await this.processes.list();
329
- await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
330
- } catch {
331
- }
332
- try {
333
- await this._sb.terminate();
334
- this.logger.debug(`${LOG_PREFIX} Sandbox terminated: ${this._sb.sandboxId}`);
335
- } catch {
336
- }
337
- this._sb = null;
338
- }
339
- this._imageSnapshot = null;
340
- }
341
- async getInfo() {
342
- return {
343
- id: this.id,
344
- name: this.name,
345
- provider: this.provider,
346
- status: this.status,
347
- createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
348
- metadata: {
349
- appName: this.appName,
350
- image: this._imageSnapshot?.imageId ?? this.baseImage,
351
- timeoutMs: this.timeoutMs
352
- }
353
- };
354
- }
355
- getInstructions() {
356
- const defaultInstructions = this._getDefaultInstructions();
357
- if (this._instructionsOverride === void 0) return defaultInstructions;
358
- if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
359
- return this._instructionsOverride({ defaultInstructions });
360
- }
361
- _getDefaultInstructions() {
362
- return `Modal cloud sandbox running ${this.baseImage}. Use executeCommand() to run shell commands.`;
363
- }
364
- // ---------------------------------------------------------------------------
365
- // Dead-sandbox Retry
366
- // ---------------------------------------------------------------------------
367
- isSandboxDeadError(error) {
368
- if (!error) return false;
369
- if (error instanceof ClientClosedError) return true;
370
- if (error instanceof NotFoundError) return true;
371
- const errorStr = String(error);
372
- return errorStr.includes("sandbox not found") || errorStr.includes("has been terminated") || errorStr.includes("already completed") || errorStr.includes("was cancelled") || // gRPC NOT_FOUND (code 5)
373
- /status[:\s]+5\b/.test(errorStr) || errorStr.includes("NOT_FOUND");
374
- }
375
- handleSandboxDead() {
376
- this._sb = null;
377
- this.status = "stopped";
378
- }
379
- /** @internal Retries fn() once after restarting if the sandbox is dead. */
380
- async retryOnDead(fn) {
381
- try {
382
- return await fn();
383
- } catch (error) {
384
- if (this.isSandboxDeadError(error) && !this._isRetrying) {
385
- this.handleSandboxDead();
386
- this._isRetrying = true;
387
- try {
388
- await this.ensureRunning();
389
- return await fn();
390
- } finally {
391
- this._isRetrying = false;
392
- }
393
- }
394
- throw error;
395
- }
396
- }
397
- // ---------------------------------------------------------------------------
398
- // Internal Helpers
399
- // ---------------------------------------------------------------------------
400
- _generateId() {
401
- return `modal-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
402
- }
403
- _getClient() {
404
- if (!this._client) {
405
- this._client = new ModalClient({
406
- ...this.tokenId && { tokenId: this.tokenId },
407
- ...this.tokenSecret && { tokenSecret: this.tokenSecret }
408
- });
409
- }
410
- return this._client;
411
- }
179
+ //#endregion
180
+ //#region src/sandbox/index.ts
181
+ const LOG_PREFIX = "[ModalSandbox]";
182
+ /**
183
+ * Modal cloud sandbox provider for Mastra workspaces.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * import { Workspace } from '@mastra/core/workspace';
188
+ * import { ModalSandbox } from '@mastra/modal';
189
+ *
190
+ * const sandbox = new ModalSandbox({
191
+ * baseImage: 'ubuntu:22.04',
192
+ * timeoutMs: 60_000,
193
+ * });
194
+ *
195
+ * const workspace = new Workspace({ sandbox });
196
+ * const result = await workspace.executeCommand('echo hello');
197
+ * ```
198
+ */
199
+ var ModalSandbox = class ModalSandbox extends MastraSandbox {
200
+ id;
201
+ name = "ModalSandbox";
202
+ provider = "modal";
203
+ status = "pending";
204
+ _sb = null;
205
+ _imageSnapshot = null;
206
+ _client = null;
207
+ _createdAt = null;
208
+ _isRetrying = false;
209
+ appName;
210
+ baseImage;
211
+ timeoutMs;
212
+ env;
213
+ workdir;
214
+ tokenId;
215
+ tokenSecret;
216
+ _instructionsOverride;
217
+ _constructorOptions;
218
+ constructor(options = {}) {
219
+ super({
220
+ ...options,
221
+ name: "ModalSandbox",
222
+ processes: new ModalProcessManager({ env: options.env ?? {} })
223
+ });
224
+ this.id = options.id ?? this._generateId();
225
+ this.appName = options.appName ?? "mastra";
226
+ this.baseImage = options.baseImage ?? "ubuntu:22.04";
227
+ this.timeoutMs = options.timeoutMs ?? 3e5;
228
+ this.env = options.env ?? {};
229
+ this.workdir = options.workdir;
230
+ this.tokenId = options.tokenId;
231
+ this.tokenSecret = options.tokenSecret;
232
+ this._instructionsOverride = options.instructions;
233
+ this._constructorOptions = { ...options };
234
+ }
235
+ /**
236
+ * Construct a sibling `ModalSandbox` that inherits this sandbox's
237
+ * configuration (credentials, app, base image, workdir, instructions) with
238
+ * per-instance overrides.
239
+ *
240
+ * Performs no I/O — the sandbox clone provisions (or reconnects to a
241
+ * running Modal sandbox with the same logical `id`) on its own `start()`.
242
+ * Use it when one configured sandbox acts as the template for a fleet of
243
+ * independent sandboxes (e.g. one per project).
244
+ *
245
+ * `options.idleTimeoutMinutes` maps to Modal's `timeoutMs` (wall-clock
246
+ * lifetime); `options.sandboxId` is ignored because Modal reconnects by
247
+ * logical `id`.
248
+ */
249
+ clone(options = {}) {
250
+ const { id: _id, ...base } = this._constructorOptions;
251
+ return new ModalSandbox({
252
+ ...base,
253
+ ...options.id !== void 0 && { id: options.id },
254
+ ...options.env !== void 0 && { env: options.env },
255
+ ...options.idleTimeoutMinutes !== void 0 && { timeoutMs: options.idleTimeoutMinutes * 6e4 }
256
+ });
257
+ }
258
+ /**
259
+ * Get the underlying Modal Sandbox instance for direct SDK access.
260
+ *
261
+ * @throws {SandboxNotReadyError} If the sandbox has not been started.
262
+ */
263
+ get modal() {
264
+ if (!this._sb) throw new SandboxNotReadyError(this.id);
265
+ return this._sb;
266
+ }
267
+ /** Reconnects to a running sandbox with this id if one exists, otherwise creates a new one. */
268
+ async start() {
269
+ if (this._sb) return;
270
+ const client = this._getClient();
271
+ try {
272
+ this._sb = await client.sandboxes.fromName(this.appName, this.id);
273
+ this._createdAt = /* @__PURE__ */ new Date();
274
+ this.logger.debug(`${LOG_PREFIX} Reconnected to running sandbox: ${this.id}`);
275
+ return;
276
+ } catch (error) {
277
+ if (!(error instanceof NotFoundError)) throw error;
278
+ }
279
+ const app = await client.apps.fromName(this.appName, { createIfMissing: true });
280
+ if (this._imageSnapshot) {
281
+ this.logger.debug(`${LOG_PREFIX} Rebooting from snapshot: ${this.id}`);
282
+ this._sb = await client.sandboxes.create(app, this._imageSnapshot, {
283
+ name: this.id,
284
+ timeoutMs: this.timeoutMs,
285
+ env: Object.keys(this.env).length > 0 ? this.env : void 0,
286
+ workdir: this.workdir
287
+ });
288
+ this._createdAt = /* @__PURE__ */ new Date();
289
+ this.logger.debug(`${LOG_PREFIX} Created new sandbox from snapshot: ${this._sb?.sandboxId}`);
290
+ return;
291
+ }
292
+ const image = client.images.fromRegistry(this.baseImage);
293
+ this.logger.debug(`${LOG_PREFIX} Creating sandbox: ${this.id} (baseImage: ${this.baseImage})`);
294
+ this._sb = await client.sandboxes.create(app, image, {
295
+ name: this.id,
296
+ timeoutMs: this.timeoutMs,
297
+ env: Object.keys(this.env).length > 0 ? this.env : void 0,
298
+ workdir: this.workdir
299
+ });
300
+ this._createdAt = /* @__PURE__ */ new Date();
301
+ this.logger.debug(`${LOG_PREFIX} Created sandbox: ${this._sb.sandboxId}`);
302
+ }
303
+ /**
304
+ * Snapshot the sandbox filesystem before terminating it.
305
+ * Future starts will create net-new sandboxes from the snapshot.
306
+ */
307
+ async stop() {
308
+ if (!this._sb) return;
309
+ try {
310
+ const procs = await this.processes.list();
311
+ await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
312
+ } catch {}
313
+ try {
314
+ this._imageSnapshot = await this._sb.snapshotFilesystem();
315
+ this.logger.debug(`${LOG_PREFIX} Snapshot created: ${this._imageSnapshot.imageId}`);
316
+ } catch (error) {
317
+ this.logger.debug(`${LOG_PREFIX} Snapshot failed, terminating without snapshot:`, error);
318
+ }
319
+ try {
320
+ await this._sb.terminate({ wait: true });
321
+ this.logger.debug(`${LOG_PREFIX} Sandbox terminated: ${this._sb.sandboxId}`);
322
+ this._sb = null;
323
+ } catch (error) {
324
+ if (this.isSandboxDeadError(error)) this._sb = null;
325
+ else throw error;
326
+ }
327
+ }
328
+ /** Terminates the sandbox, ending its lifetime. Unlike stop(), no snapshot is preserved. */
329
+ async destroy() {
330
+ if (this._sb) {
331
+ try {
332
+ const procs = await this.processes.list();
333
+ await Promise.all(procs.filter((p) => p.running).map((p) => this.processes.kill(p.pid)));
334
+ } catch {}
335
+ try {
336
+ await this._sb.terminate();
337
+ this.logger.debug(`${LOG_PREFIX} Sandbox terminated: ${this._sb.sandboxId}`);
338
+ } catch {}
339
+ this._sb = null;
340
+ }
341
+ this._imageSnapshot = null;
342
+ }
343
+ async getInfo() {
344
+ return {
345
+ id: this.id,
346
+ name: this.name,
347
+ provider: this.provider,
348
+ status: this.status,
349
+ createdAt: this._createdAt ?? /* @__PURE__ */ new Date(),
350
+ metadata: {
351
+ appName: this.appName,
352
+ image: this._imageSnapshot?.imageId ?? this.baseImage,
353
+ timeoutMs: this.timeoutMs
354
+ }
355
+ };
356
+ }
357
+ getInstructions() {
358
+ const defaultInstructions = this._getDefaultInstructions();
359
+ if (this._instructionsOverride === void 0) return defaultInstructions;
360
+ if (typeof this._instructionsOverride === "string") return this._instructionsOverride;
361
+ return this._instructionsOverride({ defaultInstructions });
362
+ }
363
+ _getDefaultInstructions() {
364
+ return `Modal cloud sandbox running ${this.baseImage}. Use executeCommand() to run shell commands.`;
365
+ }
366
+ isSandboxDeadError(error) {
367
+ if (!error) return false;
368
+ if (error instanceof ClientClosedError) return true;
369
+ if (error instanceof NotFoundError) return true;
370
+ const errorStr = String(error);
371
+ return errorStr.includes("sandbox not found") || errorStr.includes("has been terminated") || errorStr.includes("already completed") || errorStr.includes("was cancelled") || /status[:\s]+5\b/.test(errorStr) || errorStr.includes("NOT_FOUND");
372
+ }
373
+ handleSandboxDead() {
374
+ this._sb = null;
375
+ this.status = "stopped";
376
+ }
377
+ /** @internal Retries fn() once after restarting if the sandbox is dead. */
378
+ async retryOnDead(fn) {
379
+ try {
380
+ return await fn();
381
+ } catch (error) {
382
+ if (this.isSandboxDeadError(error) && !this._isRetrying) {
383
+ this.handleSandboxDead();
384
+ this._isRetrying = true;
385
+ try {
386
+ await this.ensureRunning();
387
+ return await fn();
388
+ } finally {
389
+ this._isRetrying = false;
390
+ }
391
+ }
392
+ throw error;
393
+ }
394
+ }
395
+ _generateId() {
396
+ return `modal-sandbox-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
397
+ }
398
+ _getClient() {
399
+ if (!this._client) this._client = new ModalClient({
400
+ ...this.tokenId && { tokenId: this.tokenId },
401
+ ...this.tokenSecret && { tokenSecret: this.tokenSecret }
402
+ });
403
+ return this._client;
404
+ }
412
405
  };
413
-
406
+ //#endregion
414
407
  export { ModalProcessManager, ModalSandbox };
415
- //# sourceMappingURL=index.js.map
408
+
416
409
  //# sourceMappingURL=index.js.map