@agent-relay/sandbox 0.1.2 → 0.1.5
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 +247 -5
- package/dist/agent37/client.d.ts +115 -0
- package/dist/agent37/client.d.ts.map +1 -0
- package/dist/agent37/client.js +270 -0
- package/dist/agent37/client.js.map +1 -0
- package/dist/agent37/index.d.ts +5 -0
- package/dist/agent37/index.d.ts.map +1 -0
- package/dist/agent37/index.js +3 -0
- package/dist/agent37/index.js.map +1 -0
- package/dist/agent37/runtime.d.ts +415 -0
- package/dist/agent37/runtime.d.ts.map +1 -0
- package/dist/agent37/runtime.js +855 -0
- package/dist/agent37/runtime.js.map +1 -0
- package/dist/agentcore/bench.d.ts +93 -0
- package/dist/agentcore/bench.d.ts.map +1 -0
- package/dist/agentcore/bench.js +125 -0
- package/dist/agentcore/bench.js.map +1 -0
- package/dist/agentcore/capabilities.d.ts +169 -0
- package/dist/agentcore/capabilities.d.ts.map +1 -0
- package/dist/agentcore/capabilities.js +153 -0
- package/dist/agentcore/capabilities.js.map +1 -0
- package/dist/agentcore/config.d.ts +156 -0
- package/dist/agentcore/config.d.ts.map +1 -0
- package/dist/agentcore/config.js +12 -0
- package/dist/agentcore/config.js.map +1 -0
- package/dist/agentcore/index.d.ts +6 -0
- package/dist/agentcore/index.d.ts.map +1 -0
- package/dist/agentcore/index.js +3 -0
- package/dist/agentcore/index.js.map +1 -0
- package/dist/agentcore/internal/sdk.d.ts +181 -0
- package/dist/agentcore/internal/sdk.d.ts.map +1 -0
- package/dist/agentcore/internal/sdk.js +223 -0
- package/dist/agentcore/internal/sdk.js.map +1 -0
- package/dist/agentcore/runtime.d.ts +177 -0
- package/dist/agentcore/runtime.d.ts.map +1 -0
- package/dist/agentcore/runtime.js +706 -0
- package/dist/agentcore/runtime.js.map +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +11 -0
- package/dist/core/index.js.map +1 -0
- package/dist/daytona/index.d.ts +5 -0
- package/dist/daytona/index.d.ts.map +1 -0
- package/dist/daytona/index.js +3 -0
- package/dist/daytona/index.js.map +1 -0
- package/dist/daytona/runtime.d.ts +23 -0
- package/dist/daytona/runtime.d.ts.map +1 -1
- package/dist/daytona/runtime.js +167 -5
- package/dist/daytona/runtime.js.map +1 -1
- package/dist/daytona/wire-supplement.d.ts +25 -0
- package/dist/daytona/wire-supplement.d.ts.map +1 -0
- package/dist/daytona/wire-supplement.js +16 -0
- package/dist/daytona/wire-supplement.js.map +1 -0
- package/dist/e2b/index.d.ts +3 -0
- package/dist/e2b/index.d.ts.map +1 -0
- package/dist/e2b/index.js +2 -0
- package/dist/e2b/index.js.map +1 -0
- package/dist/e2b/runtime.d.ts +107 -72
- package/dist/e2b/runtime.d.ts.map +1 -1
- package/dist/e2b/runtime.js +675 -190
- package/dist/e2b/runtime.js.map +1 -1
- package/dist/freestyle/capabilities.d.ts +28 -0
- package/dist/freestyle/capabilities.d.ts.map +1 -0
- package/dist/freestyle/capabilities.js +42 -0
- package/dist/freestyle/capabilities.js.map +1 -0
- package/dist/freestyle/config.d.ts +36 -0
- package/dist/freestyle/config.d.ts.map +1 -0
- package/dist/freestyle/config.js +2 -0
- package/dist/freestyle/config.js.map +1 -0
- package/dist/freestyle/index.d.ts +6 -0
- package/dist/freestyle/index.d.ts.map +1 -0
- package/dist/freestyle/index.js +3 -0
- package/dist/freestyle/index.js.map +1 -0
- package/dist/freestyle/internal/sdk.d.ts +69 -0
- package/dist/freestyle/internal/sdk.d.ts.map +1 -0
- package/dist/freestyle/internal/sdk.js +19 -0
- package/dist/freestyle/internal/sdk.js.map +1 -0
- package/dist/freestyle/runtime.d.ts +163 -0
- package/dist/freestyle/runtime.d.ts.map +1 -0
- package/dist/freestyle/runtime.js +798 -0
- package/dist/freestyle/runtime.js.map +1 -0
- package/dist/index.d.ts +34 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/local/index.d.ts +3 -0
- package/dist/local/index.d.ts.map +1 -0
- package/dist/local/index.js +2 -0
- package/dist/local/index.js.map +1 -0
- package/dist/microsandbox/index.d.ts +3 -0
- package/dist/microsandbox/index.d.ts.map +1 -0
- package/dist/microsandbox/index.js +2 -0
- package/dist/microsandbox/index.js.map +1 -0
- package/dist/microsandbox/runtime.d.ts +793 -0
- package/dist/microsandbox/runtime.d.ts.map +1 -0
- package/dist/microsandbox/runtime.js +2303 -0
- package/dist/microsandbox/runtime.js.map +1 -0
- package/dist/modal/bench.d.ts +199 -0
- package/dist/modal/bench.d.ts.map +1 -0
- package/dist/modal/bench.js +450 -0
- package/dist/modal/bench.js.map +1 -0
- package/dist/modal/capabilities.d.ts +180 -0
- package/dist/modal/capabilities.d.ts.map +1 -0
- package/dist/modal/capabilities.js +159 -0
- package/dist/modal/capabilities.js.map +1 -0
- package/dist/modal/config.d.ts +192 -0
- package/dist/modal/config.d.ts.map +1 -0
- package/dist/modal/config.js +169 -0
- package/dist/modal/config.js.map +1 -0
- package/dist/modal/index.d.ts +9 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +5 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/modal/internal/sdk.d.ts +141 -0
- package/dist/modal/internal/sdk.d.ts.map +1 -0
- package/dist/modal/internal/sdk.js +51 -0
- package/dist/modal/internal/sdk.js.map +1 -0
- package/dist/modal/runtime.d.ts +271 -0
- package/dist/modal/runtime.d.ts.map +1 -0
- package/dist/modal/runtime.js +871 -0
- package/dist/modal/runtime.js.map +1 -0
- package/dist/mount-script.d.ts +16 -1
- package/dist/mount-script.d.ts.map +1 -1
- package/dist/mount-script.js +76 -15
- package/dist/mount-script.js.map +1 -1
- package/dist/orchestrator.d.ts +15 -0
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +13 -1
- package/dist/orchestrator.js.map +1 -1
- package/dist/port.d.ts +144 -6
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +27 -5
- package/dist/port.js.map +1 -1
- package/dist/types.d.ts +50 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vercel/bench.d.ts +169 -0
- package/dist/vercel/bench.d.ts.map +1 -0
- package/dist/vercel/bench.js +337 -0
- package/dist/vercel/bench.js.map +1 -0
- package/dist/vercel/capabilities.d.ts +106 -0
- package/dist/vercel/capabilities.d.ts.map +1 -0
- package/dist/vercel/capabilities.js +128 -0
- package/dist/vercel/capabilities.js.map +1 -0
- package/dist/vercel/config.d.ts +119 -0
- package/dist/vercel/config.d.ts.map +1 -0
- package/dist/vercel/config.js +8 -0
- package/dist/vercel/config.js.map +1 -0
- package/dist/vercel/index.d.ts +6 -0
- package/dist/vercel/index.d.ts.map +1 -0
- package/dist/vercel/index.js +3 -0
- package/dist/vercel/index.js.map +1 -0
- package/dist/vercel/internal/sdk.d.ts +173 -0
- package/dist/vercel/internal/sdk.d.ts.map +1 -0
- package/dist/vercel/internal/sdk.js +117 -0
- package/dist/vercel/internal/sdk.js.map +1 -0
- package/dist/vercel/runtime.d.ts +305 -0
- package/dist/vercel/runtime.d.ts.map +1 -0
- package/dist/vercel/runtime.js +1169 -0
- package/dist/vercel/runtime.js.map +1 -0
- package/docs/freestyle.md +110 -0
- package/package.json +41 -5
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { vercelCapabilityModes, vercelObservedCapabilities, vercelSandboxCapabilities, vercelWorkflowCapabilities, } from "./capabilities.js";
|
|
5
|
+
import { createOfficialVercelSandboxApi, currentRequestDeadlineSignal, isVercelNotFoundError, withRequestDeadline, } from "./internal/sdk.js";
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
7
|
+
const DEFAULT_CREATE_TIMEOUT_MS = 180_000;
|
|
8
|
+
const DEFAULT_LOOKUP_TIMEOUT_MS = 15_000;
|
|
9
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
|
|
10
|
+
const DEFAULT_FILE_TIMEOUT_MS = 60_000;
|
|
11
|
+
const DEFAULT_LIFECYCLE_TIMEOUT_MS = 120_000;
|
|
12
|
+
const DEFAULT_DELETE_TIMEOUT_MS = 120_000;
|
|
13
|
+
const DEFAULT_POLL_INTERVAL_MS = 750;
|
|
14
|
+
const DEFAULT_RETRY_DEADLINE_MS = 180_000;
|
|
15
|
+
const DEFAULT_LIST_PAGE_SIZE = 100;
|
|
16
|
+
const DEFAULT_MAX_LIST_PAGES = 50;
|
|
17
|
+
/** The provider caps tags per sandbox; exceeding it is rejected server-side. */
|
|
18
|
+
const MAX_TAGS = 5;
|
|
19
|
+
/** The provider caps exposed ports per sandbox; exceeding it is rejected. */
|
|
20
|
+
const MAX_PORTS = 15;
|
|
21
|
+
/**
|
|
22
|
+
* Conservative client-side bound on generated sandbox names. The provider's
|
|
23
|
+
* exact limit is not documented in the SDK types, so the adapter stays well
|
|
24
|
+
* inside any plausible one rather than discovering it as a create failure.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_NAME_LENGTH = 63;
|
|
27
|
+
/**
|
|
28
|
+
* Room the adapter reserves for its own generated suffix on a bare launch:
|
|
29
|
+
* `<prefix>-<16-hex-chars>`. Validated up front so a prefix that fits the raw
|
|
30
|
+
* name budget but not the generated one cannot slip past into a launch that
|
|
31
|
+
* only fails at the provider.
|
|
32
|
+
*/
|
|
33
|
+
const GENERATED_SUFFIX_LENGTH = 17;
|
|
34
|
+
export class VercelOperationTimeoutError extends Error {
|
|
35
|
+
operation;
|
|
36
|
+
timeoutMs;
|
|
37
|
+
constructor(operation, timeoutMs) {
|
|
38
|
+
super(`Vercel ${operation} did not complete within ${timeoutMs}ms`);
|
|
39
|
+
this.operation = operation;
|
|
40
|
+
this.timeoutMs = timeoutMs;
|
|
41
|
+
this.name = "VercelOperationTimeoutError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class VercelLifecycleTimeoutError extends Error {
|
|
45
|
+
sandboxName;
|
|
46
|
+
expected;
|
|
47
|
+
lastState;
|
|
48
|
+
timeoutMs;
|
|
49
|
+
constructor(sandboxName, expected, lastState, timeoutMs) {
|
|
50
|
+
super(`Vercel sandbox "${sandboxName}" did not reach ${expected} within ${timeoutMs}ms `
|
|
51
|
+
+ `(last state: "${lastState}")`);
|
|
52
|
+
this.sandboxName = sandboxName;
|
|
53
|
+
this.expected = expected;
|
|
54
|
+
this.lastState = lastState;
|
|
55
|
+
this.timeoutMs = timeoutMs;
|
|
56
|
+
this.name = "VercelLifecycleTimeoutError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class VercelDestroyVerificationError extends Error {
|
|
60
|
+
sandboxName;
|
|
61
|
+
timeoutMs;
|
|
62
|
+
constructor(sandboxName, timeoutMs) {
|
|
63
|
+
super(`Vercel sandbox "${sandboxName}" delete was accepted but absence was not verified `
|
|
64
|
+
+ `within ${timeoutMs}ms`);
|
|
65
|
+
this.sandboxName = sandboxName;
|
|
66
|
+
this.timeoutMs = timeoutMs;
|
|
67
|
+
this.name = "VercelDestroyVerificationError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export class VercelUnknownExitCodeError extends Error {
|
|
71
|
+
cmdId;
|
|
72
|
+
constructor(cmdId) {
|
|
73
|
+
super(`Vercel command "${cmdId}" reported no exit code; refusing to treat it as success`);
|
|
74
|
+
this.cmdId = cmdId;
|
|
75
|
+
this.name = "VercelUnknownExitCodeError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class VercelForeignSandboxError extends Error {
|
|
79
|
+
sandboxName;
|
|
80
|
+
namePrefix;
|
|
81
|
+
constructor(sandboxName, namePrefix) {
|
|
82
|
+
super(`Vercel sandbox "${sandboxName}" is outside the owned prefix "${namePrefix}" `
|
|
83
|
+
+ "and will not be mutated by this runtime");
|
|
84
|
+
this.sandboxName = sandboxName;
|
|
85
|
+
this.namePrefix = namePrefix;
|
|
86
|
+
this.name = "VercelForeignSandboxError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class VercelDetachedLaunchUnsupportedError extends Error {
|
|
90
|
+
constructor() {
|
|
91
|
+
super("Vercel Sandbox.create resolves only once the sandbox is running; there is no "
|
|
92
|
+
+ "mid-boot handle to hand back, so detached launch is not offered");
|
|
93
|
+
this.name = "VercelDetachedLaunchUnsupportedError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export class VercelTagLimitError extends Error {
|
|
97
|
+
count;
|
|
98
|
+
constructor(count) {
|
|
99
|
+
super(`Vercel sandboxes accept at most ${MAX_TAGS} tags; received ${count}`);
|
|
100
|
+
this.count = count;
|
|
101
|
+
this.name = "VercelTagLimitError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export class VercelListPageLimitError extends Error {
|
|
105
|
+
maxPages;
|
|
106
|
+
operation;
|
|
107
|
+
constructor(maxPages, operation) {
|
|
108
|
+
super(`Vercel ${operation} exceeded the ${maxPages}-page listing cap; refusing to return `
|
|
109
|
+
+ "a silently truncated result");
|
|
110
|
+
this.maxPages = maxPages;
|
|
111
|
+
this.operation = operation;
|
|
112
|
+
this.name = "VercelListPageLimitError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export class VercelCapabilityMismatchError extends Error {
|
|
116
|
+
constructor(message) {
|
|
117
|
+
super(`Vercel capability declaration mismatch: ${message}`);
|
|
118
|
+
this.name = "VercelCapabilityMismatchError";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* One budget for one logical operation, shared across every round trip it makes.
|
|
123
|
+
*
|
|
124
|
+
* The trap this closes: an operation built from four calls that each carry a
|
|
125
|
+
* 30-second timeout can legitimately take two minutes, which is not what any
|
|
126
|
+
* caller passing "30 seconds" meant. A `Deadline` fixes `expiresAt` once, and
|
|
127
|
+
* every subsequent call draws from what is left rather than starting fresh.
|
|
128
|
+
*
|
|
129
|
+
* `totalMs` is what the error reports, because that is the promise that was
|
|
130
|
+
* broken — not the sliver of it that happened to be left when the clock ran out.
|
|
131
|
+
*/
|
|
132
|
+
class Deadline {
|
|
133
|
+
totalMs;
|
|
134
|
+
operation;
|
|
135
|
+
expiresAt;
|
|
136
|
+
constructor(totalMs, operation) {
|
|
137
|
+
this.totalMs = totalMs;
|
|
138
|
+
this.operation = operation;
|
|
139
|
+
this.expiresAt = Date.now() + totalMs;
|
|
140
|
+
}
|
|
141
|
+
/** Milliseconds left, floored at zero. */
|
|
142
|
+
remaining() {
|
|
143
|
+
return Math.max(0, this.expiresAt - Date.now());
|
|
144
|
+
}
|
|
145
|
+
/** Milliseconds left, or throw if the budget is already spent. */
|
|
146
|
+
require() {
|
|
147
|
+
const left = this.remaining();
|
|
148
|
+
if (left <= 0) {
|
|
149
|
+
throw new VercelOperationTimeoutError(this.operation, this.totalMs);
|
|
150
|
+
}
|
|
151
|
+
return left;
|
|
152
|
+
}
|
|
153
|
+
expired() {
|
|
154
|
+
return this.remaining() <= 0;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Vercel Sandbox provider adapter.
|
|
159
|
+
*
|
|
160
|
+
* Identity note, and it is load-bearing: a Vercel sandbox is addressed by its
|
|
161
|
+
* **name**, unique within a project, not by an opaque server id. `RuntimeHandle.id`
|
|
162
|
+
* therefore carries the sandbox name. That is what makes prefix ownership a real
|
|
163
|
+
* boundary here rather than a labelling convention — the adapter will not mutate
|
|
164
|
+
* a sandbox whose name falls outside its prefix.
|
|
165
|
+
*/
|
|
166
|
+
export class VercelSandboxRuntime {
|
|
167
|
+
id = "vercel";
|
|
168
|
+
capabilities = vercelWorkflowCapabilities;
|
|
169
|
+
declaredCapabilities = vercelSandboxCapabilities;
|
|
170
|
+
declaredCapabilityModes = vercelCapabilityModes;
|
|
171
|
+
observedCapabilities = vercelObservedCapabilities;
|
|
172
|
+
credentials;
|
|
173
|
+
namePrefix;
|
|
174
|
+
defaultHomeDir;
|
|
175
|
+
source;
|
|
176
|
+
vcpus;
|
|
177
|
+
sandboxTimeoutMs;
|
|
178
|
+
persistent;
|
|
179
|
+
ports;
|
|
180
|
+
env;
|
|
181
|
+
requestTimeoutMs;
|
|
182
|
+
createTimeoutMs;
|
|
183
|
+
lookupTimeoutMs;
|
|
184
|
+
execTimeoutMs;
|
|
185
|
+
fileTimeoutMs;
|
|
186
|
+
lifecycleTimeoutMs;
|
|
187
|
+
deleteTimeoutMs;
|
|
188
|
+
pollIntervalMs;
|
|
189
|
+
retryDeadlineMs;
|
|
190
|
+
listPageSize;
|
|
191
|
+
maxListPages;
|
|
192
|
+
injectedApiFactory;
|
|
193
|
+
registrations = new Map();
|
|
194
|
+
instances = new Map();
|
|
195
|
+
constructor(options, dependencies = {}) {
|
|
196
|
+
this.credentials = {
|
|
197
|
+
token: required(options.token, "Vercel token"),
|
|
198
|
+
teamId: required(options.teamId, "Vercel team id"),
|
|
199
|
+
projectId: required(options.projectId, "Vercel project id"),
|
|
200
|
+
};
|
|
201
|
+
this.namePrefix = validateNamePrefix(options.namePrefix);
|
|
202
|
+
this.defaultHomeDir = required(options.defaultHomeDir, "Vercel default home directory");
|
|
203
|
+
this.source = validateSource(options.source);
|
|
204
|
+
this.vcpus = validateVcpus(options.vcpus);
|
|
205
|
+
this.sandboxTimeoutMs = optionalPositive(options.sandboxTimeoutMs, "Vercel sandboxTimeoutMs");
|
|
206
|
+
this.persistent = options.persistent;
|
|
207
|
+
this.ports = validatePorts(options.ports);
|
|
208
|
+
this.env = validateEnv(options.env ?? {});
|
|
209
|
+
this.requestTimeoutMs = positiveDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
|
|
210
|
+
this.createTimeoutMs = positiveDuration(options.createTimeoutMs, DEFAULT_CREATE_TIMEOUT_MS);
|
|
211
|
+
this.lookupTimeoutMs = positiveDuration(options.lookupTimeoutMs, DEFAULT_LOOKUP_TIMEOUT_MS);
|
|
212
|
+
this.execTimeoutMs = positiveDuration(options.execTimeoutMs, DEFAULT_EXEC_TIMEOUT_MS);
|
|
213
|
+
this.fileTimeoutMs = positiveDuration(options.fileTimeoutMs, DEFAULT_FILE_TIMEOUT_MS);
|
|
214
|
+
this.lifecycleTimeoutMs = positiveDuration(options.lifecycleTimeoutMs, DEFAULT_LIFECYCLE_TIMEOUT_MS);
|
|
215
|
+
this.deleteTimeoutMs = positiveDuration(options.deleteTimeoutMs, DEFAULT_DELETE_TIMEOUT_MS);
|
|
216
|
+
this.pollIntervalMs = positiveDuration(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS);
|
|
217
|
+
this.retryDeadlineMs = positiveDuration(options.retryDeadlineMs, DEFAULT_RETRY_DEADLINE_MS);
|
|
218
|
+
this.listPageSize = positiveDuration(options.listPageSize, DEFAULT_LIST_PAGE_SIZE);
|
|
219
|
+
this.maxListPages = positiveDuration(options.maxListPages, DEFAULT_MAX_LIST_PAGES);
|
|
220
|
+
this.injectedApiFactory = dependencies.apiFactory;
|
|
221
|
+
reconcileVercelCapabilities(this);
|
|
222
|
+
}
|
|
223
|
+
// --- launch ---------------------------------------------------------------
|
|
224
|
+
async launch(options = {}) {
|
|
225
|
+
const timeoutMs = options.createTimeoutSeconds && options.createTimeoutSeconds > 0
|
|
226
|
+
? Math.ceil(options.createTimeoutSeconds * 1_000)
|
|
227
|
+
: this.createTimeoutMs;
|
|
228
|
+
const name = this.ownedName(options.name?.trim() || options.label?.trim());
|
|
229
|
+
const labels = validateTags(options.labels ?? {});
|
|
230
|
+
const env = validateEnv({ ...this.env, ...(options.env ?? {}) });
|
|
231
|
+
const created = await this.run(timeoutMs, "create", (signal) => this.api().then((api) => api.create({
|
|
232
|
+
name,
|
|
233
|
+
...(hasEntries(labels) ? { tags: { ...labels } } : {}),
|
|
234
|
+
...(hasEntries(env) ? { env: { ...env } } : {}),
|
|
235
|
+
...(this.ports ? { ports: [...this.ports] } : {}),
|
|
236
|
+
...(this.sandboxTimeoutMs ? { timeout: this.sandboxTimeoutMs } : {}),
|
|
237
|
+
...(this.persistent !== undefined
|
|
238
|
+
? { persistent: this.persistent }
|
|
239
|
+
: {}),
|
|
240
|
+
...(this.vcpus ? { resources: { vcpus: this.vcpus } } : {}),
|
|
241
|
+
...(this.source.type === "image" ? { image: this.source.image } : {}),
|
|
242
|
+
...(this.source.type === "runtime"
|
|
243
|
+
? { runtime: this.source.runtime }
|
|
244
|
+
: {}),
|
|
245
|
+
signal,
|
|
246
|
+
})));
|
|
247
|
+
if (!created || typeof created.name !== "string" || !created.name.trim()) {
|
|
248
|
+
throw new Error("Vercel create response is missing a sandbox name");
|
|
249
|
+
}
|
|
250
|
+
this.instances.set(created.name, created);
|
|
251
|
+
this.register(created.name, {
|
|
252
|
+
owned: true,
|
|
253
|
+
labels,
|
|
254
|
+
state: normalizeState(created.status),
|
|
255
|
+
createdAtMs: toMillis(created.createdAt),
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
id: created.name,
|
|
259
|
+
state: normalizeState(created.status),
|
|
260
|
+
homeDir: this.defaultHomeDir,
|
|
261
|
+
...(options.workdir ? { workdir: options.workdir } : {}),
|
|
262
|
+
...(created.createdAt ? { createdAt: toIso(created.createdAt) } : {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Launch a sandbox bound to the caller's scope.
|
|
267
|
+
*
|
|
268
|
+
* Ergonomic borrowed from the `await using` shape the SDK itself exposes on
|
|
269
|
+
* `Sandbox.create`: the sandbox is destroyed — and its destruction verified —
|
|
270
|
+
* when the scope exits, including on a thrown error. Cleanup you cannot
|
|
271
|
+
* forget is the only kind that survives a crash path.
|
|
272
|
+
*
|
|
273
|
+
* ```ts
|
|
274
|
+
* await using lease = await runtime.acquire({ labels: { job: "build" } });
|
|
275
|
+
* await runtime.exec(lease.handle, "npm test");
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
async acquire(options = {}) {
|
|
279
|
+
const handle = await this.launch(options);
|
|
280
|
+
return {
|
|
281
|
+
handle,
|
|
282
|
+
[Symbol.asyncDispose]: async () => {
|
|
283
|
+
await this.destroy(handle);
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// --- lookup ---------------------------------------------------------------
|
|
288
|
+
async findByLabels(labels, options = {}) {
|
|
289
|
+
const matches = await this.findAllByLabels(labels, { ...options, limit: 1 });
|
|
290
|
+
return matches[0] ?? null;
|
|
291
|
+
}
|
|
292
|
+
async findAllByLabels(labels, options = {}) {
|
|
293
|
+
const wanted = validateTags(labels);
|
|
294
|
+
const limit = options.limit;
|
|
295
|
+
// A non-positive cap means "zero handles wanted": short-circuit before we
|
|
296
|
+
// spend any pages proving the obvious.
|
|
297
|
+
if (limit !== undefined && limit <= 0) {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
const states = options.states === undefined ? ["STARTED"] : options.states;
|
|
301
|
+
const excluded = new Set(options.excludeIds ?? []);
|
|
302
|
+
const handles = [];
|
|
303
|
+
// Iterate pages one at a time and filter as they arrive: with a small
|
|
304
|
+
// `limit` the first candidate on page one lets us skip the rest of the
|
|
305
|
+
// listing entirely, instead of materialising every page and truncating.
|
|
306
|
+
for await (const page of this.listPages({
|
|
307
|
+
namePrefix: this.namePrefix,
|
|
308
|
+
...(hasEntries(wanted) ? { tags: { ...wanted } } : {}),
|
|
309
|
+
}, options.timeoutMs ?? this.lookupTimeoutMs, "label lookup", options.pageSize)) {
|
|
310
|
+
for (const item of page) {
|
|
311
|
+
if (!this.isOwnedName(item.name) || excluded.has(item.name)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// Re-check the tags client-side. The `tags` query parameter is a
|
|
315
|
+
// server filter this adapter has not yet proven; if the provider were
|
|
316
|
+
// to ignore it, an unchecked result would hand the caller someone
|
|
317
|
+
// else's sandbox as a warm lease. Verifying locally costs nothing.
|
|
318
|
+
if (!matchesAllTags(item.tags, wanted)) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!matchesState(item.status, states)) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
this.register(item.name, {
|
|
325
|
+
owned: options.owned ?? true,
|
|
326
|
+
labels: item.tags ?? {},
|
|
327
|
+
state: normalizeState(item.status),
|
|
328
|
+
createdAtMs: item.createdAt,
|
|
329
|
+
});
|
|
330
|
+
handles.push(this.toHandle(item));
|
|
331
|
+
if (limit !== undefined && handles.length >= limit) {
|
|
332
|
+
return handles;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return handles;
|
|
337
|
+
}
|
|
338
|
+
async countByLabels(labels, options = {}) {
|
|
339
|
+
const maxCount = options.maxCount;
|
|
340
|
+
// Zero is a legitimate ask ("do I already have any?") and must not turn
|
|
341
|
+
// into a full paginated listing that returns every match.
|
|
342
|
+
if (maxCount !== undefined && maxCount <= 0) {
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
const effectiveLimit = maxCount ?? options.limit;
|
|
346
|
+
const matches = await this.findAllByLabels(labels, {
|
|
347
|
+
states: options.states,
|
|
348
|
+
...(effectiveLimit !== undefined ? { limit: effectiveLimit } : {}),
|
|
349
|
+
...(options.pageSize !== undefined ? { pageSize: options.pageSize } : {}),
|
|
350
|
+
...(options.timeoutMs !== undefined
|
|
351
|
+
? { timeoutMs: options.timeoutMs }
|
|
352
|
+
: {}),
|
|
353
|
+
});
|
|
354
|
+
return matches.length;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Re-resolve a sandbox by name.
|
|
358
|
+
*
|
|
359
|
+
* Deliberately implemented over `list`, not `Sandbox.get`: `get` resumes the
|
|
360
|
+
* session as a side effect, which starts billing and boots a VM. A lookup
|
|
361
|
+
* must not be able to do that — a crash-recovery scan over a hundred handles
|
|
362
|
+
* would otherwise wake every one of them.
|
|
363
|
+
*/
|
|
364
|
+
async getById(id, options = {}) {
|
|
365
|
+
const item = await this.remoteByName(id, this.lookupTimeoutMs);
|
|
366
|
+
if (!item) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const states = options.states === undefined ? null : options.states;
|
|
370
|
+
if (!matchesState(item.status, states)) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
this.register(id, {
|
|
374
|
+
owned: options.owned ?? false,
|
|
375
|
+
labels: item.tags ?? {},
|
|
376
|
+
state: normalizeState(item.status),
|
|
377
|
+
createdAtMs: item.createdAt,
|
|
378
|
+
});
|
|
379
|
+
return this.toHandle(item, options);
|
|
380
|
+
}
|
|
381
|
+
async listOwned(options = {}) {
|
|
382
|
+
const items = await this.listAll({ namePrefix: this.namePrefix }, options.timeoutMs ?? this.lookupTimeoutMs, "owned sandbox list");
|
|
383
|
+
const states = options.states === undefined ? null : options.states;
|
|
384
|
+
return items
|
|
385
|
+
.filter((item) => this.isOwnedName(item.name))
|
|
386
|
+
.filter((item) => options.includeTerminated || !isTerminated(item.status))
|
|
387
|
+
.filter((item) => matchesState(item.status, states))
|
|
388
|
+
.map((item) => ({
|
|
389
|
+
name: item.name,
|
|
390
|
+
state: normalizeState(item.status),
|
|
391
|
+
providerStatus: item.status,
|
|
392
|
+
createdAt: new Date(item.createdAt).toISOString(),
|
|
393
|
+
terminated: isTerminated(item.status),
|
|
394
|
+
...(item.tags ? { tags: item.tags } : {}),
|
|
395
|
+
...(typeof item.vcpus === "number" ? { vcpus: item.vcpus } : {}),
|
|
396
|
+
...(typeof item.memory === "number" ? { memoryMiB: item.memory } : {}),
|
|
397
|
+
...(typeof item.totalActiveCpuDurationMs === "number"
|
|
398
|
+
? { activeCpuMs: item.totalActiveCpuDurationMs }
|
|
399
|
+
: {}),
|
|
400
|
+
...(typeof item.totalDurationMs === "number"
|
|
401
|
+
? { wallClockMs: item.totalDurationMs }
|
|
402
|
+
: {}),
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
// --- exec -----------------------------------------------------------------
|
|
406
|
+
async runScript(handle, options) {
|
|
407
|
+
const timeoutMs = options.timeoutMs && options.timeoutMs > 0
|
|
408
|
+
? Math.ceil(options.timeoutMs)
|
|
409
|
+
: this.execTimeoutMs;
|
|
410
|
+
// One budget for the whole call. Fetching the output is a second round
|
|
411
|
+
// trip, and giving it a fresh `timeoutMs` would let a caller who asked for
|
|
412
|
+
// 60s wait 120s.
|
|
413
|
+
return this.runScriptWithDeadline(handle, options, new Deadline(timeoutMs, "command"), timeoutMs);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Same as `runScript` but reuses an existing operation deadline.
|
|
417
|
+
*
|
|
418
|
+
* `uploadBundle` calls in from behind its own bundle-wide budget; giving the
|
|
419
|
+
* verification step a fresh `runScript` deadline would let a partly-consumed
|
|
420
|
+
* upload silently extend past its promised total. `sandboxTimeoutMs` is the
|
|
421
|
+
* script's sandbox-side cap and is passed through separately: the provider
|
|
422
|
+
* is timing the process, not our round trips.
|
|
423
|
+
*/
|
|
424
|
+
async runScriptWithDeadline(handle, options, deadline, sandboxTimeoutMs) {
|
|
425
|
+
this.requireRegistered(handle);
|
|
426
|
+
const command = await this.run(deadline, "command", (signal) => this.sandbox(handle.id).then((sandbox) => sandbox.runCommand({
|
|
427
|
+
...this.commandParams(options),
|
|
428
|
+
timeoutMs: sandboxTimeoutMs,
|
|
429
|
+
signal,
|
|
430
|
+
})));
|
|
431
|
+
return this.collect(command, deadline);
|
|
432
|
+
}
|
|
433
|
+
async exec(handle, command, options = {}) {
|
|
434
|
+
const result = await this.runScript(handle, { command, ...options });
|
|
435
|
+
if (result.exitCode === null) {
|
|
436
|
+
throw new VercelUnknownExitCodeError(result.cmdId ?? "unknown");
|
|
437
|
+
}
|
|
438
|
+
return { output: result.output, exitCode: result.exitCode };
|
|
439
|
+
}
|
|
440
|
+
// --- files ----------------------------------------------------------------
|
|
441
|
+
async uploadFile(handle, source, destination) {
|
|
442
|
+
await this.uploadBundle(handle, { files: [{ source, destination }] });
|
|
443
|
+
}
|
|
444
|
+
async uploadBundle(handle, options) {
|
|
445
|
+
this.requireRegistered(handle);
|
|
446
|
+
const files = [];
|
|
447
|
+
for (const file of options.files) {
|
|
448
|
+
const content = typeof file.source === "string"
|
|
449
|
+
? await readFile(file.source)
|
|
450
|
+
: file.source;
|
|
451
|
+
files.push({ path: file.destination, content });
|
|
452
|
+
}
|
|
453
|
+
if (options.manifest !== undefined) {
|
|
454
|
+
files.push({
|
|
455
|
+
path: options.manifestPath ?? "/workspace/manifest.json",
|
|
456
|
+
content: Buffer.from(JSON.stringify(options.manifest, null, 2), "utf8"),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (files.length === 0) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const parents = [
|
|
463
|
+
...new Set(files
|
|
464
|
+
.map((file) => parentDirectory(file.path))
|
|
465
|
+
.filter((parent) => parent !== null)),
|
|
466
|
+
];
|
|
467
|
+
// One budget across every parent mkdir, the write, and the verification.
|
|
468
|
+
// Per-call timeouts would make the worst case (parents + 2) x fileTimeoutMs,
|
|
469
|
+
// which is unbounded in the number of directories a bundle happens to span.
|
|
470
|
+
const deadline = new Deadline(this.fileTimeoutMs, "bundle upload");
|
|
471
|
+
const sandbox = await this.sandbox(handle.id);
|
|
472
|
+
for (const parent of parents) {
|
|
473
|
+
await this.run(deadline, "mkdir", (signal) => sandbox.mkDir(parent, { signal }));
|
|
474
|
+
}
|
|
475
|
+
await this.run(deadline, "file upload", (signal) => sandbox.writeFiles(files, { signal }));
|
|
476
|
+
// The write API reports success without telling us what landed. Verify the
|
|
477
|
+
// destinations exist, because a bundle that half-uploaded and reported
|
|
478
|
+
// success is indistinguishable from a working one until the workload fails.
|
|
479
|
+
// Route through the shared bundle deadline rather than starting a fresh
|
|
480
|
+
// `runScript` budget — the mkdirs and the write may already have consumed
|
|
481
|
+
// most of the caller's `fileTimeoutMs`.
|
|
482
|
+
const verified = await this.runScriptWithDeadline(handle, {
|
|
483
|
+
command: files
|
|
484
|
+
.map((file) => `test -f ${shellSingleQuote(file.path)}`)
|
|
485
|
+
.join(" && "),
|
|
486
|
+
}, deadline, deadline.require());
|
|
487
|
+
if (verified.exitCode !== 0) {
|
|
488
|
+
throw new Error("Failed to verify uploaded Vercel bundle files");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async downloadFile(handle, source, destination) {
|
|
492
|
+
this.requireRegistered(handle);
|
|
493
|
+
const sandbox = await this.sandbox(handle.id);
|
|
494
|
+
const bytes = await this.run(this.fileTimeoutMs, "file download", (signal) => sandbox.readFileToBuffer({ path: source }, { signal }));
|
|
495
|
+
if (bytes === null) {
|
|
496
|
+
throw new Error(`Vercel sandbox "${handle.id}" has no file at "${source}"`);
|
|
497
|
+
}
|
|
498
|
+
const buffer = Buffer.from(bytes);
|
|
499
|
+
if (destination) {
|
|
500
|
+
await writeFile(destination, buffer);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
return buffer;
|
|
504
|
+
}
|
|
505
|
+
async getHomeDir(handle) {
|
|
506
|
+
this.requireRegistered(handle);
|
|
507
|
+
handle.homeDir = handle.homeDir ?? this.defaultHomeDir;
|
|
508
|
+
return handle.homeDir;
|
|
509
|
+
}
|
|
510
|
+
// --- lifecycle ------------------------------------------------------------
|
|
511
|
+
async stop(handle) {
|
|
512
|
+
const entry = this.registrations.get(handle.id);
|
|
513
|
+
if (!entry || !entry.owned) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.requireOwnedName(handle.id);
|
|
517
|
+
// Lookup, the stop call, and the settle poll all draw from one budget.
|
|
518
|
+
const deadline = new Deadline(this.lifecycleTimeoutMs, "stop");
|
|
519
|
+
const current = await this.requireRemote(handle.id, deadline);
|
|
520
|
+
if (isStopped(current.status)) {
|
|
521
|
+
entry.state = "STOPPED";
|
|
522
|
+
handle.state = "STOPPED";
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (!isStopping(current.status)) {
|
|
526
|
+
const sandbox = await this.sandbox(handle.id);
|
|
527
|
+
await this.run(deadline, "stop", (signal) => sandbox.stop({ signal }));
|
|
528
|
+
}
|
|
529
|
+
await this.waitForSettled(handle.id, isStopped, "stopped", deadline);
|
|
530
|
+
entry.state = "STOPPED";
|
|
531
|
+
handle.state = "STOPPED";
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Resume a stopped sandbox.
|
|
535
|
+
*
|
|
536
|
+
* Vercel has no explicit start call: `Sandbox.get({ resume: true })` is the
|
|
537
|
+
* resume path, and it is the one place this adapter *wants* that side effect.
|
|
538
|
+
*/
|
|
539
|
+
async start(handle) {
|
|
540
|
+
const entry = this.registrations.get(handle.id);
|
|
541
|
+
if (!entry || !entry.owned) {
|
|
542
|
+
return handle;
|
|
543
|
+
}
|
|
544
|
+
this.requireOwnedName(handle.id);
|
|
545
|
+
// Up to four round trips — lookup, settle-if-stopping, resume, settle — so
|
|
546
|
+
// one budget is the only honest reading of `lifecycleTimeoutMs`.
|
|
547
|
+
const deadline = new Deadline(this.lifecycleTimeoutMs, "resume");
|
|
548
|
+
const current = await this.requireRemote(handle.id, deadline);
|
|
549
|
+
if (isTerminated(current.status)) {
|
|
550
|
+
throw new Error(`Vercel sandbox "${handle.id}" is ${current.status} and cannot be resumed`);
|
|
551
|
+
}
|
|
552
|
+
if (isStopping(current.status)) {
|
|
553
|
+
await this.waitForSettled(handle.id, isStopped, "stopped", deadline);
|
|
554
|
+
}
|
|
555
|
+
const resumed = await this.run(deadline, "resume", (signal) => this.api().then((api) => api.get({ name: handle.id, resume: true, signal })));
|
|
556
|
+
this.instances.set(handle.id, resumed);
|
|
557
|
+
await this.waitForSettled(handle.id, isRunning, "running", deadline);
|
|
558
|
+
entry.state = "STARTED";
|
|
559
|
+
handle.state = "STARTED";
|
|
560
|
+
return handle;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Delete a sandbox and verify it is gone.
|
|
564
|
+
*
|
|
565
|
+
* The registration is retained when verification fails, so a caller can retry
|
|
566
|
+
* cleanup. Dropping it would make a leaked sandbox unreachable through this
|
|
567
|
+
* runtime and therefore invisible to every subsequent audit.
|
|
568
|
+
*/
|
|
569
|
+
async destroy(handle) {
|
|
570
|
+
const entry = this.registrations.get(handle.id);
|
|
571
|
+
if (!entry) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (!entry.owned) {
|
|
575
|
+
this.forget(handle.id);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
this.requireOwnedName(handle.id);
|
|
579
|
+
// The delete call and the proof of absence share one budget: verification
|
|
580
|
+
// is part of the teardown, not a second operation with its own allowance.
|
|
581
|
+
const deadline = new Deadline(this.deleteTimeoutMs, "delete");
|
|
582
|
+
try {
|
|
583
|
+
const sandbox = await this.sandbox(handle.id);
|
|
584
|
+
await this.run(deadline, "delete", (signal) => sandbox.delete({ signal }));
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (!isVercelNotFoundError(error)) {
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
// Already gone. Fall through to verification, which will agree.
|
|
591
|
+
}
|
|
592
|
+
const verified = await this.waitUntilDeleted(handle.id, deadline, entry.createdAtMs);
|
|
593
|
+
if (!verified) {
|
|
594
|
+
throw new VercelDestroyVerificationError(handle.id, deadline.totalMs);
|
|
595
|
+
}
|
|
596
|
+
this.forget(handle.id);
|
|
597
|
+
}
|
|
598
|
+
// --- internals ------------------------------------------------------------
|
|
599
|
+
async api() {
|
|
600
|
+
if (this.injectedApiFactory) {
|
|
601
|
+
return this.injectedApiFactory();
|
|
602
|
+
}
|
|
603
|
+
return createOfficialVercelSandboxApi(this.credentials);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Resolve a live sandbox object, caching it per name.
|
|
607
|
+
*
|
|
608
|
+
* The cached instance holds the SDK client, and that client's fetch reads the
|
|
609
|
+
* ambient operation deadline at call time rather than at construction time —
|
|
610
|
+
* so a cached handle never carries a stale deadline into a later operation.
|
|
611
|
+
*/
|
|
612
|
+
async sandbox(name) {
|
|
613
|
+
const cached = this.instances.get(name);
|
|
614
|
+
if (cached) {
|
|
615
|
+
return cached;
|
|
616
|
+
}
|
|
617
|
+
const resolved = await this.run(this.lookupTimeoutMs, "attach", (signal) => this.api().then((api) => api.get({ name, signal })));
|
|
618
|
+
this.instances.set(name, resolved);
|
|
619
|
+
return resolved;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Bound one logical operation by an absolute deadline.
|
|
623
|
+
*
|
|
624
|
+
* Two mechanisms, deliberately: the ambient signal aborts the SDK's requests
|
|
625
|
+
* *including its internal retries*, and the race turns whatever comes back
|
|
626
|
+
* into one typed error. Either alone leaves a hole — the signal alone yields
|
|
627
|
+
* an opaque `AbortError`, the race alone leaves the request running.
|
|
628
|
+
*/
|
|
629
|
+
/**
|
|
630
|
+
* Bound one logical operation by an absolute deadline.
|
|
631
|
+
*
|
|
632
|
+
* Two mechanisms, deliberately: the ambient signal aborts the SDK's requests
|
|
633
|
+
* *including its internal retries*, and the race turns whatever comes back
|
|
634
|
+
* into one typed error. Either alone leaves a hole — the signal alone yields
|
|
635
|
+
* an opaque `AbortError`, the race alone leaves the request running.
|
|
636
|
+
*
|
|
637
|
+
* Pass a `Deadline` to share one budget across a multi-round-trip operation;
|
|
638
|
+
* pass a number for a single call that owns its own budget.
|
|
639
|
+
*/
|
|
640
|
+
run(budget, operation, fn) {
|
|
641
|
+
const deadline = typeof budget === "number"
|
|
642
|
+
? new Deadline(budget, operation)
|
|
643
|
+
: budget;
|
|
644
|
+
const remainingMs = deadline.require();
|
|
645
|
+
// The retry ceiling is an absolute cap on any *single* call, so it clamps
|
|
646
|
+
// what remains rather than replacing the operation's budget.
|
|
647
|
+
const callMs = Math.min(remainingMs, this.retryDeadlineMs);
|
|
648
|
+
// Report whichever cap actually fired. Saying "did not complete within
|
|
649
|
+
// 60000ms" after 25ms because the retry ceiling cut it short would send a
|
|
650
|
+
// reader hunting for a slow network instead of at the ceiling they set.
|
|
651
|
+
const timedOut = () => callMs < remainingMs
|
|
652
|
+
? new VercelOperationTimeoutError(`${operation} (SDK retry ceiling)`, callMs)
|
|
653
|
+
: new VercelOperationTimeoutError(deadline.operation, deadline.totalMs);
|
|
654
|
+
return withRequestDeadline(callMs, async () => {
|
|
655
|
+
const signal = currentRequestDeadlineSignal();
|
|
656
|
+
let timer;
|
|
657
|
+
try {
|
|
658
|
+
return await Promise.race([
|
|
659
|
+
fn(signal).catch((error) => {
|
|
660
|
+
if (isAbortError(error)) {
|
|
661
|
+
throw timedOut();
|
|
662
|
+
}
|
|
663
|
+
throw error;
|
|
664
|
+
}),
|
|
665
|
+
new Promise((_resolve, reject) => {
|
|
666
|
+
timer = setTimeout(() => reject(timedOut()), callMs);
|
|
667
|
+
}),
|
|
668
|
+
]);
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
// Always cleared: a pinned timer keeps the event loop alive and stops
|
|
672
|
+
// a short-lived process from exiting.
|
|
673
|
+
if (timer !== undefined) {
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
commandParams(options) {
|
|
680
|
+
const env = validateEnv(options.env ?? {});
|
|
681
|
+
return {
|
|
682
|
+
// The port speaks shell scripts; the provider speaks argv. Wrapping in
|
|
683
|
+
// `sh -c` bridges them without interpolating anything into the script:
|
|
684
|
+
// cwd and env go through the provider's own fields, so no caller value is
|
|
685
|
+
// ever spliced into a command string.
|
|
686
|
+
cmd: "sh",
|
|
687
|
+
args: ["-c", options.command],
|
|
688
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
689
|
+
...(hasEntries(env) ? { env: { ...env } } : {}),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
async collect(command, budget) {
|
|
693
|
+
const [stdout, stderr] = await this.run(budget, "command output", async (signal) => [
|
|
694
|
+
await command.output("stdout", { signal }),
|
|
695
|
+
await command.output("stderr", { signal }),
|
|
696
|
+
]);
|
|
697
|
+
return {
|
|
698
|
+
output: combineOutput(stdout, stderr),
|
|
699
|
+
...(stdout ? { stdout } : {}),
|
|
700
|
+
...(stderr ? { stderr } : {}),
|
|
701
|
+
exitCode: command.exitCode,
|
|
702
|
+
cmdId: command.cmdId,
|
|
703
|
+
// `truncated` is deliberately omitted: the provider does not report a cap,
|
|
704
|
+
// and `undefined` means "not reported", never "known complete".
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
register(name, next) {
|
|
708
|
+
const existing = this.registrations.get(name);
|
|
709
|
+
this.registrations.set(name, {
|
|
710
|
+
owned: existing?.owned === true || next.owned,
|
|
711
|
+
labels: hasEntries(next.labels)
|
|
712
|
+
? { ...next.labels }
|
|
713
|
+
: existing?.labels ?? {},
|
|
714
|
+
state: next.state ?? existing?.state,
|
|
715
|
+
createdAtMs: next.createdAtMs ?? existing?.createdAtMs,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
forget(name) {
|
|
719
|
+
this.registrations.delete(name);
|
|
720
|
+
this.instances.delete(name);
|
|
721
|
+
}
|
|
722
|
+
requireRegistered(handle) {
|
|
723
|
+
const entry = this.registrations.get(handle.id);
|
|
724
|
+
if (!entry) {
|
|
725
|
+
throw new Error(`Vercel runtime handle "${handle.id}" is not attached`);
|
|
726
|
+
}
|
|
727
|
+
return entry;
|
|
728
|
+
}
|
|
729
|
+
requireOwnedName(name) {
|
|
730
|
+
if (!this.isOwnedName(name)) {
|
|
731
|
+
throw new VercelForeignSandboxError(name, this.namePrefix);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Walk the sandbox listing one page at a time.
|
|
736
|
+
*
|
|
737
|
+
* A generator so callers can consume pages incrementally: a single-result
|
|
738
|
+
* lookup can stop after the first candidate on page one instead of forcing
|
|
739
|
+
* every page in the project to load before filtering. Every page still draws
|
|
740
|
+
* from one shared budget, so a listing cannot cost pages x timeout.
|
|
741
|
+
*/
|
|
742
|
+
async *listPages(params, budget, operation, pageSize) {
|
|
743
|
+
const limit = pageSize && pageSize > 0
|
|
744
|
+
? Math.ceil(pageSize)
|
|
745
|
+
: this.listPageSize;
|
|
746
|
+
const deadline = typeof budget === "number"
|
|
747
|
+
? new Deadline(budget, operation)
|
|
748
|
+
: budget;
|
|
749
|
+
let cursor;
|
|
750
|
+
for (let page = 0; page < this.maxListPages; page += 1) {
|
|
751
|
+
const response = await this.run(deadline, operation, (signal) => this.api().then((api) => api.list({
|
|
752
|
+
...params,
|
|
753
|
+
limit,
|
|
754
|
+
...(cursor ? { cursor } : {}),
|
|
755
|
+
signal,
|
|
756
|
+
})));
|
|
757
|
+
if (!response || !Array.isArray(response.sandboxes)) {
|
|
758
|
+
throw new Error("Vercel sandbox list response is malformed");
|
|
759
|
+
}
|
|
760
|
+
const malformed = response.sandboxes.findIndex((item) => !isVercelSandboxListItem(item));
|
|
761
|
+
if (malformed !== -1) {
|
|
762
|
+
throw new Error(`Vercel sandbox list item ${malformed} is malformed`);
|
|
763
|
+
}
|
|
764
|
+
yield response.sandboxes;
|
|
765
|
+
const next = response.pagination?.next;
|
|
766
|
+
if (!next) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
cursor = next;
|
|
770
|
+
}
|
|
771
|
+
throw new VercelListPageLimitError(this.maxListPages, operation);
|
|
772
|
+
}
|
|
773
|
+
async listAll(params, budget, operation, pageSize) {
|
|
774
|
+
const items = [];
|
|
775
|
+
for await (const page of this.listPages(params, budget, operation, pageSize)) {
|
|
776
|
+
items.push(...page);
|
|
777
|
+
}
|
|
778
|
+
return items;
|
|
779
|
+
}
|
|
780
|
+
async remoteByName(name, budget) {
|
|
781
|
+
// `namePrefix` narrows server-side; the exact-name match is still done here
|
|
782
|
+
// because a prefix query legitimately returns siblings.
|
|
783
|
+
const items = await this.listAll({ namePrefix: name }, budget, `lookup for sandbox "${name}"`);
|
|
784
|
+
return items.find((item) => item.name === name) ?? null;
|
|
785
|
+
}
|
|
786
|
+
async requireRemote(name, budget = this.lookupTimeoutMs) {
|
|
787
|
+
const item = await this.remoteByName(name, budget);
|
|
788
|
+
if (!item) {
|
|
789
|
+
throw new Error(`Vercel sandbox "${name}" is no longer available`);
|
|
790
|
+
}
|
|
791
|
+
return item;
|
|
792
|
+
}
|
|
793
|
+
async waitForSettled(name, settled, description, deadline) {
|
|
794
|
+
let lastState = "unknown";
|
|
795
|
+
for (;;) {
|
|
796
|
+
const item = await this.remoteByName(name, Math.min(this.lookupTimeoutMs, Math.max(1, deadline.remaining())));
|
|
797
|
+
if (!item) {
|
|
798
|
+
throw new Error(`Vercel sandbox "${name}" disappeared while waiting for ${description}`);
|
|
799
|
+
}
|
|
800
|
+
lastState = item.status;
|
|
801
|
+
if (settled(item.status)) {
|
|
802
|
+
return item;
|
|
803
|
+
}
|
|
804
|
+
if (isTerminated(item.status)) {
|
|
805
|
+
throw new Error(`Vercel sandbox "${name}" became ${item.status} while waiting for ${description}`);
|
|
806
|
+
}
|
|
807
|
+
if (deadline.expired()) {
|
|
808
|
+
throw new VercelLifecycleTimeoutError(name, description, lastState, deadline.totalMs);
|
|
809
|
+
}
|
|
810
|
+
await delay(this.pollIntervalMs);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Poll until the named sandbox is verifiably gone.
|
|
815
|
+
*
|
|
816
|
+
* Vercel deletion removes the row rather than flagging it, so absence is the
|
|
817
|
+
* proof. The `createdAtMs` guard covers the one case absence alone gets
|
|
818
|
+
* wrong: a *new* sandbox created under the same name would otherwise read as
|
|
819
|
+
* our deleted one surviving, and we would report a leak that does not exist —
|
|
820
|
+
* or worse, keep waiting on it and time out a clean teardown.
|
|
821
|
+
*/
|
|
822
|
+
async waitUntilDeleted(name, deadline, createdAtMs) {
|
|
823
|
+
for (;;) {
|
|
824
|
+
const item = await this.remoteByName(name, Math.min(this.lookupTimeoutMs, Math.max(1, deadline.remaining())));
|
|
825
|
+
if (!item) {
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
if (createdAtMs !== undefined && item.createdAt !== createdAtMs) {
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
if (deadline.expired()) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
await delay(this.pollIntervalMs);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
toHandle(item, options = {}) {
|
|
838
|
+
return {
|
|
839
|
+
id: item.name,
|
|
840
|
+
state: normalizeState(item.status),
|
|
841
|
+
homeDir: options.homeDir ?? this.defaultHomeDir,
|
|
842
|
+
...(options.workdir ? { workdir: options.workdir } : {}),
|
|
843
|
+
createdAt: new Date(item.createdAt).toISOString(),
|
|
844
|
+
...(item.updatedAt
|
|
845
|
+
? { updatedAt: new Date(item.updatedAt).toISOString() }
|
|
846
|
+
: {}),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
ownedName(requested) {
|
|
850
|
+
if (!requested) {
|
|
851
|
+
return `${this.namePrefix}-${randomUUID().replaceAll("-", "").slice(0, 16)}`;
|
|
852
|
+
}
|
|
853
|
+
if (this.isOwnedName(requested)) {
|
|
854
|
+
return requested;
|
|
855
|
+
}
|
|
856
|
+
// A caller-supplied name is slugged and salted with a digest of the
|
|
857
|
+
// original. Two different requests that slug to the same string therefore
|
|
858
|
+
// still get distinct sandboxes instead of silently colliding on one.
|
|
859
|
+
const digest = createHash("sha256").update(requested).digest("hex").slice(0, 8);
|
|
860
|
+
const room = MAX_NAME_LENGTH - this.namePrefix.length - digest.length - 2;
|
|
861
|
+
const slug = requested
|
|
862
|
+
.normalize("NFKC")
|
|
863
|
+
.toLowerCase()
|
|
864
|
+
.replace(/[^a-z0-9-]+/gu, "-")
|
|
865
|
+
.replace(/^-+|-+$/gu, "")
|
|
866
|
+
.slice(0, Math.max(1, room)) || "sandbox";
|
|
867
|
+
return `${this.namePrefix}-${slug}-${digest}`;
|
|
868
|
+
}
|
|
869
|
+
isOwnedName(name) {
|
|
870
|
+
return name === this.namePrefix || name.startsWith(`${this.namePrefix}-`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Construction-time reconciliation between the SDK-free declarations and the
|
|
875
|
+
* surface actually implemented.
|
|
876
|
+
*
|
|
877
|
+
* The registries are exhaustive over every capability key, so adding a key
|
|
878
|
+
* without deciding how it is backed is a type error rather than an unnoticed
|
|
879
|
+
* `false`. A conservative *under*-claim is always allowed — that is how a
|
|
880
|
+
* capability stays false until live evidence promotes it — but a `true` claim
|
|
881
|
+
* without the implementation behind it throws here, at construction, rather
|
|
882
|
+
* than surfacing as a runtime surprise at the call site.
|
|
883
|
+
*/
|
|
884
|
+
export function reconcileVercelCapabilities(runtime) {
|
|
885
|
+
const workflowRegistry = {
|
|
886
|
+
pty: false,
|
|
887
|
+
snapshots: false,
|
|
888
|
+
isolation: "strong",
|
|
889
|
+
persistentHandle: typeof runtime.getById === "function",
|
|
890
|
+
streamingLogs: false,
|
|
891
|
+
};
|
|
892
|
+
for (const key of Object.keys(workflowRegistry)) {
|
|
893
|
+
if (workflowRegistry[key] !== vercelWorkflowCapabilities[key]) {
|
|
894
|
+
throw new VercelCapabilityMismatchError(String(key));
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const outerRegistry = {
|
|
898
|
+
warmLease: typeof runtime.findAllByLabels === "function",
|
|
899
|
+
lifecycle: typeof runtime.start === "function" && typeof runtime.stop === "function",
|
|
900
|
+
};
|
|
901
|
+
if (vercelSandboxCapabilities.warmLease && !outerRegistry.warmLease) {
|
|
902
|
+
throw new VercelCapabilityMismatchError("warmLease");
|
|
903
|
+
}
|
|
904
|
+
if (vercelSandboxCapabilities.lifecycle && !outerRegistry.lifecycle) {
|
|
905
|
+
throw new VercelCapabilityMismatchError("lifecycle");
|
|
906
|
+
}
|
|
907
|
+
// --- modes ---------------------------------------------------------------
|
|
908
|
+
//
|
|
909
|
+
// A boolean can only over-claim *whether* a capability exists; a mode can
|
|
910
|
+
// over-claim its *shape*, which is harder to catch later. An over-claimed mode
|
|
911
|
+
// reads as a settled fact, `isPendingEvidence()` reports false for it, and so
|
|
912
|
+
// nothing downstream ever revisits it. Each check below is a shape this
|
|
913
|
+
// adapter would be lying about.
|
|
914
|
+
//
|
|
915
|
+
// `filesystem` is intentionally absent from this table and from the declared
|
|
916
|
+
// modes — see VERCEL_FILESYSTEM_MODE_UNDECLARED. It is per-instance
|
|
917
|
+
// configuration whose across-restart behavior rides on the same unproven live
|
|
918
|
+
// probe as `lifecycle`, so "unknown" is the accurate answer.
|
|
919
|
+
// Read through the wide port type on purpose. `vercelCapabilityModes` is
|
|
920
|
+
// `as const`, so comparing its literals directly is a compile error today and
|
|
921
|
+
// would silently become dead code the moment someone edits a value. Widening
|
|
922
|
+
// keeps these checks live against whatever the table actually says.
|
|
923
|
+
const modes = vercelCapabilityModes;
|
|
924
|
+
if (modes.outputStreams === "combined-stream"
|
|
925
|
+
|| modes.outputStreams === "separate-streams") {
|
|
926
|
+
// `runScript` awaits output("stdout")/output("stderr") to completion; there
|
|
927
|
+
// is no incremental channel on this port to back a streaming claim.
|
|
928
|
+
throw new VercelCapabilityMismatchError("modes.outputStreams");
|
|
929
|
+
}
|
|
930
|
+
if (modes.lifetime === "never-idle") {
|
|
931
|
+
// Every Vercel sandbox carries a wall-clock termination deadline.
|
|
932
|
+
throw new VercelCapabilityMismatchError("modes.lifetime");
|
|
933
|
+
}
|
|
934
|
+
if (modes.interactive !== "not-exposed") {
|
|
935
|
+
// openInteractive() is real in the SDK and unreachable through this port.
|
|
936
|
+
// Both a positive claim and a bare "unknown" would misdescribe that: the
|
|
937
|
+
// port's lack of a PTY operation is settled, not pending evidence.
|
|
938
|
+
throw new VercelCapabilityMismatchError("modes.interactive");
|
|
939
|
+
}
|
|
940
|
+
if (modes.snapshots !== "not-exposed") {
|
|
941
|
+
// Same for snapshot()/fork(): real in the SDK, no port operation reaches
|
|
942
|
+
// them. Distinct from observed.snapshotCapture, which a canary may promote.
|
|
943
|
+
throw new VercelCapabilityMismatchError("modes.snapshots");
|
|
944
|
+
}
|
|
945
|
+
const observedRegistry = {
|
|
946
|
+
cleanupVerified: typeof runtime.destroy === "function"
|
|
947
|
+
&& typeof runtime.listOwned === "function",
|
|
948
|
+
fork: false,
|
|
949
|
+
lifecycle: outerRegistry.lifecycle,
|
|
950
|
+
neverIdle: false,
|
|
951
|
+
ptySurvival: false,
|
|
952
|
+
snapshotCapture: false,
|
|
953
|
+
streamingExec: false,
|
|
954
|
+
warmLease: outerRegistry.warmLease,
|
|
955
|
+
};
|
|
956
|
+
for (const key of Object.keys(observedRegistry)) {
|
|
957
|
+
if (vercelObservedCapabilities[key] && !observedRegistry[key]) {
|
|
958
|
+
throw new VercelCapabilityMismatchError(String(key));
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// --- helpers ----------------------------------------------------------------
|
|
963
|
+
function validateNamePrefix(value) {
|
|
964
|
+
const prefix = required(value, "Vercel name prefix");
|
|
965
|
+
// Lowercase alphanumeric with internal hyphens: the intersection of what
|
|
966
|
+
// Vercel resource names accept and what survives a DNS label, since exposed
|
|
967
|
+
// ports become subdomains derived from the sandbox.
|
|
968
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(prefix)) {
|
|
969
|
+
throw new Error("Vercel name prefix must be lowercase alphanumeric, optionally hyphenated");
|
|
970
|
+
}
|
|
971
|
+
if (prefix.length > MAX_NAME_LENGTH - GENERATED_SUFFIX_LENGTH) {
|
|
972
|
+
throw new Error(`Vercel name prefix must leave ${GENERATED_SUFFIX_LENGTH} characters of room `
|
|
973
|
+
+ `for the generated suffix within ${MAX_NAME_LENGTH} characters`);
|
|
974
|
+
}
|
|
975
|
+
return prefix;
|
|
976
|
+
}
|
|
977
|
+
function validateSource(source) {
|
|
978
|
+
if (!source) {
|
|
979
|
+
return { type: "default" };
|
|
980
|
+
}
|
|
981
|
+
if (source.type === "default") {
|
|
982
|
+
return { type: "default" };
|
|
983
|
+
}
|
|
984
|
+
if (source.type === "image") {
|
|
985
|
+
return { type: "image", image: required(source.image, "Vercel image") };
|
|
986
|
+
}
|
|
987
|
+
if (source.type === "runtime") {
|
|
988
|
+
return {
|
|
989
|
+
type: "runtime",
|
|
990
|
+
runtime: required(source.runtime, "Vercel runtime"),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
throw new Error("Vercel sandbox source is invalid");
|
|
994
|
+
}
|
|
995
|
+
function validateVcpus(value) {
|
|
996
|
+
if (value === undefined) {
|
|
997
|
+
return undefined;
|
|
998
|
+
}
|
|
999
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
1000
|
+
throw new Error("Vercel vcpus must be a positive integer");
|
|
1001
|
+
}
|
|
1002
|
+
return value;
|
|
1003
|
+
}
|
|
1004
|
+
function validatePorts(ports) {
|
|
1005
|
+
if (!ports) {
|
|
1006
|
+
return undefined;
|
|
1007
|
+
}
|
|
1008
|
+
if (ports.length > MAX_PORTS) {
|
|
1009
|
+
throw new Error(`Vercel sandboxes expose at most ${MAX_PORTS} ports; received ${ports.length}`);
|
|
1010
|
+
}
|
|
1011
|
+
for (const port of ports) {
|
|
1012
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
1013
|
+
throw new Error(`Invalid Vercel port "${port}"`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return [...ports];
|
|
1017
|
+
}
|
|
1018
|
+
function validateEnv(env) {
|
|
1019
|
+
for (const name of Object.keys(env)) {
|
|
1020
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
|
|
1021
|
+
throw new Error(`Invalid Vercel environment variable name "${name}"`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return env;
|
|
1025
|
+
}
|
|
1026
|
+
function validateTags(tags) {
|
|
1027
|
+
const keys = Object.keys(tags);
|
|
1028
|
+
if (keys.length > MAX_TAGS) {
|
|
1029
|
+
throw new VercelTagLimitError(keys.length);
|
|
1030
|
+
}
|
|
1031
|
+
for (const key of keys) {
|
|
1032
|
+
if (!key.trim()) {
|
|
1033
|
+
throw new Error("Vercel tag keys must be non-empty");
|
|
1034
|
+
}
|
|
1035
|
+
if (typeof tags[key] !== "string") {
|
|
1036
|
+
throw new Error(`Vercel tag "${key}" must have a string value`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return tags;
|
|
1040
|
+
}
|
|
1041
|
+
function required(value, description) {
|
|
1042
|
+
const normalized = value?.trim();
|
|
1043
|
+
if (!normalized) {
|
|
1044
|
+
throw new Error(`${description} is required`);
|
|
1045
|
+
}
|
|
1046
|
+
return normalized;
|
|
1047
|
+
}
|
|
1048
|
+
function optionalPositive(value, description) {
|
|
1049
|
+
if (value === undefined) {
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1053
|
+
throw new Error(`${description} must be a positive number`);
|
|
1054
|
+
}
|
|
1055
|
+
return Math.ceil(value);
|
|
1056
|
+
}
|
|
1057
|
+
function positiveDuration(value, fallback) {
|
|
1058
|
+
return value !== undefined && Number.isFinite(value) && value > 0
|
|
1059
|
+
? Math.ceil(value)
|
|
1060
|
+
: fallback;
|
|
1061
|
+
}
|
|
1062
|
+
function hasEntries(value) {
|
|
1063
|
+
return !!value && Object.keys(value).length > 0;
|
|
1064
|
+
}
|
|
1065
|
+
function isVercelSandboxListItem(value) {
|
|
1066
|
+
return Boolean(value
|
|
1067
|
+
&& typeof value === "object"
|
|
1068
|
+
&& typeof value.name === "string"
|
|
1069
|
+
&& value.name.trim()
|
|
1070
|
+
&& typeof value.status === "string"
|
|
1071
|
+
&& value.status.trim()
|
|
1072
|
+
&& typeof value.createdAt === "number");
|
|
1073
|
+
}
|
|
1074
|
+
function matchesAllTags(actual, wanted) {
|
|
1075
|
+
const entries = Object.entries(wanted);
|
|
1076
|
+
if (entries.length === 0) {
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
if (!actual) {
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
return entries.every(([key, value]) => actual[key] === value);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Map a provider status onto the port's uppercase state vocabulary.
|
|
1086
|
+
*
|
|
1087
|
+
* `snapshotting` maps to STOPPING because that is what it is from a caller's
|
|
1088
|
+
* perspective — the VM is on its way down and cannot take work.
|
|
1089
|
+
*/
|
|
1090
|
+
function normalizeState(status) {
|
|
1091
|
+
switch (status.toLowerCase()) {
|
|
1092
|
+
case "running":
|
|
1093
|
+
return "STARTED";
|
|
1094
|
+
case "pending":
|
|
1095
|
+
return "STARTING";
|
|
1096
|
+
case "stopping":
|
|
1097
|
+
case "snapshotting":
|
|
1098
|
+
return "STOPPING";
|
|
1099
|
+
case "stopped":
|
|
1100
|
+
return "STOPPED";
|
|
1101
|
+
case "failed":
|
|
1102
|
+
case "aborted":
|
|
1103
|
+
return "FAILED";
|
|
1104
|
+
default:
|
|
1105
|
+
return status.toUpperCase();
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function normalizeRequestedState(state) {
|
|
1109
|
+
const normalized = state.toUpperCase();
|
|
1110
|
+
if (normalized === "RUNNING")
|
|
1111
|
+
return "STARTED";
|
|
1112
|
+
if (normalized === "PAUSED" || normalized === "SUSPENDED")
|
|
1113
|
+
return "STOPPED";
|
|
1114
|
+
return normalized;
|
|
1115
|
+
}
|
|
1116
|
+
function matchesState(status, states) {
|
|
1117
|
+
if (states === null) {
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
const actual = normalizeState(status);
|
|
1121
|
+
return states.some((state) => normalizeRequestedState(state) === actual);
|
|
1122
|
+
}
|
|
1123
|
+
function isRunning(status) {
|
|
1124
|
+
return status.toLowerCase() === "running";
|
|
1125
|
+
}
|
|
1126
|
+
function isStopped(status) {
|
|
1127
|
+
return status.toLowerCase() === "stopped";
|
|
1128
|
+
}
|
|
1129
|
+
function isStopping(status) {
|
|
1130
|
+
const normalized = status.toLowerCase();
|
|
1131
|
+
return normalized === "stopping" || normalized === "snapshotting";
|
|
1132
|
+
}
|
|
1133
|
+
function isTerminated(status) {
|
|
1134
|
+
const normalized = status.toLowerCase();
|
|
1135
|
+
return normalized === "failed" || normalized === "aborted";
|
|
1136
|
+
}
|
|
1137
|
+
function isAbortError(error) {
|
|
1138
|
+
if (!error || typeof error !== "object") {
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
const name = error.name;
|
|
1142
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
1143
|
+
}
|
|
1144
|
+
function toMillis(value) {
|
|
1145
|
+
return value instanceof Date ? value.getTime() : undefined;
|
|
1146
|
+
}
|
|
1147
|
+
function toIso(value) {
|
|
1148
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
1149
|
+
}
|
|
1150
|
+
function combineOutput(stdout, stderr) {
|
|
1151
|
+
if (stdout && stderr) {
|
|
1152
|
+
return stdout.endsWith("\n") || stderr.startsWith("\n")
|
|
1153
|
+
? `${stdout}${stderr}`
|
|
1154
|
+
: `${stdout}\n${stderr}`;
|
|
1155
|
+
}
|
|
1156
|
+
return stdout || stderr || "";
|
|
1157
|
+
}
|
|
1158
|
+
function parentDirectory(destination) {
|
|
1159
|
+
const normalized = destination.trim().replace(/\/+$/gu, "");
|
|
1160
|
+
const index = normalized.lastIndexOf("/");
|
|
1161
|
+
return index > 0 ? normalized.slice(0, index) : null;
|
|
1162
|
+
}
|
|
1163
|
+
function shellSingleQuote(value) {
|
|
1164
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
1165
|
+
}
|
|
1166
|
+
function delay(ms) {
|
|
1167
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1168
|
+
}
|
|
1169
|
+
//# sourceMappingURL=runtime.js.map
|