@ekairos/sandbox 1.22.15-beta.development.0 → 1.22.16-beta.development.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/service.js CHANGED
@@ -1,8 +1,9 @@
1
- import { Sandbox as VercelSandbox } from "@vercel/sandbox";
1
+ import { Sandbox as VercelSandbox, Snapshot as VercelSnapshot } from "@vercel/sandbox";
2
2
  import { Daytona, Image } from "@daytonaio/sdk";
3
3
  import { id } from "@instantdb/admin";
4
4
  import { resolveRuntime } from "@ekairos/domain/runtime";
5
5
  import { runCommandInSandbox } from "./commands.js";
6
+ import { resolveVercelSandboxConfig, safeVercelConfigForRecord, } from "./vercel-options.js";
6
7
  import { execFile } from "node:child_process";
7
8
  import { randomUUID } from "node:crypto";
8
9
  import { existsSync, promises as fs } from "node:fs";
@@ -611,16 +612,41 @@ export class SandboxService {
611
612
  }
612
613
  static async provisionVercelSandbox(config, extra) {
613
614
  const creds = await SandboxService.resolveVercelCredentials(config);
615
+ const resolved = extra?.resolved ?? resolveVercelSandboxConfig(config);
616
+ if (resolved.reuse && resolved.name) {
617
+ try {
618
+ return await VercelSandbox.get({
619
+ name: resolved.name,
620
+ teamId: creds.teamId,
621
+ projectId: creds.projectId,
622
+ token: creds.token,
623
+ resume: true,
624
+ });
625
+ }
626
+ catch (error) {
627
+ const status = Number(error?.response?.status ?? 0);
628
+ const message = formatSandboxError(error).toLowerCase();
629
+ if (status !== 404 && !message.includes("not found")) {
630
+ throw error;
631
+ }
632
+ }
633
+ }
614
634
  return await VercelSandbox.create({
615
635
  teamId: creds.teamId,
616
636
  projectId: creds.projectId,
617
637
  token: creds.token,
618
- timeout: config.timeoutMs ?? 30 * 60 * 1000,
619
- ports: Array.isArray(config.ports) ? config.ports : [],
638
+ ...(resolved.name ? { name: resolved.name } : {}),
639
+ timeout: resolved.timeoutMs,
640
+ ports: resolved.ports,
620
641
  // IMPORTANT: pass runtime as-is (e.g. "python3.13") to match provider expectations.
621
642
  // Don't normalize to "python3"/"node22" as that can cause provider-side 400s.
622
- runtime: (config.runtime ?? "node22"),
623
- resources: { vcpus: config.resources?.vcpus ?? 2 },
643
+ runtime: resolved.runtime,
644
+ resources: { vcpus: resolved.vcpus },
645
+ persistent: resolved.persistent,
646
+ ...(resolved.snapshotExpirationMs !== undefined
647
+ ? { snapshotExpiration: resolved.snapshotExpirationMs }
648
+ : {}),
649
+ ...(resolved.tags ? { tags: resolved.tags } : {}),
624
650
  networkPolicy: extra?.networkPolicy,
625
651
  env: extra?.env,
626
652
  });
@@ -1001,6 +1027,7 @@ export class SandboxService {
1001
1027
  const sandboxId = id();
1002
1028
  const now = Date.now();
1003
1029
  const provider = SandboxService.resolveProvider(config);
1030
+ const resolvedVercel = provider === "vercel" ? resolveVercelSandboxConfig(config, { sandboxId }) : undefined;
1004
1031
  let daytonaEphemeral = undefined;
1005
1032
  let installedSkills = [];
1006
1033
  try {
@@ -1010,13 +1037,14 @@ export class SandboxService {
1010
1037
  status: "creating",
1011
1038
  ...(ekairos ? { sandboxUserId: ekairos.sandboxUserId } : {}),
1012
1039
  provider,
1013
- timeout: config.timeoutMs,
1014
- runtime: config.runtime,
1015
- vcpus: config.resources?.vcpus,
1016
- ports: config.ports,
1040
+ timeout: resolvedVercel?.timeoutMs ?? config.timeoutMs,
1041
+ runtime: resolvedVercel?.runtime ?? config.runtime,
1042
+ vcpus: resolvedVercel?.vcpus ?? config.resources?.vcpus,
1043
+ ports: (resolvedVercel?.ports ?? config.ports),
1017
1044
  purpose: config.purpose,
1018
1045
  params: {
1019
1046
  ...baseParams,
1047
+ ...(resolvedVercel ? { vercel: safeVercelConfigForRecord(config, resolvedVercel) } : {}),
1020
1048
  ...(ekairos
1021
1049
  ? {
1022
1050
  ekairos: {
@@ -1116,6 +1144,7 @@ export class SandboxService {
1116
1144
  sandbox = await SandboxService.provisionVercelSandbox(config, {
1117
1145
  networkPolicy: ekairos?.networkPolicy,
1118
1146
  env: Object.keys(vercelEnv).length > 0 ? vercelEnv : undefined,
1147
+ resolved: resolvedVercel,
1119
1148
  });
1120
1149
  if (ekairos) {
1121
1150
  await SandboxService.bootstrapEkairosFiles(sandbox, ekairos.manifest);
@@ -1129,7 +1158,10 @@ export class SandboxService {
1129
1158
  const msg = formatSandboxError(e);
1130
1159
  if (sandbox && provider === "vercel") {
1131
1160
  try {
1132
- await sandbox.stop();
1161
+ await sandbox.stop({ blocking: true });
1162
+ if (resolvedVercel?.deleteOnStop) {
1163
+ await sandbox.delete();
1164
+ }
1133
1165
  }
1134
1166
  catch {
1135
1167
  // ignore cleanup errors during failed bootstrap
@@ -1178,10 +1210,7 @@ export class SandboxService {
1178
1210
  : {}),
1179
1211
  ...(provider === "vercel"
1180
1212
  ? {
1181
- vercel: {
1182
- ...baseParams?.vercel,
1183
- ...(config.vercel ?? {}),
1184
- },
1213
+ vercel: resolvedVercel ? safeVercelConfigForRecord(config, resolvedVercel) : {},
1185
1214
  }
1186
1215
  : {}),
1187
1216
  ...(provider === "daytona"
@@ -1422,6 +1451,181 @@ export class SandboxService {
1422
1451
  ...(params.data ? { data: sanitizeInstantValue(params.data) } : {}),
1423
1452
  }));
1424
1453
  }
1454
+ async readProcessRow(processId) {
1455
+ const result = await this.adminDb.query({
1456
+ sandbox_processes: {
1457
+ $: { where: { id: processId }, limit: 1 },
1458
+ sandbox: {},
1459
+ },
1460
+ });
1461
+ return result?.sandbox_processes?.[0] ?? null;
1462
+ }
1463
+ async writeProcessChunkByProcessId(processId, type, data, opts) {
1464
+ const row = await this.readProcessRow(processId);
1465
+ if (!row)
1466
+ throw new Error("sandbox_process_not_found");
1467
+ const linkedSandbox = Array.isArray(row?.sandbox) ? row.sandbox[0] : row?.sandbox;
1468
+ const sandboxId = String(linkedSandbox?.id ?? row?.sandboxId ?? "").trim();
1469
+ if (!sandboxId)
1470
+ throw new Error("sandbox_process_sandbox_missing");
1471
+ const streamClientId = String(row.streamClientId ?? "").trim() || createSandboxProcessStreamClientId(processId);
1472
+ const streams = this.adminDb?.streams;
1473
+ if (!streams?.createWriteStream)
1474
+ throw new Error("sandbox_process_streams_unavailable");
1475
+ const stream = streams.createWriteStream({ clientId: streamClientId });
1476
+ const writer = stream.getWriter();
1477
+ try {
1478
+ const seq = Number(row.metadata?.lastSeq ?? row.metadata?.chunkCount ?? 0) + 1;
1479
+ await this.writeProcessChunk({
1480
+ writer,
1481
+ sandboxId,
1482
+ processId,
1483
+ seq,
1484
+ type,
1485
+ data,
1486
+ });
1487
+ if (opts?.close) {
1488
+ await writer.close();
1489
+ }
1490
+ await this.adminDb.transact([
1491
+ this.adminDb.tx.sandbox_processes[processId].update({
1492
+ updatedAt: Date.now(),
1493
+ metadata: sanitizeInstantValue({
1494
+ ...(row.metadata ?? {}),
1495
+ lastSeq: seq,
1496
+ chunkCount: seq,
1497
+ }),
1498
+ }),
1499
+ ]);
1500
+ }
1501
+ finally {
1502
+ try {
1503
+ writer.releaseLock();
1504
+ }
1505
+ catch {
1506
+ // ignore
1507
+ }
1508
+ }
1509
+ }
1510
+ async startObservedProcess(sandboxId, opts) {
1511
+ const processId = id();
1512
+ const now = Date.now();
1513
+ try {
1514
+ const record = await this.getSandboxRecord(sandboxId);
1515
+ if (!record)
1516
+ return { ok: false, error: "Valid sandbox record not found" };
1517
+ if (record.status !== "active")
1518
+ return { ok: false, error: `sandbox_not_active:${record.status}` };
1519
+ const streamSession = await this.createProcessStream({ sandboxId, processId });
1520
+ const stream = streamSession.stream;
1521
+ const writer = stream.getWriter();
1522
+ try {
1523
+ await this.adminDb.transact([
1524
+ this.adminDb.tx.sandbox_processes[processId]
1525
+ .update({
1526
+ kind: opts.kind ?? "command",
1527
+ mode: opts.mode ?? "foreground",
1528
+ status: "running",
1529
+ provider: String(record.provider ?? "unknown"),
1530
+ command: sanitizeInstantString(opts.command),
1531
+ args: sanitizeInstantValue(Array.isArray(opts.args) ? opts.args : []),
1532
+ cwd: asOptionalString(opts.cwd),
1533
+ env: sanitizeInstantValue(opts.env),
1534
+ externalProcessId: asOptionalString(opts.externalProcessId),
1535
+ streamId: streamSession.streamId,
1536
+ streamClientId: streamSession.streamClientId,
1537
+ streamStartedAt: now,
1538
+ startedAt: now,
1539
+ updatedAt: now,
1540
+ metadata: sanitizeInstantValue({
1541
+ ...(opts.metadata ?? {}),
1542
+ observed: true,
1543
+ lastSeq: 1,
1544
+ chunkCount: 1,
1545
+ }),
1546
+ })
1547
+ .link({ sandbox: sandboxId, stream: streamSession.streamId }),
1548
+ ]);
1549
+ await this.writeProcessChunk({
1550
+ writer,
1551
+ sandboxId,
1552
+ processId,
1553
+ seq: 1,
1554
+ type: "status",
1555
+ data: {
1556
+ status: "running",
1557
+ command: opts.command,
1558
+ args: Array.isArray(opts.args) ? opts.args : [],
1559
+ cwd: opts.cwd ?? null,
1560
+ externalProcessId: opts.externalProcessId ?? null,
1561
+ },
1562
+ });
1563
+ // Keep observed-process streams open across calls; finishObservedProcess closes them.
1564
+ }
1565
+ finally {
1566
+ try {
1567
+ writer.releaseLock();
1568
+ }
1569
+ catch {
1570
+ // ignore
1571
+ }
1572
+ }
1573
+ return {
1574
+ ok: true,
1575
+ data: {
1576
+ processId,
1577
+ streamId: streamSession.streamId,
1578
+ streamClientId: streamSession.streamClientId,
1579
+ },
1580
+ };
1581
+ }
1582
+ catch (e) {
1583
+ return { ok: false, error: formatInstantSchemaError(e) };
1584
+ }
1585
+ }
1586
+ async appendObservedProcessChunk(processId, type, data) {
1587
+ try {
1588
+ await this.writeProcessChunkByProcessId(processId, type, data);
1589
+ return { ok: true, data: undefined };
1590
+ }
1591
+ catch (e) {
1592
+ return { ok: false, error: formatInstantSchemaError(e) };
1593
+ }
1594
+ }
1595
+ async finishObservedProcess(processId, opts) {
1596
+ try {
1597
+ const row = await this.readProcessRow(processId);
1598
+ if (!row)
1599
+ return { ok: false, error: "sandbox_process_not_found" };
1600
+ const exitCode = Number.isFinite(Number(opts?.exitCode)) ? Number(opts?.exitCode) : undefined;
1601
+ const status = opts?.status ?? (exitCode === undefined || exitCode === 0 ? "exited" : "failed");
1602
+ await this.writeProcessChunkByProcessId(processId, status === "failed" ? "error" : "exit", {
1603
+ exitCode: exitCode ?? null,
1604
+ status,
1605
+ ...(opts?.errorText ? { message: opts.errorText } : {}),
1606
+ }, { close: true });
1607
+ const finishedAt = Date.now();
1608
+ await this.adminDb.transact([
1609
+ this.adminDb.tx.sandbox_processes[processId].update({
1610
+ status,
1611
+ ...(exitCode !== undefined ? { exitCode } : {}),
1612
+ streamFinishedAt: finishedAt,
1613
+ streamAbortReason: opts?.errorText ?? null,
1614
+ exitedAt: finishedAt,
1615
+ updatedAt: finishedAt,
1616
+ metadata: sanitizeInstantValue({
1617
+ ...(row.metadata ?? {}),
1618
+ ...(opts?.metadata ?? {}),
1619
+ ...(opts?.errorText ? { error: opts.errorText } : {}),
1620
+ }),
1621
+ }),
1622
+ ]);
1623
+ return { ok: true, data: undefined };
1624
+ }
1625
+ catch (e) {
1626
+ return { ok: false, error: formatInstantSchemaError(e) };
1627
+ }
1628
+ }
1425
1629
  async stopSandbox(sandboxId) {
1426
1630
  try {
1427
1631
  const result = await this.reconnectToSandbox(sandboxId);
@@ -1432,13 +1636,19 @@ export class SandboxService {
1432
1636
  const deleteOnStop = record?.provider === "sprites"
1433
1637
  ? SandboxService.parseOptionalBoolean(process.env.SANDBOX_SPRITES_DELETE_ON_STOP) ??
1434
1638
  Boolean(record?.params?.sprites?.deleteOnStop ?? true)
1435
- : SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
1436
- Boolean(record?.params?.daytona?.ephemeral);
1639
+ : record?.provider === "vercel"
1640
+ ? SandboxService.parseOptionalBoolean(process.env.SANDBOX_VERCEL_DELETE_ON_STOP) ??
1641
+ Boolean(record?.params?.vercel?.deleteOnStop ?? !record?.params?.vercel?.persistent)
1642
+ : SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
1643
+ Boolean(record?.params?.daytona?.ephemeral);
1437
1644
  if (result.ok) {
1438
1645
  try {
1439
1646
  const sandbox = result.data.sandbox;
1440
1647
  if (isVercelSandbox(sandbox)) {
1441
- await sandbox.stop();
1648
+ await sandbox.stop({ blocking: true });
1649
+ if (deleteOnStop) {
1650
+ await sandbox.delete();
1651
+ }
1442
1652
  }
1443
1653
  else if (sandbox?.__provider === "sprites") {
1444
1654
  // Sprites does not have a reliable "stop" semantic; deleting is the durable cleanup primitive.
@@ -1981,6 +2191,33 @@ export class SandboxService {
1981
2191
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
1982
2192
  });
1983
2193
  const record = recordResult?.sandbox_sandboxes?.[0];
2194
+ if (record?.externalSandboxId && record.provider === "vercel") {
2195
+ const sandboxResult = await this.reconnectToSandbox(sandboxId);
2196
+ if (!sandboxResult.ok)
2197
+ return { ok: false, error: sandboxResult.error };
2198
+ const sandbox = sandboxResult.data.sandbox;
2199
+ if (!isVercelSandbox(sandbox))
2200
+ return { ok: false, error: "checkpoint_not_supported" };
2201
+ const expiration = Number(record?.params?.vercel?.snapshotExpirationMs);
2202
+ const snapshot = await sandbox.snapshot({
2203
+ ...(Number.isFinite(expiration) ? { expiration } : {}),
2204
+ });
2205
+ const checkpointId = String(snapshot?.snapshotId ?? "").trim();
2206
+ if (!checkpointId)
2207
+ return { ok: false, error: "vercel_snapshot_id_missing" };
2208
+ await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
2209
+ updatedAt: Date.now(),
2210
+ params: {
2211
+ ...(record.params ?? {}),
2212
+ vercel: {
2213
+ ...(record.params?.vercel ?? {}),
2214
+ lastCheckpointId: checkpointId,
2215
+ lastCheckpointComment: String(params?.comment ?? "").trim() || undefined,
2216
+ },
2217
+ },
2218
+ }));
2219
+ return { ok: true, data: { checkpointId } };
2220
+ }
1984
2221
  if (!record?.externalSandboxId || record.provider !== "sprites") {
1985
2222
  return { ok: false, error: "checkpoint_not_supported" };
1986
2223
  }
@@ -2022,6 +2259,21 @@ export class SandboxService {
2022
2259
  sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
2023
2260
  });
2024
2261
  const record = recordResult?.sandbox_sandboxes?.[0];
2262
+ if (record?.externalSandboxId && record.provider === "vercel") {
2263
+ const creds = await SandboxService.resolveVercelCredentials(record?.params ?? {});
2264
+ const listed = await VercelSnapshot.list({
2265
+ teamId: creds.teamId,
2266
+ projectId: creds.projectId,
2267
+ token: creds.token,
2268
+ name: String(record.externalSandboxId),
2269
+ limit: 50,
2270
+ sortOrder: "desc",
2271
+ });
2272
+ const checkpointIds = (listed.snapshots ?? [])
2273
+ .map((snapshot) => String(snapshot?.id ?? "").trim())
2274
+ .filter(Boolean);
2275
+ return { ok: true, data: { checkpointIds } };
2276
+ }
2025
2277
  if (!record?.externalSandboxId || record.provider !== "sprites") {
2026
2278
  return { ok: false, error: "checkpoint_not_supported" };
2027
2279
  }