/** * Typed API client. * * Hand-written against the OpenAPI schema rather than generated, because the * surface is small and the hand-written version can state the one thing a * generated client cannot: that a 404 for another workspace's id and a 404 for a * missing id are the same answer, and that this client never asks the server * which workspace it is in. */ export type Role = "viewer" | "operator" | "owner"; export interface Session { workspace_id: string; token: string; expires_at: string; mode: string; } export interface Detection { class_id: number; label: string; score: number; box: [number, number, number, number]; } export interface TimingSplit { queue_inclusive_ms: number | null; service_ms: number | null; preprocess_ms: number | null; infer_ms: number | null; postprocess_ms: number | null; persist_ms: number | null; batch_wait_ms: number | null; peak_rss_bytes: number | null; } export interface BoxDiff { label: string; kind: "added" | "missing" | "matched" | "relabelled"; box: [number, number, number, number]; score: number | null; shadow_score: number | null; } export interface ItemDiff { agreement: number; added: BoxDiff[]; missing: BoxDiff[]; relabelled: BoxDiff[]; } export interface WorkItem { id: string; page_id: string; candidate_id: string; state: "queued" | "running" | "succeeded" | "failed" | "cancelled"; role: "primary" | "shadow"; attempts: number; failure_code: string | null; failure_detail: string | null; timings: TimingSplit; detections: Detection[]; shadow_detections: Detection[]; diff: ItemDiff | null; image_url: string | null; page_size: [number, number] | null; synthetic: boolean; provenance: string | null; } export interface Batch { id: string; status: string; total_items: number; counts: Record; release_id: string; candidate_id: string; candidate_name: string; synthetic: boolean; created_at: string; finished_at: string | null; cancel_requested_at: string | null; timings: TimingSplit | null; } export interface Candidate { id: string; name: string; candidate_hash: string; artifact_name: string; artifact_format: string; artifact_sha256: string; repo_id: string; revision: string; runtime: string; resize: string; target: number; threads: number; score_threshold: number; tile_enabled: boolean; valid: boolean; invalid_reason: string | null; } export interface GateVerdict { gate: string; passed: boolean; observed: unknown; threshold: unknown; detail: string | null; } export interface Evaluation { id: string; candidate_id: string; candidate_name: string; reference_id: string | null; status: string; metrics: Record | null; gates: GateVerdict[] | null; created_at: string; finished_at: string | null; failure_detail: string | null; } export interface Release { id: string; action: "promote" | "rollback" | "seed"; candidate_id: string; candidate_name: string; previous_release_id: string | null; reason: string; actor: string; channel_version: number; created_at: string; } export interface Channel { name: string; version: number; active_release_id: string | null; active_candidate_name: string | null; history: Release[]; } export interface PoolStatus { pool: string; queued: number; running: number; } export interface SystemStatus { mode: string; dialect: string; queue_depth: PoolStatus[]; queue_limit: number; worker_mem_budget_bytes: number; memory_safety_margin: number; admission_mispredictions: number; host_free_bytes: number | null; git_sha: string | null; } export class ApiError extends Error { readonly status: number; readonly body: unknown; constructor(status: number, body: unknown) { super(describe(status, body)); this.name = "ApiError"; this.status = status; this.body = body; } } function describe(status: number, body: unknown): string { if (body && typeof body === "object" && "detail" in body) { const detail = (body as { detail: unknown }).detail; if (typeof detail === "string") return `${status}: ${detail}`; if (detail && typeof detail === "object" && "detail" in detail) { const inner = (detail as { detail: unknown }).detail; const code = (detail as { code?: unknown }).code; const failed = (detail as { failed?: unknown }).failed; const parts = [typeof inner === "string" ? inner : JSON.stringify(inner)]; if (code) parts.push(`code=${String(code)}`); if (Array.isArray(failed) && failed.length) parts.push(`failed=${failed.join(",")}`); return `${status}: ${parts.join(" ")}`; } return `${status}: ${JSON.stringify(detail)}`; } return `${status}`; } const TOKEN_KEY = "forgesight.token"; const WORKSPACE_KEY = "forgesight.workspace"; export function storedToken(): string | null { return localStorage.getItem(TOKEN_KEY); } export function storedWorkspace(): string | null { return localStorage.getItem(WORKSPACE_KEY); } export function storeSession(s: Session): void { // The token is held in localStorage so a reload keeps the session. That is a // deliberate trade for a single-user local tool; a public deployment would put // this behind httpOnly cookies and a CSRF token instead. localStorage.setItem(TOKEN_KEY, s.token); localStorage.setItem(WORKSPACE_KEY, s.workspace_id); } export function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(WORKSPACE_KEY); } export function authHeaders(): Record { const token = storedToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } /** Set once per page load so a bad token cannot cause a reload loop. */ const RELOAD_GUARD = "forgesight.reloaded-for-auth"; async function request( path: string, init: RequestInit & { auth?: boolean } = {}, ): Promise { const token = storedToken(); const headers: Record = { ...(init.headers as Record | undefined), }; if (init.auth !== false && token) headers.Authorization = `Bearer ${token}`; const res = await fetch(path, { ...init, headers }); if (res.status === 204) return undefined as T; const text = await res.text(); let body: unknown = text; if (text) { try { body = JSON.parse(text); } catch { /* a non-JSON body is surfaced as-is */ } } if (res.status === 401 && token) { // The stored token no longer works: it expired, or the database it belonged // to was reset. Drop it and reload so the app mints a fresh session instead // of showing a permanently broken page. Guarded, because reloading into // another 401 would never terminate. if (!sessionStorage.getItem(RELOAD_GUARD)) { sessionStorage.setItem(RELOAD_GUARD, "1"); clearSession(); location.reload(); } else { clearSession(); } } if (!res.ok) throw new ApiError(res.status, body); return body as T; } export const api = { info: () => request>("/v1/system/info", { auth: false }), createSession: () => request("/v1/sessions", { method: "POST", auth: false }), listBatches: () => request<{ items: Batch[]; next_cursor: string | null }>("/v1/batches"), getBatch: (id: string) => request(`/v1/batches/${id}`), listItems: (batchId: string) => request(`/v1/batches/${batchId}/items`), getItem: (id: string) => request(`/v1/items/${id}`), cancelBatch: (id: string) => request(`/v1/batches/${id}/cancel`, { method: "POST" }), uploadBatch: (files: File[], shadowCandidateId: string | null, idempotencyKey: string) => { const form = new FormData(); for (const f of files) form.append("files", f, f.name); const q = shadowCandidateId ? `?shadow_candidate_id=${encodeURIComponent(shadowCandidateId)}` : ""; return request(`/v1/batches${q}`, { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: form, }); }, listCandidates: () => request("/v1/candidates"), evaluate: (candidateId: string) => request(`/v1/candidates/${candidateId}/evaluate`, { method: "POST" }), getEvaluation: (id: string) => request(`/v1/evaluations/${id}`), releases: () => request("/v1/releases"), promote: (candidateId: string, expectedChannelVersion: number, reason: string) => request<{ channel: Channel; release: Release }>("/v1/releases/promote", { method: "POST", body: JSON.stringify({ candidate_id: candidateId, expected_channel_version: expectedChannelVersion, reason, }), }), rollback: (expectedChannelVersion: number, reason: string) => request<{ channel: Channel; release: Release }>("/v1/releases/rollback", { method: "POST", body: JSON.stringify({ expected_channel_version: expectedChannelVersion, reason }), }), systemStatus: () => request("/v1/system/status"), };