Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.26.0"
".": "1.27.0"
}
8 changes: 4 additions & 4 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 119
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai/runloop-0b7cd0c2fc193b18189cd7f44cf45ece7726b5d485fb72577f7d235266432ea0.yml
openapi_spec_hash: 78c340dbfb9d3d58b24ef318fc2a657b
config_hash: 218b8d25038e627faab98532392ee9a0
configured_endpoints: 120
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai/runloop-50686c7a06e432373c5dd7e771ab1483a6ca0531b1dd9a10fc31c0cf2833bcdf.yml
openapi_spec_hash: a554b4d3815148c094778b53bfe20b21
config_hash: 6d102da8b9fcc3d0deb42d7a707e78c6
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 1.27.0 (2026-07-24)

Full Changelog: [v1.26.0...v1.27.0](https://github.com/runloopai/api-client-ts/compare/v1.26.0...v1.27.0)

### Features

* **mux:** wait_for_eviction endpoint for flex eviction notifications ([#10325](https://github.com/runloopai/api-client-ts/issues/10325)) ([429f2b1](https://github.com/runloopai/api-client-ts/commit/429f2b1ea76284ab6006e6e819457dfeddaaf5c9))


### Bug Fixes

* **mux:** field-level merge on network policy egress updates ([#10338](https://github.com/runloopai/api-client-ts/issues/10338)) ([835c772](https://github.com/runloopai/api-client-ts/commit/835c7720715b525a371c06745dc93aac249b01ee))
* **mux:** move eviction-watch SSE route off the /{id} slot ([#10409](https://github.com/runloopai/api-client-ts/issues/10409)) ([80d82cf](https://github.com/runloopai/api-client-ts/commit/80d82cf7798ce423dff86876ff89bd752f80d0f0))


### Chores

* **stainless:** generate watch_evictions as an SSE stream ([#10404](https://github.com/runloopai/api-client-ts/issues/10404)) ([043ebbc](https://github.com/runloopai/api-client-ts/commit/043ebbc407067c3d7d620b9a00fa1937dd3bcb3b))

## 1.26.0 (2026-07-22)

Full Changelog: [v1.25.0...v1.26.0](https://github.com/runloopai/api-client-ts/compare/v1.25.0...v1.26.0)
Expand Down
2 changes: 2 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ Methods:
Types:

- <code><a href="./src/resources/devboxes/devboxes.ts">DevboxAsyncExecutionDetailView</a></code>
- <code><a href="./src/resources/devboxes/devboxes.ts">DevboxEvictionEventView</a></code>
- <code><a href="./src/resources/devboxes/devboxes.ts">DevboxExecutionDetailView</a></code>
- <code><a href="./src/resources/devboxes/devboxes.ts">DevboxKillExecutionRequest</a></code>
- <code><a href="./src/resources/devboxes/devboxes.ts">DevboxListView</a></code>
Expand Down Expand Up @@ -213,6 +214,7 @@ Methods:
- <code title="post /v1/devboxes/{id}/suspend">client.devboxes.<a href="./src/resources/devboxes/devboxes.ts">suspend</a>(id) -> DevboxView</code>
- <code title="post /v1/devboxes/{id}/upload_file">client.devboxes.<a href="./src/resources/devboxes/devboxes.ts">uploadFile</a>(id, { ...params }) -> unknown</code>
- <code title="post /v1/devboxes/{devbox_id}/executions/{execution_id}/wait_for_status">client.devboxes.<a href="./src/resources/devboxes/devboxes.ts">waitForCommand</a>(devboxId, executionId, { ...params }) -> DevboxAsyncExecutionDetailView</code>
- <code title="get /v1/devboxes/evictions/watch">client.devboxes.<a href="./src/resources/devboxes/devboxes.ts">watchEvictions</a>() -> DevboxEvictionEventView</code>
- <code title="post /v1/devboxes/{id}/write_file_contents">client.devboxes.<a href="./src/resources/devboxes/devboxes.ts">writeFileContents</a>(id, { ...params }) -> DevboxExecutionDetailView</code>

## DiskSnapshots
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@runloop/api-client",
"version": "1.26.0",
"version": "1.27.0",
"description": "The official TypeScript library for the Runloop API",
"author": "Runloop <support@runloop.ai>",
"types": "dist/sdk.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"dxt_version": "0.2",
"name": "@runloop/api-client-mcp",
"version": "1.26.0",
"version": "1.27.0",
"description": "The official MCP Server for the Runloop API",
"author": {
"name": "Runloop",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@runloop/api-client-mcp",
"version": "1.26.0",
"version": "1.27.0",
"description": "The official MCP Server for the Runloop API",
"author": "Runloop <support@runloop.ai>",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const newMcpServer = async (stainlessApiKey: string | undefined) =>
new McpServer(
{
name: 'runloop_api_client_api',
version: '1.26.0',
version: '1.27.0',
},
{
instructions: await getInstructions(stainlessApiKey),
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ import {
DevboxDeleteDiskSnapshotResponse,
DevboxDownloadFileParams,
DevboxEnableTunnelParams,
DevboxEvictionEventView,
DevboxExecuteAsyncParams,
DevboxExecuteParams,
DevboxExecuteSyncParams,
Expand Down Expand Up @@ -673,6 +674,7 @@ export declare namespace Runloop {
export {
Devboxes as Devboxes,
type DevboxAsyncExecutionDetailView as DevboxAsyncExecutionDetailView,
type DevboxEvictionEventView as DevboxEvictionEventView,
type DevboxExecutionDetailView as DevboxExecutionDetailView,
type DevboxKillExecutionRequest as DevboxKillExecutionRequest,
type DevboxListView as DevboxListView,
Expand Down
29 changes: 29 additions & 0 deletions src/resources/devboxes/devboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { APIResource } from '../../resource';
import { isRequestOptions } from '../../core';
import { APIPromise } from '../../core';
import * as Core from '../../core';
import * as Shared from '../shared';
import * as DiskSnapshotsAPI from './disk-snapshots';
Expand Down Expand Up @@ -32,6 +33,7 @@ import {
DiskSnapshotsCursorIDPage,
type DiskSnapshotsCursorIDPageParams,
} from '../../pagination';
import { Stream } from '../../streaming';
import { type Response } from '../../_shims/index';
import {
longPollUntil,
Expand Down Expand Up @@ -538,6 +540,19 @@ export class Devboxes extends APIResource {
});
}

/**
* Subscribe, via server-sent events, to pending infrastructure evictions for every
* Devbox in the account. On connect the stream emits one event per Devbox that
* currently has a pending eviction, then one event as each further eviction is
* scheduled. Best-effort and advisory: a Devbox stays running until its deadline,
* and delivery is not guaranteed.
*/
watchEvictions(options?: Core.RequestOptions): APIPromise<Stream<DevboxEvictionEventView>> {
return this._client.get('/v1/devboxes/evictions/watch', { ...options, stream: true }) as APIPromise<
Stream<DevboxEvictionEventView>
>;
}

/**
* Write UTF-8 string contents to a file at path on the Devbox. Note for large
* files (larger than 100MB), the upload_file endpoint must be used.
Expand Down Expand Up @@ -619,6 +634,19 @@ export interface DevboxAsyncExecutionDetailView {
stdout_truncated?: boolean | null;
}

export interface DevboxEvictionEventView {
/**
* The ID of the Devbox with a pending eviction.
*/
devbox_id: string;

/**
* Unix timestamp (milliseconds) after which the Devbox will be suspended. Advisory
* and best-effort.
*/
eviction_deadline_ms: number;
}

export interface DevboxExecutionDetailView {
/**
* Devbox id where command was executed.
Expand Down Expand Up @@ -1539,6 +1567,7 @@ Devboxes.Executions = Executions;
export declare namespace Devboxes {
export {
type DevboxAsyncExecutionDetailView as DevboxAsyncExecutionDetailView,
type DevboxEvictionEventView as DevboxEvictionEventView,
type DevboxExecutionDetailView as DevboxExecutionDetailView,
type DevboxKillExecutionRequest as DevboxKillExecutionRequest,
type DevboxListView as DevboxListView,
Expand Down
1 change: 1 addition & 0 deletions src/resources/devboxes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
DevboxViewsDevboxesCursorIDPage,
Devboxes,
type DevboxAsyncExecutionDetailView,
type DevboxEvictionEventView,
type DevboxExecutionDetailView,
type DevboxKillExecutionRequest,
type DevboxListView,
Expand Down
1 change: 1 addition & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export {
DevboxViewsDevboxesCursorIDPage,
Devboxes,
type DevboxAsyncExecutionDetailView,
type DevboxEvictionEventView,
type DevboxExecutionDetailView,
type DevboxKillExecutionRequest,
type DevboxListView,
Expand Down
3 changes: 2 additions & 1 deletion src/resources/network-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export class NetworkPolicies extends APIResource {
}

/**
* Update an existing NetworkPolicy. All fields are optional.
* Update an existing NetworkPolicy. All fields are optional - null fields preserve
* existing values, provided fields replace entirely.
*/
update(
id: string,
Expand Down
30 changes: 30 additions & 0 deletions src/sdk/devbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { LongPollRequestOptions, PollingOptions } from '../lib/polling';
import { Snapshot } from './snapshot';
import { Execution } from './execution';
import { ExecutionResult } from './execution-result';
import { EvictionCallback, getEvictionMonitor } from './eviction';
import { uuidv7 } from 'uuidv7';

// Re-export Execution and ExecutionResult for Devbox namespace
Expand Down Expand Up @@ -1029,6 +1030,35 @@ export class Devbox {
async keepAlive(options?: Core.RequestOptions): Promise<DevboxKeepAliveResponse> {
return this.client.devboxes.keepAlive(this._id, options);
}

/**
* Register a callback fired once if this devbox gets a pending infrastructure eviction.
*
* The first `onEvict` across any devbox on this client opens a single account-wide
* notification stream; it closes automatically once every registered devbox has been
* notified. The callback runs at most once and is invoked as
* `callback(devbox, evictionDeadlineMs)` — this devbox and the Unix millisecond
* deadline by which it will be suspended. Use it to run cleanup before suspend.
*
* @param {EvictionCallback} callback - Invoked with this devbox and its eviction deadline (ms).
*
* @example
* ```typescript
* devbox.onEvict((devbox, evictionDeadlineMs) => {
* console.log(`${devbox.id} will be suspended at ${evictionDeadlineMs}`);
* });
* ```
*/
onEvict(callback: EvictionCallback): void {
getEvictionMonitor(this.client).register(this, callback);
}

/**
* Withdraw this devbox's eviction interest registered via {@link onEvict}.
*/
cancelOnEvict(): void {
getEvictionMonitor(this.client).unregister(this._id);
}
}

/**
Expand Down
126 changes: 126 additions & 0 deletions src/sdk/eviction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Runloop } from '../index';
import { Stream } from '../streaming';
import type { DevboxEvictionEventView } from '../resources/devboxes/devboxes';
import type { Devbox } from './devbox';

/**
* Invoked once when the devbox it was registered for has a pending eviction.
*
* @param devbox - The devbox with a pending eviction.
* @param evictionDeadlineMs - Unix timestamp (ms) by which the devbox will be suspended.
*/
export type EvictionCallback = (devbox: Devbox, evictionDeadlineMs: number) => void;

/**
* Fans account-wide eviction notifications out to per-devbox callbacks.
*
* One monitor is shared by every {@link Devbox} built from the same generated
* client (see {@link getEvictionMonitor}), so all registered devboxes are served
* by a single SSE connection. The stream opens the moment the first devbox
* registers interest and closes as soon as the last interested devbox has been
* notified.
*
* Delivery contract:
* - The server replays every currently-pending eviction on connect, so a devbox
* that registers after its eviction was scheduled is still notified.
* - Notifications for devboxes not in the interest set are discarded.
* - A devbox is removed from the interest set *before* its callback runs, so the
* callback fires at most once even if the server repeats the notification.
*/
export class EvictionMonitor {
private entries = new Map<string, { devbox: Devbox; callback: EvictionCallback }>();
private stream: Stream<DevboxEvictionEventView> | null = null;
private running = false;

constructor(private client: Runloop) {}

/** Add `devbox` to the interest set, opening the stream if idle. */
register(devbox: Devbox, callback: EvictionCallback): void {
this.entries.set(devbox.id, { devbox, callback });
if (!this.running) {
this.running = true;
void this.run();
}
}

/** Drop `devboxId`; close the stream if it was the last interested devbox. */
unregister(devboxId: string): void {
this.entries.delete(devboxId);
if (this.entries.size === 0) {
this.close();
}
}

/** Clear all interest and tear down the stream. */
close(): void {
this.entries.clear();
this.stream?.controller.abort();
this.stream = null;
this.running = false;
}

private async run(): Promise<void> {
// The server force-closes the stream on purpose (leader change / slow consumer) and a
// long-lived HTTP/2 stream can drop; the client is expected to reconnect and re-read the
// snapshot, which re-delivers anything missed. So reconnect (with backoff) until no devbox is
// still interested.
const INITIAL_BACKOFF_MS = 500;
const MAX_BACKOFF_MS = 30_000;
let backoff = INITIAL_BACKOFF_MS;
try {
while (this.entries.size > 0) {
try {
// Force the SSE Accept header: the endpoint only streams for text/event-stream; the
// generated client's default (application/json) gets an empty text/plain response, so the
// feed would silently deliver nothing.
const stream = await this.client.devboxes.watchEvictions({
headers: { Accept: 'text/event-stream' },
});
this.stream = stream;
for await (const event of stream) {
this.dispatch(event);
if (this.entries.size === 0) return;
}
// Clean end: reset backoff and reconnect if still interested.
backoff = INITIAL_BACKOFF_MS;
} catch (error) {
// Aborting the stream on close surfaces as an AbortError; that is an intentional teardown.
if (error instanceof Error && error.name === 'AbortError') return;
if (this.entries.size === 0) return;
// Otherwise a routine disconnect — fall through to backoff + reconnect.
}
if (this.entries.size === 0) return;
await new Promise((resolve) => setTimeout(resolve, backoff));
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
}
} finally {
this.stream = null;
this.running = false;
}
}

private dispatch(event: DevboxEvictionEventView): void {
const entry = this.entries.get(event.devbox_id);
if (!entry) return;
// Remove before signaling so a duplicate notification for the same devbox is
// discarded and the callback fires at most once.
this.entries.delete(event.devbox_id);
try {
entry.callback(entry.devbox, event.eviction_deadline_ms);
} catch (error) {
console.error(`Error in eviction callback for devbox ${event.devbox_id}:`, error);
}
}
}

const monitors = new WeakMap<Runloop, EvictionMonitor>();

/** Return the shared {@link EvictionMonitor} for `client`, creating it once. */
export function getEvictionMonitor(client: Runloop): EvictionMonitor {
let monitor = monitors.get(client);
if (!monitor) {
monitor = new EvictionMonitor(client);
monitors.set(client, monitor);
}
return monitor;
}
1 change: 1 addition & 0 deletions src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Devbox, DevboxCmdOps, DevboxFileOps, DevboxNetOps, type ExecuteStreamingCallbacks } from './devbox';
export { EvictionMonitor, getEvictionMonitor, type EvictionCallback } from './eviction';
export { Blueprint } from './blueprint';
export { Snapshot } from './snapshot';
export { StorageObject } from './storage-object';
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '1.26.0'; // x-release-please-version
export const VERSION = '1.27.0'; // x-release-please-version
11 changes: 11 additions & 0 deletions tests/api-resources/devboxes/devboxes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,17 @@ describe('resource devboxes', () => {
});
});

test('watchEvictions', async () => {
const responsePromise = client.devboxes.watchEvictions();
const rawResponse = await responsePromise.asResponse();
expect(rawResponse).toBeInstanceOf(Response);
const response = await responsePromise;
expect(response).not.toBeInstanceOf(Response);
const dataAndResponse = await responsePromise.withResponse();
expect(dataAndResponse.data).toBe(response);
expect(dataAndResponse.response).toBe(rawResponse);
});

test('writeFileContents: only required params', async () => {
const responsePromise = client.devboxes.writeFileContents('id', {
contents: 'contents',
Expand Down
Loading
Loading