@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 +38 -0
- package/README.md +35 -3
- package/dist/engine.d.ts +15 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/index.cjs +441 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +444 -4
- package/dist/index.js.map +1 -1
- package/dist/studio/assets/{core-CZ7lrnPw.js → core-Dfgl0E7t.js} +1 -1
- package/dist/studio/assets/{index-sToavS1V.js → index-D_X2t6Tb.js} +2 -2
- package/dist/studio/assets/{main-n0iDoxTR.js → main-BBEpIQZw.js} +203 -203
- package/dist/studio/assets/style-CZOyTV0B.css +1 -0
- package/dist/studio/index.html +2 -2
- package/dist/types.d.ts +125 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +3 -0
- package/dist/worker.d.ts.map +1 -0
- package/package.json +5 -5
- package/dist/studio/assets/style-BWN25Qlo.css +0 -1
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
|
|
3
|
+
Deploy a full Mastra server or a non-HTTP worker into a workspace sandbox.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"
|
|
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;
|