fix: complete opencode workbench runtime migration

This commit is contained in:
UniDesk Codex
2026-06-30 21:02:06 +08:00
parent 1d3c2ad4e5
commit a3d25a8b1d
12 changed files with 425 additions and 182 deletions
+9 -1
View File
@@ -29,12 +29,18 @@ const requiredFiles = Object.freeze([
"src/utils/scheduler/keyed-singleflight.ts",
"src/utils/safe-storage.ts",
"src/utils/workbench-health.ts",
"src/utils/workbench-error-runtime.ts",
"src/utils/workbench-realtime-runtime.ts",
"src/utils/workbench-refresh-runtime.ts",
"src/utils/workbench-storage-runtime.ts",
"src/utils/workbench-stream-transport.ts",
"src/stores/index.ts",
"src/stores/app.ts",
"src/stores/auth.ts",
"src/stores/workbench.ts",
"src/stores/workbench-timeline-model.ts",
"src/stores/workbench-session-cache.ts",
"src/composables/useWorkbenchScrollRuntime.ts",
"src/composables/useTraceSubscription.ts",
"src/composables/useAutoRefresh.ts",
"src/composables/useClipboard.ts",
@@ -71,6 +77,7 @@ const html = readWeb("index.html");
const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
const appSource = readCloudWebAppSource(rootDir);
const workbenchStoreSource = readWeb("src/stores/workbench.ts");
const workbenchRealtimeRuntimeSource = `${readWeb("src/utils/workbench-realtime-runtime.ts")}\n${readWeb("src/utils/workbench-stream-transport.ts")}`;
const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue");
const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue");
const conversationPanelSource = readWeb("src/components/workbench/ConversationPanel.vue");
@@ -110,7 +117,8 @@ assertIncludes(appSource, "useTableLoader", "table loader composable must remain
assertIncludes(appSource, "useForm", "form composable must remain available for CRUD dialogs");
assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web session cookie boundary");
assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved");
assertIncludes(workbenchStoreSource, "connectWorkbenchEvents", "Workbench must use the unified SSE realtime entry");
assertIncludes(workbenchStoreSource, "createWorkbenchStreamTransportRuntime", "Workbench store must enter SSE through the realtime runtime boundary");
assertIncludes(workbenchRealtimeRuntimeSource, "connectWorkbenchEvents", "Workbench realtime runtime must own the unified SSE EventSource entry");
assert.doesNotMatch(workbenchStoreSource, /scheduleRealtimeGapHydration|hydrateRealtimeGap/u, "Workbench realtime consumer must not repair projection through REST gap fill");
assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling");
assertIncludes(workbenchStoreSource, "if (existing) return;", "Session list scheduled refreshes must coalesce instead of resetting timers under SSE error storms");
@@ -3,8 +3,9 @@ import { computed, ref, watch } from "vue";
import type { RunnerTrace } from "@/types";
import LoadingState from "@/components/common/LoadingState.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
import { formatDisplayClock } from "@/config/runtime";
import { readWorkbenchString, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>();
@@ -12,7 +13,7 @@ const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolea
const TRACE_RENDER_ROW_WINDOW = 120;
const listRef = ref<HTMLOListElement | null>(null);
const expanded = ref(false);
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useBottomFollowScroll(listRef, { threshold: 24 });
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(listRef, { threshold: 24 });
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
@@ -74,17 +75,15 @@ function setExpandedProgrammatically(value: boolean): void {
function readStoredExpanded(): boolean | null {
if (!props.storageKey) return null;
try {
const value = window.localStorage.getItem(props.storageKey);
if (value === "1") return true;
if (value === "0") return false;
} catch { /* ignore */ }
const value = readWorkbenchString(props.storageKey, "");
if (value === "1") return true;
if (value === "0") return false;
return null;
}
function writeStoredExpanded(value: boolean): void {
if (!props.storageKey) return;
try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ }
writeWorkbenchString(props.storageKey, value ? "1" : "0");
}
function rowIsTool(row: TraceEventRow): boolean {
@@ -13,11 +13,12 @@ import MessageActions from "@/components/workbench/MessageActions.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
import { useWorkbenchStore } from "@/stores/workbench";
import { buildWorkbenchTimelineRows, workbenchTimelineSignature } from "@/stores/workbench-timeline-model";
import { normalizeErrorDiagnostic as normalizeWorkbenchErrorDiagnostic } from "@/utils/workbench-error-runtime";
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
import { traceIdentityText } from "./message-rendering";
import { traceLifecycleExpanded } from "./trace-lifecycle";
@@ -25,7 +26,7 @@ import { traceLifecycleExpanded } from "./trace-lifecycle";
const workbench = useWorkbenchStore();
const clipboard = useClipboard();
const panelRef = ref<HTMLElement | null>(null);
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 });
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56 });
const detailMessageId = ref<string | null>(null);
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value));
@@ -163,27 +164,7 @@ function messageErrorDiagnostic(message: ChatMessage): ErrorDiagnostic | null {
}
function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
for (const value of values) {
const record = recordValue(value);
if (!record) continue;
return {
...record,
contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"),
traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id),
requestId: firstNonEmptyString(record.requestId, record.request_id),
serviceId: firstNonEmptyString(record.serviceId, record.service_id),
route: firstNonEmptyString(record.route),
layer: firstNonEmptyString(record.layer),
category: firstNonEmptyString(record.category),
code: firstValue(record.code),
httpStatus: firstNumber(record.httpStatus, record.http_status),
source: firstNonEmptyString(record.source),
observedAt: firstNonEmptyString(record.observedAt, record.observed_at),
retryable: firstBooleanValue(record.retryable),
valuesPrinted: record.valuesPrinted === true
};
}
return null;
return normalizeWorkbenchErrorDiagnostic(...values);
}
function recordValue(value: unknown): Record<string, unknown> | null {
@@ -2,7 +2,7 @@
import { computed, ref, watch } from "vue";
import type { ChatMessage, RunnerTrace, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
import { formatDisplayClock } from "@/config/runtime";
import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering";
import { traceDisplayRows, traceNoiseEventCount } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
@@ -11,7 +11,7 @@ const props = defineProps<{ message: ChatMessage }>();
const rawListRef = ref<HTMLOListElement | null>(null);
const rawMode = ref(false);
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom, toggleFollowing } = useBottomFollowScroll(rawListRef, { threshold: 24 });
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom, toggleFollowing } = useWorkbenchBottomFollowScroll(rawListRef, { threshold: 24 });
const trace = computed(() => props.message.runnerTrace ?? null);
const events = computed(() => Array.isArray(trace.value?.events) ? trace.value.events : []);
@@ -8,10 +8,12 @@ import { useClipboard } from "@/composables/useClipboard";
import LoadingState from "@/components/common/LoadingState.vue";
import { formatDisplayDateTime } from "@/config/runtime";
import { workbenchSessionPath } from "@/utils";
import { readWorkbenchNumber, readWorkbenchString, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
const workbench = useWorkbenchStore();
const { copied, copy } = useClipboard();
const COLLAPSED_STORAGE_KEY = "hwlab.workbench.sessionRailCollapsed.v1";
const WIDTH_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
const width = ref(readWidth());
const collapsed = ref(readCollapsed());
const dragging = ref(false);
@@ -58,29 +60,21 @@ function keyResize(event: KeyboardEvent): void {
function setWidth(value: number): void {
width.value = Math.min(Math.max(Math.round(value), 220), 380);
try { localStorage.setItem("hwlab.workbench.sessionSidebarWidth.v1", String(width.value)); } catch { /* ignore */ }
writeWorkbenchString(WIDTH_STORAGE_KEY, String(width.value));
}
function readWidth(): number {
try {
const parsed = Number(localStorage.getItem("hwlab.workbench.sessionSidebarWidth.v1"));
return Number.isFinite(parsed) ? Math.min(Math.max(Math.round(parsed), 220), 380) : 300;
} catch {
return 300;
}
const parsed = readWorkbenchNumber(WIDTH_STORAGE_KEY, 300);
return Number.isFinite(parsed) ? Math.min(Math.max(Math.round(parsed), 220), 380) : 300;
}
function toggleCollapsed(): void {
collapsed.value = !collapsed.value;
try { localStorage.setItem(COLLAPSED_STORAGE_KEY, collapsed.value ? "true" : "false"); } catch { /* ignore */ }
writeWorkbenchString(COLLAPSED_STORAGE_KEY, collapsed.value ? "true" : "false");
}
function readCollapsed(): boolean {
try {
return localStorage.getItem(COLLAPSED_STORAGE_KEY) === "true";
} catch {
return false;
}
return readWorkbenchString(COLLAPSED_STORAGE_KEY, "false") === "true";
}
function formatSessionUpdatedTime(value: string | null | undefined): string {
@@ -0,0 +1,9 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Workbench scroll/follow runtime boundary over the shared bottom-follow primitive.
import type { Ref } from "vue";
import { useBottomFollowScroll, type BottomFollowScrollOptions } from "@/composables/useBottomFollowScroll";
export function useWorkbenchBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: BottomFollowScrollOptions = {}) {
return useBottomFollowScroll(containerRef, options);
}
+45 -135
View File
@@ -4,7 +4,11 @@
import { computed, nextTick, ref } from "vue";
import { defineStore } from "pinia";
import { api } from "@/api";
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health";
import { normalizeApiErrorRecord as normalizeRuntimeApiErrorRecord, normalizeErrorDiagnostic as normalizeRuntimeErrorDiagnostic } from "@/utils/workbench-error-runtime";
import { createWorkbenchReadHydrationRuntime, shouldCooldownWorkbenchReadFailure as shouldCooldownWorkbenchReadRuntimeFailure } from "@/utils/workbench-refresh-runtime";
import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
import { createWorkbenchStreamTransportRuntime, type WorkbenchRealtimeEvent } from "@/utils/workbench-realtime-runtime";
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types";
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
@@ -13,6 +17,7 @@ import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbench
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { trimWorkbenchSessionCache } from "./workbench-session-cache";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
@@ -89,18 +94,15 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const realtimeSessionRefreshInFlight = new Set<string>();
const realtimeErrorGapFillLastAtByKey = new Map<string, number>();
const activeTraceRestGapFillTimers = new Map<string, number>();
let realtimeStream: WorkbenchEventStream | null = null;
let realtimeKey = "";
const realtimeTransport = createWorkbenchStreamTransportRuntime();
const realtimeOutboxSeqByKey = new Map<string, number>();
const sessionListRefreshInFlight = new Map<string, Promise<void>>();
const sessionListRefreshTimers = new Map<string, number>();
const sessionListLastRefreshAtByKey = new Map<string, number>();
const workbenchProjectionSignalSourceId = nextProtocolId("wbtab");
let workbenchProjectionSignalChannel: BroadcastChannel | null = null;
let workbenchReadHydrationActive = 0;
const workbenchReadHydrationQueue: Array<() => void> = [];
const workbenchReadCooldownUntilByKey = new Map<string, number>();
const workbenchReadLastStartedAtByKey = new Map<string, number>();
const workbenchReadHydrationRuntime = createWorkbenchReadHydrationRuntime({ concurrency: WORKBENCH_READ_HYDRATION_CONCURRENCY, failureCooldownMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS });
const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS });
const projectedActiveSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value));
const routeActiveSession = computed(() => routeSelectedSessionRecord(explicitSessionId.value, sessionDetailLoadingId.value, error.value, projectedActiveSession.value));
@@ -186,16 +188,27 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function refreshLive(): Promise<void> {
const [healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds] = await Promise.all([
liveCall("health/live", api.healthLive),
liveCall("health", api.health),
liveCall("REST index", api.restIndex),
liveCall("hwpod specs", api.hwpod.specs),
liveCall("hwpod node ops", api.hwpod.nodeOpsHealth),
liveCall("live builds", api.liveBuilds)
liveHealthCall("health/live", api.healthLive),
liveHealthCall("health", api.health),
liveHealthCall("REST index", api.restIndex),
liveHealthCall("hwpod specs", api.hwpod.specs),
liveHealthCall("hwpod node ops", api.hwpod.nodeOpsHealth),
liveHealthCall("live builds", api.liveBuilds)
]);
live.value = { healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() };
}
async function liveHealthCall<T>(label: string, call: () => Promise<ApiResult<T>>): Promise<ApiResult<T>> {
const snapshot = await workbenchHealthProbeCache.probe<ApiResult<T>>({
key: label,
fetcher: () => liveCall(label, call),
classify: (value) => value.ok ? "ok" : "degraded"
});
if (snapshot.value) return snapshot.value;
const diagnostic: ErrorDiagnostic = { contractVersion: "hwlab-error-diagnostic-v1", code: "workbench_health_probe_unavailable", category: "health", layer: "workbench-health-runtime", source: "workbench-web", retryable: true, valuesPrinted: false };
return { ok: false, status: 0, data: null, error: `${label}: health probe unavailable`, diagnostic } as ApiResult<T>;
}
async function createSession(): Promise<void> {
beginSessionSelection();
loading.value = true;
@@ -396,6 +409,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function rememberSessionList(source: WorkbenchSessionRecord[]): void {
reduceServerState({ type: "session.list", sessions: source });
const trimmed = trimWorkbenchSessionCache(serverState.value, { retainSessionIds: [activeSessionId.value, explicitSessionId.value], maxSessions: currentSessionListLimit() });
serverState.value = trimmed.state;
}
function forgetSession(sessionId: string): void {
@@ -458,50 +473,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function runWorkbenchReadHydration<T>(task: () => Promise<T>, cooldownKey?: string, options: { minIntervalMs?: number; force?: boolean } = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
const run = () => {
if (cooldownKey) {
const cooldownUntilMs = workbenchReadCooldownUntilByKey.get(cooldownKey) ?? 0;
if (cooldownUntilMs > Date.now()) {
resolve(createWorkbenchReadCooldownResult(cooldownKey, cooldownUntilMs) as T);
const next = workbenchReadHydrationQueue.shift();
if (next) next();
return;
}
if (cooldownUntilMs > 0) workbenchReadCooldownUntilByKey.delete(cooldownKey);
const minIntervalMs = Math.max(0, Math.trunc(options.minIntervalMs ?? 0));
const lastStartedAtMs = workbenchReadLastStartedAtByKey.get(cooldownKey) ?? 0;
if (!options.force && minIntervalMs > 0 && lastStartedAtMs > 0 && Date.now() - lastStartedAtMs < minIntervalMs) {
resolve(createWorkbenchReadThrottledResult(cooldownKey, lastStartedAtMs + minIntervalMs) as T);
const next = workbenchReadHydrationQueue.shift();
if (next) next();
return;
}
if (minIntervalMs > 0) workbenchReadLastStartedAtByKey.set(cooldownKey, Date.now());
}
workbenchReadHydrationActive += 1;
Promise.resolve()
.then(task)
.then(
(value) => {
if (cooldownKey && isApiResultLike(value) && shouldCooldownWorkbenchReadFailure(value)) {
workbenchReadCooldownUntilByKey.set(cooldownKey, Date.now() + WORKBENCH_READ_FAILURE_COOLDOWN_MS);
}
resolve(value);
},
reject,
)
.finally(() => {
workbenchReadHydrationActive = Math.max(0, workbenchReadHydrationActive - 1);
const next = workbenchReadHydrationQueue.shift();
if (next) next();
});
};
if (workbenchReadHydrationActive < WORKBENCH_READ_HYDRATION_CONCURRENCY) {
run();
return;
}
workbenchReadHydrationQueue.push(run);
return workbenchReadHydrationRuntime.run(task, cooldownKey, {
minIntervalMs: options.minIntervalMs,
force: options.force,
shouldCooldownFailure: (value) => isApiResultLike(value) && shouldCooldownWorkbenchReadFailure(value),
reason: cooldownKey ?? null,
});
}
@@ -568,13 +544,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function shouldCooldownWorkbenchReadFailure(result: ApiResult<unknown>): boolean {
const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic);
const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code);
const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category);
const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source);
if (result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network") return true;
if (result.status === 503) return code === "projection_store_unavailable" || code === "workbench_read_model_store_unavailable";
return false;
return shouldCooldownWorkbenchReadRuntimeFailure(result);
}
function fetchWorkbenchTurnStatus(traceId: string, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
@@ -1109,7 +1079,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
function setProviderProfile(value: ProviderProfile): void {
providerProfile.value = value;
localStorage.setItem("hwlab.workbench.providerProfile.v1", value);
writeWorkbenchString("hwlab.workbench.providerProfile.v1", value);
}
async function refreshProviderOptions(): Promise<boolean> {
@@ -1129,7 +1099,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
function clearRecentDrafts(): void {
recentDrafts.value = [];
try { localStorage.removeItem(RECENT_DRAFTS_STORAGE_KEY); } catch { /* ignore */ }
removeWorkbenchStorageKey(RECENT_DRAFTS_STORAGE_KEY);
}
function clearSessionMessages(): void {
@@ -1166,12 +1136,8 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
const traceId = realtimeTraceId();
const sessionId = selectedSessionId.value ?? null;
const key = workbenchRealtimeScopeKey(sessionId, traceId);
if (key === realtimeKey && realtimeStream) return;
stopRealtime();
realtimeKey = key;
if (!sessionId && !traceId) return;
const afterSeq = realtimeOutboxSeqByKey.get(key) ?? null;
realtimeStream = connectWorkbenchEvents({
realtimeTransport.restart({
sessionId,
traceId,
afterSeq,
@@ -1241,9 +1207,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
}
function stopRealtime(): void {
realtimeStream?.close();
realtimeStream = null;
realtimeKey = "";
realtimeTransport.stop();
}
function realtimeTraceId(): string | null {
@@ -1453,7 +1417,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
if (!id) return;
const payload = { type: "session-projection", sourceId: workbenchProjectionSignalSourceId, sessionId: id, traceId: firstNonEmptyString(traceId) ?? null, reason, at: new Date().toISOString(), valuesRedacted: true };
try { workbenchProjectionSignalChannel?.postMessage(payload); } catch { /* ignore */ }
try { window.localStorage.setItem(WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY, JSON.stringify(payload)); } catch { /* ignore */ }
writeWorkbenchString(WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY, JSON.stringify(payload));
}
function handleWorkbenchProjectionSignal(value: unknown): void {
@@ -2401,51 +2365,11 @@ function normalizeProjectionBlocker(value: unknown, context: { diagnostic?: Erro
}
function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
for (const value of values) {
const record = recordValue(value);
if (!record) continue;
const httpStatus = firstFiniteNumber(record.httpStatus, record.http_status);
return {
...record,
contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"),
traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id),
requestId: firstNonEmptyString(record.requestId, record.request_id),
serviceId: firstNonEmptyString(record.serviceId, record.service_id),
route: firstNonEmptyString(record.route),
layer: firstNonEmptyString(record.layer),
category: firstNonEmptyString(record.category),
code: firstStringOrNumber(record.code),
httpStatus: httpStatus ?? null,
source: firstNonEmptyString(record.source),
observedAt: firstNonEmptyString(record.observedAt, record.observed_at),
retryable: firstBoolean(record.retryable),
valuesPrinted: record.valuesPrinted === true
};
}
return null;
return normalizeRuntimeErrorDiagnostic(...values);
}
function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null {
const record = recordValue(value);
if (!record) return null;
const normalizedDiagnostic = normalizeErrorDiagnostic(record.diagnostic, diagnostic);
const message = firstNonEmptyString(record.message, record.userMessage, record.reason, fallback);
return {
...record,
message: message ?? fallback ?? "Workbench 请求失败",
userMessage: firstNonEmptyString(record.userMessage, record.message, fallback),
code: firstStringOrNumber(record.code, normalizedDiagnostic?.code),
reason: firstNonEmptyString(record.reason),
retryable: firstBoolean(record.retryable, normalizedDiagnostic?.retryable),
layer: firstNonEmptyString(record.layer, normalizedDiagnostic?.layer),
category: firstNonEmptyString(record.category, normalizedDiagnostic?.category),
route: firstNonEmptyString(record.route, normalizedDiagnostic?.route),
traceId: firstNonEmptyString(record.traceId, normalizedDiagnostic?.traceId),
requestId: firstNonEmptyString(record.requestId, normalizedDiagnostic?.requestId),
source: firstNonEmptyString(record.source, normalizedDiagnostic?.source),
diagnostic: normalizedDiagnostic,
valuesPrinted: record.valuesPrinted === true
} as ApiError;
return normalizeRuntimeApiErrorRecord(value, fallback, diagnostic);
}
function agentErrorFromApiFailure(result: ApiResult<unknown>, projection: ProjectionDiagnostic, fallback: string): ChatMessage["error"] {
@@ -2666,31 +2590,17 @@ function isTraceActiveStatus(status: unknown): boolean {
}
function readString(key: string, fallback: string): string {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
return readWorkbenchString(key, fallback);
}
function readRecentDrafts(): DraftEntry[] {
try {
const raw = localStorage.getItem(RECENT_DRAFTS_STORAGE_KEY);
return raw ? normalizeRecentDrafts(JSON.parse(raw)) : [];
} catch {
return [];
}
return normalizeRecentDrafts(readWorkbenchJson<unknown[]>(RECENT_DRAFTS_STORAGE_KEY, []).value);
}
function writeRecentDrafts(drafts: DraftEntry[]): void {
try { localStorage.setItem(RECENT_DRAFTS_STORAGE_KEY, JSON.stringify(drafts)); } catch { /* ignore */ }
writeWorkbenchJson(RECENT_DRAFTS_STORAGE_KEY, drafts);
}
function readNumber(key: string, fallback: number): number {
try {
const value = Number(localStorage.getItem(key));
return Number.isFinite(value) && value > 0 ? value : fallback;
} catch {
return fallback;
}
return readWorkbenchNumber(key, fallback);
}
@@ -0,0 +1,86 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Workbench typed error and diagnostic normalization. Mechanically migrated from OpenCode error-envelope semantics, then adapted to HWLAB API contracts.
import type { ApiError, ErrorDiagnostic } from "@/types";
export function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
for (const value of values) {
const record = recordValue(value);
if (!record) continue;
const httpStatus = firstFiniteNumber(record.httpStatus, record.http_status);
return {
...record,
contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"),
traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id),
requestId: firstNonEmptyString(record.requestId, record.request_id),
serviceId: firstNonEmptyString(record.serviceId, record.service_id),
route: firstNonEmptyString(record.route),
layer: firstNonEmptyString(record.layer),
category: firstNonEmptyString(record.category),
code: firstStringOrNumber(record.code),
httpStatus: httpStatus ?? null,
source: firstNonEmptyString(record.source),
observedAt: firstNonEmptyString(record.observedAt, record.observed_at),
retryable: firstBoolean(record.retryable),
valuesPrinted: record.valuesPrinted === true
};
}
return null;
}
export function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null {
const record = recordValue(value);
if (!record) return null;
const normalizedDiagnostic = normalizeErrorDiagnostic(record.diagnostic, diagnostic);
const message = firstNonEmptyString(record.message, record.userMessage, record.reason, fallback);
return {
...record,
message: message ?? fallback ?? "Workbench 请求失败",
userMessage: firstNonEmptyString(record.userMessage, record.message, fallback),
code: firstStringOrNumber(record.code, normalizedDiagnostic?.code),
reason: firstNonEmptyString(record.reason),
retryable: firstBoolean(record.retryable, normalizedDiagnostic?.retryable),
layer: firstNonEmptyString(record.layer, normalizedDiagnostic?.layer),
category: firstNonEmptyString(record.category, normalizedDiagnostic?.category),
route: firstNonEmptyString(record.route, normalizedDiagnostic?.route),
traceId: firstNonEmptyString(record.traceId, normalizedDiagnostic?.traceId),
requestId: firstNonEmptyString(record.requestId, normalizedDiagnostic?.requestId),
source: firstNonEmptyString(record.source, normalizedDiagnostic?.source),
diagnostic: normalizedDiagnostic,
valuesPrinted: record.valuesPrinted === true
} as ApiError;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value !== "string") continue;
const text = value.trim();
if (text) return text;
}
return null;
}
function firstStringOrNumber(...values: unknown[]): string | number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function firstFiniteNumber(...values: unknown[]): number | null {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) return Math.trunc(number);
}
return null;
}
function firstBoolean(...values: unknown[]): boolean | null {
for (const value of values) if (typeof value === "boolean") return value;
return null;
}
@@ -0,0 +1,4 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Production Workbench realtime runtime entry point for SSE transport, event coalescing, and scoped cursor ownership.
export { createWorkbenchStreamTransportRuntime, WorkbenchStreamTransportRuntime, type WorkbenchStreamTransportRestartInput, type WorkbenchStreamTransportRestartResult, type WorkbenchRealtimeEvent } from "@/utils/workbench-stream-transport";
@@ -0,0 +1,154 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Workbench REST refresh queue, keyed single-flight, cooldown, and request-storm suppression.
import { AsyncQueue } from "@/utils/scheduler/async-queue";
import { createKeyedSingleflight } from "@/utils/scheduler/keyed-singleflight";
import { normalizeErrorDiagnostic } from "@/utils/workbench-error-runtime";
interface ApiResultLike {
ok: boolean;
status: number;
data?: unknown;
error?: string | null;
apiError?: Record<string, unknown> | null;
diagnostic?: Record<string, unknown> | null;
}
export interface WorkbenchReadHydrationRuntimeOptions {
concurrency: number;
failureCooldownMs: number;
now?: () => number;
}
export interface WorkbenchReadHydrationRunOptions {
minIntervalMs?: number;
force?: boolean;
shouldCooldownFailure?: (value: unknown) => boolean;
reason?: string | null;
}
type QueuedTask = () => void;
export class WorkbenchReadHydrationRuntime {
private readonly queue = new AsyncQueue<QueuedTask>();
private readonly singleflight = createKeyedSingleflight<unknown>();
private readonly cooldownUntilByKey = new Map<string, number>();
private readonly lastStartedAtByKey = new Map<string, number>();
private readonly now: () => number;
private readonly failureCooldownMs: number;
constructor(options: WorkbenchReadHydrationRuntimeOptions) {
this.now = options.now ?? Date.now;
this.failureCooldownMs = Math.max(0, Math.trunc(options.failureCooldownMs));
const concurrency = Math.max(1, Math.trunc(options.concurrency));
for (let index = 0; index < concurrency; index += 1) void this.workerLoop();
}
run<T>(task: () => Promise<T>, cooldownKey?: string, options: WorkbenchReadHydrationRunOptions = {}): Promise<T> {
const key = normalizeOptionalKey(cooldownKey);
const execute = () => this.enqueue(() => this.executeTask(task, key, options));
if (!key) return execute();
return this.singleflight.run(key, () => execute(), { reason: options.reason ?? null }) as Promise<T>;
}
private enqueue<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push(() => {
void task().then(resolve, reject);
});
});
}
private async executeTask<T>(task: () => Promise<T>, key: string | null, options: WorkbenchReadHydrationRunOptions): Promise<T> {
if (key) {
const now = this.now();
const cooldownUntilMs = this.cooldownUntilByKey.get(key) ?? 0;
if (cooldownUntilMs > now) return createWorkbenchReadCooldownResult(key, cooldownUntilMs, now) as T;
if (cooldownUntilMs > 0) this.cooldownUntilByKey.delete(key);
const minIntervalMs = Math.max(0, Math.trunc(options.minIntervalMs ?? 0));
const lastStartedAtMs = this.lastStartedAtByKey.get(key) ?? 0;
if (!options.force && minIntervalMs > 0 && lastStartedAtMs > 0 && now - lastStartedAtMs < minIntervalMs) return createWorkbenchReadThrottledResult(key, lastStartedAtMs + minIntervalMs, now) as T;
if (minIntervalMs > 0) this.lastStartedAtByKey.set(key, now);
}
const value = await task();
if (key && options.shouldCooldownFailure?.(value)) this.cooldownUntilByKey.set(key, this.now() + this.failureCooldownMs);
return value;
}
private async workerLoop(): Promise<void> {
for (;;) {
const run = await this.queue.next();
run();
}
}
}
export function createWorkbenchReadHydrationRuntime(options: WorkbenchReadHydrationRuntimeOptions): WorkbenchReadHydrationRuntime {
return new WorkbenchReadHydrationRuntime(options);
}
export function shouldCooldownWorkbenchReadFailure(value: unknown): boolean {
const result = value as ApiResultLike | null;
if (!result || typeof result !== "object" || typeof result.status !== "number") return false;
const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic);
const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code);
const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category);
const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source);
if (result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network") return true;
if (result.status === 503) return code === "projection_store_unavailable" || code === "workbench_read_model_store_unavailable";
return false;
}
function createWorkbenchReadCooldownResult(key: string, cooldownUntilMs: number, now: number): ApiResultLike {
const retryAfterMs = Math.max(0, cooldownUntilMs - now);
const message = `Workbench read hydration is cooling down after a transient failure (${key}); retry after ${retryAfterMs}ms.`;
const diagnostic = diagnosticEnvelope("workbench_read_hydration_cooldown", "network", message, key, retryAfterMs);
return { ok: false, status: 503, data: null, error: message, apiError: { ...diagnostic, message, diagnostic }, diagnostic };
}
function createWorkbenchReadThrottledResult(key: string, nextAllowedAtMs: number, now: number): ApiResultLike {
const retryAfterMs = Math.max(0, nextAllowedAtMs - now);
const message = `Workbench read hydration is throttled (${key}); retry after ${retryAfterMs}ms.`;
const diagnostic = diagnosticEnvelope("workbench_read_hydration_throttled", "throttle", message, key, retryAfterMs);
return { ok: false, status: 0, data: null, error: message, apiError: { ...diagnostic, message, diagnostic }, diagnostic };
}
function diagnosticEnvelope(code: string, category: string, message: string, key: string, retryAfterMs: number): Record<string, unknown> {
return {
contractVersion: "hwlab-error-diagnostic-v1",
code,
category,
source: "workbench-web",
layer: "workbench-refresh-runtime",
message,
retryable: true,
recoveryAction: "suppress-duplicate-refresh",
rootCause: code,
scopedKey: key,
retryAfterMs,
valuesPrinted: false,
valuesRedacted: true
};
}
function normalizeOptionalKey(value: string | null | undefined): string | null {
const text = String(value ?? "").trim();
return text || null;
}
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value !== "string") continue;
const text = value.trim();
if (text) return text;
}
return null;
}
function firstStringOrNumber(...values: unknown[]): string | number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
@@ -0,0 +1,40 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Workbench local storage namespace wrapper backed by SafeStorageRuntime.
import { createSafeStorageRuntime, readJsonStorage, removeStorageKey, writeJsonStorage, type SafeStorageResult, type StorageLike } from "@/utils/safe-storage";
const runtime = createSafeStorageRuntime();
export function readWorkbenchString(key: string, fallback: string): string {
return workbenchStorage().getItem(key) ?? fallback;
}
export function writeWorkbenchString(key: string, value: string): void {
workbenchStorage().setItem(key, value);
}
export function removeWorkbenchStorageKey(key: string): SafeStorageResult<null> {
return removeStorageKey(workbenchStorage(), key);
}
export function readWorkbenchNumber(key: string, fallback: number): number {
const value = Number(readWorkbenchString(key, ""));
return Number.isFinite(value) && value > 0 ? value : fallback;
}
export function readWorkbenchJson<T>(key: string, fallback: T): SafeStorageResult<T> {
return readJsonStorage(workbenchStorage(), key, fallback);
}
export function writeWorkbenchJson<T>(key: string, value: T): SafeStorageResult<T> {
return writeJsonStorage(workbenchStorage(), key, value);
}
function workbenchStorage(): StorageLike {
return runtime.localStorageDirect(globalLocalStorage(), "workbench");
}
function globalLocalStorage(): StorageLike | null {
if (typeof globalThis === "undefined") return null;
return (globalThis as typeof globalThis & { localStorage?: StorageLike }).localStorage ?? null;
}
@@ -0,0 +1,58 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Workbench SSE transport lifecycle and scope-key ownership.
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { workbenchRealtimeScopeKey } from "@/utils/workbench-key";
export type { WorkbenchRealtimeEvent };
export interface WorkbenchStreamTransportRestartInput {
sessionId?: string | null;
traceId?: string | null;
afterSeq?: number | null;
onOpen?: () => void;
onError?: (event: Event) => void;
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
}
export interface WorkbenchStreamTransportRestartResult {
key: string;
changed: boolean;
streamStarted: boolean;
}
export class WorkbenchStreamTransportRuntime {
private stream: WorkbenchEventStream | null = null;
private key = "";
restart(input: WorkbenchStreamTransportRestartInput): WorkbenchStreamTransportRestartResult {
const key = workbenchRealtimeScopeKey(input.sessionId ?? null, input.traceId ?? null);
if (key === this.key && this.stream) return { key, changed: false, streamStarted: true };
this.stop();
this.key = key;
if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false };
this.stream = connectWorkbenchEvents({
sessionId: input.sessionId ?? null,
traceId: input.traceId ?? null,
afterSeq: input.afterSeq ?? null,
onOpen: input.onOpen,
onError: input.onError,
onEvent: input.onEvent
});
return { key, changed: true, streamStarted: Boolean(this.stream) };
}
stop(): void {
this.stream?.close();
this.stream = null;
this.key = "";
}
currentKey(): string {
return this.key;
}
}
export function createWorkbenchStreamTransportRuntime(): WorkbenchStreamTransportRuntime {
return new WorkbenchStreamTransportRuntime();
}