@mastra/deployer-sandbox 0.1.3 → 0.2.0-alpha.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # @mastra/deployer-sandbox
2
2
 
3
+ ## 0.2.0-alpha.1
4
+
5
+ ### Minor Changes
6
+
7
+ - Run isolated non-HTTP workers with bounded input, separate byte-preserving output streams, cancellation, relaunch, and cleanup controls. ([#19641](https://github.com/mastra-ai/mastra/pull/19641))
8
+
9
+ ```ts
10
+ import { deployWorkerToSandbox } from '@mastra/deployer-sandbox';
11
+
12
+ const worker = await deployWorkerToSandbox({
13
+ sandbox,
14
+ dir: './dist/worker',
15
+ executionId: 'job-1',
16
+ command: 'node',
17
+ args: ['index.mjs'],
18
+ input: { type: 'stdin', data: request },
19
+ });
20
+
21
+ const stdout = await worker.readOutput('stdout');
22
+ await worker.cancel();
23
+ const retry = await worker.relaunch({ executionId: 'job-2' });
24
+ await retry.destroy();
25
+ ```
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [[`55c9e24`](https://github.com/mastra-ai/mastra/commit/55c9e248c27c1d72b5bb7e94ea6b8a3999eee49f), [`07f5b4b`](https://github.com/mastra-ai/mastra/commit/07f5b4ba9d608d88865030732e580298296adf99)]:
30
+ - @mastra/core@1.55.0-alpha.2
31
+ - @mastra/deployer@1.55.0-alpha.2
32
+
33
+ ## 0.1.4-alpha.0
34
+
35
+ ### Patch Changes
36
+
37
+ - Updated dependencies [[`3f472b4`](https://github.com/mastra-ai/mastra/commit/3f472b468892a1ff14ccb43cc0343b86f7d8fd7d), [`35b929b`](https://github.com/mastra-ai/mastra/commit/35b929b7abc3d20d85c7985880960ac2d04a6c86), [`9b3626a`](https://github.com/mastra-ai/mastra/commit/9b3626aeb1d16fcd34b0a8e94c114ddb80a3b240)]:
38
+ - @mastra/core@1.55.0-alpha.0
39
+ - @mastra/deployer@1.55.0-alpha.0
40
+
3
41
  ## 0.1.3
4
42
 
5
43
  ### Patch Changes
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @mastra/deployer-sandbox
2
2
 
3
- Deploy a full Mastra server into any workspace sandbox that supports networking — and get a live public URL in seconds.
3
+ Deploy a full Mastra server or a non-HTTP worker into a workspace sandbox.
4
4
 
5
- Works with any `WorkspaceSandbox` provider that implements the core `networking` capability (Vercel Sandbox, E2B, Daytona, ...). Positioning: **ephemeral environments** — instant previews, PR/CI smoke deploys, agent-built-app verification, multi-tenant untrusted instances. Not production hosting.
5
+ Server deployments work with any `WorkspaceSandbox` provider that implements `executeCommand` and `networking`. Worker deployments require only `executeCommand`; they do not allocate ports, ingress, public URLs, or HTTP health checks. Positioning: **ephemeral environments** — instant previews, PR/CI smoke deploys, isolated jobs, agent-built-app verification, and multi-tenant untrusted instances. Not production hosting.
6
6
 
7
7
  ## Usage
8
8
 
@@ -59,7 +59,39 @@ const deployment = await deployToSandbox({ sandbox, dir: '.mastra/output', port:
59
59
  console.log(deployment.url);
60
60
  ```
61
61
 
62
- Pass `wake: true` to resume a stopped sandbox before returning — useful in a route handler that fronts the sandbox. If the server isn't healthy after the resume (some providers restore the filesystem but not processes), the wake relaunches it:
62
+ ## Non-HTTP workers and custom commands
63
+
64
+ Deploy a prebuilt worker artifact without requiring networking. The command, arguments, working directory, and install command are trusted developer-authored inputs. The deployer preserves the artifact layout rather than assuming a Mastra-specific entrypoint or process protocol.
65
+
66
+ ```typescript
67
+ import { deployWorkerToSandbox } from '@mastra/deployer-sandbox';
68
+ import { VercelSandbox } from '@mastra/vercel';
69
+
70
+ const worker = await deployWorkerToSandbox({
71
+ sandbox: new VercelSandbox({ sandboxName: 'experiment-worker' }),
72
+ dir: '.mastra/experiment-worker',
73
+ executionId: 'attempt-1',
74
+ mode: 'job',
75
+ command: 'node',
76
+ args: ['index.mjs'],
77
+ env: { JOB_ID: 'job-123' },
78
+ input: { type: 'stdin', data: JSON.stringify({ requestId: 'request-123' }) },
79
+ startupTimeoutMs: 10_000,
80
+ executionTimeoutMs: 15 * 60_000,
81
+ terminationGraceMs: 5_000,
82
+ });
83
+
84
+ const stdout = await worker.readOutput('stdout', { offset: 0, maxBytes: 64 * 1024 });
85
+ const stderr = await worker.readOutput('stderr', { offset: 0, maxBytes: 64 * 1024 });
86
+ console.log(await worker.status(), stdout, stderr);
87
+ await worker.cancel();
88
+ ```
89
+
90
+ Each caller-provided execution ID gets isolated runtime state. Input is bounded and may be delivered through stdin or staged at an artifact-relative file path. Stdout and stderr remain separate and are read as raw bytes with offsets, EOF, truncation, and interruption metadata; the deployer does not interpret their protocol.
91
+
92
+ Dependency installs are serialized and cached using the artifact's `package.json`, supported lockfiles, and install command. `cancel()` sends TERM and then KILL to the process group when necessary. `stop()` snapshot-stops the provider sandbox without assuming process preservation. `relaunch({ executionId })` starts the recorded command under a new execution identity. `destroy()` retries permanent sandbox deletion and returns a typed result.
93
+
94
+ Pass `wake: true` to resume a stopped server sandbox before returning — useful in a route handler that fronts the sandbox. If the server isn't healthy after the resume (some providers restore the filesystem but not processes), the wake relaunches it:
63
95
 
64
96
  ```typescript
65
97
  import { getDeployment } from '@mastra/deployer-sandbox/client';
package/dist/engine.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { WorkspaceSandbox } from '@mastra/core/workspace';
1
2
  import type { DeployToSandboxOptions, SandboxDeployment } from './types.js';
2
3
  /**
3
4
  * Deploy a prebuilt Mastra server directory into any workspace sandbox that
@@ -16,4 +17,18 @@ export declare function buildLaunchScript(opts: {
16
17
  port: number;
17
18
  env: Record<string, string>;
18
19
  }): string;
20
+ /** Create a gzipped tarball of the directory contents (excluding node_modules). */
21
+ export declare function createTarball(dir: string): Promise<Buffer>;
22
+ /**
23
+ * Upload a file into the sandbox. Uses the provider's native `writeFiles` fast
24
+ * path when available, otherwise falls back to base64 chunks over
25
+ * `executeCommand` — so `executeCommand` + `networking` is the minimum contract.
26
+ */
27
+ export declare function uploadFile(sandbox: WorkspaceSandbox, remotePath: string, content: Buffer): Promise<void>;
28
+ /**
29
+ * Hash everything that determines the outcome of a dependency install:
30
+ * package.json, any bundled lockfile, and the install command itself. A
31
+ * matching hash means the previous `node_modules` can be reused.
32
+ */
33
+ export declare function hashInstallInputs(dir: string, installCommand: string): Promise<string | null>;
19
34
  //# sourceMappingURL=engine.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,sBAAsB,EAAuB,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAc9F;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA+HjG;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,MAAM,CAyBhH"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAgB/D,OAAO,KAAK,EAAE,sBAAsB,EAAuB,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAc9F;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA+HjG;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,MAAM,CAyBhH;AAED,mFAAmF;AACnF,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAShE;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB9G;AAKD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAmBnG"}
package/dist/index.cjs CHANGED
@@ -380,10 +380,451 @@ var SandboxDeployer = class extends _mastra_deployer.Deployer {
380
380
  }
381
381
  };
382
382
  //#endregion
383
+ //#region src/worker.ts
384
+ const ARCHIVE = ".mastra-worker.tar.gz";
385
+ const RUNTIME_DIR = ".mastra/executions";
386
+ const INSTALL_MARKER = ".mastra-install-hash";
387
+ const INSTALL_LOCK = ".mastra-install-lock";
388
+ const ARTIFACT_LOCK = ".mastra-artifact-lock";
389
+ const EXECUTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
390
+ const DEFAULT_INPUT_LIMIT = 16 * 1024 * 1024;
391
+ const DEFAULT_OUTPUT_READ_LIMIT = 1024 * 1024;
392
+ async function deployWorkerToSandbox(options) {
393
+ validateOptions(options);
394
+ const { sandbox, dir, executionId, command, mode = "worker", args = [], env = {}, workingDirectory = ".", installCommand = "npm install --omit=dev", startupTimeoutMs = 1e4, executionTimeoutMs, terminationGraceMs = 5e3, inputLimitBytes = DEFAULT_INPUT_LIMIT } = options;
395
+ const remoteDir = await require_shared.resolveRemoteDir(sandbox, options.remoteDir);
396
+ const config = {
397
+ sandbox,
398
+ remoteDir,
399
+ command,
400
+ args,
401
+ env,
402
+ workingDirectory,
403
+ mode,
404
+ startupTimeoutMs,
405
+ executionTimeoutMs,
406
+ terminationGraceMs,
407
+ inputLimitBytes
408
+ };
409
+ const archive = `${remoteDir}/${ARCHIVE}`;
410
+ const tarball = await createTarball(dir);
411
+ const installHash = await hashInstallInputs(dir, installCommand);
412
+ const artifactLock = `${remoteDir}/${ARTIFACT_LOCK}`;
413
+ let artifactLockAcquired = false;
414
+ try {
415
+ await require_shared.runInSandbox(sandbox, `mkdir -p ${require_shared.shellQuote(remoteDir)}`);
416
+ await acquireLock(sandbox, artifactLock, options.installTimeoutMs, "worker artifact");
417
+ artifactLockAcquired = true;
418
+ await uploadFile(sandbox, archive, tarball);
419
+ await require_shared.runInSandbox(sandbox, `tar -xzf ${require_shared.shellQuote(archive)} -C ${require_shared.shellQuote(remoteDir)} && rm -f ${require_shared.shellQuote(archive)}`, { label: "extract worker artifact" });
420
+ } catch (error) {
421
+ throw workerPhaseError("upload", error);
422
+ } finally {
423
+ if (artifactLockAcquired) await require_shared.runInSandbox(sandbox, `rm -rf ${require_shared.shellQuote(artifactLock)}`, {
424
+ allowFailure: true,
425
+ label: "release worker artifact lock"
426
+ });
427
+ }
428
+ try {
429
+ await installDependencies(sandbox, remoteDir, installHash ?? void 0, installCommand, options.installTimeoutMs);
430
+ } catch (error) {
431
+ throw workerPhaseError("install", error);
432
+ }
433
+ return createExecution(config, executionId, options.input);
434
+ }
435
+ function validateOptions(options) {
436
+ if (!options.sandbox.executeCommand) throw new Error(`Sandbox provider "${options.sandbox.provider}" does not support executeCommand, which is required for worker deploys.`);
437
+ validateExecutionId(options.executionId);
438
+ if (!options.command || /[\0\r\n]/.test(options.command)) throw new Error("Worker command must be a non-empty executable path.");
439
+ if (options.args?.some((arg) => arg.includes("\0"))) throw new Error("Worker arguments must not contain NUL bytes.");
440
+ for (const key of Object.keys(options.env ?? {})) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid worker environment variable name: ${key}`);
441
+ validateRelativePath(options.workingDirectory ?? ".", "workingDirectory");
442
+ validateInput(options.input);
443
+ for (const [name, value] of [
444
+ ["inputLimitBytes", options.inputLimitBytes],
445
+ ["startupTimeoutMs", options.startupTimeoutMs],
446
+ ["executionTimeoutMs", options.executionTimeoutMs],
447
+ ["terminationGraceMs", options.terminationGraceMs]
448
+ ]) if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) throw new Error(`${name} must be greater than zero.`);
449
+ }
450
+ function validateRelativePath(value, label) {
451
+ if (!value || path.posix.isAbsolute(value) || path.posix.normalize(value).startsWith("..")) throw new Error(`Worker ${label} must stay within the deployed artifact root.`);
452
+ }
453
+ function validateInput(input) {
454
+ if (input?.type === "file") validateRelativePath(input.path, "input file path");
455
+ }
456
+ async function acquireLock(sandbox, lock, timeout, label) {
457
+ const timeoutMs = timeout ?? 6e5;
458
+ const attempts = Math.max(1, Math.ceil(timeoutMs / 1e3));
459
+ await require_shared.runInSandbox(sandbox, [
460
+ "i=0",
461
+ `while ! mkdir ${require_shared.shellQuote(lock)} 2>/dev/null; do`,
462
+ ` if [ "$i" -ge ${attempts} ]; then echo ${require_shared.shellQuote(`${label} lock timeout`)} >&2; exit 1; fi`,
463
+ " sleep 1; i=$((i + 1))",
464
+ "done"
465
+ ].join("\n"), {
466
+ timeout: timeoutMs,
467
+ label: `acquire ${label} lock`
468
+ });
469
+ }
470
+ async function installDependencies(sandbox, remoteDir, installHash, installCommand, timeout) {
471
+ if (!installHash) return;
472
+ const marker = `${remoteDir}/${INSTALL_MARKER}`;
473
+ const lock = `${remoteDir}/${INSTALL_LOCK}`;
474
+ await acquireLock(sandbox, lock, timeout, "dependency install");
475
+ try {
476
+ await require_shared.runInSandbox(sandbox, [
477
+ `current="$(cat ${require_shared.shellQuote(marker)} 2>/dev/null || true)"`,
478
+ `if [ "$current" != ${require_shared.shellQuote(installHash)} ]; then`,
479
+ ` cd ${require_shared.shellQuote(remoteDir)} && ${installCommand}`,
480
+ ` printf %s ${require_shared.shellQuote(installHash)} > ${require_shared.shellQuote(`${marker}.tmp`)}`,
481
+ ` mv ${require_shared.shellQuote(`${marker}.tmp`)} ${require_shared.shellQuote(marker)}`,
482
+ "fi"
483
+ ].join("\n"), {
484
+ timeout: timeout ?? 6e5,
485
+ label: "install worker dependencies"
486
+ });
487
+ } finally {
488
+ await require_shared.runInSandbox(sandbox, `rm -rf ${require_shared.shellQuote(lock)}`, {
489
+ allowFailure: true,
490
+ label: "release dependency install lock"
491
+ });
492
+ }
493
+ }
494
+ async function createExecution(config, executionId, input) {
495
+ validateExecutionId(executionId);
496
+ const paths = executionPaths(config.remoteDir, executionId);
497
+ await require_shared.runInSandbox(config.sandbox, `mkdir -p ${require_shared.shellQuote(`${config.remoteDir}/${RUNTIME_DIR}`)} && mkdir -m 700 ${require_shared.shellQuote(paths.dir)}`, { label: "create worker execution namespace" });
498
+ const script = buildExecutionScript(config, paths, await stageInput(config, paths, input));
499
+ await uploadFile(config.sandbox, paths.script, Buffer.from(script));
500
+ await require_shared.runInSandbox(config.sandbox, `chmod 700 ${require_shared.shellQuote(paths.script)}`);
501
+ try {
502
+ await launchExecution(config.sandbox, paths);
503
+ } catch (error) {
504
+ await writeFailedStatus(config.sandbox, paths, executionId, "launch", error);
505
+ throw workerPhaseError("launch", error);
506
+ }
507
+ const startup = await waitForStartup(config, executionId, paths);
508
+ if (startup.state === "timed_out") await cancelExecution(config, executionId, paths, "startup");
509
+ if (startup.state === "failed" || startup.state === "timed_out" || startup.state === "provider_unavailable") throw new Error(`Worker ${startup.state} during startup${"message" in startup && startup.message ? `: ${startup.message}` : ""}.`);
510
+ const info = await require_shared.getInfoSafe(config.sandbox);
511
+ return deployment(config, executionId, paths, info?.id ?? config.sandbox.id ?? "unknown", info?.timeoutAt);
512
+ }
513
+ function deployment(config, executionId, paths, sandboxId, expiresAt) {
514
+ return {
515
+ sandboxId,
516
+ executionId,
517
+ expiresAt,
518
+ status: (options) => readWorkerStatus(config.sandbox, executionId, paths, options),
519
+ readOutput: (stream, options) => readOutput(config.sandbox, executionId, paths, stream, options),
520
+ cancel: () => cancelExecution(config, executionId, paths),
521
+ stop: async () => {
522
+ if (!config.sandbox.stop) throw new Error(`Sandbox provider "${config.sandbox.provider}" does not support stop.`);
523
+ await config.sandbox.stop();
524
+ },
525
+ destroy: (options) => destroyWithRetry(config.sandbox, options),
526
+ relaunch: async (options) => {
527
+ if (options.executionId === executionId) throw new Error("Relaunch requires a new executionId.");
528
+ validateInput(options.input);
529
+ return createExecution(config, options.executionId, options.input);
530
+ }
531
+ };
532
+ }
533
+ async function stageInput(config, paths, input) {
534
+ if (!input) return void 0;
535
+ const data = typeof input.data === "string" ? Buffer.from(input.data) : Buffer.from(input.data);
536
+ if (data.byteLength > config.inputLimitBytes) throw new Error(`Worker input exceeds inputLimitBytes (${data.byteLength} > ${config.inputLimitBytes}).`);
537
+ const path$1 = input.type === "stdin" ? paths.stdin : path.posix.resolve(config.remoteDir, input.path);
538
+ await uploadFile(config.sandbox, path$1, data);
539
+ await require_shared.runInSandbox(config.sandbox, `chmod 600 ${require_shared.shellQuote(path$1)}`);
540
+ return input.type === "stdin" ? path$1 : void 0;
541
+ }
542
+ function buildExecutionScript(config, paths, stdinPath) {
543
+ const cwd = path.posix.resolve(config.remoteDir, config.workingDirectory);
544
+ const envPrefix = Object.entries(config.env).map(([key, value]) => `${key}=${require_shared.shellQuote(value)}`).join(" ");
545
+ const executable = [require_shared.shellQuote(config.command), ...config.args.map(require_shared.shellQuote)].join(" ");
546
+ const target = `${envPrefix ? `env ${envPrefix} ` : ""}${executable}`;
547
+ const graceAttempts = Math.max(1, Math.ceil(config.terminationGraceMs / 1e3));
548
+ const state = (value) => `tmp=${require_shared.shellQuote(`${paths.status}.tmp.$$`)}; printf '%s\\n' ${require_shared.shellQuote(value)} > "$tmp"; mv "$tmp" ${require_shared.shellQuote(paths.status)};`;
549
+ return [
550
+ "#!/bin/sh",
551
+ `cd ${require_shared.shellQuote(cwd)}`,
552
+ `execution_id=${require_shared.shellQuote(paths.executionId)}`,
553
+ `stdout=${require_shared.shellQuote(paths.stdout)}`,
554
+ `stderr=${require_shared.shellQuote(paths.stderr)}`,
555
+ `pidfile=${require_shared.shellQuote(paths.pid)}`,
556
+ `tokenfile=${require_shared.shellQuote(paths.pidToken)}`,
557
+ `: > "$stdout"; : > "$stderr"`,
558
+ state(`starting|${paths.executionId}`),
559
+ `setsid sh -c ${require_shared.shellQuote(`exec ${target}${stdinPath ? ` < ${require_shared.shellQuote(stdinPath)}` : ""}`)} > "$stdout" 2> "$stderr" &`,
560
+ "child=$!",
561
+ "printf %s \"$child\" > \"$pidfile\"",
562
+ `if [ -r "/proc/$child/stat" ]; then awk '{print $22}' "/proc/$child/stat" > "$tokenfile"; else : > "$tokenfile"; fi`,
563
+ state(`running|${paths.executionId}`),
564
+ "cancelled=0",
565
+ `trap 'cancelled=1; kill -TERM -"$child" 2>/dev/null || kill -TERM "$child" 2>/dev/null || true' TERM INT`,
566
+ ...config.executionTimeoutMs ? [`(sleep ${Math.max(1, Math.ceil(config.executionTimeoutMs / 1e3))}; if kill -0 "$child" 2>/dev/null; then ${state(`timed_out|${paths.executionId}|execution`)} kill -TERM -"$child" 2>/dev/null || kill -TERM "$child" 2>/dev/null || true; i=0; while kill -0 "$child" 2>/dev/null && [ "$i" -lt ${graceAttempts} ]; do sleep 1; i=$((i + 1)); done; kill -KILL -"$child" 2>/dev/null || kill -KILL "$child" 2>/dev/null || true; fi) &`, "watchdog=$!"] : [],
567
+ "wait \"$child\"",
568
+ "code=$?",
569
+ ...config.executionTimeoutMs ? ["kill \"$watchdog\" 2>/dev/null || true"] : [],
570
+ `current="$(cat ${require_shared.shellQuote(paths.status)} 2>/dev/null || true)"`,
571
+ `case "$current" in timed_out*) ;; *) if [ "$cancelled" -eq 1 ]; then ${state(`cancelled|${paths.executionId}|TERM`)} else signal=''; if [ "$code" -gt 128 ]; then signal="SIG$((code - 128))"; fi; tmp=${require_shared.shellQuote(`${paths.status}.tmp.$$`)}; printf 'exited|%s|%s|%s\\n' "$execution_id" "$code" "$signal" > "$tmp"; mv "$tmp" ${require_shared.shellQuote(paths.status)}; fi ;; esac`,
572
+ "rm -f \"$pidfile\" \"$tokenfile\"",
573
+ "exit \"$code\""
574
+ ].join("\n");
575
+ }
576
+ async function launchExecution(sandbox, paths) {
577
+ await require_shared.runInSandbox(sandbox, `setsid nohup sh ${require_shared.shellQuote(paths.script)} >/dev/null 2>&1 & echo $!`, { label: "launch worker execution" });
578
+ }
579
+ async function waitForStartup(config, executionId, paths) {
580
+ const deadline = Date.now() + config.startupTimeoutMs;
581
+ while (Date.now() < deadline) {
582
+ const status = await readWorkerStatus(config.sandbox, executionId, paths);
583
+ if (status.state !== "unknown" && status.state !== "starting") return status;
584
+ await new Promise((resolve) => setTimeout(resolve, 100));
585
+ }
586
+ return {
587
+ state: "timed_out",
588
+ executionId,
589
+ phase: "startup"
590
+ };
591
+ }
592
+ async function readWorkerStatus(sandbox, executionId, paths, options) {
593
+ const providerState = sandbox.status;
594
+ if (providerState === "destroyed" || providerState === "destroying") return {
595
+ state: "provider_unavailable",
596
+ executionId,
597
+ providerState
598
+ };
599
+ if (providerState === "stopped" || providerState === "stopping") {
600
+ if (!options?.wake || !sandbox.start) return {
601
+ state: "provider_unavailable",
602
+ executionId,
603
+ providerState
604
+ };
605
+ try {
606
+ await sandbox.start();
607
+ } catch (error) {
608
+ return {
609
+ state: "provider_unavailable",
610
+ executionId,
611
+ providerState,
612
+ message: errorMessage(error)
613
+ };
614
+ }
615
+ }
616
+ try {
617
+ const result = await require_shared.runInSandbox(sandbox, [
618
+ `status="$(cat ${require_shared.shellQuote(paths.status)} 2>/dev/null || true)"`,
619
+ `if [ -f ${require_shared.shellQuote(paths.pid)} ]; then`,
620
+ ` pid="$(cat ${require_shared.shellQuote(paths.pid)})"`,
621
+ ` expected="$(cat ${require_shared.shellQuote(paths.pidToken)} 2>/dev/null || true)"`,
622
+ ` actual="$(if [ -r "/proc/$pid/stat" ]; then awk '{print $22}' "/proc/$pid/stat"; fi)"`,
623
+ ` if kill -0 "$pid" 2>/dev/null && { [ -z "$expected" ] || [ "$expected" = "$actual" ]; }; then echo "running|${executionId}"; exit 0; fi`,
624
+ ` if kill -0 "$pid" 2>/dev/null; then echo "stale|${executionId}"; exit 0; fi`,
625
+ "fi",
626
+ `if [ -n "$status" ]; then printf '%s\\n' "$status"; else echo "unknown|${executionId}"; fi`
627
+ ].join("\n"), {
628
+ allowFailure: true,
629
+ label: "read worker status"
630
+ });
631
+ if (result.exitCode !== 0) return {
632
+ state: "provider_unavailable",
633
+ executionId,
634
+ providerState: sandbox.status,
635
+ message: result.stderr || result.stdout || "Sandbox status inspection failed."
636
+ };
637
+ return parseStatus(executionId, result.stdout.trim());
638
+ } catch (error) {
639
+ return {
640
+ state: "provider_unavailable",
641
+ executionId,
642
+ providerState: sandbox.status,
643
+ message: errorMessage(error)
644
+ };
645
+ }
646
+ }
647
+ function parseStatus(executionId, value) {
648
+ const [state, recordedId, first, second] = value.split("|");
649
+ if (recordedId !== executionId || state === "stale") return {
650
+ state: "unknown",
651
+ executionId
652
+ };
653
+ if (state === "starting") return {
654
+ state,
655
+ executionId
656
+ };
657
+ if (state === "running") return {
658
+ state,
659
+ executionId
660
+ };
661
+ if (state === "exited") {
662
+ const exitCode = Number(first);
663
+ return Number.isInteger(exitCode) ? {
664
+ state,
665
+ executionId,
666
+ exitCode,
667
+ ...second ? { signal: second } : {}
668
+ } : {
669
+ state: "unknown",
670
+ executionId
671
+ };
672
+ }
673
+ if (state === "cancelled") return {
674
+ state,
675
+ executionId,
676
+ ...first ? { signal: first } : {}
677
+ };
678
+ if (state === "timed_out" && (first === "startup" || first === "execution")) return {
679
+ state,
680
+ executionId,
681
+ phase: first
682
+ };
683
+ if (state === "failed" && (first === "upload" || first === "install" || first === "launch")) return {
684
+ state,
685
+ executionId,
686
+ phase: first,
687
+ message: second ?? ""
688
+ };
689
+ return {
690
+ state: "unknown",
691
+ executionId
692
+ };
693
+ }
694
+ async function cancelExecution(config, executionId, paths, timeoutPhase) {
695
+ const current = await readWorkerStatus(config.sandbox, executionId, paths);
696
+ if (current.state !== "running" && current.state !== "starting") return current;
697
+ const attempts = Math.max(1, Math.ceil(config.terminationGraceMs / 1e3));
698
+ const terminal = timeoutPhase ? `timed_out|${executionId}|startup` : `cancelled|${executionId}|TERM`;
699
+ await require_shared.runInSandbox(config.sandbox, [
700
+ `pid="$(cat ${require_shared.shellQuote(paths.pid)} 2>/dev/null || true)"`,
701
+ "[ -n \"$pid\" ] || exit 0",
702
+ `expected="$(cat ${require_shared.shellQuote(paths.pidToken)} 2>/dev/null || true)"`,
703
+ `actual="$(if [ -r "/proc/$pid/stat" ]; then awk '{print $22}' "/proc/$pid/stat"; fi)"`,
704
+ "[ -n \"$expected\" ] && [ \"$expected\" != \"$actual\" ] && exit 0",
705
+ "kill -TERM -\"$pid\" 2>/dev/null || kill -TERM \"$pid\" 2>/dev/null || true",
706
+ `i=0; while kill -0 "$pid" 2>/dev/null && [ "$i" -lt ${attempts} ]; do sleep 1; i=$((i + 1)); done`,
707
+ "kill -KILL -\"$pid\" 2>/dev/null || kill -KILL \"$pid\" 2>/dev/null || true",
708
+ `tmp=${require_shared.shellQuote(`${paths.status}.tmp.$$`)}; printf '%s\\n' ${require_shared.shellQuote(terminal)} > "$tmp"; mv "$tmp" ${require_shared.shellQuote(paths.status)}`,
709
+ `rm -f ${require_shared.shellQuote(paths.pid)} ${require_shared.shellQuote(paths.pidToken)}`
710
+ ].join("\n"), {
711
+ allowFailure: true,
712
+ timeout: config.terminationGraceMs + 5e3,
713
+ label: "cancel worker execution"
714
+ });
715
+ return parseStatus(executionId, terminal);
716
+ }
717
+ async function readOutput(sandbox, executionId, paths, stream, options) {
718
+ const offset = Math.max(0, Math.floor(options?.offset ?? 0));
719
+ const maxBytes = Math.max(1, Math.floor(options?.maxBytes ?? DEFAULT_OUTPUT_READ_LIMIT));
720
+ const path$2 = stream === "stdout" ? paths.stdout : paths.stderr;
721
+ try {
722
+ const result = await require_shared.runInSandbox(sandbox, `size=$(wc -c < ${require_shared.shellQuote(path$2)} 2>/dev/null || echo 0); printf '%s\\n' "$size"; tail -c +${offset + 1} ${require_shared.shellQuote(path$2)} 2>/dev/null | head -c ${maxBytes} | base64`, {
723
+ allowFailure: true,
724
+ label: `read worker ${stream}`
725
+ });
726
+ if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `Unable to read worker ${stream}.`);
727
+ const newline = result.stdout.indexOf("\n");
728
+ const totalBytes = Number((newline === -1 ? result.stdout : result.stdout.slice(0, newline)).trim()) || 0;
729
+ const encoded = newline === -1 ? "" : result.stdout.slice(newline + 1).replace(/\s/g, "");
730
+ const data = Buffer.from(encoded, "base64");
731
+ const nextOffset = offset + data.byteLength;
732
+ const status = await readWorkerStatus(sandbox, executionId, paths);
733
+ const terminal = [
734
+ "exited",
735
+ "cancelled",
736
+ "timed_out",
737
+ "failed"
738
+ ].includes(status.state);
739
+ const interrupted = status.state === "provider_unavailable" || status.state === "unknown";
740
+ return {
741
+ stream,
742
+ data,
743
+ offset,
744
+ nextOffset,
745
+ totalBytes,
746
+ eof: terminal && nextOffset >= totalBytes,
747
+ truncated: nextOffset < totalBytes,
748
+ interrupted
749
+ };
750
+ } catch {
751
+ return {
752
+ stream,
753
+ data: /* @__PURE__ */ new Uint8Array(),
754
+ offset,
755
+ nextOffset: offset,
756
+ totalBytes: offset,
757
+ eof: false,
758
+ truncated: false,
759
+ interrupted: true
760
+ };
761
+ }
762
+ }
763
+ async function destroyWithRetry(sandbox, options) {
764
+ if (!sandbox.destroy) return {
765
+ state: "unsupported",
766
+ attempts: 0
767
+ };
768
+ const attempts = Math.max(1, Math.floor(options?.attempts ?? 3));
769
+ const delayMs = Math.max(0, Math.floor(options?.delayMs ?? 250));
770
+ let lastError;
771
+ for (let attempt = 1; attempt <= attempts; attempt++) try {
772
+ await sandbox.destroy();
773
+ return {
774
+ state: "destroyed",
775
+ attempts: attempt
776
+ };
777
+ } catch (error) {
778
+ lastError = error;
779
+ if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs));
780
+ }
781
+ return {
782
+ state: "exhausted",
783
+ attempts,
784
+ error: lastError
785
+ };
786
+ }
787
+ async function writeFailedStatus(sandbox, paths, executionId, phase, error) {
788
+ await writeStatus(sandbox, paths.status, `failed|${executionId}|${phase}|${sanitizeStatusValue(errorMessage(error))}`);
789
+ }
790
+ async function writeStatus(sandbox, path$3, value) {
791
+ await require_shared.runInSandbox(sandbox, `tmp=${require_shared.shellQuote(`${path$3}.tmp.$$`)}; printf '%s\\n' ${require_shared.shellQuote(value)} > "$tmp"; mv "$tmp" ${require_shared.shellQuote(path$3)}`, {
792
+ allowFailure: true,
793
+ label: "write worker status"
794
+ });
795
+ }
796
+ function executionPaths(remoteDir, executionId) {
797
+ const dir = `${remoteDir}/${RUNTIME_DIR}/${executionId}`;
798
+ return {
799
+ executionId,
800
+ dir,
801
+ script: `${dir}/launch.sh`,
802
+ pid: `${dir}/pid`,
803
+ pidToken: `${dir}/pid-start`,
804
+ status: `${dir}/status`,
805
+ stdin: `${dir}/stdin`,
806
+ stdout: `${dir}/stdout`,
807
+ stderr: `${dir}/stderr`
808
+ };
809
+ }
810
+ function validateExecutionId(executionId) {
811
+ if (!executionId || !EXECUTION_ID_PATTERN.test(executionId)) throw new Error("Worker executionId must contain only letters, numbers, dots, underscores, and hyphens.");
812
+ }
813
+ function workerPhaseError(phase, error) {
814
+ return new Error(`Worker ${phase} failed: ${errorMessage(error)}`, { cause: error });
815
+ }
816
+ function errorMessage(error) {
817
+ return error instanceof Error ? error.message : String(error);
818
+ }
819
+ function sanitizeStatusValue(value) {
820
+ return value.replace(/[|\r\n]/g, " ").slice(0, 500);
821
+ }
822
+ //#endregion
383
823
  exports.MANIFEST_FILENAME = MANIFEST_FILENAME;
384
824
  exports.SandboxDeployer = SandboxDeployer;
385
825
  exports.buildLaunchScript = buildLaunchScript;
386
826
  exports.deployToSandbox = deployToSandbox;
827
+ exports.deployWorkerToSandbox = deployWorkerToSandbox;
387
828
  exports.readDeploymentManifest = readDeploymentManifest;
388
829
  exports.updateEdgeConfigAlias = updateEdgeConfigAlias;
389
830
  exports.writeDeploymentManifest = writeDeploymentManifest;