mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
Add SDK health wait gate (#206)
* Add SDK health wait gate * Default connect to waiting for health * Document connect health wait default * Add abort signal to connect health wait * Refactor SDK health probe helper * Update quickstart health wait note * Remove example health polling * Fix docker example codex startup
This commit is contained in:
parent
4335ef6af6
commit
e7343e14bd
14 changed files with 423 additions and 95 deletions
|
|
@ -16,17 +16,11 @@ docker run --rm -p 3000:3000 \
|
||||||
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||||
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
|
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||||
alpine:latest sh -c "\
|
alpine:latest sh -c "\
|
||||||
apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \
|
apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \
|
||||||
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \
|
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \
|
||||||
sandbox-agent install-agent claude && \
|
|
||||||
sandbox-agent install-agent codex && \
|
|
||||||
sandbox-agent server --no-token --host 0.0.0.0 --port 3000"
|
sandbox-agent server --no-token --host 0.0.0.0 --port 3000"
|
||||||
```
|
```
|
||||||
|
|
||||||
<Note>
|
|
||||||
Alpine is required for some agent binaries that target musl libc.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## TypeScript with dockerode
|
## TypeScript with dockerode
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -37,17 +31,18 @@ const docker = new Docker();
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
|
|
||||||
const container = await docker.createContainer({
|
const container = await docker.createContainer({
|
||||||
Image: "alpine:latest",
|
Image: "node:22-bookworm-slim",
|
||||||
Cmd: ["sh", "-c", [
|
Cmd: ["sh", "-c", [
|
||||||
"apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
|
"apt-get update",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6",
|
||||||
|
"rm -rf /var/lib/apt/lists/*",
|
||||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||||
"sandbox-agent install-agent claude",
|
|
||||||
"sandbox-agent install-agent codex",
|
|
||||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||||
].join(" && ")],
|
].join(" && ")],
|
||||||
Env: [
|
Env: [
|
||||||
`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
|
`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
|
||||||
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
|
`OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
|
||||||
|
`CODEX_API_KEY=${process.env.CODEX_API_KEY}`,
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
ExposedPorts: { [`${PORT}/tcp`]: {} },
|
ExposedPorts: { [`${PORT}/tcp`]: {} },
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
|
|
@ -61,7 +56,7 @@ await container.start();
|
||||||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||||
const sdk = await SandboxAgent.connect({ baseUrl });
|
const sdk = await SandboxAgent.connect({ baseUrl });
|
||||||
|
|
||||||
const session = await sdk.createSession({ agent: "claude" });
|
const session = await sdk.createSession({ agent: "codex" });
|
||||||
await session.prompt([{ type: "text", text: "Summarize this repository." }]);
|
await session.prompt([{ type: "text", text: "Summarize this repository." }]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ const sdk = await SandboxAgent.connect({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`SandboxAgent.connect(...)` now waits for `/v1/health` by default before other SDK requests proceed. To disable that gate, pass `waitForHealth: false`. To keep the default gate but fail after a bounded wait, pass `waitForHealth: { timeoutMs: 120_000 }`. To cancel the startup wait early, pass `signal: abortController.signal`.
|
||||||
|
|
||||||
With a custom fetch handler (for example, proxying requests inside Workers):
|
With a custom fetch handler (for example, proxying requests inside Workers):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -47,6 +49,19 @@ const sdk = await SandboxAgent.connect({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With an abort signal for the startup health gate:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.connect({
|
||||||
|
baseUrl: "http://127.0.0.1:2468",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
```
|
||||||
|
|
||||||
With persistence:
|
With persistence:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -170,6 +185,8 @@ Parameters:
|
||||||
- `token` (optional): Bearer token for authenticated servers
|
- `token` (optional): Bearer token for authenticated servers
|
||||||
- `headers` (optional): Additional request headers
|
- `headers` (optional): Additional request headers
|
||||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
||||||
|
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||||
|
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
|
||||||
|
|
||||||
## Types
|
## Types
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { SimpleBox } from "@boxlite-ai/boxlite";
|
import { SimpleBox } from "@boxlite-ai/boxlite";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
import { setupImage, OCI_DIR } from "./setup-image.ts";
|
import { setupImage, OCI_DIR } from "./setup-image.ts";
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
|
|
@ -26,9 +26,7 @@ if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.std
|
||||||
|
|
||||||
const baseUrl = "http://localhost:3000";
|
const baseUrl = "http://localhost:3000";
|
||||||
|
|
||||||
console.log("Waiting for server...");
|
console.log("Connecting to server...");
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const client = await SandboxAgent.connect({ baseUrl });
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||||
const sessionId = session.id;
|
const sessionId = session.id;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "computesdk";
|
} from "computesdk";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
|
@ -116,9 +116,6 @@ export async function setupComputeSdkSandboxAgent(): Promise<{
|
||||||
|
|
||||||
const baseUrl = await sandbox.getUrl({ port: PORT });
|
const baseUrl = await sandbox.getUrl({ port: PORT });
|
||||||
|
|
||||||
console.log("Waiting for server...");
|
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
try {
|
try {
|
||||||
await sandbox.destroy();
|
await sandbox.destroy();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Daytona, Image } from "@daytonaio/sdk";
|
import { Daytona, Image } from "@daytonaio/sdk";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
|
|
||||||
const daytona = new Daytona();
|
const daytona = new Daytona();
|
||||||
|
|
||||||
|
|
@ -25,9 +25,7 @@ await sandbox.process.executeCommand(
|
||||||
|
|
||||||
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
||||||
|
|
||||||
console.log("Waiting for server...");
|
console.log("Connecting to server...");
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const client = await SandboxAgent.connect({ baseUrl });
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
||||||
const sessionId = session.id;
|
const sessionId = session.id;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Daytona } from "@daytonaio/sdk";
|
import { Daytona } from "@daytonaio/sdk";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
|
|
||||||
const daytona = new Daytona();
|
const daytona = new Daytona();
|
||||||
|
|
||||||
|
|
@ -30,9 +30,7 @@ await sandbox.process.executeCommand(
|
||||||
|
|
||||||
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
|
||||||
|
|
||||||
console.log("Waiting for server...");
|
console.log("Connecting to server...");
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const client = await SandboxAgent.connect({ baseUrl });
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } });
|
||||||
const sessionId = session.id;
|
const sessionId = session.id;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
|
|
||||||
const IMAGE = "alpine:latest";
|
const IMAGE = "node:22-bookworm-slim";
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
|
const agent = detectAgent();
|
||||||
|
const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null;
|
||||||
|
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath)
|
||||||
|
? [`${codexAuthPath}:/root/.codex/auth.json:ro`]
|
||||||
|
: [];
|
||||||
|
|
||||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||||
|
|
||||||
|
|
@ -24,29 +31,30 @@ console.log("Starting container...");
|
||||||
const container = await docker.createContainer({
|
const container = await docker.createContainer({
|
||||||
Image: IMAGE,
|
Image: IMAGE,
|
||||||
Cmd: ["sh", "-c", [
|
Cmd: ["sh", "-c", [
|
||||||
"apk add --no-cache curl ca-certificates libstdc++ libgcc bash",
|
"apt-get update",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6",
|
||||||
|
"rm -rf /var/lib/apt/lists/*",
|
||||||
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh",
|
||||||
"sandbox-agent install-agent claude",
|
|
||||||
"sandbox-agent install-agent codex",
|
|
||||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
|
||||||
].join(" && ")],
|
].join(" && ")],
|
||||||
Env: [
|
Env: [
|
||||||
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
|
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
|
||||||
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
|
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
|
||||||
|
process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "",
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
ExposedPorts: { [`${PORT}/tcp`]: {} },
|
ExposedPorts: { [`${PORT}/tcp`]: {} },
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] },
|
PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] },
|
||||||
|
Binds: bindMounts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await container.start();
|
await container.start();
|
||||||
|
|
||||||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const client = await SandboxAgent.connect({ baseUrl });
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } });
|
const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } });
|
||||||
const sessionId = session.id;
|
const sessionId = session.id;
|
||||||
|
|
||||||
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Sandbox } from "@e2b/code-interpreter";
|
import { Sandbox } from "@e2b/code-interpreter";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
|
|
||||||
const envs: Record<string, string> = {};
|
const envs: Record<string, string> = {};
|
||||||
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
@ -27,9 +27,7 @@ await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --por
|
||||||
|
|
||||||
const baseUrl = `https://${sandbox.getHost(3000)}`;
|
const baseUrl = `https://${sandbox.getHost(3000)}`;
|
||||||
|
|
||||||
console.log("Waiting for server...");
|
console.log("Connecting to server...");
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const client = await SandboxAgent.connect({ baseUrl });
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } });
|
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } });
|
||||||
const sessionId = session.id;
|
const sessionId = session.id;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { waitForHealth } from "./sandbox-agent-client.ts";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const EXAMPLE_IMAGE = "sandbox-agent-examples:latest";
|
const EXAMPLE_IMAGE = "sandbox-agent-examples:latest";
|
||||||
|
|
@ -173,7 +172,7 @@ async function ensureExampleImage(_docker: Docker): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a Docker container running sandbox-agent and wait for it to be healthy.
|
* Start a Docker container running sandbox-agent.
|
||||||
* Registers SIGINT/SIGTERM handlers for cleanup.
|
* Registers SIGINT/SIGTERM handlers for cleanup.
|
||||||
*/
|
*/
|
||||||
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
|
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
|
||||||
|
|
@ -275,18 +274,8 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
||||||
}
|
}
|
||||||
const baseUrl = `http://127.0.0.1:${mappedHostPort}`;
|
const baseUrl = `http://127.0.0.1:${mappedHostPort}`;
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
} catch (err) {
|
|
||||||
stopStartupLogs();
|
|
||||||
console.error(" Container logs:");
|
|
||||||
for (const chunk of logChunks) {
|
|
||||||
process.stderr.write(` ${chunk}`);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
stopStartupLogs();
|
stopStartupLogs();
|
||||||
console.log(` Ready (${baseUrl})`);
|
console.log(` Started (${baseUrl})`);
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
stopStartupLogs();
|
stopStartupLogs();
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
* Provides minimal helpers for connecting to and interacting with sandbox-agent servers.
|
* Provides minimal helpers for connecting to and interacting with sandbox-agent servers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setTimeout as delay } from "node:timers/promises";
|
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
return baseUrl.replace(/\/+$/, "");
|
return baseUrl.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
@ -74,41 +72,6 @@ export function buildHeaders({
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForHealth({
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
extraHeaders,
|
|
||||||
timeoutMs = 120_000,
|
|
||||||
}: {
|
|
||||||
baseUrl: string;
|
|
||||||
token?: string;
|
|
||||||
extraHeaders?: Record<string, string>;
|
|
||||||
timeoutMs?: number;
|
|
||||||
}): Promise<void> {
|
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
let lastError: unknown;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const headers = buildHeaders({ token, extraHeaders });
|
|
||||||
const response = await fetch(`${normalized}/v1/health`, { headers });
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data?.status === "ok") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastError = new Error(`Unexpected health response: ${JSON.stringify(data)}`);
|
|
||||||
} else {
|
|
||||||
lastError = new Error(`Health check failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
await delay(500);
|
|
||||||
}
|
|
||||||
throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateSessionId(): string {
|
export function generateSessionId(): string {
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
let id = "session-";
|
let id = "session-";
|
||||||
|
|
@ -144,4 +107,3 @@ export function detectAgent(): string {
|
||||||
}
|
}
|
||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Sandbox } from "@vercel/sandbox";
|
import { Sandbox } from "@vercel/sandbox";
|
||||||
import { SandboxAgent } from "sandbox-agent";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared";
|
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
|
||||||
|
|
||||||
const envs: Record<string, string> = {};
|
const envs: Record<string, string> = {};
|
||||||
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
@ -38,9 +38,7 @@ await sandbox.runCommand({
|
||||||
|
|
||||||
const baseUrl = sandbox.domain(3000);
|
const baseUrl = sandbox.domain(3000);
|
||||||
|
|
||||||
console.log("Waiting for server...");
|
console.log("Connecting to server...");
|
||||||
await waitForHealth({ baseUrl });
|
|
||||||
|
|
||||||
const client = await SandboxAgent.connect({ baseUrl });
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } });
|
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } });
|
||||||
const sessionId = session.id;
|
const sessionId = session.id;
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,23 @@ const DEFAULT_BASE_URL = "http://sandbox-agent";
|
||||||
const DEFAULT_REPLAY_MAX_EVENTS = 50;
|
const DEFAULT_REPLAY_MAX_EVENTS = 50;
|
||||||
const DEFAULT_REPLAY_MAX_CHARS = 12_000;
|
const DEFAULT_REPLAY_MAX_CHARS = 12_000;
|
||||||
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500;
|
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500;
|
||||||
|
const HEALTH_WAIT_MIN_DELAY_MS = 500;
|
||||||
|
const HEALTH_WAIT_MAX_DELAY_MS = 15_000;
|
||||||
|
const HEALTH_WAIT_LOG_AFTER_MS = 5_000;
|
||||||
|
const HEALTH_WAIT_LOG_EVERY_MS = 10_000;
|
||||||
|
|
||||||
|
export interface SandboxAgentHealthWaitOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SandboxAgentConnectCommonOptions {
|
interface SandboxAgentConnectCommonOptions {
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
persist?: SessionPersistDriver;
|
persist?: SessionPersistDriver;
|
||||||
replayMaxEvents?: number;
|
replayMaxEvents?: number;
|
||||||
replayMaxChars?: number;
|
replayMaxChars?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
waitForHealth?: boolean | SandboxAgentHealthWaitOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SandboxAgentConnectOptions =
|
export type SandboxAgentConnectOptions =
|
||||||
|
|
@ -477,12 +487,17 @@ export class SandboxAgent {
|
||||||
private readonly token?: string;
|
private readonly token?: string;
|
||||||
private readonly fetcher: typeof fetch;
|
private readonly fetcher: typeof fetch;
|
||||||
private readonly defaultHeaders?: HeadersInit;
|
private readonly defaultHeaders?: HeadersInit;
|
||||||
|
private readonly healthWait: NormalizedHealthWaitOptions;
|
||||||
|
private readonly healthWaitAbortController = new AbortController();
|
||||||
|
|
||||||
private readonly persist: SessionPersistDriver;
|
private readonly persist: SessionPersistDriver;
|
||||||
private readonly replayMaxEvents: number;
|
private readonly replayMaxEvents: number;
|
||||||
private readonly replayMaxChars: number;
|
private readonly replayMaxChars: number;
|
||||||
|
|
||||||
private spawnHandle?: SandboxAgentSpawnHandle;
|
private spawnHandle?: SandboxAgentSpawnHandle;
|
||||||
|
private healthPromise?: Promise<void>;
|
||||||
|
private healthError?: Error;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
private readonly liveConnections = new Map<string, LiveAcpConnection>();
|
private readonly liveConnections = new Map<string, LiveAcpConnection>();
|
||||||
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
|
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
|
||||||
|
|
@ -504,10 +519,13 @@ export class SandboxAgent {
|
||||||
}
|
}
|
||||||
this.fetcher = resolvedFetch;
|
this.fetcher = resolvedFetch;
|
||||||
this.defaultHeaders = options.headers;
|
this.defaultHeaders = options.headers;
|
||||||
|
this.healthWait = normalizeHealthWaitOptions(options.waitForHealth, options.signal);
|
||||||
this.persist = options.persist ?? new InMemorySessionPersistDriver();
|
this.persist = options.persist ?? new InMemorySessionPersistDriver();
|
||||||
|
|
||||||
this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS);
|
this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS);
|
||||||
this.replayMaxChars = normalizePositiveInt(options.replayMaxChars, DEFAULT_REPLAY_MAX_CHARS);
|
this.replayMaxChars = normalizePositiveInt(options.replayMaxChars, DEFAULT_REPLAY_MAX_CHARS);
|
||||||
|
|
||||||
|
this.startHealthWait();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async connect(options: SandboxAgentConnectOptions): Promise<SandboxAgent> {
|
static async connect(options: SandboxAgentConnectOptions): Promise<SandboxAgent> {
|
||||||
|
|
@ -529,6 +547,7 @@ export class SandboxAgent {
|
||||||
token: handle.token,
|
token: handle.token,
|
||||||
fetch: options.fetch,
|
fetch: options.fetch,
|
||||||
headers: options.headers,
|
headers: options.headers,
|
||||||
|
waitForHealth: false,
|
||||||
persist: options.persist,
|
persist: options.persist,
|
||||||
replayMaxEvents: options.replayMaxEvents,
|
replayMaxEvents: options.replayMaxEvents,
|
||||||
replayMaxChars: options.replayMaxChars,
|
replayMaxChars: options.replayMaxChars,
|
||||||
|
|
@ -539,6 +558,9 @@ export class SandboxAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
|
this.disposed = true;
|
||||||
|
this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed."));
|
||||||
|
|
||||||
const connections = [...this.liveConnections.values()];
|
const connections = [...this.liveConnections.values()];
|
||||||
this.liveConnections.clear();
|
this.liveConnections.clear();
|
||||||
const pending = [...this.pendingLiveConnections.values()];
|
const pending = [...this.pendingLiveConnections.values()];
|
||||||
|
|
@ -706,7 +728,7 @@ export class SandboxAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHealth(): Promise<HealthResponse> {
|
async getHealth(): Promise<HealthResponse> {
|
||||||
return this.requestJson("GET", `${API_PREFIX}/health`);
|
return this.requestHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAgents(options?: AgentQueryOptions): Promise<AgentListResponse> {
|
async listAgents(options?: AgentQueryOptions): Promise<AgentListResponse> {
|
||||||
|
|
@ -935,6 +957,8 @@ export class SandboxAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLiveConnection(agent: string): Promise<LiveAcpConnection> {
|
private async getLiveConnection(agent: string): Promise<LiveAcpConnection> {
|
||||||
|
await this.awaitHealthy();
|
||||||
|
|
||||||
const existing = this.liveConnections.get(agent);
|
const existing = this.liveConnections.get(agent);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
|
|
@ -1115,6 +1139,7 @@ export class SandboxAgent {
|
||||||
headers: options.headers,
|
headers: options.headers,
|
||||||
accept: options.accept ?? "application/json",
|
accept: options.accept ?? "application/json",
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
|
skipReadyWait: options.skipReadyWait,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
|
@ -1125,6 +1150,10 @@ export class SandboxAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestRaw(method: string, path: string, options: RequestOptions = {}): Promise<Response> {
|
private async requestRaw(method: string, path: string, options: RequestOptions = {}): Promise<Response> {
|
||||||
|
if (!options.skipReadyWait) {
|
||||||
|
await this.awaitHealthy(options.signal);
|
||||||
|
}
|
||||||
|
|
||||||
const url = this.buildUrl(path, options.query);
|
const url = this.buildUrl(path, options.query);
|
||||||
const headers = this.buildHeaders(options.headers);
|
const headers = this.buildHeaders(options.headers);
|
||||||
|
|
||||||
|
|
@ -1161,6 +1190,79 @@ export class SandboxAgent {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startHealthWait(): void {
|
||||||
|
if (!this.healthWait.enabled || this.healthPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthPromise = this.runHealthWait().catch((error) => {
|
||||||
|
this.healthError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async awaitHealthy(signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!this.healthPromise) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForAbortable(this.healthPromise, signal);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (this.healthError) {
|
||||||
|
throw this.healthError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runHealthWait(): Promise<void> {
|
||||||
|
const signal = this.healthWait.enabled
|
||||||
|
? anyAbortSignal([this.healthWait.signal, this.healthWaitAbortController.signal])
|
||||||
|
: undefined;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const deadline =
|
||||||
|
typeof this.healthWait.timeoutMs === "number" ? startedAt + this.healthWait.timeoutMs : undefined;
|
||||||
|
|
||||||
|
let delayMs = HEALTH_WAIT_MIN_DELAY_MS;
|
||||||
|
let nextLogAt = startedAt + HEALTH_WAIT_LOG_AFTER_MS;
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
while (!this.disposed && (deadline === undefined || Date.now() < deadline)) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await this.requestHealth({ signal });
|
||||||
|
if (health.status === "ok") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = new Error(`Unexpected health response: ${JSON.stringify(health)}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now >= nextLogAt) {
|
||||||
|
const details = formatHealthWaitError(lastError);
|
||||||
|
console.warn(
|
||||||
|
`sandbox-agent at ${this.baseUrl} is not healthy after ${now - startedAt}ms; still waiting (${details})`,
|
||||||
|
);
|
||||||
|
nextLogAt = now + HEALTH_WAIT_LOG_EVERY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(delayMs, signal);
|
||||||
|
delayMs = Math.min(HEALTH_WAIT_MAX_DELAY_MS, delayMs * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for sandbox-agent health after ${this.healthWait.timeoutMs}ms (${formatHealthWaitError(lastError)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private buildHeaders(extra?: HeadersInit): Headers {
|
private buildHeaders(extra?: HeadersInit): Headers {
|
||||||
const headers = new Headers(this.defaultHeaders ?? undefined);
|
const headers = new Headers(this.defaultHeaders ?? undefined);
|
||||||
|
|
||||||
|
|
@ -1190,6 +1292,13 @@ export class SandboxAgent {
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requestHealth(options: { signal?: AbortSignal } = {}): Promise<HealthResponse> {
|
||||||
|
return this.requestJson("GET", `${API_PREFIX}/health`, {
|
||||||
|
signal: options.signal,
|
||||||
|
skipReadyWait: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryValue = string | number | boolean | null | undefined;
|
type QueryValue = string | number | boolean | null | undefined;
|
||||||
|
|
@ -1202,8 +1311,13 @@ type RequestOptions = {
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
skipReadyWait?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NormalizedHealthWaitOptions =
|
||||||
|
| { enabled: false; timeoutMs?: undefined; signal?: undefined }
|
||||||
|
| { enabled: true; timeoutMs?: number; signal?: AbortSignal };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-select and call `authenticate` based on the agent's advertised auth methods.
|
* Auto-select and call `authenticate` based on the agent's advertised auth methods.
|
||||||
* Prefers env-var-based methods that the server process already has configured.
|
* Prefers env-var-based methods that the server process already has configured.
|
||||||
|
|
@ -1375,6 +1489,30 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb
|
||||||
return Math.floor(value as number);
|
return Math.floor(value as number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHealthWaitOptions(
|
||||||
|
value: boolean | SandboxAgentHealthWaitOptions | undefined,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
): NormalizedHealthWaitOptions {
|
||||||
|
if (value === false) {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === true || value === undefined) {
|
||||||
|
return { enabled: true, signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs =
|
||||||
|
typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0
|
||||||
|
? Math.floor(value.timeoutMs)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
signal,
|
||||||
|
timeoutMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSpawnOptions(
|
function normalizeSpawnOptions(
|
||||||
spawn: SandboxAgentSpawnOptions | boolean | undefined,
|
spawn: SandboxAgentSpawnOptions | boolean | undefined,
|
||||||
defaultEnabled: boolean,
|
defaultEnabled: boolean,
|
||||||
|
|
@ -1405,6 +1543,92 @@ async function readProblem(response: Response): Promise<ProblemDetails | undefin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHealthWaitError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error === undefined || error === null) {
|
||||||
|
return "unknown error";
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyAbortSignal(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
|
||||||
|
const active = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
||||||
|
if (active.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.length === 1) {
|
||||||
|
return active[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onAbort = (event: Event) => {
|
||||||
|
cleanup();
|
||||||
|
const signal = event.target as AbortSignal;
|
||||||
|
controller.abort(signal.reason ?? createAbortError());
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const signal of active) {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const signal of active) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
controller.abort(signal.reason ?? createAbortError());
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const signal of active) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAbortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||||
|
if (!signal) {
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason));
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
cleanup();
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function consumeProcessLogSse(
|
async function consumeProcessLogSse(
|
||||||
body: ReadableStream<Uint8Array>,
|
body: ReadableStream<Uint8Array>,
|
||||||
listener: ProcessLogListener,
|
listener: ProcessLogListener,
|
||||||
|
|
@ -1494,3 +1718,43 @@ function toWebSocketUrl(url: string): string {
|
||||||
function isAbortError(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
return error instanceof Error && error.name === "AbortError";
|
return error instanceof Error && error.name === "AbortError";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAbortError(reason?: unknown): Error {
|
||||||
|
if (reason instanceof Error) {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = typeof reason === "string" ? reason : "This operation was aborted.";
|
||||||
|
if (typeof DOMException !== "undefined") {
|
||||||
|
return new DOMException(message, "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(message);
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!signal) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(signal.reason instanceof Error ? signal.reason : createAbortError(signal.reason));
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export { AcpRpcError } from "acp-http-client";
|
||||||
export { buildInspectorUrl } from "./inspector.ts";
|
export { buildInspectorUrl } from "./inspector.ts";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
SandboxAgentHealthWaitOptions,
|
||||||
AgentQueryOptions,
|
AgentQueryOptions,
|
||||||
ProcessLogFollowQuery,
|
ProcessLogFollowQuery,
|
||||||
ProcessLogListener,
|
ProcessLogListener,
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,111 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("waits for health before non-ACP HTTP helpers", async () => {
|
||||||
|
const defaultFetch = globalThis.fetch;
|
||||||
|
if (!defaultFetch) {
|
||||||
|
throw new Error("Global fetch is not available in this runtime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthAttempts = 0;
|
||||||
|
const seenPaths: string[] = [];
|
||||||
|
const customFetch: typeof fetch = async (input, init) => {
|
||||||
|
const outgoing = new Request(input, init);
|
||||||
|
const parsed = new URL(outgoing.url);
|
||||||
|
seenPaths.push(parsed.pathname);
|
||||||
|
|
||||||
|
if (parsed.pathname === "/v1/health") {
|
||||||
|
healthAttempts += 1;
|
||||||
|
if (healthAttempts < 3) {
|
||||||
|
return new Response("warming up", { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl);
|
||||||
|
const forwarded = new Request(forwardedUrl.toString(), outgoing);
|
||||||
|
return defaultFetch(forwarded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.connect({
|
||||||
|
token,
|
||||||
|
fetch: customFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agents = await sdk.listAgents();
|
||||||
|
expect(Array.isArray(agents.agents)).toBe(true);
|
||||||
|
expect(healthAttempts).toBe(3);
|
||||||
|
|
||||||
|
const firstAgentsRequest = seenPaths.indexOf("/v1/agents");
|
||||||
|
expect(firstAgentsRequest).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(seenPaths.slice(0, firstAgentsRequest)).toEqual([
|
||||||
|
"/v1/health",
|
||||||
|
"/v1/health",
|
||||||
|
"/v1/health",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sdk.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces health timeout when a request awaits readiness", async () => {
|
||||||
|
const customFetch: typeof fetch = async (input, init) => {
|
||||||
|
const outgoing = new Request(input, init);
|
||||||
|
const parsed = new URL(outgoing.url);
|
||||||
|
|
||||||
|
if (parsed.pathname === "/v1/health") {
|
||||||
|
return new Response("warming up", { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected request path during timeout test: ${parsed.pathname}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.connect({
|
||||||
|
token,
|
||||||
|
fetch: customFetch,
|
||||||
|
waitForHealth: { timeoutMs: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sdk.listAgents()).rejects.toThrow("Timed out waiting for sandbox-agent health");
|
||||||
|
await sdk.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the shared health wait when connect signal is aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const customFetch: typeof fetch = async (input, init) => {
|
||||||
|
const outgoing = new Request(input, init);
|
||||||
|
const parsed = new URL(outgoing.url);
|
||||||
|
|
||||||
|
if (parsed.pathname !== "/v1/health") {
|
||||||
|
throw new Error(`Unexpected request path during abort test: ${parsed.pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
outgoing.signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(outgoing.signal.reason ?? new DOMException("Connect aborted", "AbortError"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (outgoing.signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoing.signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.connect({
|
||||||
|
token,
|
||||||
|
fetch: customFetch,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = sdk.listAgents();
|
||||||
|
controller.abort(new DOMException("Connect aborted", "AbortError"));
|
||||||
|
|
||||||
|
await expect(pending).rejects.toThrow("Connect aborted");
|
||||||
|
await sdk.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("restores a session on stale connection by recreating and replaying history on first prompt", async () => {
|
it("restores a session on stale connection by recreating and replaying history on first prompt", async () => {
|
||||||
const persist = new InMemorySessionPersistDriver({
|
const persist = new InMemorySessionPersistDriver({
|
||||||
maxEventsPerSession: 200,
|
maxEventsPerSession: 200,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue