diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f97891a67..c775f946f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.26.0" + ".": "1.27.0" } diff --git a/.stats.yml b/.stats.yml index 4e9c9d433..8b4084be5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4daafd6b0..802696f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/api.md b/api.md index 38fb50a45..3af4951ad 100644 --- a/api.md +++ b/api.md @@ -169,6 +169,7 @@ Methods: Types: - DevboxAsyncExecutionDetailView +- DevboxEvictionEventView - DevboxExecutionDetailView - DevboxKillExecutionRequest - DevboxListView @@ -213,6 +214,7 @@ Methods: - client.devboxes.suspend(id) -> DevboxView - client.devboxes.uploadFile(id, { ...params }) -> unknown - client.devboxes.waitForCommand(devboxId, executionId, { ...params }) -> DevboxAsyncExecutionDetailView +- client.devboxes.watchEvictions() -> DevboxEvictionEventView - client.devboxes.writeFileContents(id, { ...params }) -> DevboxExecutionDetailView ## DiskSnapshots diff --git a/package.json b/package.json index f69fc5911..34552f541 100644 --- a/package.json +++ b/package.json @@ -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 ", "types": "dist/sdk.d.ts", diff --git a/packages/mcp-server/manifest.json b/packages/mcp-server/manifest.json index f5194782b..478bd1dfb 100644 --- a/packages/mcp-server/manifest.json +++ b/packages/mcp-server/manifest.json @@ -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", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 532a609fe..a759828a0 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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 ", "types": "dist/index.d.ts", diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index c494e9f2d..099bbe38f 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -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), diff --git a/src/index.ts b/src/index.ts index 0aeaca948..b4ac078fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -196,6 +196,7 @@ import { DevboxDeleteDiskSnapshotResponse, DevboxDownloadFileParams, DevboxEnableTunnelParams, + DevboxEvictionEventView, DevboxExecuteAsyncParams, DevboxExecuteParams, DevboxExecuteSyncParams, @@ -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, diff --git a/src/resources/devboxes/devboxes.ts b/src/resources/devboxes/devboxes.ts index d413e7ff2..a009f2beb 100644 --- a/src/resources/devboxes/devboxes.ts +++ b/src/resources/devboxes/devboxes.ts @@ -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'; @@ -32,6 +33,7 @@ import { DiskSnapshotsCursorIDPage, type DiskSnapshotsCursorIDPageParams, } from '../../pagination'; +import { Stream } from '../../streaming'; import { type Response } from '../../_shims/index'; import { longPollUntil, @@ -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> { + return this._client.get('/v1/devboxes/evictions/watch', { ...options, stream: true }) as APIPromise< + Stream + >; + } + /** * 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. @@ -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. @@ -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, diff --git a/src/resources/devboxes/index.ts b/src/resources/devboxes/index.ts index 016a2b2e6..f52683f68 100644 --- a/src/resources/devboxes/index.ts +++ b/src/resources/devboxes/index.ts @@ -5,6 +5,7 @@ export { DevboxViewsDevboxesCursorIDPage, Devboxes, type DevboxAsyncExecutionDetailView, + type DevboxEvictionEventView, type DevboxExecutionDetailView, type DevboxKillExecutionRequest, type DevboxListView, diff --git a/src/resources/index.ts b/src/resources/index.ts index 5e4e2439d..fe0f43691 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -86,6 +86,7 @@ export { DevboxViewsDevboxesCursorIDPage, Devboxes, type DevboxAsyncExecutionDetailView, + type DevboxEvictionEventView, type DevboxExecutionDetailView, type DevboxKillExecutionRequest, type DevboxListView, diff --git a/src/resources/network-policies.ts b/src/resources/network-policies.ts index fdf8ad8ae..5ffff2f5c 100644 --- a/src/resources/network-policies.ts +++ b/src/resources/network-policies.ts @@ -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, diff --git a/src/sdk/devbox.ts b/src/sdk/devbox.ts index e34a938d3..6d8627f58 100644 --- a/src/sdk/devbox.ts +++ b/src/sdk/devbox.ts @@ -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 @@ -1029,6 +1030,35 @@ export class Devbox { async keepAlive(options?: Core.RequestOptions): Promise { 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); + } } /** diff --git a/src/sdk/eviction.ts b/src/sdk/eviction.ts new file mode 100644 index 000000000..60eb93017 --- /dev/null +++ b/src/sdk/eviction.ts @@ -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(); + private stream: Stream | 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 { + // 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(); + +/** 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; +} diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 088063279..4c8da4af8 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -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'; diff --git a/src/version.ts b/src/version.ts index a58c5e122..f7cc16593 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.26.0'; // x-release-please-version +export const VERSION = '1.27.0'; // x-release-please-version diff --git a/tests/api-resources/devboxes/devboxes.test.ts b/tests/api-resources/devboxes/devboxes.test.ts index fdd846d80..9f547d3bf 100644 --- a/tests/api-resources/devboxes/devboxes.test.ts +++ b/tests/api-resources/devboxes/devboxes.test.ts @@ -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', diff --git a/tests/smoketests/object-oriented/eviction.test.ts b/tests/smoketests/object-oriented/eviction.test.ts new file mode 100644 index 000000000..6cae58f2e --- /dev/null +++ b/tests/smoketests/object-oriented/eviction.test.ts @@ -0,0 +1,105 @@ +import { Devbox } from '@runloop/api-client/sdk'; +import type { DevboxEvictionEventView } from '@runloop/api-client/resources/devboxes/devboxes'; + +// Hermetic tests: they drive the shared EvictionMonitor through an in-memory fake +// SSE stream instead of a live devbox eviction, so `devbox.onEvict` / `cancelOnEvict` +// (and the monitor's dispatch + teardown) are exercised deterministically. + +// The generated watch_evictions stream: an async-iterable of eviction events plus an +// AbortController the monitor aborts on close(). +type FakeStream = { + controller: AbortController; + [Symbol.asyncIterator](): AsyncIterator; +}; + +function streamOf(events: DevboxEvictionEventView[]): FakeStream { + return { + controller: new AbortController(), + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event; + } + }, + }; +} + +// A stream that emits nothing until the monitor aborts it (i.e. on cancelOnEvict). +function blockingStream(): FakeStream { + const controller = new AbortController(); + return { + controller, + async *[Symbol.asyncIterator]() { + await new Promise((resolve) => { + if (controller.signal.aborted) { + resolve(); + } else { + controller.signal.addEventListener('abort', () => resolve(), { once: true }); + } + }); + }, + }; +} + +// Type the fake as whatever Devbox.fromId expects, without importing the generated client. +type Client = Parameters[0]; +function fakeClient(watchEvictions: () => Promise): Client { + return { devboxes: { watchEvictions } } as unknown as Client; +} + +const tick = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms)); + +(process.env['RUN_SMOKETESTS'] ? describe : describe.skip)('object-oriented eviction notifications', () => { + test('onEvict fires once for a matching devbox and ignores others', async () => { + const events: DevboxEvictionEventView[] = [ + { devbox_id: 'dbx_other', eviction_deadline_ms: 1 }, + { devbox_id: 'dbx_match', eviction_deadline_ms: 1_720_000_000_000 }, + ]; + const devbox = Devbox.fromId( + fakeClient(async () => streamOf(events)), + 'dbx_match', + ); + + let receivedDevbox: Devbox | undefined; + const calls: Array<{ id: string; deadline: number }> = []; + const fired = new Promise((resolve) => { + devbox.onEvict((evicted, evictionDeadlineMs) => { + receivedDevbox = evicted; + calls.push({ id: evicted.id, deadline: evictionDeadlineMs }); + resolve(); + }); + }); + + let guard: ReturnType; + const timeout = new Promise((_resolve, reject) => { + guard = setTimeout(() => reject(new Error('onEvict callback never fired')), 2000); + }); + try { + await Promise.race([fired, timeout]); + } finally { + clearTimeout(guard!); + } + await tick(); + + // Fired once, with the devbox object and its deadline; the unmatched id was discarded. + expect(receivedDevbox).toBe(devbox); + expect(calls).toEqual([{ id: 'dbx_match', deadline: 1_720_000_000_000 }]); + }); + + test('cancelOnEvict stops watching and aborts the stream', async () => { + const stream = blockingStream(); + const devbox = Devbox.fromId( + fakeClient(async () => stream), + 'dbx_cancel', + ); + + const callback = jest.fn(); + devbox.onEvict(callback); + await tick(); // let the monitor open the stream + + devbox.cancelOnEvict(); + + expect(stream.controller.signal.aborted).toBe(true); + await tick(); + expect(callback).not.toHaveBeenCalled(); + }); +});