diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9eeeeb45..d0950c80 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.107.0" + ".": "0.108.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 299a82f2..afe7b45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.108.0](https://github.com/kernel/kernel-node-sdk/compare/v0.107.0...v0.108.0) (2026-09-17) + + +### Features + +* feat: add config registry analysis waiter ([0c8d02a](https://github.com/kernel/kernel-node-sdk/commit/0c8d02a9f4baf1da7bb14cef4969d31e12ce804a)) + ## [0.107.0](https://github.com/kernel/kernel-node-sdk/compare/v0.106.0...v0.107.0) (2026-09-16) diff --git a/package.json b/package.json index 67e659ca..188a9a81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onkernel/sdk", - "version": "0.107.0", + "version": "0.108.0", "description": "The official TypeScript library for the Kernel API", "author": "Kernel <>", "types": "dist/index.d.ts", diff --git a/src/lib/config-registry-wait.ts b/src/lib/config-registry-wait.ts new file mode 100644 index 00000000..94f74efe --- /dev/null +++ b/src/lib/config-registry-wait.ts @@ -0,0 +1,133 @@ +import { APIConnectionTimeoutError, APIUserAbortError, KernelError } from '../core/error'; +import { buildHeaders } from '../internal/headers'; +import type { RequestOptions } from '../internal/request-options'; +import type { ConfigRegistryResponse } from '../resources/config-registry/config-registry'; + +const defaultPollIntervalMs = 5_000; +const terminalStatuses = new Set(['completed', 'failed', 'canceled', 'expired']); + +export type ConfigRegistryAnalysisWaitOptions = Pick< + RequestOptions, + 'headers' | 'maxRetries' | 'timeout' | 'fetchOptions' | 'signal' | 'defaultBaseURL' +> & { + pollIntervalMs?: number; + maxWaitMs?: number | null; +}; + +type AnalysisRetriever = { + retrieve(id: string, options?: RequestOptions): Promise; +}; + +const timeoutError = (id: string, polls: number, lastStatus: string | undefined, startedAt: number) => + new APIConnectionTimeoutError({ + message: `Timed out waiting for config registry analysis ${JSON.stringify(id)} after ${( + performance.now() - startedAt + ).toFixed(0)}ms and ${polls} polls; last status was ${JSON.stringify(lastStatus)}`, + }); + +const wait = (ms: number, signal?: AbortSignal | null): Promise => { + if (signal?.aborted) { + return Promise.reject(new APIUserAbortError()); + } + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(); + }; + const onAbort = () => finish(new APIUserAbortError()); + const timer = setTimeout(() => finish(), ms); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); +}; + +const analysisFinished = (response: ConfigRegistryResponse, requestedID: string): [boolean, string] => { + const analysis = response.analysis; + if (!analysis || typeof analysis !== 'object') { + throw new KernelError( + `Config registry response for ${JSON.stringify(requestedID)} is missing an analysis`, + ); + } + if (typeof analysis.id !== 'string' || !analysis.id) { + throw new KernelError( + `Config registry response for ${JSON.stringify(requestedID)} has no valid analysis ID`, + ); + } + if (analysis.id !== requestedID) { + throw new KernelError( + `Config registry response for ${JSON.stringify(requestedID)} returned analysis ${JSON.stringify( + analysis.id, + )}`, + ); + } + if (typeof analysis.status !== 'string' || !analysis.status) { + throw new KernelError(`Config registry analysis ${JSON.stringify(requestedID)} has no valid status`); + } + if (!Object.prototype.hasOwnProperty.call(analysis, 'finished_at')) { + throw new KernelError(`Config registry analysis ${JSON.stringify(requestedID)} is missing finished_at`); + } + if ( + analysis.finished_at !== null && + (typeof analysis.finished_at !== 'string' || Number.isNaN(Date.parse(analysis.finished_at))) + ) { + throw new KernelError( + `Config registry analysis ${JSON.stringify(requestedID)} has an invalid finished_at`, + ); + } + + return [analysis.finished_at !== null || terminalStatuses.has(analysis.status), analysis.status]; +}; + +export async function waitForConfigRegistryAnalysis( + resource: AnalysisRetriever, + id: string, + options: ConfigRegistryAnalysisWaitOptions = {}, +): Promise { + if (!id) { + throw new TypeError('id must be non-empty'); + } + const { pollIntervalMs = defaultPollIntervalMs, maxWaitMs, ...requestOptions } = options; + if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) { + throw new RangeError('pollIntervalMs must be finite and positive'); + } + if (maxWaitMs != null && (!Number.isFinite(maxWaitMs) || maxWaitMs < 0)) { + throw new RangeError('maxWaitMs must be finite and non-negative'); + } + + const startedAt = performance.now(); + const deadline = maxWaitMs == null ? undefined : startedAt + maxWaitMs; + const retrieveOptions: RequestOptions = { + ...requestOptions, + headers: buildHeaders([requestOptions.headers, { 'X-Stainless-Poll-Helper': 'true' }]), + }; + let polls = 0; + let lastStatus: string | undefined; + + while (true) { + if (polls > 0 && deadline !== undefined && performance.now() >= deadline) { + throw timeoutError(id, polls, lastStatus, startedAt); + } + + // oxlint-disable-next-line no-await-in-loop -- each retrieval determines whether another poll is needed. + const response = await resource.retrieve(id, retrieveOptions); + polls += 1; + const [finished, status] = analysisFinished(response, id); + lastStatus = status; + if (finished) return response; + + let delay = pollIntervalMs * (0.9 + Math.random() * 0.2); + if (deadline !== undefined) { + const remaining = deadline - performance.now(); + if (remaining <= 0) throw timeoutError(id, polls, lastStatus, startedAt); + delay = Math.min(delay, remaining); + } + // oxlint-disable-next-line no-await-in-loop -- cancellation and the polling deadline must cover the delay. + await wait(delay, requestOptions.signal); + } +} diff --git a/src/resources/config-registry/analyses.ts b/src/resources/config-registry/analyses.ts index 83b9d294..57dd5571 100644 --- a/src/resources/config-registry/analyses.ts +++ b/src/resources/config-registry/analyses.ts @@ -7,6 +7,10 @@ import { APIPromise } from '../../core/api-promise'; import { OffsetPagination, type OffsetPaginationParams, PagePromise } from '../../core/pagination'; import { RequestOptions } from '../../internal/request-options'; import { path } from '../../internal/utils/path'; +import { + waitForConfigRegistryAnalysis, + type ConfigRegistryAnalysisWaitOptions, +} from '../../lib/config-registry-wait'; /** * Resolve browser and proxy recommendations for bot-protected sites. @@ -26,6 +30,20 @@ export class Analyses extends APIResource { return this._client.get(path`/config-registry/analyses/${id}`, options); } + /** + * Wait for an analysis to finish and return its complete result. + * + * The first retrieval happens immediately. `maxWaitMs` is a soft polling + * deadline: an in-flight request and its normal retries may finish after it. + * Timing out or aborting does not cancel the remote analysis. + */ + async waitForResult( + id: string, + options?: ConfigRegistryAnalysisWaitOptions, + ): Promise { + return await waitForConfigRegistryAnalysis(this, id, options); + } + /** * Lists analyses for the selected project, newest first. * @@ -73,7 +91,10 @@ export interface AnalysisListParams extends OffsetPaginationParams { } export declare namespace Analyses { - export { type AnalysisListParams as AnalysisListParams }; + export { + type AnalysisListParams as AnalysisListParams, + type ConfigRegistryAnalysisWaitOptions as ConfigRegistryAnalysisWaitOptions, + }; } -export { type AnalysisSummariesOffsetPagination }; +export { type AnalysisSummariesOffsetPagination, type ConfigRegistryAnalysisWaitOptions }; diff --git a/src/resources/config-registry/index.ts b/src/resources/config-registry/index.ts index 6991de4c..9aab8663 100644 --- a/src/resources/config-registry/index.ts +++ b/src/resources/config-registry/index.ts @@ -1,6 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -export { Analyses, type AnalysisListParams } from './analyses'; +export { Analyses, type AnalysisListParams, type ConfigRegistryAnalysisWaitOptions } from './analyses'; export { ConfigRegistry, type Analysis, diff --git a/src/version.ts b/src/version.ts index 94039eb8..dd27ba7d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.107.0'; // x-release-please-version +export const VERSION = '0.108.0'; // x-release-please-version diff --git a/tests/lib/config-registry-wait.test.ts b/tests/lib/config-registry-wait.test.ts new file mode 100644 index 00000000..bc67862d --- /dev/null +++ b/tests/lib/config-registry-wait.test.ts @@ -0,0 +1,159 @@ +import Kernel, { APIConnectionTimeoutError, APIUserAbortError, KernelError } from '@onkernel/sdk'; + +const analysisResponse = ( + status: string, + { id = 'analysis-1', finishedAt = null }: { id?: string; finishedAt?: string | null } = {}, +) => ({ + analysis: { + id, + created_at: '2026-09-16T00:00:00Z', + expires_at: '2026-09-16T00:45:00Z', + failure: null, + finished_at: finishedAt, + status, + intent: null, + }, + recommendation: null, + target: { + domain: 'example.com', + host: 'example.com', + normalized: 'https://example.com/', + }, + working_configurations: [], + guidance: null, + workload_outcome: null, +}); + +const requestURL = (input: string | URL | Request) => + input instanceof Request ? input.url : input.toString(); + +describe('config registry analysis waiter', () => { + test('polls unknown unfinished statuses until the analysis is finished', async () => { + const responses = [ + analysisResponse('running'), + analysisResponse('queued'), + analysisResponse('archived', { finishedAt: '2026-09-16T00:01:00Z' }), + ]; + const requests: Request[] = []; + const client = new Kernel({ + apiKey: 'test', + baseURL: 'https://api.example', + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push(request); + return Response.json(responses.shift()); + }, + }); + + const result = await client.configRegistry.analyses.waitForResult('analysis-1', { + pollIntervalMs: 1, + headers: { 'X-Test': 'preserved', 'X-Stainless-Poll-Helper': 'caller' }, + }); + + expect(result.analysis?.status).toBe('archived'); + expect(requests).toHaveLength(3); + expect(requests.every((request) => request.headers.get('X-Test') === 'preserved')).toBe(true); + expect(requests.every((request) => request.headers.get('X-Stainless-Poll-Helper') === 'true')).toBe(true); + expect(requestURL(requests[0]!)).toBe('https://api.example/config-registry/analyses/analysis-1'); + }); + + test.each(['completed', 'failed', 'canceled', 'expired'])( + 'returns %s without requiring finished_at', + async (status) => { + let requests = 0; + const client = new Kernel({ + apiKey: 'test', + baseURL: 'https://api.example', + fetch: async () => { + requests += 1; + return Response.json(analysisResponse(status)); + }, + }); + + const result = await client.configRegistry.analyses.waitForResult('analysis-1'); + + expect(result.analysis?.status).toBe(status); + expect(requests).toBe(1); + }, + ); + + test('a zero max wait reads once before timing out', async () => { + let requests = 0; + const client = new Kernel({ + apiKey: 'test', + baseURL: 'https://api.example', + fetch: async () => { + requests += 1; + return Response.json(analysisResponse('running')); + }, + }); + + await expect( + client.configRegistry.analyses.waitForResult('analysis-1', { maxWaitMs: 0 }), + ).rejects.toThrow(APIConnectionTimeoutError); + expect(requests).toBe(1); + }); + + test.each([ + [{ analysis: null }, 'missing an analysis'], + [analysisResponse('running', { id: 'analysis-2' }), 'analysis-2'], + ])('rejects an invalid analysis response', async (payload, message) => { + const client = new Kernel({ + apiKey: 'test', + baseURL: 'https://api.example', + fetch: async () => Response.json(payload), + }); + + await expect(client.configRegistry.analyses.waitForResult('analysis-1')).rejects.toThrow( + message as string, + ); + await expect(client.configRegistry.analyses.waitForResult('analysis-1')).rejects.toThrow(KernelError); + }); + + test('rejects a response that omits finished_at', async () => { + const payload = analysisResponse('running'); + delete (payload.analysis as { finished_at?: string | null }).finished_at; + const client = new Kernel({ + apiKey: 'test', + baseURL: 'https://api.example', + fetch: async () => Response.json(payload), + }); + + await expect(client.configRegistry.analyses.waitForResult('analysis-1')).rejects.toThrow('finished_at'); + }); + + test.each([ + { pollIntervalMs: 0 }, + { pollIntervalMs: Number.NaN }, + { maxWaitMs: -1 }, + { maxWaitMs: Number.POSITIVE_INFINITY }, + ])('rejects invalid timing options', async (options) => { + const client = new Kernel({ apiKey: 'test', baseURL: 'https://api.example' }); + + await expect(client.configRegistry.analyses.waitForResult('analysis-1', options)).rejects.toThrow( + RangeError, + ); + }); + + test('aborts while sleeping without issuing another request', async () => { + const controller = new AbortController(); + let requests = 0; + const client = new Kernel({ + apiKey: 'test', + baseURL: 'https://api.example', + fetch: async () => { + requests += 1; + setTimeout(() => controller.abort(), 0); + return Response.json(analysisResponse('running')); + }, + }); + + await expect( + client.configRegistry.analyses.waitForResult('analysis-1', { + pollIntervalMs: 60_000, + signal: controller.signal, + }), + ).rejects.toThrow(APIUserAbortError); + expect(requests).toBe(1); + }); +});