// @homepilot/compute-client — jobs, progress, devices, and sharing policy. // // THE single source of truth for Wave A/B compute behaviour. Web, desktop, and // mobile all build the same client and call the same methods — so a fix here // (e.g. job-polling) reaches every app on its next build, with no duplication. // // Transport for progress events is an injected port (SSE on web/desktop, a // polyfill or polling on mobile), keeping this module platform-pure. import type { ApiClient } from "@homepilot/api-client"; import type { Device, Job, JobEvent, ComputeStatus, SupplierPolicy, ComputeSource, ComputeDevice, ModelManifest, ModelRoute, ComputeSettings, RouteResolution, LocalResources, SourceTestResult, CloudSyncResult, } from "@homepilot/types"; export interface ImageJobInput { model?: string; prompt: string; negativePrompt?: string; width?: number; height?: number; steps?: number; seed?: number; } export interface VideoJobInput { model?: string; prompt?: string; image?: string; } /** Port: each app injects its event transport (SSE / polyfill / polling). */ export interface EventTransport { subscribe(url: string, onEvent: (event: JobEvent) => void): () => void; } export interface ComputeClient { // Wave A — generation jobs createImageJob(input: ImageJobInput): Promise; createVideoJob(input: VideoJobInput): Promise; getJobStatus(id: string): Promise; subscribeToJobEvents( id: string, onEvent: (event: JobEvent) => void, transport: EventTransport, ): () => void; getComputeStatus(): Promise; // Wave B / Batch 8 — device sharing listUserDevices(): Promise; getDevicePolicy(deviceId: string): Promise; setDevicePolicy(deviceId: string, policy: Partial): Promise; // PR 2/3 — Compute Sources & Routing admin (/compute/admin/*) listComputeSources(): Promise; /** Upsert a source. Pass `credential` to store a secret out-of-band (write-only). */ upsertComputeSource( source: Partial & { id: string; credential?: string }, ): Promise; deleteComputeSource(id: string): Promise; setSourceCredential(id: string, credential: string): Promise<{ credentialRef: string }>; testComputeSource(id: string): Promise; listComputeDevices(sourceId?: string): Promise; getLocalResources(): Promise; /** Pull paired devices + advertised models from OllaBridge Cloud into the registry. */ syncFromCloud(): Promise; listModelManifests(): Promise; upsertModelManifest(manifest: ModelManifest): Promise; deleteModelManifest(id: string): Promise; listRoutes(): Promise; upsertRoute(route: ModelRoute): Promise; deleteRoute(modality: string, modelId: string): Promise; getComputeSettings(): Promise; putComputeSettings(patch: Partial): Promise; resolveRoute(modality: string, modelId?: string): Promise; } export function createComputeClient(api: ApiClient): ComputeClient { // The HTTP API speaks snake_case; @homepilot/types is camelCase. Normalize // responses in one place so every app gets correctly-shaped objects, while // requests are snake_case via toSnake(). This is the value of the SSOT: the // wire/representation seam lives here, not duplicated per app. const get = (path: string) => api.get(path).then((r) => camelize(r) as T); const post = (path: string, body?: unknown) => api.post(path, body).then((r) => camelize(r) as T); const put = (path: string, body?: unknown) => api.put(path, body).then((r) => camelize(r) as T); const del = (path: string) => api.del(path).then(() => undefined); return { createImageJob: (input) => post("/v1/images/generations", toSnake(input)), createVideoJob: (input) => post("/v1/videos/generations", toSnake(input)), getJobStatus: (id) => get(`/v1/jobs/${id}`), subscribeToJobEvents: (id, onEvent, transport) => transport.subscribe(`${api.baseUrl}/v1/jobs/${id}/events`, onEvent), getComputeStatus: () => get("/compute/status"), listUserDevices: () => get("/v1/devices"), getDevicePolicy: (deviceId) => get(`/v1/devices/${deviceId}/policy`), setDevicePolicy: (deviceId, policy) => put(`/v1/devices/${deviceId}/policy`, toSnake(policy)), // --- Compute admin --- listComputeSources: () => get<{ sources: ComputeSource[] }>("/compute/admin/sources").then((r) => r.sources), upsertComputeSource: (source) => post<{ source: ComputeSource }>("/compute/admin/sources", deepSnake(source)).then( (r) => r.source, ), deleteComputeSource: (id) => del(`/compute/admin/sources/${encodeURIComponent(id)}`), setSourceCredential: (id, credential) => post<{ credentialRef: string }>( `/compute/admin/sources/${encodeURIComponent(id)}/credential`, { credential }, ), testComputeSource: (id) => post(`/compute/admin/sources/${encodeURIComponent(id)}/test`), listComputeDevices: (sourceId) => get<{ devices: ComputeDevice[] }>( `/compute/admin/devices${sourceId ? `?source_id=${encodeURIComponent(sourceId)}` : ""}`, ).then((r) => r.devices), getLocalResources: () => get("/v1/system/resources"), syncFromCloud: () => post("/compute/admin/sync"), listModelManifests: () => get<{ models: ModelManifest[] }>("/compute/admin/models").then((r) => r.models), upsertModelManifest: (manifest) => post<{ model: ModelManifest }>("/compute/admin/models", deepSnake(manifest)).then( (r) => r.model, ), deleteModelManifest: (id) => del(`/compute/admin/models/${encodeURIComponent(id)}`), listRoutes: () => get<{ routes: ModelRoute[] }>("/compute/admin/routes").then((r) => r.routes), upsertRoute: (route) => post<{ route: ModelRoute }>("/compute/admin/routes", deepSnake(route)).then( (r) => r.route, ), deleteRoute: (modality, modelId) => del( `/compute/admin/routes/${encodeURIComponent(modality)}/${encodeURIComponent(modelId)}`, ), getComputeSettings: () => get<{ settings: ComputeSettings }>("/compute/admin/settings").then((r) => r.settings), putComputeSettings: (patch) => put<{ settings: ComputeSettings }>("/compute/admin/settings", deepSnake(patch)).then( (r) => r.settings, ), resolveRoute: (modality, modelId) => get( `/compute/admin/resolve?modality=${encodeURIComponent(modality)}` + (modelId ? `&model_id=${encodeURIComponent(modelId)}` : ""), ), }; } const toCamel = (key: string): string => key.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase()); /** Recursively rewrite object keys snake_case → camelCase (values untouched). */ function camelize(value: unknown): unknown { if (Array.isArray(value)) return value.map(camelize); if (value && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[toCamel(k)] = camelize(v); } return out; } return value; } /** Shallow camelCase → snake_case for request bodies (the API speaks snake). */ function toSnake(obj: object): Record { const out: Record = {}; for (const [key, value] of Object.entries(obj)) { if (value === undefined) continue; out[key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`)] = value; } return out; } const snakeKey = (key: string): string => key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); /** Recursive camelCase → snake_case for nested request bodies (e.g. compute * settings with per-modality defaults). Arrays and primitives pass through. */ function deepSnake(value: unknown): unknown { if (Array.isArray(value)) return value.map(deepSnake); if (value && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { if (v === undefined) continue; out[snakeKey(k)] = deepSnake(v); } return out; } return value; }