diff --git a/.gitignore b/.gitignore index 48e02d9..253680b 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,5 @@ dist # AI agents .opencode +# Added by code-review-graph +.code-review-graph/ diff --git a/AGENTS.md b/AGENTS.md index 8d29c26..adb8779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,3 +65,42 @@ Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`: ## Dependencies Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only. + + +## 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. diff --git a/src/capsule.ts b/src/capsule.ts new file mode 100644 index 0000000..195ddd4 --- /dev/null +++ b/src/capsule.ts @@ -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; + +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([ + "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 { + 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; + static create( + template: string, + opts?: CapsuleCreateOptions, + ): Promise; + /** Creates a capsule and returns a high-level capsule handle. */ + static async create( + templateOrOpts?: string | CapsuleCreateOptions, + opts?: CapsuleCreateOptions, + ): Promise { + 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 { + return new WrennClient(opts).capsules.destroy(id); + } + + /** Fetches the latest capsule metadata. */ + getInfo(): Promise { + return this.client.capsules.get(this.id); + } + + /** Polls capsule metadata until the capsule reaches `running`. */ + async waitForReady(opts?: WaitForReadyOptions): Promise { + 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 { + return this.client.capsules.destroy(this.id); + } + + /** Pauses this capsule and returns the updated capsule metadata. */ + pause(): Promise { + return this.client.capsules.pause(this.id); + } + + /** Resumes this capsule and optionally waits until it becomes ready. */ + async resume(opts?: CapsuleResumeOptions): Promise { + 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 { + return this.client.capsules.ping(this.id); + } + + /** Fetches resource metrics for this capsule. */ + getMetrics(opts?: CapsuleMetricsOptions): Promise { + 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 { + this.close(); + } +} + +/** @deprecated Use {@link Capsule} instead. */ +export const Sandbox = Capsule; diff --git a/src/config.ts b/src/config.ts index 300dd0d..fe2f291 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ /** 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. */ export const ENV_API_KEY = "WRENN_API_KEY"; diff --git a/src/index.ts b/src/index.ts index 372ac43..021015f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,15 @@ export type { HttpClientConfig, RequestOptions } from "./_shared/http.js"; export { HttpClient } from "./_shared/http.js"; export type { WsConnectionOpts } 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 { FileUploadInput, OperationJsonBody, diff --git a/tests/capsule.test.ts b/tests/capsule.test.ts new file mode 100644 index 0000000..47f993b --- /dev/null +++ b/tests/capsule.test.ts @@ -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); + }); +}); diff --git a/tests/foundation.test.ts b/tests/foundation.test.ts index d5965eb..cfec76a 100644 --- a/tests/foundation.test.ts +++ b/tests/foundation.test.ts @@ -31,7 +31,7 @@ describe("resolveConfig", () => { vi.stubEnv("WRENN_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", () => { diff --git a/tests/integration/capsule.integration.test.ts b/tests/integration/capsule.integration.test.ts new file mode 100644 index 0000000..39696af --- /dev/null +++ b/tests/integration/capsule.integration.test.ts @@ -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, + ); +});