import type { AgentChatResponse, AgentChatResultResponse, AccessDevicePodRelation, AccessToolId, AccessUserRole, AccessUserStatus, AdminAccessMatrixResponse, AdminAccessMutationResponse, AdminAccessSummaryResponse, AdminAccessUsersResponse, ApiResult, ConversationRecord, DevicePodEventsResponse, DevicePodListResponse, DevicePodStatusResponse, LiveBuildsPayload, LiveProbePayload, WebPerformanceSummaryResponse, SkillsResponse, SkillFileResponse, SkillTreeResponse, WorkspaceRecord } from "../../types/domain"; import { recordApiTiming } from "../performance/rum"; export interface ActivityRef { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null; } export type ActivityRefSource = (() => ActivityRef | null) | null; export interface ApiRequestOptions extends RequestInit { timeoutMs?: number; timeoutName?: string; /** * Inactivity-timeout reference. When supplied, the abort is re-evaluated * against `Date.now() - lastActivityAt`, so a long-running fetch stays * alive as long as upstream keeps emitting trace events. Mirrors the * pre-React `app-device-pod.ts:fetchJson` activityRef contract so the * Code Agent / trace / probe endpoints share the same model. */ activityRef?: ActivityRef | ActivityRefSource; } const ACTIVITY_RESCHEDULER_MS = 1000; const DEFAULT_TIMEOUT_MS = 4500; export async function fetchJson(path: string, options: ApiRequestOptions = {}): Promise> { const controller = new AbortController(); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const activitySource = resolveActivitySource(options.activityRef); let timer: number | null = null; const inactivityRef: { current: { idleMs: number; lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } | null } = { current: null }; const startedAt = Date.now(); const method = typeof options.method === "string" ? options.method : "GET"; function readActivity(): { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } { const fallback = { lastActivityAt: startedAt, lastActivityIso: new Date(startedAt).toISOString() }; if (!activitySource) return fallback; const ref = activitySource(); if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return fallback; return ref; } function schedule(): void { if (timer !== null) { window.clearTimeout(timer); timer = null; } if (!activitySource) { timer = window.setTimeout(() => controller.abort(), timeoutMs); return; } const activity = readActivity(); const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); const remaining = timeoutMs - idleMs; if (remaining <= 0) { inactivityRef.current = { idleMs, lastActivityAt: activity.lastActivityAt, lastActivityIso: activity.lastActivityIso, waitingFor: activity.waitingFor, lastEventLabel: activity.lastEventLabel }; controller.abort(); return; } timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS))); } schedule(); try { const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", headers: { accept: "application/json", ...(options.body ? { "content-type": "application/json" } : {}), ...(options.headers ?? {}) }, signal: controller.signal }); const payload = await response.json().catch(() => null) as T | null; recordApiTiming({ path, method, status: response.status, durationMs: Date.now() - startedAt, outcome: response.ok ? "ok" : "http_error" }); return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) }; } catch (error) { recordApiTiming({ path, method, status: 0, durationMs: Date.now() - startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" }); if (error instanceof DOMException && error.name === "AbortError") { if (inactivityRef.current) { const detail = inactivityRef.current; const idleSeconds = Math.round(detail.idleMs / 1000); return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${idleSeconds}s;waitingFor=${detail.waitingFor ?? "unknown"};lastEventLabel=${detail.lastEventLabel ?? "none"})` }; } return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` }; } return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) }; } finally { if (timer !== null) window.clearTimeout(timer); } } function resolveActivitySource(ref: ApiRequestOptions["activityRef"]): ActivityRefSource { if (typeof ref === "function") return ref; if (ref && typeof ref === "object") return () => ref; return null; } export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise> { const controller = new AbortController(); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const activitySource = resolveActivitySource(options.activityRef); let timer: number | null = null; const inactivityRef: { current: { idleMs: number; lastActivityAt: number } | null } = { current: null }; const startedAt = Date.now(); const method = typeof options.method === "string" ? options.method : "GET"; function readActivity(): number { if (!activitySource) return startedAt; const ref = activitySource(); if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return startedAt; return ref.lastActivityAt; } function schedule(): void { if (timer !== null) window.clearTimeout(timer); if (!activitySource) { timer = window.setTimeout(() => controller.abort(), timeoutMs); return; } const idleMs = Math.max(0, Date.now() - readActivity()); const remaining = timeoutMs - idleMs; if (remaining <= 0) { inactivityRef.current = { idleMs, lastActivityAt: readActivity() }; controller.abort(); return; } timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS))); } schedule(); try { const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", signal: controller.signal }); const text = await response.text(); recordApiTiming({ path, method, status: response.status, durationMs: Date.now() - startedAt, outcome: response.ok ? "ok" : "http_error" }); return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` }; } catch (error) { recordApiTiming({ path, method, status: 0, durationMs: Date.now() - startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" }); if (error instanceof DOMException && error.name === "AbortError") { if (inactivityRef.current) { const detail = inactivityRef.current; return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}s)` }; } return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` }; } return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) }; } finally { if (timer !== null) window.clearTimeout(timer); } } function errorMessage(payload: unknown, fallback: string): string { if (payload && typeof payload === "object") { const record = payload as Record; const nested = record.error && typeof record.error === "object" ? record.error as Record : null; const candidate = nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error; if (typeof candidate === "string" && candidate.trim()) return candidate; } return fallback; } export const api = { session: (): Promise> => fetchJson("/auth/session", { timeoutName: "auth session" }), authBootstrap: (): Promise> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }), login: (username: string, password: string): Promise> => fetchJson("/auth/login", { method: "POST", body: JSON.stringify({ username, password }), timeoutName: "auth login" }), logout: (): Promise> => fetchJson("/auth/logout", { method: "POST", body: JSON.stringify({}) }), healthLive: (): Promise> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }), liveBuilds: (): Promise> => fetchJson("/v1/live-builds", { timeoutMs: 12000, timeoutName: "live builds" }), health: (): Promise> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }), restIndex: (): Promise> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }), adapter: (): Promise> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }), devicePods: (): Promise> => fetchJson("/v1/device-pods", { timeoutMs: 12000, timeoutName: "Device Pods" }), devicePodStatus: (devicePodId: string): Promise> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/status`, { timeoutMs: 12000, timeoutName: "Device Pod status" }), devicePodEvents: (devicePodId: string): Promise> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/events?limit=120`, { timeoutMs: 12000, timeoutName: "Device Pod events" }), adminAccessSummary: (): Promise> => fetchJson("/v1/admin/access/summary", { timeoutMs: 12000, timeoutName: "admin access summary" }), adminAccessUsers: (): Promise> => fetchJson("/v1/admin/access/users", { timeoutMs: 12000, timeoutName: "admin access users" }), adminAccessUser: (userId: string): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { timeoutMs: 12000, timeoutName: "admin access user" }), updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin access user update" }), setAdminAccessDevicePod: (userId: string, devicePodId: string, relation: AccessDevicePodRelation, allowed: boolean): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access device-pod" }), setAdminAccessTool: (userId: string, toolId: AccessToolId, allowed: boolean): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access tool" }), workspace: (projectId: string): Promise> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }), updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }), selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }), conversations: (projectId: string): Promise> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }), deleteConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }), saveConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }), sendAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }), steerAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }), getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }), cancelAgentMessage: (payload: Record): Promise> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }), gateDiagnostics: (): Promise> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }), webPerformanceSummary: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }), skills: (): Promise> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }), uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }), skillTree: (skillId: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }), skillFile: (skillId: string, relativePath: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, { timeoutMs: 12000, timeoutName: "skill file" }) }; export interface GateDiagnosticsResponse { serviceId?: string; route?: string; contractVersion?: string; status?: string; sourceKind?: string; liveBackend?: boolean; observedAt?: string; rowCount?: number; rowsCount?: number; columns?: string[]; rows?: GateDiagnosticsRow[]; error?: string; } export interface GateDiagnosticsRow { category?: string; check?: string; status?: string; statusKey?: string; owner?: string; detail?: string; evidence?: string[]; updatedAt?: string; next?: string; sourceKind?: string; }