feat: add high-level Capsule lifecycle API

This commit is contained in:
Tasnim Kabir Sadik
2026-05-14 22:51:01 +06:00
parent 52282618bd
commit 8fb9753fde
8 changed files with 579 additions and 2 deletions

2
.gitignore vendored
View File

@ -138,3 +138,5 @@ dist
# AI agents # AI agents
.opencode .opencode
# Added by code-review-graph
.code-review-graph/

View File

@ -65,3 +65,42 @@ Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
## Dependencies ## Dependencies
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only. Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.

228
src/capsule.ts Normal file
View File

@ -0,0 +1,228 @@
import {
type OperationJsonBody,
type OperationJsonResponse,
type OperationQueryParams,
WrennClient,
} from "./client.js";
import type { ClientConfig } from "./config.js";
import { TimeoutError, WrennError } from "./exceptions.js";
export type CapsuleInfo = OperationJsonResponse<"getCapsule", 200>;
export type CapsuleMetrics = OperationJsonResponse<"getCapsuleMetrics", 200>;
export type CapsuleMetricsOptions = OperationQueryParams<"getCapsuleMetrics">;
type CapsuleStatus = NonNullable<CapsuleInfo["status"]>;
const DEFAULT_TEMPLATE = "minimal";
const DEFAULT_VCPUS = 1;
const DEFAULT_MEMORY_MB = 512;
const DEFAULT_TIMEOUT_SEC = 0;
const DEFAULT_WAIT_TIMEOUT_MS = 60_000;
const DEFAULT_WAIT_INTERVAL_MS = 1_000;
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
"error",
"missing",
"stopped",
]);
/** Options accepted when creating a new capsule. */
export interface CapsuleCreateOptions extends ClientConfig {
/** Template name to boot. Defaults to `minimal`. */
template?: string;
/** Number of virtual CPUs. Defaults to `1`. */
vcpus?: number;
/** Memory allocation in MiB. Defaults to `512`. */
memory_mb?: number;
/** Auto-pause TTL in seconds. Defaults to `0`, meaning no auto-pause. */
timeout_sec?: number;
}
/** Options used by lifecycle operations that wait for a running capsule. */
export interface WaitForReadyOptions {
/** Maximum time to wait before failing. Defaults to 60 seconds. */
timeoutMs?: number;
/** Delay between status polls. Defaults to 1 second. */
intervalMs?: number;
/** Optional cancellation signal. */
signal?: AbortSignal;
}
export type CapsuleResumeOptions = WaitForReadyOptions & {
/** When true, wait until the resumed capsule reports `running`. */
wait?: boolean;
};
function clientConfigFrom(opts?: ClientConfig): ClientConfig | undefined {
if (!opts) return undefined;
const config: ClientConfig = {};
if (opts.baseUrl !== undefined) config.baseUrl = opts.baseUrl;
if (opts.apiKey !== undefined) config.apiKey = opts.apiKey;
if (opts.token !== undefined) config.token = opts.token;
if (opts.hostToken !== undefined) config.hostToken = opts.hostToken;
return config;
}
function assertNotAborted(signal?: AbortSignal): void {
if (!signal?.aborted) return;
throw new DOMException("Operation aborted", "AbortError");
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
assertNotAborted(signal);
return new Promise((resolve, reject) => {
const cleanup = () => signal?.removeEventListener("abort", abort);
const timeout = setTimeout(() => {
cleanup();
resolve();
}, ms);
const abort = () => {
clearTimeout(timeout);
cleanup();
reject(new DOMException("Operation aborted", "AbortError"));
};
signal?.addEventListener("abort", abort, { once: true });
});
}
/** Main user-facing handle for a Wrenn capsule. */
export class Capsule {
readonly id: string;
readonly client: WrennClient;
/**
* Wraps an existing capsule ID without fetching or creating remote resources.
*
* @param id - Existing capsule identifier.
* @param opts - Optional client configuration.
*/
constructor(id: string, opts?: ClientConfig) {
this.id = id;
this.client = new WrennClient(opts);
}
static create(opts?: CapsuleCreateOptions): Promise<Capsule>;
static create(
template: string,
opts?: CapsuleCreateOptions,
): Promise<Capsule>;
/** Creates a capsule and returns a high-level capsule handle. */
static async create(
templateOrOpts?: string | CapsuleCreateOptions,
opts?: CapsuleCreateOptions,
): Promise<Capsule> {
const template =
typeof templateOrOpts === "string"
? templateOrOpts
: (templateOrOpts?.template ?? DEFAULT_TEMPLATE);
const createOpts =
typeof templateOrOpts === "string" ? opts : templateOrOpts;
const clientConfig = clientConfigFrom(createOpts);
const client = new WrennClient(clientConfig);
const body: OperationJsonBody<"createCapsule"> = {
memory_mb: createOpts?.memory_mb ?? DEFAULT_MEMORY_MB,
template,
timeout_sec: createOpts?.timeout_sec ?? DEFAULT_TIMEOUT_SEC,
vcpus: createOpts?.vcpus ?? DEFAULT_VCPUS,
};
const capsule = await client.capsules.create(body);
if (!capsule.id) {
throw new WrennError(
500,
"Created capsule response did not include an id",
);
}
return new Capsule(capsule.id, clientConfig);
}
/** Wraps an existing capsule ID without validating it remotely. */
static connect(id: string, opts?: ClientConfig): Capsule {
return new Capsule(id, opts);
}
/** Destroys a capsule by ID without constructing an instance. */
static destroy(id: string, opts?: ClientConfig): Promise<void> {
return new WrennClient(opts).capsules.destroy(id);
}
/** Fetches the latest capsule metadata. */
getInfo(): Promise<CapsuleInfo> {
return this.client.capsules.get(this.id);
}
/** Polls capsule metadata until the capsule reaches `running`. */
async waitForReady(opts?: WaitForReadyOptions): Promise<CapsuleInfo> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
const intervalMs = opts?.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
const deadline = Date.now() + timeoutMs;
let isFirstPoll = true;
while (true) {
assertNotAborted(opts?.signal);
if (!isFirstPoll && Date.now() >= deadline) {
throw new TimeoutError(
`Timed out waiting for capsule ${this.id} to become running`,
);
}
const requestOpts = opts?.signal ? { signal: opts.signal } : undefined;
const capsule = await this.client.capsules.get(this.id, requestOpts);
isFirstPoll = false;
if (capsule.status === "running") return capsule;
if (capsule.status && TERMINAL_STATUSES.has(capsule.status)) {
throw new WrennError(
409,
`Capsule ${this.id} reached terminal status "${capsule.status}"`,
);
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new TimeoutError(
`Timed out waiting for capsule ${this.id} to become running`,
);
}
await delay(Math.min(intervalMs, remainingMs), opts?.signal);
}
}
/** Destroys this capsule. */
destroy(): Promise<void> {
return this.client.capsules.destroy(this.id);
}
/** Pauses this capsule and returns the updated capsule metadata. */
pause(): Promise<CapsuleInfo> {
return this.client.capsules.pause(this.id);
}
/** Resumes this capsule and optionally waits until it becomes ready. */
async resume(opts?: CapsuleResumeOptions): Promise<CapsuleInfo> {
const capsule = await this.client.capsules.resume(this.id);
if (!opts?.wait) return capsule;
return this.waitForReady(opts);
}
/** Resets this capsule's inactivity timer. */
ping(): Promise<void> {
return this.client.capsules.ping(this.id);
}
/** Fetches resource metrics for this capsule. */
getMetrics(opts?: CapsuleMetricsOptions): Promise<CapsuleMetrics> {
return this.client.capsules.metrics(this.id, opts);
}
/** 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. */
async [Symbol.asyncDispose](): Promise<void> {
this.close();
}
}
/** @deprecated Use {@link Capsule} instead. */
export const Sandbox = Capsule;

