@agentuity/sandbox 3.0.12 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/dist/api-reference.d.ts +1221 -0
- package/dist/api-reference.d.ts.map +1 -0
- package/dist/api-reference.js +1046 -0
- package/dist/api-reference.js.map +1 -0
- package/dist/base64.d.ts +2 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +14 -0
- package/dist/base64.js.map +1 -0
- package/dist/client.d.ts +431 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +632 -0
- package/dist/client.js.map +1 -0
- package/dist/create.d.ts +203 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +235 -0
- package/dist/create.js.map +1 -0
- package/dist/destroy.d.ts +23 -0
- package/dist/destroy.d.ts.map +1 -0
- package/dist/destroy.js +30 -0
- package/dist/destroy.js.map +1 -0
- package/dist/disk-checkpoint.d.ts +108 -0
- package/dist/disk-checkpoint.d.ts.map +1 -0
- package/dist/disk-checkpoint.js +124 -0
- package/dist/disk-checkpoint.js.map +1 -0
- package/dist/events.d.ts +56 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +54 -0
- package/dist/events.js.map +1 -0
- package/dist/execute.d.ts +99 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +138 -0
- package/dist/execute.js.map +1 -0
- package/dist/execution.d.ts +150 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/execution.js +120 -0
- package/dist/execution.js.map +1 -0
- package/dist/files.d.ts +283 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +471 -0
- package/dist/files.js.map +1 -0
- package/dist/get.d.ts +288 -0
- package/dist/get.d.ts.map +1 -0
- package/dist/get.js +256 -0
- package/dist/get.js.map +1 -0
- package/dist/getStatus.d.ts +23 -0
- package/dist/getStatus.d.ts.map +1 -0
- package/dist/getStatus.js +53 -0
- package/dist/getStatus.js.map +1 -0
- package/dist/index.d.ts +42 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -1
- package/dist/index.js.map +1 -1
- package/dist/job.d.ts +227 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +109 -0
- package/dist/job.js.map +1 -0
- package/dist/list.d.ts +330 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/list.js +209 -0
- package/dist/list.js.map +1 -0
- package/dist/pause.d.ts +39 -0
- package/dist/pause.d.ts.map +1 -0
- package/dist/pause.js +48 -0
- package/dist/pause.js.map +1 -0
- package/dist/resolve.d.ts +75 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +76 -0
- package/dist/resolve.js.map +1 -0
- package/dist/resume.d.ts +23 -0
- package/dist/resume.d.ts.map +1 -0
- package/dist/resume.js +30 -0
- package/dist/resume.js.map +1 -0
- package/dist/run.d.ts +73 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +568 -0
- package/dist/run.js.map +1 -0
- package/dist/runtime.d.ts +94 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +82 -0
- package/dist/runtime.js.map +1 -0
- package/dist/snapshot-build.d.ts +48 -0
- package/dist/snapshot-build.d.ts.map +1 -0
- package/dist/snapshot-build.js +72 -0
- package/dist/snapshot-build.js.map +1 -0
- package/dist/snapshot.d.ts +596 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +612 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/types.d.ts +1010 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +853 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +296 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +234 -0
- package/dist/util.js.map +1 -0
- package/package.json +7 -2
- package/src/api-reference.ts +1094 -0
- package/src/base64.ts +14 -0
- package/src/client.ts +998 -0
- package/src/create.ts +273 -0
- package/src/destroy.ts +43 -0
- package/src/disk-checkpoint.ts +184 -0
- package/src/events.ts +72 -0
- package/src/execute.ts +167 -0
- package/src/execution.ts +152 -0
- package/src/files.ts +637 -0
- package/src/get.ts +291 -0
- package/src/getStatus.ts +72 -0
- package/src/index.ts +252 -18
- package/src/job.ts +161 -0
- package/src/list.ts +239 -0
- package/src/pause.ts +75 -0
- package/src/resolve.ts +96 -0
- package/src/resume.ts +41 -0
- package/src/run.ts +783 -0
- package/src/runtime.ts +106 -0
- package/src/snapshot-build.ts +94 -0
- package/src/snapshot.ts +791 -0
- package/src/types.ts +1033 -0
- package/src/util.ts +280 -0
package/src/run.ts
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import type { Logger } from '@agentuity/client';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { finished } from 'node:stream/promises';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { APIClient, PaymentRequiredError } from '@agentuity/api';
|
|
7
|
+
import { sandboxCreate } from './create.ts';
|
|
8
|
+
import { sandboxDestroy } from './destroy.ts';
|
|
9
|
+
import { executionGet } from './execution.ts';
|
|
10
|
+
import { sandboxGetStatus } from './getStatus.ts';
|
|
11
|
+
import { ExecutionCancelledError, writeAndDrain } from './util.ts';
|
|
12
|
+
import { SandboxRunOptionsSchema, type SandboxRunResult } from './types.ts';
|
|
13
|
+
import { getServiceUrls } from '@agentuity/config';
|
|
14
|
+
|
|
15
|
+
const timingLogsEnabled = false;
|
|
16
|
+
const EXECUTION_WAIT_DURATION = '5m';
|
|
17
|
+
const EXIT_CODE_FAST_WAIT_DURATION = '250ms';
|
|
18
|
+
const TERMINAL_EXECUTION_STATUSES = new Set(['completed', 'failed', 'timeout', 'cancelled']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates a Writable stream that captures all chunks to a buffer array
|
|
22
|
+
* and optionally tees (forwards) them to one or more user-provided streams.
|
|
23
|
+
*
|
|
24
|
+
* @param chunks - Array to collect Buffer chunks into
|
|
25
|
+
* @param userStreams - Optional user-provided Writable stream(s) to forward chunks to
|
|
26
|
+
* @returns A Writable stream that captures and optionally forwards data
|
|
27
|
+
*/
|
|
28
|
+
function createTeeWritable(chunks: Buffer[], ...userStreams: (Writable | undefined)[]): Writable {
|
|
29
|
+
const tee = new PassThrough();
|
|
30
|
+
|
|
31
|
+
// Always capture chunks to the buffer
|
|
32
|
+
tee.on('data', (chunk: Buffer) => {
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Pipe to all provided user streams with proper backpressure handling
|
|
37
|
+
for (const userStream of userStreams) {
|
|
38
|
+
if (userStream) {
|
|
39
|
+
tee.pipe(userStream, { end: false });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return tee;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const SandboxRunParamsSchema = z.object({
|
|
47
|
+
options: SandboxRunOptionsSchema.describe('sandbox run options'),
|
|
48
|
+
orgId: z.string().optional().describe('organization id'),
|
|
49
|
+
region: z.string().optional().describe('region id'),
|
|
50
|
+
apiKey: z.string().optional().describe('api key'),
|
|
51
|
+
signal: z.custom<AbortSignal>().optional().describe('abort signal'),
|
|
52
|
+
stdin: z.custom<Readable>().optional().describe('stdin readable stream'),
|
|
53
|
+
stdout: z.custom<Writable>().optional().describe('stdout writable stream'),
|
|
54
|
+
stderr: z.custom<Writable>().optional().describe('stderr writable stream'),
|
|
55
|
+
logger: z.custom<Logger>().optional().describe('logger instance'),
|
|
56
|
+
});
|
|
57
|
+
export type SandboxRunParams = z.infer<typeof SandboxRunParamsSchema>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a sandbox, executes a command, and waits for completion.
|
|
61
|
+
*
|
|
62
|
+
* This is a high-level convenience function that handles the full lifecycle:
|
|
63
|
+
* creating a sandbox, streaming I/O, polling for completion, and cleanup.
|
|
64
|
+
*
|
|
65
|
+
* @param client - The API client to use for the request
|
|
66
|
+
* @param params - Parameters including command options, I/O streams, and timeout settings
|
|
67
|
+
* @returns The run result including exit code and duration
|
|
68
|
+
* @throws {SandboxResponseError} If sandbox creation fails, execution times out, or is cancelled
|
|
69
|
+
*/
|
|
70
|
+
export async function sandboxRun(
|
|
71
|
+
client: APIClient,
|
|
72
|
+
params: SandboxRunParams
|
|
73
|
+
): Promise<SandboxRunResult> {
|
|
74
|
+
const { options, orgId, region, apiKey, signal, stdin, stdout, stderr, logger } = params;
|
|
75
|
+
const started = Date.now();
|
|
76
|
+
if (timingLogsEnabled) console.error(`[TIMING] +0ms: sandbox run started`);
|
|
77
|
+
|
|
78
|
+
let stdinStreamId: string | undefined;
|
|
79
|
+
let stdinStreamUrl: string | undefined;
|
|
80
|
+
|
|
81
|
+
// Handle stdin stream configuration:
|
|
82
|
+
// - If stdin is "ignore", pass it through to skip stdin handling on server
|
|
83
|
+
// - If stdin is an explicit stream ID, use it directly
|
|
84
|
+
// - If stdin readable is provided, create a stream for it
|
|
85
|
+
const stdinConfig = options.stream?.stdin;
|
|
86
|
+
if (stdinConfig === 'ignore') {
|
|
87
|
+
stdinStreamId = 'ignore';
|
|
88
|
+
logger?.debug('stdin explicitly ignored');
|
|
89
|
+
} else if (stdinConfig && stdinConfig !== 'ignore') {
|
|
90
|
+
// User provided an explicit stream ID
|
|
91
|
+
stdinStreamId = stdinConfig;
|
|
92
|
+
logger?.debug('using provided stdin stream ID: %s', stdinStreamId);
|
|
93
|
+
} else if (stdin && region && apiKey) {
|
|
94
|
+
const streamResult = await createStdinStream(region, apiKey, orgId, logger);
|
|
95
|
+
stdinStreamId = streamResult.id;
|
|
96
|
+
stdinStreamUrl = streamResult.url;
|
|
97
|
+
logger?.debug('created stdin stream: %s', stdinStreamId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const createResponse = await sandboxCreate(client, {
|
|
101
|
+
options: {
|
|
102
|
+
...options,
|
|
103
|
+
command: {
|
|
104
|
+
exec: options.command.exec,
|
|
105
|
+
files: options.command.files,
|
|
106
|
+
mode: 'oneshot',
|
|
107
|
+
},
|
|
108
|
+
stream: {
|
|
109
|
+
...options.stream,
|
|
110
|
+
stdin: stdinStreamId,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
orgId,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const sandboxId = createResponse.sandboxId;
|
|
117
|
+
const stdoutStreamUrl = createResponse.stdoutStreamUrl;
|
|
118
|
+
const stderrStreamUrl = createResponse.stderrStreamUrl;
|
|
119
|
+
|
|
120
|
+
logger?.debug(
|
|
121
|
+
'sandbox created: %s, stdoutUrl: %s, stderrUrl: %s',
|
|
122
|
+
sandboxId,
|
|
123
|
+
stdoutStreamUrl ?? 'none',
|
|
124
|
+
stderrStreamUrl ?? 'none'
|
|
125
|
+
);
|
|
126
|
+
if (timingLogsEnabled)
|
|
127
|
+
console.error(`[TIMING] +${Date.now() - started}ms: sandbox created (${sandboxId})`);
|
|
128
|
+
|
|
129
|
+
const abortController = new AbortController();
|
|
130
|
+
const streamPromises: Promise<void>[] = [];
|
|
131
|
+
|
|
132
|
+
// Create capture buffers for stdout/stderr
|
|
133
|
+
const stdoutChunks: Buffer[] = [];
|
|
134
|
+
const stderrChunks: Buffer[] = [];
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// Start stdin streaming if we have stdin and a stream URL
|
|
138
|
+
if (stdin && stdinStreamUrl && apiKey) {
|
|
139
|
+
const stdinPromise = streamStdinToUrl(
|
|
140
|
+
stdin,
|
|
141
|
+
stdinStreamUrl,
|
|
142
|
+
apiKey,
|
|
143
|
+
abortController.signal,
|
|
144
|
+
logger
|
|
145
|
+
);
|
|
146
|
+
streamPromises.push(stdinPromise);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if stdout and stderr are the same stream (combined output)
|
|
150
|
+
const isCombinedOutput =
|
|
151
|
+
stdoutStreamUrl && stderrStreamUrl && stdoutStreamUrl === stderrStreamUrl;
|
|
152
|
+
|
|
153
|
+
if (isCombinedOutput) {
|
|
154
|
+
// Stream combined output to stdout only to avoid duplicates
|
|
155
|
+
if (stdoutStreamUrl) {
|
|
156
|
+
logger?.debug('using combined output stream (stdout === stderr)');
|
|
157
|
+
const teeStream = createTeeWritable(stdoutChunks, stdout);
|
|
158
|
+
const combinedPromise = streamUrlToWritable(
|
|
159
|
+
stdoutStreamUrl,
|
|
160
|
+
teeStream,
|
|
161
|
+
abortController.signal,
|
|
162
|
+
logger,
|
|
163
|
+
started
|
|
164
|
+
);
|
|
165
|
+
streamPromises.push(combinedPromise);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
// Start stdout streaming with capture
|
|
169
|
+
if (stdoutStreamUrl) {
|
|
170
|
+
const teeStream = createTeeWritable(stdoutChunks, stdout);
|
|
171
|
+
const stdoutPromise = streamUrlToWritable(
|
|
172
|
+
stdoutStreamUrl,
|
|
173
|
+
teeStream,
|
|
174
|
+
abortController.signal,
|
|
175
|
+
logger,
|
|
176
|
+
started
|
|
177
|
+
);
|
|
178
|
+
streamPromises.push(stdoutPromise);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Start stderr streaming with capture
|
|
182
|
+
if (stderrStreamUrl) {
|
|
183
|
+
const teeStream = createTeeWritable(stderrChunks, stderr);
|
|
184
|
+
const stderrPromise = streamUrlToWritable(
|
|
185
|
+
stderrStreamUrl,
|
|
186
|
+
teeStream,
|
|
187
|
+
abortController.signal,
|
|
188
|
+
logger,
|
|
189
|
+
started
|
|
190
|
+
);
|
|
191
|
+
streamPromises.push(stderrPromise);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Wait for execution completion in parallel with stream consumption. The old
|
|
196
|
+
// flow waited for stream EOF first and only then started polling for the
|
|
197
|
+
// final exit code, which adds avoidable tail latency now that create returns
|
|
198
|
+
// an execution ID immediately for oneshot sandboxes.
|
|
199
|
+
let finalExecution:
|
|
200
|
+
| {
|
|
201
|
+
exitCode?: number;
|
|
202
|
+
status: string;
|
|
203
|
+
}
|
|
204
|
+
| undefined;
|
|
205
|
+
if (createResponse.executionId) {
|
|
206
|
+
logger?.debug(
|
|
207
|
+
'waiting for execution %s and %d stream(s) in parallel',
|
|
208
|
+
createResponse.executionId,
|
|
209
|
+
streamPromises.length
|
|
210
|
+
);
|
|
211
|
+
const completionPromise = waitForRunCompletion(
|
|
212
|
+
client,
|
|
213
|
+
sandboxId,
|
|
214
|
+
createResponse.executionId,
|
|
215
|
+
orgId,
|
|
216
|
+
signal,
|
|
217
|
+
logger,
|
|
218
|
+
started
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
finalExecution = signal
|
|
222
|
+
? await raceWithAbort(completionPromise, signal, abortController, sandboxId)
|
|
223
|
+
: await completionPromise;
|
|
224
|
+
await waitForStreamsToDrain(streamPromises, signal, abortController, sandboxId);
|
|
225
|
+
} else {
|
|
226
|
+
logger?.debug(
|
|
227
|
+
'missing executionId on create response, falling back to stream-first completion'
|
|
228
|
+
);
|
|
229
|
+
await waitForStreamsToDrain(streamPromises, signal, abortController, sandboxId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (timingLogsEnabled)
|
|
233
|
+
console.error(`[TIMING] +${Date.now() - started}ms: completion wait finished`);
|
|
234
|
+
logger?.debug('completion wait finished, resolving final exit code');
|
|
235
|
+
|
|
236
|
+
// Stream EOF means the sandbox is done — hadron only closes streams after the
|
|
237
|
+
// container exits. Poll for the exit code with retries because the lifecycle
|
|
238
|
+
// event (carrying the exit code) may still be in flight to Catalyst when the
|
|
239
|
+
// stream completes.
|
|
240
|
+
//
|
|
241
|
+
// Hadron drains container logs for up to 5s after exit, then closes the
|
|
242
|
+
// stream, then sends the lifecycle event in a goroutine. So the exit code
|
|
243
|
+
// typically arrives at Catalyst 5–7s after the container exits. We use a
|
|
244
|
+
// linear 1s polling interval (not exponential backoff) so we don't overshoot
|
|
245
|
+
// the window — 15 attempts × 1s = 15s total, which comfortably covers the
|
|
246
|
+
// drain + lifecycle propagation delay.
|
|
247
|
+
let exitCode = finalExecution?.exitCode ?? 0;
|
|
248
|
+
const statusPollStart = Date.now();
|
|
249
|
+
let shouldWaitForSandboxStatus = finalExecution?.exitCode == null;
|
|
250
|
+
let sandboxStatusReconciled = false;
|
|
251
|
+
if (finalExecution?.exitCode == null) {
|
|
252
|
+
if (createResponse.executionId && finalExecution?.status === 'completed') {
|
|
253
|
+
try {
|
|
254
|
+
const execution = await executionGet(client, {
|
|
255
|
+
executionId: createResponse.executionId,
|
|
256
|
+
orgId,
|
|
257
|
+
wait: EXIT_CODE_FAST_WAIT_DURATION,
|
|
258
|
+
signal,
|
|
259
|
+
});
|
|
260
|
+
if (execution.exitCode != null) {
|
|
261
|
+
exitCode = execution.exitCode;
|
|
262
|
+
finalExecution.exitCode = execution.exitCode;
|
|
263
|
+
shouldWaitForSandboxStatus = false;
|
|
264
|
+
logger?.debug(
|
|
265
|
+
'[run] exit code %d found from fast execution retry (+%dms)',
|
|
266
|
+
exitCode,
|
|
267
|
+
Date.now() - statusPollStart
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
|
272
|
+
logger?.debug(
|
|
273
|
+
'[run] fast execution exit code retry failed (+%dms): %s',
|
|
274
|
+
Date.now() - statusPollStart,
|
|
275
|
+
err
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (shouldWaitForSandboxStatus) {
|
|
282
|
+
try {
|
|
283
|
+
const sandboxStatus = await sandboxGetStatus(client, {
|
|
284
|
+
sandboxId,
|
|
285
|
+
orgId,
|
|
286
|
+
waitForStatus: ['terminated', 'failed'],
|
|
287
|
+
waitMs: 15000,
|
|
288
|
+
});
|
|
289
|
+
if (sandboxStatus.exitCode != null) {
|
|
290
|
+
exitCode = sandboxStatus.exitCode;
|
|
291
|
+
sandboxStatusReconciled = true;
|
|
292
|
+
logger?.debug(
|
|
293
|
+
'[run] exit code %d found after server-side wait (+%dms)',
|
|
294
|
+
exitCode,
|
|
295
|
+
Date.now() - statusPollStart
|
|
296
|
+
);
|
|
297
|
+
} else if (sandboxStatus.status === 'failed') {
|
|
298
|
+
exitCode = 1;
|
|
299
|
+
sandboxStatusReconciled = true;
|
|
300
|
+
logger?.debug(
|
|
301
|
+
'[run] sandbox failed after server-side wait (+%dms)',
|
|
302
|
+
Date.now() - statusPollStart
|
|
303
|
+
);
|
|
304
|
+
} else if (sandboxStatus.status === 'terminated') {
|
|
305
|
+
sandboxStatusReconciled = true;
|
|
306
|
+
logger?.debug(
|
|
307
|
+
'[run] sandbox terminated without exit code after server-side wait (+%dms)',
|
|
308
|
+
Date.now() - statusPollStart
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
logger?.debug(
|
|
312
|
+
'[run] sandbox status wait expired with status=%s (+%dms)',
|
|
313
|
+
sandboxStatus.status,
|
|
314
|
+
Date.now() - statusPollStart
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
|
319
|
+
logger?.debug(
|
|
320
|
+
'[run] sandboxGetStatus server-side wait failed (+%dms): %s',
|
|
321
|
+
Date.now() - statusPollStart,
|
|
322
|
+
err
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
finalExecution &&
|
|
329
|
+
finalExecution?.exitCode == null &&
|
|
330
|
+
finalExecution?.status !== 'completed' &&
|
|
331
|
+
!sandboxStatusReconciled
|
|
332
|
+
) {
|
|
333
|
+
exitCode = 1;
|
|
334
|
+
logger?.debug(
|
|
335
|
+
'[run] using fallback exit code 1 for terminal status=%s after sandbox status reconciliation failed',
|
|
336
|
+
finalExecution?.status
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (exitCode === 0) {
|
|
340
|
+
if (finalExecution?.exitCode != null) {
|
|
341
|
+
logger?.debug('[run] using execution exit code 0 from long-poll result');
|
|
342
|
+
} else {
|
|
343
|
+
logger?.debug(
|
|
344
|
+
'[run] exit code wait finished with default 0 (+%dms)',
|
|
345
|
+
Date.now() - statusPollStart
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (timingLogsEnabled)
|
|
351
|
+
console.error(
|
|
352
|
+
`[TIMING] +${Date.now() - started}ms: sandboxGet complete (exit: ${exitCode})`
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Build captured output strings
|
|
356
|
+
const capturedStdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
|
357
|
+
const capturedStderr = isCombinedOutput
|
|
358
|
+
? capturedStdout
|
|
359
|
+
: Buffer.concat(stderrChunks).toString('utf-8');
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
sandboxId,
|
|
363
|
+
exitCode,
|
|
364
|
+
durationMs: Date.now() - started,
|
|
365
|
+
stdout: capturedStdout,
|
|
366
|
+
stderr: capturedStderr,
|
|
367
|
+
};
|
|
368
|
+
} catch (error) {
|
|
369
|
+
abortController.abort();
|
|
370
|
+
try {
|
|
371
|
+
await sandboxDestroy(client, { sandboxId, orgId });
|
|
372
|
+
} catch {
|
|
373
|
+
// Ignore cleanup errors
|
|
374
|
+
}
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function waitForRunCompletion(
|
|
380
|
+
client: APIClient,
|
|
381
|
+
sandboxId: string,
|
|
382
|
+
executionId: string,
|
|
383
|
+
orgId: string | undefined,
|
|
384
|
+
signal: AbortSignal | undefined,
|
|
385
|
+
logger: Logger | undefined,
|
|
386
|
+
started: number
|
|
387
|
+
): Promise<{ exitCode?: number; status: string }> {
|
|
388
|
+
const completionAbortController = new AbortController();
|
|
389
|
+
let onAbort: (() => void) | undefined;
|
|
390
|
+
if (signal) {
|
|
391
|
+
onAbort = () => completionAbortController.abort(signal.reason);
|
|
392
|
+
if (signal.aborted) {
|
|
393
|
+
onAbort();
|
|
394
|
+
} else {
|
|
395
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const completionSignal = completionAbortController.signal;
|
|
401
|
+
const executionPromise = waitForExecutionCompletion(
|
|
402
|
+
client,
|
|
403
|
+
executionId,
|
|
404
|
+
orgId,
|
|
405
|
+
completionSignal,
|
|
406
|
+
logger,
|
|
407
|
+
started
|
|
408
|
+
);
|
|
409
|
+
const statusPromise = waitForSandboxStatusCompletion(
|
|
410
|
+
client,
|
|
411
|
+
sandboxId,
|
|
412
|
+
orgId,
|
|
413
|
+
completionSignal,
|
|
414
|
+
logger,
|
|
415
|
+
started
|
|
416
|
+
).catch((err) => {
|
|
417
|
+
if (completionSignal.aborted) {
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
logger?.debug('[run] sandbox status completion wait failed: %s', err);
|
|
421
|
+
return new Promise<never>(() => {});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const result = await Promise.race([executionPromise, statusPromise]);
|
|
425
|
+
return result;
|
|
426
|
+
} finally {
|
|
427
|
+
if (onAbort && signal) {
|
|
428
|
+
signal.removeEventListener('abort', onAbort);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function waitForExecutionCompletion(
|
|
434
|
+
client: APIClient,
|
|
435
|
+
executionId: string,
|
|
436
|
+
orgId: string | undefined,
|
|
437
|
+
signal: AbortSignal | undefined,
|
|
438
|
+
logger: Logger | undefined,
|
|
439
|
+
started: number
|
|
440
|
+
): Promise<{ exitCode?: number; status: string }> {
|
|
441
|
+
while (true) {
|
|
442
|
+
if (signal?.aborted) {
|
|
443
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const result = await executionGet(client, {
|
|
447
|
+
executionId,
|
|
448
|
+
orgId,
|
|
449
|
+
wait: EXECUTION_WAIT_DURATION,
|
|
450
|
+
signal,
|
|
451
|
+
});
|
|
452
|
+
logger?.debug(
|
|
453
|
+
'[run] execution wait: id=%s status=%s exit=%s +%dms',
|
|
454
|
+
executionId,
|
|
455
|
+
result.status,
|
|
456
|
+
result.exitCode ?? 'undefined',
|
|
457
|
+
Date.now() - started
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
if (TERMINAL_EXECUTION_STATUSES.has(result.status)) {
|
|
461
|
+
return {
|
|
462
|
+
exitCode: result.exitCode,
|
|
463
|
+
status: result.status,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function waitForSandboxStatusCompletion(
|
|
470
|
+
client: APIClient,
|
|
471
|
+
sandboxId: string,
|
|
472
|
+
orgId: string | undefined,
|
|
473
|
+
signal: AbortSignal | undefined,
|
|
474
|
+
logger: Logger | undefined,
|
|
475
|
+
started: number
|
|
476
|
+
): Promise<{ exitCode?: number; status: string }> {
|
|
477
|
+
while (true) {
|
|
478
|
+
if (signal?.aborted) {
|
|
479
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const result = await sandboxGetStatus(client, {
|
|
483
|
+
sandboxId,
|
|
484
|
+
orgId,
|
|
485
|
+
waitForStatus: ['idle', 'terminated', 'failed'],
|
|
486
|
+
waitMs: 300000,
|
|
487
|
+
signal,
|
|
488
|
+
});
|
|
489
|
+
logger?.debug(
|
|
490
|
+
'[run] sandbox status wait: sandbox=%s status=%s exit=%s +%dms',
|
|
491
|
+
sandboxId,
|
|
492
|
+
result.status,
|
|
493
|
+
result.exitCode ?? 'undefined',
|
|
494
|
+
Date.now() - started
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
if (result.exitCode != null) {
|
|
498
|
+
return {
|
|
499
|
+
exitCode: result.exitCode,
|
|
500
|
+
status: 'completed',
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (result.status === 'failed') {
|
|
504
|
+
return {
|
|
505
|
+
exitCode: 1,
|
|
506
|
+
status: 'failed',
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (result.status === 'terminated') {
|
|
510
|
+
return {
|
|
511
|
+
status: 'completed',
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function waitForStreamsToDrain(
|
|
520
|
+
streamPromises: Promise<void>[],
|
|
521
|
+
signal: AbortSignal | undefined,
|
|
522
|
+
abortController: AbortController,
|
|
523
|
+
sandboxId: string
|
|
524
|
+
): Promise<void> {
|
|
525
|
+
if (streamPromises.length === 0) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (signal) {
|
|
530
|
+
let onAbort: (() => void) | undefined;
|
|
531
|
+
try {
|
|
532
|
+
await Promise.race([
|
|
533
|
+
Promise.allSettled(streamPromises).then(() => undefined),
|
|
534
|
+
new Promise<never>((_, reject) => {
|
|
535
|
+
onAbort = () => {
|
|
536
|
+
abortController.abort();
|
|
537
|
+
reject(
|
|
538
|
+
new ExecutionCancelledError({
|
|
539
|
+
message: 'Sandbox execution cancelled',
|
|
540
|
+
sandboxId,
|
|
541
|
+
})
|
|
542
|
+
);
|
|
543
|
+
};
|
|
544
|
+
if (signal.aborted) {
|
|
545
|
+
onAbort();
|
|
546
|
+
} else {
|
|
547
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
548
|
+
}
|
|
549
|
+
}),
|
|
550
|
+
]);
|
|
551
|
+
} finally {
|
|
552
|
+
if (onAbort) {
|
|
553
|
+
signal.removeEventListener('abort', onAbort);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await Promise.allSettled(streamPromises);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function raceWithAbort<T>(
|
|
563
|
+
promise: Promise<T>,
|
|
564
|
+
signal: AbortSignal,
|
|
565
|
+
abortController: AbortController,
|
|
566
|
+
sandboxId: string
|
|
567
|
+
): Promise<T> {
|
|
568
|
+
let onAbort: (() => void) | undefined;
|
|
569
|
+
try {
|
|
570
|
+
return await Promise.race([
|
|
571
|
+
promise,
|
|
572
|
+
new Promise<never>((_, reject) => {
|
|
573
|
+
onAbort = () => {
|
|
574
|
+
abortController.abort();
|
|
575
|
+
reject(
|
|
576
|
+
new ExecutionCancelledError({
|
|
577
|
+
message: 'Sandbox execution cancelled',
|
|
578
|
+
sandboxId,
|
|
579
|
+
})
|
|
580
|
+
);
|
|
581
|
+
};
|
|
582
|
+
if (signal.aborted) {
|
|
583
|
+
onAbort();
|
|
584
|
+
} else {
|
|
585
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
586
|
+
}
|
|
587
|
+
}),
|
|
588
|
+
]);
|
|
589
|
+
} finally {
|
|
590
|
+
if (onAbort) {
|
|
591
|
+
signal.removeEventListener('abort', onAbort);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function createStdinStream(
|
|
597
|
+
region: string,
|
|
598
|
+
apiKey: string,
|
|
599
|
+
orgId?: string,
|
|
600
|
+
logger?: Logger
|
|
601
|
+
): Promise<{ id: string; url: string }> {
|
|
602
|
+
const urls = getServiceUrls(region);
|
|
603
|
+
const streamBaseUrl = urls.stream;
|
|
604
|
+
|
|
605
|
+
// Build URL with orgId query param for CLI token validation
|
|
606
|
+
const queryParams = new URLSearchParams();
|
|
607
|
+
if (orgId) {
|
|
608
|
+
queryParams.set('orgId', orgId);
|
|
609
|
+
}
|
|
610
|
+
const queryString = queryParams.toString();
|
|
611
|
+
const url = `${streamBaseUrl}${queryString ? `?${queryString}` : ''}`;
|
|
612
|
+
logger?.trace('creating stdin stream: %s', url);
|
|
613
|
+
|
|
614
|
+
const response = await fetch(url, {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: {
|
|
617
|
+
'Content-Type': 'application/json',
|
|
618
|
+
Authorization: `Bearer ${apiKey}`,
|
|
619
|
+
},
|
|
620
|
+
body: JSON.stringify({
|
|
621
|
+
name: `sandbox-stdin-${Date.now()}`,
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
if (!response.ok) {
|
|
626
|
+
if (response.status === 402) {
|
|
627
|
+
throw new PaymentRequiredError({
|
|
628
|
+
url: url,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
throw new Error(`Failed to create stdin stream: ${response.status} ${response.statusText}`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const data = (await response.json()) as { id: string };
|
|
635
|
+
logger?.debug('created stdin stream: %s', data.id);
|
|
636
|
+
|
|
637
|
+
// Include orgId in the URL for subsequent PUT requests (needed for CLI token auth)
|
|
638
|
+
const putQueryString = orgId ? `?orgId=${encodeURIComponent(orgId)}` : '';
|
|
639
|
+
return {
|
|
640
|
+
id: data.id,
|
|
641
|
+
url: `${streamBaseUrl}/${data.id}${putQueryString}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function streamStdinToUrl(
|
|
646
|
+
stdin: Readable,
|
|
647
|
+
url: string,
|
|
648
|
+
apiKey: string,
|
|
649
|
+
signal: AbortSignal,
|
|
650
|
+
logger?: Logger
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
try {
|
|
653
|
+
logger?.debug('streaming stdin to: %s', url);
|
|
654
|
+
|
|
655
|
+
// Convert Node.js Readable to a web ReadableStream for fetch body
|
|
656
|
+
let controllerClosed = false;
|
|
657
|
+
const webStream = new ReadableStream({
|
|
658
|
+
start(controller) {
|
|
659
|
+
stdin.on('data', (chunk: Buffer) => {
|
|
660
|
+
if (!signal.aborted && !controllerClosed) {
|
|
661
|
+
controller.enqueue(chunk);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
stdin.on('end', () => {
|
|
665
|
+
if (!controllerClosed) {
|
|
666
|
+
controllerClosed = true;
|
|
667
|
+
controller.close();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
stdin.on('error', (err) => {
|
|
671
|
+
if (!controllerClosed) {
|
|
672
|
+
controllerClosed = true;
|
|
673
|
+
controller.error(err);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
signal.addEventListener('abort', () => {
|
|
677
|
+
if (!controllerClosed) {
|
|
678
|
+
controllerClosed = true;
|
|
679
|
+
controller.close();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const response = await fetch(url, {
|
|
686
|
+
method: 'PUT',
|
|
687
|
+
headers: {
|
|
688
|
+
Authorization: `Bearer ${apiKey}`,
|
|
689
|
+
},
|
|
690
|
+
body: webStream,
|
|
691
|
+
signal,
|
|
692
|
+
duplex: 'half',
|
|
693
|
+
} as RequestInit);
|
|
694
|
+
|
|
695
|
+
if (!response.ok) {
|
|
696
|
+
logger?.debug('stdin stream PUT failed: %d', response.status);
|
|
697
|
+
} else {
|
|
698
|
+
logger?.debug('stdin stream completed');
|
|
699
|
+
}
|
|
700
|
+
} catch (err) {
|
|
701
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
702
|
+
logger?.debug('stdin stream aborted (expected on completion)');
|
|
703
|
+
} else {
|
|
704
|
+
logger?.debug('stdin stream error: %s', err);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function streamUrlToWritable(
|
|
710
|
+
url: string,
|
|
711
|
+
writable: Writable,
|
|
712
|
+
signal: AbortSignal,
|
|
713
|
+
logger?: Logger,
|
|
714
|
+
_started?: number
|
|
715
|
+
): Promise<void> {
|
|
716
|
+
const streamStart = Date.now();
|
|
717
|
+
try {
|
|
718
|
+
// Signal to Pulse that this is a v2 stream so it waits for v2 metadata
|
|
719
|
+
// instead of falling back to the legacy download path on a short timeout.
|
|
720
|
+
const v2Url = new URL(url);
|
|
721
|
+
v2Url.searchParams.set('v', '2');
|
|
722
|
+
logger?.debug('[stream] fetching: %s', v2Url.href);
|
|
723
|
+
const response = await fetch(v2Url.href, { signal });
|
|
724
|
+
logger?.debug(
|
|
725
|
+
'[stream] response status=%d in %dms',
|
|
726
|
+
response.status,
|
|
727
|
+
Date.now() - streamStart
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
if (!response.ok || !response.body) {
|
|
731
|
+
logger?.debug('[stream] not ok or no body (status=%d) — returning empty', response.status);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const reader = response.body.getReader();
|
|
736
|
+
let chunks = 0;
|
|
737
|
+
let totalBytes = 0;
|
|
738
|
+
|
|
739
|
+
// Read until EOF - Pulse will block until data is available
|
|
740
|
+
while (true) {
|
|
741
|
+
const { done, value } = await reader.read();
|
|
742
|
+
if (done) {
|
|
743
|
+
logger?.debug(
|
|
744
|
+
'[stream] EOF after %dms (%d chunks, %d bytes)',
|
|
745
|
+
Date.now() - streamStart,
|
|
746
|
+
chunks,
|
|
747
|
+
totalBytes
|
|
748
|
+
);
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (value) {
|
|
753
|
+
chunks++;
|
|
754
|
+
totalBytes += value.length;
|
|
755
|
+
if (chunks <= 3 || chunks % 100 === 0) {
|
|
756
|
+
logger?.debug(
|
|
757
|
+
'[stream] chunk #%d: %d bytes (total: %d bytes, +%dms)',
|
|
758
|
+
chunks,
|
|
759
|
+
value.length,
|
|
760
|
+
totalBytes,
|
|
761
|
+
Date.now() - streamStart
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
await writeAndDrain(writable, value);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Signal end-of-stream to the tee/pipe chain so downstream
|
|
768
|
+
// consumers (e.g. process.stdout pipe) know no more data is coming.
|
|
769
|
+
writable.end();
|
|
770
|
+
if ('once' in writable) {
|
|
771
|
+
await finished(writable as NodeJS.WritableStream).catch(() => {
|
|
772
|
+
// Ignore finish errors here; the main read/write path already
|
|
773
|
+
// reported meaningful stream errors.
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
778
|
+
logger?.debug('[stream] aborted after %dms', Date.now() - streamStart);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
logger?.debug('[stream] error after %dms: %s', Date.now() - streamStart, err);
|
|
782
|
+
}
|
|
783
|
+
}
|