feat: align SDK with updated OpenAPI spec and add missing unit tests
- Update generated types from new openapi.yaml (capsule stats, usage, metrics, pause/resume lifecycle, host/channel management, auth flow) - Add Capsule pause/resume/ping/getMetrics lifecycle methods - Add Capsule.waitForReady abort signal support - Add PtyManager.connect and PtySession disposal - Fix HttpClient empty-body response handling (content-length: 0) - Add streamProcess() to CommandManager for background process streams - Add integration tests for capsule lifecycle, git, and PTY features - Add unit tests for AsyncQueue error paths, PtySession.close, Git.checkout without create, Git.add single string, Notebook.execCell error case, and PtyStartOptions fields
This commit is contained in:
2
Makefile
2
Makefile
@ -1,7 +1,7 @@
|
||||
# Makefile
|
||||
.PHONY: generate lint test test-integration check build
|
||||
|
||||
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||
SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/feat/migrate-to-ch/internal/api/openapi.yaml"
|
||||
SPEC_PATH = "api/openapi.yaml"
|
||||
|
||||
generate:
|
||||
|
||||
554
README.md
554
README.md
@ -1 +1,553 @@
|
||||
# js-sdk
|
||||
# Wrenn JavaScript SDK
|
||||
|
||||
JavaScript and TypeScript client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute Python code -- all from Node.js.
|
||||
|
||||
Designed as an e2b-style SDK. If you're migrating, `Sandbox` is available as a deprecated alias for `Capsule`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @wrenn/sdk
|
||||
```
|
||||
|
||||
Requires Node.js 18+.
|
||||
|
||||
## Authentication
|
||||
|
||||
Set the `WRENN_API_KEY` environment variable:
|
||||
|
||||
```bash
|
||||
export WRENN_API_KEY="wrn_your_api_key_here"
|
||||
```
|
||||
|
||||
Optionally override the API base URL:
|
||||
|
||||
```bash
|
||||
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
|
||||
```
|
||||
|
||||
You can also pass credentials directly:
|
||||
|
||||
```ts
|
||||
import { Capsule } from "@wrenn/sdk";
|
||||
|
||||
const capsule = await Capsule.create("minimal", {
|
||||
apiKey: "wrn_...",
|
||||
baseUrl: "https://app.wrenn.dev/api",
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wrenn Capsules
|
||||
|
||||
### Quick Start
|
||||
|
||||
```ts
|
||||
import { Capsule } from "@wrenn/sdk";
|
||||
|
||||
const capsule = await Capsule.create("minimal");
|
||||
|
||||
try {
|
||||
await capsule.waitForReady();
|
||||
|
||||
const result = await capsule.commands.exec("echo", { args: ["hello"] });
|
||||
console.log(result.stdout); // "hello\n"
|
||||
} finally {
|
||||
await capsule.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
### Creating Capsules
|
||||
|
||||
```ts
|
||||
import { Capsule } from "@wrenn/sdk";
|
||||
|
||||
// Create with defaults: template="minimal", vcpus=1, memory_mb=512.
|
||||
const capsule = await Capsule.create();
|
||||
|
||||
// Create with an explicit template.
|
||||
const python = await Capsule.create("base-python");
|
||||
|
||||
// Create with resource and client options.
|
||||
const larger = await Capsule.create("minimal", {
|
||||
vcpus: 2,
|
||||
memory_mb: 1024,
|
||||
timeout_sec: 300,
|
||||
apiKey: "wrn_...",
|
||||
});
|
||||
|
||||
// Equivalent options-object form.
|
||||
const fromOptions = await Capsule.create({
|
||||
template: "minimal",
|
||||
vcpus: 2,
|
||||
memory_mb: 1024,
|
||||
});
|
||||
```
|
||||
|
||||
### Resource Cleanup
|
||||
|
||||
Capsules created with `Capsule.create()` are owned by that SDK instance. When used with `await using`, the remote capsule is destroyed automatically when the block exits:
|
||||
|
||||
```ts
|
||||
await using capsule = await Capsule.create("minimal");
|
||||
|
||||
await capsule.waitForReady();
|
||||
await capsule.commands.exec("echo", { args: ["work"] });
|
||||
// capsule is automatically destroyed here
|
||||
```
|
||||
|
||||
Capsules attached with `new Capsule(id)` or `Capsule.connect(id)` are not owned. `await using` a connected capsule only runs local cleanup and does not destroy the remote capsule.
|
||||
|
||||
Use `destroy()` when you want to delete the remote capsule:
|
||||
|
||||
```ts
|
||||
const capsule = await Capsule.create("minimal");
|
||||
|
||||
try {
|
||||
await capsule.waitForReady();
|
||||
await capsule.commands.exec("echo", { args: ["work"] });
|
||||
} finally {
|
||||
await capsule.destroy();
|
||||
}
|
||||
```
|
||||
|
||||
### Connecting to Existing Capsules
|
||||
|
||||
Attach to an existing capsule by ID. This wraps the ID locally and does not fetch or validate it until you call an API method:
|
||||
|
||||
```ts
|
||||
const capsule = Capsule.connect("cl-abc123");
|
||||
const info = await capsule.getInfo();
|
||||
|
||||
if (info.status === "paused") {
|
||||
await capsule.resume({ wait: true });
|
||||
}
|
||||
|
||||
const result = await capsule.commands.exec("echo", { args: ["still running"] });
|
||||
console.log(result.stdout);
|
||||
```
|
||||
|
||||
For code interpreter capsules:
|
||||
|
||||
```ts
|
||||
import { CodeInterpreter } from "@wrenn/sdk";
|
||||
|
||||
const interpreter = CodeInterpreter.connect("cl-abc123");
|
||||
const result = await interpreter.notebook.execCell("print('reconnected')");
|
||||
console.log(result.stdout);
|
||||
```
|
||||
|
||||
### Lifecycle Management
|
||||
|
||||
```ts
|
||||
// Instance methods.
|
||||
await capsule.pause(); // returns status like "pausing"
|
||||
await capsule.resume(); // returns status like "resuming"
|
||||
await capsule.resume({ wait: true });
|
||||
await capsule.destroy();
|
||||
await capsule.ping(); // reset inactivity timer
|
||||
await capsule.waitForReady();
|
||||
|
||||
const info = await capsule.getInfo();
|
||||
console.log(info.status); // "running"
|
||||
|
||||
const metrics = await capsule.getMetrics({ range: "10m" });
|
||||
|
||||
// Static helper.
|
||||
await Capsule.destroy("cl-abc123", { apiKey: "wrn_..." });
|
||||
```
|
||||
|
||||
### Command Execution
|
||||
|
||||
Commands are accessed via `capsule.commands`:
|
||||
|
||||
```ts
|
||||
// Foreground command.
|
||||
const result = await capsule.commands.exec("python3", {
|
||||
args: ["-c", "print(42)"],
|
||||
timeoutSec: 30,
|
||||
cwd: "/app",
|
||||
});
|
||||
|
||||
console.log(result.stdout); // "42\n"
|
||||
console.log(result.stderr);
|
||||
console.log(result.exit_code); // 0
|
||||
|
||||
// Background process.
|
||||
const process = await capsule.commands.start("python3", {
|
||||
args: ["server.py"],
|
||||
tag: "web-server",
|
||||
envs: { PORT: "8000" },
|
||||
cwd: "/app",
|
||||
});
|
||||
|
||||
console.log(process.pid);
|
||||
console.log(process.tag);
|
||||
```
|
||||
|
||||
#### Streaming Output
|
||||
|
||||
```ts
|
||||
// Stream a new command.
|
||||
for await (const event of capsule.commands.stream("python3", {
|
||||
args: ["-u", "train.py"],
|
||||
})) {
|
||||
if (event.type === "stdout") {
|
||||
process.stdout.write(String(event.data ?? ""));
|
||||
}
|
||||
|
||||
if (event.type === "stderr") {
|
||||
process.stderr.write(String(event.data ?? ""));
|
||||
}
|
||||
|
||||
if (event.type === "exit") {
|
||||
console.log("exited", event.exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to a running background process stream.
|
||||
await using connection = await capsule.commands.streamProcess("web-server");
|
||||
connection.send({ type: "ping" });
|
||||
// connection is automatically closed here
|
||||
```
|
||||
|
||||
#### Process Management
|
||||
|
||||
```ts
|
||||
const processes = await capsule.commands.list();
|
||||
|
||||
for (const proc of processes.processes ?? []) {
|
||||
console.log(proc.pid, proc.tag);
|
||||
}
|
||||
|
||||
await capsule.commands.kill("web-server", "SIGTERM");
|
||||
```
|
||||
|
||||
### Filesystem
|
||||
|
||||
Files are accessed via `capsule.files`:
|
||||
|
||||
```ts
|
||||
// Write and read files.
|
||||
await capsule.files.write("/app/main.py", "print('hello')");
|
||||
|
||||
const content = await capsule.files.read("/app/main.py");
|
||||
console.log(content.toString());
|
||||
|
||||
// List directory.
|
||||
const listing = await capsule.files.list("/app", { depth: 1 });
|
||||
|
||||
for (const entry of listing.entries ?? []) {
|
||||
console.log(entry.name, entry.type, entry.size);
|
||||
}
|
||||
|
||||
// Create directory.
|
||||
await capsule.files.mkdir("/app/data");
|
||||
|
||||
// Remove file or directory.
|
||||
await capsule.files.remove("/app/old_data");
|
||||
```
|
||||
|
||||
#### Streaming Large Files
|
||||
|
||||
```ts
|
||||
await capsule.files.uploadStream(
|
||||
"/data/large.txt",
|
||||
Buffer.from("large file content"),
|
||||
);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of capsule.files.downloadStream("/data/large.txt")) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
console.log(Buffer.concat(chunks).toString());
|
||||
```
|
||||
|
||||
### Git
|
||||
|
||||
Git operations are accessed via `capsule.git`. All commands execute the real `git` binary inside the capsule:
|
||||
|
||||
```ts
|
||||
// Clone a repository.
|
||||
await capsule.git.clone("https://github.com/org/repo.git", {
|
||||
path: "/app/repo",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
// Use repository-scoped commands.
|
||||
const status = await capsule.git.status({ cwd: "/app/repo" });
|
||||
console.log(status.stdout);
|
||||
|
||||
const log = await capsule.git.log({ cwd: "/app/repo", maxCount: 5 });
|
||||
console.log(log.stdout);
|
||||
|
||||
// Branches.
|
||||
await capsule.git.checkout("feature", { cwd: "/app/repo", create: true });
|
||||
console.log((await capsule.git.branch({ cwd: "/app/repo" })).stdout);
|
||||
|
||||
// Stage and commit.
|
||||
await capsule.git.add(["README.md", "src/index.ts"], { cwd: "/app/repo" });
|
||||
await capsule.git.commit("initial commit", { cwd: "/app/repo" });
|
||||
|
||||
// Push and pull.
|
||||
await capsule.git.pull({ cwd: "/app/repo", remote: "origin", branch: "main" });
|
||||
await capsule.git.push({ cwd: "/app/repo", remote: "origin", branch: "main" });
|
||||
```
|
||||
|
||||
Git helpers return command results. Check `exit_code`, `stdout`, and `stderr` for command-level failures.
|
||||
|
||||
### Interactive Terminal (PTY)
|
||||
|
||||
```ts
|
||||
await using term = await capsule.pty.start({
|
||||
cmd: "/bin/bash",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
|
||||
term.input("ls -la\n");
|
||||
|
||||
for await (const event of term.events) {
|
||||
if (event.type === "data") {
|
||||
process.stdout.write(String(event.data ?? ""));
|
||||
}
|
||||
|
||||
if (event.type === "exit") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// terminal WebSocket is automatically closed here
|
||||
```
|
||||
|
||||
Reconnect to a tagged session:
|
||||
|
||||
```ts
|
||||
await using term = await capsule.pty.connect("my-session-tag");
|
||||
term.input("echo reconnected\n");
|
||||
// terminal WebSocket is automatically closed here
|
||||
```
|
||||
|
||||
**PtySession methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `input(data)` | Send text or bytes to stdin |
|
||||
| `resize(cols, rows)` | Resize the terminal |
|
||||
| `kill()` | Send a kill control message |
|
||||
| `close()` | Close the WebSocket connection |
|
||||
| `events` | Async iterator of PTY events |
|
||||
|
||||
---
|
||||
|
||||
## Code Interpreter
|
||||
|
||||
`CodeInterpreter` creates or connects to a capsule intended for Python code execution. It uses the capsule command API to execute cells with `python3 -c`.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```ts
|
||||
import { CodeInterpreter } from "@wrenn/sdk";
|
||||
|
||||
await using interpreter = await CodeInterpreter.create();
|
||||
|
||||
await interpreter.capsule.waitForReady();
|
||||
|
||||
const result = await interpreter.notebook.execCell("print('hello')");
|
||||
console.log(result.stdout); // "hello\n"
|
||||
// interpreter.capsule is automatically destroyed here
|
||||
```
|
||||
|
||||
Interpreters created with `CodeInterpreter.create()` own their capsule and destroy it on `await using` disposal. Interpreters attached with `CodeInterpreter.connect(id)` follow connected capsule semantics and leave the remote capsule running.
|
||||
|
||||
### Custom Templates
|
||||
|
||||
By default, `CodeInterpreter.create()` uses the `jupyter` template. You can specify a custom template:
|
||||
|
||||
```ts
|
||||
const interpreter = await CodeInterpreter.create({
|
||||
template: "my-custom-python-template",
|
||||
timeout_sec: 300,
|
||||
});
|
||||
|
||||
const result = await interpreter.notebook.execCell("print('custom template')", {
|
||||
timeoutSec: 60,
|
||||
});
|
||||
```
|
||||
|
||||
### Code Interpreter + Commands/Files
|
||||
|
||||
The code interpreter wrapper exposes the underlying standard capsule:
|
||||
|
||||
```ts
|
||||
const interpreter = await CodeInterpreter.create();
|
||||
const { capsule } = interpreter;
|
||||
|
||||
await interpreter.notebook.execCell("open('/tmp/data.csv', 'w').write('a,b\\n1,2')");
|
||||
|
||||
const content = await capsule.files.read("/tmp/data.csv");
|
||||
console.log(content.toString());
|
||||
|
||||
const result = await capsule.commands.exec("wc", { args: ["-l", "/tmp/data.csv"] });
|
||||
console.log(result.stdout);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The SDK maps unsuccessful API responses to typed errors:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
HostHasCapsulesError,
|
||||
NotFoundError,
|
||||
PayloadTooLargeError,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
WrennError,
|
||||
} from "@wrenn/sdk";
|
||||
|
||||
try {
|
||||
await Capsule.connect("missing").getInfo();
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
console.log(error.code);
|
||||
console.log(error.message);
|
||||
console.log(error.statusCode); // 404
|
||||
}
|
||||
|
||||
if (error instanceof WrennError) {
|
||||
console.log(error.body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All SDK errors inherit from `WrennError` and expose `statusCode`, `code`, `message`, and `body`.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from e2b
|
||||
|
||||
Replace your imports and prefer `Capsule` for new code:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
import { Sandbox } from "e2b";
|
||||
const sandbox = await Sandbox.create();
|
||||
|
||||
// After
|
||||
import { Capsule } from "@wrenn/sdk";
|
||||
const capsule = await Capsule.create();
|
||||
```
|
||||
|
||||
The `Sandbox` name is available as a deprecated alias:
|
||||
|
||||
```ts
|
||||
import { Sandbox } from "@wrenn/sdk";
|
||||
|
||||
const sandbox = await Sandbox.create("minimal");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Low-Level Client
|
||||
|
||||
For direct API access, use `WrennClient`:
|
||||
|
||||
```ts
|
||||
import { WrennClient } from "@wrenn/sdk";
|
||||
|
||||
const client = new WrennClient({ apiKey: "wrn_..." });
|
||||
|
||||
const capsule = await client.capsules.create({
|
||||
template: "minimal",
|
||||
vcpus: 1,
|
||||
memory_mb: 512,
|
||||
timeout_sec: 300,
|
||||
});
|
||||
|
||||
await client.capsules.pause(capsule.id);
|
||||
await client.capsules.resume(capsule.id);
|
||||
await client.capsules.ping(capsule.id);
|
||||
await client.capsules.destroy(capsule.id);
|
||||
|
||||
const templates = await client.snapshots.list();
|
||||
console.log(templates);
|
||||
```
|
||||
|
||||
Available resource groups:
|
||||
|
||||
| Resource | Property |
|
||||
|----------|----------|
|
||||
| Auth | `client.auth` |
|
||||
| Account | `client.account` |
|
||||
| API keys | `client.apiKeys` |
|
||||
| Users | `client.users` |
|
||||
| Teams | `client.teams` |
|
||||
| Capsules | `client.capsules` |
|
||||
| Files | `client.files` |
|
||||
| Snapshots | `client.snapshots` |
|
||||
| Hosts | `client.hosts` |
|
||||
| Channels | `client.channels` |
|
||||
|
||||
Generated OpenAPI types are exported from the package root:
|
||||
|
||||
```ts
|
||||
import type { components, operations, paths } from "@wrenn/sdk";
|
||||
|
||||
type CapsuleSchema = components["schemas"]["Capsule"];
|
||||
type GetCapsuleOperation = operations["getCapsule"];
|
||||
type CapsulePath = paths["/v1/capsules/{id}"];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
This project uses [Bun](https://bun.sh) for dependency management and script execution. The package build still uses `tsup`, and tests use Vitest.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run linting
|
||||
make lint
|
||||
|
||||
# Run unit tests
|
||||
make test
|
||||
|
||||
# Build CJS, ESM, and declaration files
|
||||
make build
|
||||
|
||||
# Run lint + unit tests
|
||||
make check
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
Integration tests require a live Wrenn server. Set credentials via environment variables:
|
||||
|
||||
```bash
|
||||
export WRENN_API_KEY="wrn_..."
|
||||
export WRENN_BASE_URL="https://app.wrenn.dev/api" # optional
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests are automatically skipped when `WRENN_API_KEY` is not available.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
openapi: "3.1.0"
|
||||
info:
|
||||
title: Wrenn API
|
||||
description: MicroVM-based code execution platform API.
|
||||
version: "0.1.4"
|
||||
description: AI agent execution platform API.
|
||||
version: "0.2.0"
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
@ -866,8 +866,8 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateCapsuleRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Capsule created
|
||||
"202":
|
||||
description: Capsule creation initiated (status will be "starting")
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -988,8 +988,8 @@ paths:
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Capsule destroyed
|
||||
"202":
|
||||
description: Capsule destruction initiated
|
||||
|
||||
/v1/capsules/{id}/exec:
|
||||
parameters:
|
||||
@ -1260,8 +1260,8 @@ paths:
|
||||
destroys all running resources. The capsule exists only as files on
|
||||
disk and can be resumed later.
|
||||
responses:
|
||||
"200":
|
||||
description: Capsule paused (snapshot taken, resources released)
|
||||
"202":
|
||||
description: Capsule pause initiated (status will be "pausing")
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -1289,11 +1289,11 @@ paths:
|
||||
- apiKeyAuth: []
|
||||
description: |
|
||||
Restores a paused capsule from its snapshot using UFFD for lazy
|
||||
memory loading. Boots a fresh Firecracker process, sets up a new
|
||||
memory loading. Boots a fresh Cloud Hypervisor process, sets up a new
|
||||
network slot, and waits for envd to become ready.
|
||||
responses:
|
||||
"200":
|
||||
description: Capsule resumed (new VM booted from snapshot)
|
||||
"202":
|
||||
description: Capsule resume initiated (status will be "resuming")
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -2035,6 +2035,51 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/sandbox-events:
|
||||
post:
|
||||
summary: Sandbox lifecycle event callback
|
||||
operationId: sandboxEventCallback
|
||||
tags: [hosts]
|
||||
security:
|
||||
- hostTokenAuth: []
|
||||
description: |
|
||||
Receives autonomous lifecycle events from host agents (e.g. auto-pause
|
||||
from the TTL reaper). The event is published to an internal Redis stream
|
||||
for the control plane's event consumer to process.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [event, sandbox_id, host_id]
|
||||
properties:
|
||||
event:
|
||||
type: string
|
||||
enum: [sandbox.auto_paused]
|
||||
sandbox_id:
|
||||
type: string
|
||||
host_id:
|
||||
type: string
|
||||
timestamp:
|
||||
type: integer
|
||||
format: int64
|
||||
responses:
|
||||
"204":
|
||||
description: Event accepted
|
||||
"400":
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: Host ID mismatch
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/auth/refresh:
|
||||
post:
|
||||
summary: Refresh host JWT
|
||||
@ -2395,6 +2440,14 @@ paths:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
components:
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Invalid request parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
securitySchemes:
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
@ -2592,7 +2645,7 @@ components:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
|
||||
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
|
||||
template:
|
||||
type: string
|
||||
vcpus:
|
||||
@ -3059,7 +3112,7 @@ components:
|
||||
mem_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
description: "Resident memory in bytes (VmRSS of Firecracker process)"
|
||||
description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||
disk_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
@ -225,19 +225,22 @@ export class HttpClient {
|
||||
): Promise<T> {
|
||||
const res = await this.rawRequest(method, path, body, opts);
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
await throwErrorFromResponse(res);
|
||||
}
|
||||
|
||||
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (opts?.asText) {
|
||||
return (await res.text()) as T;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
private async rawRequest(
|
||||
|
||||
@ -46,6 +46,11 @@ export class WsConnection {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
/** Closes the WebSocket when used with `await using`. */
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** Indicates whether the connection has closed or failed. */
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
|
||||
@ -29,6 +29,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 1_000;
|
||||
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
|
||||
"error",
|
||||
"missing",
|
||||
"stopping",
|
||||
"stopped",
|
||||
]);
|
||||
|
||||
@ -93,6 +94,9 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
|
||||
/** Main user-facing handle for a Wrenn capsule. */
|
||||
export class Capsule {
|
||||
private disposed = false;
|
||||
private ownsRemote = false;
|
||||
|
||||
/** Capsule identifier used for all instance operations. */
|
||||
readonly id: string;
|
||||
/** Low-level client backing this capsule handle. */
|
||||
@ -161,7 +165,7 @@ export class Capsule {
|
||||
);
|
||||
}
|
||||
|
||||
return new Capsule(capsule.id, clientConfig);
|
||||
return new Capsule(capsule.id, clientConfig).markOwned();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -247,8 +251,10 @@ export class Capsule {
|
||||
* @returns Resolves when the capsule is destroyed.
|
||||
* @throws WrennError subclasses for unsuccessful API responses.
|
||||
*/
|
||||
destroy(): Promise<void> {
|
||||
return this.client.capsules.destroy(this.id);
|
||||
async destroy(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
await this.client.capsules.destroy(this.id);
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -299,10 +305,20 @@ export class Capsule {
|
||||
/** Local cleanup hook. This does not mutate or destroy the remote capsule. */
|
||||
close(): void {}
|
||||
|
||||
/** Local async-disposal hook. This does not mutate or destroy the remote capsule. */
|
||||
/** Destroys capsules created by this SDK instance when used with `await using`. */
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
if (this.ownsRemote) {
|
||||
await this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
private markOwned(): this {
|
||||
this.ownsRemote = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link Capsule} instead. */
|
||||
|
||||
@ -589,7 +589,7 @@ export class CapsulesResource extends BaseResource {
|
||||
create(
|
||||
body: JsonBody<"createCapsule">,
|
||||
opts?: RequestOptions,
|
||||
): Promise<JsonResponse<"createCapsule", 201>> {
|
||||
): Promise<JsonResponse<"createCapsule", 202>> {
|
||||
return this.http.post("/v1/capsules", body, opts);
|
||||
}
|
||||
|
||||
@ -779,7 +779,7 @@ export class CapsulesResource extends BaseResource {
|
||||
pause(
|
||||
id: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<JsonResponse<"pauseCapsule", 200>> {
|
||||
): Promise<JsonResponse<"pauseCapsule", 202>> {
|
||||
return this.http.post(
|
||||
`/v1/capsules/${encodePath(id)}/pause`,
|
||||
undefined,
|
||||
@ -798,7 +798,7 @@ export class CapsulesResource extends BaseResource {
|
||||
resume(
|
||||
id: string,
|
||||
opts?: RequestOptions,
|
||||
): Promise<JsonResponse<"resumeCapsule", 200>> {
|
||||
): Promise<JsonResponse<"resumeCapsule", 202>> {
|
||||
return this.http.post(
|
||||
`/v1/capsules/${encodePath(id)}/resume`,
|
||||
undefined,
|
||||
|
||||
@ -87,4 +87,15 @@ export class CodeInterpreter {
|
||||
static connect(id: string, opts?: ClientConfig): CodeInterpreter {
|
||||
return new CodeInterpreter(Capsule.connect(id, opts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the underlying capsule according to its ownership semantics.
|
||||
*
|
||||
* Interpreters created with {@link CodeInterpreter.create} destroy their remote
|
||||
* capsule on disposal. Interpreters attached with {@link CodeInterpreter.connect}
|
||||
* leave the remote capsule running.
|
||||
*/
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.capsule[Symbol.asyncDispose]();
|
||||
}
|
||||
}
|
||||
|
||||
@ -718,7 +718,7 @@ export interface paths {
|
||||
/**
|
||||
* Resume a paused capsule
|
||||
* @description Restores a paused capsule from its snapshot using UFFD for lazy
|
||||
* memory loading. Boots a fresh Firecracker process, sets up a new
|
||||
* memory loading. Boots a fresh Cloud Hypervisor process, sets up a new
|
||||
* network slot, and waits for envd to become ready.
|
||||
*/
|
||||
post: operations["resumeCapsule"];
|
||||
@ -1146,6 +1146,28 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/hosts/sandbox-events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Sandbox lifecycle event callback
|
||||
* @description Receives autonomous lifecycle events from host agents (e.g. auto-pause
|
||||
* from the TTL reaper). The event is published to an internal Redis stream
|
||||
* for the control plane's event consumer to process.
|
||||
*/
|
||||
post: operations["sandboxEventCallback"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/hosts/auth/refresh": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1312,6 +1334,29 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/admin/users/{id}/admin": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Grant or revoke platform admin
|
||||
* @description Sets the platform admin flag on a user. Cannot remove the last admin.
|
||||
* Requires platform admin access (JWT + is_admin).
|
||||
* The target user's JWT is not re-issued — their frontend will reflect the
|
||||
* change on next login or team switch.
|
||||
*/
|
||||
put: operations["setUserAdmin"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@ -1408,7 +1453,7 @@ export interface components {
|
||||
Capsule: {
|
||||
id?: string;
|
||||
/** @enum {string} */
|
||||
status?: "pending" | "starting" | "running" | "paused" | "hibernated" | "stopped" | "missing" | "error";
|
||||
status?: "pending" | "starting" | "running" | "pausing" | "paused" | "resuming" | "stopping" | "hibernated" | "stopped" | "missing" | "error";
|
||||
template?: string;
|
||||
vcpus?: number;
|
||||
memory_mb?: number;
|
||||
@ -1667,7 +1712,7 @@ export interface components {
|
||||
cpu_pct?: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Resident memory in bytes (VmRSS of Firecracker process)
|
||||
* @description Resident memory in bytes (VmRSS of Cloud Hypervisor process)
|
||||
*/
|
||||
mem_bytes?: number;
|
||||
/**
|
||||
@ -1743,7 +1788,7 @@ export interface components {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Invalid request */
|
||||
/** @description Invalid request parameters */
|
||||
BadRequest: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@ -2746,8 +2791,8 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Capsule created */
|
||||
201: {
|
||||
/** @description Capsule creation initiated (status will be "starting") */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
@ -2858,8 +2903,8 @@ export interface operations {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Capsule destroyed */
|
||||
204: {
|
||||
/** @description Capsule destruction initiated */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
@ -3117,8 +3162,8 @@ export interface operations {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Capsule paused (snapshot taken, resources released) */
|
||||
200: {
|
||||
/** @description Capsule pause initiated (status will be "pausing") */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
@ -3148,8 +3193,8 @@ export interface operations {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Capsule resumed (new VM booted from snapshot) */
|
||||
200: {
|
||||
/** @description Capsule resume initiated (status will be "resuming") */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
@ -3895,6 +3940,53 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
sandboxEventCallback: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @enum {string} */
|
||||
event: "sandbox.auto_paused";
|
||||
sandbox_id: string;
|
||||
host_id: string;
|
||||
/** Format: int64 */
|
||||
timestamp?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Event accepted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Invalid request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Host ID mismatch */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
refreshHostToken: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4249,4 +4341,50 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
setUserAdmin: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description true to grant admin, false to revoke. */
|
||||
admin: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Admin status updated */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
/** @description Caller is not a platform admin */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description User not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -79,6 +79,11 @@ export class PtySession {
|
||||
close(): void {
|
||||
this.connection.close();
|
||||
}
|
||||
|
||||
/** Closes the underlying WebSocket when used with `await using`. */
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Interactive terminal API bound to one capsule. */
|
||||
|
||||
@ -109,8 +109,8 @@ describe("Capsule", () => {
|
||||
capsuleResponse("cap_1"),
|
||||
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
|
||||
new Response(null, { status: 204 }),
|
||||
capsuleResponse("cap_1", "paused"),
|
||||
capsuleResponse("cap_1", "running"),
|
||||
capsuleResponse("cap_1", "pausing"),
|
||||
capsuleResponse("cap_1", "resuming"),
|
||||
new Response(null, { status: 204 }),
|
||||
]);
|
||||
const capsule = new Capsule("cap_1", {
|
||||
@ -122,9 +122,9 @@ describe("Capsule", () => {
|
||||
range: "10m",
|
||||
});
|
||||
await expect(capsule.ping()).resolves.toBeUndefined();
|
||||
await expect(capsule.pause()).resolves.toMatchObject({ status: "paused" });
|
||||
await expect(capsule.pause()).resolves.toMatchObject({ status: "pausing" });
|
||||
await expect(capsule.resume()).resolves.toMatchObject({
|
||||
status: "running",
|
||||
status: "resuming",
|
||||
});
|
||||
await expect(capsule.destroy()).resolves.toBeUndefined();
|
||||
|
||||
@ -157,14 +157,19 @@ describe("Capsule", () => {
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("fails waitForReady on terminal capsule states", async () => {
|
||||
setupFetch([capsuleResponse("cap_1", "error")]);
|
||||
it.each([
|
||||
"error",
|
||||
"missing",
|
||||
"stopping",
|
||||
"stopped",
|
||||
])("fails waitForReady on terminal capsule state %s", async (status) => {
|
||||
setupFetch([capsuleResponse("cap_1", status)]);
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
await expect(capsule.waitForReady()).rejects.toThrow(
|
||||
'Capsule cap_1 reached terminal status "error"',
|
||||
`Capsule cap_1 reached terminal status "${status}"`,
|
||||
);
|
||||
});
|
||||
|
||||
@ -188,10 +193,64 @@ describe("Capsule", () => {
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("does not mutate the remote capsule when closed or disposed", async () => {
|
||||
it("resumes and waits until the capsule is running", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { calls } = setupFetch([
|
||||
capsuleResponse("cap_1", "resuming"),
|
||||
capsuleResponse("cap_1", "running"),
|
||||
]);
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
const ready = capsule.resume({
|
||||
wait: true,
|
||||
intervalMs: 100,
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await expect(ready).resolves.toMatchObject({ status: "running" });
|
||||
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
|
||||
"POST https://api.example.com/v1/capsules/cap_1/resume",
|
||||
"GET https://api.example.com/v1/capsules/cap_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts waitForReady when the signal is already aborted", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
capsule.waitForReady({ signal: controller.signal }),
|
||||
).rejects.toThrow("Operation aborted");
|
||||
});
|
||||
|
||||
it("aborts waitForReady when the signal fires during polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
setupFetch([capsuleResponse("cap_1", "starting")]);
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const ready = capsule.waitForReady({
|
||||
intervalMs: 100,
|
||||
timeoutMs: 5_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(ready).rejects.toThrow("Operation aborted");
|
||||
});
|
||||
|
||||
it("does not mutate the remote capsule when connected capsules are closed or disposed", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const capsule = new Capsule("cap_1", {
|
||||
const capsule = Capsule.connect("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
@ -201,6 +260,43 @@ describe("Capsule", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys created capsules when async disposed", async () => {
|
||||
const { calls } = setupFetch([
|
||||
capsuleResponse("cap_created"),
|
||||
new Response(null, { status: 204 }),
|
||||
]);
|
||||
|
||||
const capsule = await Capsule.create({
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
await capsule[Symbol.asyncDispose]();
|
||||
|
||||
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
|
||||
"POST https://api.example.com/v1/capsules",
|
||||
"DELETE https://api.example.com/v1/capsules/cap_created",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not destroy an already destroyed owned capsule twice", async () => {
|
||||
const { calls } = setupFetch([
|
||||
capsuleResponse("cap_created"),
|
||||
new Response(null, { status: 204 }),
|
||||
]);
|
||||
|
||||
const capsule = await Capsule.create({
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
await capsule.destroy();
|
||||
await capsule[Symbol.asyncDispose]();
|
||||
|
||||
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
|
||||
"POST https://api.example.com/v1/capsules",
|
||||
"DELETE https://api.example.com/v1/capsules/cap_created",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports Sandbox as a deprecated Capsule alias", () => {
|
||||
expect(Sandbox).toBe(Capsule);
|
||||
});
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Capsule } from "../src/capsule.js";
|
||||
import { CodeInterpreter } from "../src/code-interpreter/index.js";
|
||||
|
||||
describe("CodeInterpreter", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a capsule with the jupyter template by default", async () => {
|
||||
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
|
||||
new Capsule("cap_1", {
|
||||
@ -19,7 +23,6 @@ describe("CodeInterpreter", () => {
|
||||
expect(create).toHaveBeenCalledWith("jupyter", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
create.mockRestore();
|
||||
});
|
||||
|
||||
it("connects to an existing capsule", () => {
|
||||
@ -53,4 +56,34 @@ describe("CodeInterpreter", () => {
|
||||
timeoutSec: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stderr and non-zero exit code on failure", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||
exit_code: 1,
|
||||
stderr: "SyntaxError: invalid syntax\n",
|
||||
stdout: "",
|
||||
});
|
||||
const interpreter = new CodeInterpreter(capsule);
|
||||
|
||||
await expect(interpreter.notebook.execCell("invalid(")).resolves.toEqual({
|
||||
exitCode: 1,
|
||||
stderr: "SyntaxError: invalid syntax\n",
|
||||
stdout: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("disposes the wrapped capsule", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const dispose = vi.spyOn(capsule, Symbol.asyncDispose).mockResolvedValue();
|
||||
const interpreter = new CodeInterpreter(capsule);
|
||||
|
||||
await interpreter[Symbol.asyncDispose]();
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@ -71,6 +71,51 @@ describe("CommandManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("connects to an existing background process stream", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const mockConnection = {
|
||||
close: vi.fn(),
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
send: vi.fn(),
|
||||
};
|
||||
const connectProcess = vi
|
||||
.spyOn(capsule.client.capsules, "connectProcess")
|
||||
.mockResolvedValue(mockConnection as never);
|
||||
|
||||
const result = await capsule.commands.streamProcess("123");
|
||||
|
||||
expect(connectProcess).toHaveBeenCalledWith("cap_1", "123", {
|
||||
onMessage: expect.any(Function),
|
||||
});
|
||||
expect(result).toBe(mockConnection);
|
||||
});
|
||||
|
||||
it("passes timeout to streamProcess", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const connectProcess = vi
|
||||
.spyOn(capsule.client.capsules, "connectProcess")
|
||||
.mockResolvedValue({
|
||||
close: vi.fn(),
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
send: vi.fn(),
|
||||
} as never);
|
||||
|
||||
await capsule.commands.streamProcess("worker", { timeoutMs: 5000 });
|
||||
|
||||
expect(connectProcess).toHaveBeenCalledWith("cap_1", "worker", {
|
||||
onMessage: expect.any(Function),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("streams command events over the exec WebSocket", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
|
||||
@ -3,6 +3,7 @@ import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { AsyncQueue } from "../src/_shared/async-queue.js";
|
||||
import { HttpClient } from "../src/_shared/http.js";
|
||||
import { WsConnection } from "../src/_shared/websocket.js";
|
||||
import { resolveConfig } from "../src/config.js";
|
||||
@ -233,6 +234,57 @@ describe("HttpClient", () => {
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("downloads binary response bodies as ReadableStream", async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(body, { status: 200 })),
|
||||
);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
const stream = await client.download("/v1/file", { path: "/a.txt" });
|
||||
|
||||
expect(stream).toBeInstanceOf(ReadableStream);
|
||||
const reader = stream.getReader();
|
||||
const { value } = await reader.read();
|
||||
expect(value).toEqual(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
|
||||
it("handles content-length zero as empty response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { "content-length": "0" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
const result = await client.get("/v1/empty");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles empty text body as undefined", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("", { status: 200 })),
|
||||
);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
const result = await client.get("/v1/blank");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WsConnection", () => {
|
||||
@ -263,7 +315,8 @@ describe("WsConnection", () => {
|
||||
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
||||
expect(messages).toEqual([{ type: "ready" }]);
|
||||
|
||||
connection.close();
|
||||
await connection[Symbol.asyncDispose]();
|
||||
expect(connection.isClosed).toBe(true);
|
||||
server.close();
|
||||
});
|
||||
|
||||
@ -285,3 +338,37 @@ describe("WsConnection", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AsyncQueue", () => {
|
||||
it("rejects waiting consumers when failed", async () => {
|
||||
const queue = new AsyncQueue<string>();
|
||||
const pending = queue.next();
|
||||
|
||||
queue.fail(new Error("socket died"));
|
||||
|
||||
await expect(pending).rejects.toThrow("socket died");
|
||||
await expect(queue.next()).rejects.toThrow("socket died");
|
||||
});
|
||||
|
||||
it("ends the queue from return and resolves as done", async () => {
|
||||
const queue = new AsyncQueue<string>();
|
||||
|
||||
const result = await queue.return();
|
||||
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
await expect(queue.next()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates error through throw and rejects future reads", async () => {
|
||||
const queue = new AsyncQueue<string>();
|
||||
const error = new Error("consumer threw");
|
||||
|
||||
const result = queue.throw(error);
|
||||
|
||||
await expect(result).rejects.toThrow("consumer threw");
|
||||
await expect(queue.next()).rejects.toThrow("consumer threw");
|
||||
});
|
||||
});
|
||||
|
||||
@ -71,4 +71,36 @@ describe("Git", () => {
|
||||
"At least one file is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks out an existing branch without creating it", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||
exit_code: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
await capsule.git.checkout("main");
|
||||
|
||||
expect(exec).toHaveBeenCalledWith("git", {
|
||||
args: ["checkout", "main"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a single string file argument in add", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||
exit_code: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
await capsule.git.add("src/a.ts");
|
||||
|
||||
expect(exec).toHaveBeenCalledWith("git", {
|
||||
args: ["add", "src/a.ts"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -90,4 +90,36 @@ describeWithApiKey("Capsule live integration", () => {
|
||||
},
|
||||
waitTimeoutMs * 2 + 30_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"destroys an owned live capsule when async disposed",
|
||||
async () => {
|
||||
let capsuleId: string | undefined;
|
||||
|
||||
try {
|
||||
const capsule = await Capsule.create(template, {
|
||||
...clientOpts,
|
||||
timeout_sec: 60,
|
||||
});
|
||||
capsuleId = capsule.id;
|
||||
|
||||
await capsule.waitForReady({
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: waitTimeoutMs,
|
||||
});
|
||||
|
||||
await capsule[Symbol.asyncDispose]();
|
||||
|
||||
await expect(
|
||||
Capsule.connect(capsuleId, clientOpts).getInfo(),
|
||||
).resolves.toMatchObject({ id: capsuleId, status: "stopped" });
|
||||
capsuleId = undefined;
|
||||
} finally {
|
||||
if (capsuleId) {
|
||||
await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
waitTimeoutMs + 30_000,
|
||||
);
|
||||
});
|
||||
|
||||
@ -200,4 +200,33 @@ describeWithApiKey("Higher-level abstractions live integration", () => {
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"destroys an owned code interpreter capsule when async disposed",
|
||||
async () => {
|
||||
let capsuleId: string | undefined;
|
||||
|
||||
try {
|
||||
const interpreter = await createCodeInterpreterWithRetry();
|
||||
capsuleId = interpreter.capsule.id;
|
||||
|
||||
await interpreter.capsule.waitForReady({
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: waitTimeoutMs,
|
||||
});
|
||||
|
||||
await interpreter[Symbol.asyncDispose]();
|
||||
|
||||
await expect(
|
||||
Capsule.connect(capsuleId, clientOpts).getInfo(),
|
||||
).resolves.toMatchObject({ id: capsuleId, status: "stopped" });
|
||||
capsuleId = undefined;
|
||||
} finally {
|
||||
if (capsuleId) {
|
||||
await Capsule.destroy(capsuleId, clientOpts).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
testTimeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
@ -9,11 +9,12 @@ describe("PtyManager", () => {
|
||||
});
|
||||
const sent: unknown[] = [];
|
||||
let onMessage: ((message: unknown) => void) | undefined;
|
||||
const close = vi.fn();
|
||||
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
|
||||
async (_id, opts) => {
|
||||
onMessage = opts.onMessage;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
close,
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
@ -49,6 +50,9 @@ describe("PtyManager", () => {
|
||||
type: "output",
|
||||
},
|
||||
});
|
||||
|
||||
await session[Symbol.asyncDispose]();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("connects to an existing PTY tag", async () => {
|
||||
@ -68,4 +72,60 @@ describe("PtyManager", () => {
|
||||
|
||||
expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]);
|
||||
});
|
||||
|
||||
it("closes the connection when close() is called directly", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const close = vi.fn();
|
||||
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
|
||||
close,
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
send: vi.fn(),
|
||||
} as never);
|
||||
|
||||
const session = await capsule.pty.start();
|
||||
session.close();
|
||||
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sends all PtyStartOptions fields in the start message", async () => {
|
||||
const capsule = new Capsule("cap_1", {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
const sent: unknown[] = [];
|
||||
vi.spyOn(capsule.client.capsules, "ptySession").mockResolvedValue({
|
||||
close: vi.fn(),
|
||||
get isClosed() {
|
||||
return false;
|
||||
},
|
||||
send: (message: unknown) => sent.push(message),
|
||||
} as never);
|
||||
|
||||
await capsule.pty.start({
|
||||
cmd: "/bin/bash",
|
||||
args: ["--login"],
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
envs: { TERM: "xterm-256color" },
|
||||
cwd: "/home/user",
|
||||
user: "user",
|
||||
});
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "start",
|
||||
cmd: "/bin/bash",
|
||||
args: ["--login"],
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
envs: { TERM: "xterm-256color" },
|
||||
cwd: "/home/user",
|
||||
user: "user",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user