mirror of
https://github.com/hanzoai/mobile.git
synced 2026-10-30 14:53:17 +00:00
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
import { bearer, reason, refusal, v1, type Refusal } from './api'
|
|
|
|
// The cloud half of the tasks plane: agent sessions from /v1/agents/sessions.
|
|
// One fetch, one defensive normalize — envelope and field names drift across
|
|
// deployments, so every read degrades to something honest ('—') rather than
|
|
// throwing or inventing.
|
|
|
|
export type Session = {
|
|
id: string
|
|
title: string
|
|
// running | paused | done | error when the server's word is recognizable;
|
|
// otherwise the server's word itself, verbatim, never a guess.
|
|
status: string
|
|
// Last movement as ms epoch, only when the server said.
|
|
at?: number
|
|
}
|
|
|
|
export type Load =
|
|
| { kind: 'ok'; sessions: Session[] }
|
|
| { kind: 'anon' }
|
|
| { kind: 'refused'; refusal: Refusal }
|
|
|
|
export async function load(): Promise<Load> {
|
|
const auth = await bearer()
|
|
// No credential is not a refusal — nothing was asked. The screen offers
|
|
// sign-in instead of sending a request that can only bounce.
|
|
if (!auth.Authorization) return { kind: 'anon' }
|
|
let res: Response
|
|
try {
|
|
res = await fetch(v1('/agents/sessions'), {
|
|
headers: { Accept: 'application/json', ...auth },
|
|
})
|
|
} catch {
|
|
return {
|
|
kind: 'refused',
|
|
refusal: refusal(0, 'No connection. Check the network and pull to retry.'),
|
|
}
|
|
}
|
|
if (!res.ok) {
|
|
// reason() in lib/api is the ONE reader of what a server said — strict
|
|
// JSON, key redaction, capped — so a refusal here speaks with the same
|
|
// voice as every other surface.
|
|
const body = await res.text().catch(() => '')
|
|
return { kind: 'refused', refusal: refusal(res.status, reason(body)) }
|
|
}
|
|
const body = await res.json().catch(() => null)
|
|
return { kind: 'ok', sessions: normalize(body) }
|
|
}
|
|
|
|
// Words other deployments use for the same four states. Anything not here
|
|
// passes through verbatim — an unknown word rendered plainly beats a wrong
|
|
// chip.
|
|
const alias: Record<string, string> = {
|
|
active: 'running',
|
|
in_progress: 'running',
|
|
started: 'running',
|
|
working: 'running',
|
|
complete: 'done',
|
|
completed: 'done',
|
|
finished: 'done',
|
|
succeeded: 'done',
|
|
failed: 'error',
|
|
errored: 'error',
|
|
}
|
|
|
|
function normalize(body: unknown): Session[] {
|
|
return rows(body).map((row, index) => {
|
|
const raw = (row && typeof row === 'object' ? row : {}) as Record<string, unknown>
|
|
const status = (text(raw.status) ?? text(raw.state) ?? '—').toLowerCase()
|
|
return {
|
|
id: text(raw.id) ?? text(raw.session_id) ?? `row-${index}`,
|
|
title: text(raw.title) ?? text(raw.name) ?? text(raw.task) ?? '—',
|
|
status: alias[status] ?? status,
|
|
at: when(
|
|
raw.updated_at ?? raw.updatedAt ?? raw.created_at ?? raw.createdAt ?? raw.started_at ?? raw.startedAt
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
function rows(body: unknown): unknown[] {
|
|
if (Array.isArray(body)) return body
|
|
if (body && typeof body === 'object') {
|
|
for (const key of ['data', 'items', 'sessions'] as const) {
|
|
const value = (body as Record<string, unknown>)[key]
|
|
if (Array.isArray(value)) return value
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
function text(value: unknown): string | undefined {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
return undefined
|
|
}
|
|
|
|
function when(value: unknown): number | undefined {
|
|
// Epochs arrive in seconds or milliseconds; anything under ~2001-09 as ms
|
|
// is really seconds.
|
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
return value < 1e12 ? value * 1000 : value
|
|
}
|
|
if (typeof value === 'string') {
|
|
const t = Date.parse(value)
|
|
if (!Number.isNaN(t)) return t
|
|
}
|
|
return undefined
|
|
}
|