@ekairos/sandbox 1.22.34-beta.development.0 → 1.22.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -452
- package/dist/action-steps.d.ts +156 -0
- package/dist/action-steps.d.ts.map +1 -0
- package/dist/action-steps.js +153 -0
- package/dist/action-steps.js.map +1 -0
- package/dist/actions.d.ts +263 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +208 -0
- package/dist/actions.js.map +1 -0
- package/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/contract.d.ts +86 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +83 -0
- package/dist/contract.js.map +1 -0
- package/dist/domain.d.ts +2 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +2 -0
- package/dist/domain.js.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/daytona.d.ts +14 -0
- package/dist/providers/daytona.d.ts.map +1 -0
- package/dist/providers/daytona.js +153 -0
- package/dist/providers/daytona.js.map +1 -0
- package/dist/providers/provider.d.ts +3 -0
- package/dist/providers/provider.d.ts.map +1 -0
- package/dist/providers/provider.js +18 -0
- package/dist/providers/provider.js.map +1 -0
- package/dist/providers/sprites.d.ts +39 -0
- package/dist/providers/sprites.d.ts.map +1 -0
- package/dist/providers/sprites.js +234 -0
- package/dist/providers/sprites.js.map +1 -0
- package/dist/providers/types.d.ts +15 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +9 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/providers/vercel.d.ts +26 -0
- package/dist/providers/vercel.d.ts.map +1 -0
- package/dist/providers/vercel.js +182 -0
- package/dist/providers/vercel.js.map +1 -0
- package/dist/public.d.ts +56 -0
- package/dist/public.d.ts.map +1 -0
- package/dist/public.js +37 -0
- package/dist/public.js.map +1 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +7 -1
- package/dist/runtime.js.map +1 -1
- package/dist/sandbox.d.ts +76 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +154 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/schema.d.ts +18 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +43 -15
- package/dist/schema.js.map +1 -1
- package/dist/service.d.ts +98 -43
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +811 -543
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vercel-options.d.ts +21 -0
- package/dist/vercel-options.d.ts.map +1 -0
- package/dist/vercel-options.js +149 -0
- package/dist/vercel-options.js.map +1 -0
- package/package.json +43 -7
package/dist/service.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { Sandbox as VercelSandbox } from "@vercel/sandbox";
|
|
2
|
-
import { Daytona
|
|
1
|
+
import { Sandbox as VercelSandbox, Snapshot as VercelSnapshot } from "@vercel/sandbox";
|
|
2
|
+
import { Daytona } 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 {
|
|
6
|
+
import { buildDeclarativeImage, getDaytonaConfig, resolveDaytonaLanguage, resolveDaytonaVolumes, } from "./providers/daytona.js";
|
|
7
|
+
import { resolveProvider } from "./providers/provider.js";
|
|
8
|
+
import { asSpritesSandbox, getSpritesByName, parseSpritesCheckpointIdFromNdjson, provisionSpritesSandbox, spritesExec, spritesFetch, spritesJson, } from "./providers/sprites.js";
|
|
9
|
+
import { isVercelSandbox, } from "./providers/types.js";
|
|
10
|
+
import { provisionVercelSandbox, resolveVercelCredentials, } from "./providers/vercel.js";
|
|
11
|
+
import { resolveVercelSandboxConfig, safeVercelConfigForRecord, } from "./vercel-options.js";
|
|
7
12
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { existsSync, promises as fs } from "node:fs";
|
|
9
|
-
import os from "node:os";
|
|
10
13
|
import path from "node:path";
|
|
11
|
-
import { promisify } from "node:util";
|
|
12
|
-
const execFileAsync = promisify(execFile);
|
|
13
14
|
const EKAIROS_ROOT_DIR = "/vercel/sandbox/.ekairos";
|
|
14
15
|
const EKAIROS_RUNTIME_MANIFEST_PATH = `${EKAIROS_ROOT_DIR}/runtime.json`;
|
|
15
16
|
const EKAIROS_HTTP_HELPER_PATH = `${EKAIROS_ROOT_DIR}/instant-http.mjs`;
|
|
@@ -17,6 +18,8 @@ const EKAIROS_QUERY_SCRIPT_PATH = `${EKAIROS_ROOT_DIR}/query.mjs`;
|
|
|
17
18
|
const CODEX_HOME_DIR = "/vercel/sandbox/.codex";
|
|
18
19
|
const CODEX_SKILLS_DIR = `${CODEX_HOME_DIR}/skills`;
|
|
19
20
|
const INSTANT_API_BASE_URL = "https://api.instantdb.com";
|
|
21
|
+
const SANDBOX_PROCESS_STREAM_VERSION = 1;
|
|
22
|
+
const SANDBOX_PROCESS_TERMINAL_STATUSES = new Set(["exited", "failed", "killed", "lost"]);
|
|
20
23
|
function formatInstantSchemaError(err) {
|
|
21
24
|
const base = err instanceof Error ? err.message : String(err);
|
|
22
25
|
const body = err?.body;
|
|
@@ -49,19 +52,169 @@ function formatSandboxError(err) {
|
|
|
49
52
|
return base;
|
|
50
53
|
return `${base}: ${detail}`;
|
|
51
54
|
}
|
|
55
|
+
function nowIso() {
|
|
56
|
+
return new Date().toISOString();
|
|
57
|
+
}
|
|
58
|
+
function asOptionalString(value) {
|
|
59
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
60
|
+
}
|
|
61
|
+
function sanitizeInstantString(value) {
|
|
62
|
+
return value.includes("\0") ? value.replace(/\0/g, "") : value;
|
|
63
|
+
}
|
|
64
|
+
function sanitizeInstantValue(value) {
|
|
65
|
+
if (typeof value === "string") {
|
|
66
|
+
return sanitizeInstantString(value);
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.map((item) => sanitizeInstantValue(item));
|
|
70
|
+
}
|
|
71
|
+
if (value && typeof value === "object" && !(value instanceof Date)) {
|
|
72
|
+
const sanitized = {};
|
|
73
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
74
|
+
sanitized[key] = sanitizeInstantValue(entry);
|
|
75
|
+
}
|
|
76
|
+
return sanitized;
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function createSandboxProcessStreamClientId(processId) {
|
|
81
|
+
const normalized = String(processId ?? "").trim();
|
|
82
|
+
if (!normalized)
|
|
83
|
+
throw new Error("sandbox_process_id_required");
|
|
84
|
+
return `sandbox-process:${normalized}`;
|
|
85
|
+
}
|
|
86
|
+
function encodeSandboxProcessStreamChunk(chunk) {
|
|
87
|
+
return `${JSON.stringify(chunk)}\n`;
|
|
88
|
+
}
|
|
89
|
+
function parseSandboxProcessStreamChunk(value) {
|
|
90
|
+
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
91
|
+
if (!parsed || typeof parsed !== "object") {
|
|
92
|
+
throw new Error("invalid_sandbox_process_stream_chunk");
|
|
93
|
+
}
|
|
94
|
+
const record = parsed;
|
|
95
|
+
if (record.version !== SANDBOX_PROCESS_STREAM_VERSION) {
|
|
96
|
+
throw new Error(`invalid_sandbox_process_stream_version:${String(record.version)}`);
|
|
97
|
+
}
|
|
98
|
+
return record;
|
|
99
|
+
}
|
|
100
|
+
function sandboxProcessFinishedHookToken(processId) {
|
|
101
|
+
return `sandbox-process:${processId}:finished`;
|
|
102
|
+
}
|
|
103
|
+
async function resumeSandboxProcessHook(processId, payload) {
|
|
104
|
+
try {
|
|
105
|
+
const { resumeHook } = await import("workflow/api");
|
|
106
|
+
await resumeHook(sandboxProcessFinishedHookToken(processId), payload);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// No workflow may be listening; process metadata and streams remain the source of truth.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function commandResultFromProcessStream(params) {
|
|
113
|
+
const stdout = params.chunks
|
|
114
|
+
.filter((chunk) => chunk.type === "stdout")
|
|
115
|
+
.map((chunk) => String(chunk.data?.text ?? ""))
|
|
116
|
+
.join("");
|
|
117
|
+
const stderr = params.chunks
|
|
118
|
+
.filter((chunk) => chunk.type === "stderr" || chunk.type === "error")
|
|
119
|
+
.map((chunk) => String(chunk.data?.text ?? chunk.data?.message ?? ""))
|
|
120
|
+
.join("");
|
|
121
|
+
const exitChunk = [...params.chunks].reverse().find((chunk) => chunk.type === "exit");
|
|
122
|
+
const exitCode = Number(exitChunk?.data?.exitCode ?? params.processRow?.exitCode ?? 1);
|
|
123
|
+
const command = [
|
|
124
|
+
String(params.processRow?.command ?? ""),
|
|
125
|
+
...(Array.isArray(params.processRow?.args) ? params.processRow.args : []),
|
|
126
|
+
]
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join(" ");
|
|
129
|
+
return {
|
|
130
|
+
success: exitCode === 0,
|
|
131
|
+
exitCode,
|
|
132
|
+
output: stdout,
|
|
133
|
+
error: stderr,
|
|
134
|
+
command,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export class SandboxCommandRun {
|
|
138
|
+
constructor(data, service) {
|
|
139
|
+
this.service = null;
|
|
140
|
+
this.data = data;
|
|
141
|
+
this.service = service ?? null;
|
|
142
|
+
}
|
|
143
|
+
get sandboxId() {
|
|
144
|
+
return this.data.sandboxId;
|
|
145
|
+
}
|
|
146
|
+
get processId() {
|
|
147
|
+
return this.data.processId;
|
|
148
|
+
}
|
|
149
|
+
get streamId() {
|
|
150
|
+
return this.data.streamId;
|
|
151
|
+
}
|
|
152
|
+
get streamClientId() {
|
|
153
|
+
return this.data.streamClientId;
|
|
154
|
+
}
|
|
155
|
+
getService() {
|
|
156
|
+
if (!this.service) {
|
|
157
|
+
throw new Error("sandbox_command_run_service_required");
|
|
158
|
+
}
|
|
159
|
+
return this.service;
|
|
160
|
+
}
|
|
161
|
+
async readStream() {
|
|
162
|
+
const stream = await this.getService().readProcessStream(this.processId);
|
|
163
|
+
if (!stream.ok)
|
|
164
|
+
throw new Error(stream.error);
|
|
165
|
+
return stream.data;
|
|
166
|
+
}
|
|
167
|
+
async snapshot() {
|
|
168
|
+
const snapshot = await this.getService().getProcessSnapshot(this.processId);
|
|
169
|
+
if (!snapshot.ok)
|
|
170
|
+
throw new Error(snapshot.error);
|
|
171
|
+
return snapshot.data;
|
|
172
|
+
}
|
|
173
|
+
async wait(params) {
|
|
174
|
+
if (this.data.result)
|
|
175
|
+
return this.data.result;
|
|
176
|
+
const initial = await this.snapshot();
|
|
177
|
+
if (SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(initial.status ?? ""))) {
|
|
178
|
+
const stream = await this.readStream();
|
|
179
|
+
const result = commandResultFromProcessStream({ processRow: initial, chunks: stream.chunks });
|
|
180
|
+
this.data.result = result;
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const { createHook } = await import("workflow");
|
|
185
|
+
const hook = createHook({
|
|
186
|
+
token: sandboxProcessFinishedHookToken(this.processId),
|
|
187
|
+
});
|
|
188
|
+
const result = await hook;
|
|
189
|
+
this.data.result = result;
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Outside workflow context, or if hooks are unavailable, poll the durable row.
|
|
194
|
+
}
|
|
195
|
+
const timeoutMs = Math.max(0, Number(params?.timeoutMs ?? 5 * 60 * 1000));
|
|
196
|
+
const pollMs = Math.max(50, Number(params?.pollMs ?? 500));
|
|
197
|
+
const deadline = Date.now() + timeoutMs;
|
|
198
|
+
while (Date.now() <= deadline) {
|
|
199
|
+
const row = await this.snapshot();
|
|
200
|
+
if (SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(row.status ?? ""))) {
|
|
201
|
+
const stream = await this.readStream();
|
|
202
|
+
const result = commandResultFromProcessStream({ processRow: row, chunks: stream.chunks });
|
|
203
|
+
this.data.result = result;
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`sandbox_process_wait_timeout:${this.processId}`);
|
|
209
|
+
}
|
|
210
|
+
then(onfulfilled, onrejected) {
|
|
211
|
+
return this.wait().then(onfulfilled, onrejected);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
52
214
|
export class SandboxService {
|
|
53
215
|
constructor(db) {
|
|
54
216
|
this.adminDb = db;
|
|
55
217
|
}
|
|
56
|
-
static getVercelCredentials() {
|
|
57
|
-
const teamId = String(process.env.SANDBOX_VERCEL_TEAM_ID ?? "").trim();
|
|
58
|
-
const projectId = String(process.env.SANDBOX_VERCEL_PROJECT_ID ?? "").trim();
|
|
59
|
-
const token = String(process.env.SANDBOX_VERCEL_TOKEN ?? "").trim();
|
|
60
|
-
if (!teamId || !projectId || !token) {
|
|
61
|
-
throw new Error("Missing required Vercel sandbox environment variables");
|
|
62
|
-
}
|
|
63
|
-
return { teamId, projectId, token };
|
|
64
|
-
}
|
|
65
218
|
static getDomainName(domain) {
|
|
66
219
|
const metaName = typeof domain?.meta?.name === "string" ? domain.meta.name.trim() : "";
|
|
67
220
|
const contextName = typeof domain?.context === "function" ? String(domain.context()?.name ?? "").trim() : "";
|
|
@@ -105,7 +258,9 @@ export class SandboxService {
|
|
|
105
258
|
}
|
|
106
259
|
static buildEkairosManifest(params) {
|
|
107
260
|
const contextString = SandboxService.getDomainContextString(params.domain);
|
|
108
|
-
const schemaJson = SandboxService.cloneJson(params.domain.
|
|
261
|
+
const schemaJson = SandboxService.cloneJson(typeof params.domain.instantSchema === "function"
|
|
262
|
+
? params.domain.instantSchema()
|
|
263
|
+
: params.domain.toInstantSchema());
|
|
109
264
|
return {
|
|
110
265
|
version: 1,
|
|
111
266
|
instant: {
|
|
@@ -245,7 +400,7 @@ export class SandboxService {
|
|
|
245
400
|
if (!config.env || !config.domain) {
|
|
246
401
|
throw new Error("sandbox_runtime_requires_env_and_domain");
|
|
247
402
|
}
|
|
248
|
-
const provider =
|
|
403
|
+
const provider = resolveProvider(config);
|
|
249
404
|
if (provider !== "vercel") {
|
|
250
405
|
throw new Error("ekairos_runtime_requires_vercel_provider");
|
|
251
406
|
}
|
|
@@ -347,422 +502,6 @@ export class SandboxService {
|
|
|
347
502
|
fileCount: skill.files.length,
|
|
348
503
|
}));
|
|
349
504
|
}
|
|
350
|
-
static resolveVercelWorkingDirectory(config) {
|
|
351
|
-
const fromConfig = String(config.vercel?.cwd ?? "").trim();
|
|
352
|
-
if (fromConfig)
|
|
353
|
-
return path.resolve(fromConfig);
|
|
354
|
-
const fromEnv = String(process.env.SANDBOX_VERCEL_CWD ?? "").trim();
|
|
355
|
-
if (fromEnv)
|
|
356
|
-
return path.resolve(fromEnv);
|
|
357
|
-
return process.cwd();
|
|
358
|
-
}
|
|
359
|
-
static findLinkedVercelProjectFile(startDir) {
|
|
360
|
-
let current = path.resolve(startDir);
|
|
361
|
-
while (true) {
|
|
362
|
-
const candidate = path.join(current, ".vercel", "project.json");
|
|
363
|
-
if (existsSync(candidate))
|
|
364
|
-
return candidate;
|
|
365
|
-
const parent = path.dirname(current);
|
|
366
|
-
if (parent === current)
|
|
367
|
-
return null;
|
|
368
|
-
current = parent;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
static async readLinkedVercelProject(config) {
|
|
372
|
-
const cwd = SandboxService.resolveVercelWorkingDirectory(config);
|
|
373
|
-
const file = SandboxService.findLinkedVercelProjectFile(cwd);
|
|
374
|
-
if (!file) {
|
|
375
|
-
return { cwd };
|
|
376
|
-
}
|
|
377
|
-
try {
|
|
378
|
-
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
379
|
-
return {
|
|
380
|
-
cwd,
|
|
381
|
-
orgId: typeof parsed?.orgId === "string" ? parsed.orgId : undefined,
|
|
382
|
-
projectId: typeof parsed?.projectId === "string" ? parsed.projectId : undefined,
|
|
383
|
-
projectName: typeof parsed?.projectName === "string" ? parsed.projectName : undefined,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
return { cwd };
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
static async pullVercelOidcToken(config) {
|
|
391
|
-
const cwd = SandboxService.resolveVercelWorkingDirectory(config);
|
|
392
|
-
const tmpPath = path.join(os.tmpdir(), `ekairos-vercel-env-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
|
|
393
|
-
const args = ["env", "pull", tmpPath, "--yes", "--environment", String(config.vercel?.environment ?? "development")];
|
|
394
|
-
const scope = String(config.vercel?.scope ?? process.env.SANDBOX_VERCEL_SCOPE ?? "").trim();
|
|
395
|
-
if (scope) {
|
|
396
|
-
args.push("--scope", scope);
|
|
397
|
-
}
|
|
398
|
-
const token = String(process.env.VERCEL_TOKEN ?? process.env.SANDBOX_VERCEL_TOKEN ?? "").trim();
|
|
399
|
-
if (token) {
|
|
400
|
-
args.push("--token", token);
|
|
401
|
-
}
|
|
402
|
-
const isWindows = process.platform === "win32";
|
|
403
|
-
const command = isWindows ? (process.env.COMSPEC || "cmd.exe") : "vercel";
|
|
404
|
-
const commandArgs = isWindows ? ["/c", "vercel", ...args] : args;
|
|
405
|
-
try {
|
|
406
|
-
await execFileAsync(command, commandArgs, {
|
|
407
|
-
cwd,
|
|
408
|
-
windowsHide: true,
|
|
409
|
-
timeout: 120000,
|
|
410
|
-
maxBuffer: 1024 * 1024 * 10,
|
|
411
|
-
});
|
|
412
|
-
const content = await fs.readFile(tmpPath, "utf8");
|
|
413
|
-
const match = content.match(/VERCEL_OIDC_TOKEN=\"?([^\r\n\"]+)\"?/);
|
|
414
|
-
const oidc = String(match?.[1] ?? "").trim();
|
|
415
|
-
if (!oidc) {
|
|
416
|
-
throw new Error("VERCEL_OIDC_TOKEN missing from vercel env pull output");
|
|
417
|
-
}
|
|
418
|
-
return oidc;
|
|
419
|
-
}
|
|
420
|
-
finally {
|
|
421
|
-
await fs.rm(tmpPath, { force: true }).catch(() => { });
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
static async resolveVercelCredentials(config) {
|
|
425
|
-
const explicitTeamId = String(config.vercel?.orgId ?? process.env.SANDBOX_VERCEL_TEAM_ID ?? "").trim();
|
|
426
|
-
const explicitProjectId = String(config.vercel?.projectId ?? process.env.SANDBOX_VERCEL_PROJECT_ID ?? "").trim();
|
|
427
|
-
const explicitToken = String(config.vercel?.token ?? process.env.SANDBOX_VERCEL_TOKEN ?? process.env.VERCEL_OIDC_TOKEN ?? "").trim();
|
|
428
|
-
if (explicitTeamId && explicitProjectId && explicitToken) {
|
|
429
|
-
return { teamId: explicitTeamId, projectId: explicitProjectId, token: explicitToken };
|
|
430
|
-
}
|
|
431
|
-
const linked = await SandboxService.readLinkedVercelProject(config);
|
|
432
|
-
const teamId = explicitTeamId || String(linked.orgId ?? "").trim();
|
|
433
|
-
const projectId = explicitProjectId || String(linked.projectId ?? "").trim();
|
|
434
|
-
let token = explicitToken;
|
|
435
|
-
if (!token) {
|
|
436
|
-
token = await SandboxService.pullVercelOidcToken(config);
|
|
437
|
-
}
|
|
438
|
-
if (!teamId || !projectId || !token) {
|
|
439
|
-
throw new Error("Missing Vercel sandbox credentials. Link the project (`vercel link`) and ensure `vercel env pull` can resolve VERCEL_OIDC_TOKEN, or provide explicit SANDBOX_VERCEL_* env vars.");
|
|
440
|
-
}
|
|
441
|
-
return { teamId, projectId, token };
|
|
442
|
-
}
|
|
443
|
-
static async provisionVercelSandbox(config, extra) {
|
|
444
|
-
const creds = await SandboxService.resolveVercelCredentials(config);
|
|
445
|
-
return await VercelSandbox.create({
|
|
446
|
-
teamId: creds.teamId,
|
|
447
|
-
projectId: creds.projectId,
|
|
448
|
-
token: creds.token,
|
|
449
|
-
timeout: config.timeoutMs ?? 30 * 60 * 1000,
|
|
450
|
-
ports: Array.isArray(config.ports) ? config.ports : [],
|
|
451
|
-
// IMPORTANT: pass runtime as-is (e.g. "python3.13") to match provider expectations.
|
|
452
|
-
// Don't normalize to "python3"/"node22" as that can cause provider-side 400s.
|
|
453
|
-
runtime: (config.runtime ?? "node22"),
|
|
454
|
-
resources: { vcpus: config.resources?.vcpus ?? 2 },
|
|
455
|
-
networkPolicy: extra?.networkPolicy,
|
|
456
|
-
env: extra?.env,
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
static getDaytonaConfig() {
|
|
460
|
-
const apiKey = String(process.env.DAYTONA_API_KEY ?? "").trim();
|
|
461
|
-
const apiUrl = String(process.env.DAYTONA_API_URL ?? "").trim() ||
|
|
462
|
-
String(process.env.DAYTONA_SERVER_URL ?? "").trim();
|
|
463
|
-
const jwtToken = String(process.env.DAYTONA_JWT_TOKEN ?? "").trim();
|
|
464
|
-
const organizationId = String(process.env.DAYTONA_ORGANIZATION_ID ?? "").trim();
|
|
465
|
-
const target = String(process.env.DAYTONA_TARGET ?? "").trim();
|
|
466
|
-
if (!apiUrl) {
|
|
467
|
-
throw new Error("Missing required Daytona env var: DAYTONA_API_URL (or DAYTONA_SERVER_URL)");
|
|
468
|
-
}
|
|
469
|
-
if (!apiKey && !(jwtToken && organizationId)) {
|
|
470
|
-
throw new Error("Missing required Daytona env vars: DAYTONA_API_KEY or DAYTONA_JWT_TOKEN + DAYTONA_ORGANIZATION_ID");
|
|
471
|
-
}
|
|
472
|
-
const config = {
|
|
473
|
-
apiUrl,
|
|
474
|
-
target: target || undefined,
|
|
475
|
-
apiKey: apiKey || undefined,
|
|
476
|
-
jwtToken: jwtToken || undefined,
|
|
477
|
-
organizationId: organizationId || undefined,
|
|
478
|
-
};
|
|
479
|
-
return config;
|
|
480
|
-
}
|
|
481
|
-
static normalizeBaseUrl(raw) {
|
|
482
|
-
const trimmed = String(raw ?? "").trim();
|
|
483
|
-
if (!trimmed)
|
|
484
|
-
return "";
|
|
485
|
-
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
486
|
-
}
|
|
487
|
-
static getSpritesConfig() {
|
|
488
|
-
const token = String(process.env.SPRITES_API_TOKEN ?? process.env.SPRITE_TOKEN ?? "").trim();
|
|
489
|
-
if (!token) {
|
|
490
|
-
throw new Error("Missing required Sprites token env var: SPRITES_API_TOKEN (or SPRITE_TOKEN)");
|
|
491
|
-
}
|
|
492
|
-
const baseUrl = SandboxService.normalizeBaseUrl(String(process.env.SPRITES_API_BASE_URL ?? process.env.SPRITES_API_URL ?? "").trim()) || "https://api.sprites.dev";
|
|
493
|
-
return { baseUrl, token };
|
|
494
|
-
}
|
|
495
|
-
static async spritesFetch(path, init) {
|
|
496
|
-
const { baseUrl, token } = SandboxService.getSpritesConfig();
|
|
497
|
-
const fetchFn = globalThis?.fetch;
|
|
498
|
-
if (typeof fetchFn !== "function") {
|
|
499
|
-
throw new Error("fetch_not_available");
|
|
500
|
-
}
|
|
501
|
-
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
502
|
-
const url = `${baseUrl}${normalizedPath}`;
|
|
503
|
-
return await fetchFn(url, {
|
|
504
|
-
...init,
|
|
505
|
-
headers: {
|
|
506
|
-
Authorization: `Bearer ${token}`,
|
|
507
|
-
...(init?.headers ?? {}),
|
|
508
|
-
},
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
static async spritesJson(path, init) {
|
|
512
|
-
const res = await SandboxService.spritesFetch(path, {
|
|
513
|
-
...init,
|
|
514
|
-
headers: {
|
|
515
|
-
Accept: "application/json",
|
|
516
|
-
...(init?.headers ?? {}),
|
|
517
|
-
},
|
|
518
|
-
});
|
|
519
|
-
if (!res?.ok) {
|
|
520
|
-
const text = await res?.text?.().catch(() => "");
|
|
521
|
-
throw new Error(`sprites_http_${res?.status ?? "unknown"}: ${text || "request_failed"}`);
|
|
522
|
-
}
|
|
523
|
-
return (await res.json().catch(() => ({})));
|
|
524
|
-
}
|
|
525
|
-
static async spritesText(path, init) {
|
|
526
|
-
const res = await SandboxService.spritesFetch(path, init);
|
|
527
|
-
const text = await res?.text?.().catch(() => "");
|
|
528
|
-
return { ok: Boolean(res?.ok), status: Number(res?.status ?? 0), text: String(text ?? "") };
|
|
529
|
-
}
|
|
530
|
-
static toSpritesPreviewUrl(spriteUrl, port) {
|
|
531
|
-
const base = String(spriteUrl ?? "").trim();
|
|
532
|
-
if (!base)
|
|
533
|
-
return "";
|
|
534
|
-
try {
|
|
535
|
-
const u = new URL(base);
|
|
536
|
-
if (Number.isFinite(port) && port > 0) {
|
|
537
|
-
u.port = String(Math.floor(port));
|
|
538
|
-
}
|
|
539
|
-
const next = u.toString();
|
|
540
|
-
return next.endsWith("/") ? next.slice(0, -1) : next;
|
|
541
|
-
}
|
|
542
|
-
catch {
|
|
543
|
-
// Best effort fallback: append port if missing.
|
|
544
|
-
if (!port)
|
|
545
|
-
return base;
|
|
546
|
-
return base.replace(/\/+$/, "") + ":" + String(Math.floor(port));
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
static asSpritesSandbox(sprite) {
|
|
550
|
-
const name = String(sprite?.name ?? "").trim();
|
|
551
|
-
const url = typeof sprite?.url === "string" ? sprite.url : undefined;
|
|
552
|
-
return {
|
|
553
|
-
__provider: "sprites",
|
|
554
|
-
name,
|
|
555
|
-
id: sprite?.id ? String(sprite.id) : undefined,
|
|
556
|
-
url,
|
|
557
|
-
getPreviewLink: async (port) => {
|
|
558
|
-
const base = url ?? "";
|
|
559
|
-
const next = SandboxService.toSpritesPreviewUrl(base, port);
|
|
560
|
-
return { url: next };
|
|
561
|
-
},
|
|
562
|
-
domain: async (port) => {
|
|
563
|
-
const base = url ?? "";
|
|
564
|
-
return SandboxService.toSpritesPreviewUrl(base, port);
|
|
565
|
-
},
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
static async getSpritesByName(name) {
|
|
569
|
-
const safeName = String(name ?? "").trim();
|
|
570
|
-
if (!safeName)
|
|
571
|
-
return { ok: false, status: 400, error: "sprites_name_required" };
|
|
572
|
-
const res = await SandboxService.spritesFetch(`/v1/sprites/${encodeURIComponent(safeName)}`, {
|
|
573
|
-
method: "GET",
|
|
574
|
-
headers: { Accept: "application/json" },
|
|
575
|
-
});
|
|
576
|
-
if (!res?.ok) {
|
|
577
|
-
const text = await res?.text?.().catch(() => "");
|
|
578
|
-
return { ok: false, status: Number(res?.status ?? 0), error: text || `sprites_http_${res?.status ?? "unknown"}` };
|
|
579
|
-
}
|
|
580
|
-
const json = await res.json().catch(() => ({}));
|
|
581
|
-
return { ok: true, sprite: json };
|
|
582
|
-
}
|
|
583
|
-
static async provisionSpritesSandbox(params) {
|
|
584
|
-
const requestedName = String(params.config?.sprites?.name ?? "").trim();
|
|
585
|
-
const name = requestedName || `ekairos-${params.sandboxId}`;
|
|
586
|
-
// Idempotent: if already exists, reuse.
|
|
587
|
-
const existing = await SandboxService.getSpritesByName(name);
|
|
588
|
-
if (existing.ok) {
|
|
589
|
-
const sprite = existing.sprite ?? {};
|
|
590
|
-
return SandboxService.asSpritesSandbox({
|
|
591
|
-
name: String(sprite?.name ?? name),
|
|
592
|
-
id: sprite?.id ? String(sprite.id) : undefined,
|
|
593
|
-
url: typeof sprite?.url === "string" ? sprite.url : undefined,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
const waitForCapacity = params.config?.sprites?.waitForCapacity ?? true;
|
|
597
|
-
const auth = params.config?.sprites?.urlSettings?.auth ?? "public";
|
|
598
|
-
const body = {
|
|
599
|
-
name,
|
|
600
|
-
wait_for_capacity: Boolean(waitForCapacity),
|
|
601
|
-
url_settings: { auth },
|
|
602
|
-
};
|
|
603
|
-
const created = await SandboxService.spritesJson("/v1/sprites", {
|
|
604
|
-
method: "POST",
|
|
605
|
-
headers: { "Content-Type": "application/json" },
|
|
606
|
-
body: JSON.stringify(body),
|
|
607
|
-
});
|
|
608
|
-
return SandboxService.asSpritesSandbox({
|
|
609
|
-
name: String(created?.name ?? name),
|
|
610
|
-
id: created?.id ? String(created.id) : undefined,
|
|
611
|
-
url: typeof created?.url === "string" ? created.url : undefined,
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
static normalizeSpritesExecResult(payload) {
|
|
615
|
-
const exitCodeRaw = payload?.exit_code ??
|
|
616
|
-
payload?.exitCode ??
|
|
617
|
-
payload?.code ??
|
|
618
|
-
payload?.status ??
|
|
619
|
-
payload?.result?.exit_code ??
|
|
620
|
-
payload?.result?.exitCode;
|
|
621
|
-
const exitCode = Number(exitCodeRaw ?? 0);
|
|
622
|
-
const stdout = typeof payload?.stdout === "string"
|
|
623
|
-
? payload.stdout
|
|
624
|
-
: typeof payload?.output === "string"
|
|
625
|
-
? payload.output
|
|
626
|
-
: typeof payload?.out === "string"
|
|
627
|
-
? payload.out
|
|
628
|
-
: typeof payload?.result?.stdout === "string"
|
|
629
|
-
? payload.result.stdout
|
|
630
|
-
: "";
|
|
631
|
-
const stderr = typeof payload?.stderr === "string"
|
|
632
|
-
? payload.stderr
|
|
633
|
-
: typeof payload?.error === "string"
|
|
634
|
-
? payload.error
|
|
635
|
-
: typeof payload?.err === "string"
|
|
636
|
-
? payload.err
|
|
637
|
-
: typeof payload?.result?.stderr === "string"
|
|
638
|
-
? payload.result.stderr
|
|
639
|
-
: "";
|
|
640
|
-
return {
|
|
641
|
-
exitCode: Number.isFinite(exitCode) ? exitCode : 0,
|
|
642
|
-
stdout,
|
|
643
|
-
stderr,
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
static async spritesExec(params) {
|
|
647
|
-
const spriteName = String(params.spriteName ?? "").trim();
|
|
648
|
-
if (!spriteName)
|
|
649
|
-
throw new Error("sprites_name_required");
|
|
650
|
-
const parts = [String(params.command ?? "").trim(), ...(Array.isArray(params.args) ? params.args : [])].filter(Boolean);
|
|
651
|
-
if (parts.length === 0)
|
|
652
|
-
throw new Error("sprites_command_required");
|
|
653
|
-
const search = new URLSearchParams();
|
|
654
|
-
for (const part of parts) {
|
|
655
|
-
search.append("cmd", String(part));
|
|
656
|
-
}
|
|
657
|
-
const hasStdin = typeof params.stdin === "string" || Buffer.isBuffer(params.stdin);
|
|
658
|
-
if (hasStdin) {
|
|
659
|
-
search.set("stdin", "true");
|
|
660
|
-
}
|
|
661
|
-
const path = `/v1/sprites/${encodeURIComponent(spriteName)}/exec?${search.toString()}`;
|
|
662
|
-
const init = {
|
|
663
|
-
method: "POST",
|
|
664
|
-
};
|
|
665
|
-
if (hasStdin) {
|
|
666
|
-
init.body = params.stdin;
|
|
667
|
-
}
|
|
668
|
-
const res = await SandboxService.spritesFetch(path, init);
|
|
669
|
-
const text = await res?.text?.().catch(() => "");
|
|
670
|
-
const parsed = (() => {
|
|
671
|
-
try {
|
|
672
|
-
return text ? JSON.parse(text) : {};
|
|
673
|
-
}
|
|
674
|
-
catch {
|
|
675
|
-
return { stdout: String(text ?? "") };
|
|
676
|
-
}
|
|
677
|
-
})();
|
|
678
|
-
if (!res?.ok) {
|
|
679
|
-
const err = typeof parsed?.error === "string" ? parsed.error : text;
|
|
680
|
-
throw new Error(err || `sprites_exec_http_${res?.status ?? "unknown"}`);
|
|
681
|
-
}
|
|
682
|
-
return SandboxService.normalizeSpritesExecResult(parsed);
|
|
683
|
-
}
|
|
684
|
-
static resolveProvider(config) {
|
|
685
|
-
const explicit = String(config.provider ?? "").trim().toLowerCase();
|
|
686
|
-
if (explicit === "daytona")
|
|
687
|
-
return "daytona";
|
|
688
|
-
if (explicit === "vercel")
|
|
689
|
-
return "vercel";
|
|
690
|
-
if (explicit === "sprites")
|
|
691
|
-
return "sprites";
|
|
692
|
-
const env = String(process.env.SANDBOX_PROVIDER ?? "").trim().toLowerCase();
|
|
693
|
-
if (env === "daytona")
|
|
694
|
-
return "daytona";
|
|
695
|
-
if (env === "vercel")
|
|
696
|
-
return "vercel";
|
|
697
|
-
if (env === "sprites")
|
|
698
|
-
return "sprites";
|
|
699
|
-
return "sprites";
|
|
700
|
-
}
|
|
701
|
-
static resolveDaytonaLanguage(config) {
|
|
702
|
-
if (config.daytona?.language)
|
|
703
|
-
return config.daytona.language;
|
|
704
|
-
const runtime = String(config.runtime ?? "").toLowerCase();
|
|
705
|
-
if (runtime.startsWith("python"))
|
|
706
|
-
return "python";
|
|
707
|
-
if (runtime.startsWith("node"))
|
|
708
|
-
return "javascript";
|
|
709
|
-
if (runtime.startsWith("ts") || runtime.includes("typescript"))
|
|
710
|
-
return "typescript";
|
|
711
|
-
return undefined;
|
|
712
|
-
}
|
|
713
|
-
static async resolveDaytonaVolumes(daytona, volumes) {
|
|
714
|
-
if (!volumes || volumes.length === 0)
|
|
715
|
-
return [];
|
|
716
|
-
const resolved = [];
|
|
717
|
-
const shouldLog = SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_LOG_VOLUMES) ?? false;
|
|
718
|
-
for (const volume of volumes) {
|
|
719
|
-
const mountPath = String(volume.mountPath ?? "").trim();
|
|
720
|
-
if (!mountPath)
|
|
721
|
-
continue;
|
|
722
|
-
const volumeId = String(volume.volumeId ?? "").trim();
|
|
723
|
-
if (volumeId) {
|
|
724
|
-
resolved.push({ volumeId, mountPath });
|
|
725
|
-
continue;
|
|
726
|
-
}
|
|
727
|
-
const volumeName = String(volume.volumeName ?? "").trim();
|
|
728
|
-
if (!volumeName) {
|
|
729
|
-
throw new Error("Daytona volume requires volumeId or volumeName");
|
|
730
|
-
}
|
|
731
|
-
let resolvedVolume = await daytona.volume.get(volumeName, true);
|
|
732
|
-
const stateRaw = String(resolvedVolume?.state ?? "").trim().toLowerCase();
|
|
733
|
-
const waitStates = new Set(["creating", "provisioning", "pending", "pending_create", "pending-create", "initializing"]);
|
|
734
|
-
const readyStates = new Set(["available", "active", "ready"]);
|
|
735
|
-
if (waitStates.has(stateRaw)) {
|
|
736
|
-
const maxAttempts = 12;
|
|
737
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
738
|
-
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
739
|
-
resolvedVolume = await daytona.volume.get(volumeName, true);
|
|
740
|
-
const state = String(resolvedVolume?.state ?? "").trim().toLowerCase();
|
|
741
|
-
if (shouldLog) {
|
|
742
|
-
console.log(`[daytona:volume] name=${volumeName} state=${state} attempt=${attempt}/${maxAttempts}`);
|
|
743
|
-
}
|
|
744
|
-
if (readyStates.has(state))
|
|
745
|
-
break;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
const finalState = String(resolvedVolume?.state ?? "").trim().toLowerCase();
|
|
749
|
-
if (finalState && !readyStates.has(finalState)) {
|
|
750
|
-
if (shouldLog) {
|
|
751
|
-
console.log(`[daytona:volume] name=${volumeName} state=${finalState} mountPath=${mountPath} (not ready)`);
|
|
752
|
-
}
|
|
753
|
-
throw new Error(`Daytona volume not ready: ${volumeName} (state=${finalState})`);
|
|
754
|
-
}
|
|
755
|
-
const resolvedId = String(resolvedVolume?.id ?? "").trim();
|
|
756
|
-
if (!resolvedId) {
|
|
757
|
-
throw new Error(`Daytona volume not resolved: ${volumeName}`);
|
|
758
|
-
}
|
|
759
|
-
if (shouldLog) {
|
|
760
|
-
console.log(`[daytona:volume] name=${volumeName} id=${resolvedId} mountPath=${mountPath}`);
|
|
761
|
-
}
|
|
762
|
-
resolved.push({ volumeId: resolvedId, mountPath });
|
|
763
|
-
}
|
|
764
|
-
return resolved;
|
|
765
|
-
}
|
|
766
505
|
static shellEscapeArg(value) {
|
|
767
506
|
if (value.length === 0)
|
|
768
507
|
return "''";
|
|
@@ -780,58 +519,11 @@ export class SandboxService {
|
|
|
780
519
|
return false;
|
|
781
520
|
return undefined;
|
|
782
521
|
}
|
|
783
|
-
static parseCsvList(value) {
|
|
784
|
-
return String(value ?? "")
|
|
785
|
-
.split(",")
|
|
786
|
-
.map((entry) => entry.trim())
|
|
787
|
-
.filter(Boolean);
|
|
788
|
-
}
|
|
789
|
-
static resolvePythonVersion(runtime) {
|
|
790
|
-
const fromEnv = String(process.env.SANDBOX_DAYTONA_DECLARATIVE_PYTHON ?? "").trim() ||
|
|
791
|
-
String(process.env.STRUCTURE_DAYTONA_DECLARATIVE_PYTHON ?? "").trim();
|
|
792
|
-
if (fromEnv)
|
|
793
|
-
return fromEnv;
|
|
794
|
-
const match = String(runtime ?? "").match(/python\s*([0-9]+\.[0-9]+)/i);
|
|
795
|
-
if (match?.[1])
|
|
796
|
-
return match[1];
|
|
797
|
-
return "3.12";
|
|
798
|
-
}
|
|
799
|
-
static buildDeclarativeImage(config) {
|
|
800
|
-
const imageFlag = String(config.daytona?.image ?? "").trim();
|
|
801
|
-
const envFlag = SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DECLARATIVE_IMAGE) ??
|
|
802
|
-
SandboxService.parseOptionalBoolean(process.env.STRUCTURE_DAYTONA_DECLARATIVE_IMAGE) ??
|
|
803
|
-
false;
|
|
804
|
-
const useDeclarative = envFlag || imageFlag.startsWith("declarative");
|
|
805
|
-
if (!useDeclarative)
|
|
806
|
-
return undefined;
|
|
807
|
-
const baseImage = String(process.env.SANDBOX_DAYTONA_DECLARATIVE_BASE ?? "").trim() ||
|
|
808
|
-
String(process.env.STRUCTURE_DAYTONA_DECLARATIVE_BASE ?? "").trim();
|
|
809
|
-
const pythonVersion = SandboxService.resolvePythonVersion(config.runtime);
|
|
810
|
-
const isStructureDataset = config.purpose === "structure.dataset" || typeof config.params?.datasetId === "string";
|
|
811
|
-
const defaultPackages = isStructureDataset ? ["pandas", "openpyxl"] : [];
|
|
812
|
-
const packages = [
|
|
813
|
-
...SandboxService.parseCsvList(process.env.SANDBOX_DAYTONA_DECLARATIVE_PIP),
|
|
814
|
-
...SandboxService.parseCsvList(process.env.STRUCTURE_DAYTONA_DECLARATIVE_PIP),
|
|
815
|
-
...defaultPackages,
|
|
816
|
-
];
|
|
817
|
-
const uniquePackages = Array.from(new Set(packages));
|
|
818
|
-
let image;
|
|
819
|
-
if (baseImage) {
|
|
820
|
-
image = Image.base(baseImage);
|
|
821
|
-
}
|
|
822
|
-
else {
|
|
823
|
-
image = Image.debianSlim(pythonVersion);
|
|
824
|
-
}
|
|
825
|
-
if (uniquePackages.length > 0) {
|
|
826
|
-
image = image.pipInstall(uniquePackages);
|
|
827
|
-
}
|
|
828
|
-
image = image.workdir("/home/daytona");
|
|
829
|
-
return image;
|
|
830
|
-
}
|
|
831
522
|
async createSandbox(config) {
|
|
832
523
|
const sandboxId = id();
|
|
833
524
|
const now = Date.now();
|
|
834
|
-
const provider =
|
|
525
|
+
const provider = resolveProvider(config);
|
|
526
|
+
const resolvedVercel = provider === "vercel" ? resolveVercelSandboxConfig(config, { sandboxId }) : undefined;
|
|
835
527
|
let daytonaEphemeral = undefined;
|
|
836
528
|
let installedSkills = [];
|
|
837
529
|
try {
|
|
@@ -841,13 +533,14 @@ export class SandboxService {
|
|
|
841
533
|
status: "creating",
|
|
842
534
|
...(ekairos ? { sandboxUserId: ekairos.sandboxUserId } : {}),
|
|
843
535
|
provider,
|
|
844
|
-
timeout: config.timeoutMs,
|
|
845
|
-
runtime: config.runtime,
|
|
846
|
-
vcpus: config.resources?.vcpus,
|
|
847
|
-
ports: config.ports,
|
|
536
|
+
timeout: resolvedVercel?.timeoutMs ?? config.timeoutMs,
|
|
537
|
+
runtime: resolvedVercel?.runtime ?? config.runtime,
|
|
538
|
+
vcpus: resolvedVercel?.vcpus ?? config.resources?.vcpus,
|
|
539
|
+
ports: (resolvedVercel?.ports ?? config.ports),
|
|
848
540
|
purpose: config.purpose,
|
|
849
541
|
params: {
|
|
850
542
|
...baseParams,
|
|
543
|
+
...(resolvedVercel ? { vercel: safeVercelConfigForRecord(config, resolvedVercel) } : {}),
|
|
851
544
|
...(ekairos
|
|
852
545
|
? {
|
|
853
546
|
ekairos: {
|
|
@@ -881,10 +574,10 @@ export class SandboxService {
|
|
|
881
574
|
let sandbox = null;
|
|
882
575
|
try {
|
|
883
576
|
if (provider === "daytona") {
|
|
884
|
-
const daytona = new Daytona(
|
|
885
|
-
const language =
|
|
577
|
+
const daytona = new Daytona(getDaytonaConfig());
|
|
578
|
+
const language = resolveDaytonaLanguage(config);
|
|
886
579
|
const requestedVolumes = config.daytona?.volumes ?? [];
|
|
887
|
-
const volumes = await
|
|
580
|
+
const volumes = await resolveDaytonaVolumes(daytona, requestedVolumes);
|
|
888
581
|
const envVars = config.daytona?.envVars;
|
|
889
582
|
const isPublic = config.daytona?.public;
|
|
890
583
|
const envEphemeral = SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_EPHEMERAL);
|
|
@@ -895,7 +588,7 @@ export class SandboxService {
|
|
|
895
588
|
const autoArchiveInterval = config.daytona?.autoArchiveIntervalMin;
|
|
896
589
|
const autoDeleteInterval = config.daytona?.autoDeleteIntervalMin;
|
|
897
590
|
const resolvedAutoDeleteInterval = ephemeral ? undefined : autoDeleteInterval;
|
|
898
|
-
const declarativeImage =
|
|
591
|
+
const declarativeImage = buildDeclarativeImage(config);
|
|
899
592
|
const image = declarativeImage ?? config.daytona?.image;
|
|
900
593
|
const snapshot = config.daytona?.snapshot;
|
|
901
594
|
const resources = config.resources?.vcpus ? { cpu: config.resources.vcpus } : undefined;
|
|
@@ -934,7 +627,7 @@ export class SandboxService {
|
|
|
934
627
|
}
|
|
935
628
|
}
|
|
936
629
|
else if (provider === "sprites") {
|
|
937
|
-
sandbox = await
|
|
630
|
+
sandbox = await provisionSpritesSandbox({
|
|
938
631
|
sandboxId,
|
|
939
632
|
config,
|
|
940
633
|
});
|
|
@@ -944,9 +637,10 @@ export class SandboxService {
|
|
|
944
637
|
...(Array.isArray(config.skills) && config.skills.length > 0 ? { CODEX_HOME: CODEX_HOME_DIR } : {}),
|
|
945
638
|
...(ekairos?.env ?? {}),
|
|
946
639
|
};
|
|
947
|
-
sandbox = await
|
|
640
|
+
sandbox = await provisionVercelSandbox(config, {
|
|
948
641
|
networkPolicy: ekairos?.networkPolicy,
|
|
949
642
|
env: Object.keys(vercelEnv).length > 0 ? vercelEnv : undefined,
|
|
643
|
+
resolved: resolvedVercel,
|
|
950
644
|
});
|
|
951
645
|
if (ekairos) {
|
|
952
646
|
await SandboxService.bootstrapEkairosFiles(sandbox, ekairos.manifest);
|
|
@@ -960,7 +654,10 @@ export class SandboxService {
|
|
|
960
654
|
const msg = formatSandboxError(e);
|
|
961
655
|
if (sandbox && provider === "vercel") {
|
|
962
656
|
try {
|
|
963
|
-
await sandbox.stop();
|
|
657
|
+
await sandbox.stop({ blocking: true });
|
|
658
|
+
if (resolvedVercel?.deleteOnStop) {
|
|
659
|
+
await sandbox.delete();
|
|
660
|
+
}
|
|
964
661
|
}
|
|
965
662
|
catch {
|
|
966
663
|
// ignore cleanup errors during failed bootstrap
|
|
@@ -977,7 +674,7 @@ export class SandboxService {
|
|
|
977
674
|
? sandbox.id
|
|
978
675
|
: provider === "sprites"
|
|
979
676
|
? String(sandbox.name)
|
|
980
|
-
: sandbox.
|
|
677
|
+
: sandbox.name;
|
|
981
678
|
const sandboxUrl = provider === "sprites" ? sandbox.url : undefined;
|
|
982
679
|
const activateMutations = [
|
|
983
680
|
this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
@@ -1009,10 +706,7 @@ export class SandboxService {
|
|
|
1009
706
|
: {}),
|
|
1010
707
|
...(provider === "vercel"
|
|
1011
708
|
? {
|
|
1012
|
-
vercel: {
|
|
1013
|
-
...baseParams?.vercel,
|
|
1014
|
-
...(config.vercel ?? {}),
|
|
1015
|
-
},
|
|
709
|
+
vercel: resolvedVercel ? safeVercelConfigForRecord(config, resolvedVercel) : {},
|
|
1016
710
|
}
|
|
1017
711
|
: {}),
|
|
1018
712
|
...(provider === "daytona"
|
|
@@ -1058,7 +752,7 @@ export class SandboxService {
|
|
|
1058
752
|
return { ok: false, error: "Valid sandbox record not found" };
|
|
1059
753
|
}
|
|
1060
754
|
if (record.provider === "daytona") {
|
|
1061
|
-
const daytona = new Daytona(
|
|
755
|
+
const daytona = new Daytona(getDaytonaConfig());
|
|
1062
756
|
try {
|
|
1063
757
|
const sandbox = await daytona.get(String(record.externalSandboxId));
|
|
1064
758
|
const state = String(sandbox.state ?? "").toLowerCase();
|
|
@@ -1082,7 +776,7 @@ export class SandboxService {
|
|
|
1082
776
|
if (record.provider === "sprites") {
|
|
1083
777
|
const name = String(record.externalSandboxId ?? "").trim();
|
|
1084
778
|
try {
|
|
1085
|
-
const spriteRes = await
|
|
779
|
+
const spriteRes = await getSpritesByName(name);
|
|
1086
780
|
if (!spriteRes.ok) {
|
|
1087
781
|
if (record.status === "active") {
|
|
1088
782
|
await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
@@ -1094,7 +788,7 @@ export class SandboxService {
|
|
|
1094
788
|
return { ok: false, error: spriteRes.error || "sprites_not_found" };
|
|
1095
789
|
}
|
|
1096
790
|
const sprite = spriteRes.sprite ?? {};
|
|
1097
|
-
const spritesSandbox =
|
|
791
|
+
const spritesSandbox = asSpritesSandbox({
|
|
1098
792
|
name: String(sprite?.name ?? name),
|
|
1099
793
|
id: sprite?.id ? String(sprite.id) : undefined,
|
|
1100
794
|
url: typeof sprite?.url === "string" ? sprite.url : undefined,
|
|
@@ -1138,16 +832,17 @@ export class SandboxService {
|
|
|
1138
832
|
if (record.provider !== "vercel") {
|
|
1139
833
|
return { ok: false, error: "Valid sandbox record not found" };
|
|
1140
834
|
}
|
|
1141
|
-
const creds = await
|
|
835
|
+
const creds = await resolveVercelCredentials(record?.params ?? {});
|
|
1142
836
|
try {
|
|
1143
837
|
const maxAttempts = 20;
|
|
1144
838
|
const delayMs = 500;
|
|
1145
839
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1146
840
|
const sandbox = await VercelSandbox.get({
|
|
1147
|
-
|
|
841
|
+
name: String(record.externalSandboxId),
|
|
1148
842
|
teamId: creds.teamId,
|
|
1149
843
|
projectId: creds.projectId,
|
|
1150
844
|
token: creds.token,
|
|
845
|
+
resume: true,
|
|
1151
846
|
});
|
|
1152
847
|
if (!sandbox)
|
|
1153
848
|
return { ok: false, error: "Sandbox not found" };
|
|
@@ -1175,11 +870,262 @@ export class SandboxService {
|
|
|
1175
870
|
}
|
|
1176
871
|
}
|
|
1177
872
|
async getSandboxRecord(sandboxId) {
|
|
1178
|
-
const
|
|
873
|
+
const query = {
|
|
1179
874
|
sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 }, user: {} },
|
|
1180
|
-
}
|
|
875
|
+
};
|
|
876
|
+
const recordResult = await this.adminDb.query(query);
|
|
1181
877
|
return recordResult?.sandbox_sandboxes?.[0] ?? null;
|
|
1182
878
|
}
|
|
879
|
+
async getProcessSnapshot(processId) {
|
|
880
|
+
try {
|
|
881
|
+
const query = {
|
|
882
|
+
sandbox_processes: {
|
|
883
|
+
$: { where: { id: processId }, limit: 1 },
|
|
884
|
+
sandbox: {},
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
const processResult = await this.adminDb.query(query);
|
|
888
|
+
const processRow = processResult?.sandbox_processes?.[0];
|
|
889
|
+
if (!processRow)
|
|
890
|
+
return { ok: false, error: "sandbox_process_not_found" };
|
|
891
|
+
return { ok: true, data: processRow };
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
async markOpenProcessesLost(sandboxId, reason) {
|
|
898
|
+
try {
|
|
899
|
+
const processResult = await this.adminDb.query({
|
|
900
|
+
sandbox_processes: {
|
|
901
|
+
$: {
|
|
902
|
+
where: { "sandbox.id": sandboxId },
|
|
903
|
+
limit: 500,
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
const rows = Array.isArray(processResult?.sandbox_processes)
|
|
908
|
+
? processResult.sandbox_processes
|
|
909
|
+
: [];
|
|
910
|
+
const now = Date.now();
|
|
911
|
+
const txs = rows
|
|
912
|
+
.filter((row) => !SANDBOX_PROCESS_TERMINAL_STATUSES.has(String(row?.status ?? "")))
|
|
913
|
+
.map((row) => this.adminDb.tx.sandbox_processes[String(row.id)].update({
|
|
914
|
+
status: "lost",
|
|
915
|
+
streamFinishedAt: row.streamFinishedAt ?? now,
|
|
916
|
+
streamAbortReason: reason,
|
|
917
|
+
exitedAt: now,
|
|
918
|
+
updatedAt: now,
|
|
919
|
+
metadata: {
|
|
920
|
+
...(row.metadata ?? {}),
|
|
921
|
+
lostReason: reason,
|
|
922
|
+
},
|
|
923
|
+
}));
|
|
924
|
+
if (txs.length > 0) {
|
|
925
|
+
await this.adminDb.transact(txs);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
// Best-effort cleanup; stopping the sandbox should not fail because process metadata could not be marked.
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
async createProcessStream(params) {
|
|
933
|
+
const streams = this.adminDb?.streams;
|
|
934
|
+
if (!streams?.createWriteStream) {
|
|
935
|
+
throw new Error("sandbox_process_streams_unavailable");
|
|
936
|
+
}
|
|
937
|
+
const streamClientId = params.streamClientId || createSandboxProcessStreamClientId(params.processId);
|
|
938
|
+
const stream = streams.createWriteStream({ clientId: streamClientId });
|
|
939
|
+
const streamId = typeof stream.streamId === "function" ? await stream.streamId() : streamClientId;
|
|
940
|
+
return { stream, streamId, streamClientId };
|
|
941
|
+
}
|
|
942
|
+
async writeProcessChunk(params) {
|
|
943
|
+
await params.writer.write(encodeSandboxProcessStreamChunk({
|
|
944
|
+
version: SANDBOX_PROCESS_STREAM_VERSION,
|
|
945
|
+
at: nowIso(),
|
|
946
|
+
seq: params.seq,
|
|
947
|
+
type: params.type,
|
|
948
|
+
sandboxId: params.sandboxId,
|
|
949
|
+
processId: params.processId,
|
|
950
|
+
...(params.data ? { data: sanitizeInstantValue(params.data) } : {}),
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
async readProcessRow(processId) {
|
|
954
|
+
const query = {
|
|
955
|
+
sandbox_processes: {
|
|
956
|
+
$: { where: { id: processId }, limit: 1 },
|
|
957
|
+
sandbox: {},
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
const result = await this.adminDb.query(query);
|
|
961
|
+
return result?.sandbox_processes?.[0] ?? null;
|
|
962
|
+
}
|
|
963
|
+
async writeProcessChunkByProcessId(processId, type, data, opts) {
|
|
964
|
+
const row = await this.readProcessRow(processId);
|
|
965
|
+
if (!row)
|
|
966
|
+
throw new Error("sandbox_process_not_found");
|
|
967
|
+
const linkedSandbox = Array.isArray(row?.sandbox) ? row.sandbox[0] : row?.sandbox;
|
|
968
|
+
const sandboxId = String(linkedSandbox?.id ?? row?.sandboxId ?? "").trim();
|
|
969
|
+
if (!sandboxId)
|
|
970
|
+
throw new Error("sandbox_process_sandbox_missing");
|
|
971
|
+
const streamClientId = String(row.streamClientId ?? "").trim() || createSandboxProcessStreamClientId(processId);
|
|
972
|
+
const streams = this.adminDb?.streams;
|
|
973
|
+
if (!streams?.createWriteStream)
|
|
974
|
+
throw new Error("sandbox_process_streams_unavailable");
|
|
975
|
+
const stream = streams.createWriteStream({ clientId: streamClientId });
|
|
976
|
+
const writer = stream.getWriter();
|
|
977
|
+
try {
|
|
978
|
+
const seq = Number(row.metadata?.lastSeq ?? row.metadata?.chunkCount ?? 0) + 1;
|
|
979
|
+
await this.writeProcessChunk({
|
|
980
|
+
writer,
|
|
981
|
+
sandboxId,
|
|
982
|
+
processId,
|
|
983
|
+
seq,
|
|
984
|
+
type,
|
|
985
|
+
data,
|
|
986
|
+
});
|
|
987
|
+
if (opts?.close) {
|
|
988
|
+
await writer.close();
|
|
989
|
+
}
|
|
990
|
+
await this.adminDb.transact([
|
|
991
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
992
|
+
updatedAt: Date.now(),
|
|
993
|
+
metadata: sanitizeInstantValue({
|
|
994
|
+
...(row.metadata ?? {}),
|
|
995
|
+
lastSeq: seq,
|
|
996
|
+
chunkCount: seq,
|
|
997
|
+
}),
|
|
998
|
+
}),
|
|
999
|
+
]);
|
|
1000
|
+
}
|
|
1001
|
+
finally {
|
|
1002
|
+
try {
|
|
1003
|
+
writer.releaseLock();
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
// ignore
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
async startObservedProcess(sandboxId, opts) {
|
|
1011
|
+
const processId = id();
|
|
1012
|
+
const now = Date.now();
|
|
1013
|
+
try {
|
|
1014
|
+
const record = await this.getSandboxRecord(sandboxId);
|
|
1015
|
+
if (!record)
|
|
1016
|
+
return { ok: false, error: "Valid sandbox record not found" };
|
|
1017
|
+
if (record.status !== "active")
|
|
1018
|
+
return { ok: false, error: `sandbox_not_active:${record.status}` };
|
|
1019
|
+
const streamSession = await this.createProcessStream({ sandboxId, processId });
|
|
1020
|
+
const stream = streamSession.stream;
|
|
1021
|
+
const writer = stream.getWriter();
|
|
1022
|
+
try {
|
|
1023
|
+
await this.adminDb.transact([
|
|
1024
|
+
this.adminDb.tx.sandbox_processes[processId]
|
|
1025
|
+
.update({
|
|
1026
|
+
kind: opts.kind ?? "command",
|
|
1027
|
+
mode: opts.mode ?? "foreground",
|
|
1028
|
+
status: "running",
|
|
1029
|
+
provider: String(record.provider ?? "unknown"),
|
|
1030
|
+
command: sanitizeInstantString(opts.command),
|
|
1031
|
+
args: sanitizeInstantValue(Array.isArray(opts.args) ? opts.args : []),
|
|
1032
|
+
cwd: asOptionalString(opts.cwd),
|
|
1033
|
+
env: sanitizeInstantValue(opts.env),
|
|
1034
|
+
externalProcessId: asOptionalString(opts.externalProcessId),
|
|
1035
|
+
streamId: streamSession.streamId,
|
|
1036
|
+
streamClientId: streamSession.streamClientId,
|
|
1037
|
+
streamStartedAt: now,
|
|
1038
|
+
startedAt: now,
|
|
1039
|
+
updatedAt: now,
|
|
1040
|
+
metadata: sanitizeInstantValue({
|
|
1041
|
+
...(opts.metadata ?? {}),
|
|
1042
|
+
observed: true,
|
|
1043
|
+
lastSeq: 1,
|
|
1044
|
+
chunkCount: 1,
|
|
1045
|
+
}),
|
|
1046
|
+
})
|
|
1047
|
+
.link({ sandbox: sandboxId, stream: streamSession.streamId }),
|
|
1048
|
+
]);
|
|
1049
|
+
await this.writeProcessChunk({
|
|
1050
|
+
writer,
|
|
1051
|
+
sandboxId,
|
|
1052
|
+
processId,
|
|
1053
|
+
seq: 1,
|
|
1054
|
+
type: "status",
|
|
1055
|
+
data: {
|
|
1056
|
+
status: "running",
|
|
1057
|
+
command: opts.command,
|
|
1058
|
+
args: Array.isArray(opts.args) ? opts.args : [],
|
|
1059
|
+
cwd: opts.cwd ?? null,
|
|
1060
|
+
externalProcessId: opts.externalProcessId ?? null,
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
// Keep observed-process streams open across calls; finishObservedProcess closes them.
|
|
1064
|
+
}
|
|
1065
|
+
finally {
|
|
1066
|
+
try {
|
|
1067
|
+
writer.releaseLock();
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
// ignore
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
ok: true,
|
|
1075
|
+
data: {
|
|
1076
|
+
processId,
|
|
1077
|
+
streamId: streamSession.streamId,
|
|
1078
|
+
streamClientId: streamSession.streamClientId,
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
catch (e) {
|
|
1083
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async appendObservedProcessChunk(processId, type, data) {
|
|
1087
|
+
try {
|
|
1088
|
+
await this.writeProcessChunkByProcessId(processId, type, data);
|
|
1089
|
+
return { ok: true, data: undefined };
|
|
1090
|
+
}
|
|
1091
|
+
catch (e) {
|
|
1092
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async finishObservedProcess(processId, opts) {
|
|
1096
|
+
try {
|
|
1097
|
+
const row = await this.readProcessRow(processId);
|
|
1098
|
+
if (!row)
|
|
1099
|
+
return { ok: false, error: "sandbox_process_not_found" };
|
|
1100
|
+
const exitCode = Number.isFinite(Number(opts?.exitCode)) ? Number(opts?.exitCode) : undefined;
|
|
1101
|
+
const status = opts?.status ?? (exitCode === undefined || exitCode === 0 ? "exited" : "failed");
|
|
1102
|
+
await this.writeProcessChunkByProcessId(processId, status === "failed" ? "error" : "exit", {
|
|
1103
|
+
exitCode: exitCode ?? null,
|
|
1104
|
+
status,
|
|
1105
|
+
...(opts?.errorText ? { message: opts.errorText } : {}),
|
|
1106
|
+
}, { close: true });
|
|
1107
|
+
const finishedAt = Date.now();
|
|
1108
|
+
await this.adminDb.transact([
|
|
1109
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1110
|
+
status,
|
|
1111
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
1112
|
+
streamFinishedAt: finishedAt,
|
|
1113
|
+
streamAbortReason: opts?.errorText ?? null,
|
|
1114
|
+
exitedAt: finishedAt,
|
|
1115
|
+
updatedAt: finishedAt,
|
|
1116
|
+
metadata: sanitizeInstantValue({
|
|
1117
|
+
...(row.metadata ?? {}),
|
|
1118
|
+
...(opts?.metadata ?? {}),
|
|
1119
|
+
...(opts?.errorText ? { error: opts.errorText } : {}),
|
|
1120
|
+
}),
|
|
1121
|
+
}),
|
|
1122
|
+
]);
|
|
1123
|
+
return { ok: true, data: undefined };
|
|
1124
|
+
}
|
|
1125
|
+
catch (e) {
|
|
1126
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1183
1129
|
async stopSandbox(sandboxId) {
|
|
1184
1130
|
try {
|
|
1185
1131
|
const result = await this.reconnectToSandbox(sandboxId);
|
|
@@ -1190,18 +1136,24 @@ export class SandboxService {
|
|
|
1190
1136
|
const deleteOnStop = record?.provider === "sprites"
|
|
1191
1137
|
? SandboxService.parseOptionalBoolean(process.env.SANDBOX_SPRITES_DELETE_ON_STOP) ??
|
|
1192
1138
|
Boolean(record?.params?.sprites?.deleteOnStop ?? true)
|
|
1193
|
-
:
|
|
1194
|
-
|
|
1139
|
+
: record?.provider === "vercel"
|
|
1140
|
+
? SandboxService.parseOptionalBoolean(process.env.SANDBOX_VERCEL_DELETE_ON_STOP) ??
|
|
1141
|
+
Boolean(record?.params?.vercel?.deleteOnStop ?? !record?.params?.vercel?.persistent)
|
|
1142
|
+
: SandboxService.parseOptionalBoolean(process.env.SANDBOX_DAYTONA_DELETE_ON_STOP) ??
|
|
1143
|
+
Boolean(record?.params?.daytona?.ephemeral);
|
|
1195
1144
|
if (result.ok) {
|
|
1196
1145
|
try {
|
|
1197
1146
|
const sandbox = result.data.sandbox;
|
|
1198
|
-
if (sandbox
|
|
1199
|
-
await sandbox.stop();
|
|
1147
|
+
if (isVercelSandbox(sandbox)) {
|
|
1148
|
+
await sandbox.stop({ blocking: true });
|
|
1149
|
+
if (deleteOnStop) {
|
|
1150
|
+
await sandbox.delete();
|
|
1151
|
+
}
|
|
1200
1152
|
}
|
|
1201
1153
|
else if (sandbox?.__provider === "sprites") {
|
|
1202
1154
|
// Sprites does not have a reliable "stop" semantic; deleting is the durable cleanup primitive.
|
|
1203
1155
|
try {
|
|
1204
|
-
await
|
|
1156
|
+
await spritesFetch(`/v1/sprites/${encodeURIComponent(String(sandbox.name))}`, {
|
|
1205
1157
|
method: "DELETE",
|
|
1206
1158
|
});
|
|
1207
1159
|
}
|
|
@@ -1210,7 +1162,7 @@ export class SandboxService {
|
|
|
1210
1162
|
}
|
|
1211
1163
|
}
|
|
1212
1164
|
else {
|
|
1213
|
-
const daytona = new Daytona(
|
|
1165
|
+
const daytona = new Daytona(getDaytonaConfig());
|
|
1214
1166
|
await daytona.stop(sandbox);
|
|
1215
1167
|
if (deleteOnStop) {
|
|
1216
1168
|
try {
|
|
@@ -1231,6 +1183,7 @@ export class SandboxService {
|
|
|
1231
1183
|
shutdownAt: Date.now(),
|
|
1232
1184
|
updatedAt: Date.now(),
|
|
1233
1185
|
}));
|
|
1186
|
+
await this.markOpenProcessesLost(sandboxId, "sandbox_stopped");
|
|
1234
1187
|
return { ok: true, data: undefined };
|
|
1235
1188
|
}
|
|
1236
1189
|
catch (e) {
|
|
@@ -1278,13 +1231,13 @@ export class SandboxService {
|
|
|
1278
1231
|
if (!sandboxResult.ok)
|
|
1279
1232
|
return { ok: false, error: sandboxResult.error };
|
|
1280
1233
|
const sandbox = sandboxResult.data.sandbox;
|
|
1281
|
-
if (sandbox
|
|
1234
|
+
if (isVercelSandbox(sandbox)) {
|
|
1282
1235
|
const result = await runCommandInSandbox(sandbox, command, args);
|
|
1283
1236
|
return { ok: true, data: result };
|
|
1284
1237
|
}
|
|
1285
1238
|
if (sandbox.__provider === "sprites") {
|
|
1286
1239
|
const fullCommand = args.length > 0 ? [command, ...args].join(" ") : command;
|
|
1287
|
-
const res = await
|
|
1240
|
+
const res = await spritesExec({
|
|
1288
1241
|
spriteName: String(sandbox.name ?? ""),
|
|
1289
1242
|
command,
|
|
1290
1243
|
args,
|
|
@@ -1319,13 +1272,279 @@ export class SandboxService {
|
|
|
1319
1272
|
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1320
1273
|
}
|
|
1321
1274
|
}
|
|
1275
|
+
async runCommandProcess(sandboxId, command, args = [], opts) {
|
|
1276
|
+
const processId = id();
|
|
1277
|
+
const now = Date.now();
|
|
1278
|
+
let writer = null;
|
|
1279
|
+
let stream = null;
|
|
1280
|
+
let seq = 0;
|
|
1281
|
+
try {
|
|
1282
|
+
const record = await this.getSandboxRecord(sandboxId);
|
|
1283
|
+
if (!record)
|
|
1284
|
+
return { ok: false, error: "Valid sandbox record not found" };
|
|
1285
|
+
if (record.status !== "active")
|
|
1286
|
+
return { ok: false, error: `sandbox_not_active:${record.status}` };
|
|
1287
|
+
const streamSession = await this.createProcessStream({ sandboxId, processId });
|
|
1288
|
+
stream = streamSession.stream;
|
|
1289
|
+
writer = stream.getWriter();
|
|
1290
|
+
await this.adminDb.transact([
|
|
1291
|
+
this.adminDb.tx.sandbox_processes[processId]
|
|
1292
|
+
.update({
|
|
1293
|
+
kind: opts?.kind ?? "command",
|
|
1294
|
+
mode: opts?.mode ?? "foreground",
|
|
1295
|
+
status: "running",
|
|
1296
|
+
provider: String(record.provider ?? "unknown"),
|
|
1297
|
+
command: sanitizeInstantString(command),
|
|
1298
|
+
args: sanitizeInstantValue(Array.isArray(args) ? args : []),
|
|
1299
|
+
cwd: asOptionalString(opts?.cwd),
|
|
1300
|
+
env: sanitizeInstantValue(opts?.env),
|
|
1301
|
+
streamId: streamSession.streamId,
|
|
1302
|
+
streamClientId: streamSession.streamClientId,
|
|
1303
|
+
streamStartedAt: now,
|
|
1304
|
+
startedAt: now,
|
|
1305
|
+
updatedAt: now,
|
|
1306
|
+
metadata: sanitizeInstantValue(opts?.metadata),
|
|
1307
|
+
})
|
|
1308
|
+
.link({ sandbox: sandboxId, stream: streamSession.streamId }),
|
|
1309
|
+
]);
|
|
1310
|
+
seq += 1;
|
|
1311
|
+
await this.writeProcessChunk({
|
|
1312
|
+
writer,
|
|
1313
|
+
sandboxId,
|
|
1314
|
+
processId,
|
|
1315
|
+
seq,
|
|
1316
|
+
type: "status",
|
|
1317
|
+
data: {
|
|
1318
|
+
status: "running",
|
|
1319
|
+
command,
|
|
1320
|
+
args: Array.isArray(args) ? args : [],
|
|
1321
|
+
cwd: opts?.cwd ?? null,
|
|
1322
|
+
},
|
|
1323
|
+
});
|
|
1324
|
+
const result = await this.runCommand(sandboxId, command, args);
|
|
1325
|
+
const finishedAt = Date.now();
|
|
1326
|
+
let finalResult;
|
|
1327
|
+
let status;
|
|
1328
|
+
let exitCode;
|
|
1329
|
+
let errorText;
|
|
1330
|
+
if (result.ok) {
|
|
1331
|
+
finalResult = result.data;
|
|
1332
|
+
exitCode = Number(result.data.exitCode ?? (result.data.success === false ? 1 : 0));
|
|
1333
|
+
status = exitCode === 0 ? "exited" : "failed";
|
|
1334
|
+
const stdout = String(result.data.stdout ?? result.data.output ?? "");
|
|
1335
|
+
const stderr = String(result.data.stderr ?? result.data.error ?? "");
|
|
1336
|
+
if (stdout) {
|
|
1337
|
+
seq += 1;
|
|
1338
|
+
await this.writeProcessChunk({
|
|
1339
|
+
writer,
|
|
1340
|
+
sandboxId,
|
|
1341
|
+
processId,
|
|
1342
|
+
seq,
|
|
1343
|
+
type: "stdout",
|
|
1344
|
+
data: { text: stdout },
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
if (stderr) {
|
|
1348
|
+
seq += 1;
|
|
1349
|
+
await this.writeProcessChunk({
|
|
1350
|
+
writer,
|
|
1351
|
+
sandboxId,
|
|
1352
|
+
processId,
|
|
1353
|
+
seq,
|
|
1354
|
+
type: "stderr",
|
|
1355
|
+
data: { text: stderr },
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
exitCode = 1;
|
|
1361
|
+
status = "failed";
|
|
1362
|
+
errorText = result.error;
|
|
1363
|
+
finalResult = {
|
|
1364
|
+
success: false,
|
|
1365
|
+
exitCode,
|
|
1366
|
+
output: "",
|
|
1367
|
+
error: result.error,
|
|
1368
|
+
command: [command, ...(Array.isArray(args) ? args : [])].join(" "),
|
|
1369
|
+
};
|
|
1370
|
+
seq += 1;
|
|
1371
|
+
await this.writeProcessChunk({
|
|
1372
|
+
writer,
|
|
1373
|
+
sandboxId,
|
|
1374
|
+
processId,
|
|
1375
|
+
seq,
|
|
1376
|
+
type: "error",
|
|
1377
|
+
data: { message: result.error },
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
seq += 1;
|
|
1381
|
+
await this.writeProcessChunk({
|
|
1382
|
+
writer,
|
|
1383
|
+
sandboxId,
|
|
1384
|
+
processId,
|
|
1385
|
+
seq,
|
|
1386
|
+
type: "exit",
|
|
1387
|
+
data: { exitCode, status },
|
|
1388
|
+
});
|
|
1389
|
+
await writer.close();
|
|
1390
|
+
writer = null;
|
|
1391
|
+
await this.adminDb.transact([
|
|
1392
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1393
|
+
status,
|
|
1394
|
+
exitCode,
|
|
1395
|
+
streamFinishedAt: finishedAt,
|
|
1396
|
+
streamAbortReason: null,
|
|
1397
|
+
exitedAt: finishedAt,
|
|
1398
|
+
updatedAt: finishedAt,
|
|
1399
|
+
metadata: sanitizeInstantValue({
|
|
1400
|
+
...(opts?.metadata ?? {}),
|
|
1401
|
+
...(errorText ? { error: errorText } : {}),
|
|
1402
|
+
chunkCount: seq,
|
|
1403
|
+
result: finalResult,
|
|
1404
|
+
}),
|
|
1405
|
+
}),
|
|
1406
|
+
]);
|
|
1407
|
+
await resumeSandboxProcessHook(processId, finalResult);
|
|
1408
|
+
return {
|
|
1409
|
+
ok: true,
|
|
1410
|
+
data: new SandboxCommandRun({
|
|
1411
|
+
sandboxId,
|
|
1412
|
+
processId,
|
|
1413
|
+
streamId: streamSession.streamId,
|
|
1414
|
+
streamClientId: streamSession.streamClientId,
|
|
1415
|
+
result: finalResult,
|
|
1416
|
+
}, this),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
catch (e) {
|
|
1420
|
+
const message = formatInstantSchemaError(e);
|
|
1421
|
+
const failedAt = Date.now();
|
|
1422
|
+
try {
|
|
1423
|
+
if (writer) {
|
|
1424
|
+
seq += 1;
|
|
1425
|
+
await this.writeProcessChunk({
|
|
1426
|
+
writer,
|
|
1427
|
+
sandboxId,
|
|
1428
|
+
processId,
|
|
1429
|
+
seq,
|
|
1430
|
+
type: "error",
|
|
1431
|
+
data: { message },
|
|
1432
|
+
});
|
|
1433
|
+
await writer.abort(message);
|
|
1434
|
+
writer = null;
|
|
1435
|
+
}
|
|
1436
|
+
else if (stream) {
|
|
1437
|
+
await stream.abort(message);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
// ignore stream cleanup errors
|
|
1442
|
+
}
|
|
1443
|
+
try {
|
|
1444
|
+
const finalResult = {
|
|
1445
|
+
success: false,
|
|
1446
|
+
exitCode: 1,
|
|
1447
|
+
output: "",
|
|
1448
|
+
error: message,
|
|
1449
|
+
command: [command, ...(Array.isArray(args) ? args : [])].join(" "),
|
|
1450
|
+
};
|
|
1451
|
+
await this.adminDb.transact([
|
|
1452
|
+
this.adminDb.tx.sandbox_processes[processId].update({
|
|
1453
|
+
status: "failed",
|
|
1454
|
+
streamFinishedAt: failedAt,
|
|
1455
|
+
streamAbortReason: message,
|
|
1456
|
+
exitedAt: failedAt,
|
|
1457
|
+
updatedAt: failedAt,
|
|
1458
|
+
metadata: sanitizeInstantValue({
|
|
1459
|
+
...(opts?.metadata ?? {}),
|
|
1460
|
+
error: message,
|
|
1461
|
+
result: finalResult,
|
|
1462
|
+
}),
|
|
1463
|
+
}),
|
|
1464
|
+
]);
|
|
1465
|
+
await resumeSandboxProcessHook(processId, finalResult);
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
// ignore partial metadata failures
|
|
1469
|
+
}
|
|
1470
|
+
return { ok: false, error: message };
|
|
1471
|
+
}
|
|
1472
|
+
finally {
|
|
1473
|
+
try {
|
|
1474
|
+
writer?.releaseLock();
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
// ignore
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
async runCommandWithProcessStream(sandboxId, command, args = [], opts) {
|
|
1482
|
+
const run = await this.runCommandProcess(sandboxId, command, args, opts);
|
|
1483
|
+
if (!run.ok)
|
|
1484
|
+
return run;
|
|
1485
|
+
const result = await run.data;
|
|
1486
|
+
return {
|
|
1487
|
+
ok: true,
|
|
1488
|
+
data: {
|
|
1489
|
+
processId: run.data.processId,
|
|
1490
|
+
streamId: run.data.streamId,
|
|
1491
|
+
streamClientId: run.data.streamClientId,
|
|
1492
|
+
result,
|
|
1493
|
+
},
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
async readProcessStream(processId) {
|
|
1497
|
+
try {
|
|
1498
|
+
const processResult = await this.adminDb.query({
|
|
1499
|
+
sandbox_processes: {
|
|
1500
|
+
$: { where: { id: processId }, limit: 1 },
|
|
1501
|
+
},
|
|
1502
|
+
});
|
|
1503
|
+
const processRow = processResult?.sandbox_processes?.[0];
|
|
1504
|
+
if (!processRow)
|
|
1505
|
+
return { ok: false, error: "sandbox_process_not_found" };
|
|
1506
|
+
const streams = this.adminDb?.streams;
|
|
1507
|
+
if (!streams?.createReadStream)
|
|
1508
|
+
return { ok: false, error: "sandbox_process_streams_unavailable" };
|
|
1509
|
+
const clientId = String(processRow.streamClientId ?? "").trim() || undefined;
|
|
1510
|
+
const streamId = String(processRow.streamId ?? "").trim() || undefined;
|
|
1511
|
+
if (!clientId && !streamId)
|
|
1512
|
+
return { ok: false, error: "sandbox_process_stream_missing" };
|
|
1513
|
+
const stream = streams.createReadStream({ clientId, streamId });
|
|
1514
|
+
const chunks = [];
|
|
1515
|
+
let byteOffset = 0;
|
|
1516
|
+
let buffer = "";
|
|
1517
|
+
for await (const raw of stream) {
|
|
1518
|
+
const encoded = typeof raw === "string" ? raw : String(raw ?? "");
|
|
1519
|
+
if (!encoded)
|
|
1520
|
+
continue;
|
|
1521
|
+
byteOffset += new TextEncoder().encode(encoded).length;
|
|
1522
|
+
buffer += encoded;
|
|
1523
|
+
const lines = buffer.split("\n");
|
|
1524
|
+
buffer = lines.pop() ?? "";
|
|
1525
|
+
for (const line of lines) {
|
|
1526
|
+
const trimmed = line.trim();
|
|
1527
|
+
if (!trimmed)
|
|
1528
|
+
continue;
|
|
1529
|
+
chunks.push(parseSandboxProcessStreamChunk(trimmed));
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const trailing = buffer.trim();
|
|
1533
|
+
if (trailing)
|
|
1534
|
+
chunks.push(parseSandboxProcessStreamChunk(trailing));
|
|
1535
|
+
return { ok: true, data: { chunks, byteOffset } };
|
|
1536
|
+
}
|
|
1537
|
+
catch (e) {
|
|
1538
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1322
1541
|
async writeFiles(sandboxId, files) {
|
|
1323
1542
|
try {
|
|
1324
1543
|
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
1325
1544
|
if (!sandboxResult.ok)
|
|
1326
1545
|
return { ok: false, error: sandboxResult.error };
|
|
1327
1546
|
const sandbox = sandboxResult.data.sandbox;
|
|
1328
|
-
if (sandbox
|
|
1547
|
+
if (isVercelSandbox(sandbox)) {
|
|
1329
1548
|
await sandbox.writeFiles(files.map((f) => ({
|
|
1330
1549
|
path: f.path,
|
|
1331
1550
|
content: Buffer.from(f.contentBase64, "base64"),
|
|
@@ -1342,7 +1561,7 @@ export class SandboxService {
|
|
|
1342
1561
|
const dirPath = filePath.includes("/") ? filePath.split("/").slice(0, -1).join("/") : "";
|
|
1343
1562
|
const dirCmd = dirPath ? `mkdir -p ${SandboxService.shellEscapeArg(dirPath)} && ` : "";
|
|
1344
1563
|
const cmd = `${dirCmd}printf %s ${SandboxService.shellEscapeArg(String(f.contentBase64 ?? ""))} | base64 -d > ${SandboxService.shellEscapeArg(filePath)}`;
|
|
1345
|
-
await
|
|
1564
|
+
await spritesExec({
|
|
1346
1565
|
spriteName,
|
|
1347
1566
|
command: "sh",
|
|
1348
1567
|
args: ["-lc", cmd],
|
|
@@ -1367,7 +1586,7 @@ export class SandboxService {
|
|
|
1367
1586
|
if (!sandboxResult.ok)
|
|
1368
1587
|
return { ok: false, error: sandboxResult.error };
|
|
1369
1588
|
const sandbox = sandboxResult.data.sandbox;
|
|
1370
|
-
if (sandbox
|
|
1589
|
+
if (isVercelSandbox(sandbox)) {
|
|
1371
1590
|
const stream = await sandbox.readFile({ path });
|
|
1372
1591
|
if (!stream) {
|
|
1373
1592
|
return { ok: true, data: { contentBase64: "" } };
|
|
@@ -1395,7 +1614,7 @@ export class SandboxService {
|
|
|
1395
1614
|
return { ok: false, error: "sprites_name_required" };
|
|
1396
1615
|
const filePath = String(path ?? "").trim();
|
|
1397
1616
|
const cmd = `if [ -f ${SandboxService.shellEscapeArg(filePath)} ]; then base64 ${SandboxService.shellEscapeArg(filePath)} | tr -d '\\n'; fi`;
|
|
1398
|
-
const res = await
|
|
1617
|
+
const res = await spritesExec({
|
|
1399
1618
|
spriteName,
|
|
1400
1619
|
command: "sh",
|
|
1401
1620
|
args: ["-lc", cmd],
|
|
@@ -1409,30 +1628,37 @@ export class SandboxService {
|
|
|
1409
1628
|
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1410
1629
|
}
|
|
1411
1630
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
.
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1421
|
-
|
|
1422
|
-
if (!data)
|
|
1423
|
-
continue;
|
|
1424
|
-
const m = data.match(/\bID:\s*(v[0-9]+)\b/i) || data.match(/\bCheckpoint\s+(v[0-9]+)\b/i);
|
|
1425
|
-
if (m?.[1]) {
|
|
1426
|
-
candidates.push(String(m[1]));
|
|
1427
|
-
}
|
|
1631
|
+
async getPortUrl(sandboxId, port) {
|
|
1632
|
+
try {
|
|
1633
|
+
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
1634
|
+
if (!sandboxResult.ok)
|
|
1635
|
+
return { ok: false, error: sandboxResult.error };
|
|
1636
|
+
const sandbox = sandboxResult.data.sandbox;
|
|
1637
|
+
const normalizedPort = Math.max(1, Math.floor(Number(port)));
|
|
1638
|
+
if (isVercelSandbox(sandbox)) {
|
|
1639
|
+
const url = sandbox.domain(normalizedPort);
|
|
1640
|
+
return { ok: true, data: { url: String(url ?? "").replace(/\/+$/, "") } };
|
|
1428
1641
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1642
|
+
if (sandbox.__provider === "sprites") {
|
|
1643
|
+
const base = String(sandbox.url ?? "").trim().replace(/\/+$/, "");
|
|
1644
|
+
if (!base)
|
|
1645
|
+
return { ok: false, error: "sprites_url_missing" };
|
|
1646
|
+
if (normalizedPort === 8080)
|
|
1647
|
+
return { ok: true, data: { url: base } };
|
|
1648
|
+
try {
|
|
1649
|
+
const u = new URL(base);
|
|
1650
|
+
u.port = String(normalizedPort);
|
|
1651
|
+
return { ok: true, data: { url: u.toString().replace(/\/+$/, "") } };
|
|
1652
|
+
}
|
|
1653
|
+
catch {
|
|
1654
|
+
return { ok: true, data: { url: `${base}:${normalizedPort}` } };
|
|
1655
|
+
}
|
|
1431
1656
|
}
|
|
1657
|
+
return { ok: false, error: "sandbox_port_url_not_supported" };
|
|
1658
|
+
}
|
|
1659
|
+
catch (e) {
|
|
1660
|
+
return { ok: false, error: formatInstantSchemaError(e) };
|
|
1432
1661
|
}
|
|
1433
|
-
if (candidates.length === 0)
|
|
1434
|
-
return null;
|
|
1435
|
-
return candidates[candidates.length - 1] ?? null;
|
|
1436
1662
|
}
|
|
1437
1663
|
async createCheckpoint(sandboxId, params) {
|
|
1438
1664
|
try {
|
|
@@ -1440,13 +1666,40 @@ export class SandboxService {
|
|
|
1440
1666
|
sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
|
|
1441
1667
|
});
|
|
1442
1668
|
const record = recordResult?.sandbox_sandboxes?.[0];
|
|
1669
|
+
if (record?.externalSandboxId && record.provider === "vercel") {
|
|
1670
|
+
const sandboxResult = await this.reconnectToSandbox(sandboxId);
|
|
1671
|
+
if (!sandboxResult.ok)
|
|
1672
|
+
return { ok: false, error: sandboxResult.error };
|
|
1673
|
+
const sandbox = sandboxResult.data.sandbox;
|
|
1674
|
+
if (!isVercelSandbox(sandbox))
|
|
1675
|
+
return { ok: false, error: "checkpoint_not_supported" };
|
|
1676
|
+
const expiration = Number(record?.params?.vercel?.snapshotExpirationMs);
|
|
1677
|
+
const snapshot = await sandbox.snapshot({
|
|
1678
|
+
...(Number.isFinite(expiration) ? { expiration } : {}),
|
|
1679
|
+
});
|
|
1680
|
+
const checkpointId = String(snapshot?.snapshotId ?? "").trim();
|
|
1681
|
+
if (!checkpointId)
|
|
1682
|
+
return { ok: false, error: "vercel_snapshot_id_missing" };
|
|
1683
|
+
await this.adminDb.transact(this.adminDb.tx.sandbox_sandboxes[sandboxId].update({
|
|
1684
|
+
updatedAt: Date.now(),
|
|
1685
|
+
params: {
|
|
1686
|
+
...(record.params ?? {}),
|
|
1687
|
+
vercel: {
|
|
1688
|
+
...(record.params?.vercel ?? {}),
|
|
1689
|
+
lastCheckpointId: checkpointId,
|
|
1690
|
+
lastCheckpointComment: String(params?.comment ?? "").trim() || undefined,
|
|
1691
|
+
},
|
|
1692
|
+
},
|
|
1693
|
+
}));
|
|
1694
|
+
return { ok: true, data: { checkpointId } };
|
|
1695
|
+
}
|
|
1443
1696
|
if (!record?.externalSandboxId || record.provider !== "sprites") {
|
|
1444
1697
|
return { ok: false, error: "checkpoint_not_supported" };
|
|
1445
1698
|
}
|
|
1446
1699
|
const name = String(record.externalSandboxId).trim();
|
|
1447
1700
|
const comment = String(params?.comment ?? "").trim();
|
|
1448
1701
|
const body = comment ? { comment } : {};
|
|
1449
|
-
const res = await
|
|
1702
|
+
const res = await spritesFetch(`/v1/sprites/${encodeURIComponent(name)}/checkpoint`, {
|
|
1450
1703
|
method: "POST",
|
|
1451
1704
|
headers: { "Content-Type": "application/json" },
|
|
1452
1705
|
body: JSON.stringify(body),
|
|
@@ -1455,7 +1708,7 @@ export class SandboxService {
|
|
|
1455
1708
|
if (!res?.ok) {
|
|
1456
1709
|
return { ok: false, error: text || `sprites_checkpoint_http_${res?.status ?? "unknown"}` };
|
|
1457
1710
|
}
|
|
1458
|
-
const checkpointId =
|
|
1711
|
+
const checkpointId = parseSpritesCheckpointIdFromNdjson(text);
|
|
1459
1712
|
if (!checkpointId) {
|
|
1460
1713
|
return { ok: false, error: "sprites_checkpoint_id_missing" };
|
|
1461
1714
|
}
|
|
@@ -1481,11 +1734,26 @@ export class SandboxService {
|
|
|
1481
1734
|
sandbox_sandboxes: { $: { where: { id: sandboxId }, limit: 1 } },
|
|
1482
1735
|
});
|
|
1483
1736
|
const record = recordResult?.sandbox_sandboxes?.[0];
|
|
1737
|
+
if (record?.externalSandboxId && record.provider === "vercel") {
|
|
1738
|
+
const creds = await resolveVercelCredentials(record?.params ?? {});
|
|
1739
|
+
const listed = await VercelSnapshot.list({
|
|
1740
|
+
teamId: creds.teamId,
|
|
1741
|
+
projectId: creds.projectId,
|
|
1742
|
+
token: creds.token,
|
|
1743
|
+
name: String(record.externalSandboxId),
|
|
1744
|
+
limit: 50,
|
|
1745
|
+
sortOrder: "desc",
|
|
1746
|
+
});
|
|
1747
|
+
const checkpointIds = (listed.snapshots ?? [])
|
|
1748
|
+
.map((snapshot) => String(snapshot?.id ?? "").trim())
|
|
1749
|
+
.filter(Boolean);
|
|
1750
|
+
return { ok: true, data: { checkpointIds } };
|
|
1751
|
+
}
|
|
1484
1752
|
if (!record?.externalSandboxId || record.provider !== "sprites") {
|
|
1485
1753
|
return { ok: false, error: "checkpoint_not_supported" };
|
|
1486
1754
|
}
|
|
1487
1755
|
const name = String(record.externalSandboxId).trim();
|
|
1488
|
-
const json = await
|
|
1756
|
+
const json = await spritesJson(`/v1/sprites/${encodeURIComponent(name)}/checkpoints`, {
|
|
1489
1757
|
method: "GET",
|
|
1490
1758
|
headers: { Accept: "application/json" },
|
|
1491
1759
|
});
|
|
@@ -1512,7 +1780,7 @@ export class SandboxService {
|
|
|
1512
1780
|
const cp = String(checkpointId ?? "").trim();
|
|
1513
1781
|
if (!cp)
|
|
1514
1782
|
return { ok: false, error: "checkpoint_id_required" };
|
|
1515
|
-
const res = await
|
|
1783
|
+
const res = await spritesFetch(`/v1/sprites/${encodeURIComponent(name)}/checkpoints/${encodeURIComponent(cp)}/restore`, { method: "POST" });
|
|
1516
1784
|
const text = await res?.text?.().catch(() => "");
|
|
1517
1785
|
if (!res?.ok) {
|
|
1518
1786
|
return { ok: false, error: text || `sprites_restore_http_${res?.status ?? "unknown"}` };
|