307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2.
|
|
// Responsibility: Workbench SSE client. Realtime events and sync replay are projection authority; REST routes are detail/history reads.
|
|
|
|
import { fetchJson, type ApiRequestOptions } from "@/api/client";
|
|
import type { ApiResult, ChatMessage, ProjectionDiagnostic, TraceEvent } from "@/types";
|
|
import { createCoalescedEventQueue } from "@/utils/scheduler/coalesced-event-queue";
|
|
import { composeWorkbenchScopedKey, firstScopePart } from "@/utils/workbench-key";
|
|
import { recordWorkbenchRuntimeDiagnostic, recordWorkbenchSseLifecycle } from "@/utils/workbench-performance";
|
|
import type { WorkbenchRealtimeCapabilities } from "@/config/runtime";
|
|
|
|
export interface WorkbenchRealtimeTraceSnapshot {
|
|
traceId?: string | null;
|
|
status?: string | null;
|
|
events?: TraceEvent[];
|
|
eventCount?: number | null;
|
|
lastEvent?: TraceEvent | null;
|
|
updatedAt?: string | null;
|
|
projection?: ProjectionDiagnostic | null;
|
|
projectionStatus?: string | null;
|
|
projectionHealth?: string | null;
|
|
staleMs?: number | null;
|
|
blocker?: ProjectionDiagnostic["blocker"] | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface WorkbenchRealtimeEvent {
|
|
schema?: string | null;
|
|
eventType?: string | null;
|
|
eventId?: string | null;
|
|
sourceEventId?: string | null;
|
|
capabilities?: WorkbenchRealtimeCapabilities | null;
|
|
liveOnly?: boolean | null;
|
|
replay?: boolean | null;
|
|
lossPossible?: boolean | null;
|
|
type?: string;
|
|
contractVersion?: string | null;
|
|
realtimeAuthority?: string | null;
|
|
status?: string;
|
|
serverSentAt?: string | null;
|
|
eventCreatedAt?: string | null;
|
|
hwlabSessionId?: string | null;
|
|
sessionId?: string | null;
|
|
threadId?: string | null;
|
|
traceId?: string | null;
|
|
runId?: string | null;
|
|
commandId?: string | null;
|
|
event?: TraceEvent | null;
|
|
snapshot?: WorkbenchRealtimeTraceSnapshot | null;
|
|
message?: ChatMessage | null;
|
|
turn?: Record<string, unknown> | null;
|
|
reason?: string | null;
|
|
error?: { code?: string; message?: string; [key: string]: unknown } | null;
|
|
traceSeq?: number | null;
|
|
outboxSeq?: number | null;
|
|
cursor?: { traceSeq?: number | null; outboxSeq?: number | null; [key: string]: unknown };
|
|
entity?: {
|
|
family?: string | null;
|
|
id?: string | null;
|
|
version?: number | string | null;
|
|
entityVersion?: number | string | null;
|
|
outboxSeq?: number | string | null;
|
|
traceSeq?: number | string | null;
|
|
projectionRevision?: string | null;
|
|
committedAt?: string | null;
|
|
serverCommittedAt?: string | null;
|
|
authority?: string | null;
|
|
detailProjection?: boolean | null;
|
|
[key: string]: unknown;
|
|
} | null;
|
|
authority?: string | null;
|
|
detailProjection?: boolean | null;
|
|
projectionRevision?: string | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface WorkbenchSyncReplayRequest {
|
|
sessionId?: string | null;
|
|
traceId?: string | null;
|
|
since?: number | null;
|
|
}
|
|
|
|
export interface WorkbenchSyncReplayResponse {
|
|
contractVersion?: string | null;
|
|
realtimeAuthority?: string | null;
|
|
scope?: Record<string, unknown> | null;
|
|
cursor?: Record<string, unknown> | null;
|
|
events?: WorkbenchRealtimeEvent[];
|
|
delta?: WorkbenchRealtimeEvent[] | {
|
|
sessions?: Record<string, unknown>[];
|
|
messages?: Record<string, unknown>[];
|
|
parts?: Record<string, unknown>[];
|
|
turns?: Record<string, unknown>[];
|
|
traceEvents?: Record<string, unknown>[];
|
|
checkpoints?: Record<string, unknown>[];
|
|
[key: string]: unknown;
|
|
};
|
|
families?: Record<string, unknown> | null;
|
|
authority?: Record<string, unknown> | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface WorkbenchEventStreamOptions {
|
|
realtimeCapabilities: WorkbenchRealtimeCapabilities;
|
|
sessionId?: string | null;
|
|
traceId?: string | null;
|
|
afterSeq?: number | null;
|
|
flushMaxItemsPerChunk?: number | null;
|
|
flushMaxChunkMs?: number | null;
|
|
flushYieldMs?: number | null;
|
|
onIngress?: (frame: WorkbenchSseIngressFrame) => void;
|
|
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
|
|
onOpen?: () => void;
|
|
onError?: (event: Event) => void;
|
|
}
|
|
|
|
export interface WorkbenchEventStream {
|
|
close: () => void;
|
|
}
|
|
|
|
export interface WorkbenchSseIngressFrame {
|
|
eventName: string;
|
|
rawText: string;
|
|
decodeStatus: "accepted" | "invalid-json" | "not-object";
|
|
}
|
|
|
|
interface QueuedRealtimeEvent {
|
|
payload: WorkbenchRealtimeEvent;
|
|
eventName: string;
|
|
}
|
|
|
|
const WORKBENCH_EVENT_NAMES = [
|
|
"hwlab.event.v1",
|
|
"workbench.connected",
|
|
"workbench.trace.snapshot",
|
|
"workbench.trace.event",
|
|
"workbench.trace.unavailable",
|
|
"workbench.message.snapshot",
|
|
"workbench.turn.snapshot",
|
|
"workbench.heartbeat",
|
|
"workbench.error"
|
|
];
|
|
|
|
export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): WorkbenchEventStream | null {
|
|
if (typeof EventSource === "undefined") return null;
|
|
const eventRoute = workbenchEventStreamPath(options);
|
|
const connectStartedAt = typeof performance === "undefined" ? Date.now() : performance.now();
|
|
recordWorkbenchSseLifecycle({ state: "connect", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
|
|
const source = new EventSource(eventRoute, { withCredentials: true });
|
|
const queue = createCoalescedEventQueue<QueuedRealtimeEvent>({
|
|
keyOf: (item) => realtimeCoalesceKey(item.payload, item.eventName),
|
|
maxItemsPerChunk: options.flushMaxItemsPerChunk,
|
|
maxChunkMs: options.flushMaxChunkMs,
|
|
yieldSchedule: (flush) => scheduleRealtimeFlushYield(flush, options.flushYieldMs),
|
|
onFlush: (items) => {
|
|
for (const item of items) options.onEvent(item.payload, item.eventName);
|
|
},
|
|
onDrain: (info) => {
|
|
if (info.eventCount === 0 && info.droppedCount === 0) return;
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-events",
|
|
sessionId: options.sessionId,
|
|
traceId: options.traceId,
|
|
outcome: "ok",
|
|
diagnostic: {
|
|
code: "workbench_sse_flush",
|
|
reason: info.reason,
|
|
source: "eventsource-coalesced-queue",
|
|
eventCount: info.eventCount,
|
|
deliveredCount: info.deliveredCount,
|
|
dropped: info.droppedCount,
|
|
chunkCount: info.chunkCount,
|
|
replacedByKey: info.replacedByKey,
|
|
flushDurationMs: Math.round(info.flushDurationMs),
|
|
maxItemsPerChunk: info.maxItemsPerChunk,
|
|
maxChunkMs: Math.round(info.maxChunkMs),
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
});
|
|
source.onopen = () => {
|
|
const openedAt = typeof performance === "undefined" ? Date.now() : performance.now();
|
|
recordWorkbenchSseLifecycle({ state: "open", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId, valueMs: Math.max(0, Math.round(openedAt - connectStartedAt)) });
|
|
options.onOpen?.();
|
|
};
|
|
source.onerror = (event) => {
|
|
recordWorkbenchSseLifecycle({ state: "error", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId, errorName: event.type });
|
|
options.onError?.(event);
|
|
};
|
|
const listeners = WORKBENCH_EVENT_NAMES.map((name) => {
|
|
const listener = (event: MessageEvent) => {
|
|
enqueueRealtimeFrame(event.data, name, options, queue);
|
|
};
|
|
source.addEventListener(name, listener);
|
|
return { name, listener };
|
|
});
|
|
source.onmessage = (event) => {
|
|
enqueueRealtimeFrame(event.data, "message", options, queue);
|
|
};
|
|
return {
|
|
close() {
|
|
queue.clear("stream-close");
|
|
for (const { name, listener } of listeners) source.removeEventListener(name, listener);
|
|
recordWorkbenchSseLifecycle({ state: "close", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
|
|
source.close();
|
|
}
|
|
};
|
|
}
|
|
|
|
export function workbenchEventStreamPath(options: Pick<WorkbenchEventStreamOptions, "realtimeCapabilities" | "sessionId" | "traceId" | "afterSeq">): string {
|
|
const params = new URLSearchParams();
|
|
appendParam(params, "sessionId", options.sessionId);
|
|
appendParam(params, "traceId", options.traceId);
|
|
if (!options.realtimeCapabilities.liveKafkaSse && options.realtimeCapabilities.projectionRealtime) appendNumberParam(params, "afterSeq", options.afterSeq);
|
|
return `/v1/workbench/events?${params.toString()}`;
|
|
}
|
|
|
|
export function workbenchProjectionEventStreamPath(options: Pick<WorkbenchEventStreamOptions, "sessionId" | "traceId" | "afterSeq">): string {
|
|
const params = new URLSearchParams();
|
|
appendParam(params, "sessionId", options.sessionId);
|
|
appendParam(params, "traceId", options.traceId);
|
|
appendNumberParam(params, "afterSeq", options.afterSeq);
|
|
return `/v1/workbench/projection-events?${params.toString()}`;
|
|
}
|
|
|
|
export async function fetchWorkbenchSyncReplay(input: WorkbenchSyncReplayRequest, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchSyncReplayResponse>> {
|
|
return fetchJson<WorkbenchSyncReplayResponse>(workbenchSyncReplayPath(input), {
|
|
...options,
|
|
timeoutName: options.timeoutName ?? "workbench sync replay"
|
|
});
|
|
}
|
|
|
|
export function workbenchSyncReplayPath(input: WorkbenchSyncReplayRequest): string {
|
|
const params = new URLSearchParams();
|
|
appendParam(params, "sessionId", input.sessionId);
|
|
appendParam(params, "traceId", input.traceId);
|
|
appendNumberParam(params, "since", input.since);
|
|
return `/v1/workbench/sync?${params.toString()}`;
|
|
}
|
|
|
|
function appendParam(params: URLSearchParams, key: string, value: string | null | undefined): void {
|
|
const text = typeof value === "string" ? value.trim() : "";
|
|
if (text) params.set(key, text);
|
|
}
|
|
|
|
function appendNumberParam(params: URLSearchParams, key: string, value: number | null | undefined): void {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
params.set(key, String(Math.trunc(parsed)));
|
|
}
|
|
|
|
function enqueueRealtimeFrame(raw: unknown, eventName: string, options: WorkbenchEventStreamOptions, queue: ReturnType<typeof createCoalescedEventQueue<QueuedRealtimeEvent>>): void {
|
|
const rawText = typeof raw === "string" ? raw : String(raw ?? "");
|
|
const decoded = decodeRealtimeEvent(rawText);
|
|
options.onIngress?.({ eventName, rawText, decodeStatus: decoded.status });
|
|
if (decoded.payload) queue.push({ payload: decoded.payload, eventName });
|
|
}
|
|
|
|
function decodeRealtimeEvent(raw: string): { payload: WorkbenchRealtimeEvent | null; status: WorkbenchSseIngressFrame["decodeStatus"] } {
|
|
try {
|
|
const value = JSON.parse(raw);
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? { payload: value as WorkbenchRealtimeEvent, status: "accepted" }
|
|
: { payload: null, status: "not-object" };
|
|
} catch {
|
|
return { payload: null, status: "invalid-json" };
|
|
}
|
|
}
|
|
|
|
function scheduleRealtimeFlushYield(flush: () => void, yieldMs: number | null | undefined): () => void {
|
|
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" && (!Number.isFinite(Number(yieldMs)) || Number(yieldMs) <= 0)) {
|
|
const id = window.requestAnimationFrame(() => flush());
|
|
return () => window.cancelAnimationFrame(id);
|
|
}
|
|
const delay = Math.max(0, Math.trunc(Number(yieldMs ?? 0)));
|
|
const id = setTimeout(() => flush(), delay);
|
|
return () => clearTimeout(id);
|
|
}
|
|
|
|
export function realtimeCoalesceKey(event: WorkbenchRealtimeEvent, eventName: string): string | null {
|
|
if (eventName === "hwlab.event.v1" || event.schema === "hwlab.event.v1") {
|
|
const eventId = firstScopePart(event.eventId, event.sourceEventId, event.event?.sourceEventId);
|
|
return eventId ? composeWorkbenchScopedKey("workbench.sse.live-kafka-event", eventId) : null;
|
|
}
|
|
const entity = event.entity;
|
|
const entityFamily = firstScopePart(entity?.family);
|
|
const entityId = firstScopePart(entity?.id);
|
|
const entityVersion = numericCursor(entity?.version ?? entity?.entityVersion);
|
|
if (entityFamily && entityId && entityVersion !== null) return composeWorkbenchScopedKey("workbench.realtime.entity", entityFamily, entityId, entityVersion);
|
|
const sessionId = firstScopePart(event.sessionId, event.message?.sessionId, event.snapshot?.sessionId, event.event?.sessionId, event.turn?.sessionId);
|
|
const traceId = firstScopePart(event.traceId, event.message?.traceId, event.snapshot?.traceId, event.event?.traceId, event.turn?.traceId);
|
|
const outboxSeq = numericCursor(event.cursor?.outboxSeq ?? event.outboxSeq);
|
|
const traceSeq = numericCursor(event.cursor?.traceSeq ?? event.traceSeq ?? event.event?.seq ?? event.event?.projectedSeq);
|
|
if (eventName === "workbench.turn.snapshot" && traceId) return composeWorkbenchScopedKey("workbench.sse.turn-snapshot", sessionId, traceId);
|
|
if (eventName === "workbench.trace.event" || eventName === "message") {
|
|
const eventSeq = outboxSeq ?? traceSeq;
|
|
return eventSeq === null ? null : composeWorkbenchScopedKey("workbench.sse.event", sessionId, traceId, eventName, eventSeq);
|
|
}
|
|
const messageId = firstScopePart(event.message?.messageId, event.message?.id);
|
|
return composeWorkbenchScopedKey("workbench.sse.snapshot", sessionId, traceId, eventName, messageId, outboxSeq ?? traceSeq);
|
|
}
|
|
|
|
function numericCursor(value: unknown): number | null {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
|
|
}
|