Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/api/workbench.ts
T
2026-07-14 07:18:51 +02:00

319 lines
14 KiB
TypeScript

// 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 SessionDetailOptions {
includeMessages?: boolean | 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<string, unknown>;
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<string, unknown>;
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<ApiResult<WorkbenchLaunchResponse>> => fetchJson("/v1/workbench/launches", { method: "POST", body: JSON.stringify(input), timeoutMs: 30000, timeoutName: "workbench launch" }),
sessions: (options: SessionListOptions = {}): Promise<ApiResult<WorkbenchSessionListResponse>> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }),
session: (sessionId: string, options: SessionDetailOptions | number | null = {}): Promise<ApiResult<WorkbenchSessionDetailResponse>> => {
const detailOptions = sessionDetailOptions(options);
return fetchJson(sessionDetailPath(sessionId, detailOptions), { timeoutMs: requestTimeoutMs(detailOptions.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" });
},
sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }),
traceEvents: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: WorkbenchTraceRequestOptions): Promise<ApiResult<AgentChatResultResponse>> => normalizeWorkbenchTraceResult(await fetchJson<Record<string, unknown>>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "workbench trace", activityRef }), traceId)
};
export function workbenchSessionDetailPathForTest(sessionId: string, options: SessionDetailOptions | number | null = {}): string {
return sessionDetailPath(sessionId, sessionDetailOptions(options));
}
export function workbenchSessionMessagesPathForTest(sessionId: string, options: SessionMessageOptions = {}): string {
return sessionMessagesPath(sessionId, options);
}
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 sessionDetailPath(sessionId: string, options: SessionDetailOptions): string {
const params = new URLSearchParams();
params.set("includeMessages", options.includeMessages === true ? "true" : "false");
return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}?${params.toString()}`;
}
function sessionDetailOptions(input: SessionDetailOptions | number | null | undefined): SessionDetailOptions {
if (typeof input === "number") return { timeoutMs: input, includeMessages: false };
if (!input || typeof input !== "object") return { includeMessages: false };
return { ...input, includeMessages: input.includeMessages === true };
}
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 normalizeWorkbenchTraceResult(result: ApiResult<Record<string, unknown>>, traceId: string): ApiResult<AgentChatResultResponse> {
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
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 recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : 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;
}