// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1. // Responsibility: Workbench read-model HTTP client for session, message, turn and trace projection APIs. import { fetchJson, type ApiRequestOptions } from "./client"; import type { AgentChatResultResponse, ApiResult, ChatMessage, ProjectionDiagnostic, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types"; export interface WorkbenchSessionListResponse { sessions?: WorkbenchSessionRecord[]; count?: number; total?: number; cursor?: string | null; hasMore?: boolean; nextCursor?: string | null; } export interface WorkbenchSessionDetailResponse { session?: WorkbenchSessionRecord; } export interface WorkbenchMessagePageResponse { sessionId?: string | null; messages?: ChatMessage[]; count?: number; total?: number; hasMore?: boolean; } export interface SessionListOptions { includeSessionId?: string | null; limit?: number | null; cursor?: string | null; timeoutMs?: number | null; } export interface SessionMessageOptions { limit?: number | null; cursor?: string | null; timeoutMs?: number | null; } export interface WorkbenchTraceRequestOptions { afterProjectedSeq?: number | null; limit?: number | null; } export interface WorkbenchLaunchContext { source?: string | null; projectId: string; taskRef: string; sourceId?: string | null; sourceKind?: string | null; fileRef?: string | null; relativePath?: string | null; fileName?: string | null; taskId?: string | null; title?: string | null; status?: string | null; hwpodId?: string | null; nodeId?: string | null; workspaceRootRef?: string | null; workspaceRootHash?: string | null; workspaceRootLabel?: string | null; mdtodoRootRef?: string | null; hwpodWorkspaceArgs?: string | null; contextFingerprint?: string | null; providerProfile?: string | null; capabilities?: Record; executionContext?: WorkbenchLaunchExecutionContext | null; bodyPreview?: string | null; reportLinks?: Array<{ linkId?: string; label?: string; href?: string; relativePath?: string | null }>; } export interface WorkbenchLaunchExecutionContext { sourceId?: string | null; sourceKind?: string | null; projectId?: string | null; taskRef?: string | null; fileRef?: string | null; relativePath?: string | null; taskId?: string | null; hwpodId?: string | null; nodeId?: string | null; workspaceRootRef?: string | null; workspaceRootHash?: string | null; workspaceRootLabel?: string | null; mdtodoRootRef?: string | null; hwpodWorkspaceArgs?: string | null; contextFingerprint?: string | null; capabilities?: Record; valuesRedacted?: boolean; } export interface WorkbenchLaunchRequest { projectId: string; taskRef: string; sessionId?: string | null; conversationId?: string | null; providerProfile?: string | null; launchContext?: WorkbenchLaunchContext; } export interface WorkbenchLaunchResponse { ok?: boolean; status?: string; contractVersion?: string; sessionId?: string | null; conversationId?: string | null; projectId?: string | null; taskRef?: string | null; workbenchUrl?: string | null; linkId?: string | null; launchContext?: WorkbenchLaunchContext | null; valuesRedacted?: boolean; } const SESSION_LIST_TIMEOUT_MS = 30_000; const SESSION_DETAIL_TIMEOUT_MS = 45_000; export const workbenchAPI = { launch: (input: WorkbenchLaunchRequest): Promise> => fetchJson("/v1/workbench/launches", { method: "POST", body: JSON.stringify(input), timeoutMs: 30000, timeoutName: "workbench launch" }), sessions: (options: SessionListOptions = {}): Promise> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }), session: (sessionId: string, timeoutMs: number | null = null): Promise> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }), sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }), turn: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => normalizeWorkbenchTurnResult(await fetchJson>(`/v1/workbench/turns/${encodeURIComponent(traceId)}`, { timeoutMs, timeoutName: "workbench turn", activityRef }), traceId), traceEvents: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: WorkbenchTraceRequestOptions): Promise> => normalizeWorkbenchTraceResult(await fetchJson>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "workbench trace", activityRef }), traceId) }; function sessionListPath(options: SessionListOptions): string { const params = new URLSearchParams(); const includeSessionId = typeof options.includeSessionId === "string" ? options.includeSessionId.trim() : ""; if (includeSessionId) params.set("includeSessionId", includeSessionId); if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit))))); const cursor = typeof options.cursor === "string" ? options.cursor.trim() : ""; if (cursor) params.set("cursor", cursor); const query = params.toString(); return `/v1/workbench/sessions${query ? `?${query}` : ""}`; } function sessionMessagesPath(sessionId: string, options: SessionMessageOptions): string { const params = new URLSearchParams(); if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit))))); const cursor = typeof options.cursor === "string" ? options.cursor.trim() : ""; if (cursor) params.set("cursor", cursor); const query = params.toString(); return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages${query ? `?${query}` : ""}`; } function requestTimeoutMs(value: number | null | undefined, fallback: number): number { const timeout = Number(value); return Number.isFinite(timeout) && timeout > 0 ? Math.trunc(timeout) : fallback; } function workbenchTracePath(traceId: string, options: WorkbenchTraceRequestOptions = {}): string { const params = new URLSearchParams(); if (Number.isFinite(options.afterProjectedSeq)) params.set("afterProjectedSeq", String(Math.max(0, Math.trunc(Number(options.afterProjectedSeq))))); if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit))))); const query = params.toString(); return `/v1/workbench/traces/${encodeURIComponent(traceId)}/events${query ? `?${query}` : ""}`; } function normalizeWorkbenchTurnResult(result: ApiResult>, traceId: string): ApiResult { if (!result.ok || !result.data) return result as ApiResult; const turn = recordValue(result.data.turn) ?? result.data; const trace = recordValue(turn.trace); const status = normalizeStatus(textValue(turn.status)) ?? "unknown"; const projection = normalizeProjectionDiagnostic(result.data.projection, turn.projection, result.data); const timing = normalizeTimingProjection(turn); return { ...result, data: { ...turn, projection, timing, startedAt: timing?.startedAt ?? undefined, lastEventAt: timing?.lastEventAt ?? undefined, finishedAt: timing?.finishedAt ?? undefined, durationMs: timing?.durationMs ?? undefined, projectionStatus: projection?.projectionStatus ?? textValue(result.data.projectionStatus) ?? undefined, projectionHealth: projection?.projectionHealth ?? textValue(result.data.projectionHealth) ?? undefined, lastProjectedSeq: Number.isFinite(Number(result.data.lastProjectedSeq)) ? Number(result.data.lastProjectedSeq) : undefined, sourceRunId: projection?.sourceRunId ?? textValue(result.data.sourceRunId) ?? undefined, sourceCommandId: projection?.sourceCommandId ?? textValue(result.data.sourceCommandId) ?? undefined, staleMs: projection?.staleMs ?? numberValue(result.data.staleMs) ?? undefined, blocker: projection?.blocker ?? recordValue(result.data.blocker) ?? undefined, traceId: textValue(turn.traceId) ?? traceId, status, running: typeof turn.running === "boolean" ? turn.running : undefined, terminal: typeof turn.terminal === "boolean" ? turn.terminal : undefined, sessionId: textValue(turn.sessionId) ?? undefined, threadId: textValue(turn.threadId) ?? undefined, agentRun: recordValue(turn.agentRun) ?? undefined, updatedAt: textValue(turn.updatedAt) ?? textValue(result.data.observedAt) ?? undefined } as AgentChatResultResponse }; } function normalizeWorkbenchTraceResult(result: ApiResult>, traceId: string): ApiResult { if (!result.ok || !result.data) return result as ApiResult; const events = Array.isArray(result.data.events) ? result.data.events : []; const nextProjectedSeq = Number(result.data.nextProjectedSeq); const traceStatus = textValue(result.data.traceStatus) ?? undefined; const eventCount = Number.isFinite(Number(result.data.eventCount)) ? Number(result.data.eventCount) : events.length; const normalizedNextProjectedSeq = Number.isFinite(nextProjectedSeq) ? Math.trunc(nextProjectedSeq) : null; const hasMore = result.data.hasMore === true; const fullTraceLoaded = typeof result.data.fullTraceLoaded === "boolean" ? result.data.fullTraceLoaded : undefined; const projection = normalizeProjectionDiagnostic(result.data.projection, result.data); const timing = normalizeTimingProjection(result.data); return { ...result, data: { ...result.data, traceId: textValue(result.data.traceId) ?? traceId, status: undefined, traceStatus, timing, startedAt: timing?.startedAt ?? undefined, lastEventAt: timing?.lastEventAt ?? undefined, finishedAt: timing?.finishedAt ?? undefined, durationMs: timing?.durationMs ?? undefined, events, traceEvents: events, eventCount, hasMore, fullTraceLoaded, projection, projectionStatus: projection?.projectionStatus ?? textValue(result.data.projectionStatus) ?? undefined, projectionHealth: projection?.projectionHealth ?? textValue(result.data.projectionHealth) ?? undefined, staleMs: projection?.staleMs ?? numberValue(result.data.staleMs) ?? undefined, blocker: projection?.blocker ?? recordValue(result.data.blocker) ?? undefined, nextProjectedSeq: normalizedNextProjectedSeq, range: recordValue(result.data.range) ?? undefined, runnerTrace: { traceId: textValue(result.data.traceId) ?? traceId, status: undefined, timing, startedAt: timing?.startedAt ?? undefined, lastEventAt: timing?.lastEventAt ?? undefined, finishedAt: timing?.finishedAt ?? undefined, durationMs: timing?.durationMs ?? undefined, events, eventCount, hasMore, fullTraceLoaded, nextProjectedSeq: normalizedNextProjectedSeq, range: recordValue(result.data.range) ?? undefined, projection, projectionStatus: projection?.projectionStatus ?? textValue(result.data.projectionStatus) ?? undefined, projectionHealth: projection?.projectionHealth ?? textValue(result.data.projectionHealth) ?? undefined, staleMs: projection?.staleMs ?? numberValue(result.data.staleMs) ?? undefined, blocker: projection?.blocker ?? recordValue(result.data.blocker) ?? undefined, eventSource: "trace-api" } } as AgentChatResultResponse }; } function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | undefined { const record = recordValue(value); const source = recordValue(record?.timing); if (!source) return undefined; const startedAt = textValue(source.startedAt); const lastEventAt = textValue(source.lastEventAt); const finishedAt = textValue(source.finishedAt); const durationMs = numberValue(source.durationMs); // observedAt and lastEventAgeMs are server-side diagnostic snapshots. // They must NOT be used as the user-visible relative time authority. // Running relative time display must use startedAt/lastEventAt + browser local now(). const observedAt = textValue(source.observedAt); const lastEventAgeMs = numberValue(source.lastEventAgeMs); if (!startedAt && !lastEventAt && !finishedAt && durationMs == null && lastEventAgeMs == null) return undefined; return { ...source, startedAt: startedAt ?? null, lastEventAt: lastEventAt ?? null, finishedAt: finishedAt ?? null, durationMs, observedAt: observedAt ?? null, lastEventAgeMs: lastEventAgeMs ?? null, valuesRedacted: source.valuesRedacted !== false } as WorkbenchTurnTimingProjection; } function normalizeStatus(value: unknown): string | null { const text = textValue(value); if (!text) return null; const normalized = text.toLowerCase().replace(/_/gu, "-"); return normalized === "cancelled" ? "canceled" : normalized; } function recordValue(value: unknown): Record | null { return value && typeof value === "object" ? value as Record : null; } function normalizeProjectionDiagnostic(...values: unknown[]): ProjectionDiagnostic | undefined { for (const value of values) { const record = recordValue(value); if (!record) continue; const source = recordValue(record.projection) ?? record; const blocker = recordValue(source.blocker ?? record.blocker); const projectionStatus = textValue(source.projectionStatus ?? record.projectionStatus); const projectionHealth = textValue(source.projectionHealth ?? record.projectionHealth); const lastProjectedSeq = numberValue(source.lastProjectedSeq ?? record.lastProjectedSeq); const staleMs = numberValue(source.staleMs ?? record.staleMs); const sourceRunId = textValue(source.sourceRunId ?? record.sourceRunId); const sourceCommandId = textValue(source.sourceCommandId ?? record.sourceCommandId); if (!projectionStatus && !projectionHealth && !blocker && lastProjectedSeq == null && staleMs == null && !sourceRunId && !sourceCommandId) continue; return { ...source, projectionStatus: projectionStatus ?? undefined, projectionHealth: projectionHealth ?? undefined, lastProjectedSeq, sourceRunId: sourceRunId ?? undefined, sourceCommandId: sourceCommandId ?? undefined, staleMs, blocker: blocker ?? null, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic; } return undefined; } function numberValue(value: unknown): number | null { const number = Number(value); return Number.isFinite(number) ? number : null; } function textValue(...values: unknown[]): string | null { for (const value of values) { if (typeof value !== "string") continue; const text = value.trim(); if (text) return text; } return null; }