diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 266948ce..a9c77821 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -40,6 +40,82 @@ console.log(resp.result); await amika.deleteSandbox(sb.name); ``` +## Workflow helpers + +These methods compose the existing sandbox APIs into common patterns. They are optional — the step-by-step flow above still works the same way. + +### `createSandboxAndWait(req, wait?)` + +Creates a sandbox and polls until it is ready. Equivalent to `createSandbox()` followed by `waitForSandbox()`. + +```ts +const sandbox = await amika.createSandboxAndWait( + { + name: "dev-box", + repoUrl: "git@github.com:org/proj.git", + preset: "coder", + }, + { timeoutMs: 10 * 60_000 }, +); +``` + +### `withSandbox(req, fn, options?)` + +Creates a sandbox, waits until it is ready, runs your callback, then deletes the sandbox. Cleanup runs even if the callback throws. + +```ts +const sshDestination = await amika.withSandbox( + { + name: "dev-box", + repoUrl: "git@github.com:org/proj.git", + preset: "coder", + }, + async (sandbox) => { + const ssh = await amika.getSSH(sandbox.name); + return ssh.sshDestination; + }, +); +``` + +Keep the sandbox after the callback: + +```ts +await amika.withSandbox( + { name: "dev-box", repoUrl: "git@github.com:org/proj.git" }, + async (sandbox) => { + await amika.agentSend(sandbox.name, { + message: "Set up the project", + agent: "claude", + }); + }, + { deleteOnExit: false }, +); +``` + +### `runAgent(req, options?)` + +Creates a sandbox, sends one agent message, and deletes the sandbox when finished. + +```ts +const { result, sessionId } = await amika.runAgent({ + name: "dev-box", + repoUrl: "git@github.com:org/proj.git", + preset: "coder", + message: "Refactor the auth module", + agent: "claude", + newSession: true, +}); + +console.log(result); +console.log(sessionId); +``` + +The same helpers are also exported as standalone functions: + +```ts +import { createSandboxAndWait, withSandbox, runAgent } from "@amika/sdk"; +``` + ## Configuration ```ts @@ -90,6 +166,38 @@ Types are camelCased and translated to/from snake_case on the wire. See `src/typ `waitForSandbox`, `waitForSandboxStart`, and `waitForSandboxStop` poll `getSandbox` every **3 seconds** with **no client-side timeout**, matching Go's `WaitForSandbox`. They throw `AmikaError` if the sandbox enters `failed` state, including the server's `errorMessage` when present. +### Wait options + +Each wait method also accepts an optional second argument: + +```ts +await amika.waitForSandbox(sb.name, { + timeoutMs: 10 * 60_000, // optional client-side timeout + pollIntervalMs: 5_000, // optional, default 3_000 + signal: abortController.signal, // optional cancellation + onPoll: (sandbox) => { + console.log(`sandbox is ${sandbox.state}`); + }, +}); +``` + +The same options can be passed to workflow helpers through `wait` in `WorkflowOptions`: + +```ts +await amika.withSandbox( + { name: "dev-box", repoUrl: "git@github.com:org/proj.git" }, + async (sandbox) => { + /* ... */ + }, + { + wait: { timeoutMs: 10 * 60_000 }, + deleteOnExit: true, + }, +); +``` + +When `timeoutMs` is set, the wait methods throw `AmikaError` if the target state is not reached in time. When `signal` is aborted, they throw `AmikaError` with a cancellation message. + ## Errors ```ts diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index e08820d3..14f3a678 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -1,6 +1,18 @@ import { AmikaError, AmikaHTTPError, extractAgentAuthError } from "@/errors"; import { HTTPClient } from "@/http"; import { StaticTokenSource, type TokenSource } from "@/token"; +import { + createSandboxAndWait as createSandboxAndWaitWorkflow, + runAgent as runAgentWorkflow, + type RunAgentRequest, + type RunAgentResult, + type WorkflowOptions, + withSandbox as withSandboxWorkflow, +} from "@/workflows"; +import { + type WaitOptions, + waitForSandboxState, +} from "@/wait"; import { type AgentSendRequest, type AgentSendResponse, @@ -31,7 +43,8 @@ const API_BASE_PATH = "/api/v0beta1"; const DEFAULT_TIMEOUT_MS = 30_000; const AGENT_SEND_TIMEOUT_MS = 10 * 60 * 1000; -const WAIT_POLL_INTERVAL_MS = 3_000; + +export type { RunAgentRequest, RunAgentResult, WorkflowOptions, WaitOptions }; export interface AmikaClientOptions { baseUrl: string; @@ -91,17 +104,49 @@ export class AmikaClient { return remoteSandboxFromWire(data ?? {}); } + /** + * Create a sandbox and poll until it reaches a ready state. Combines + * {@link createSandbox} and {@link waitForSandbox}. + */ + createSandboxAndWait( + req: CreateSandboxRequest, + wait?: WaitOptions, + ): Promise { + return createSandboxAndWaitWorkflow(this, req, wait); + } + + /** + * Create a sandbox, wait until ready, run `fn`, then delete the sandbox + * (best-effort). Re-throws errors from `fn` after cleanup. + */ + withSandbox( + req: CreateSandboxRequest, + fn: (sandbox: RemoteSandbox) => Promise, + options?: WorkflowOptions, + ): Promise { + return withSandboxWorkflow(this, req, fn, options); + } + + /** + * Provision a sandbox, send one agent message, and optionally delete the + * sandbox when finished. + */ + runAgent(req: RunAgentRequest, options?: WorkflowOptions): Promise { + return runAgentWorkflow(this, req, options); + } + /** * Polls `getSandbox(name)` every 3 seconds until the sandbox reaches a * ready state (`active`, `running`, `started`) or `failed`. No client-side - * timeout — matches Go's `WaitForSandbox`. + * timeout by default — matches Go's `WaitForSandbox`. */ - waitForSandbox(name: string): Promise { + waitForSandbox(name: string, options?: WaitOptions): Promise { return waitForSandboxState( (n) => this.getSandbox(n), name, ["active", "running", "started"], "sandbox provisioning failed", + options, ); } @@ -129,12 +174,16 @@ export class AmikaClient { ); } - waitForSandboxStart(name: string): Promise { + waitForSandboxStart( + name: string, + options?: WaitOptions, + ): Promise { return waitForSandboxState( (n) => this.getSandbox(n), name, ["active", "running", "started"], "sandbox start failed", + options, ); } @@ -145,12 +194,16 @@ export class AmikaClient { ); } - waitForSandboxStop(name: string): Promise { + waitForSandboxStop( + name: string, + options?: WaitOptions, + ): Promise { return waitForSandboxState( (n) => this.getSandbox(n), name, ["stopped"], "sandbox stop failed", + options, ); } @@ -312,24 +365,3 @@ function resolveTokenSource(options: AmikaClientOptions): TokenSource { return new StaticTokenSource(options.accessToken); throw new Error("AmikaClient: accessToken or tokenSource is required"); } - -async function waitForSandboxState( - getSandbox: (name: string) => Promise, - name: string, - readyStates: readonly string[], - failMsg: string, -): Promise { - // Match Go: no client-side timeout, just poll until terminal state. - for (;;) { - const sb = await getSandbox(name); - if (sb.state === "failed") { - throw new AmikaError(sb.errorMessage || failMsg); - } - if (readyStates.includes(sb.state)) return sb; - await sleep(WAIT_POLL_INTERVAL_MS); - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index fd6b0b62..8d507e20 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,5 +1,17 @@ export { AmikaClient } from "@/client"; -export type { AmikaClientOptions } from "@/client"; +export type { + AmikaClientOptions, + RunAgentRequest, + RunAgentResult, + WaitOptions, + WorkflowOptions, +} from "@/client"; + +export { + createSandboxAndWait, + runAgent, + withSandbox, +} from "@/workflows"; export { AmikaError, AmikaHTTPError, extractAgentAuthError } from "@/errors"; diff --git a/sdk/typescript/src/wait.ts b/sdk/typescript/src/wait.ts new file mode 100644 index 00000000..900c865d --- /dev/null +++ b/sdk/typescript/src/wait.ts @@ -0,0 +1,71 @@ +import { AmikaError } from "@/errors"; +import type { RemoteSandbox } from "@/types"; + +/** Default poll interval for sandbox wait helpers (matches Go apiclient). */ +export const DEFAULT_WAIT_POLL_INTERVAL_MS = 3_000; + +/** Options for polling until a sandbox reaches a target state. */ +export interface WaitOptions { + /** Maximum time to wait before throwing. Omit for no client-side timeout. */ + timeoutMs?: number; + /** Time between poll attempts. Defaults to 3 seconds. */ + pollIntervalMs?: number; + /** When aborted, waiting stops with an AmikaError. */ + signal?: AbortSignal; + /** Called after each poll with the latest sandbox record. */ + onPoll?: (sandbox: RemoteSandbox) => void; +} + +/** + * Polls `getSandbox(name)` until the sandbox reaches one of `readyStates`, + * enters `failed`, times out, or is aborted. + */ +export async function waitForSandboxState( + getSandbox: (name: string) => Promise, + name: string, + readyStates: readonly string[], + failMsg: string, + options?: WaitOptions, +): Promise { + const pollIntervalMs = + options?.pollIntervalMs ?? DEFAULT_WAIT_POLL_INTERVAL_MS; + const deadline = + options?.timeoutMs !== undefined + ? Date.now() + options.timeoutMs + : undefined; + let lastState: string | undefined; + + for (;;) { + assertNotAborted(options?.signal, name); + + if (deadline !== undefined && Date.now() >= deadline) { + throw new AmikaError( + lastState === undefined + ? `timed out waiting for sandbox "${name}" to reach ${readyStates.join("|")}` + : `timed out waiting for sandbox "${name}" to reach ${readyStates.join("|")} (last state: ${lastState})`, + ); + } + + const sb = await getSandbox(name); + lastState = sb.state; + options?.onPoll?.(sb); + + if (sb.state === "failed") { + throw new AmikaError(sb.errorMessage || failMsg); + } + if (readyStates.includes(sb.state)) return sb; + + await sleep(pollIntervalMs); + assertNotAborted(options?.signal, name); + } +} + +function assertNotAborted(signal: AbortSignal | undefined, name: string): void { + if (signal?.aborted) { + throw new AmikaError(`waiting for sandbox "${name}" was aborted`); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/typescript/src/workflows.ts b/sdk/typescript/src/workflows.ts new file mode 100644 index 00000000..e955258a --- /dev/null +++ b/sdk/typescript/src/workflows.ts @@ -0,0 +1,93 @@ +import type { AmikaClient } from "@/client"; +import type { + AgentSendRequest, + AgentSendResponse, + CreateSandboxRequest, + RemoteSandbox, +} from "@/types"; +import type { WaitOptions } from "@/wait"; + +/** Options shared by high-level sandbox workflow helpers. */ +export interface WorkflowOptions { + wait?: WaitOptions; + /** Delete the sandbox when the workflow finishes. Default true. */ + deleteOnExit?: boolean; +} + +/** Result of {@link AmikaClient.runAgent}. */ +export interface RunAgentResult extends AgentSendResponse { + sandbox: RemoteSandbox; +} + +/** Request body for {@link AmikaClient.runAgent}. */ +export type RunAgentRequest = CreateSandboxRequest & { + message: string; + agent?: AgentSendRequest["agent"]; + newSession?: AgentSendRequest["newSession"]; + sessionId?: AgentSendRequest["sessionId"]; +}; + +/** + * Create a sandbox and poll until it is ready. Combines + * {@link AmikaClient.createSandbox} and {@link AmikaClient.waitForSandbox}. + */ +export async function createSandboxAndWait( + client: AmikaClient, + req: CreateSandboxRequest, + wait?: WaitOptions, +): Promise { + const created = await client.createSandbox(req); + return client.waitForSandbox(created.name, wait); +} + +/** + * Create a sandbox, wait until ready, run `fn`, then delete the sandbox + * (best-effort). Re-throws errors from `fn` after cleanup. + */ +export async function withSandbox( + client: AmikaClient, + req: CreateSandboxRequest, + fn: (sandbox: RemoteSandbox) => Promise, + options?: WorkflowOptions, +): Promise { + const deleteOnExit = options?.deleteOnExit ?? true; + const created = await client.createSandbox(req); + try { + const ready = await client.waitForSandbox(created.name, options?.wait); + return await fn(ready); + } finally { + if (deleteOnExit) { + try { + await client.deleteSandbox(created.name); + } catch { + // Best-effort cleanup; preserve the original error from fn. + } + } + } +} + +/** + * Provision a sandbox, send one agent message, and optionally delete the + * sandbox when finished. + */ +export async function runAgent( + client: AmikaClient, + req: RunAgentRequest, + options?: WorkflowOptions, +): Promise { + const { message, agent, newSession, sessionId, ...sandboxReq } = req; + return withSandbox( + client, + sandboxReq, + async (sandbox) => { + const resp = await client.agentSend(sandbox.name, { + message, + agent, + newSession, + sessionId, + }); + return { ...resp, sandbox }; + }, + options, + ); +} diff --git a/sdk/typescript/test/functional/helpers.ts b/sdk/typescript/test/functional/helpers.ts index bcda7957..d0e2cc3f 100644 --- a/sdk/typescript/test/functional/helpers.ts +++ b/sdk/typescript/test/functional/helpers.ts @@ -184,11 +184,11 @@ export async function provisionSandbox( client: AmikaClient, overrides: Partial = {}, ): Promise { - const created = await client.createSandbox( + const created = await client.createSandboxAndWait( buildCreateSandboxRequest(overrides), ); - // Register cleanup immediately so a failure in waitForSandbox still tears - // down the newly created sandbox. + // Register cleanup immediately so a failure after provisioning still tears + // down the sandbox. afterAll(async () => { try { await client.deleteSandbox(created.name); @@ -196,7 +196,7 @@ export async function provisionSandbox( // Already deleted by the test, or the server is unreachable; ignore. } }); - return await client.waitForSandbox(created.name); + return created; } // Most operations finish in <1s, but sandbox provisioning, stop, start, and diff --git a/sdk/typescript/test/workflows.test.ts b/sdk/typescript/test/workflows.test.ts new file mode 100644 index 00000000..e7cd89d5 --- /dev/null +++ b/sdk/typescript/test/workflows.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi } from "vitest"; + +import { AmikaClient } from "@/client"; +import { mockFetch } from "./helpers.js"; + +const BASE = "https://api.example.com"; + +function makeClient(fetchImpl: typeof fetch): AmikaClient { + return new AmikaClient({ + baseUrl: BASE, + accessToken: "tok", + fetch: fetchImpl, + }); +} + +function activeSandbox(name: string) { + return { id: "1", name, state: "active", repo_url: "" }; +} + +describe("AmikaClient.createSandboxAndWait", () => { + it("creates a sandbox and polls until ready", async () => { + vi.useFakeTimers(); + try { + const { fetch, calls } = mockFetch([ + { status: 202, body: { id: "1", name: "dev", state: "initializing" } }, + { status: 200, body: { name: "dev", state: "initializing" } }, + { status: 200, body: activeSandbox("dev") }, + ]); + const client = makeClient(fetch); + const promise = client.createSandboxAndWait({ name: "dev", repoUrl: "x" }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(3_000); + + const sb = await promise; + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${BASE}/api/v0beta1/sandboxes`); + expect(sb.state).toBe("active"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("AmikaClient.withSandbox", () => { + it("deletes the sandbox after fn resolves", async () => { + vi.useFakeTimers(); + try { + const { fetch, calls } = mockFetch([ + { status: 202, body: { id: "1", name: "dev", state: "initializing" } }, + { status: 200, body: activeSandbox("dev") }, + { status: 204, body: "" }, + ]); + const client = makeClient(fetch); + const promise = client.withSandbox({ name: "dev" }, async (sb) => { + expect(sb.state).toBe("active"); + return "done"; + }); + + await vi.advanceTimersByTimeAsync(0); + const result = await promise; + expect(result).toBe("done"); + expect(calls.at(-1)?.method).toBe("DELETE"); + expect(calls.at(-1)?.url).toBe(`${BASE}/api/v0beta1/sandboxes/dev`); + } finally { + vi.useRealTimers(); + } + }); + + it("deletes the sandbox when fn throws", async () => { + const { fetch, calls } = mockFetch([ + { status: 202, body: { id: "1", name: "dev", state: "initializing" } }, + { status: 200, body: activeSandbox("dev") }, + { status: 204, body: "" }, + ]); + const client = makeClient(fetch); + await expect( + client.withSandbox({ name: "dev" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(calls.at(-1)?.method).toBe("DELETE"); + }); + + it("skips delete when deleteOnExit is false", async () => { + vi.useFakeTimers(); + try { + const { fetch, calls } = mockFetch([ + { status: 202, body: { id: "1", name: "dev", state: "initializing" } }, + { status: 200, body: activeSandbox("dev") }, + ]); + const client = makeClient(fetch); + const promise = client.withSandbox( + { name: "dev" }, + async () => "ok", + { deleteOnExit: false }, + ); + + await vi.advanceTimersByTimeAsync(0); + await expect(promise).resolves.toBe("ok"); + expect(calls.some((c) => c.method === "DELETE")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("still deletes when waitForSandbox fails", async () => { + const { fetch, calls } = mockFetch([ + { status: 202, body: { id: "1", name: "dev", state: "initializing" } }, + { + status: 200, + body: { name: "dev", state: "failed", error_message: "no capacity" }, + }, + { status: 204, body: "" }, + ]); + const client = makeClient(fetch); + await expect( + client.withSandbox({ name: "dev" }, async () => "unused"), + ).rejects.toThrow(/no capacity/); + expect(calls.at(-1)?.method).toBe("DELETE"); + }); +}); + +describe("AmikaClient.runAgent", () => { + it("provisions a sandbox, sends a message, and deletes afterward", async () => { + vi.useFakeTimers(); + try { + const { fetch, calls } = mockFetch([ + { status: 202, body: { id: "1", name: "dev", state: "initializing" } }, + { status: 200, body: activeSandbox("dev") }, + { + status: 200, + body: { response: "ok", session_id: "s1", is_error: false }, + }, + { status: 204, body: "" }, + ]); + const client = makeClient(fetch); + const promise = client.runAgent({ + name: "dev", + repoUrl: "git@github.com:org/proj.git", + message: "hello", + agent: "claude", + newSession: true, + }); + + await vi.advanceTimersByTimeAsync(0); + const result = await promise; + expect(result.result).toBe("ok"); + expect(result.sessionId).toBe("s1"); + expect(result.sandbox.name).toBe("dev"); + expect(calls.some((c) => c.url.endsWith("/agent-send"))).toBe(true); + expect(calls.at(-1)?.method).toBe("DELETE"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("AmikaClient.waitForSandbox options", () => { + it("respects timeoutMs", async () => { + vi.useFakeTimers(); + try { + const start = new Date("2026-01-01T00:00:00.000Z"); + vi.setSystemTime(start); + + const { fetch } = mockFetch([ + { status: 200, body: { name: "dev", state: "initializing" } }, + ]); + const client = makeClient(fetch); + const promise = client.waitForSandbox("dev", { + timeoutMs: 100, + pollIntervalMs: 50, + }); + const rejection = expect(promise).rejects.toThrow( + /timed out waiting for sandbox/, + ); + + await vi.advanceTimersByTimeAsync(0); + vi.setSystemTime(new Date(start.getTime() + 101)); + await vi.advanceTimersByTimeAsync(50); + await rejection; + } finally { + vi.useRealTimers(); + } + }); + + it("calls onPoll after each poll", async () => { + vi.useFakeTimers(); + try { + const { fetch } = mockFetch([ + { status: 200, body: { name: "dev", state: "initializing" } }, + { status: 200, body: activeSandbox("dev") }, + ]); + const client = makeClient(fetch); + const states: string[] = []; + const promise = client.waitForSandbox("dev", { + pollIntervalMs: 100, + onPoll: (sb) => states.push(sb.state), + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(100); + await promise; + expect(states).toEqual(["initializing", "active"]); + } finally { + vi.useRealTimers(); + } + }); + + it("throws when the abort signal fires", async () => { + vi.useFakeTimers(); + try { + const { fetch } = mockFetch([ + { status: 200, body: { name: "dev", state: "initializing" } }, + ]); + const client = makeClient(fetch); + const controller = new AbortController(); + const promise = client.waitForSandbox("dev", { + pollIntervalMs: 1_000, + signal: controller.signal, + }); + + const rejection = expect(promise).rejects.toThrow(/was aborted/); + await vi.advanceTimersByTimeAsync(0); + controller.abort(); + await vi.advanceTimersByTimeAsync(1_000); + await rejection; + } finally { + vi.useRealTimers(); + } + }); +});