@blaxel/core 0.3.22-preview.297 → 0.3.22-preview.299
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/common/settings.js +2 -2
- package/dist/cjs/sandbox/process/process.http.test.js +126 -0
- package/dist/cjs/sandbox/process/process.js +82 -67
- package/dist/cjs/sandbox/process/process.live.test.js +103 -0
- package/dist/cjs/sandbox/process/process.wait.test.js +121 -0
- package/dist/cjs/types/sandbox/process/process.d.ts +7 -2
- package/dist/cjs/types/sandbox/process/process.http.test.d.ts +1 -0
- package/dist/cjs/types/sandbox/process/process.live.test.d.ts +1 -0
- package/dist/cjs/types/sandbox/process/process.wait.test.d.ts +1 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/settings.js +2 -2
- package/dist/cjs-browser/sandbox/process/process.http.test.js +126 -0
- package/dist/cjs-browser/sandbox/process/process.js +82 -67
- package/dist/cjs-browser/sandbox/process/process.live.test.js +103 -0
- package/dist/cjs-browser/sandbox/process/process.wait.test.js +121 -0
- package/dist/cjs-browser/types/sandbox/process/process.d.ts +7 -2
- package/dist/cjs-browser/types/sandbox/process/process.http.test.d.ts +1 -0
- package/dist/cjs-browser/types/sandbox/process/process.live.test.d.ts +1 -0
- package/dist/cjs-browser/types/sandbox/process/process.wait.test.d.ts +1 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/settings.js +2 -2
- package/dist/esm/sandbox/process/process.http.test.js +124 -0
- package/dist/esm/sandbox/process/process.js +85 -70
- package/dist/esm/sandbox/process/process.live.test.js +101 -0
- package/dist/esm/sandbox/process/process.wait.test.js +119 -0
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/settings.js +2 -2
- package/dist/esm-browser/sandbox/process/process.http.test.js +124 -0
- package/dist/esm-browser/sandbox/process/process.js +85 -70
- package/dist/esm-browser/sandbox/process/process.live.test.js +101 -0
- package/dist/esm-browser/sandbox/process/process.wait.test.js +119 -0
- package/package.json +1 -1
|
@@ -30,8 +30,8 @@ function missingCredentialsMessage() {
|
|
|
30
30
|
return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
|
|
31
31
|
}
|
|
32
32
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
33
|
-
const BUILD_VERSION = "0.3.22-preview.
|
|
34
|
-
const BUILD_COMMIT = "
|
|
33
|
+
const BUILD_VERSION = "0.3.22-preview.299";
|
|
34
|
+
const BUILD_COMMIT = "0978bae88a7844ecb221e08137918547b9f0439b";
|
|
35
35
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
36
36
|
const BLAXEL_API_VERSION = "2026-04-28";
|
|
37
37
|
// Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const client_fetch_1 = require("@hey-api/client-fetch");
|
|
4
|
+
const node_http_1 = require("node:http");
|
|
5
|
+
const vitest_1 = require("vitest");
|
|
6
|
+
vitest_1.vi.mock("../../common/env.js", () => ({ env: process.env }));
|
|
7
|
+
const process_js_1 = require("./process.js");
|
|
8
|
+
const servers = [];
|
|
9
|
+
(0, vitest_1.afterEach)(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); }))); });
|
|
10
|
+
async function localServer(handler) {
|
|
11
|
+
const server = (0, node_http_1.createServer)(handler);
|
|
12
|
+
servers.push(server);
|
|
13
|
+
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
14
|
+
const address = server.address();
|
|
15
|
+
if (!address || typeof address === "string")
|
|
16
|
+
throw new Error("missing port");
|
|
17
|
+
return new process_js_1.SandboxProcess({ metadata: { name: "local" }, spec: {}, forceUrl: `http://127.0.0.1:${address.port}`, headers: {} });
|
|
18
|
+
}
|
|
19
|
+
(0, vitest_1.describe)("process HTTP contract", () => {
|
|
20
|
+
(0, vitest_1.it)("preserves caller identity after a lost POST response and never retries POST", async () => {
|
|
21
|
+
let starts = 0;
|
|
22
|
+
let name = "";
|
|
23
|
+
const process = await localServer((request, response) => {
|
|
24
|
+
void (async () => {
|
|
25
|
+
if (request.method === "POST") {
|
|
26
|
+
starts++;
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of request) {
|
|
29
|
+
if (Buffer.isBuffer(chunk))
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
name = JSON.parse(Buffer.concat(chunks).toString()).name;
|
|
33
|
+
response.destroy();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
response.setHeader("content-type", "application/json");
|
|
37
|
+
response.end(JSON.stringify({ name, status: "completed", exitCode: 0, stdout: "done" }));
|
|
38
|
+
})().catch((error) => response.destroy(error));
|
|
39
|
+
});
|
|
40
|
+
const input = { name: "original-command", command: "echo done" };
|
|
41
|
+
let error;
|
|
42
|
+
try {
|
|
43
|
+
await process.exec(input);
|
|
44
|
+
}
|
|
45
|
+
catch (caught) {
|
|
46
|
+
error = caught;
|
|
47
|
+
}
|
|
48
|
+
(0, vitest_1.expect)(error).toBeInstanceOf(TypeError);
|
|
49
|
+
(0, vitest_1.expect)(name).toBe(input.name);
|
|
50
|
+
(0, vitest_1.expect)(input).toEqual({ name: "original-command", command: "echo done" });
|
|
51
|
+
(0, vitest_1.expect)((await process.wait(name)).status).toBe("completed");
|
|
52
|
+
(0, vitest_1.expect)(starts).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
(0, vitest_1.it)("rejects failed log streams while intentional close remains successful", async () => {
|
|
55
|
+
let streaming = false;
|
|
56
|
+
const process = await localServer((_request, response) => {
|
|
57
|
+
if (!streaming) {
|
|
58
|
+
response.writeHead(503);
|
|
59
|
+
response.end("unavailable");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
response.writeHead(200, { "content-type": "text/plain" });
|
|
63
|
+
response.write("stdout:hello\n");
|
|
64
|
+
});
|
|
65
|
+
const onError = vitest_1.vi.fn();
|
|
66
|
+
await (0, vitest_1.expect)(process.streamLogs("p", { onError }).wait()).rejects.toThrow("503");
|
|
67
|
+
(0, vitest_1.expect)(onError).toHaveBeenCalledTimes(1);
|
|
68
|
+
streaming = true;
|
|
69
|
+
const stream = process.streamLogs("p");
|
|
70
|
+
stream.close();
|
|
71
|
+
await (0, vitest_1.expect)(stream.wait()).resolves.toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
(0, vitest_1.it)("retries real status HTTP errors within the wait deadline", async () => {
|
|
74
|
+
let reads = 0;
|
|
75
|
+
const process = await localServer((_request, response) => {
|
|
76
|
+
reads++;
|
|
77
|
+
response.setHeader("content-type", "application/json");
|
|
78
|
+
if (reads === 1) {
|
|
79
|
+
response.writeHead(503);
|
|
80
|
+
response.end(JSON.stringify({ error: "unavailable" }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
response.end(JSON.stringify({ status: "completed", exitCode: 0 }));
|
|
84
|
+
});
|
|
85
|
+
(0, vitest_1.expect)((await process.wait("p", { maxWait: 2000 })).status).toBe("completed");
|
|
86
|
+
(0, vitest_1.expect)(reads).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
(0, vitest_1.it)("uses the configured client and interceptors for execution and log streaming", async () => {
|
|
89
|
+
const process = await localServer((request, response) => {
|
|
90
|
+
(0, vitest_1.expect)(request.headers["x-session"]).toBe("inherited");
|
|
91
|
+
(0, vitest_1.expect)(request.headers["x-intercepted"]).toBe("yes");
|
|
92
|
+
if (request.method === "POST") {
|
|
93
|
+
response.setHeader("content-type", "application/x-ndjson");
|
|
94
|
+
response.end(JSON.stringify({ type: "stdout", data: "streamed" }) + "\n" + JSON.stringify({ type: "result", data: JSON.stringify({ status: "completed", exitCode: 0, pid: "123" }) }) + "\n");
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
response.setHeader("content-type", "text/plain");
|
|
98
|
+
response.end("stdout:reconnected\n");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const generated = (0, client_fetch_1.createClient)({ headers: { "x-session": "inherited" } });
|
|
102
|
+
const intercept = vitest_1.vi.fn((request) => { request.headers.set("x-intercepted", "yes"); return request; });
|
|
103
|
+
generated.interceptors.request.use(intercept);
|
|
104
|
+
Object.defineProperty(process, "client", { value: generated });
|
|
105
|
+
const onLog = vitest_1.vi.fn();
|
|
106
|
+
(0, vitest_1.expect)((await process.exec({ command: "echo streamed", waitForCompletion: true, onLog })).status).toBe("completed");
|
|
107
|
+
await process.streamLogs("123", { onLog }).wait();
|
|
108
|
+
(0, vitest_1.expect)(onLog.mock.calls).toEqual([["streamed"], ["reconnected"]]);
|
|
109
|
+
(0, vitest_1.expect)(intercept).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
(0, vitest_1.it)("observes after a lost kill response, without sending kill twice", async () => {
|
|
112
|
+
let kills = 0;
|
|
113
|
+
const process = await localServer((request, response) => {
|
|
114
|
+
if (request.method === "DELETE") {
|
|
115
|
+
kills++;
|
|
116
|
+
response.destroy();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
response.setHeader("content-type", "application/json");
|
|
120
|
+
response.end(JSON.stringify({ status: "killed" }));
|
|
121
|
+
});
|
|
122
|
+
await (0, vitest_1.expect)(process.kill("p")).rejects.toBeInstanceOf(TypeError);
|
|
123
|
+
(0, vitest_1.expect)((await process.wait("p", { maxWait: 2000 })).status).toBe("killed");
|
|
124
|
+
(0, vitest_1.expect)(kills).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SandboxProcess = void 0;
|
|
4
|
-
const settings_js_1 = require("../../common/settings.js");
|
|
5
4
|
const action_js_1 = require("../action.js");
|
|
6
5
|
const transient_retry_js_1 = require("../../common/transient-retry.js");
|
|
7
6
|
const index_js_1 = require("../client/index.js");
|
|
@@ -15,9 +14,7 @@ class SandboxProcess extends action_js_1.SandboxAction {
|
|
|
15
14
|
if (options.onError) {
|
|
16
15
|
options.onError(err);
|
|
17
16
|
}
|
|
18
|
-
|
|
19
|
-
console.error("Stream error:", err);
|
|
20
|
-
}
|
|
17
|
+
throw err;
|
|
21
18
|
};
|
|
22
19
|
const processLine = (line) => {
|
|
23
20
|
if (line.startsWith("[keepalive]")) {
|
|
@@ -38,19 +35,15 @@ class SandboxProcess extends action_js_1.SandboxAction {
|
|
|
38
35
|
const done = (async () => {
|
|
39
36
|
let buffer = '';
|
|
40
37
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
const { response: stream, data, error } = await (0, index_js_1.getProcessByIdentifierLogsStream)(this.withClient({
|
|
39
|
+
path: { identifier },
|
|
40
|
+
baseUrl: this.url,
|
|
44
41
|
signal: controller.signal,
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
handleError(new Error(`Failed to stream logs: ${await stream.text()}`));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
42
|
+
parseAs: "stream",
|
|
43
|
+
}));
|
|
44
|
+
this.handleResponseError(stream, data, error);
|
|
51
45
|
if (!stream.body) {
|
|
52
|
-
|
|
53
|
-
return;
|
|
46
|
+
throw new Error('No stream body');
|
|
54
47
|
}
|
|
55
48
|
const reader = stream.body.getReader();
|
|
56
49
|
const decoder = new TextDecoder();
|
|
@@ -74,7 +67,7 @@ class SandboxProcess extends action_js_1.SandboxAction {
|
|
|
74
67
|
}
|
|
75
68
|
}
|
|
76
69
|
catch (err) {
|
|
77
|
-
if (
|
|
70
|
+
if (controller.signal.aborted) {
|
|
78
71
|
// Process remaining buffer before returning on abort
|
|
79
72
|
if (buffer.trim()) {
|
|
80
73
|
processLine(buffer);
|
|
@@ -84,27 +77,16 @@ class SandboxProcess extends action_js_1.SandboxAction {
|
|
|
84
77
|
handleError(err instanceof Error ? err : new Error('Unknown stream error'));
|
|
85
78
|
}
|
|
86
79
|
})();
|
|
80
|
+
// Callback-only users need not await wait(); preserve rejection for those who do.
|
|
81
|
+
void done.catch(() => { });
|
|
87
82
|
return {
|
|
88
83
|
close: () => controller.abort(),
|
|
89
84
|
wait: () => done,
|
|
90
85
|
};
|
|
91
86
|
}
|
|
92
87
|
async exec(process) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let onStderr;
|
|
96
|
-
if ('onLog' in process && process.onLog) {
|
|
97
|
-
onLog = process.onLog;
|
|
98
|
-
delete process.onLog;
|
|
99
|
-
}
|
|
100
|
-
if ('onStdout' in process && process.onStdout) {
|
|
101
|
-
onStdout = process.onStdout;
|
|
102
|
-
delete process.onStdout;
|
|
103
|
-
}
|
|
104
|
-
if ('onStderr' in process && process.onStderr) {
|
|
105
|
-
onStderr = process.onStderr;
|
|
106
|
-
delete process.onStderr;
|
|
107
|
-
}
|
|
88
|
+
const { onLog, onStdout, onStderr, ...request } = process;
|
|
89
|
+
process = request;
|
|
108
90
|
// Store original wait_for_completion setting
|
|
109
91
|
const shouldWaitForCompletion = process.waitForCompletion;
|
|
110
92
|
// When waiting for completion with streaming callbacks, use streaming endpoint
|
|
@@ -133,22 +115,15 @@ class SandboxProcess extends action_js_1.SandboxAction {
|
|
|
133
115
|
}
|
|
134
116
|
}
|
|
135
117
|
async execWithStreaming(processRequest, options) {
|
|
136
|
-
const headers = this.sandbox.forceUrl ? this.sandbox.headers : settings_js_1.settings.headers;
|
|
137
118
|
const controller = new AbortController();
|
|
138
|
-
const response = await
|
|
139
|
-
|
|
119
|
+
const { response, data, error } = await (0, index_js_1.postProcess)(this.withClient({
|
|
120
|
+
baseUrl: this.url,
|
|
140
121
|
signal: controller.signal,
|
|
141
|
-
headers: {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
body: JSON.stringify(processRequest),
|
|
147
|
-
});
|
|
148
|
-
if (!response.ok) {
|
|
149
|
-
const errorText = await response.text();
|
|
150
|
-
throw new Error(`Failed to execute process: ${errorText}`);
|
|
151
|
-
}
|
|
122
|
+
headers: { Accept: "text/event-stream" },
|
|
123
|
+
body: processRequest,
|
|
124
|
+
parseAs: "stream",
|
|
125
|
+
}));
|
|
126
|
+
this.handleResponseError(response, data, error);
|
|
152
127
|
const contentType = response.headers.get('Content-Type') || '';
|
|
153
128
|
const isStreaming = contentType.includes('application/x-ndjson');
|
|
154
129
|
// Fallback: server doesn't support streaming, use legacy approach
|
|
@@ -283,37 +258,77 @@ class SandboxProcess extends action_js_1.SandboxAction {
|
|
|
283
258
|
close: () => controller.abort(),
|
|
284
259
|
};
|
|
285
260
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
261
|
+
/** Wait for a terminal API state. Timeout/cancellation never stops the command. */
|
|
262
|
+
async wait(identifier, { maxWait = 60000, interval = 1000, signal } = {}) {
|
|
263
|
+
if (!Number.isFinite(maxWait) || (maxWait < 0 && maxWait !== -1) || !Number.isFinite(interval) || interval <= 0) {
|
|
264
|
+
throw new RangeError("maxWait must be -1 or finite and non-negative; interval must be finite and positive");
|
|
265
|
+
}
|
|
266
|
+
signal?.throwIfAborted();
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
let lastError;
|
|
269
|
+
let pollTimer;
|
|
270
|
+
const cancel = () => controller.abort(signal?.reason);
|
|
271
|
+
const timeoutError = () => new Error(`Process did not finish in time (${identifier}); it may still be running`, { cause: lastError });
|
|
272
|
+
if (maxWait === 0)
|
|
273
|
+
throw timeoutError();
|
|
274
|
+
const deadline = maxWait === -1 ? Infinity : performance.now() + maxWait;
|
|
275
|
+
const timeout = maxWait === -1 ? undefined : setTimeout(() => controller.abort(timeoutError()), maxWait);
|
|
276
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
277
|
+
const interrupted = new Promise((_, reject) => {
|
|
278
|
+
// Preserve the caller's AbortSignal reason, which need not be an Error.
|
|
279
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
|
280
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
|
|
281
|
+
});
|
|
282
|
+
const poll = async () => {
|
|
283
|
+
while (true) {
|
|
284
|
+
if (performance.now() >= deadline)
|
|
285
|
+
controller.abort(timeoutError());
|
|
286
|
+
controller.signal.throwIfAborted();
|
|
287
|
+
try {
|
|
288
|
+
const result = await this.get(identifier, { signal: controller.signal, retry: false });
|
|
289
|
+
if (performance.now() >= deadline)
|
|
290
|
+
controller.abort(timeoutError());
|
|
291
|
+
controller.signal.throwIfAborted();
|
|
292
|
+
if (["completed", "failed", "killed", "stopped"].includes(result.status))
|
|
293
|
+
return result;
|
|
294
|
+
if (result.status !== "running")
|
|
295
|
+
throw new Error(`Unknown process status: ${result.status}`);
|
|
296
|
+
lastError = undefined;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
controller.signal.throwIfAborted();
|
|
300
|
+
const retryable = (0, transient_retry_js_1.isTransientResetError)(error) || (0, transient_retry_js_1.isRetryableGatewayError)(error)
|
|
301
|
+
|| (error instanceof action_js_1.ResponseError && [408, 429, 500].includes(error.status ?? 0))
|
|
302
|
+
|| (error instanceof TypeError && /^(fetch failed|Failed to fetch|NetworkError when attempting to fetch resource\.)$/.test(error.message));
|
|
303
|
+
if (!retryable)
|
|
304
|
+
throw error;
|
|
305
|
+
lastError = error;
|
|
306
|
+
}
|
|
307
|
+
// A failed observation uses the same polling cadence as a running process.
|
|
308
|
+
await new Promise(resolve => { pollTimer = setTimeout(resolve, interval); });
|
|
301
309
|
}
|
|
310
|
+
};
|
|
311
|
+
try {
|
|
312
|
+
return await Promise.race([poll(), interrupted]);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
clearTimeout(pollTimer);
|
|
317
|
+
signal?.removeEventListener("abort", cancel);
|
|
302
318
|
}
|
|
303
|
-
return data;
|
|
304
319
|
}
|
|
305
|
-
async get(identifier) {
|
|
306
|
-
|
|
307
|
-
// wait() poll loop resilient). exec() stays un-retried — it is a
|
|
308
|
-
// non-idempotent POST and retrying it duplicates the process (ENG-2340).
|
|
309
|
-
return (0, transient_retry_js_1.retryOnTransientReset)(async () => {
|
|
320
|
+
async get(identifier, { signal, retry = true } = {}) {
|
|
321
|
+
const read = async () => {
|
|
310
322
|
const { response, data, error } = await (0, index_js_1.getProcessByIdentifier)(this.withClient({
|
|
311
323
|
path: { identifier },
|
|
312
324
|
baseUrl: this.url,
|
|
325
|
+
signal,
|
|
313
326
|
}));
|
|
314
327
|
this.handleResponseError(response, data, error);
|
|
315
328
|
return data;
|
|
316
|
-
}
|
|
329
|
+
};
|
|
330
|
+
// wait owns the retry budget; standalone reads retain their existing retries.
|
|
331
|
+
return retry ? (0, transient_retry_js_1.retryOnTransientReset)(read) : read();
|
|
317
332
|
}
|
|
318
333
|
async list() {
|
|
319
334
|
// Idempotent GET: self-heal a transient connection reset.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const node_http_1 = require("node:http");
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const vitest_1 = require("vitest");
|
|
6
|
+
vitest_1.vi.mock("../../common/env.js", () => ({ env: process.env }));
|
|
7
|
+
const process_js_1 = require("./process.js");
|
|
8
|
+
// Opt-in disposable sandbox API. No credentials or .env files are loaded.
|
|
9
|
+
const url = process.env.LOCAL_PROCESS_API_URL;
|
|
10
|
+
const client = (forceUrl) => new process_js_1.SandboxProcess({ metadata: { name: "local-process-tests" }, spec: {}, forceUrl, headers: {} });
|
|
11
|
+
vitest_1.describe.skipIf(!url)("real sandbox process API", () => {
|
|
12
|
+
(0, vitest_1.it)("reconnects after wait timeout and recovers exit code and logs", async () => {
|
|
13
|
+
const api = client(url);
|
|
14
|
+
const name = `ts-timeout-${(0, node_crypto_1.randomUUID)()}`;
|
|
15
|
+
await api.exec({ name, command: "sleep 1; echo recovered; exit 7", waitForCompletion: false, keepAlive: false });
|
|
16
|
+
await (0, vitest_1.expect)(api.wait(name, { maxWait: 20, interval: 5 })).rejects.toThrow("did not finish in time");
|
|
17
|
+
const result = await api.wait(name, { maxWait: -1, interval: 20, signal: AbortSignal.timeout(5000) });
|
|
18
|
+
(0, vitest_1.expect)(result.status).toBe("failed");
|
|
19
|
+
(0, vitest_1.expect)(result.exitCode).toBe(7);
|
|
20
|
+
(0, vitest_1.expect)(await api.logs(name)).toContain("recovered");
|
|
21
|
+
});
|
|
22
|
+
(0, vitest_1.it)("cancels only observation, then explicitly kills and confirms the original command", async () => {
|
|
23
|
+
const api = client(url);
|
|
24
|
+
const name = `ts-cancel-${(0, node_crypto_1.randomUUID)()}`;
|
|
25
|
+
await api.exec({ name, command: "sleep 30", waitForCompletion: false, keepAlive: false });
|
|
26
|
+
try {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
controller.abort("done waiting");
|
|
29
|
+
await (0, vitest_1.expect)(api.wait(name, { signal: controller.signal })).rejects.toBe("done waiting");
|
|
30
|
+
(0, vitest_1.expect)((await api.get(name)).status).toBe("running");
|
|
31
|
+
await api.kill(name);
|
|
32
|
+
const result = await api.wait(name, { maxWait: 5000, interval: 20 });
|
|
33
|
+
(0, vitest_1.expect)(["killed", "failed", "completed"]).toContain(result.status);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await api.kill(name).catch(() => { });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
(0, vitest_1.it)("gracefully stops and confirms an API terminal state", async () => {
|
|
40
|
+
const api = client(url);
|
|
41
|
+
const name = `ts-stop-${(0, node_crypto_1.randomUUID)()}`;
|
|
42
|
+
await api.exec({ name, command: "sleep 30", waitForCompletion: false, keepAlive: false });
|
|
43
|
+
try {
|
|
44
|
+
await api.stop(name);
|
|
45
|
+
const result = await api.wait(name, { maxWait: 5000, interval: 20 });
|
|
46
|
+
(0, vitest_1.expect)(["stopped", "failed", "completed"]).toContain(result.status);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await api.kill(name).catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
(0, vitest_1.it)("streams logs and the final result through the generated client", async () => {
|
|
53
|
+
const api = client(url);
|
|
54
|
+
const output = [];
|
|
55
|
+
const result = await api.exec({ name: `ts-stream-${(0, node_crypto_1.randomUUID)()}`, command: "echo streamed-live", waitForCompletion: true, keepAlive: false, onLog: line => output.push(line) });
|
|
56
|
+
(0, vitest_1.expect)(result.status).toBe("completed");
|
|
57
|
+
(0, vitest_1.expect)(result.exitCode).toBe(0);
|
|
58
|
+
(0, vitest_1.expect)(output.join("\n")).toContain("streamed-live");
|
|
59
|
+
});
|
|
60
|
+
(0, vitest_1.it)("recovers the original command after its execution response is lost", async () => {
|
|
61
|
+
let posts = 0;
|
|
62
|
+
const server = (0, node_http_1.createServer)((request, response) => {
|
|
63
|
+
void (async () => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
for await (const chunk of request) {
|
|
66
|
+
if (Buffer.isBuffer(chunk))
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
const body = Buffer.concat(chunks);
|
|
70
|
+
const upstream = await fetch(`${url}${request.url}`, { method: request.method, headers: { "content-type": "application/json" }, body: body.length ? body : undefined });
|
|
71
|
+
await upstream.arrayBuffer();
|
|
72
|
+
posts++;
|
|
73
|
+
response.destroy();
|
|
74
|
+
})().catch((error) => response.destroy(error));
|
|
75
|
+
});
|
|
76
|
+
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
77
|
+
const address = server.address();
|
|
78
|
+
if (!address || typeof address === "string")
|
|
79
|
+
throw new Error("missing port");
|
|
80
|
+
const proxy = client(`http://127.0.0.1:${address.port}`);
|
|
81
|
+
try {
|
|
82
|
+
const identifier = `ts-lost-response-${(0, node_crypto_1.randomUUID)()}`;
|
|
83
|
+
let failure;
|
|
84
|
+
try {
|
|
85
|
+
await proxy.exec({ name: identifier, command: "echo exactly-once", waitForCompletion: true, keepAlive: false });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
failure = error;
|
|
89
|
+
}
|
|
90
|
+
(0, vitest_1.expect)(failure).toBeInstanceOf(TypeError);
|
|
91
|
+
const api = client(url);
|
|
92
|
+
const result = await api.wait(identifier, { maxWait: 5000 });
|
|
93
|
+
(0, vitest_1.expect)(result.status).toBe("completed");
|
|
94
|
+
(0, vitest_1.expect)(result.exitCode).toBe(0);
|
|
95
|
+
(0, vitest_1.expect)((await api.logs(identifier)).match(/exactly-once/g)).toHaveLength(1);
|
|
96
|
+
(0, vitest_1.expect)(posts).toBe(1);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
server.closeAllConnections();
|
|
100
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
vitest_1.vi.mock("../../common/env.js", () => ({ env: process.env }));
|
|
5
|
+
const process_js_1 = require("./process.js");
|
|
6
|
+
const action_js_1 = require("../action.js");
|
|
7
|
+
const api = () => new process_js_1.SandboxProcess({ metadata: { name: "local" }, spec: {}, forceUrl: "http://localhost", headers: {} });
|
|
8
|
+
const response = (status) => new Response(JSON.stringify({ status, exitCode: status === "failed" ? 7 : 0 }), { headers: { "content-type": "application/json" } });
|
|
9
|
+
(0, vitest_1.afterEach)(() => { vitest_1.vi.useRealTimers(); vitest_1.vi.unstubAllGlobals(); vitest_1.vi.restoreAllMocks(); });
|
|
10
|
+
(0, vitest_1.describe)("process wait", () => {
|
|
11
|
+
vitest_1.it.each(["completed", "failed", "killed", "stopped"])("returns terminal %s immediately", async (status) => {
|
|
12
|
+
const fetch = vitest_1.vi.fn().mockResolvedValue(response(status));
|
|
13
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
14
|
+
(0, vitest_1.expect)((await api().wait("original")).status).toBe(status);
|
|
15
|
+
(0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
|
|
16
|
+
});
|
|
17
|
+
vitest_1.it.each([401, 403, 404])("propagates HTTP %s instead of returning the previous running state", async (status) => {
|
|
18
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn().mockResolvedValueOnce(response("running")).mockResolvedValue(new Response("{}", { status })));
|
|
19
|
+
await (0, vitest_1.expect)(api().wait("original", { interval: 1 })).rejects.toBeInstanceOf(action_js_1.ResponseError);
|
|
20
|
+
});
|
|
21
|
+
vitest_1.it.each([408, 429, 500, 502, 503, 504])("recovers from HTTP %s", async (status) => {
|
|
22
|
+
const fetch = vitest_1.vi.fn().mockResolvedValueOnce(new Response("{}", { status })).mockResolvedValue(response("completed"));
|
|
23
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
24
|
+
(0, vitest_1.expect)((await api().wait("original", { interval: 1 })).status).toBe("completed");
|
|
25
|
+
(0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(2);
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.it)("recovers from a lost connection", async () => {
|
|
28
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn().mockRejectedValueOnce(new TypeError("fetch failed")).mockResolvedValue(response("completed")));
|
|
29
|
+
(0, vitest_1.expect)((await api().wait("original", { interval: 1 })).status).toBe("completed");
|
|
30
|
+
});
|
|
31
|
+
(0, vitest_1.it)("bounds a hung request and aborts its transport", async () => {
|
|
32
|
+
vitest_1.vi.useFakeTimers();
|
|
33
|
+
const fetch = vitest_1.vi.fn(() => new Promise(() => { }));
|
|
34
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
35
|
+
const assertion = (0, vitest_1.expect)(api().wait("original", { maxWait: 25 })).rejects.toThrow("Process did not finish in time (original)");
|
|
36
|
+
await vitest_1.vi.advanceTimersByTimeAsync(25);
|
|
37
|
+
await assertion;
|
|
38
|
+
(0, vitest_1.expect)(fetch.mock.calls[0][0].signal.aborted).toBe(true);
|
|
39
|
+
(0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
(0, vitest_1.it)("retains the last network error as the timeout cause", async () => {
|
|
42
|
+
vitest_1.vi.useFakeTimers();
|
|
43
|
+
const cause = new TypeError("fetch failed");
|
|
44
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn().mockRejectedValue(cause));
|
|
45
|
+
const assertion = (0, vitest_1.expect)(api().wait("original", { maxWait: 25 })).rejects.toMatchObject({ cause });
|
|
46
|
+
await vitest_1.vi.advanceTimersByTimeAsync(25);
|
|
47
|
+
await assertion;
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.it)("cancels while sleeping without another request or leaked timers", async () => {
|
|
50
|
+
vitest_1.vi.useFakeTimers();
|
|
51
|
+
const fetch = vitest_1.vi.fn().mockResolvedValue(response("running"));
|
|
52
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const reason = new Error("caller cancelled");
|
|
55
|
+
const assertion = (0, vitest_1.expect)(api().wait("original", { signal: controller.signal })).rejects.toBe(reason);
|
|
56
|
+
await vitest_1.vi.advanceTimersByTimeAsync(5);
|
|
57
|
+
controller.abort(reason);
|
|
58
|
+
await assertion;
|
|
59
|
+
await vitest_1.vi.advanceTimersByTimeAsync(2000);
|
|
60
|
+
(0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
|
|
61
|
+
(0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
(0, vitest_1.it)("waits indefinitely through transient failures and returns a terminal state", async () => {
|
|
64
|
+
vitest_1.vi.useFakeTimers();
|
|
65
|
+
const fetch = vitest_1.vi.fn()
|
|
66
|
+
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
|
67
|
+
.mockImplementation(() => Promise.resolve(response("running")));
|
|
68
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
69
|
+
let settled = false;
|
|
70
|
+
const waiting = api().wait("original", { maxWait: -1, interval: 60_000 });
|
|
71
|
+
void waiting.then(() => { settled = true; });
|
|
72
|
+
await vitest_1.vi.advanceTimersByTimeAsync(86_400_000);
|
|
73
|
+
(0, vitest_1.expect)(settled).toBe(false);
|
|
74
|
+
(0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(1); // Only the next polling interval.
|
|
75
|
+
fetch.mockImplementation(() => Promise.resolve(response("failed")));
|
|
76
|
+
await vitest_1.vi.advanceTimersByTimeAsync(60_000);
|
|
77
|
+
(0, vitest_1.expect)(await waiting).toMatchObject({ status: "failed", exitCode: 7 });
|
|
78
|
+
(0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
(0, vitest_1.it)("cancels an infinite wait with a hung request", async () => {
|
|
81
|
+
vitest_1.vi.useFakeTimers();
|
|
82
|
+
const fetch = vitest_1.vi.fn(() => new Promise(() => { }));
|
|
83
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const reason = new Error("caller cancelled");
|
|
86
|
+
const assertion = (0, vitest_1.expect)(api().wait("original", { maxWait: -1, signal: controller.signal })).rejects.toBe(reason);
|
|
87
|
+
await vitest_1.vi.advanceTimersByTimeAsync(86_400_000);
|
|
88
|
+
(0, vitest_1.expect)(vitest_1.vi.getTimerCount()).toBe(0);
|
|
89
|
+
controller.abort(reason);
|
|
90
|
+
await assertion;
|
|
91
|
+
(0, vitest_1.expect)(fetch.mock.calls[0][0].signal.aborted).toBe(true);
|
|
92
|
+
(0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
(0, vitest_1.it)("still propagates permanent errors during an infinite wait", async () => {
|
|
95
|
+
const fetch = vitest_1.vi.fn().mockResolvedValue(new Response("{}", { status: 404 }));
|
|
96
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
97
|
+
await (0, vitest_1.expect)(api().wait("original", { maxWait: -1 })).rejects.toBeInstanceOf(action_js_1.ResponseError);
|
|
98
|
+
(0, vitest_1.expect)(fetch).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
vitest_1.it.each([-2, -0.5, Infinity, NaN])("rejects invalid maxWait %s", async (maxWait) => {
|
|
101
|
+
const fetch = vitest_1.vi.fn();
|
|
102
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
103
|
+
await (0, vitest_1.expect)(api().wait("original", { maxWait })).rejects.toBeInstanceOf(RangeError);
|
|
104
|
+
(0, vitest_1.expect)(fetch).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
(0, vitest_1.it)("keeps zero as an immediate deadline", async () => {
|
|
107
|
+
const fetch = vitest_1.vi.fn();
|
|
108
|
+
vitest_1.vi.stubGlobal("fetch", fetch);
|
|
109
|
+
await (0, vitest_1.expect)(api().wait("original", { maxWait: 0 })).rejects.toThrow("did not finish in time");
|
|
110
|
+
(0, vitest_1.expect)(fetch).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
(0, vitest_1.it)("rejects an unknown state instead of claiming completion", async () => {
|
|
113
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn().mockResolvedValue(response("unknown")));
|
|
114
|
+
await (0, vitest_1.expect)(api().wait("original")).rejects.toThrow("Unknown process status");
|
|
115
|
+
});
|
|
116
|
+
(0, vitest_1.it)("rejects a late terminal response even before the timeout callback runs", async () => {
|
|
117
|
+
vitest_1.vi.spyOn(performance, "now").mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(30);
|
|
118
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn().mockResolvedValue(response("completed")));
|
|
119
|
+
await (0, vitest_1.expect)(api().wait("original", { maxWait: 25 })).rejects.toThrow("Process did not finish in time");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -15,11 +15,16 @@ export declare class SandboxProcess extends SandboxAction {
|
|
|
15
15
|
};
|
|
16
16
|
exec(process: ProcessRequest | ProcessRequestWithLog): Promise<PostProcessResponse | ProcessResponseWithLog>;
|
|
17
17
|
private execWithStreaming;
|
|
18
|
-
|
|
18
|
+
/** Wait for a terminal API state. Timeout/cancellation never stops the command. */
|
|
19
|
+
wait(identifier: string, { maxWait, interval, signal }?: {
|
|
19
20
|
maxWait?: number;
|
|
20
21
|
interval?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}): Promise<GetProcessByIdentifierResponse>;
|
|
24
|
+
get(identifier: string, { signal, retry }?: {
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
retry?: boolean;
|
|
21
27
|
}): Promise<GetProcessByIdentifierResponse>;
|
|
22
|
-
get(identifier: string): Promise<GetProcessByIdentifierResponse>;
|
|
23
28
|
list(): Promise<GetProcessResponse>;
|
|
24
29
|
stop(identifier: string): Promise<DeleteProcessByIdentifierResponse>;
|
|
25
30
|
kill(identifier: string): Promise<DeleteProcessByIdentifierKillResponse>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|