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
|
# Makefile
|
||||||
.PHONY: generate lint test test-integration check build
|
.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"
|
SPEC_PATH = "api/openapi.yaml"
|
||||||
|
|
||||||
generate:
|
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"
|
openapi: "3.1.0"
|
||||||
info:
|
info:
|
||||||
title: Wrenn API
|
title: Wrenn API
|
||||||
description: MicroVM-based code execution platform API.
|
description: AI agent execution platform API.
|
||||||
version: "0.1.4"
|
version: "0.2.0"
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8080
|
- url: http://localhost:8080
|
||||||
@ -866,8 +866,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateCapsuleRequest"
|
$ref: "#/components/schemas/CreateCapsuleRequest"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"202":
|
||||||
description: Capsule created
|
description: Capsule creation initiated (status will be "starting")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -988,8 +988,8 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"202":
|
||||||
description: Capsule destroyed
|
description: Capsule destruction initiated
|
||||||
|
|
||||||
/v1/capsules/{id}/exec:
|
/v1/capsules/{id}/exec:
|
||||||
parameters:
|
parameters:
|
||||||
@ -1260,8 +1260,8 @@ paths:
|
|||||||
destroys all running resources. The capsule exists only as files on
|
destroys all running resources. The capsule exists only as files on
|
||||||
disk and can be resumed later.
|
disk and can be resumed later.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Capsule paused (snapshot taken, resources released)
|
description: Capsule pause initiated (status will be "pausing")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -1289,11 +1289,11 @@ paths:
|
|||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
description: |
|
description: |
|
||||||
Restores a paused capsule from its snapshot using UFFD for lazy
|
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.
|
network slot, and waits for envd to become ready.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Capsule resumed (new VM booted from snapshot)
|
description: Capsule resume initiated (status will be "resuming")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -2035,6 +2035,51 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$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:
|
/v1/hosts/auth/refresh:
|
||||||
post:
|
post:
|
||||||
summary: Refresh host JWT
|
summary: Refresh host JWT
|
||||||
@ -2395,6 +2440,14 @@ paths:
|
|||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Invalid request parameters
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
apiKeyAuth:
|
apiKeyAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@ -2592,7 +2645,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
|
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
|
||||||
template:
|
template:
|
||||||
type: string
|
type: string
|
||||||
vcpus:
|
vcpus:
|
||||||
@ -3059,7 +3112,7 @@ components:
|
|||||||
mem_bytes:
|
mem_bytes:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: "Resident memory in bytes (VmRSS of Firecracker process)"
|
description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||||
disk_bytes:
|
disk_bytes:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|||||||
@ -225,19 +225,22 @@ export class HttpClient {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await this.rawRequest(method, path, body, opts);
|
const res = await this.rawRequest(method, path, body, opts);
|
||||||
|
|
||||||
if (res.status === 204) {
|
|
||||||
return undefined as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
await throwErrorFromResponse(res);
|
await throwErrorFromResponse(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
if (opts?.asText) {
|
if (opts?.asText) {
|
||||||
return (await res.text()) as T;
|
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(
|
private async rawRequest(
|
||||||
|
|||||||
@ -46,6 +46,11 @@ export class WsConnection {
|
|||||||
this.ws.close();
|
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. */
|
/** Indicates whether the connection has closed or failed. */
|
||||||
get isClosed(): boolean {
|
get isClosed(): boolean {
|
||||||
return this.closed;
|
return this.closed;
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const DEFAULT_WAIT_INTERVAL_MS = 1_000;
|
|||||||
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
|
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
|
||||||
"error",
|
"error",
|
||||||
"missing",
|
"missing",
|
||||||
|
"stopping",
|
||||||
"stopped",
|
"stopped",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -93,6 +94,9 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
|
|
||||||
/** Main user-facing handle for a Wrenn capsule. */
|
/** Main user-facing handle for a Wrenn capsule. */
|
||||||
export class Capsule {
|
export class Capsule {
|
||||||
|
private disposed = false;
|
||||||
|
private ownsRemote = false;
|
||||||
|
|
||||||
/** Capsule identifier used for all instance operations. */
|
/** Capsule identifier used for all instance operations. */
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
/** Low-level client backing this capsule handle. */
|
/** 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.
|
* @returns Resolves when the capsule is destroyed.
|
||||||
* @throws WrennError subclasses for unsuccessful API responses.
|
* @throws WrennError subclasses for unsuccessful API responses.
|
||||||
*/
|
*/
|
||||||
destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
return this.client.capsules.destroy(this.id);
|
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. */
|
/** Local cleanup hook. This does not mutate or destroy the remote capsule. */
|
||||||
close(): void {}
|
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> {
|
async [Symbol.asyncDispose](): Promise<void> {
|
||||||
|
if (this.ownsRemote) {
|
||||||
|
await this.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markOwned(): this {
|
||||||
|
this.ownsRemote = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use {@link Capsule} instead. */
|
/** @deprecated Use {@link Capsule} instead. */
|
||||||
|
|||||||
@ -589,7 +589,7 @@ export class CapsulesResource extends BaseResource {
|
|||||||
create(
|
create(
|
||||||
body: JsonBody<"createCapsule">,
|
body: JsonBody<"createCapsule">,
|
||||||
opts?: RequestOptions,
|
opts?: RequestOptions,
|
||||||
): Promise<JsonResponse<"createCapsule", 201>> {
|
): Promise<JsonResponse<"createCapsule", 202>> {
|
||||||
return this.http.post("/v1/capsules", body, opts);
|
return this.http.post("/v1/capsules", body, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -779,7 +779,7 @@ export class CapsulesResource extends BaseResource {
|
|||||||
pause(
|
pause(
|
||||||
id: string,
|
id: string,
|
||||||
opts?: RequestOptions,
|
opts?: RequestOptions,
|
||||||
): Promise<JsonResponse<"pauseCapsule", 200>> {
|
): Promise<JsonResponse<"pauseCapsule", 202>> {
|
||||||
return this.http.post(
|
return this.http.post(
|
||||||
`/v1/capsules/${encodePath(id)}/pause`,
|
`/v1/capsules/${encodePath(id)}/pause`,
|
||||||
undefined,
|
undefined,
|
||||||
@ -798,7 +798,7 @@ export class CapsulesResource extends BaseResource {
|
|||||||
resume(
|
resume(
|
||||||
id: string,
|
id: string,
|
||||||
opts?: RequestOptions,
|
opts?: RequestOptions,
|
||||||
): Promise<JsonResponse<"resumeCapsule", 200>> {
|
): Promise<JsonResponse<"resumeCapsule", 202>> {
|
||||||
return this.http.post(
|
return this.http.post(
|
||||||
`/v1/capsules/${encodePath(id)}/resume`,
|
`/v1/capsules/${encodePath(id)}/resume`,
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@ -87,4 +87,15 @@ export class CodeInterpreter {
|
|||||||
static connect(id: string, opts?: ClientConfig): CodeInterpreter {
|
static connect(id: string, opts?: ClientConfig): CodeInterpreter {
|
||||||
return new CodeInterpreter(Capsule.connect(id, opts));
|
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
|
* Resume a paused capsule
|
||||||
* @description Restores a paused capsule from its snapshot using UFFD for lazy
|
* @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.
|
* network slot, and waits for envd to become ready.
|
||||||
*/
|
*/
|
||||||
post: operations["resumeCapsule"];
|
post: operations["resumeCapsule"];
|
||||||
@ -1146,6 +1146,28 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/hosts/auth/refresh": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1312,6 +1334,29 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@ -1408,7 +1453,7 @@ export interface components {
|
|||||||
Capsule: {
|
Capsule: {
|
||||||
id?: string;
|
id?: string;
|
||||||
/** @enum {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;
|
template?: string;
|
||||||
vcpus?: number;
|
vcpus?: number;
|
||||||
memory_mb?: number;
|
memory_mb?: number;
|
||||||
@ -1667,7 +1712,7 @@ export interface components {
|
|||||||
cpu_pct?: number;
|
cpu_pct?: number;
|
||||||
/**
|
/**
|
||||||
* Format: int64
|
* Format: int64
|
||||||
* @description Resident memory in bytes (VmRSS of Firecracker process)
|
* @description Resident memory in bytes (VmRSS of Cloud Hypervisor process)
|
||||||
*/
|
*/
|
||||||
mem_bytes?: number;
|
mem_bytes?: number;
|
||||||
/**
|
/**
|
||||||
@ -1743,7 +1788,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Invalid request */
|
/** @description Invalid request parameters */
|
||||||
BadRequest: {
|
BadRequest: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -2746,8 +2791,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule created */
|
/** @description Capsule creation initiated (status will be "starting") */
|
||||||
201: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -2858,8 +2903,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule destroyed */
|
/** @description Capsule destruction initiated */
|
||||||
204: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -3117,8 +3162,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule paused (snapshot taken, resources released) */
|
/** @description Capsule pause initiated (status will be "pausing") */
|
||||||
200: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
@ -3148,8 +3193,8 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Capsule resumed (new VM booted from snapshot) */
|
/** @description Capsule resume initiated (status will be "resuming") */
|
||||||
200: {
|
202: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[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: {
|
refreshHostToken: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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 {
|
close(): void {
|
||||||
this.connection.close();
|
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. */
|
/** Interactive terminal API bound to one capsule. */
|
||||||
|
|||||||
@ -109,8 +109,8 @@ describe("Capsule", () => {
|
|||||||
capsuleResponse("cap_1"),
|
capsuleResponse("cap_1"),
|
||||||
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
|
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
|
||||||
new Response(null, { status: 204 }),
|
new Response(null, { status: 204 }),
|
||||||
capsuleResponse("cap_1", "paused"),
|
capsuleResponse("cap_1", "pausing"),
|
||||||
capsuleResponse("cap_1", "running"),
|
capsuleResponse("cap_1", "resuming"),
|
||||||
new Response(null, { status: 204 }),
|
new Response(null, { status: 204 }),
|
||||||
]);
|
]);
|
||||||
const capsule = new Capsule("cap_1", {
|
const capsule = new Capsule("cap_1", {
|
||||||
@ -122,9 +122,9 @@ describe("Capsule", () => {
|
|||||||
range: "10m",
|
range: "10m",
|
||||||
});
|
});
|
||||||
await expect(capsule.ping()).resolves.toBeUndefined();
|
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({
|
await expect(capsule.resume()).resolves.toMatchObject({
|
||||||
status: "running",
|
status: "resuming",
|
||||||
});
|
});
|
||||||
await expect(capsule.destroy()).resolves.toBeUndefined();
|
await expect(capsule.destroy()).resolves.toBeUndefined();
|
||||||
|
|
||||||
@ -157,14 +157,19 @@ describe("Capsule", () => {
|
|||||||
expect(calls).toHaveLength(3);
|
expect(calls).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails waitForReady on terminal capsule states", async () => {
|
it.each([
|
||||||
setupFetch([capsuleResponse("cap_1", "error")]);
|
"error",
|
||||||
|
"missing",
|
||||||
|
"stopping",
|
||||||
|
"stopped",
|
||||||
|
])("fails waitForReady on terminal capsule state %s", async (status) => {
|
||||||
|
setupFetch([capsuleResponse("cap_1", status)]);
|
||||||
const capsule = new Capsule("cap_1", {
|
const capsule = new Capsule("cap_1", {
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(capsule.waitForReady()).rejects.toThrow(
|
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;
|
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();
|
const fetchMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
const capsule = new Capsule("cap_1", {
|
const capsule = Capsule.connect("cap_1", {
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -201,6 +260,43 @@ describe("Capsule", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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", () => {
|
it("exports Sandbox as a deprecated Capsule alias", () => {
|
||||||
expect(Sandbox).toBe(Capsule);
|
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 { Capsule } from "../src/capsule.js";
|
||||||
import { CodeInterpreter } from "../src/code-interpreter/index.js";
|
import { CodeInterpreter } from "../src/code-interpreter/index.js";
|
||||||
|
|
||||||
describe("CodeInterpreter", () => {
|
describe("CodeInterpreter", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a capsule with the jupyter template by default", async () => {
|
it("creates a capsule with the jupyter template by default", async () => {
|
||||||
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
|
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
|
||||||
new Capsule("cap_1", {
|
new Capsule("cap_1", {
|
||||||
@ -19,7 +23,6 @@ describe("CodeInterpreter", () => {
|
|||||||
expect(create).toHaveBeenCalledWith("jupyter", {
|
expect(create).toHaveBeenCalledWith("jupyter", {
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
});
|
});
|
||||||
create.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("connects to an existing capsule", () => {
|
it("connects to an existing capsule", () => {
|
||||||
@ -53,4 +56,34 @@ describe("CodeInterpreter", () => {
|
|||||||
timeoutSec: 30,
|
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 () => {
|
it("streams command events over the exec WebSocket", async () => {
|
||||||
const capsule = new Capsule("cap_1", {
|
const capsule = new Capsule("cap_1", {
|
||||||
baseUrl: "https://api.example.com",
|
baseUrl: "https://api.example.com",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { AddressInfo } from "node:net";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
|
||||||
|
import { AsyncQueue } from "../src/_shared/async-queue.js";
|
||||||
import { HttpClient } from "../src/_shared/http.js";
|
import { HttpClient } from "../src/_shared/http.js";
|
||||||
import { WsConnection } from "../src/_shared/websocket.js";
|
import { WsConnection } from "../src/_shared/websocket.js";
|
||||||
import { resolveConfig } from "../src/config.js";
|
import { resolveConfig } from "../src/config.js";
|
||||||
@ -233,6 +234,57 @@ describe("HttpClient", () => {
|
|||||||
|
|
||||||
await assertion;
|
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", () => {
|
describe("WsConnection", () => {
|
||||||
@ -263,7 +315,8 @@ describe("WsConnection", () => {
|
|||||||
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
||||||
expect(messages).toEqual([{ type: "ready" }]);
|
expect(messages).toEqual([{ type: "ready" }]);
|
||||||
|
|
||||||
connection.close();
|
await connection[Symbol.asyncDispose]();
|
||||||
|
expect(connection.isClosed).toBe(true);
|
||||||
server.close();
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,3 +338,37 @@ describe("WsConnection", () => {
|
|||||||
vi.useRealTimers();
|
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",
|
"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,
|
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,
|
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[] = [];
|
const sent: unknown[] = [];
|
||||||
let onMessage: ((message: unknown) => void) | undefined;
|
let onMessage: ((message: unknown) => void) | undefined;
|
||||||
|
const close = vi.fn();
|
||||||
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
|
vi.spyOn(capsule.client.capsules, "ptySession").mockImplementation(
|
||||||
async (_id, opts) => {
|
async (_id, opts) => {
|
||||||
onMessage = opts.onMessage;
|
onMessage = opts.onMessage;
|
||||||
return {
|
return {
|
||||||
close: vi.fn(),
|
close,
|
||||||
get isClosed() {
|
get isClosed() {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@ -49,6 +50,9 @@ describe("PtyManager", () => {
|
|||||||
type: "output",
|
type: "output",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await session[Symbol.asyncDispose]();
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("connects to an existing PTY tag", async () => {
|
it("connects to an existing PTY tag", async () => {
|
||||||
@ -68,4 +72,60 @@ describe("PtyManager", () => {
|
|||||||
|
|
||||||
expect(sent).toEqual([{ tag: "pty-tag", type: "connect" }]);
|
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