View File

@ -1,5 +1,5 @@
/** Default Wrenn API origin used when no base URL is supplied. */ /** Default Wrenn API origin used when no base URL is supplied. */
export const DEFAULT_BASE_URL = "https://api.wrenn.dev"; export const DEFAULT_BASE_URL = "https://app.wrenn.dev/api";
/** Environment variable used for API-key authentication. */ /** Environment variable used for API-key authentication. */
export const ENV_API_KEY = "WRENN_API_KEY"; export const ENV_API_KEY = "WRENN_API_KEY";

View File

@ -2,6 +2,15 @@ export type { HttpClientConfig, RequestOptions } from "./_shared/http.js";
export { HttpClient } from "./_shared/http.js"; export { HttpClient } from "./_shared/http.js";
export type { WsConnectionOpts } from "./_shared/websocket.js"; export type { WsConnectionOpts } from "./_shared/websocket.js";
export { WsConnection } from "./_shared/websocket.js"; export { WsConnection } from "./_shared/websocket.js";
export type {
CapsuleCreateOptions,
CapsuleInfo,
CapsuleMetrics,
CapsuleMetricsOptions,
CapsuleResumeOptions,
WaitForReadyOptions,
} from "./capsule.js";
export { Capsule, Sandbox } from "./capsule.js";
export type { export type {
FileUploadInput, FileUploadInput,
OperationJsonBody, OperationJsonBody,

207
tests/capsule.test.ts Normal file
View File

@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Capsule, Sandbox } from "../src/capsule.js";
interface CapturedRequest {
url: string;
init: RequestInit;
}
function setupFetch(responses: Response[]) {
const calls: CapturedRequest[] = [];
const fetchMock = vi.fn(
async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
const response = responses.shift();
if (!response) throw new Error("No mock response configured");
return response;
},
);
vi.stubGlobal("fetch", fetchMock);
return { calls, fetchMock };
}
function capsuleResponse(id: string, status = "running") {
return Response.json({
id,
memory_mb: 512,
status,
template: "minimal",
timeout_sec: 0,
vcpus: 1,
});
}
describe("Capsule", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
it("wraps an existing capsule without fetching it", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = Capsule.connect("cap_1", {
apiKey: "api-key",
baseUrl: "https://api.example.com",
});
expect(capsule.id).toBe("cap_1");
expect(fetchMock).not.toHaveBeenCalled();
expect(capsule).toBeInstanceOf(Capsule);
});
it("creates a capsule from defaulted options", async () => {
const { calls } = setupFetch([capsuleResponse("cap_created")]);
const capsule = await Capsule.create({
apiKey: "api-key",
baseUrl: "https://api.example.com/",
});
expect(capsule.id).toBe("cap_created");
expect(calls.at(-1)?.url).toBe("https://api.example.com/v1/capsules");
expect(calls.at(-1)?.init.method).toBe("POST");
expect(calls.at(-1)?.init.body).toBe(
JSON.stringify({
memory_mb: 512,
template: "minimal",
timeout_sec: 0,
vcpus: 1,
}),
);
});
it("creates a capsule with a template overload and explicit resources", async () => {
const { calls } = setupFetch([capsuleResponse("cap_custom")]);
const capsule = await Capsule.create("node", {
apiKey: "api-key",
baseUrl: "https://api.example.com",
memory_mb: 1024,
timeout_sec: 60,
vcpus: 2,
});
expect(capsule.id).toBe("cap_custom");
expect(calls.at(-1)?.init.body).toBe(
JSON.stringify({
memory_mb: 1024,
template: "node",
timeout_sec: 60,
vcpus: 2,
}),
);
});
it("throws when create does not return an id", async () => {
setupFetch([Response.json({ status: "running" })]);
await expect(
Capsule.create({ baseUrl: "https://api.example.com" }),
).rejects.toThrow("Created capsule response did not include an id");
});
it("maps instance lifecycle methods to the low-level client", async () => {
const { calls } = setupFetch([
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"),
new Response(null, { status: 204 }),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.getInfo()).resolves.toMatchObject({ id: "cap_1" });
await expect(capsule.getMetrics({ range: "10m" })).resolves.toMatchObject({
range: "10m",
});
await expect(capsule.ping()).resolves.toBeUndefined();
await expect(capsule.pause()).resolves.toMatchObject({ status: "paused" });
await expect(capsule.resume()).resolves.toMatchObject({
status: "running",
});
await expect(capsule.destroy()).resolves.toBeUndefined();
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
"GET https://api.example.com/v1/capsules/cap_1",
"GET https://api.example.com/v1/capsules/cap_1/metrics?range=10m",
"POST https://api.example.com/v1/capsules/cap_1/ping",
"POST https://api.example.com/v1/capsules/cap_1/pause",
"POST https://api.example.com/v1/capsules/cap_1/resume",
"DELETE https://api.example.com/v1/capsules/cap_1",
]);
});
it("waits until the capsule is running", async () => {
vi.useFakeTimers();
const { calls } = setupFetch([
capsuleResponse("cap_1", "pending"),
capsuleResponse("cap_1", "starting"),
capsuleResponse("cap_1", "running"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 1_000 });
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(100);
await expect(ready).resolves.toMatchObject({ status: "running" });
expect(calls).toHaveLength(3);
});
it("fails waitForReady on terminal capsule states", async () => {
setupFetch([capsuleResponse("cap_1", "error")]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
await expect(capsule.waitForReady()).rejects.toThrow(
'Capsule cap_1 reached terminal status "error"',
);
});
it("times out while waiting for readiness", async () => {
vi.useFakeTimers();
setupFetch([
capsuleResponse("cap_1", "starting"),
capsuleResponse("cap_1", "starting"),
]);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 150 });
const assertion = expect(ready).rejects.toThrow(
"Timed out waiting for capsule cap_1 to become running",
);
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(50);
await assertion;
});
it("does not mutate the remote capsule when closed or disposed", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const capsule = new Capsule("cap_1", {
baseUrl: "https://api.example.com",
});
capsule.close();
await capsule[Symbol.asyncDispose]();
expect(fetchMock).not.toHaveBeenCalled();
});
it("exports Sandbox as a deprecated Capsule alias", () => {
expect(Sandbox).toBe(Capsule);
});
});

