@agent-relay/sandbox 0.1.2 → 0.1.3
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 +226 -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/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/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/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/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/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 +35 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- 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/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/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 +35 -4
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { modalCapabilityModes, modalSandboxCapabilities, } from "./capabilities.js";
|
|
4
|
+
import { resolveModalRuntimeOptions, } from "./config.js";
|
|
5
|
+
import { createOfficialModalClient, } from "./internal/sdk.js";
|
|
6
|
+
/**
|
|
7
|
+
* Modal sandbox runtime.
|
|
8
|
+
*
|
|
9
|
+
* ## How Modal's model differs, and what this adapter does about it
|
|
10
|
+
*
|
|
11
|
+
* **Sandboxes are children of an App, built from an Image.** `create` takes
|
|
12
|
+
* both, so the runtime resolves one `App` and one `Image` up front and reuses
|
|
13
|
+
* them. Neither has a default; both come from config.
|
|
14
|
+
*
|
|
15
|
+
* **Sandboxes have a maximum lifetime and the SDK default is five minutes.**
|
|
16
|
+
* The provider terminates the sandbox when it elapses, running work included.
|
|
17
|
+
* `maxLifetimeMs` is therefore required config and is always sent explicitly.
|
|
18
|
+
* It is unrelated to any request deadline — see the note on
|
|
19
|
+
* `createTimeoutSeconds` in {@link ModalRuntime.launch}.
|
|
20
|
+
*
|
|
21
|
+
* **Sandboxes cannot be stopped and restarted.** `terminate()` is the only
|
|
22
|
+
* lifecycle transition and it is terminal. `start`/`stop` are absent from this
|
|
23
|
+
* class rather than present as no-ops, and `lifecycle: false` is declared so
|
|
24
|
+
* the capability resolver — which cannot see an absent method — agrees.
|
|
25
|
+
*
|
|
26
|
+
* **Ownership rides on Modal's native, server-side tags.** Every sandbox is
|
|
27
|
+
* created carrying an ownership tag, every lookup filters on it server-side,
|
|
28
|
+
* and every destructive or reattaching operation re-checks it. Collision
|
|
29
|
+
* safety is structural here, not a naming convention.
|
|
30
|
+
*
|
|
31
|
+
* **Async exec is deliberately not implemented.** Modal's `exec` hands back a
|
|
32
|
+
* live `ContainerProcess`, but nothing public re-resolves one from an id after
|
|
33
|
+
* the fact. Implementing `startScript` without a real `getScriptStatus` would
|
|
34
|
+
* let a caller submit a command it could never poll or reap — precisely the
|
|
35
|
+
* failure the port's all-or-nothing `asyncExec` rule exists to prevent. The
|
|
36
|
+
* trio is omitted, so the resolver derives `asyncExec: false`.
|
|
37
|
+
*
|
|
38
|
+
* **V1 `create` is pinned on purpose.** Modal also offers
|
|
39
|
+
* `experimentalCreate` (the V2 backend). V2 sandboxes do not support tags and
|
|
40
|
+
* are not returned by `sandboxes.list()`, which would silently destroy both the
|
|
41
|
+
* ownership model and the cleanup story above. Do not "upgrade" this call.
|
|
42
|
+
*/
|
|
43
|
+
export class ModalRuntime {
|
|
44
|
+
id = "modal";
|
|
45
|
+
declaredCapabilities = modalSandboxCapabilities;
|
|
46
|
+
declaredCapabilityModes = modalCapabilityModes;
|
|
47
|
+
options;
|
|
48
|
+
clientFactory;
|
|
49
|
+
clientPromise = null;
|
|
50
|
+
contextPromise = null;
|
|
51
|
+
/**
|
|
52
|
+
* Reconciliation tasks that outlive their caller.
|
|
53
|
+
*
|
|
54
|
+
* The only source today is `launch()` racing a `sandboxes.create()` past its
|
|
55
|
+
* client-side deadline: when the deadline fires the create can still complete
|
|
56
|
+
* and hand back a sandbox that would otherwise bill silently. `close()` awaits
|
|
57
|
+
* these before releasing the gRPC channel so that a reconciliation cannot be
|
|
58
|
+
* orphaned by teardown.
|
|
59
|
+
*/
|
|
60
|
+
reconciliations = new Set();
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = resolveModalRuntimeOptions(options);
|
|
63
|
+
this.clientFactory = options.clientFactory ?? createOfficialModalClient;
|
|
64
|
+
reconcileModalCapabilities(this);
|
|
65
|
+
}
|
|
66
|
+
// --- lookup -------------------------------------------------------------
|
|
67
|
+
async findByLabels(labels, options = {}) {
|
|
68
|
+
// Preserve a caller-supplied `limit: 0`: a zero-result lookup is a valid
|
|
69
|
+
// question with a defined answer, not a value to round up to one.
|
|
70
|
+
if (normalizePositiveInt(options.limit) === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const matches = await this.findAllByLabels(labels, { ...options, limit: 1 });
|
|
74
|
+
return matches[0] ?? null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Server-side tag search.
|
|
78
|
+
*
|
|
79
|
+
* Two honesty notes:
|
|
80
|
+
*
|
|
81
|
+
* - Modal's `list` yields sandboxes carrying *at least* the requested tags,
|
|
82
|
+
* so the ownership tag is merged into the filter rather than applied after
|
|
83
|
+
* the fact. A foreign sandbox is never fetched, let alone returned.
|
|
84
|
+
* - Modal's `Sandbox` object carries **no state field**. The only way to
|
|
85
|
+
* learn whether one is running is `poll()`, one round trip each. So state
|
|
86
|
+
* is left `undefined` unless the caller actually asked to filter on it,
|
|
87
|
+
* in which case each candidate costs one extra call. Reporting a guessed
|
|
88
|
+
* state for free would be worse than reporting none.
|
|
89
|
+
*/
|
|
90
|
+
async findAllByLabels(labels, options = {}) {
|
|
91
|
+
const tags = this.buildLookupTags(labels, options.owned);
|
|
92
|
+
const limit = normalizePositiveInt(options.limit) ?? Number.POSITIVE_INFINITY;
|
|
93
|
+
if (limit === 0) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const excluded = new Set(options.excludeIds ?? []);
|
|
97
|
+
const states = options.states ?? null;
|
|
98
|
+
const deadline = this.deadline(options.timeoutMs ?? this.options.lookupTimeoutMs, "findAllByLabels");
|
|
99
|
+
const context = await this.context(deadline);
|
|
100
|
+
const matches = [];
|
|
101
|
+
const iterator = context.client.sandboxes
|
|
102
|
+
.list(this.listParams(context, tags))[Symbol.asyncIterator]();
|
|
103
|
+
// `for await` awaits `iterator.next()` outside `deadline.run`, so a stalled
|
|
104
|
+
// list page can outlive `timeoutMs` and the caller's budget stops meaning
|
|
105
|
+
// anything. Drive the iterator by hand and wrap each fetch in the deadline.
|
|
106
|
+
for (;;) {
|
|
107
|
+
const step = await deadline.run(Promise.resolve(iterator.next()));
|
|
108
|
+
if (step.done) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
const sandbox = step.value;
|
|
112
|
+
if (excluded.has(sandbox.sandboxId)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!(await this.tagsReallyMatch(sandbox, tags, deadline))) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (states === null) {
|
|
119
|
+
matches.push(this.toHandle(sandbox.sandboxId));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const state = await deadline.run(sandbox.poll().then(exitCodeToState));
|
|
123
|
+
if (!states.includes(state)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
matches.push(this.toHandle(sandbox.sandboxId, state));
|
|
127
|
+
}
|
|
128
|
+
if (matches.length >= limit) {
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return matches;
|
|
133
|
+
}
|
|
134
|
+
async countByLabels(labels, options = {}) {
|
|
135
|
+
const tags = this.buildLookupTags(labels, true);
|
|
136
|
+
const states = options.states ?? null;
|
|
137
|
+
// `maxCount` is an early exit for the *count*: stop paging once the caller
|
|
138
|
+
// has learned enough, and report exactly what was counted. `limit` is a
|
|
139
|
+
// page-size hint elsewhere in the port and treating it as a ceiling here
|
|
140
|
+
// would silently undercount matching sandboxes.
|
|
141
|
+
const ceiling = normalizePositiveInt(options.maxCount) ?? Number.POSITIVE_INFINITY;
|
|
142
|
+
if (ceiling === 0) {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
const deadline = this.deadline(options.timeoutMs ?? this.options.lookupTimeoutMs, "countByLabels");
|
|
146
|
+
const context = await this.context(deadline);
|
|
147
|
+
let count = 0;
|
|
148
|
+
const iterator = context.client.sandboxes
|
|
149
|
+
.list(this.listParams(context, tags))[Symbol.asyncIterator]();
|
|
150
|
+
// Same reason as findAllByLabels: the iterator advance must sit inside the
|
|
151
|
+
// deadline or a stalled page defeats the caller's timeoutMs.
|
|
152
|
+
for (;;) {
|
|
153
|
+
const step = await deadline.run(Promise.resolve(iterator.next()));
|
|
154
|
+
if (step.done) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const sandbox = step.value;
|
|
158
|
+
if (!(await this.tagsReallyMatch(sandbox, tags, deadline))) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (states !== null) {
|
|
162
|
+
const state = await deadline.run(sandbox.poll().then(exitCodeToState));
|
|
163
|
+
if (!states.includes(state)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
count += 1;
|
|
168
|
+
if (count >= ceiling) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return count;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Re-resolve a sandbox by id.
|
|
176
|
+
*
|
|
177
|
+
* Ownership is re-checked here, not assumed. `fromId` will happily return any
|
|
178
|
+
* sandbox in the workspace, so a caller holding a synthesized or stale id
|
|
179
|
+
* could otherwise reattach to a sandbox belonging to another lane. A foreign
|
|
180
|
+
* sandbox reads as absent (`null`) rather than raising, because "not one of
|
|
181
|
+
* mine" and "not there" are the same answer to this question.
|
|
182
|
+
*
|
|
183
|
+
* Unlike `findAllByLabels`/`countByLabels`, this path is not server-side
|
|
184
|
+
* tag-filtered: Modal's `fromId` accepts no tag argument, so a foreign
|
|
185
|
+
* sandbox is fetched before `isOwned()` can reject it. That costs one extra
|
|
186
|
+
* `getTags()` round trip for a lookup a lane could otherwise have skipped;
|
|
187
|
+
* the trade is deliberate because `fromId` is the only reattach primitive
|
|
188
|
+
* Modal exposes.
|
|
189
|
+
*/
|
|
190
|
+
async getById(id, options = {}) {
|
|
191
|
+
const sandboxId = id?.trim();
|
|
192
|
+
if (!sandboxId) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const deadline = this.deadline(this.options.lookupTimeoutMs, "getById");
|
|
196
|
+
const context = await this.context(deadline);
|
|
197
|
+
let sandbox;
|
|
198
|
+
try {
|
|
199
|
+
sandbox = await deadline.run(context.client.sandboxes.fromId(sandboxId));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (isNotFound(error)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
if (options.owned !== false && !(await this.isOwned(sandbox, deadline))) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const states = options.states ?? null;
|
|
211
|
+
if (states === null) {
|
|
212
|
+
return this.toHandle(sandbox.sandboxId);
|
|
213
|
+
}
|
|
214
|
+
const state = await deadline.run(sandbox.poll().then(exitCodeToState));
|
|
215
|
+
return states.includes(state) ? this.toHandle(sandbox.sandboxId, state) : null;
|
|
216
|
+
}
|
|
217
|
+
// --- lifecycle ----------------------------------------------------------
|
|
218
|
+
/**
|
|
219
|
+
* Create a sandbox.
|
|
220
|
+
*
|
|
221
|
+
* `createTimeoutSeconds` is a **client-side deadline on this call**. It is
|
|
222
|
+
* deliberately NOT forwarded to Modal's `timeoutMs`, which despite the name
|
|
223
|
+
* is the sandbox's maximum lifetime. Conflating them would make a caller who
|
|
224
|
+
* asked to wait 30 s for provisioning receive a sandbox that self-destructs
|
|
225
|
+
* 30 s later. Lifetime comes from `maxLifetimeMs` config and nowhere else.
|
|
226
|
+
*/
|
|
227
|
+
async launch(options = {}) {
|
|
228
|
+
const tags = this.buildOwnedTags(options.labels ?? {});
|
|
229
|
+
const timeoutMs = options.createTimeoutSeconds === undefined
|
|
230
|
+
? this.options.createTimeoutMs
|
|
231
|
+
: secondsToMs(options.createTimeoutSeconds, "createTimeoutSeconds");
|
|
232
|
+
const deadline = this.deadline(timeoutMs, "launch");
|
|
233
|
+
const context = await this.context(deadline);
|
|
234
|
+
const params = {
|
|
235
|
+
name: this.sandboxName(options.name),
|
|
236
|
+
tags,
|
|
237
|
+
workdir: this.options.workdir,
|
|
238
|
+
// Always explicit. Omitting this is how a caller silently inherits
|
|
239
|
+
// Modal's 5-minute cap.
|
|
240
|
+
timeoutMs: this.options.maxLifetimeMs,
|
|
241
|
+
...(this.options.idleTimeoutMs === undefined
|
|
242
|
+
? {}
|
|
243
|
+
: { idleTimeoutMs: this.options.idleTimeoutMs }),
|
|
244
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
245
|
+
...(this.options.resources?.cpu === undefined ? {} : { cpu: this.options.resources.cpu }),
|
|
246
|
+
...(this.options.resources?.cpuLimit === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: { cpuLimit: this.options.resources.cpuLimit }),
|
|
249
|
+
...(this.options.resources?.memoryMiB === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { memoryMiB: this.options.resources.memoryMiB }),
|
|
252
|
+
...(this.options.resources?.memoryLimitMiB === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: { memoryLimitMiB: this.options.resources.memoryLimitMiB }),
|
|
255
|
+
...(this.options.cloud === undefined ? {} : { cloud: this.options.cloud }),
|
|
256
|
+
...(this.options.regions === undefined ? {} : { regions: this.options.regions }),
|
|
257
|
+
...(this.options.blockNetwork === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { blockNetwork: this.options.blockNetwork }),
|
|
260
|
+
};
|
|
261
|
+
// Hold the raw create promise so it outlives the deadline race. If the
|
|
262
|
+
// caller's client-side budget fires before Modal responds, the create can
|
|
263
|
+
// still complete and hand back a billed sandbox with no handle in reach.
|
|
264
|
+
// Reconcile that outcome in the background — see `trackReconciliation`.
|
|
265
|
+
const pending = context.client.sandboxes.create(context.app, context.image, params);
|
|
266
|
+
let sandbox;
|
|
267
|
+
try {
|
|
268
|
+
sandbox = await deadline.run(pending);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof ModalDeadlineExceededError) {
|
|
272
|
+
this.trackReconciliation(this.reconcileOrphanedCreate(pending));
|
|
273
|
+
}
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
return this.toHandle(sandbox.sandboxId, "running");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Terminate a create that outlived its deadline.
|
|
280
|
+
*
|
|
281
|
+
* Runs after `launch()` has already rejected, so the caller has no handle
|
|
282
|
+
* for the sandbox that might still be materialising. Failure modes here are
|
|
283
|
+
* all silent by design: if the create ultimately rejects there is nothing to
|
|
284
|
+
* clean up; if terminate fails there is no caller to raise to. `close()`
|
|
285
|
+
* awaits these before releasing the gRPC channel so a reconciliation cannot
|
|
286
|
+
* be orphaned by teardown.
|
|
287
|
+
*/
|
|
288
|
+
async reconcileOrphanedCreate(pending) {
|
|
289
|
+
let sandbox;
|
|
290
|
+
try {
|
|
291
|
+
sandbox = await pending;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
await sandbox.terminate({ wait: false });
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// The next label sweep — either the caller's or this runtime's cleanup —
|
|
301
|
+
// is the safety net. There is no useful action to take from here.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
trackReconciliation(work) {
|
|
305
|
+
this.reconciliations.add(work);
|
|
306
|
+
void work.finally(() => this.reconciliations.delete(work));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Terminate a sandbox and verify it is gone.
|
|
310
|
+
*
|
|
311
|
+
* Ownership is checked before the terminate, not after. `destroy` is
|
|
312
|
+
* irreversible, so paying one extra round trip to be certain the sandbox is
|
|
313
|
+
* ours is the correct trade — a mis-routed handle must fail loudly rather
|
|
314
|
+
* than delete another lane's work.
|
|
315
|
+
*
|
|
316
|
+
* Verification prefers Modal's own signal: `terminate({ wait: true })`
|
|
317
|
+
* resolves with an exit code only once the sandbox has actually finished. If
|
|
318
|
+
* the provider resolves without one, the adapter falls back to polling until
|
|
319
|
+
* `poll()` reports a finished sandbox, so `cleanupVerified` never rests on a
|
|
320
|
+
* request merely having been accepted.
|
|
321
|
+
*/
|
|
322
|
+
async destroy(handle) {
|
|
323
|
+
const sandboxId = requireHandleId(handle);
|
|
324
|
+
const deadline = this.deadline(this.options.destroyTimeoutMs, "destroy");
|
|
325
|
+
const context = await this.context(deadline);
|
|
326
|
+
let sandbox;
|
|
327
|
+
try {
|
|
328
|
+
sandbox = await deadline.run(context.client.sandboxes.fromId(sandboxId));
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
// Already absent is success: destroy is idempotent by contract.
|
|
332
|
+
if (isNotFound(error)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
if (!(await this.isOwned(sandbox, deadline))) {
|
|
338
|
+
throw new ModalForeignSandboxError(sandboxId, this.options.namePrefix);
|
|
339
|
+
}
|
|
340
|
+
const settled = await deadline.run(sandbox.terminate({ wait: true }));
|
|
341
|
+
if (typeof settled === "number") {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
await this.waitUntilGone(sandbox, deadline);
|
|
345
|
+
}
|
|
346
|
+
// --- files --------------------------------------------------------------
|
|
347
|
+
async uploadBundle(handle, options) {
|
|
348
|
+
const sandboxId = requireHandleId(handle);
|
|
349
|
+
if (options.files.length === 0) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const deadline = this.deadline(this.options.uploadTimeoutMs, "uploadBundle");
|
|
353
|
+
const context = await this.context(deadline);
|
|
354
|
+
const sandbox = await this.ownedSandbox(sandboxId, context, deadline);
|
|
355
|
+
// Sequential on purpose. Modal's filesystem writes go through the same
|
|
356
|
+
// per-sandbox command router; firing a bundle in parallel buys little and
|
|
357
|
+
// makes a partial failure much harder to attribute to a file.
|
|
358
|
+
for (const file of options.files) {
|
|
359
|
+
deadline.assertNotExpired();
|
|
360
|
+
const destination = file.destination?.trim();
|
|
361
|
+
if (!destination) {
|
|
362
|
+
throw new Error("ModalRuntime uploadBundle requires a non-empty destination for every file");
|
|
363
|
+
}
|
|
364
|
+
if (!destination.startsWith("/")) {
|
|
365
|
+
// Modal requires absolute remote paths and rejects anything else
|
|
366
|
+
// server-side; failing here names the offending file.
|
|
367
|
+
throw new Error(`ModalRuntime uploadBundle requires an absolute destination path; got "${destination}"`);
|
|
368
|
+
}
|
|
369
|
+
const bytes = typeof file.source === "string"
|
|
370
|
+
? new Uint8Array(Buffer.from(file.source, "utf8"))
|
|
371
|
+
: new Uint8Array(file.source);
|
|
372
|
+
await deadline.run(sandbox.filesystem.writeBytes(bytes, destination));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// --- exec ---------------------------------------------------------------
|
|
376
|
+
/**
|
|
377
|
+
* Run a command to completion and return its buffered output.
|
|
378
|
+
*
|
|
379
|
+
* Modal's `exec` takes an **argv array**, not a shell string, so the caller's
|
|
380
|
+
* command is wrapped in `sh -lc`. Without that wrapping every pipeline,
|
|
381
|
+
* redirect, and variable expansion a caller writes would be passed through as
|
|
382
|
+
* a literal argument.
|
|
383
|
+
*
|
|
384
|
+
* stdout, stderr, and the exit code are awaited together. Draining the
|
|
385
|
+
* streams only after `wait()` resolves risks deadlocking against a process
|
|
386
|
+
* that fills a pipe buffer and blocks before exiting.
|
|
387
|
+
*/
|
|
388
|
+
async runScript(handle, options) {
|
|
389
|
+
const sandboxId = requireHandleId(handle);
|
|
390
|
+
const command = options.command?.trim();
|
|
391
|
+
if (!command) {
|
|
392
|
+
throw new Error("ModalRuntime runScript requires a non-empty command");
|
|
393
|
+
}
|
|
394
|
+
const timeoutMs = options.timeoutMs ?? this.options.execTimeoutMs;
|
|
395
|
+
const deadline = this.deadline(timeoutMs, "runScript");
|
|
396
|
+
const context = await this.context(deadline);
|
|
397
|
+
const sandbox = await this.ownedSandbox(sandboxId, context, deadline);
|
|
398
|
+
const process = await deadline.run(sandbox.exec(["sh", "-lc", command], {
|
|
399
|
+
workdir: handle.workdir ?? this.options.workdir,
|
|
400
|
+
// Hand Modal the same budget, so the provider kills a runaway command
|
|
401
|
+
// even if this client goes away. The outer deadline is a second line of
|
|
402
|
+
// defence, not the only one.
|
|
403
|
+
timeoutMs,
|
|
404
|
+
stdout: "pipe",
|
|
405
|
+
stderr: "pipe",
|
|
406
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
407
|
+
}));
|
|
408
|
+
const [stdout, stderr, exitCode] = await deadline.run(Promise.all([
|
|
409
|
+
process.stdout.readText(),
|
|
410
|
+
process.stderr.readText(),
|
|
411
|
+
process.wait(),
|
|
412
|
+
]));
|
|
413
|
+
return {
|
|
414
|
+
output: stdout + stderr,
|
|
415
|
+
stdout,
|
|
416
|
+
stderr,
|
|
417
|
+
exitCode,
|
|
418
|
+
// Modal reports no truncation signal on these streams. `undefined` means
|
|
419
|
+
// "not reported", never "known complete", so it is left off entirely.
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// --- internals ----------------------------------------------------------
|
|
423
|
+
/** Tags every sandbox this runtime creates must carry. */
|
|
424
|
+
ownerTags() {
|
|
425
|
+
return { [this.options.ownerTagKey]: this.options.namePrefix };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Merge caller labels with the ownership tag, refusing to let a caller
|
|
429
|
+
* overwrite it. Silently winning that collision either way is a bug: our
|
|
430
|
+
* value would make a foreign sandbox look owned, theirs would make ours
|
|
431
|
+
* invisible to cleanup.
|
|
432
|
+
*/
|
|
433
|
+
buildOwnedTags(labels) {
|
|
434
|
+
if (Object.hasOwn(labels, this.options.ownerTagKey)) {
|
|
435
|
+
throw new ModalTagCollisionError(this.options.ownerTagKey);
|
|
436
|
+
}
|
|
437
|
+
return { ...labels, ...this.ownerTags() };
|
|
438
|
+
}
|
|
439
|
+
buildLookupTags(labels, owned) {
|
|
440
|
+
// `owned: false` is an explicit opt-out for cross-lane audits. Anything
|
|
441
|
+
// else — including `undefined` — scopes the search to this runtime.
|
|
442
|
+
if (owned === false) {
|
|
443
|
+
return { ...labels };
|
|
444
|
+
}
|
|
445
|
+
return this.buildOwnedTags(labels);
|
|
446
|
+
}
|
|
447
|
+
listParams(context, tags) {
|
|
448
|
+
return {
|
|
449
|
+
tags,
|
|
450
|
+
...(context.app.appId === undefined ? {} : { appId: context.app.appId }),
|
|
451
|
+
...(this.options.environment === undefined
|
|
452
|
+
? {}
|
|
453
|
+
: { environment: this.options.environment }),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Confirm in-process that a listed sandbox really carries the tags we
|
|
458
|
+
* filtered on.
|
|
459
|
+
*
|
|
460
|
+
* The server-side filter is the mechanism this adapter's ownership rests on,
|
|
461
|
+
* and it has not yet been proven live — `warmLease` is still `false`. A
|
|
462
|
+
* filter that is silently ignored or partially applied would return a foreign
|
|
463
|
+
* sandbox, and handing that back as a warm lease is worse than returning
|
|
464
|
+
* nothing: the caller would exec into another tenant's container. So the
|
|
465
|
+
* claim is re-checked against the sandbox's own tags rather than trusted.
|
|
466
|
+
*
|
|
467
|
+
* Skipped when there is nothing to check, so an unfiltered audit listing
|
|
468
|
+
* (`owned: false` with no labels) does not pay for a round trip that could
|
|
469
|
+
* not reject anything.
|
|
470
|
+
*/
|
|
471
|
+
async tagsReallyMatch(sandbox, wanted, deadline) {
|
|
472
|
+
const entries = Object.entries(wanted);
|
|
473
|
+
if (!this.options.verifyTagsClientSide || entries.length === 0) {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
let actual;
|
|
477
|
+
try {
|
|
478
|
+
actual = await deadline.run(sandbox.getTags());
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
// A sandbox that vanished between `list` and `getTags` is a non-match,
|
|
482
|
+
// not a lookup failure — otherwise one racing termination would abort
|
|
483
|
+
// the entire scan and hide every other candidate from the caller.
|
|
484
|
+
if (isNotFound(error)) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
return entries.every(([key, value]) => actual[key] === value);
|
|
490
|
+
}
|
|
491
|
+
async isOwned(sandbox, deadline) {
|
|
492
|
+
const tags = await deadline.run(sandbox.getTags());
|
|
493
|
+
return tags[this.options.ownerTagKey] === this.options.namePrefix;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Resolve a sandbox by id and prove ownership before returning it.
|
|
497
|
+
*
|
|
498
|
+
* Every write-or-execute path that takes a caller-supplied id must go
|
|
499
|
+
* through this: `fromId` will happily return any sandbox in the workspace,
|
|
500
|
+
* so a stale or synthesized handle would otherwise let a caller reattach to
|
|
501
|
+
* another lane's sandbox and mutate it. `destroy` uses its own resolution
|
|
502
|
+
* because it must also treat an already-absent sandbox as success — the
|
|
503
|
+
* only shape here that is idempotent.
|
|
504
|
+
*/
|
|
505
|
+
async ownedSandbox(sandboxId, context, deadline) {
|
|
506
|
+
const sandbox = await deadline.run(context.client.sandboxes.fromId(sandboxId));
|
|
507
|
+
if (!(await this.isOwned(sandbox, deadline))) {
|
|
508
|
+
throw new ModalForeignSandboxError(sandboxId, this.options.namePrefix);
|
|
509
|
+
}
|
|
510
|
+
return sandbox;
|
|
511
|
+
}
|
|
512
|
+
async waitUntilGone(sandbox, deadline) {
|
|
513
|
+
for (;;) {
|
|
514
|
+
const exitCode = await deadline.run(sandbox.poll());
|
|
515
|
+
if (exitCode !== null) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
deadline.assertNotExpired();
|
|
519
|
+
await sleep(this.options.pollIntervalMs);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
sandboxName(name) {
|
|
523
|
+
const suffix = name?.trim() || randomUUID();
|
|
524
|
+
return `${this.options.namePrefix}-${suffix}`;
|
|
525
|
+
}
|
|
526
|
+
toHandle(id, state) {
|
|
527
|
+
return {
|
|
528
|
+
id,
|
|
529
|
+
homeDir: this.options.defaultHomeDir,
|
|
530
|
+
workdir: this.options.workdir,
|
|
531
|
+
...(state === undefined ? {} : { state }),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
deadline(totalMs, operation) {
|
|
535
|
+
return new Deadline(totalMs, operation, this.options.requestTimeoutMs);
|
|
536
|
+
}
|
|
537
|
+
client() {
|
|
538
|
+
this.clientPromise ??= Promise.resolve(this.clientFactory(this.options)).catch((error) => {
|
|
539
|
+
// Do not cache a rejected client: a transient construction failure would
|
|
540
|
+
// otherwise poison every later call for the lifetime of the runtime.
|
|
541
|
+
this.clientPromise = null;
|
|
542
|
+
throw error;
|
|
543
|
+
});
|
|
544
|
+
return this.clientPromise;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the App and Image once and reuse them. Both are required by every
|
|
548
|
+
* `create`, neither changes over the runtime's life, and each resolution is
|
|
549
|
+
* a round trip.
|
|
550
|
+
*/
|
|
551
|
+
context(deadline) {
|
|
552
|
+
this.contextPromise ??= (async () => {
|
|
553
|
+
const client = await this.client();
|
|
554
|
+
const app = await client.apps.fromName(this.options.appName, {
|
|
555
|
+
createIfMissing: this.options.createAppIfMissing,
|
|
556
|
+
...(this.options.environment === undefined
|
|
557
|
+
? {}
|
|
558
|
+
: { environment: this.options.environment }),
|
|
559
|
+
});
|
|
560
|
+
const image = client.images.fromRegistry(this.options.imageTag);
|
|
561
|
+
return { client, app, image };
|
|
562
|
+
})().catch((error) => {
|
|
563
|
+
this.contextPromise = null;
|
|
564
|
+
throw error;
|
|
565
|
+
});
|
|
566
|
+
return deadline.run(this.contextPromise);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Release the gRPC channel.
|
|
570
|
+
*
|
|
571
|
+
* Not part of `SandboxRuntime`, but necessary: the SDK holds an open HTTP/2
|
|
572
|
+
* connection, and a Node process that never calls this will not exit on its
|
|
573
|
+
* own. Long-lived hosts can ignore it; short-lived scripts and tests cannot.
|
|
574
|
+
*/
|
|
575
|
+
async close() {
|
|
576
|
+
// Drain reconciliations first: releasing the gRPC channel while an orphan
|
|
577
|
+
// terminate is still in flight would abandon exactly the resource this
|
|
578
|
+
// path exists to clean up.
|
|
579
|
+
if (this.reconciliations.size > 0) {
|
|
580
|
+
await Promise.allSettled([...this.reconciliations]);
|
|
581
|
+
}
|
|
582
|
+
const pending = this.clientPromise;
|
|
583
|
+
this.clientPromise = null;
|
|
584
|
+
this.contextPromise = null;
|
|
585
|
+
if (!pending) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
(await pending).close();
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// Closing a channel that is already gone is not an error worth raising
|
|
593
|
+
// from a teardown path.
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// --- errors ---------------------------------------------------------------
|
|
598
|
+
/** A handle that does not carry this runtime's ownership tag. */
|
|
599
|
+
export class ModalForeignSandboxError extends Error {
|
|
600
|
+
sandboxId;
|
|
601
|
+
expectedOwner;
|
|
602
|
+
constructor(sandboxId, expectedOwner) {
|
|
603
|
+
super(`Modal sandbox ${sandboxId} is not owned by "${expectedOwner}": refusing to operate on it`);
|
|
604
|
+
this.name = "ModalForeignSandboxError";
|
|
605
|
+
this.sandboxId = sandboxId;
|
|
606
|
+
this.expectedOwner = expectedOwner;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** A caller label collided with the reserved ownership tag key. */
|
|
610
|
+
export class ModalTagCollisionError extends Error {
|
|
611
|
+
tagKey;
|
|
612
|
+
constructor(tagKey) {
|
|
613
|
+
super(`Label key "${tagKey}" is reserved for Modal sandbox ownership and cannot be set by a caller`);
|
|
614
|
+
this.name = "ModalTagCollisionError";
|
|
615
|
+
this.tagKey = tagKey;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* An operation exceeded its explicit client-side deadline.
|
|
620
|
+
*
|
|
621
|
+
* Two independent caps bound any Modal call: this per-operation budget, and
|
|
622
|
+
* the SDK's own per-request `requestTimeoutMs`. They fail differently on
|
|
623
|
+
* purpose — the SDK's cap surfaces as a gRPC error from the vendor, this one
|
|
624
|
+
* as `ModalDeadlineExceededError` — so the error *type* already says which
|
|
625
|
+
* fired. The message names the sibling cap anyway, because a reader looking at
|
|
626
|
+
* a timeout usually needs to check both numbers before concluding the network
|
|
627
|
+
* was slow.
|
|
628
|
+
*/
|
|
629
|
+
export class ModalDeadlineExceededError extends Error {
|
|
630
|
+
operation;
|
|
631
|
+
timeoutMs;
|
|
632
|
+
/** The SDK's per-request cap, when the runtime knew it. */
|
|
633
|
+
requestTimeoutMs;
|
|
634
|
+
constructor(operation, timeoutMs, requestTimeoutMs) {
|
|
635
|
+
super(`Modal ${operation} exceeded its ${timeoutMs}ms operation deadline`
|
|
636
|
+
+ (requestTimeoutMs === undefined
|
|
637
|
+
? ""
|
|
638
|
+
: ` (the SDK's per-request cap is ${requestTimeoutMs}ms and fails separately, `
|
|
639
|
+
+ "as a gRPC error rather than this one)"));
|
|
640
|
+
this.name = "ModalDeadlineExceededError";
|
|
641
|
+
this.operation = operation;
|
|
642
|
+
this.timeoutMs = timeoutMs;
|
|
643
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Declared capabilities disagree with what this class actually implements. */
|
|
647
|
+
export class ModalCapabilityMismatchError extends Error {
|
|
648
|
+
mismatches;
|
|
649
|
+
constructor(mismatches) {
|
|
650
|
+
super(`Modal adapter capability reconciliation failed:\n - ${mismatches.join("\n - ")}`);
|
|
651
|
+
this.name = "ModalCapabilityMismatchError";
|
|
652
|
+
this.mismatches = mismatches;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// --- capability reconciliation --------------------------------------------
|
|
656
|
+
/**
|
|
657
|
+
* Assert at construction time that every declared capability matches what this
|
|
658
|
+
* class actually implements.
|
|
659
|
+
*
|
|
660
|
+
* The point is to fail on the developer's machine rather than in production.
|
|
661
|
+
* A declaration and an implementation drift apart silently — someone adds a
|
|
662
|
+
* `stop` that no-ops, or flips `warmLease` to true while `findAllByLabels`
|
|
663
|
+
* still returns `[]` — and the orchestrator believes the declaration. Every
|
|
664
|
+
* rule below encodes a way this adapter could lie.
|
|
665
|
+
*/
|
|
666
|
+
export function reconcileModalCapabilities(runtime) {
|
|
667
|
+
const declared = runtime.declaredCapabilities ?? {};
|
|
668
|
+
const mismatches = [];
|
|
669
|
+
const has = (method) => typeof runtime[method] === "function";
|
|
670
|
+
// lifecycle: declaring it true while start/stop are absent would tell the
|
|
671
|
+
// orchestrator it can suspend a sandbox that has no suspend.
|
|
672
|
+
if (declared.lifecycle === true && !(has("start") && has("stop"))) {
|
|
673
|
+
mismatches.push("declaredCapabilities.lifecycle is true but start/stop are not implemented "
|
|
674
|
+
+ "(Modal exposes no stop/start for a Sandbox; terminate is terminal)");
|
|
675
|
+
}
|
|
676
|
+
// Scoped to Modal on purpose, and NOT a general rule.
|
|
677
|
+
//
|
|
678
|
+
// For most providers, `start`/`stop` present with `lifecycle: false` is the
|
|
679
|
+
// legitimate shape of a real implementation awaiting a live probe — the
|
|
680
|
+
// orchestrator reads the declaration, not method presence, so the under-claim
|
|
681
|
+
// is safe and is the only way a capability can wait for evidence. Forbidding
|
|
682
|
+
// it would make evidence-gating impossible.
|
|
683
|
+
//
|
|
684
|
+
// Modal is the narrow case where the check still holds: the SDK exposes no
|
|
685
|
+
// stop/start for a Sandbox *at all*, so a `start`/`stop` appearing on this
|
|
686
|
+
// class could not be an unproven implementation. It could only be a
|
|
687
|
+
// fabrication or a no-op, and either would tell the orchestrator a Modal
|
|
688
|
+
// sandbox can be suspended when nothing can suspend it.
|
|
689
|
+
if (declared.lifecycle !== true && (has("start") || has("stop"))) {
|
|
690
|
+
mismatches.push("declaredCapabilities.lifecycle is not true but start/stop are implemented. "
|
|
691
|
+
+ "Modal exposes no stop/start for a Sandbox, so these cannot be a real "
|
|
692
|
+
+ "implementation awaiting a live probe — only a no-op or a fabrication. "
|
|
693
|
+
+ "(This check is Modal-specific: elsewhere, methods present with "
|
|
694
|
+
+ "lifecycle:false is the correct way to gate a capability on evidence.)");
|
|
695
|
+
}
|
|
696
|
+
// warmLease: a true declaration promises real server-side label search.
|
|
697
|
+
if (declared.warmLease === true && !has("findAllByLabels")) {
|
|
698
|
+
mismatches.push("declaredCapabilities.warmLease is true but findAllByLabels is not implemented");
|
|
699
|
+
}
|
|
700
|
+
// asyncExec is all-or-nothing. A partial trio is the exact shape that lets a
|
|
701
|
+
// caller submit a command it can never poll or reap.
|
|
702
|
+
const asyncParts = [
|
|
703
|
+
["startScript", has("startScript")],
|
|
704
|
+
["getScriptStatus", has("getScriptStatus")],
|
|
705
|
+
["getScriptLogs", has("getScriptLogs")],
|
|
706
|
+
["getById", has("getById")],
|
|
707
|
+
];
|
|
708
|
+
const present = asyncParts.filter(([, ok]) => ok).map(([name]) => name);
|
|
709
|
+
const execParts = asyncParts.slice(0, 3);
|
|
710
|
+
const execPresent = execParts.filter(([, ok]) => ok);
|
|
711
|
+
if (execPresent.length > 0 && execPresent.length < execParts.length) {
|
|
712
|
+
mismatches.push(`async exec is all-or-nothing but only [${present.join(", ")}] are implemented: `
|
|
713
|
+
+ "Modal cannot re-resolve a ContainerProcess by id, so the trio must stay absent");
|
|
714
|
+
}
|
|
715
|
+
// --- modes ---------------------------------------------------------------
|
|
716
|
+
//
|
|
717
|
+
// The booleans above can only be wrong about *whether* a capability exists.
|
|
718
|
+
// The modes can be wrong about its *shape*, which is the subtler lie: a mode
|
|
719
|
+
// that over-claims reads as settled fact rather than as a pending cell, and
|
|
720
|
+
// `isPendingEvidence()` reports false for it, so nothing downstream will ever
|
|
721
|
+
// revisit it. Each rule below is a shape this adapter could misstate.
|
|
722
|
+
const modes = runtime.declaredCapabilityModes ?? {};
|
|
723
|
+
// Modal terminates every sandbox at a deadline that always exists — five
|
|
724
|
+
// minutes by default, MODAL_MAX_LIFETIME_MS at the ceiling. "never-idle" is
|
|
725
|
+
// the one lifetime Modal structurally cannot offer, which is exactly why
|
|
726
|
+
// `neverIdle` sits in MODAL_STRUCTURALLY_FALSE.
|
|
727
|
+
if (modes.lifetime === "never-idle") {
|
|
728
|
+
mismatches.push('declaredCapabilityModes.lifetime is "never-idle" but every Modal Sandbox '
|
|
729
|
+
+ "carries a maximum lifetime the provider enforces (see "
|
|
730
|
+
+ "MODAL_STRUCTURALLY_FALSE.neverIdle); there is no no-deadline setting");
|
|
731
|
+
}
|
|
732
|
+
// A live-stream mode promises callers an incremental channel. `runScript`
|
|
733
|
+
// drains both pipes with readText() before returning, and the port exposes no
|
|
734
|
+
// streaming surface at all, so any streaming member is a claim this adapter
|
|
735
|
+
// cannot honor no matter what the provider can do.
|
|
736
|
+
if (modes.outputStreams === "combined-stream"
|
|
737
|
+
|| modes.outputStreams === "separate-streams") {
|
|
738
|
+
mismatches.push(`declaredCapabilityModes.outputStreams is "${modes.outputStreams}" but runScript `
|
|
739
|
+
+ "drains stdout/stderr to completion before returning and the port exposes no "
|
|
740
|
+
+ 'streaming operation; the honest value is "buffered"');
|
|
741
|
+
}
|
|
742
|
+
// Filesystem persistence means surviving a stop/start of the same sandbox.
|
|
743
|
+
// Modal has no such pair, so "persistent" cannot be true here.
|
|
744
|
+
if (modes.filesystem === "persistent") {
|
|
745
|
+
mismatches.push('declaredCapabilityModes.filesystem is "persistent" but Modal has no stop/start '
|
|
746
|
+
+ "pair for state to survive across; terminate is the only transition and it is "
|
|
747
|
+
+ "terminal");
|
|
748
|
+
}
|
|
749
|
+
// PTY and snapshots are real on Modal and unreachable through this port. That
|
|
750
|
+
// asymmetry is the whole reason "not-exposed" exists, and it is the pair most
|
|
751
|
+
// likely to be "corrected" upward by someone reading the provider's docs
|
|
752
|
+
// instead of this package's port.
|
|
753
|
+
if (modes.interactive === "pty" && !has("startScript")) {
|
|
754
|
+
mismatches.push('declaredCapabilityModes.interactive is "pty" but this package\'s port declares no '
|
|
755
|
+
+ 'PTY operation; Modal supports pty:true, so the honest value is "not-exposed" '
|
|
756
|
+
+ '(a fact about our port) rather than a positive claim');
|
|
757
|
+
}
|
|
758
|
+
if (modes.snapshots === "snapshot" || modes.snapshots === "snapshot-and-fork") {
|
|
759
|
+
mismatches.push(`declaredCapabilityModes.snapshots is "${modes.snapshots}" but this package's port `
|
|
760
|
+
+ "declares no snapshot operation; Modal has snapshotFilesystem/snapshotDirectory, "
|
|
761
|
+
+ 'so the honest value is "not-exposed" rather than a positive claim');
|
|
762
|
+
}
|
|
763
|
+
// The port's own resolver is the final authority; make sure it agrees.
|
|
764
|
+
const required = [
|
|
765
|
+
"findByLabels",
|
|
766
|
+
"findAllByLabels",
|
|
767
|
+
"countByLabels",
|
|
768
|
+
"launch",
|
|
769
|
+
"uploadBundle",
|
|
770
|
+
"runScript",
|
|
771
|
+
"destroy",
|
|
772
|
+
];
|
|
773
|
+
for (const method of required) {
|
|
774
|
+
if (!has(method)) {
|
|
775
|
+
mismatches.push(`SandboxRuntime requires ${method} but it is not implemented`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (mismatches.length > 0) {
|
|
779
|
+
throw new ModalCapabilityMismatchError(mismatches);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// --- helpers --------------------------------------------------------------
|
|
783
|
+
/**
|
|
784
|
+
* An absolute client-side budget for one logical operation.
|
|
785
|
+
*
|
|
786
|
+
* Racing a promise does not cancel the work behind it — the SDK's own
|
|
787
|
+
* `timeoutMs` handles the wire. This bounds the *caller's* wait so a hung
|
|
788
|
+
* provider cannot stall an operation indefinitely, and it is shared across
|
|
789
|
+
* every round trip an operation makes rather than being reset per call.
|
|
790
|
+
*/
|
|
791
|
+
class Deadline {
|
|
792
|
+
totalMs;
|
|
793
|
+
operation;
|
|
794
|
+
requestTimeoutMs;
|
|
795
|
+
expiresAt;
|
|
796
|
+
constructor(totalMs, operation, requestTimeoutMs) {
|
|
797
|
+
this.totalMs = totalMs;
|
|
798
|
+
this.operation = operation;
|
|
799
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
800
|
+
this.expiresAt = Date.now() + totalMs;
|
|
801
|
+
}
|
|
802
|
+
expired() {
|
|
803
|
+
return new ModalDeadlineExceededError(this.operation, this.totalMs, this.requestTimeoutMs);
|
|
804
|
+
}
|
|
805
|
+
remainingMs() {
|
|
806
|
+
return this.expiresAt - Date.now();
|
|
807
|
+
}
|
|
808
|
+
assertNotExpired() {
|
|
809
|
+
if (this.remainingMs() <= 0) {
|
|
810
|
+
throw this.expired();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async run(work) {
|
|
814
|
+
const remaining = this.remainingMs();
|
|
815
|
+
if (remaining <= 0) {
|
|
816
|
+
throw this.expired();
|
|
817
|
+
}
|
|
818
|
+
let timer;
|
|
819
|
+
try {
|
|
820
|
+
return await Promise.race([
|
|
821
|
+
work,
|
|
822
|
+
new Promise((_resolve, reject) => {
|
|
823
|
+
timer = setTimeout(() => reject(this.expired()), remaining);
|
|
824
|
+
}),
|
|
825
|
+
]);
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
if (timer !== undefined) {
|
|
829
|
+
clearTimeout(timer);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function sleep(ms) {
|
|
835
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
836
|
+
}
|
|
837
|
+
/** Modal reports no state enum; a finished sandbox is one with an exit code. */
|
|
838
|
+
function exitCodeToState(exitCode) {
|
|
839
|
+
return exitCode === null ? "running" : "terminated";
|
|
840
|
+
}
|
|
841
|
+
function requireHandleId(handle) {
|
|
842
|
+
const id = handle?.id?.trim();
|
|
843
|
+
if (!id) {
|
|
844
|
+
throw new Error("ModalRuntime requires a handle carrying a non-empty sandbox id");
|
|
845
|
+
}
|
|
846
|
+
return id;
|
|
847
|
+
}
|
|
848
|
+
function normalizePositiveInt(value) {
|
|
849
|
+
if (value === undefined) {
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
853
|
+
throw new Error(`ModalRuntime expected a finite, non-negative count; got ${String(value)}`);
|
|
854
|
+
}
|
|
855
|
+
return Math.floor(value);
|
|
856
|
+
}
|
|
857
|
+
function secondsToMs(seconds, field) {
|
|
858
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
859
|
+
throw new Error(`ModalRuntime ${field} must be a finite, positive number of seconds; got ${String(seconds)}`);
|
|
860
|
+
}
|
|
861
|
+
return seconds * 1000;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Modal raises `NotFoundError` by name. Matching on the name rather than on an
|
|
865
|
+
* imported class keeps this module free of the vendor SDK, which is the whole
|
|
866
|
+
* point of the internal/sdk.ts boundary.
|
|
867
|
+
*/
|
|
868
|
+
function isNotFound(error) {
|
|
869
|
+
return error instanceof Error && error.name === "NotFoundError";
|
|
870
|
+
}
|
|
871
|
+
//# sourceMappingURL=runtime.js.map
|