Merge pull request #2307 from pikasTech/issue-1315-opencode-runtime-100
fix: complete OpenCode Workbench runtime migration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user