View File

@ -31,7 +31,7 @@ describe("resolveConfig", () => {
vi.stubEnv("WRENN_TOKEN", undefined); vi.stubEnv("WRENN_TOKEN", undefined);
vi.stubEnv("WRENN_HOST_TOKEN", undefined); vi.stubEnv("WRENN_HOST_TOKEN", undefined);
expect(resolveConfig()).toEqual({ baseUrl: "https://api.wrenn.dev" }); expect(resolveConfig()).toEqual({ baseUrl: "https://app.wrenn.dev/api" });
}); });
it("prefers explicit options over environment variables", () => { it("prefers explicit options over environment variables", () => {

View File

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
import { DEFAULT_BASE_URL } from "../../src/config.js";
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
const apiKey = process.env.WRENN_API_KEY;
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
const describeWithApiKey = apiKey ? describe : describe.skip;
const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions;
describeWithApiKey("Capsule live integration", () => {
it(
"creates, waits for, inspects, pings, and destroys a live capsule",
async () => {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
expect(capsule.id).toBeTypeOf("string");
expect(capsule.id.length).toBeGreaterThan(0);
const ready = await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
expect(ready).toMatchObject({ id: capsule.id, status: "running" });
const connected = Capsule.connect(capsule.id, clientOpts);
const info = await connected.getInfo();
expect(info.id).toBe(capsule.id);
await expect(connected.ping()).resolves.toBeUndefined();
const metrics = await connected.getMetrics({ range: "10m" });
expect(metrics).toBeTypeOf("object");
await connected.close();
await connected[Symbol.asyncDispose]();
await Capsule.destroy(capsule.id, clientOpts);
capsule = undefined;
} finally {
if (capsule) {
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs + 30_000,
);
it(
"pauses and resumes a live capsule through high-level methods",
async () => {
let capsule: Capsule | undefined;
try {
capsule = await Capsule.create(template, {
...clientOpts,
timeout_sec: 60,
});
await capsule.waitForReady({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
});
const paused = await capsule.pause();
expect(paused).toMatchObject({ id: capsule.id });
expect(paused.status).toBe("paused");
const resumed = await capsule.resume({
intervalMs: 2_000,
timeoutMs: waitTimeoutMs,
wait: true,
});
expect(resumed).toMatchObject({ id: capsule.id, status: "running" });
} finally {
if (capsule) {
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
}
}
},
waitTimeoutMs * 2 + 30_000,
);
});