Files
orchestra/web/src/api/client.ts
T
kami 34f3c2888f Rebuild the operator console on the ethos shell
Nine screens against orchestra-ui-spec.md and the accepted mockups, on
the ethos design system: signal violet #8F7AE5, the routing fork motif,
the 64px rail and 56px top bar, mono for every machine value and sans
for every human one.

Each screen was built by its own agent against a fixed foundation, so
the shell, tokens and primitives have one author and the screens cannot
drift into nine dialects.

Real data only. Where no endpoint exists the screen says which one it
needs instead of inventing a value. That is most of what was learned
here: Steer / Correct is disabled because nothing records a human
decision from the web, take-control is disabled because nothing
forwards keystrokes to a pane, context occupancy is missing from three
screens, and projects can show no repo, remote, quality gate or
verification policy because those live only in config.jsonc.

Three bugs the render caught that no computed value would have:

The previous stylesheet fought every shared class name and leaked
properties the new rules never mention, which is how position:fixed
survived on .topbar. It is now scoped under .legacy and applies only to
the login route, which also stops its green accent and its
backdrop-filter from reaching the console.

Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty
does not omit a struct, so absent timestamps arrived populated-looking
and rendered as "739855d ago". Stripped once at the API boundary every
screen reads through, with a test.

Long machine ids overflowed their cards and painted under the next one.

Verified by rendering: chromium screenshots of the dashboard, tasks,
task detail, terminal, workers and review at 1440px, and the dashboard
at 390px. Geist is still not on disk, so both stacks fall back to the
system faces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 02:44:43 +04:00

128 lines
4.2 KiB
TypeScript

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<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const cleaned = stripZeroTimes(v)
if (cleaned !== undefined) out[k] = cleaned
}
return out
}
return value
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
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<Account | undefined> {
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<Account>
}
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<Account>
}
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<Account>('/v1/ui/account', {
method: 'PUT',
body: JSON.stringify(body),
}),
overview: () => request<Overview>('/v1/ui/overview'),
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
upload,
create: (body: unknown) =>
request<CreatedEvent>('/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<Worker[]>('/v1/federation/workers'),
events: (since = 0) => request<Event[]>(`/v1/events?since=${since}`),
debt: () => request<DebtLedger>('/v1/debt'),
action: (id: string, action: string, body: object = {}) =>
request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`, {
method: 'POST',
body: JSON.stringify(body),
}),
}