319 lines
14 KiB
TypeScript
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;
|
|
}
|