Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 58 additions & 26 deletions sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<RemoteSandbox> {
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<T>(
req: CreateSandboxRequest,
fn: (sandbox: RemoteSandbox) => Promise<T>,
options?: WorkflowOptions,
): Promise<T> {
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<RunAgentResult> {
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<RemoteSandbox> {
waitForSandbox(name: string, options?: WaitOptions): Promise<RemoteSandbox> {
return waitForSandboxState(
(n) => this.getSandbox(n),
name,
["active", "running", "started"],
"sandbox provisioning failed",
options,
);
}

Expand Down Expand Up @@ -129,12 +174,16 @@ export class AmikaClient {
);
}

waitForSandboxStart(name: string): Promise<RemoteSandbox> {
waitForSandboxStart(
name: string,
options?: WaitOptions,
): Promise<RemoteSandbox> {
return waitForSandboxState(
(n) => this.getSandbox(n),
name,
["active", "running", "started"],
"sandbox start failed",
options,
);
}

Expand All @@ -145,12 +194,16 @@ export class AmikaClient {
);
}

waitForSandboxStop(name: string): Promise<RemoteSandbox> {
waitForSandboxStop(
name: string,
options?: WaitOptions,
): Promise<RemoteSandbox> {
return waitForSandboxState(
(n) => this.getSandbox(n),
name,
["stopped"],
"sandbox stop failed",
options,
);
}

Expand Down Expand Up @@ -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<RemoteSandbox>,
name: string,
readyStates: readonly string[],
failMsg: string,
): Promise<RemoteSandbox> {
// 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
14 changes: 13 additions & 1 deletion sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
71 changes: 71 additions & 0 deletions sdk/typescript/src/wait.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteSandbox>,
name: string,
readyStates: readonly string[],
failMsg: string,
options?: WaitOptions,
): Promise<RemoteSandbox> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Loading