import type { Account, CreatedEvent, DebtLedger, Detail, Event, Overview, Worker } from './types' function sessionExpired(response: Response) { if (response.status === 401) { window.dispatchEvent(new Event('orchestra:unauthorized')) } } async function responseError(response: Response) { const message = (await response.text()).trim() return new Error(message || `${response.status} ${response.statusText}`) } /** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does * not omit a struct, so an absent timestamp arrives populated-looking. Every * screen that formatted one rendered "739855d ago", which reads as data. * Stripping it here is one guard on the boundary every screen reads through, * rather than one guard per screen per field. */ function stripZeroTimes(value: unknown): unknown { if (typeof value === 'string') { return value.startsWith('0001-01-01') ? undefined : value } if (Array.isArray(value)) return value.map(stripZeroTimes) if (value && typeof value === 'object') { const out: Record = {} for (const [k, v] of Object.entries(value as Record)) { const cleaned = stripZeroTimes(v) if (cleaned !== undefined) out[k] = cleaned } return out } return value } async function request(path: string, init?: RequestInit): Promise { const response = await fetch(path, { credentials: 'same-origin', ...init, headers: init?.body ? { 'Content-Type': 'application/json', ...init.headers } : init?.headers, }) if (!response.ok) { sessionExpired(response) throw await responseError(response) } return stripZeroTimes(await response.json()) as T } async function text(path: string) { const response = await fetch(path, { credentials: 'same-origin' }) if (!response.ok) { sessionExpired(response) throw await responseError(response) } return response.text() } async function upload(body: string) { const response = await fetch('/v1/artifacts', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'text/markdown' }, body, }) if (!response.ok) { sessionExpired(response) throw await responseError(response) } return ((await response.json()) as { ref: string }).ref } async function session(): Promise { const response = await fetch('/v1/ui/session', { credentials: 'same-origin' }) if (response.status === 401) return undefined if (!response.ok) throw await responseError(response) return response.json() as Promise } async function login(username: string, password: string) { const response = await fetch('/v1/ui/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }) if (!response.ok) throw await responseError(response) return response.json() as Promise } async function logout() { const response = await fetch('/v1/ui/session', { method: 'DELETE', credentials: 'same-origin', }) if (!response.ok) throw await responseError(response) } export const api = { session, login, logout, updateAccount: (body: { current_password: string; username: string; new_password?: string }) => request('/v1/ui/account', { method: 'PUT', body: JSON.stringify(body), }), overview: () => request('/v1/ui/overview'), detail: (id: string) => request(`/v1/ui/tasks/${id}`), artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`), upload, create: (body: unknown) => request('/v1/ui/tasks', { method: 'POST', body: JSON.stringify(body), }), // Endpoints outside /v1/ui that the console reads directly. Adding a UI // wrapper for each would be a second copy of the same projection. workers: () => request('/v1/federation/workers'), events: (since = 0) => request(`/v1/events?since=${since}`), debt: () => request('/v1/debt'), action: (id: string, action: string, body: object = {}) => request(`/v1/ui/tasks/${id}/actions/${action}`, { method: 'POST', body: JSON.stringify(body), }), }