277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2.
|
|
// Responsibility: Workbench SSE transport lifecycle, cursor ownership, turn-style tick state and recovery actions.
|
|
// Mechanical source references:
|
|
// - OpenCode stream.transport.ts:1-17 transport/turn coordination intent.
|
|
// - OpenCode stream.transport.ts:83-130 Wait/State shape.
|
|
// - OpenCode stream.transport.ts:229-247 abort/stale wait race.
|
|
// - OpenCode stream.transport.ts:391-443 stream acquire/subscribe.
|
|
// - OpenCode stream.transport.ts:504-530 reduce output/trace.
|
|
|
|
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent, type WorkbenchSseIngressFrame } from "@/api/workbench-events";
|
|
import type { WorkbenchRealtimeCapabilities } from "@/config/runtime";
|
|
import { workbenchRealtimeScopeKey } from "@/utils/workbench-key";
|
|
|
|
export type { WorkbenchRealtimeEvent };
|
|
|
|
export interface WorkbenchStreamTransportRestartInput {
|
|
realtimeCapabilities: WorkbenchRealtimeCapabilities;
|
|
sessionId?: string | null;
|
|
traceId?: string | null;
|
|
afterSeq?: number | null;
|
|
forceReconnect?: boolean;
|
|
errorRecoveryMinMs?: number | null;
|
|
flushMaxItemsPerChunk?: number | null;
|
|
flushMaxChunkMs?: number | null;
|
|
flushYieldMs?: number | null;
|
|
onIngress?: (frame: WorkbenchSseIngressFrame) => void;
|
|
onOpen?: () => void;
|
|
onError?: (event: Event) => void;
|
|
onState?: (state: WorkbenchStreamTransportState) => void;
|
|
onRecovery?: (recovery: WorkbenchStreamTransportRecovery) => void;
|
|
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
|
|
}
|
|
|
|
export interface WorkbenchStreamTransportRestartResult {
|
|
key: string;
|
|
changed: boolean;
|
|
streamStarted: boolean;
|
|
}
|
|
|
|
export type WorkbenchStreamTransportPhase = "idle" | "connecting" | "open" | "event" | "error" | "closed" | "blocked";
|
|
|
|
export type WorkbenchStreamTransportRecoveryAction = "events-reconnect";
|
|
|
|
export interface WorkbenchStreamTransportState {
|
|
key: string;
|
|
sessionId: string | null;
|
|
traceId: string | null;
|
|
phase: WorkbenchStreamTransportPhase;
|
|
tick: number;
|
|
live: boolean;
|
|
outboxSeq: number | null;
|
|
traceSeq: number | null;
|
|
reason?: string | null;
|
|
errorName?: string | null;
|
|
diagnostic?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface WorkbenchStreamTransportRecovery {
|
|
key: string;
|
|
sessionId: string | null;
|
|
traceId: string | null;
|
|
tick: number;
|
|
reason: string;
|
|
actions: WorkbenchStreamTransportRecoveryAction[];
|
|
outboxSeq: number | null;
|
|
traceSeq: number | null;
|
|
diagnostic: Record<string, unknown>;
|
|
}
|
|
|
|
interface WorkbenchTransportWait {
|
|
tick: number;
|
|
armed: boolean;
|
|
live: boolean;
|
|
}
|
|
|
|
interface WorkbenchTransportCursor {
|
|
outboxSeq: number | null;
|
|
traceSeq: number | null;
|
|
}
|
|
|
|
export function workbenchRealtimeTraceIdForCapabilities(
|
|
capabilities: WorkbenchRealtimeCapabilities,
|
|
...candidates: Array<string | null | undefined>
|
|
): string | null {
|
|
if (capabilities.liveKafkaSse) return null;
|
|
for (const candidate of candidates) {
|
|
const traceId = typeof candidate === "string" ? candidate.trim() : "";
|
|
if (traceId) return traceId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function workbenchRealtimeTransportEnabled(capabilities: WorkbenchRealtimeCapabilities): boolean {
|
|
return capabilities.liveKafkaSse || capabilities.projectionRealtime;
|
|
}
|
|
|
|
export class WorkbenchStreamTransportRuntime {
|
|
private stream: WorkbenchEventStream | null = null;
|
|
private key = "";
|
|
private generation = 0;
|
|
private tick = 0;
|
|
private wait: WorkbenchTransportWait | null = null;
|
|
private readonly cursorByKey = new Map<string, WorkbenchTransportCursor>();
|
|
private readonly lastRecoveryAtByKey = new Map<string, number>();
|
|
|
|
restart(input: WorkbenchStreamTransportRestartInput): WorkbenchStreamTransportRestartResult {
|
|
const key = workbenchRealtimeScopeKey(input.sessionId ?? null, input.traceId ?? null);
|
|
if (key === this.key && this.stream && input.forceReconnect !== true) return { key, changed: false, streamStarted: true };
|
|
this.stop();
|
|
this.key = key;
|
|
const generation = this.generation;
|
|
this.armWait(key);
|
|
if (!workbenchRealtimeTransportEnabled(input.realtimeCapabilities)) return { key, changed: true, streamStarted: false };
|
|
if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false };
|
|
this.emitState(input, "connecting", null);
|
|
this.stream = connectWorkbenchEvents({
|
|
realtimeCapabilities: input.realtimeCapabilities,
|
|
sessionId: input.sessionId ?? null,
|
|
traceId: input.traceId ?? null,
|
|
afterSeq: !input.realtimeCapabilities.liveKafkaSse && input.realtimeCapabilities.projectionRealtime ? input.afterSeq ?? this.cursorByKey.get(key)?.outboxSeq ?? null : null,
|
|
flushMaxItemsPerChunk: input.flushMaxItemsPerChunk,
|
|
flushMaxChunkMs: input.flushMaxChunkMs,
|
|
flushYieldMs: input.flushYieldMs,
|
|
onIngress: input.onIngress,
|
|
onOpen: () => {
|
|
if (!this.isCurrentStream(key, generation)) return;
|
|
this.markLive(key);
|
|
this.emitState(input, "open", null);
|
|
input.onOpen?.();
|
|
},
|
|
onError: (event) => {
|
|
if (!this.isCurrentStream(key, generation)) return;
|
|
this.markBlocked(key);
|
|
this.emitState(input, "error", null, event.type);
|
|
input.onError?.(event);
|
|
this.requestRecovery(input, event.type || "eventsource-error");
|
|
},
|
|
onEvent: (event, eventName) => {
|
|
if (!this.isCurrentStream(key, generation)) return;
|
|
if (!input.realtimeCapabilities.liveKafkaSse && input.realtimeCapabilities.projectionRealtime) this.rememberCursor(key, event);
|
|
this.markLive(key);
|
|
this.emitState(input, "event", eventName);
|
|
input.onEvent(event, eventName);
|
|
}
|
|
});
|
|
return { key, changed: true, streamStarted: Boolean(this.stream) };
|
|
}
|
|
|
|
stop(): void {
|
|
const previousKey = this.key;
|
|
this.generation += 1;
|
|
this.stream?.close();
|
|
this.stream = null;
|
|
this.key = "";
|
|
if (previousKey) this.wait = null;
|
|
}
|
|
|
|
currentKey(): string {
|
|
return this.key;
|
|
}
|
|
|
|
currentCursor(key: string = this.key): WorkbenchTransportCursor {
|
|
return this.cursorByKey.get(key) ?? { outboxSeq: null, traceSeq: null };
|
|
}
|
|
|
|
private isCurrentStream(key: string, generation: number): boolean {
|
|
return this.stream !== null && this.key === key && this.generation === generation;
|
|
}
|
|
|
|
clear(key?: string | null): void {
|
|
const normalized = normalizeKey(key);
|
|
if (normalized) {
|
|
this.cursorByKey.delete(normalized);
|
|
this.lastRecoveryAtByKey.delete(normalized);
|
|
if (this.key === normalized) this.stop();
|
|
return;
|
|
}
|
|
this.stop();
|
|
this.cursorByKey.clear();
|
|
this.lastRecoveryAtByKey.clear();
|
|
}
|
|
|
|
private armWait(key: string): void {
|
|
this.tick += 1;
|
|
this.wait = { tick: this.tick, armed: Boolean(key), live: false };
|
|
}
|
|
|
|
private markLive(key: string): void {
|
|
if (!this.wait || this.key !== key) return;
|
|
this.wait = { ...this.wait, live: true };
|
|
}
|
|
|
|
private markBlocked(key: string): void {
|
|
if (!this.wait || this.key !== key) return;
|
|
this.wait = { ...this.wait, live: false };
|
|
}
|
|
|
|
private rememberCursor(key: string, event: WorkbenchRealtimeEvent): void {
|
|
const previous = this.cursorByKey.get(key) ?? { outboxSeq: null, traceSeq: null };
|
|
const outboxSeq = maxCursor(previous.outboxSeq, numericCursor(event.cursor?.outboxSeq ?? event.outboxSeq));
|
|
const traceSeq = maxCursor(previous.traceSeq, numericCursor(event.cursor?.traceSeq ?? event.traceSeq ?? event.event?.seq ?? event.event?.projectedSeq));
|
|
this.cursorByKey.set(key, { outboxSeq, traceSeq });
|
|
}
|
|
|
|
private requestRecovery(input: WorkbenchStreamTransportRestartInput, reason: string): void {
|
|
const key = this.key;
|
|
if (!key) return;
|
|
const now = Date.now();
|
|
const minMs = Math.max(0, Math.trunc(input.errorRecoveryMinMs ?? 0));
|
|
const lastAt = this.lastRecoveryAtByKey.get(key) ?? 0;
|
|
if (lastAt > 0 && now - lastAt < minMs) return;
|
|
this.lastRecoveryAtByKey.set(key, now);
|
|
const sessionId = input.sessionId ?? null;
|
|
const traceId = input.traceId ?? null;
|
|
const cursor = this.currentCursor(key);
|
|
const actions: WorkbenchStreamTransportRecoveryAction[] = sessionId || traceId ? ["events-reconnect"] : [];
|
|
const diagnostic = this.diagnosticEnvelope("workbench_sse_recovery", reason, key, actions);
|
|
input.onRecovery?.({ key, sessionId, traceId, tick: this.wait?.tick ?? this.tick, reason, actions, outboxSeq: cursor.outboxSeq, traceSeq: cursor.traceSeq, diagnostic });
|
|
}
|
|
|
|
private emitState(input: WorkbenchStreamTransportRestartInput, phase: WorkbenchStreamTransportPhase, reason: string | null, errorName: string | null = null): void {
|
|
if (!input.onState) return;
|
|
const key = this.key || workbenchRealtimeScopeKey(input.sessionId ?? null, input.traceId ?? null);
|
|
const cursor = this.currentCursor(key);
|
|
input.onState({
|
|
key,
|
|
sessionId: input.sessionId ?? null,
|
|
traceId: input.traceId ?? null,
|
|
phase,
|
|
tick: this.wait?.tick ?? this.tick,
|
|
live: this.wait?.live === true,
|
|
outboxSeq: cursor.outboxSeq,
|
|
traceSeq: cursor.traceSeq,
|
|
reason,
|
|
errorName,
|
|
diagnostic: phase === "error" || phase === "blocked" ? this.diagnosticEnvelope("workbench_sse_transport_error", errorName ?? reason ?? phase, key, []) : null
|
|
});
|
|
}
|
|
|
|
private diagnosticEnvelope(code: string, reason: string, key: string, actions: WorkbenchStreamTransportRecoveryAction[]): Record<string, unknown> {
|
|
return {
|
|
contractVersion: "hwlab-error-diagnostic-v1",
|
|
code,
|
|
category: "transport",
|
|
source: "workbench-web",
|
|
layer: "workbench-stream-transport",
|
|
rootCause: reason,
|
|
recoveryAction: actions.join(",") || "observe-transport-state",
|
|
transportState: this.wait?.live === true ? "live" : "degraded",
|
|
scopedKey: key,
|
|
tick: this.wait?.tick ?? this.tick,
|
|
retryable: true,
|
|
valuesPrinted: false,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
}
|
|
|
|
export function createWorkbenchStreamTransportRuntime(): WorkbenchStreamTransportRuntime {
|
|
return new WorkbenchStreamTransportRuntime();
|
|
}
|
|
|
|
function normalizeKey(value: string | null | undefined): string | null {
|
|
const text = String(value ?? "").trim();
|
|
return text || null;
|
|
}
|
|
|
|
function numericCursor(value: unknown): number | null {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
|
|
}
|
|
|
|
function maxCursor(previous: number | null, next: number | null): number | null {
|
|
if (next === null) return previous;
|
|
return previous === null ? next : Math.max(previous, next);
|
|
}
|