Skip to content
Merged
4 changes: 3 additions & 1 deletion .github/workflows/sdk-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ jobs:
- name: Run smoke tests with coverage
id: tests
continue-on-error: true
run: yarn test:objects-coverage 2>&1 | tee test-output.log
run: |
set -o pipefail
yarn test:objects-coverage 2>&1 | tee test-output.log

- name: Upload coverage report
uses: runloopai/upload-artifact@main
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ export declare namespace Runloop {
export type RunProfile = API.RunProfile;
}

export { type LongPollRequestOptions, LongPollAbortError, PollingTimeoutError } from './lib/polling';
export { toFile, fileFromPath } from './uploads';
export {
RunloopError,
Expand Down
50 changes: 21 additions & 29 deletions src/lib/devbox-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { poll, PollingOptions } from './polling';
import { longPollUntil } from './polling';

export interface DevboxStateWaitOptions<T> {
client: {
Expand All @@ -8,45 +8,37 @@ export interface DevboxStateWaitOptions<T> {
targetState: string;
statesToCheck: string[];
transitionStates: string[];
pollingOptions?: Partial<PollingOptions<T>> | undefined;
/** Timeout in milliseconds for the long-poll operation. */
timeoutMs?: number | undefined;
/** Optional AbortSignal to cancel the long-poll loop externally. */
signal?: AbortSignal | null | undefined;
errorMessage: (id: string, actualState: string) => string;
}

/**
* Shared utility for waiting for a devbox to reach a specific state.
* Uses the /wait_for_status endpoint with polling.
* Uses the /wait_for_status long-poll endpoint.
*/
export async function awaitDevboxState<T extends { status: string }>(
options: DevboxStateWaitOptions<T>,
): Promise<T> {
const { client, devboxId, targetState, statesToCheck, transitionStates, pollingOptions, errorMessage } =
options;
const { client, devboxId, targetState, statesToCheck, transitionStates, errorMessage } = options;

const longPoll = (): Promise<T> => {
// This either returns a DevboxView when status matches one of statesToCheck;
// Otherwise it throws an 408 error when times out.
return client.post(`/v1/devboxes/${devboxId}/wait_for_status`, {
body: { statuses: statesToCheck },
});
};

const finalResult = await poll(
() => longPoll(),
() => longPoll(),
const finalResult = await longPollUntil(
(signal) =>
client.post(`/v1/devboxes/${devboxId}/wait_for_status`, {
body: { statuses: statesToCheck },
signal,
// Per-request HTTP timeout must exceed the server's max long-poll hold (30s)
// so the server's 408 always arrives before the client aborts the connection.
// The longPollUntil AbortSignal enforces the caller's actual deadline.
timeout: 600000,
maxRetries: 0,
}),
{
...pollingOptions,
shouldStop: (result) => {
return !transitionStates.includes(result.status);
},
onError: (error: any) => {
if (error.status === 408) {
// Return a placeholder result to continue polling
return { status: transitionStates[0] } as T;
}

// For any other error, rethrow it
throw error;
},
timeoutMs: options.timeoutMs,
shouldStop: (result) => !transitionStates.includes(result.status),
signal: options.signal,
},
);

Expand Down
Loading
Loading