import axios, { isAxiosError, type AxiosPromise, type AxiosResponse, type RawAxiosRequestConfig, } from "axios"; import { AuthApi, AutomationsApi, Configuration, EnvironmentsApi, HumanInTheLoopApi, InsightsApi, InstallApi, MCPServersApi, ModelsApi, RunInternalsApi, RunOutputsApi, RunsApi, SecretsApi, SessionsApi, SettingsApi, SystemApi, VariablesApi, WorkflowsApi, } from "@qltysh/fabro-api-client"; export interface PaginatedEnvelope { data: T[]; meta: { has_more: boolean }; } export class ApiError extends Error { readonly status: number; readonly requestId: string | null; readonly body: unknown; constructor({ status, message, requestId, body, }: { status: number; message: string; requestId: string | null; body: unknown; }) { super(message); this.name = "ApiError"; this.status = status; this.requestId = requestId; this.body = body; } } interface ApiCallOptions { redirectOnUnauthorized?: boolean; } const PAGINATED_API_MAX_PAGES = 50; const PAGINATED_API_MAX_ITEMS = 5000; export const generatedAxios = axios.create({ baseURL: "", withCredentials: true, }); export const generatedApiConfiguration = new Configuration({ basePath: "", baseOptions: { withCredentials: true, }, }); export const authApi = new AuthApi( generatedApiConfiguration, "", generatedAxios, ); export const automationsApi = new AutomationsApi( generatedApiConfiguration, "", generatedAxios, ); export const environmentsApi = new EnvironmentsApi( generatedApiConfiguration, "", generatedAxios, ); export const humanInTheLoopApi = new HumanInTheLoopApi( generatedApiConfiguration, "", generatedAxios, ); export const insightsApi = new InsightsApi( generatedApiConfiguration, "", generatedAxios, ); export const mcpServersApi = new MCPServersApi( generatedApiConfiguration, "", generatedAxios, ); export const installApi = new InstallApi( generatedApiConfiguration, "", generatedAxios, ); export const modelsApi = new ModelsApi( generatedApiConfiguration, "", generatedAxios, ); export const runInternalsApi = new RunInternalsApi( generatedApiConfiguration, "", generatedAxios, ); export const runOutputsApi = new RunOutputsApi( generatedApiConfiguration, "", generatedAxios, ); export const runsApi = new RunsApi( generatedApiConfiguration, "", generatedAxios, ); export const secretsApi = new SecretsApi( generatedApiConfiguration, "", generatedAxios, ); export const sessionsApi = new SessionsApi( generatedApiConfiguration, "", generatedAxios, ); export const settingsApi = new SettingsApi( generatedApiConfiguration, "", generatedAxios, ); export const systemApi = new SystemApi( generatedApiConfiguration, "", generatedAxios, ); export const variablesApi = new VariablesApi( generatedApiConfiguration, "", generatedAxios, ); export const workflowsApi = new WorkflowsApi( generatedApiConfiguration, "", generatedAxios, ); export function isNotAvailable(status: number): boolean { return status === 404 || status === 501; } export function extractRequestId(body: unknown): string | null { if (!body || typeof body !== "object") return null; const record = body as Record; if (typeof record.request_id === "string") return record.request_id; if (typeof record.requestId === "string") return record.requestId; const errors = record.errors; if (!Array.isArray(errors) || errors.length === 0) return null; const first = errors[0]; if (!first || typeof first !== "object") return null; const error = first as Record; if (typeof error.request_id === "string") return error.request_id; if (typeof error.requestId === "string") return error.requestId; if (typeof error.detail === "string") { const match = error.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i); if (match) return match[1]; } return null; } function requestIdFromHeaders(headers: unknown): string | null { return ( headerValue(headers, "x-request-id") ?? headerValue(headers, "x-fabro-request-id") ?? headerValue(headers, "request-id") ); } function headerValue(headers: unknown, name: string): string | null { if (!headers || typeof headers !== "object") return null; const getter = (headers as { get?: (key: string) => unknown }).get; if (typeof getter === "function") { const value = getter.call(headers, name); if (typeof value === "string") return value; } const wanted = name.toLowerCase(); for (const [key, value] of Object.entries(headers as Record)) { if (key.toLowerCase() !== wanted) continue; if (typeof value === "string") return value; if (Array.isArray(value) && typeof value[0] === "string") return value[0]; } return null; } function apiErrorFromAxios(error: unknown): ApiError | null { if (!isAxiosError(error) || !error.response) return null; const { response } = error; const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(response.data); return new ApiError({ status: response.status, message: extractErrorDetail(response.data) ?? (response.statusText || `HTTP ${response.status}`), requestId, body: response.data ?? null, }); } export async function apiErrorFromFetchResponse(response: Response): Promise { if (response.ok) return null; const body = await readFetchErrorBody(response); const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body); return new ApiError({ status: response.status, message: extractErrorDetail(body) ?? (response.statusText || `HTTP ${response.status}`), requestId, body, }); } async function readFetchErrorBody(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { return response.json().catch(() => null); } const text = await response.text().catch(() => ""); if (!text) return null; try { return JSON.parse(text); } catch { return text; } } function extractErrorDetail(body: unknown): string | null { if (!body || typeof body !== "object") return null; const errors = (body as Record).errors; if (!Array.isArray(errors) || errors.length === 0) return null; const first = errors[0]; if (!first || typeof first !== "object") return null; const detail = (first as Record).detail; return typeof detail === "string" && detail.length > 0 ? detail : null; } function redirectToLogin(error: ApiError, options: ApiCallOptions) { if (error.status !== 401 || options.redirectOnUnauthorized === false) return; if (typeof window !== "undefined") { window.location.href = "/login"; } } export async function apiData( call: () => AxiosPromise, options: ApiCallOptions = {}, ): Promise { try { const response = await call(); return response.data; } catch (error) { const apiError = apiErrorFromAxios(error); if (!apiError) throw error; redirectToLogin(apiError, options); throw apiError; } } export async function apiResponse( call: () => AxiosPromise, options: ApiCallOptions = {}, ): Promise> { try { return await call(); } catch (error) { const apiError = apiErrorFromAxios(error); if (!apiError) throw error; redirectToLogin(apiError, options); throw apiError; } } export async function apiNullableData( call: () => AxiosPromise, ): Promise { try { return await apiData(call); } catch (error) { if (error instanceof ApiError && isNotAvailable(error.status)) return null; throw error; } } export async function fetchAllPages( label: string, loadPage: (limit: number, offset: number) => Promise & TExtra>, ): Promise & TExtra> { const limit = 100; let offset = 0; const data: TItem[] = []; let extras: TExtra | null = null; let pagesLoaded = 0; while (true) { const page = await loadPage(limit, offset); if (extras == null) { const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope & Record; extras = rest as TExtra; } pagesLoaded += 1; const pageData = page.data; const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length; const pageItems = remainingItemBudget > 0 ? pageData.slice(0, remainingItemBudget) : []; data.push(...pageItems); if (!page.meta.has_more || pageData.length === 0) { return { ...(extras ?? ({} as TExtra)), data, meta: { has_more: false }, }; } if ( pagesLoaded >= PAGINATED_API_MAX_PAGES || pageItems.length < pageData.length || data.length >= PAGINATED_API_MAX_ITEMS ) { console.warn( `Stopped paginated API fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, ); return { ...(extras ?? ({} as TExtra)), data, meta: { has_more: true }, }; } offset += page.data.length; } } export async function fetchAllStageEvents( label: string, loadPage: (sinceSeq: number, limit: number) => Promise>, ): Promise { const PAGE_LIMIT = 1000; const MAX_PAGES = 50; const data: TItem[] = []; let sinceSeq = 1; let pagesLoaded = 0; while (true) { const page = await loadPage(sinceSeq, PAGE_LIMIT); pagesLoaded += 1; if (page.data.length === 0) { if (page.meta.has_more) { console.warn( `Stage events fetch for ${label} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`, ); } return data; } data.push(...page.data); if (!page.meta.has_more) return data; if (pagesLoaded >= MAX_PAGES) { console.warn( `Stopped stage events fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, ); return data; } const highestSeq = page.data.reduce((max, event) => Math.max(max, event.seq), sinceSeq - 1); if (highestSeq < sinceSeq) { console.warn( `Stage events fetch for ${label} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`, ); return data; } sinceSeq = highestSeq + 1; } } export function requestSignalOptions(request?: Request): RawAxiosRequestConfig { return request?.signal ? { signal: request.signal } : {}; } /** Absolute href for a path under a run, for links the browser follows itself. */ function runApiPath(id: string, suffix: string): string { return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${ encodeURIComponent(id) }${suffix}`; } export function stageArtifactDownloadUrl( id: string, stageId: string, filename: string, retry: number, ): string { const searchParams = new URLSearchParams({ filename, retry: String(retry), }); return runApiPath( id, `/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`, ); } export function runArtifactsDownloadUrl(id: string): string { return runApiPath(id, "/artifacts/download"); }