Skip to content

Commit f675191

Browse files
committed
feat(devbox): add devbox.onEvict eviction notifications (SSE)
Adds an object-oriented `Devbox.onEvict(callback)` backed by a per-client `EvictionMonitor`. The first registration opens a single account-wide `watchEvictions` SSE stream; incoming notifications are matched against the local interest set, the devbox is removed before its callback fires (at-most-once), and the stream is closed once the interest set empties. Depends on the Stainless-generated `devboxes.watchEvictions` method and `DevboxEvictionEvent` type, which land downstream once the server endpoint merges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3d5d772 commit f675191

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

src/sdk/devbox.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { LongPollRequestOptions, PollingOptions } from '../lib/polling';
2222
import { Snapshot } from './snapshot';
2323
import { Execution } from './execution';
2424
import { ExecutionResult } from './execution-result';
25+
import { EvictionCallback, getEvictionMonitor } from './eviction';
2526
import { uuidv7 } from 'uuidv7';
2627

2728
// Re-export Execution and ExecutionResult for Devbox namespace
@@ -1029,6 +1030,34 @@ export class Devbox {
10291030
async keepAlive(options?: Core.RequestOptions): Promise<DevboxKeepAliveResponse> {
10301031
return this.client.devboxes.keepAlive(this._id, options);
10311032
}
1033+
1034+
/**
1035+
* Register a callback fired once if this devbox gets a pending infrastructure eviction.
1036+
*
1037+
* The first `onEvict` across any devbox on this client opens a single account-wide
1038+
* notification stream; it closes automatically once every registered devbox has been
1039+
* notified. The callback runs at most once and receives the eviction event (with its
1040+
* `eviction_deadline_ms`) — use it to run cleanup before the devbox is suspended.
1041+
*
1042+
* @param {EvictionCallback} callback - Invoked with the eviction event for this devbox.
1043+
*
1044+
* @example
1045+
* ```typescript
1046+
* devbox.onEvict((event) => {
1047+
* console.log(`devbox will be suspended at ${event.eviction_deadline_ms}`);
1048+
* });
1049+
* ```
1050+
*/
1051+
onEvict(callback: EvictionCallback): void {
1052+
getEvictionMonitor(this.client).register(this._id, callback);
1053+
}
1054+
1055+
/**
1056+
* Withdraw this devbox's eviction interest registered via {@link onEvict}.
1057+
*/
1058+
cancelOnEvict(): void {
1059+
getEvictionMonitor(this.client).unregister(this._id);
1060+
}
10321061
}
10331062

10341063
/**

src/sdk/eviction.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Runloop } from '../index';
2+
import { Stream } from '../streaming';
3+
// Assumed Stainless-generated SSE event type for `watchEvictions`; carries
4+
// `devbox_id` and `eviction_deadline_ms`. Reconcile the import once the generated
5+
// code lands.
6+
import type { DevboxEvictionEvent } from '../resources/devboxes/devboxes';
7+
8+
/**
9+
* Invoked once with the eviction event for the devbox it was registered for.
10+
* The event carries `devbox_id` and `eviction_deadline_ms`.
11+
*/
12+
export type EvictionCallback = (event: DevboxEvictionEvent) => void;
13+
14+
/**
15+
* Fans account-wide eviction notifications out to per-devbox callbacks.
16+
*
17+
* One monitor is shared by every {@link Devbox} built from the same generated
18+
* client (see {@link getEvictionMonitor}), so all registered devboxes are served
19+
* by a single SSE connection. The stream opens the moment the first devbox
20+
* registers interest and closes as soon as the last interested devbox has been
21+
* notified.
22+
*
23+
* Delivery contract:
24+
* - The server replays every currently-pending eviction on connect, so a devbox
25+
* that registers after its eviction was scheduled is still notified.
26+
* - Notifications for devboxes not in the interest set are discarded.
27+
* - A devbox is removed from the interest set *before* its callback runs, so the
28+
* callback fires at most once even if the server repeats the notification.
29+
*/
30+
export class EvictionMonitor {
31+
private callbacks = new Map<string, EvictionCallback>();
32+
private stream: Stream<DevboxEvictionEvent> | null = null;
33+
private running = false;
34+
35+
constructor(private client: Runloop) {}
36+
37+
/** Add `devboxId` to the interest set, opening the stream if idle. */
38+
register(devboxId: string, callback: EvictionCallback): void {
39+
this.callbacks.set(devboxId, callback);
40+
if (!this.running) {
41+
this.running = true;
42+
void this.run();
43+
}
44+
}
45+
46+
/** Drop `devboxId`; close the stream if it was the last interested devbox. */
47+
unregister(devboxId: string): void {
48+
this.callbacks.delete(devboxId);
49+
if (this.callbacks.size === 0) {
50+
this.close();
51+
}
52+
}
53+
54+
/** Clear all interest and tear down the stream. */
55+
close(): void {
56+
this.callbacks.clear();
57+
this.stream?.controller.abort();
58+
this.stream = null;
59+
this.running = false;
60+
}
61+
62+
private async run(): Promise<void> {
63+
try {
64+
const stream = await this.client.devboxes.watchEvictions();
65+
this.stream = stream;
66+
for await (const event of stream) {
67+
this.dispatch(event);
68+
if (this.callbacks.size === 0) break;
69+
}
70+
} catch (error) {
71+
// Aborting the stream on close surfaces as an AbortError; that is expected.
72+
if (!(error instanceof Error && error.name === 'AbortError')) {
73+
console.error('Error in eviction monitor stream:', error);
74+
}
75+
} finally {
76+
this.stream = null;
77+
this.running = false;
78+
}
79+
}
80+
81+
private dispatch(event: DevboxEvictionEvent): void {
82+
const callback = this.callbacks.get(event.devbox_id);
83+
if (!callback) return;
84+
// Remove before signaling so a duplicate notification for the same devbox is
85+
// discarded and the callback fires at most once.
86+
this.callbacks.delete(event.devbox_id);
87+
try {
88+
callback(event);
89+
} catch (error) {
90+
console.error(`Error in eviction callback for devbox ${event.devbox_id}:`, error);
91+
}
92+
}
93+
}
94+
95+
const monitors = new WeakMap<Runloop, EvictionMonitor>();
96+
97+
/** Return the shared {@link EvictionMonitor} for `client`, creating it once. */
98+
export function getEvictionMonitor(client: Runloop): EvictionMonitor {
99+
let monitor = monitors.get(client);
100+
if (!monitor) {
101+
monitor = new EvictionMonitor(client);
102+
monitors.set(client, monitor);
103+
}
104+
return monitor;
105+
}

src/sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Devbox, DevboxCmdOps, DevboxFileOps, DevboxNetOps, type ExecuteStreamingCallbacks } from './devbox';
2+
export { EvictionMonitor, getEvictionMonitor, type EvictionCallback } from './eviction';
23
export { Blueprint } from './blueprint';
34
export { Snapshot } from './snapshot';
45
export { StorageObject } from './storage-object';

0 commit comments

Comments
 (0)