// Gateway HTTP/SSE 客户端:提交 DSL 任务、订阅 Token 流、登记偏好记忆。 import type { TaskDsl } from "./dsl"; // 开发期直连 Gateway;Wails 打包后可改为本地后端地址或经 Go 绑定。 export const GATEWAY: string = (import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080"; export interface Identity { userId: string; sessionId: string; } export interface AuthUser { id: string; email: string; name?: string; } // ---- JWT 令牌存储(localStorage)---- const TOKEN_KEY = "sdx_token"; let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : ""; export function setToken(t: string): void { authToken = t; try { localStorage.setItem(TOKEN_KEY, t); } catch { /* 隐私模式忽略 */ } } export function clearToken(): void { authToken = ""; try { localStorage.removeItem(TOKEN_KEY); } catch { /* ignore */ } } export function getToken(): string { return authToken; } // bearer 把 JWT 放进请求头(无令牌则不带)。 function bearer(): Record { return authToken ? { Authorization: `Bearer ${authToken}` } : {}; } // guard401 在收到 401 时清理令牌并广播登出事件(App 监听后回到登录页)。 function guard401(res: Response): Response { if (res.status === 401) { clearToken(); if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout")); } return res; } // ---- 鉴权 API ---- export async function authRegister(email: string, password: string, name: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, name }), }); const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string }; if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `注册失败: ${res.status}`); setToken(data.token); return data.user; } export async function authLogin(email: string, password: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string }; if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`); setToken(data.token); return data.user; } // authMe 用当前令牌取登录用户;无效/过期返回 null(用于应用启动校验)。 export async function authMe(): Promise { if (!authToken) return null; const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() }); if (!res.ok) { clearToken(); return null; } const data = (await res.json()) as { user?: AuthUser }; return data.user ?? null; } export function logout(): void { clearToken(); } // submitTask: POST /api/v1/tasks,返回 task_id。 export async function submitTask(dsl: TaskDsl, id: Identity): Promise { const res = guard401( await fetch(`${GATEWAY}/api/v1/tasks`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify(dsl), }), ); if (!res.ok) { // 402=积分/预算不足等业务拒绝:透出后端的友好文案(如「租户积分余额不足,请充值后再试」)。 let msg = `submit failed: ${res.status}`; try { const d = (await res.json()) as { error?: string }; if (d.error) msg = d.error; } catch { /* 非 JSON 响应,保留状态码文案 */ } throw new Error(msg); } const data = (await res.json()) as { task_id: string }; return data.task_id; } // ---- 租户上下文 + 积分余额(面向当前用户自己的租户;只读,不碰 admin 口径)---- export interface TenantCtx { tenant: { id: string; name: string; plan: string; status: string } | null; role: string; credit_balance_micro: number; credit_enforce: boolean; // 平台是否开了余额硬拦截(开了且余额≤0 会拒绝提交) } export async function tenantCurrent(): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current`, { headers: bearer() })); if (!res.ok) return null; const d = (await res.json()) as Partial; return { tenant: d.tenant ?? null, role: d.role ?? "", credit_balance_micro: d.credit_balance_micro ?? 0, credit_enforce: !!d.credit_enforce, }; } // ---- 我所属的租户 + 切换活跃租户(多租户)---- export interface MyTenant { id: string; name: string; slug: string; plan: string; credit_balance_micro: number; shared_billing: boolean; members: number; } export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() })); if (!res.ok) return { tenants: [], active: "" }; const d = (await res.json()) as { tenants?: MyTenant[]; active?: string }; return { tenants: d.tenants ?? [], active: d.active ?? "" }; } export async function switchTenant(tenantId: string): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenant`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ tenant_id: tenantId }) })); if (!res.ok) { const d = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(d.error ?? `switch failed: ${res.status}`); } } // ---- 共享工作区(Space,增量3):活跃空间上下文 + 切换 + 列表 + 建 ---- export interface SpaceCtx { space: { id: string; name: string; kind: string; creator: string; archived: boolean } | null; role: string; // 当前用户在此空间的角色(owner/admin/member/viewer) } export interface MySpace { id: string; tenant_id: string; name: string; kind: string; // personal / project / tenant creator: string; archived: boolean; role: string; members: number; } export async function spaceCurrent(): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces/current`, { headers: bearer() })); if (!res.ok) return null; const d = (await res.json()) as Partial; return { space: d.space ?? null, role: d.role ?? "" }; } export async function mySpaces(): Promise<{ spaces: MySpace[]; active: string }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/me/spaces`, { headers: bearer() })); if (!res.ok) return { spaces: [], active: "" }; const d = (await res.json()) as { spaces?: MySpace[]; active?: string }; return { spaces: d.spaces ?? [], active: d.active ?? "" }; } export async function switchSpace(spaceId: string): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/me/space`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ space_id: spaceId }) })); if (!res.ok) { const d = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(d.error ?? `switch space failed: ${res.status}`); } } export async function createSpace(name: string, kind = "project"): Promise<{ id: string }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ name, kind }) })); if (!res.ok) { const d = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(d.error ?? `create space failed: ${res.status}`); } return (await res.json()) as { id: string }; } // ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)---- export interface UsageDay { day: string; // YYYYMMDD total_tok: number; credits_micro: number; cost_micros: number; task_count: number; } export interface UsageRecent { task_id: string; model: string; total_tok: number; credits_micro: number; cost_micros: number; currency: string; ts: number; } export interface MyUsage { from: string; to: string; balance_micro: number; credit_enforce: boolean; trend: UsageDay[]; totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number }; recent: UsageRecent[]; } export async function myUsage(days = 30): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/me/usage?days=${days}`, { headers: bearer() })); if (!res.ok) throw new Error(`usage failed: ${res.status}`); const d = (await res.json()) as Partial; return { from: d.from ?? "", to: d.to ?? "", balance_micro: d.balance_micro ?? 0, credit_enforce: !!d.credit_enforce, trend: d.trend ?? [], totals: d.totals ?? { total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 }, recent: d.recent ?? [], }; } // taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。 // 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。 export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/tasks/${taskId}`, { headers: bearer() })); if (!res.ok) throw new Error(`status failed: ${res.status}`); const d = (await res.json()) as { status?: string; detail?: string }; return { status: d.status ?? "", detail: d.detail ?? "" }; } // approveTask: POST /api/v1/tasks/:id/approve —— HITL 人工审批决定(批准放行 / 拒绝中止)。 export async function approveTask(taskId: string, approved: boolean, opts?: { node?: string; note?: string }): Promise { const res = guard401( await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/approve`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ approved, node: opts?.node ?? "", note: opts?.note ?? "" }), }), ); if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`); } // streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。 // 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头。 export function streamTokens( taskId: string, onToken: (t: string) => void, onDone: () => void, onError?: (e: unknown) => void, ): () => void { const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/stream`); es.addEventListener("token", (e) => onToken((e as MessageEvent).data)); es.addEventListener("done", () => { es.close(); onDone(); }); es.onerror = (e) => { es.close(); onError?.(e); }; return () => es.close(); } // 执行轨迹事件(与后端 contract.ExecEvent 对应):运行·观测的节点级实时事件。 export interface ExecEvent { seq: number; ts: number; node: string; // init / tool:wiki_search / prompt / model / plan / section:0 / render / task / compile kind: string; // memory|tool|prompt|model|plan|section|render|system phase: string; // start|end|error|info label: string; detail?: string; ms?: number; } // streamExec: 订阅 SSE /api/v1/tasks/:id/exec —— 与 token 流并行,逐节点点亮执行轨迹。 export function streamExec( taskId: string, onEvent: (ev: ExecEvent) => void, onDone: () => void, onError?: (e: unknown) => void, ): () => void { const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/exec`); es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent)); es.addEventListener("done", () => { es.close(); onDone(); }); es.onerror = (e) => { es.close(); onError?.(e); }; return () => es.close(); } // 入库进度事件(与后端 contract.IngestEvent 对应)。 export interface IngestEvent { stage: string; msg?: string; done?: number; total?: number; chunks?: string[]; preview?: string; // 解析阶段:解析出的文本片段 triples?: Triple[]; // 抽实体阶段:LLM 抽出的知识三元组 error?: string; } // idHeaders 把身份带进请求头:JWT(Bearer) 作鉴权与 owner 作用域;X-Session-ID 作多轮会话标识。 // 不再发 X-User-ID —— owner 由网关从已验证 JWT 取(伪造头无效)。 function idHeaders(id: Identity): Record { return { ...bearer(), "X-Session-ID": id.sessionId }; } export interface KbInfo { name: string; kind: string; // folder / project / case / general } // listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。 export async function listKb(id: Identity): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) })); const data = (await res.json()) as { kbs?: KbInfo[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`); return data.kbs ?? []; } // createKb: POST /api/v1/kb/create —— 新建知识库(项目/案件/文件夹/通用)。 export async function createKb(id: Identity, name: string, kind: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/create`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ name, kind }), }); const data = (await res.json()) as { name?: string; kind?: string; error?: string }; if (!res.ok || !data.name) throw new Error(data.error ?? `create failed: ${res.status}`); return { name: data.name, kind: data.kind ?? kind }; } export interface VaultDoc { id: string; // 文件主表雪花 ID —— 选中/取正文/关联一律用它 name: string; ext?: string; // 文件后缀(.md/.pdf…;笔记为空) size?: number; preview?: string; } export interface DocLink { from: string; // 源文件 ID to: string; // 目标文件 ID } // ---- 我的 Agent 编排(服务端保存,owner 隔离)---- export interface AgentInfo { name: string; graph: string; // {nodes,edges} JSON updated_at?: string; owner?: string; // 创建人 user.id(共享工作区) creator?: string; // 创建人名字/邮箱(显示用) mine?: boolean; // 是否本人创建(决定能否删/覆盖) } export async function listAgents(id: Identity): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) })); const data = (await res.json()) as { agents?: AgentInfo[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`); return data.agents ?? []; } export async function saveAgent(id: Identity, name: string, graph: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/agents`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ name, graph }), }); const data = (await res.json()) as { name?: string; error?: string }; if (!res.ok || !data.name) throw new Error(data.error ?? `save agent failed: ${res.status}`); } export async function deleteAgent(id: Identity, name: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/agents?name=${encodeURIComponent(name)}`, { method: "DELETE", headers: idHeaders(id) }); if (!res.ok) { const data = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(data.error ?? `delete agent failed: ${res.status}`); } } // listChatModels: GET /api/v1/admin/models?kind=chat —— 已登记的对话模型名(供编排 Agent 节点选择)。 export async function listChatModels(): Promise { try { const res = await fetch(`${GATEWAY}/api/v1/admin/models?kind=chat`); const data = (await res.json()) as { models?: Array<{ model: string }> }; return (data.models ?? []).map((m) => m.model); } catch { return []; } } // listVault: GET /api/v1/kb/vault —— 文库列表(仅元数据 + 预览,不拉全文)。 export async function listVault(id: Identity, kb: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/vault?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) }); const data = (await res.json()) as { docs?: VaultDoc[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `vault failed: ${res.status}`); return data.docs ?? []; } // getDoc: GET /api/v1/kb/doc?id= —— 按文件 ID 取单篇全文(按需加载,避免列表拉全量)。 export async function getDoc(id: Identity, docId: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/doc?id=${encodeURIComponent(docId)}`, { headers: idHeaders(id) }); const data = (await res.json()) as { content?: string; error?: string }; if (!res.ok) throw new Error(data.error ?? `doc failed: ${res.status}`); return data.content ?? ""; } // listLinks: GET /api/v1/kb/links —— 某库全部 [[双链]](反链/笔记关系图,数据小)。 export async function listLinks(id: Identity, kb: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/links?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) }); const data = (await res.json()) as { links?: DocLink[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `links failed: ${res.status}`); return data.links ?? []; } // saveNote: POST /api/v1/kb/note —— 新建/编辑笔记(落库 + 按 doc 重入库替换旧块)。 export async function saveNote(id: Identity, kb: string, name: string, content: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/note`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ kb, name, content }), }); const data = (await res.json()) as { name?: string; error?: string }; if (!res.ok || !data.name) throw new Error(data.error ?? `save failed: ${res.status}`); } // ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。 export async function ingestKb(id: Identity, kb: string, text: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ kb, text }), }); const data = (await res.json()) as { job_id?: string; error?: string }; if (!res.ok || !data.job_id) throw new Error(data.error ?? `ingest failed: ${res.status}`); return data.job_id; } // ingestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。 export async function ingestFile(id: Identity, kb: string, file: File): Promise { const fd = new FormData(); fd.append("kb", kb); fd.append("file", file); const res = await fetch(`${GATEWAY}/api/v1/kb/ingest_file`, { method: "POST", headers: idHeaders(id), body: fd }); const data = (await res.json()) as { job_id?: string; error?: string }; if (!res.ok || !data.job_id) throw new Error(data.error ?? `ingest file failed: ${res.status}`); return data.job_id; } // streamIngest: SSE 订阅入库进度(/kb/ingest/:id/stream)。返回关闭函数。 export function streamIngest( jobId: string, onEvent: (ev: IngestEvent) => void, onDone: () => void, onError?: () => void, ): () => void { const es = new EventSource(`${GATEWAY}/api/v1/kb/ingest/${jobId}/stream`); es.addEventListener("progress", (e) => onEvent(JSON.parse((e as MessageEvent).data) as IngestEvent)); es.addEventListener("done", () => { es.close(); onDone(); }); es.onerror = () => { es.close(); onError?.(); }; return () => es.close(); } export interface KbHit { text: string; score: number; } export interface Triple { s: string; p: string; o: string; } // graphKb: GET /api/v1/kb/graph —— 取某知识库的图谱三元组(→ mcp-go kb_graph,Neo4j)。 export async function graphKb(id: Identity, kb: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) }); const data = (await res.json()) as { triples?: Triple[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `graph failed: ${res.status}`); return data.triples ?? []; } // searchKb: POST /api/v1/kb/search,检索台查询(→ mcp-go kb_search,带分数)。 export async function searchKb(id: Identity, kb: string, q: string, topK = 5): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/search`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ kb, q, topK }), }); const data = (await res.json()) as { hits?: KbHit[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `search failed: ${res.status}`); return data.hits ?? []; } // generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。 // 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。 export async function generateReport(id: Identity, topic: string, kb?: string): Promise { const res = guard401( await fetch(`${GATEWAY}/api/v1/reports`, { method: "POST", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ topic, kb: kb ?? "" }), }), ); const data = (await res.json()) as { task_id?: string; error?: string }; if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`); return data.task_id; } // reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。 export function reportDownloadUrl(taskId: string): string { return `${GATEWAY}/api/v1/reports/${taskId}/download`; } // reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。 export function reportExportUrl(taskId: string, format: "docx" | "md"): string { return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`; } // setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。 export async function setMemory( id: Identity, key: string, value: string, ): Promise { const res = guard401( await fetch(`${GATEWAY}/api/v1/memory`, { method: "PUT", headers: { "Content-Type": "application/json", ...idHeaders(id) }, body: JSON.stringify({ key, value }), }), ); const data = (await res.json()) as { message?: string; error?: string }; if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`); return data.message ?? "ok"; } // 一条长期偏好(含读路径打分维度)。 export interface MemoryItem { key: string; value: string; importance: number; last_seen: string; } // listMemory: GET /api/v1/memory —— 列出当前用户偏好(已按 Score 降序)。 export async function listMemory(id: Identity): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/memory`, { headers: idHeaders(id) })); const data = (await res.json()) as { memories?: MemoryItem[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `list memory failed: ${res.status}`); return data.memories ?? []; } // deleteMemory: DELETE /api/v1/memory?key= —— 软删一条偏好。 export async function deleteMemory(id: Identity, key: string): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/memory?key=${encodeURIComponent(key)}`, { method: "DELETE", headers: idHeaders(id) })); if (!res.ok) { const d = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(d.error ?? `delete memory failed: ${res.status}`); } } // ── 工作台仪表盘聚合(GET /stats/overview)── export interface KeyCount { key: string; count: number } export interface RecentRun { task_id: string; status: string; detail: string; at: string } export interface Overview { tasks_today: number; tasks_total: number; status_count: KeyCount[]; task_trend: KeyCount[]; token_trend: KeyCount[]; eval_avg: number; faithful_avg: number; eval_count: number; kb_docs: number; kb_count: number; tokens_today: number; daily_budget: number; recent_runs: RecentRun[]; services: Record; } export async function statsOverview(): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: bearer() })); if (!res.ok) throw new Error(`stats failed: ${res.status}`); return res.json() as Promise; } // ── 运行历史 / 复盘 ── export interface RunSummary { task_id: string; status: string; detail: string; at: string; eval_level: string; eval_overall: number; } export async function listRuns(limit = 30): Promise { const res = guard401(await fetch(`${GATEWAY}/api/v1/runs?limit=${limit}`, { headers: bearer() })); if (!res.ok) throw new Error(`list runs failed: ${res.status}`); const d = (await res.json()) as { runs?: RunSummary[] }; return d.runs ?? []; } export interface EvalResult { overall: number; rule: number; llm: number; faithful: number; level: string; flags: string[]; reason: string; sources: number; corrected: boolean; } // taskEval: 取一次任务的评测结果(无则返回 null)。 export async function taskEval(taskId: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/eval`, { headers: bearer() }); if (!res.ok) return null; return res.json() as Promise; } // runReplay: 历史运行复盘 —— 读库取持久化的输出 + 轨迹(不依赖 Redis TTL,秒回)。 export async function runReplay(taskId: string): Promise<{ output: string; exec: ExecEvent[] }> { const res = guard401(await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/replay`, { headers: bearer() })); if (!res.ok) throw new Error(`replay failed: ${res.status}`); const d = (await res.json()) as { output?: string; exec?: ExecEvent[] }; return { output: d.output ?? "", exec: d.exec ?? [] }; }