@agent-relay/sandbox 0.0.0 → 0.1.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.
@@ -0,0 +1,765 @@
1
+ export class SnapshotNotFoundError extends Error {
2
+ snapshot;
3
+ constructor(snapshot, cause) {
4
+ super(`Snapshot not found in Daytona: '${snapshot}'. Refusing silent fallback to typescript base — fix DEFAULT_SNAPSHOT or rebuild/publish the snapshot before retrying.`, { cause });
5
+ this.name = 'SnapshotNotFoundError';
6
+ this.snapshot = snapshot;
7
+ }
8
+ }
9
+ // Upper bound on how many trailing bytes of a run's captured log file
10
+ // getScriptLogs pulls back into the polling process. A long run can emit MBs
11
+ // of stdout; `tail -c` bounds the read at the source so the poller never
12
+ // buffers the whole file. The failure is almost always at the END of the run,
13
+ // so the trailing bytes are the useful ones.
14
+ const SCRIPT_LOG_READ_MAX_BYTES = 262_144; // 256 KiB
15
+ const DEFAULT_DAYTONA_LOOKUP_TIMEOUT_MS = 10_000;
16
+ export class DaytonaRuntime {
17
+ id = 'daytona';
18
+ capabilities = {
19
+ pty: false,
20
+ snapshots: true,
21
+ isolation: 'strong',
22
+ persistentHandle: true,
23
+ streamingLogs: true,
24
+ };
25
+ sandboxes = new Map();
26
+ daytona;
27
+ snapshot;
28
+ defaultHomeDir;
29
+ constructor(options) {
30
+ this.daytona = options.daytona;
31
+ this.snapshot = options.snapshot;
32
+ this.defaultHomeDir = options.defaultHomeDir;
33
+ }
34
+ async launch(options = {}) {
35
+ const sandbox = await this.createSandbox(options);
36
+ const homeDir = await this.resolveHomeDir(sandbox);
37
+ return this.registerSandbox(sandbox, {
38
+ owned: true,
39
+ homeDir,
40
+ workdir: options.workdir,
41
+ });
42
+ }
43
+ async launchDetached(options = {}) {
44
+ const sandbox = await this.createSandboxDetached(options);
45
+ if (isRuntimeHandle(sandbox)) {
46
+ return {
47
+ ...sandbox,
48
+ ...(options.workdir ? { workdir: options.workdir } : {}),
49
+ };
50
+ }
51
+ return this.registerSandbox(sandbox, {
52
+ owned: true,
53
+ workdir: options.workdir,
54
+ });
55
+ }
56
+ async getById(id, options = {}) {
57
+ let sandbox;
58
+ try {
59
+ sandbox = await this.daytona.get(id);
60
+ }
61
+ catch (error) {
62
+ if (isDaytonaNotFoundError(error)) {
63
+ return null;
64
+ }
65
+ throw error;
66
+ }
67
+ const states = options.states === undefined ? null : options.states;
68
+ if (!this.matchesState(sandbox, states)) {
69
+ return null;
70
+ }
71
+ return this.registerSandbox(sandbox, {
72
+ owned: options.owned ?? false,
73
+ homeDir: options.homeDir,
74
+ workdir: options.workdir,
75
+ });
76
+ }
77
+ async findByLabels(labels, options = {}) {
78
+ const limit = options.limit ?? options.pageSize ?? 10;
79
+ const states = options.states === undefined ? ['STARTED'] : options.states;
80
+ const excludedIds = new Set(options.excludeIds ?? []);
81
+ const deadline = lookupDeadline(options.timeoutMs);
82
+ const iterator = this.listSandboxes(labels, { limit, states });
83
+ try {
84
+ for (;;) {
85
+ const next = await awaitLookupOperation(iterator.next(), deadline, 'listing matching sandboxes');
86
+ if (next.done) {
87
+ return null;
88
+ }
89
+ const listedSandbox = next.value;
90
+ if (!this.matchesState(listedSandbox, states) || excludedIds.has(listedSandbox.id)) {
91
+ continue;
92
+ }
93
+ // Daytona SDK 0.180 shares mutable client configuration across list()
94
+ // results. Rehydrate only the candidate we are about to return so its
95
+ // filesystem and process clients are pinned to the same sandbox ID.
96
+ let sandbox;
97
+ try {
98
+ sandbox = await awaitLookupOperation(this.daytona.get(listedSandbox.id), deadline, `rehydrating sandbox ${listedSandbox.id}`);
99
+ }
100
+ catch (error) {
101
+ if (isDaytonaNotFoundError(error)) {
102
+ continue;
103
+ }
104
+ throw error;
105
+ }
106
+ if (!this.matchesState(sandbox, states)) {
107
+ continue;
108
+ }
109
+ const homeDir = options.homeDir ?? await awaitLookupOperation(this.resolveHomeDir(sandbox), deadline, `resolving sandbox ${sandbox.id} home directory`);
110
+ return this.registerSandbox(sandbox, {
111
+ owned: options.owned ?? false,
112
+ homeDir,
113
+ workdir: options.workdir,
114
+ });
115
+ }
116
+ }
117
+ finally {
118
+ closeAsyncIteratorBestEffort(iterator);
119
+ }
120
+ }
121
+ async countByLabels(labels, options = {}) {
122
+ const limit = options.limit ?? options.pageSize ?? 10;
123
+ const states = options.states === undefined ? ['STARTED'] : options.states;
124
+ const maxCount = options.maxCount === undefined
125
+ ? Number.POSITIVE_INFINITY
126
+ : Math.max(0, Math.floor(options.maxCount));
127
+ if (maxCount === 0) {
128
+ return 0;
129
+ }
130
+ const deadline = lookupDeadline(options.timeoutMs);
131
+ const iterator = this.listSandboxes(labels, { limit, states });
132
+ let count = 0;
133
+ try {
134
+ for (;;) {
135
+ const next = await awaitLookupOperation(iterator.next(), deadline, 'counting matching sandboxes');
136
+ if (next.done) {
137
+ return count;
138
+ }
139
+ if (!this.matchesState(next.value, states)) {
140
+ continue;
141
+ }
142
+ count += 1;
143
+ if (count >= maxCount) {
144
+ return count;
145
+ }
146
+ }
147
+ }
148
+ finally {
149
+ closeAsyncIteratorBestEffort(iterator);
150
+ }
151
+ }
152
+ async findAllByLabels(labels, options = {}) {
153
+ const limit = options.limit ?? options.pageSize ?? 10;
154
+ const states = options.states === undefined ? ['STARTED'] : options.states;
155
+ const handles = [];
156
+ for await (const listedSandbox of this.listSandboxes(labels, { limit, states })) {
157
+ if (!this.matchesState(listedSandbox, states)) {
158
+ continue;
159
+ }
160
+ // Daytona SDK 0.180 reuses one mutable client config for every Sandbox
161
+ // yielded by list() (Daytona.js:375,408). Each Sandbox constructor
162
+ // rewrites its basePath (Sandbox.js:198-199), so serverless filesystem
163
+ // uploads read the last-listed sandbox ID (FileSystem.js:512) while
164
+ // process commands remain bound to the earlier sandbox. get() clones the
165
+ // config per Sandbox (Daytona.js:356); rehydrate before keeping any
166
+ // listed result so upload, verification, and execution share one ID.
167
+ let sandbox;
168
+ try {
169
+ sandbox = await this.daytona.get(listedSandbox.id);
170
+ }
171
+ catch (error) {
172
+ if (isDaytonaNotFoundError(error)) {
173
+ continue;
174
+ }
175
+ throw error;
176
+ }
177
+ if (!this.matchesState(sandbox, states)) {
178
+ continue;
179
+ }
180
+ handles.push(this.registerSandbox(sandbox, {
181
+ owned: options.owned ?? false,
182
+ homeDir: options.homeDir,
183
+ workdir: options.workdir,
184
+ }));
185
+ }
186
+ return handles;
187
+ }
188
+ attachSandbox(sandbox, options = {}) {
189
+ return this.registerSandbox(sandbox, {
190
+ owned: options.owned ?? false,
191
+ homeDir: options.homeDir,
192
+ workdir: options.workdir,
193
+ });
194
+ }
195
+ async exec(handle, command, options = {}) {
196
+ const sandbox = this.requireSandbox(handle);
197
+ const result = await sandbox.process.executeCommand(command, options.cwd, options.env, this.msToSeconds(options.timeoutMs));
198
+ return {
199
+ output: result.result ?? '',
200
+ exitCode: result.exitCode ?? 0,
201
+ };
202
+ }
203
+ async runScript(handle, options) {
204
+ const sandbox = this.requireSandbox(handle);
205
+ const command = this.buildScriptCommand(options);
206
+ const timeoutSeconds = this.msToSeconds(options.timeoutMs);
207
+ const useSession = options.useSession ?? true;
208
+ if (useSession) {
209
+ if (!this.supportsSessionExec(sandbox)) {
210
+ throw new Error('Daytona session execution is not available on this sandbox');
211
+ }
212
+ const sessionId = options.sessionId ?? `run-${handle.id}-${Date.now()}`;
213
+ await sandbox.process.createSession(sessionId);
214
+ const result = await sandbox.process.executeSessionCommand(sessionId, {
215
+ command,
216
+ runAsync: false,
217
+ suppressInputEcho: options.suppressInputEcho,
218
+ }, timeoutSeconds);
219
+ return {
220
+ output: result.output ?? result.stdout ?? result.stderr ?? '',
221
+ ...(result.stdout ? { stdout: result.stdout } : {}),
222
+ ...(result.stderr ? { stderr: result.stderr } : {}),
223
+ exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
224
+ ...(result.cmdId ? { cmdId: result.cmdId } : {}),
225
+ };
226
+ }
227
+ const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
228
+ return {
229
+ output: result.result ?? result.artifacts?.stdout ?? '',
230
+ ...(result.artifacts?.stdout ? { stdout: result.artifacts.stdout } : {}),
231
+ exitCode: typeof result.exitCode === 'number' ? result.exitCode : null,
232
+ };
233
+ }
234
+ async startScript(handle, options) {
235
+ const sandbox = this.requireSandbox(handle);
236
+ if (!this.supportsSessionExec(sandbox)) {
237
+ throw new Error('Daytona session execution is not available on this sandbox');
238
+ }
239
+ const sessionId = options.sessionId ?? `run-${handle.id}-${Date.now()}`;
240
+ await sandbox.process.createSession(sessionId);
241
+ // Capture the run's combined stdout+stderr to a per-session log file.
242
+ //
243
+ // Daytona's REST `getSessionCommandLogs` snapshot returns EMPTY for
244
+ // `runAsync: true` commands — the command RECORD keeps the exitCode (so
245
+ // getScriptStatus works), but the log BODY is only retrievable via the
246
+ // follow=true WebSocket stream, which the SDK implements with
247
+ // `isomorphic-ws` → node `ws`, so it does NOT run on edge runtimes without
248
+ // WebSocket client support — which is where these runs are typically
249
+ // polled from. Without capture, every poll reads empty output and a
250
+ // failing run surfaces only as the bare "runner.mjs failed" fallback
251
+ // string.
252
+ //
253
+ // A command group redirects only this run's script and leaves the session's
254
+ // stdout/stderr intact for the later synchronous `tail` read. Its exit code
255
+ // is the exit code of the last command in the group, so this still preserves
256
+ // the script status. getScriptLogs reads this file back.
257
+ const logPath = this.scriptLogPath(sessionId);
258
+ const command = `{\n${this.buildScriptCommand(options)}\n} > ${shellSingleQuote(logPath)} 2>&1`;
259
+ const result = await sandbox.process.executeSessionCommand(sessionId, {
260
+ command,
261
+ runAsync: true,
262
+ suppressInputEcho: options.suppressInputEcho,
263
+ }, this.msToSeconds(options.timeoutMs));
264
+ if (!result.cmdId) {
265
+ throw new Error('Daytona async session command did not return a command id');
266
+ }
267
+ return { sessionId, commandId: result.cmdId };
268
+ }
269
+ async getScriptStatus(handle, sessionId, commandId) {
270
+ const sandbox = this.requireSandbox(handle);
271
+ if (!this.supportsSessionExec(sandbox)) {
272
+ throw new Error('Daytona session execution is not available on this sandbox');
273
+ }
274
+ const command = await sandbox.process.getSessionCommand(sessionId, commandId);
275
+ return {
276
+ exitCode: typeof command.exitCode === 'number' ? command.exitCode : null,
277
+ };
278
+ }
279
+ async getScriptLogs(handle, sessionId, commandId) {
280
+ const sandbox = this.requireSandbox(handle);
281
+ if (!this.supportsSessionExec(sandbox)) {
282
+ throw new Error('Daytona session execution is not available on this sandbox');
283
+ }
284
+ const logs = await sandbox.process.getSessionCommandLogs(sessionId, commandId);
285
+ let output = logs.output ?? logs.stdout ?? logs.stderr ?? '';
286
+ // Fallback for runAsync commands whose snapshot logs come back empty (see
287
+ // startScript): read the per-session redirect file we captured. Bounded at
288
+ // the source with `tail -c` so a multi-MB run can't pull the whole file
289
+ // into the poller. The read uses the sync (runAsync:false) path, which
290
+ // returns output inline over REST and works on runtimes without WebSocket
291
+ // support. Best-effort: a recycled sandbox / missing file / closed session
292
+ // yields empty — exactly the same blind state as before, never a throw.
293
+ if (!output) {
294
+ try {
295
+ const logPath = this.scriptLogPath(sessionId);
296
+ const fileLogs = await sandbox.process.executeSessionCommand(sessionId, {
297
+ command: `tail -c ${SCRIPT_LOG_READ_MAX_BYTES} ${shellSingleQuote(logPath)} 2>/dev/null || true`,
298
+ runAsync: false,
299
+ });
300
+ output = fileLogs.output ?? fileLogs.stdout ?? fileLogs.stderr ?? '';
301
+ }
302
+ catch {
303
+ // best-effort; keep the empty snapshot result
304
+ }
305
+ }
306
+ return {
307
+ output,
308
+ ...(logs.stdout ? { stdout: logs.stdout } : {}),
309
+ ...(logs.stderr ? { stderr: logs.stderr } : {}),
310
+ exitCode: null,
311
+ cmdId: commandId,
312
+ };
313
+ }
314
+ startExec(handle, command, options = {}) {
315
+ return this.startScript(handle, {
316
+ command,
317
+ sessionId: options.sessionId,
318
+ timeoutMs: options.timeoutMs,
319
+ env: options.env,
320
+ useSession: true,
321
+ suppressInputEcho: true,
322
+ });
323
+ }
324
+ getExecStatus(handle, sessionId, commandId) {
325
+ return this.getScriptStatus(handle, sessionId, commandId);
326
+ }
327
+ async getExecLogs(handle, sessionId, commandId) {
328
+ const logs = await this.getScriptLogs(handle, sessionId, commandId);
329
+ return {
330
+ output: logs.output,
331
+ exitCode: logs.exitCode ?? 0,
332
+ };
333
+ }
334
+ async uploadFile(handle, source, destination) {
335
+ const sandbox = this.requireSandbox(handle);
336
+ if (typeof source === 'string') {
337
+ await sandbox.fs.uploadFile(source, destination);
338
+ return;
339
+ }
340
+ await sandbox.fs.uploadFile(source, destination);
341
+ }
342
+ async uploadBundle(handle, options) {
343
+ await this.ensureUploadParentDirectories(handle, this.uploadParentDirectories(options));
344
+ for (const file of options.files) {
345
+ await this.uploadFile(handle, file.source, file.destination);
346
+ }
347
+ if (options.manifest !== undefined) {
348
+ await this.uploadFile(handle, Buffer.from(JSON.stringify(options.manifest, null, 2), 'utf8'), options.manifestPath ?? '/workspace/manifest.json');
349
+ }
350
+ await this.verifyUploadedBundleFiles(handle, this.uploadDestinations(options));
351
+ }
352
+ async downloadFile(handle, source, destination) {
353
+ const sandbox = this.requireSandbox(handle);
354
+ if (destination) {
355
+ await sandbox.fs.downloadFile(source, destination);
356
+ return;
357
+ }
358
+ return sandbox.fs.downloadFile(source);
359
+ }
360
+ async getHomeDir(handle) {
361
+ if (handle.homeDir) {
362
+ return handle.homeDir;
363
+ }
364
+ const sandbox = this.requireSandbox(handle);
365
+ const homeDir = await this.resolveHomeDir(sandbox);
366
+ handle.homeDir = homeDir;
367
+ return homeDir;
368
+ }
369
+ async destroy(handle) {
370
+ const entry = this.sandboxes.get(handle.id);
371
+ if (!entry) {
372
+ return;
373
+ }
374
+ if (!entry.owned) {
375
+ // For attached (non-owned) sandboxes we never call the remote
376
+ // delete; just drop the local registration so the caller-managed
377
+ // resource isn't tracked here any more.
378
+ this.sandboxes.delete(handle.id);
379
+ return;
380
+ }
381
+ const client = this.daytona;
382
+ const remove = client.remove ?? client.delete;
383
+ // Order matters: do the remote delete *first*, and only drop the
384
+ // local map entry after it succeeds. If we dropped the entry first
385
+ // and the remote delete then failed, the handle id would be lost
386
+ // and the caller could not retry cleanup safely.
387
+ await remove.call(client, entry.sandbox);
388
+ this.sandboxes.delete(handle.id);
389
+ }
390
+ async stop(handle) {
391
+ const entry = this.sandboxes.get(handle.id);
392
+ if (!entry) {
393
+ return;
394
+ }
395
+ if (!entry.owned) {
396
+ return;
397
+ }
398
+ const client = this.daytona;
399
+ if (client.stop) {
400
+ await client.stop(entry.sandbox);
401
+ return;
402
+ }
403
+ await entry.sandbox.stop?.();
404
+ }
405
+ async start(handle) {
406
+ const entry = this.sandboxes.get(handle.id);
407
+ if (!entry) {
408
+ return handle;
409
+ }
410
+ if (!entry.owned) {
411
+ return handle;
412
+ }
413
+ const client = this.daytona;
414
+ if (client.start) {
415
+ await client.start(entry.sandbox);
416
+ }
417
+ else {
418
+ await entry.sandbox.start?.();
419
+ }
420
+ handle.state = 'STARTED';
421
+ return handle;
422
+ }
423
+ async createSandbox(options) {
424
+ const params = this.buildCreateParams(options);
425
+ const createOptions = this.buildCreateOptions(options);
426
+ if (this.snapshot) {
427
+ try {
428
+ return await this.createWithOptions({ snapshot: this.snapshot, ...params }, createOptions);
429
+ }
430
+ catch (err) {
431
+ // Only fall back to a fresh sandbox when the snapshot itself is
432
+ // missing. Auth/network/quota errors should bubble — otherwise
433
+ // we silently mask real failures (a 401 ends up creating an
434
+ // unsnapshotted sandbox under whichever credentials worked).
435
+ if (!isSnapshotNotFoundError(err)) {
436
+ throw err;
437
+ }
438
+ }
439
+ }
440
+ return this.createWithOptions({ language: 'typescript', ...params }, createOptions);
441
+ }
442
+ async createSandboxDetached(options) {
443
+ const params = this.buildCreateParams(options);
444
+ const createOptions = this.buildCreateOptions(options);
445
+ if (this.snapshot) {
446
+ try {
447
+ return await this.createDetachedWithOptions({ snapshot: this.snapshot, ...params }, createOptions);
448
+ }
449
+ catch (err) {
450
+ if (!isSnapshotNotFoundError(err)) {
451
+ throw err;
452
+ }
453
+ throw new SnapshotNotFoundError(this.snapshot, err);
454
+ }
455
+ }
456
+ return this.createDetachedWithOptions({ language: 'typescript', ...params }, createOptions);
457
+ }
458
+ buildCreateParams(options) {
459
+ const envVars = options.env && Object.keys(options.env).length > 0 ? options.env : undefined;
460
+ const name = options.name?.trim()
461
+ ? options.name.trim()
462
+ : options.label?.trim()
463
+ ? options.label.trim()
464
+ : undefined;
465
+ const labels = options.labels && Object.keys(options.labels).length > 0 ? options.labels : undefined;
466
+ return {
467
+ ...(envVars ? { envVars } : {}),
468
+ ...(name ? { name } : {}),
469
+ ...(labels ? { labels } : {}),
470
+ };
471
+ }
472
+ buildCreateOptions(options) {
473
+ if (!options.createTimeoutSeconds || options.createTimeoutSeconds <= 0) {
474
+ return undefined;
475
+ }
476
+ return { timeout: Math.ceil(options.createTimeoutSeconds) };
477
+ }
478
+ createWithOptions(params, createOptions) {
479
+ if (createOptions) {
480
+ return this.daytona.create(params, createOptions);
481
+ }
482
+ return this.daytona.create(params);
483
+ }
484
+ async createDetachedWithOptions(params, createOptions) {
485
+ const client = this.daytona;
486
+ const labels = params.labels && typeof params.labels === 'object'
487
+ ? { ...params.labels }
488
+ : {};
489
+ const language = typeof params.language === 'string' && params.language.trim()
490
+ ? params.language.trim()
491
+ : 'python';
492
+ labels['code-toolbox-language'] = language;
493
+ const response = await client.sandboxApi.createSandbox({
494
+ name: params.name,
495
+ snapshot: params.snapshot,
496
+ env: params.envVars ?? {},
497
+ labels,
498
+ target: client.target,
499
+ }, undefined, createOptions ? { timeout: Math.min(createOptions.timeout, 15) * 1000 } : undefined);
500
+ const handle = {
501
+ id: response.data.id,
502
+ ...((response.data.state ?? response.data.status)
503
+ ? { state: response.data.state ?? response.data.status }
504
+ : {}),
505
+ };
506
+ if (!this.matchesState(handle, ['STARTED'])) {
507
+ return handle;
508
+ }
509
+ try {
510
+ return await client.get(response.data.id);
511
+ }
512
+ catch {
513
+ return { ...handle, state: 'STARTING' };
514
+ }
515
+ }
516
+ listSandboxes(labels, options) {
517
+ const query = {
518
+ labels,
519
+ limit: options.limit,
520
+ };
521
+ if (options.states !== null) {
522
+ query.states = options.states.map(normalizeDaytonaState);
523
+ }
524
+ return this.daytona.list(query);
525
+ }
526
+ registerSandbox(sandbox, options) {
527
+ const handle = {
528
+ id: sandbox.id,
529
+ ...(this.readSandboxState(sandbox) ? { state: this.readSandboxState(sandbox) } : {}),
530
+ ...(sandbox.createdAt ? { createdAt: sandbox.createdAt } : {}),
531
+ ...(sandbox.updatedAt ? { updatedAt: sandbox.updatedAt } : {}),
532
+ ...(sandbox.lastActivityAt ? { lastActivityAt: sandbox.lastActivityAt } : {}),
533
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
534
+ ...(options.workdir ? { workdir: options.workdir } : {}),
535
+ };
536
+ this.sandboxes.set(handle.id, {
537
+ sandbox,
538
+ owned: options.owned,
539
+ });
540
+ return handle;
541
+ }
542
+ requireSandbox(handle) {
543
+ const entry = this.sandboxes.get(handle.id);
544
+ if (!entry) {
545
+ throw new Error(`Runtime handle "${handle.id}" is no longer active`);
546
+ }
547
+ return entry.sandbox;
548
+ }
549
+ supportsSessionExec(sandbox) {
550
+ const process = sandbox.process;
551
+ if (!process || typeof process !== 'object') {
552
+ return false;
553
+ }
554
+ const candidate = process;
555
+ return (typeof candidate.createSession === 'function' &&
556
+ typeof candidate.executeSessionCommand === 'function');
557
+ }
558
+ buildScriptCommand(options) {
559
+ const statements = [];
560
+ if (options.cwd) {
561
+ statements.push(`cd ${shellSingleQuote(options.cwd)}`);
562
+ }
563
+ for (const [key, value] of Object.entries(options.env ?? {})) {
564
+ statements.push(`export ${key}=${shellSingleQuote(value)}`);
565
+ }
566
+ statements.push(options.command);
567
+ return statements.join('\n');
568
+ }
569
+ // Deterministic per-session log path written by startScript's `exec`
570
+ // redirect and read back by getScriptLogs. Keyed by sessionId (known before
571
+ // the command id exists) and filesystem-sanitised. Callers that run one
572
+ // command per session get an unambiguous path back.
573
+ scriptLogPath(sessionId) {
574
+ return `/tmp/.daytona-run-${sessionSafeId(sessionId)}.log`;
575
+ }
576
+ matchesState(sandbox, states) {
577
+ if (states === null) {
578
+ return true;
579
+ }
580
+ const expected = new Set(states.map((state) => state.toUpperCase()));
581
+ const actual = this.readSandboxState(sandbox);
582
+ return actual ? expected.has(actual.toUpperCase()) : false;
583
+ }
584
+ readSandboxState(sandbox) {
585
+ const candidate = sandbox;
586
+ const value = candidate.state
587
+ ?? candidate.status
588
+ ?? candidate.sandboxState
589
+ ?? candidate.info?.state
590
+ ?? candidate.info?.status;
591
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
592
+ }
593
+ uploadParentDirectories(options) {
594
+ const directories = new Set();
595
+ for (const destination of this.uploadDestinations(options)) {
596
+ const directory = parentDirectory(destination);
597
+ if (directory) {
598
+ directories.add(directory);
599
+ }
600
+ }
601
+ return Array.from(directories).sort();
602
+ }
603
+ uploadDestinations(options) {
604
+ const destinations = options.files.map((file) => file.destination);
605
+ if (options.manifest !== undefined) {
606
+ destinations.push(options.manifestPath ?? '/workspace/manifest.json');
607
+ }
608
+ return destinations;
609
+ }
610
+ async ensureUploadParentDirectories(handle, directories) {
611
+ if (directories.length === 0) {
612
+ return;
613
+ }
614
+ const result = await this.runScript(handle, {
615
+ command: `mkdir -p ${directories.map(shellSingleQuote).join(' ')}`,
616
+ sessionId: `mkdir-${sessionSafeId(handle.id)}-${Date.now()}`,
617
+ timeoutMs: 30_000,
618
+ });
619
+ if (result.exitCode == null || result.exitCode !== 0) {
620
+ throw new Error(`Failed to create upload directories: ${result.output || result.stderr || result.stdout || 'mkdir failed'}`);
621
+ }
622
+ }
623
+ async verifyUploadedBundleFiles(handle, destinations) {
624
+ if (destinations.length === 0) {
625
+ return;
626
+ }
627
+ const checks = destinations
628
+ .map((destination) => `test -f ${shellSingleQuote(destination)}`)
629
+ .join(' && ');
630
+ const result = await this.runScript(handle, {
631
+ command: checks,
632
+ sessionId: `verify-upload-${sessionSafeId(handle.id)}-${Date.now()}`,
633
+ timeoutMs: 30_000,
634
+ });
635
+ if (result.exitCode == null || result.exitCode !== 0) {
636
+ throw new Error(`Failed to verify uploaded bundle files: ${result.output || result.stderr || result.stdout || 'remote file check failed'}`);
637
+ }
638
+ }
639
+ async resolveHomeDir(sandbox) {
640
+ try {
641
+ const home = await sandbox.getUserHomeDir();
642
+ if (home) {
643
+ return home;
644
+ }
645
+ }
646
+ catch {
647
+ // fall through to default
648
+ }
649
+ return this.defaultHomeDir;
650
+ }
651
+ msToSeconds(timeoutMs) {
652
+ if (!timeoutMs || timeoutMs <= 0) {
653
+ return undefined;
654
+ }
655
+ return Math.max(1, Math.ceil(timeoutMs / 1000));
656
+ }
657
+ }
658
+ function normalizeDaytonaState(state) {
659
+ return state.toLowerCase();
660
+ }
661
+ function lookupDeadline(timeoutMs) {
662
+ const effectiveTimeoutMs = timeoutMs ?? DEFAULT_DAYTONA_LOOKUP_TIMEOUT_MS;
663
+ const normalizedTimeoutMs = Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs > 0
664
+ ? Math.max(1, Math.ceil(effectiveTimeoutMs))
665
+ : DEFAULT_DAYTONA_LOOKUP_TIMEOUT_MS;
666
+ return {
667
+ endsAt: Date.now() + normalizedTimeoutMs,
668
+ timeoutMs: normalizedTimeoutMs,
669
+ };
670
+ }
671
+ async function awaitLookupOperation(operation, deadline, description) {
672
+ const remainingMs = deadline.endsAt - Date.now();
673
+ if (remainingMs <= 0) {
674
+ throw new Error(`Daytona sandbox lookup exceeded ${deadline.timeoutMs}ms while ${description}`);
675
+ }
676
+ let timer;
677
+ try {
678
+ return await Promise.race([
679
+ operation,
680
+ new Promise((_resolve, reject) => {
681
+ timer = setTimeout(() => {
682
+ reject(new Error(`Daytona sandbox lookup exceeded ${deadline.timeoutMs}ms while ${description}`));
683
+ }, remainingMs);
684
+ }),
685
+ ]);
686
+ }
687
+ finally {
688
+ if (timer !== undefined) {
689
+ clearTimeout(timer);
690
+ }
691
+ }
692
+ }
693
+ function closeAsyncIteratorBestEffort(iterator) {
694
+ if (!iterator.return) {
695
+ return;
696
+ }
697
+ try {
698
+ void iterator.return().catch(() => undefined);
699
+ }
700
+ catch {
701
+ // The lookup result or timeout is authoritative; iterator cleanup is best effort.
702
+ }
703
+ }
704
+ function isRuntimeHandle(value) {
705
+ return !('getUserHomeDir' in value);
706
+ }
707
+ function shellSingleQuote(value) {
708
+ return `'${value.replaceAll("'", "'\\''")}'`;
709
+ }
710
+ function sessionSafeId(value) {
711
+ return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'sandbox';
712
+ }
713
+ function parentDirectory(destination) {
714
+ const normalized = destination.trim().replace(/\/+$/g, '');
715
+ if (!normalized || normalized === '/' || !normalized.includes('/')) {
716
+ return null;
717
+ }
718
+ const separatorIndex = normalized.lastIndexOf('/');
719
+ if (separatorIndex <= 0) {
720
+ return null;
721
+ }
722
+ const directory = normalized.slice(0, separatorIndex);
723
+ return directory && directory !== '.' ? directory : null;
724
+ }
725
+ /**
726
+ * Heuristic: identify Daytona errors that indicate the snapshot we asked
727
+ * for doesn't exist (so falling back to a fresh sandbox is safe). We look
728
+ * at the HTTP status when the SDK surfaces one, plus a few well-known
729
+ * error-message shapes Daytona emits. Anything else propagates so the
730
+ * caller sees the original error (auth/network/quota/etc.).
731
+ */
732
+ function isSnapshotNotFoundError(err) {
733
+ if (!err || typeof err !== 'object')
734
+ return false;
735
+ const candidate = err;
736
+ const status = typeof candidate.status === 'number'
737
+ ? candidate.status
738
+ : typeof candidate.statusCode === 'number'
739
+ ? candidate.statusCode
740
+ : undefined;
741
+ if (status === 404)
742
+ return true;
743
+ const message = typeof candidate.message === 'string' ? candidate.message.toLowerCase() : '';
744
+ if (!message)
745
+ return false;
746
+ return (message.includes('snapshot') &&
747
+ (message.includes('not found') || message.includes('does not exist') || message.includes('no such')));
748
+ }
749
+ function isDaytonaNotFoundError(err) {
750
+ if (!err || typeof err !== 'object')
751
+ return false;
752
+ const candidate = err;
753
+ const status = typeof candidate.status === 'number'
754
+ ? candidate.status
755
+ : typeof candidate.statusCode === 'number'
756
+ ? candidate.statusCode
757
+ : undefined;
758
+ if (status === 404)
759
+ return true;
760
+ if (candidate.name === 'DaytonaNotFoundError')
761
+ return true;
762
+ const message = typeof candidate.message === 'string' ? candidate.message.toLowerCase() : '';
763
+ return message.includes('sandbox') && message.includes('not found');
764
+ }
765
+ //# sourceMappingURL=runtime.js.map