feat: complete workbench opencode runtime migration
This commit is contained in:
@@ -38,6 +38,7 @@ const requiredFiles = Object.freeze([
|
||||
"src/stores/app.ts",
|
||||
"src/stores/auth.ts",
|
||||
"src/stores/workbench.ts",
|
||||
"src/stores/workbench-event-reducer.ts",
|
||||
"src/stores/workbench-timeline-model.ts",
|
||||
"src/stores/workbench-session-cache.ts",
|
||||
"src/composables/useWorkbenchScrollRuntime.ts",
|
||||
@@ -78,6 +79,12 @@ const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record<strin
|
||||
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 workbenchRefreshRuntimeSource = readWeb("src/utils/workbench-refresh-runtime.ts");
|
||||
const workbenchEventReducerSource = readWeb("src/stores/workbench-event-reducer.ts");
|
||||
const workbenchTimelineRuntimeSource = readWeb("src/stores/workbench-timeline-model.ts");
|
||||
const workbenchScrollRuntimeSource = readWeb("src/composables/useWorkbenchScrollRuntime.ts");
|
||||
const workbenchErrorRuntimeSource = readWeb("src/utils/workbench-error-runtime.ts");
|
||||
const workbenchHealthRuntimeSource = readWeb("src/utils/workbench-health.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");
|
||||
@@ -119,9 +126,32 @@ assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web
|
||||
assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved");
|
||||
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");
|
||||
assertIncludes(workbenchRealtimeRuntimeSource, "WorkbenchStreamTransportRecovery", "SSE transport must own recovery actions, not just wrap EventSource");
|
||||
assertIncludes(workbenchRealtimeRuntimeSource, "cursorByKey", "SSE transport runtime must own cursor replay state");
|
||||
assertIncludes(workbenchRefreshRuntimeSource, "WorkbenchTraceHydrationQueueRuntime", "Trace hydration queue must live in refresh runtime");
|
||||
assertIncludes(workbenchStoreSource, "createWorkbenchTraceHydrationQueueRuntime", "Workbench store must use the migrated trace hydration runtime");
|
||||
assertIncludes(workbenchStoreSource, "createWorkbenchScheduledTaskRuntime", "Workbench store must use the migrated scheduled task runtime");
|
||||
assertIncludes(workbenchEventReducerSource, "reduceWorkbenchRealtimeEvent", "Realtime event reducer must own SSE event classification");
|
||||
assertIncludes(workbenchEventReducerSource, "workbench-event-reducer", "Realtime event reducer must emit module diagnostics for monitor root cause");
|
||||
assertIncludes(workbenchStoreSource, "reduceWorkbenchRealtimeEvent", "Workbench store must consume migrated realtime reducer actions");
|
||||
assert.doesNotMatch(workbenchStoreSource, /function applyRealtimeEvent[\s\S]{0,900}event\.type ===/u, "Workbench store applyRealtimeEvent must not branch directly on raw SSE event.type");
|
||||
assertIncludes(workbenchTimelineRuntimeSource, "TurnGap", "Timeline runtime must preserve OpenCode TurnGap row parity");
|
||||
assertIncludes(workbenchTimelineRuntimeSource, "AssistantPart", "Timeline runtime must preserve OpenCode AssistantPart row parity");
|
||||
assertIncludes(workbenchTimelineRuntimeSource, "unwrapErrorMessage", "Timeline runtime must preserve OpenCode readable error row behavior");
|
||||
assertIncludes(workbenchScrollRuntimeSource, "createWorkbenchScrollPersistenceRuntime", "Scroll runtime must preserve OpenCode session/tab scroll persistence");
|
||||
assertIncludes(conversationPanelSource, "sessionKey: scrollSessionKey", "ConversationPanel must use session-scoped scroll persistence in production");
|
||||
assertIncludes(workbenchErrorRuntimeSource, "formatServerError", "Error runtime must preserve OpenCode readable server error formatting");
|
||||
assertIncludes(workbenchErrorRuntimeSource, "projectionDiagnosticFromFailure", "Error runtime must own projection diagnostic envelopes");
|
||||
assertIncludes(workbenchHealthRuntimeSource, "createScopedCache", "ScopedCache must be used by a production Workbench runtime, not only by tests/checks");
|
||||
assert.doesNotMatch(workbenchStoreSource, /function (?:normalizeProjectionDiagnostic|projectionDiagnosticFromApiFailure|projectionDiagnosticFromFailure|normalizeProjectionBlocker|normalizeErrorDiagnostic|normalizeApiErrorRecord|agentErrorFromProjection)\b/u, "Workbench store must call ErrorRuntime directly instead of reintroducing diagnostic wrapper ownership");
|
||||
assertIncludes(workbenchStoreSource, "cleanupDroppedWorkbenchSessionCaches", "Workbench store must run OpenCode-style dropped session cache cleanup after trim");
|
||||
assertIncludes(workbenchStoreSource, "cleanupWorkbenchServerStateSessions", "Workbench store must clean dropped session trace/turn authority after trim");
|
||||
assert.doesNotMatch(workbenchStoreSource, /case "session\.forget"[\s\S]{0,400}sessionStatusById: withoutKey\(state\.sessionStatusById/u, "Session forget must not leave trace/turn authority cleanup out of reducer helpers");
|
||||
assert.doesNotMatch(workbenchStoreSource, /traceHydration(?:InFlight|Queued|Queue)|traceHydrationPumpActive|terminalRealtimeRefreshInFlight|realtimeSessionMessagesInFlight|realtimeErrorGapFillLastAtByKey|activeTraceRestGapFillTimers|realtimeOutboxSeqByKey|sessionListRefreshInFlight|sessionListRefreshTimers/u, "Workbench store must not reintroduce migrated in-flight/timer/cursor state");
|
||||
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");
|
||||
assertIncludes(workbenchRefreshRuntimeSource, "if (existing)", "Scheduled refresh runtime must coalesce instead of resetting timers under SSE error storms");
|
||||
assertIncludes(workbenchRefreshRuntimeSource, "replaceTimer", "Scheduled refresh runtime must make replacement explicit instead of ad hoc timer resets");
|
||||
assertIncludes(workbenchStoreSource, "if (activeId) scheduleSessionListRefresh(activeId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS);", "Realtime stream errors must schedule bounded session list refreshes");
|
||||
assert.doesNotMatch(workbenchStoreSource, /handleRealtimeStreamError[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Realtime stream errors must not force-refresh the full session list");
|
||||
assert.doesNotMatch(workbenchStoreSource, /refreshActiveTraceFromRest[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Active trace REST gap-fill must not force-refresh the full session list");
|
||||
|
||||
@@ -12,7 +12,9 @@ import { composeWorkbenchScopedKey, splitWorkbenchScopedKey, workbenchPathKey, w
|
||||
import { createSafeStorageRuntime, isStorageQuotaError, migrateLegacyStorage, normalizePersistedValue, readJsonStorage, removePersistedTarget, removeStorageKey, writeJsonStorage, type StorageLike } from "../src/utils/safe-storage.ts";
|
||||
import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/utils/workbench-health.ts";
|
||||
import { buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts";
|
||||
import { trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts";
|
||||
import { reduceWorkbenchRealtimeEvent } from "../src/stores/workbench-event-reducer.ts";
|
||||
import { cleanupWorkbenchServerStateDroppedSessions, cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState } from "../src/stores/workbench-server-state.ts";
|
||||
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts";
|
||||
|
||||
test("Workbench scoped keys encode delimiter characters", () => {
|
||||
assert.deepEqual(splitWorkbenchScopedKey(workbenchRealtimeScopeKey("ses|one", "trc/two")), ["workbench.realtime", "ses|one", "trc/two"]);
|
||||
@@ -152,9 +154,24 @@ test("timeline model deduplicates authoritative messages with stable row keys",
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows.map((row) => `${row.role}:${row.message.text}`), ["user:hi", "agent:done"]);
|
||||
const messageRows = rows.flatMap((row) => row.message ? [row] : []);
|
||||
assert.deepEqual(messageRows.map((row) => `${row.role}:${row.message.text}`), ["user:hi", "agent:done"]);
|
||||
assert.match(workbenchTimelineSignature(rows), /completed:4/u);
|
||||
assert.deepEqual(normalizeWorkbenchTimelineMessages(rows.map((row) => row.message)).map((message) => message.messageId), ["msg_user", "msg_agent"]);
|
||||
assert.deepEqual(normalizeWorkbenchTimelineMessages(messageRows.map((row) => row.message)).map((message) => message.messageId), ["msg_user", "msg_agent"]);
|
||||
});
|
||||
|
||||
test("timeline model preserves OpenCode turn gap, thinking, retry and error row parity", () => {
|
||||
const rows = buildWorkbenchTimelineRows([
|
||||
{ id: "msg_user_1", messageId: "msg_user_1", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-06-30T00:00:00.000Z", sessionId: "ses_a", traceId: "trc_a" },
|
||||
{ id: "msg_agent_1", messageId: "msg_agent_1", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:01.000Z", sessionId: "ses_a", traceId: "trc_a" },
|
||||
{ id: "msg_user_2", messageId: "msg_user_2", role: "user", title: "用户", text: "again", status: "sent", createdAt: "2026-06-30T00:00:02.000Z", sessionId: "ses_a", traceId: "trc_b" },
|
||||
{ id: "msg_agent_2", messageId: "msg_agent_2", role: "agent", title: "Code Agent", text: "", status: "retry" as never, createdAt: "2026-06-30T00:00:03.000Z", sessionId: "ses_a", traceId: "trc_b", error: { message: "Error: {\"error\":{\"type\":\"Provider\",\"message\":\"offline\"}}" } }
|
||||
]);
|
||||
|
||||
assert.ok(rows.some((row) => row.type === "TurnGap"));
|
||||
assert.ok(rows.some((row) => row.type === "Thinking"));
|
||||
assert.ok(rows.some((row) => row.type === "Retry"));
|
||||
assert.ok(rows.some((row) => row.type === "Error" && row.text === "Provider: offline"));
|
||||
});
|
||||
|
||||
test("session cache trim keeps retained sessions and trims messages by explicit policy", () => {
|
||||
@@ -180,6 +197,49 @@ test("session cache trim keeps retained sessions and trims messages by explicit
|
||||
assert.deepEqual(Object.keys(result.state.sessionsById).sort(), ["ses_1", "ses_3"]);
|
||||
});
|
||||
|
||||
test("session cache cleanup drops stale session/message caches with OpenCode cleanup parity", () => {
|
||||
const result = cleanupDroppedWorkbenchSessionCaches({
|
||||
sessionOrder: ["ses_1", "ses_2"],
|
||||
sessionsById: { ses_1: { sessionId: "ses_1" }, ses_2: { sessionId: "ses_2" } },
|
||||
messagesBySessionId: { ses_1: [], ses_2: [] }
|
||||
}, ["ses_2"]);
|
||||
|
||||
assert.deepEqual(result.droppedSessionIds, ["ses_1"]);
|
||||
assert.deepEqual(result.state.sessionOrder, ["ses_2"]);
|
||||
assert.deepEqual(Object.keys(result.state.sessionsById), ["ses_2"]);
|
||||
});
|
||||
|
||||
test("server-state cleanup removes dropped session trace and turn authority", () => {
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId: "ses_1", messages: [{ id: "m1", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-30T00:00:00.000Z", traceId: "trc_1", runnerTrace: { traceId: "trc_1", sessionId: "ses_1", events: [] } }] } });
|
||||
state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId: "trc_1", status: "running", running: true, terminal: false, sessionId: "ses_1" } });
|
||||
state = reduceWorkbenchServerState(state, { type: "trace.snapshot", traceId: "trc_1", trace: { traceId: "trc_1", sessionId: "ses_1", events: [] } });
|
||||
|
||||
const cleaned = cleanupWorkbenchServerStateDroppedSessions(state, []);
|
||||
assert.deepEqual(Object.keys(cleaned.messagesBySessionId), []);
|
||||
assert.deepEqual(Object.keys(cleaned.turnStatusByTraceId), []);
|
||||
assert.deepEqual(Object.keys(cleaned.traceById), []);
|
||||
|
||||
const cleanedById = cleanupWorkbenchServerStateSessions(state, ["ses_1"]);
|
||||
assert.deepEqual(Object.keys(cleanedById.messagesBySessionId), []);
|
||||
assert.deepEqual(Object.keys(cleanedById.turnStatusByTraceId), []);
|
||||
assert.deepEqual(Object.keys(cleanedById.traceById), []);
|
||||
});
|
||||
|
||||
test("realtime event reducer classifies SSE payloads before store side effects", () => {
|
||||
const trace = reduceWorkbenchRealtimeEvent({ type: "trace.event", traceId: "trc_1", event: { traceId: "trc_1", label: "delta" }, snapshot: { traceId: "trc_1", status: "running" } }, "workbench.trace.event");
|
||||
assert.equal(trace.activityLabel, "realtime:trace.event");
|
||||
assert.equal(trace.action.type, "trace.event");
|
||||
assert.equal(trace.diagnostic.module, "workbench-event-reducer");
|
||||
|
||||
const emptyMessage = reduceWorkbenchRealtimeEvent({ type: "message.snapshot" }, "workbench.message.snapshot");
|
||||
assert.deepEqual(emptyMessage.action, { type: "ignore", reason: "message.snapshot.missing-message" });
|
||||
|
||||
const error = reduceWorkbenchRealtimeEvent({ type: "error", traceId: "trc_2", error: { message: "offline" } }, "workbench.error");
|
||||
assert.equal(error.action.type, "projection.error");
|
||||
assert.equal(error.diagnostic.traceId, "trc_2");
|
||||
});
|
||||
|
||||
test("health probe cache records ok and unavailable states", async () => {
|
||||
const cache = createWorkbenchHealthProbeCache({ cacheMs: 100 });
|
||||
const ok = await cache.probe({ key: "workbench", fetcher: async () => ({ ready: true }), classify: (value) => value.ready ? "ok" : "degraded" });
|
||||
|
||||
@@ -26,7 +26,8 @@ import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||||
const workbench = useWorkbenchStore();
|
||||
const clipboard = useClipboard();
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56 });
|
||||
const scrollSessionKey = computed(() => workbench.activeSessionId ?? "workbench");
|
||||
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56, sessionKey: scrollSessionKey, tabKey: "conversation" });
|
||||
const detailMessageId = ref<string | null>(null);
|
||||
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
|
||||
const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value));
|
||||
@@ -282,34 +283,41 @@ function formatDuration(ms: number): string {
|
||||
<template>
|
||||
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
|
||||
<LoadingState v-if="workbench.sessionDetailLoading" class="conversation-detail-loading" label="加载中" />
|
||||
<template v-for="row in visibleTimelineRows" :key="row.key">
|
||||
<div v-if="row.type === 'TurnGap'" class="timeline-row timeline-turn-gap" aria-hidden="true" />
|
||||
<div v-else-if="row.type === 'Thinking'" class="timeline-row timeline-thinking" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">
|
||||
<LoadingState class="message-loading" :label="row.reasoningHeading || '思考中...'" compact />
|
||||
</div>
|
||||
<div v-else-if="row.type === 'Retry'" class="timeline-row timeline-retry" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">等待重试</div>
|
||||
<div v-else-if="row.type === 'Error'" class="timeline-row timeline-error" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">{{ row.text }}</div>
|
||||
<article
|
||||
v-for="{ key, message } in visibleTimelineRows"
|
||||
:key="key"
|
||||
v-else-if="row.message"
|
||||
class="message-card"
|
||||
:data-role="message.role"
|
||||
:data-status="message.status"
|
||||
:data-session-id="message.sessionId || undefined"
|
||||
:data-message-id="message.id || undefined"
|
||||
:data-trace-id="message.traceId || message.runnerTrace?.traceId || undefined"
|
||||
:data-turn-id="message.turnId || message.traceId || message.runnerTrace?.traceId || undefined"
|
||||
:data-role="row.message.role"
|
||||
:data-status="row.message.status"
|
||||
:data-session-id="row.message.sessionId || undefined"
|
||||
:data-message-id="row.message.id || undefined"
|
||||
:data-trace-id="row.message.traceId || row.message.runnerTrace?.traceId || undefined"
|
||||
:data-turn-id="row.message.turnId || row.message.traceId || row.message.runnerTrace?.traceId || undefined"
|
||||
>
|
||||
<header v-if="message.role !== 'user'">
|
||||
<header v-if="row.message.role !== 'user'">
|
||||
<div class="message-header-main">
|
||||
<strong>{{ message.title }}</strong>
|
||||
<span v-if="messageDurationMeta(message)" class="message-timing-meta message-duration-meta" :aria-label="messageDurationMeta(message)?.label" :title="messageDurationMeta(message)?.label">{{ messageDurationMeta(message)?.text }}</span>
|
||||
<strong>{{ row.message.title }}</strong>
|
||||
<span v-if="messageDurationMeta(row.message)" class="message-timing-meta message-duration-meta" :aria-label="messageDurationMeta(row.message)?.label" :title="messageDurationMeta(row.message)?.label">{{ messageDurationMeta(row.message)?.text }}</span>
|
||||
</div>
|
||||
<div class="message-header-actions">
|
||||
<span v-if="messageActivityMeta(message)" class="message-activity-meta" :aria-label="messageActivityMeta(message)?.label" :title="messageActivityMeta(message)?.label">{{ messageActivityMeta(message)?.text }}</span>
|
||||
<span v-if="isRunningMessage(message)" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||||
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
|
||||
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
|
||||
<span v-if="messageActivityMeta(row.message)" class="message-activity-meta" :aria-label="messageActivityMeta(row.message)?.label" :title="messageActivityMeta(row.message)?.label">{{ messageActivityMeta(row.message)?.text }}</span>
|
||||
<span v-if="isRunningMessage(row.message)" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||||
<StatusBadge v-if="showStatusBadge(row.message)" :status="row.message.status" />
|
||||
<button v-if="row.message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(row.message)">!</button>
|
||||
</div>
|
||||
</header>
|
||||
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="traceForDisplay(message)" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||||
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
|
||||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(message)" :api-error="messageApiError(message)" :diagnostic="messageErrorDiagnostic(message)" compact />
|
||||
<TraceTimeline v-if="row.message.role === 'agent' && row.message.runnerTrace" :trace="traceForDisplay(row.message)" :auto-expanded="traceAutoExpanded(row.message)" :storage-key="traceStorageKey(row.message)" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(row.message)" class="message-loading" label="思考中..." compact />
|
||||
<MessageMarkdown v-if="showMessageText(row.message)" class="message-text" :source="visibleMessageText(row.message)" />
|
||||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(row.message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(row.message)" :api-error="messageApiError(row.message)" :diagnostic="messageErrorDiagnostic(row.message)" compact />
|
||||
</article>
|
||||
</template>
|
||||
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败:{{ workbench.error }}</div>
|
||||
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话,或从左侧选择 session。</div>
|
||||
<div v-if="detailMessage" class="workbench-dialog-backdrop" role="presentation" @click.self="detailMessageId = null">
|
||||
|
||||
@@ -1,9 +1,182 @@
|
||||
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
|
||||
// Responsibility: Workbench scroll/follow runtime boundary over the shared bottom-follow primitive.
|
||||
// Responsibility: Workbench bottom-follow plus session/tab scroll persistence.
|
||||
// Mechanical source references:
|
||||
// - OpenCode context/layout-scroll.ts:16-21 persistence state.
|
||||
// - OpenCode context/layout-scroll.ts:35-51 seed/read.
|
||||
// - OpenCode context/layout-scroll.ts:53-81 debounce flush.
|
||||
// - OpenCode context/layout-scroll.ts:93-110 drop cleanup.
|
||||
|
||||
import type { Ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, watch, type ComputedRef, type Ref } from "vue";
|
||||
import { useBottomFollowScroll, type BottomFollowScrollOptions } from "@/composables/useBottomFollowScroll";
|
||||
import { readWorkbenchJson, writeWorkbenchJson } from "@/utils/workbench-storage-runtime";
|
||||
|
||||
export function useWorkbenchBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: BottomFollowScrollOptions = {}) {
|
||||
return useBottomFollowScroll(containerRef, options);
|
||||
export interface SessionScroll {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type WorkbenchScrollMap = Record<string, SessionScroll>;
|
||||
|
||||
export interface WorkbenchScrollPersistenceOptions {
|
||||
debounceMs?: number;
|
||||
getSnapshot: (sessionKey: string) => WorkbenchScrollMap | undefined;
|
||||
onFlush: (sessionKey: string, scroll: WorkbenchScrollMap) => void;
|
||||
}
|
||||
|
||||
export interface WorkbenchBottomFollowScrollOptions extends BottomFollowScrollOptions {
|
||||
sessionKey?: string | Ref<string | null | undefined> | ComputedRef<string | null | undefined> | (() => string | null | undefined);
|
||||
tabKey?: string;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
const WORKBENCH_SCROLL_STORAGE_KEY = "hwlab.workbench.scrollPersistence.v1";
|
||||
|
||||
export function createWorkbenchScrollPersistenceRuntime(opts: WorkbenchScrollPersistenceOptions) {
|
||||
const wait = Math.max(0, Math.trunc(opts.debounceMs ?? 200));
|
||||
const cache: Record<string, WorkbenchScrollMap> = {};
|
||||
const dirty = new Set<string>();
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function clone(input?: WorkbenchScrollMap): WorkbenchScrollMap {
|
||||
const out: WorkbenchScrollMap = {};
|
||||
if (!input) return out;
|
||||
for (const key of Object.keys(input)) {
|
||||
const pos = input[key];
|
||||
if (!pos) continue;
|
||||
out[key] = { x: pos.x, y: pos.y };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function seed(sessionKey: string): void {
|
||||
const next = clone(opts.getSnapshot(sessionKey));
|
||||
const current = cache[sessionKey];
|
||||
if (!current) {
|
||||
cache[sessionKey] = next;
|
||||
return;
|
||||
}
|
||||
if (Object.keys(current).length > 0) return;
|
||||
if (Object.keys(next).length === 0) return;
|
||||
cache[sessionKey] = next;
|
||||
}
|
||||
|
||||
function scroll(sessionKey: string, tab: string): SessionScroll | undefined {
|
||||
seed(sessionKey);
|
||||
return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab];
|
||||
}
|
||||
|
||||
function schedule(sessionKey: string): void {
|
||||
const prev = timers.get(sessionKey);
|
||||
if (prev) clearTimeout(prev);
|
||||
timers.set(sessionKey, setTimeout(() => flush(sessionKey), wait));
|
||||
}
|
||||
|
||||
function setScroll(sessionKey: string, tab: string, pos: SessionScroll): void {
|
||||
seed(sessionKey);
|
||||
const prev = cache[sessionKey]?.[tab];
|
||||
if (prev?.x === pos.x && prev?.y === pos.y) return;
|
||||
cache[sessionKey] = { ...(cache[sessionKey] ?? {}), [tab]: { x: pos.x, y: pos.y } };
|
||||
dirty.add(sessionKey);
|
||||
schedule(sessionKey);
|
||||
}
|
||||
|
||||
function flush(sessionKey: string): void {
|
||||
const timer = timers.get(sessionKey);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.delete(sessionKey);
|
||||
if (!dirty.has(sessionKey)) return;
|
||||
dirty.delete(sessionKey);
|
||||
opts.onFlush(sessionKey, clone(cache[sessionKey]));
|
||||
}
|
||||
|
||||
function flushAll(): void {
|
||||
for (const key of Array.from(dirty)) flush(key);
|
||||
}
|
||||
|
||||
function drop(keys: string[]): void {
|
||||
if (keys.length === 0) return;
|
||||
for (const key of keys) {
|
||||
const timer = timers.get(key);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.delete(key);
|
||||
dirty.delete(key);
|
||||
delete cache[key];
|
||||
}
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
flushAll();
|
||||
drop(Array.from(timers.keys()));
|
||||
}
|
||||
|
||||
return { cache, drop, flush, flushAll, scroll, seed, setScroll, dispose };
|
||||
}
|
||||
|
||||
const scrollPersistence = createWorkbenchScrollPersistenceRuntime({
|
||||
getSnapshot: (sessionKey) => readWorkbenchJson<Record<string, WorkbenchScrollMap>>(WORKBENCH_SCROLL_STORAGE_KEY, {}).value[sessionKey],
|
||||
onFlush: (sessionKey, scroll) => {
|
||||
const current = readWorkbenchJson<Record<string, WorkbenchScrollMap>>(WORKBENCH_SCROLL_STORAGE_KEY, {}).value;
|
||||
writeWorkbenchJson(WORKBENCH_SCROLL_STORAGE_KEY, { ...current, [sessionKey]: scroll });
|
||||
}
|
||||
});
|
||||
|
||||
export function useWorkbenchBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: WorkbenchBottomFollowScrollOptions = {}) {
|
||||
const bottom = useBottomFollowScroll(containerRef, options);
|
||||
const tabKey = options.tabKey ?? "main";
|
||||
const persist = options.persist !== false;
|
||||
const sessionKey = computed(() => normalizeSessionKey(resolveSessionKey(options.sessionKey)));
|
||||
|
||||
watch([containerRef, sessionKey], async () => {
|
||||
if (!persist) return;
|
||||
await nextTick();
|
||||
restoreScroll();
|
||||
}, { flush: "post", immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
persistScroll();
|
||||
scrollPersistence.flushAll();
|
||||
});
|
||||
|
||||
function onScroll(): void {
|
||||
bottom.onScroll();
|
||||
persistScroll();
|
||||
}
|
||||
|
||||
async function keepBottomAfterUpdate(): Promise<void> {
|
||||
await bottom.keepBottomAfterUpdate();
|
||||
if (!bottom.following.value) restoreScroll();
|
||||
}
|
||||
|
||||
function scrollToBottom(): void {
|
||||
bottom.scrollToBottom();
|
||||
void nextTick().then(persistScroll);
|
||||
}
|
||||
|
||||
function restoreScroll(): void {
|
||||
const element = containerRef.value;
|
||||
if (!element) return;
|
||||
const pos = scrollPersistence.scroll(sessionKey.value, tabKey);
|
||||
if (!pos) return;
|
||||
element.scrollTo({ left: pos.x, top: pos.y, behavior: "auto" });
|
||||
}
|
||||
|
||||
function persistScroll(): void {
|
||||
if (!persist) return;
|
||||
const element = containerRef.value;
|
||||
if (!element) return;
|
||||
scrollPersistence.setScroll(sessionKey.value, tabKey, { x: element.scrollLeft, y: element.scrollTop });
|
||||
}
|
||||
|
||||
return { ...bottom, keepBottomAfterUpdate, onScroll, restoreScroll, scrollToBottom };
|
||||
}
|
||||
|
||||
function resolveSessionKey(value: WorkbenchBottomFollowScrollOptions["sessionKey"]): string | null | undefined {
|
||||
if (typeof value === "function") return value();
|
||||
if (value && typeof value === "object" && "value" in value) return value.value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSessionKey(value: string | null | undefined): string {
|
||||
const text = String(value ?? "").trim();
|
||||
return text || "workbench";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection.
|
||||
// Responsibility: OpenCode-style Workbench realtime event reducer; store code consumes actions instead of branching on raw SSE events.
|
||||
// Mechanical source: /root/opencode/packages/app/src/context/global-sync/event-reducer.ts:21-48 global event classification, :93-270 session/message reducer action boundary.
|
||||
// Mechanical source: /root/opencode/packages/opencode/src/cli/cmd/run/session-data.ts:1-17 reducer side-effect boundary and replay notes.
|
||||
|
||||
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
|
||||
export type WorkbenchRealtimeAction =
|
||||
| { type: "trace.snapshot"; traceId: string | null; snapshot: WorkbenchRealtimeEvent["snapshot"] }
|
||||
| { type: "trace.event"; traceId: string | null; event: WorkbenchRealtimeEvent["event"]; snapshot: WorkbenchRealtimeEvent["snapshot"]; realtimeEvent: WorkbenchRealtimeEvent }
|
||||
| { type: "message.snapshot"; realtimeEvent: WorkbenchRealtimeEvent }
|
||||
| { type: "turn.snapshot"; turn: NonNullable<WorkbenchRealtimeEvent["turn"]> }
|
||||
| { type: "projection.error"; realtimeEvent: WorkbenchRealtimeEvent }
|
||||
| { type: "trace.unavailable"; traceId: string | null; reason: string }
|
||||
| { type: "ignore"; reason: string };
|
||||
|
||||
export interface WorkbenchRealtimeReducerOutput {
|
||||
activityLabel: string;
|
||||
action: WorkbenchRealtimeAction;
|
||||
diagnostic: {
|
||||
module: "workbench-event-reducer";
|
||||
eventName: string;
|
||||
eventType: string | null;
|
||||
actionType: WorkbenchRealtimeAction["type"];
|
||||
traceId: string | null;
|
||||
sessionId: string | null;
|
||||
valuesRedacted: true;
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceWorkbenchRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): WorkbenchRealtimeReducerOutput {
|
||||
const eventType = firstNonEmptyString(event.type);
|
||||
const action = reduceRealtimeAction(event, eventName);
|
||||
return {
|
||||
activityLabel: eventType ? `realtime:${eventType}` : `realtime:${eventName}`,
|
||||
action,
|
||||
diagnostic: {
|
||||
module: "workbench-event-reducer",
|
||||
eventName,
|
||||
eventType: eventType ?? null,
|
||||
actionType: action.type,
|
||||
traceId: realtimeTraceId(event),
|
||||
sessionId: realtimeSessionId(event),
|
||||
valuesRedacted: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function reduceRealtimeAction(event: WorkbenchRealtimeEvent, eventName: string): WorkbenchRealtimeAction {
|
||||
switch (event.type) {
|
||||
case "trace.snapshot":
|
||||
return { type: "trace.snapshot", traceId: realtimeTraceId(event), snapshot: event.snapshot ?? null };
|
||||
case "trace.event":
|
||||
return { type: "trace.event", traceId: realtimeTraceId(event), event: event.event ?? null, snapshot: event.snapshot ?? null, realtimeEvent: event };
|
||||
case "message.snapshot":
|
||||
return event.message ? { type: "message.snapshot", realtimeEvent: event } : { type: "ignore", reason: "message.snapshot.missing-message" };
|
||||
case "turn.snapshot":
|
||||
return event.turn ? { type: "turn.snapshot", turn: event.turn } : { type: "ignore", reason: "turn.snapshot.missing-turn" };
|
||||
case "trace.unavailable":
|
||||
return { type: "trace.unavailable", traceId: realtimeTraceId(event), reason: firstNonEmptyString(event.reason, "realtime-trace-unavailable") ?? "realtime-trace-unavailable" };
|
||||
case "error":
|
||||
return { type: "projection.error", realtimeEvent: event };
|
||||
}
|
||||
if (eventName === "workbench.error") return { type: "projection.error", realtimeEvent: event };
|
||||
return { type: "ignore", reason: firstNonEmptyString(event.type, eventName, "unsupported") ?? "unsupported" };
|
||||
}
|
||||
|
||||
function realtimeTraceId(event: WorkbenchRealtimeEvent): string | null {
|
||||
return firstNonEmptyString(event.traceId, event.snapshot?.traceId, event.event?.traceId, event.message?.traceId) ?? null;
|
||||
}
|
||||
|
||||
function realtimeSessionId(event: WorkbenchRealtimeEvent): string | null {
|
||||
return firstNonEmptyString(event.sessionId, event.message?.sessionId, event.snapshot?.sessionId, event.event?.sessionId) ?? null;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type WorkbenchServerAction =
|
||||
| { type: "message.snapshot"; sessionId: string | null | undefined; message: ChatMessage | null | undefined }
|
||||
| { type: "session.status"; session: SessionStatusAuthority }
|
||||
| { type: "session.forget"; sessionId: string }
|
||||
| { type: "session.cleanupDropped"; keepSessionIds: string[] }
|
||||
| { type: "turn.status"; turn: TurnStatusAuthority }
|
||||
| { type: "turn.forget"; traceId: string }
|
||||
| { type: "trace.snapshot"; traceId: string; trace: NonNullable<ChatMessage["runnerTrace"]> };
|
||||
@@ -48,13 +49,9 @@ export function reduceWorkbenchServerState(state: WorkbenchServerState, action:
|
||||
case "session.status":
|
||||
return { ...state, sessionStatusById: { ...state.sessionStatusById, [action.session.sessionId]: action.session } };
|
||||
case "session.forget":
|
||||
return {
|
||||
...state,
|
||||
sessionOrder: state.sessionOrder.filter((id) => id !== action.sessionId),
|
||||
sessionsById: withoutKey(state.sessionsById, action.sessionId),
|
||||
messagesBySessionId: withoutKey(state.messagesBySessionId, action.sessionId),
|
||||
sessionStatusById: withoutKey(state.sessionStatusById, action.sessionId)
|
||||
};
|
||||
return cleanupWorkbenchServerStateSessions(state, [action.sessionId]);
|
||||
case "session.cleanupDropped":
|
||||
return cleanupWorkbenchServerStateSessions(state, droppedSessionIds(state, new Set(action.keepSessionIds)));
|
||||
case "turn.status":
|
||||
return { ...state, turnStatusByTraceId: { ...state.turnStatusByTraceId, [action.turn.traceId]: action.turn } };
|
||||
case "turn.forget":
|
||||
@@ -64,6 +61,25 @@ export function reduceWorkbenchServerState(state: WorkbenchServerState, action:
|
||||
}
|
||||
}
|
||||
|
||||
// Mechanical source: /root/opencode/packages/app/src/context/global-sync/event-reducer.ts:50-91 cleanupDroppedSessionCaches.
|
||||
export function cleanupWorkbenchServerStateDroppedSessions(state: WorkbenchServerState, keepSessionIds: string[]): WorkbenchServerState {
|
||||
return reduceWorkbenchServerState(state, { type: "session.cleanupDropped", keepSessionIds });
|
||||
}
|
||||
|
||||
export function cleanupWorkbenchServerStateSessions(state: WorkbenchServerState, sessionIds: string[]): WorkbenchServerState {
|
||||
const ids = unique(sessionIds.filter(Boolean));
|
||||
if (ids.length === 0) return state;
|
||||
const dropped = new Set(ids);
|
||||
const scoped = cleanupSessionScopedAuthority(state, ids);
|
||||
return {
|
||||
...scoped,
|
||||
sessionOrder: scoped.sessionOrder.filter((sessionId) => !dropped.has(sessionId)),
|
||||
sessionsById: omitRecordKeys(scoped.sessionsById, dropped),
|
||||
messagesBySessionId: omitRecordKeys(scoped.messagesBySessionId, dropped),
|
||||
sessionStatusById: omitRecordKeys(scoped.sessionStatusById, dropped)
|
||||
};
|
||||
}
|
||||
|
||||
export function selectSessionStatusAuthority(state: WorkbenchServerState): Readonly<Record<string, SessionStatusAuthority>> {
|
||||
return state.sessionStatusById;
|
||||
}
|
||||
@@ -366,6 +382,52 @@ function withoutKey<T>(source: Record<string, T>, key: string): Record<string, T
|
||||
return next;
|
||||
}
|
||||
|
||||
function cleanupSessionScopedAuthority(state: WorkbenchServerState, sessionIds: string[]): WorkbenchServerState {
|
||||
if (sessionIds.length === 0) return state;
|
||||
const dropped = new Set(sessionIds);
|
||||
const traceIds = traceIdsForSessions(state, dropped);
|
||||
return {
|
||||
...state,
|
||||
sessionStatusById: omitRecordKeys(state.sessionStatusById, dropped),
|
||||
turnStatusByTraceId: omitRecordKeysByValue(state.turnStatusByTraceId, (turn, traceId) => dropped.has(String(turn.sessionId ?? "")) || traceIds.has(traceId)),
|
||||
traceById: omitRecordKeysByValue(state.traceById, (trace, traceId) => dropped.has(String(trace.sessionId ?? "")) || traceIds.has(traceId))
|
||||
};
|
||||
}
|
||||
|
||||
function droppedSessionIds(state: WorkbenchServerState, keep: Set<string>): string[] {
|
||||
return [
|
||||
...Object.keys(state.sessionsById),
|
||||
...Object.keys(state.messagesBySessionId),
|
||||
...Object.keys(state.sessionStatusById),
|
||||
...state.sessionOrder
|
||||
].filter((sessionId, index, list) => Boolean(sessionId) && !keep.has(sessionId) && list.indexOf(sessionId) === index);
|
||||
}
|
||||
|
||||
function traceIdsForSessions(state: WorkbenchServerState, sessionIds: Set<string>): Set<string> {
|
||||
const traces = new Set<string>();
|
||||
for (const sessionId of sessionIds) {
|
||||
for (const message of state.messagesBySessionId[sessionId] ?? []) {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (traceId) traces.add(traceId);
|
||||
}
|
||||
}
|
||||
for (const [traceId, turn] of Object.entries(state.turnStatusByTraceId)) if (sessionIds.has(String(turn.sessionId ?? ""))) traces.add(traceId);
|
||||
for (const [traceId, trace] of Object.entries(state.traceById)) if (sessionIds.has(String(trace.sessionId ?? ""))) traces.add(traceId);
|
||||
return traces;
|
||||
}
|
||||
|
||||
function omitRecordKeys<T>(record: Record<string, T>, keys: Set<string>): Record<string, T> {
|
||||
const next: Record<string, T> = {};
|
||||
for (const key of Object.keys(record)) if (!keys.has(key)) next[key] = record[key] as T;
|
||||
return next;
|
||||
}
|
||||
|
||||
function omitRecordKeysByValue<T>(record: Record<string, T>, shouldDrop: (value: T, key: string) => boolean): Record<string, T> {
|
||||
const next: Record<string, T> = {};
|
||||
for (const [key, value] of Object.entries(record)) if (!shouldDrop(value as T, key)) next[key] = value as T;
|
||||
return next;
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface WorkbenchSessionCacheTrimResult<T extends WorkbenchSessionCache
|
||||
trimmedMessageSessionIds: string[];
|
||||
}
|
||||
|
||||
export interface WorkbenchDroppedSessionCacheCleanup<T extends WorkbenchSessionCacheState> {
|
||||
state: T;
|
||||
droppedSessionIds: string[];
|
||||
}
|
||||
|
||||
export function trimWorkbenchSessionCache<T extends WorkbenchSessionCacheState>(state: T, policy: WorkbenchSessionCachePolicy): WorkbenchSessionCacheTrimResult<T> {
|
||||
const retain = new Set(normalizeIds(policy.retainSessionIds));
|
||||
const maxSessions = positiveIntegerOrNull(policy.maxSessions);
|
||||
@@ -48,6 +53,27 @@ export function trimWorkbenchSessionCache<T extends WorkbenchSessionCacheState>(
|
||||
};
|
||||
}
|
||||
|
||||
// Mechanical source: /root/opencode/packages/app/src/context/global-sync/event-reducer.ts:50-91 cleanupDroppedSessionCaches.
|
||||
export function cleanupDroppedWorkbenchSessionCaches<T extends WorkbenchSessionCacheState>(state: T, keepSessionIds: Iterable<string | null | undefined>): WorkbenchDroppedSessionCacheCleanup<T> {
|
||||
const keep = new Set(normalizeIds(keepSessionIds));
|
||||
const stale = [
|
||||
...Object.keys(state.sessionsById),
|
||||
...Object.keys(state.messagesBySessionId),
|
||||
...state.sessionOrder
|
||||
].filter((sessionId, index, list) => Boolean(sessionId) && !keep.has(sessionId) && list.indexOf(sessionId) === index);
|
||||
if (stale.length === 0) return { state, droppedSessionIds: [] };
|
||||
const dropped = new Set(stale);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
sessionOrder: state.sessionOrder.filter((sessionId) => !dropped.has(sessionId)),
|
||||
sessionsById: omitRecordKeys(state.sessionsById, dropped),
|
||||
messagesBySessionId: omitRecordKeys(state.messagesBySessionId, dropped)
|
||||
},
|
||||
droppedSessionIds: stale
|
||||
};
|
||||
}
|
||||
|
||||
function keepSessionOrder(order: string[], retain: Set<string>, maxSessions: number): string[] {
|
||||
const kept: string[] = [];
|
||||
for (const sessionId of order) {
|
||||
@@ -69,6 +95,12 @@ function pickRecordKeys<T>(record: Record<string, T>, keys: Set<string>): Record
|
||||
return next;
|
||||
}
|
||||
|
||||
function omitRecordKeys<T>(record: Record<string, T>, keys: Set<string>): Record<string, T> {
|
||||
const next: Record<string, T> = {};
|
||||
for (const key of Object.keys(record)) if (!keys.has(key)) next[key] = record[key] as T;
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeIds(values: Iterable<string | null | undefined> | null | undefined): string[] {
|
||||
if (!values) return [];
|
||||
return [...values].map((value) => String(value ?? "").trim()).filter(Boolean);
|
||||
|
||||
@@ -1,54 +1,80 @@
|
||||
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-28-p0-d518-session-timeline-consistency.
|
||||
// Responsibility: Stable Workbench timeline row identities derived from authoritative message projections.
|
||||
// Mechanical source references:
|
||||
// - OpenCode pages/session/timeline/rows.ts:8-30 TimelineRowMap.
|
||||
// - OpenCode pages/session/timeline/rows.ts:32-105 tagged row/key/equality.
|
||||
// - OpenCode pages/session/timeline/rows.ts:107-242 constructMessageRows.
|
||||
// - OpenCode pages/session/timeline/rows.ts:284-329 unwrapErrorMessage.
|
||||
|
||||
import type { ChatMessage } from "@/types";
|
||||
import { firstScopePart, workbenchTimelineRowScopeKey } from "@/utils/workbench-key";
|
||||
|
||||
export interface WorkbenchMessageTimelineRow {
|
||||
type: "message";
|
||||
export type WorkbenchTimelineRowType = "TurnGap" | "CommentStrip" | "UserMessage" | "TurnDivider" | "AssistantPart" | "Thinking" | "Retry" | "DiffSummary" | "Error";
|
||||
|
||||
interface WorkbenchTimelineRowBase {
|
||||
type: WorkbenchTimelineRowType;
|
||||
key: string;
|
||||
identity: string;
|
||||
message: ChatMessage;
|
||||
role: ChatMessage["role"];
|
||||
sessionId: string | null;
|
||||
traceId: string | null;
|
||||
userMessageId: string | null;
|
||||
message: ChatMessage | null;
|
||||
role?: ChatMessage["role"];
|
||||
}
|
||||
|
||||
export type WorkbenchTimelineRow = WorkbenchMessageTimelineRow;
|
||||
export interface WorkbenchMessageTimelineRow extends WorkbenchTimelineRowBase {
|
||||
type: "UserMessage" | "AssistantPart";
|
||||
message: ChatMessage;
|
||||
role: ChatMessage["role"];
|
||||
anchor?: boolean;
|
||||
previousAssistantPart?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkbenchSyntheticTimelineRow extends WorkbenchTimelineRowBase {
|
||||
type: "TurnGap" | "CommentStrip" | "TurnDivider" | "Thinking" | "Retry" | "DiffSummary" | "Error";
|
||||
message: null;
|
||||
label?: "compaction" | "interrupted";
|
||||
text?: string | null;
|
||||
reasoningHeading?: string | null;
|
||||
diffs?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type WorkbenchTimelineRow = WorkbenchMessageTimelineRow | WorkbenchSyntheticTimelineRow;
|
||||
|
||||
export function buildWorkbenchTimelineRows(messages: ChatMessage[]): WorkbenchTimelineRow[] {
|
||||
const rows: WorkbenchTimelineRow[] = [];
|
||||
const indexByIdentity = new Map<string, number>();
|
||||
messages.forEach((message, index) => {
|
||||
let currentUserMessageId: string | null = null;
|
||||
let assistantPartCount = 0;
|
||||
const deduped = dedupeTimelineMessages(messages);
|
||||
deduped.forEach((message, index) => {
|
||||
const identity = workbenchMessageIdentity(message, index);
|
||||
const row: WorkbenchMessageTimelineRow = {
|
||||
type: "message",
|
||||
key: workbenchTimelineRowScopeKey({
|
||||
sessionId: message.sessionId,
|
||||
traceId: message.traceId,
|
||||
type: "message",
|
||||
role: message.role,
|
||||
rowId: identity
|
||||
}),
|
||||
identity,
|
||||
message,
|
||||
role: message.role,
|
||||
sessionId: firstScopePart(message.sessionId),
|
||||
traceId: firstScopePart(message.traceId)
|
||||
};
|
||||
const existing = indexByIdentity.get(identity);
|
||||
if (existing === undefined) {
|
||||
indexByIdentity.set(identity, rows.length);
|
||||
rows.push(row);
|
||||
} else {
|
||||
rows[existing] = row;
|
||||
const sessionId = firstScopePart(message.sessionId);
|
||||
const traceId = firstScopePart(message.traceId, message.runnerTrace?.traceId);
|
||||
if (message.role === "user") {
|
||||
if (currentUserMessageId) rows.push(syntheticRow("TurnGap", currentUserMessageId, sessionId, traceId));
|
||||
currentUserMessageId = identity;
|
||||
assistantPartCount = 0;
|
||||
rows.push(messageRow("UserMessage", message, identity, sessionId, traceId, { anchor: true }));
|
||||
if (messageHasCommentStrip(message)) rows.push(syntheticRow("CommentStrip", identity, sessionId, traceId));
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessageId = currentUserMessageId ?? previousUserMessageIdentity(deduped, index) ?? identity;
|
||||
rows.push(messageRow("AssistantPart", message, identity, sessionId, traceId, { userMessageId, previousAssistantPart: assistantPartCount > 0 }));
|
||||
assistantPartCount += 1;
|
||||
|
||||
if (messageIsThinking(message)) rows.push(syntheticRow("Thinking", userMessageId, sessionId, traceId, { reasoningHeading: reasoningHeading(message) }));
|
||||
if (messageIsRetry(message)) rows.push(syntheticRow("Retry", userMessageId, sessionId, traceId));
|
||||
const errorText = messageErrorText(message);
|
||||
if (errorText) rows.push(syntheticRow("Error", userMessageId, sessionId, traceId, { text: errorText }));
|
||||
const diffs = summaryDiffs(message);
|
||||
if (diffs.length > 0 && !messageIsActive(message)) rows.push(syntheticRow("DiffSummary", userMessageId, sessionId, traceId, { diffs }));
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function normalizeWorkbenchTimelineMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
return buildWorkbenchTimelineRows(messages).map((row) => row.message);
|
||||
return buildWorkbenchTimelineRows(messages).flatMap((row) => row.message ? [row.message] : []);
|
||||
}
|
||||
|
||||
export function workbenchMessageIdentity(message: ChatMessage, fallbackIndex: number | null = null): string {
|
||||
@@ -56,12 +82,188 @@ export function workbenchMessageIdentity(message: ChatMessage, fallbackIndex: nu
|
||||
if (authorityId) return authorityId;
|
||||
const turnId = firstScopePart(message.turnId);
|
||||
if (turnId) return `turn:${turnId}`;
|
||||
const traceId = firstScopePart(message.traceId);
|
||||
const traceId = firstScopePart(message.traceId, message.runnerTrace?.traceId);
|
||||
if (traceId && message.role === "agent") return `agent-trace:${traceId}`;
|
||||
return `message-index:${fallbackIndex ?? message.createdAt}`;
|
||||
}
|
||||
|
||||
export function workbenchTimelineSignature(rows: WorkbenchTimelineRow[]): string {
|
||||
return rows.map((row) => [row.key, row.message.status, String(row.message.text ?? "").length].join(":")).join("|");
|
||||
return rows.map((row) => {
|
||||
const state = row.message ? row.message.status : row.text ?? row.label ?? row.reasoningHeading ?? "";
|
||||
const length = row.message ? String(row.message.text ?? "").length : String(state ?? "").length;
|
||||
return [row.type, row.key, state, length].join(":");
|
||||
}).join("|");
|
||||
}
|
||||
|
||||
export function timelineRowMessage(row: WorkbenchTimelineRow): ChatMessage | null {
|
||||
return row.message;
|
||||
}
|
||||
|
||||
function dedupeTimelineMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
const rows: ChatMessage[] = [];
|
||||
const indexByIdentity = new Map<string, number>();
|
||||
messages.forEach((message, index) => {
|
||||
const identity = workbenchMessageIdentity(message, index);
|
||||
const existing = indexByIdentity.get(identity);
|
||||
if (existing === undefined) {
|
||||
indexByIdentity.set(identity, rows.length);
|
||||
rows.push(message);
|
||||
} else {
|
||||
rows[existing] = message;
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function messageRow(type: "UserMessage" | "AssistantPart", message: ChatMessage, identity: string, sessionId: string | null, traceId: string | null, extra: { userMessageId?: string | null; anchor?: boolean; previousAssistantPart?: boolean } = {}): WorkbenchMessageTimelineRow {
|
||||
const userMessageId = extra.userMessageId ?? (type === "UserMessage" ? identity : null);
|
||||
return {
|
||||
type,
|
||||
key: rowKey(type, sessionId, traceId, userMessageId, message.role, identity),
|
||||
identity,
|
||||
message,
|
||||
role: message.role,
|
||||
sessionId,
|
||||
traceId,
|
||||
userMessageId,
|
||||
anchor: extra.anchor,
|
||||
previousAssistantPart: extra.previousAssistantPart
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticRow(type: WorkbenchSyntheticTimelineRow["type"], userMessageId: string | null, sessionId: string | null, traceId: string | null, extra: Partial<WorkbenchSyntheticTimelineRow> = {}): WorkbenchSyntheticTimelineRow {
|
||||
const identity = `${type}:${userMessageId ?? "pending"}:${extra.label ?? extra.text ?? extra.reasoningHeading ?? ""}`;
|
||||
return {
|
||||
type,
|
||||
key: rowKey(type, sessionId, traceId, userMessageId, null, identity),
|
||||
identity,
|
||||
sessionId,
|
||||
traceId,
|
||||
userMessageId,
|
||||
message: null,
|
||||
label: extra.label,
|
||||
text: extra.text ?? null,
|
||||
reasoningHeading: extra.reasoningHeading ?? null,
|
||||
diffs: extra.diffs
|
||||
};
|
||||
}
|
||||
|
||||
function rowKey(type: WorkbenchTimelineRowType, sessionId: string | null, traceId: string | null, userMessageId: string | null, role: ChatMessage["role"] | null, rowId: string): string {
|
||||
return workbenchTimelineRowScopeKey({ sessionId, traceId, type, role, rowId: userMessageId ? `${userMessageId}:${rowId}` : rowId });
|
||||
}
|
||||
|
||||
function previousUserMessageIdentity(messages: ChatMessage[], beforeIndex: number): string | null {
|
||||
for (let index = beforeIndex - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message?.role === "user") return workbenchMessageIdentity(message, index);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function messageHasCommentStrip(message: ChatMessage): boolean {
|
||||
return Array.isArray((message as Record<string, unknown>).comments) && ((message as Record<string, unknown>).comments as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
function messageIsThinking(message: ChatMessage): boolean {
|
||||
return message.role === "agent" && messageIsActive(message) && !String(message.text ?? "").trim();
|
||||
}
|
||||
|
||||
function messageIsRetry(message: ChatMessage): boolean {
|
||||
return normalizeStatus(message.status) === "retry";
|
||||
}
|
||||
|
||||
function messageIsActive(message: ChatMessage): boolean {
|
||||
const status = normalizeStatus(message.status);
|
||||
return status === "pending" || status === "running" || status === "busy" || status === "retry";
|
||||
}
|
||||
|
||||
function messageErrorText(message: ChatMessage): string | null {
|
||||
const source = recordValue(message.error) ?? recordValue(message.projection?.blocker) ?? recordValue(message.runnerTrace?.blocker);
|
||||
const text = firstNonEmptyString(source?.userMessage, source?.message, source?.summary, source?.code ? String(source.code) : null);
|
||||
return text ? unwrapErrorMessage(text) : null;
|
||||
}
|
||||
|
||||
function summaryDiffs(message: ChatMessage): Array<Record<string, unknown>> {
|
||||
const summary = recordValue((message as Record<string, unknown>).summary);
|
||||
const diffs = Array.isArray(summary?.diffs) ? summary.diffs : [];
|
||||
const seen = new Set<string>();
|
||||
return diffs.reduceRight<Array<Record<string, unknown>>>((result, item) => {
|
||||
const diff = recordValue(item);
|
||||
const file = firstNonEmptyString(diff?.file);
|
||||
if (!diff || !file || seen.has(file)) return result;
|
||||
seen.add(file);
|
||||
result.push(diff);
|
||||
return result;
|
||||
}, []).reverse();
|
||||
}
|
||||
|
||||
function reasoningHeading(message: ChatMessage): string | null {
|
||||
const traceEvents = Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : [];
|
||||
const reasoning = traceEvents.map((event) => firstNonEmptyString(event.text, event.message, event.outputSummary)).find(Boolean) ?? null;
|
||||
return reasoning ? extractHeading(reasoning) : null;
|
||||
}
|
||||
|
||||
function extractHeading(text: string): string | null {
|
||||
const markdown = text.replace(/\r\n?/g, "\n");
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i);
|
||||
if (html?.[1]) return cleanHeading(html[1].replace(/<[^>]+>/g, " "));
|
||||
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m);
|
||||
if (atx?.[1]) return cleanHeading(atx[1]);
|
||||
const setext = markdown.match(/^([^\n]+)\n(?:=+|-)\s*$/m);
|
||||
if (setext?.[1]) return cleanHeading(setext[1]);
|
||||
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m);
|
||||
if (strong?.[1]) return cleanHeading(strong[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanHeading(value: string): string | null {
|
||||
const text = value.replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*_~]+/g, "").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function unwrapErrorMessage(message: string): string {
|
||||
const text = message.replace(/^Error:\s*/, "").trim();
|
||||
const parse = (value: string): unknown => {
|
||||
try { return JSON.parse(value) as unknown; } catch { return undefined; }
|
||||
};
|
||||
const read = (value: string): unknown => {
|
||||
const first = parse(value);
|
||||
return typeof first === "string" ? parse(first.trim()) : first;
|
||||
};
|
||||
let json = read(text);
|
||||
if (json === undefined) {
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start !== -1 && end > start) json = read(text.slice(start, end + 1));
|
||||
}
|
||||
const source = recordValue(json);
|
||||
if (!source) return message;
|
||||
const error = recordValue(source.error);
|
||||
if (error) {
|
||||
const type = firstNonEmptyString(error.type);
|
||||
const msg = firstNonEmptyString(error.message);
|
||||
if (type && msg) return `${type}: ${msg}`;
|
||||
if (msg) return msg;
|
||||
if (type) return type;
|
||||
const code = firstNonEmptyString(error.code);
|
||||
if (code) return code;
|
||||
}
|
||||
return firstNonEmptyString(source.message, source.error) ?? message;
|
||||
}
|
||||
|
||||
function normalizeStatus(status: unknown): string {
|
||||
return String(status ?? "").trim().toLowerCase().replace(/_/gu, "-");
|
||||
}
|
||||
|
||||
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" && typeof value !== "number") continue;
|
||||
const text = String(value).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@ import { computed, nextTick, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "@/api";
|
||||
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 { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionBlocker, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime";
|
||||
import { createWorkbenchReadHydrationRuntime, createWorkbenchScheduledTaskRuntime, createWorkbenchTraceHydrationQueueRuntime, 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 { createWorkbenchStreamTransportRuntime, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } 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";
|
||||
import { workbenchRealtimeScopeKey } from "@/utils/workbench-key";
|
||||
import { composeWorkbenchScopedKey } from "@/utils/workbench-key";
|
||||
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
|
||||
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";
|
||||
import { cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
|
||||
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
|
||||
import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer";
|
||||
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
|
||||
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
|
||||
@@ -83,22 +84,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value));
|
||||
const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value));
|
||||
const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value));
|
||||
const traceHydrationInFlight = new Set<string>();
|
||||
const traceHydrationQueued = new Set<string>();
|
||||
const traceHydrationQueue: ChatMessage[] = [];
|
||||
const forcedTraceHydrationRetryTimers = new Map<string, number>();
|
||||
let traceHydrationPumpActive = false;
|
||||
const terminalRealtimeRefreshInFlight = new Set<string>();
|
||||
const realtimeSessionMessagesInFlight = new Set<string>();
|
||||
const realtimeSessionMessagesLastRefreshAtById = new Map<string, number>();
|
||||
const realtimeSessionRefreshInFlight = new Set<string>();
|
||||
const realtimeErrorGapFillLastAtByKey = new Map<string, number>();
|
||||
const activeTraceRestGapFillTimers = new Map<string, number>();
|
||||
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 traceHydrationRuntime = createWorkbenchTraceHydrationQueueRuntime<ChatMessage>({ concurrency: TRACE_HYDRATION_BACKGROUND_CONCURRENCY, delayMs: TRACE_HYDRATION_BACKGROUND_DELAY_MS });
|
||||
const forcedTraceHydrationRetryRuntime = createWorkbenchScheduledTaskRuntime();
|
||||
const terminalRealtimeRefreshRuntime = createWorkbenchScheduledTaskRuntime();
|
||||
const realtimeSessionMessagesRuntime = createWorkbenchScheduledTaskRuntime();
|
||||
const realtimeSessionRefreshRuntime = createWorkbenchScheduledTaskRuntime();
|
||||
const activeTraceRestGapFillRuntime = createWorkbenchScheduledTaskRuntime();
|
||||
const sessionListRefreshRuntime = createWorkbenchScheduledTaskRuntime();
|
||||
const workbenchProjectionSignalSourceId = nextProtocolId("wbtab");
|
||||
let workbenchProjectionSignalChannel: BroadcastChannel | null = null;
|
||||
const workbenchReadHydrationRuntime = createWorkbenchReadHydrationRuntime({ concurrency: WORKBENCH_READ_HYDRATION_CONCURRENCY, failureCooldownMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS });
|
||||
@@ -312,17 +305,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const requestIncludeSessionId = firstNonEmptyString(includeSessionId);
|
||||
const requestLimit = currentSessionListLimit();
|
||||
const requestKey = sessionListRefreshKey(requestIncludeSessionId, requestLimit);
|
||||
const existing = sessionListRefreshInFlight.get(requestKey);
|
||||
if (existing) return existing;
|
||||
const now = Date.now();
|
||||
const lastRefreshAt = sessionListLastRefreshAtByKey.get(requestKey) ?? 0;
|
||||
if (options.force !== true && lastRefreshAt > 0 && now - lastRefreshAt < SESSION_LIST_MIN_REFRESH_INTERVAL_MS) return;
|
||||
sessionListLastRefreshAtByKey.set(requestKey, now);
|
||||
const refresh = refreshSessionsNow(requestIncludeSessionId, requestLimit).finally(() => {
|
||||
if (sessionListRefreshInFlight.get(requestKey) === refresh) sessionListRefreshInFlight.delete(requestKey);
|
||||
});
|
||||
sessionListRefreshInFlight.set(requestKey, refresh);
|
||||
return refresh;
|
||||
await sessionListRefreshRuntime.run(requestKey, () => refreshSessionsNow(requestIncludeSessionId, requestLimit), { force: options.force, minIntervalMs: SESSION_LIST_MIN_REFRESH_INTERVAL_MS, reason: "session-list" });
|
||||
}
|
||||
|
||||
async function refreshSessionsNow(includeSessionId: string | null, limit: number): Promise<void> {
|
||||
@@ -350,20 +333,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
void refreshSessions(requestIncludeSessionId);
|
||||
return;
|
||||
}
|
||||
const existing = sessionListRefreshTimers.get(requestKey);
|
||||
if (existing) return;
|
||||
const lastRefreshAt = sessionListLastRefreshAtByKey.get(requestKey) ?? 0;
|
||||
const cooldownMs = lastRefreshAt > 0 ? Math.max(0, SESSION_LIST_MIN_REFRESH_INTERVAL_MS - (Date.now() - lastRefreshAt)) : 0;
|
||||
const boundedDelayMs = Math.max(0, Math.trunc(delayMs), cooldownMs);
|
||||
const timer = window.setTimeout(() => {
|
||||
sessionListRefreshTimers.delete(requestKey);
|
||||
void refreshSessions(requestIncludeSessionId);
|
||||
}, boundedDelayMs);
|
||||
sessionListRefreshTimers.set(requestKey, timer);
|
||||
sessionListRefreshRuntime.schedule(requestKey, () => refreshSessions(requestIncludeSessionId), { delayMs, minIntervalMs: SESSION_LIST_MIN_REFRESH_INTERVAL_MS, reason: "session-list" });
|
||||
}
|
||||
|
||||
function sessionListRefreshKey(includeSessionId: string | null | undefined, limit: number): string {
|
||||
return `${firstNonEmptyString(includeSessionId) ?? ""}|${limit}`;
|
||||
return composeWorkbenchScopedKey("workbench.session-list", firstNonEmptyString(includeSessionId), limit);
|
||||
}
|
||||
|
||||
async function loadMoreSessions(): Promise<void> {
|
||||
@@ -409,8 +383,16 @@ 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;
|
||||
const beforeTrim = serverState.value;
|
||||
const trimmed = trimWorkbenchSessionCache(beforeTrim, { retainSessionIds: [activeSessionId.value, explicitSessionId.value], maxSessions: currentSessionListLimit() });
|
||||
const cleaned = cleanupDroppedWorkbenchSessionCaches(trimmed.state, trimmed.state.sessionOrder);
|
||||
const authorityCleaned = cleanupWorkbenchServerStateSessions(beforeTrim, [...trimmed.evictedSessionIds, ...cleaned.droppedSessionIds]);
|
||||
serverState.value = {
|
||||
...authorityCleaned,
|
||||
sessionOrder: cleaned.state.sessionOrder,
|
||||
sessionsById: cleaned.state.sessionsById,
|
||||
messagesBySessionId: cleaned.state.messagesBySessionId
|
||||
};
|
||||
}
|
||||
|
||||
function forgetSession(sessionId: string): void {
|
||||
@@ -482,61 +464,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function workbenchReadCooldownKey(kind: string, id: string): string {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
function createWorkbenchReadCooldownResult(key: string, cooldownUntilMs: number): ApiResult<unknown> {
|
||||
const retryAfterMs = Math.max(0, cooldownUntilMs - Date.now());
|
||||
const message = `Workbench read hydration is cooling down after a transient failure (${key}); retry after ${retryAfterMs}ms.`;
|
||||
const diagnostic = {
|
||||
code: "workbench_read_hydration_cooldown",
|
||||
category: "network",
|
||||
source: "workbench-web",
|
||||
message,
|
||||
retryable: true,
|
||||
};
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
data: null,
|
||||
error: message,
|
||||
apiError: {
|
||||
code: "workbench_read_hydration_cooldown",
|
||||
category: "network",
|
||||
source: "workbench-web",
|
||||
message,
|
||||
retryable: true,
|
||||
diagnostic,
|
||||
},
|
||||
diagnostic,
|
||||
} as ApiResult<unknown>;
|
||||
}
|
||||
|
||||
function createWorkbenchReadThrottledResult(key: string, nextAllowedAtMs: number): ApiResult<unknown> {
|
||||
const retryAfterMs = Math.max(0, nextAllowedAtMs - Date.now());
|
||||
const message = `Workbench read hydration is throttled (${key}); retry after ${retryAfterMs}ms.`;
|
||||
const diagnostic = {
|
||||
code: "workbench_read_hydration_throttled",
|
||||
category: "throttle",
|
||||
source: "workbench-web",
|
||||
message,
|
||||
retryable: true,
|
||||
};
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: null,
|
||||
error: message,
|
||||
apiError: {
|
||||
code: "workbench_read_hydration_throttled",
|
||||
category: "throttle",
|
||||
source: "workbench-web",
|
||||
message,
|
||||
retryable: true,
|
||||
diagnostic,
|
||||
},
|
||||
diagnostic,
|
||||
} as ApiResult<unknown>;
|
||||
return composeWorkbenchScopedKey("workbench.read", kind, id);
|
||||
}
|
||||
|
||||
function isApiResultLike(value: unknown): value is ApiResult<unknown> {
|
||||
@@ -593,17 +521,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
async function refreshRealtimeSessionMessages(sessionId: string | null | undefined, reason: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
const id = normalizeWorkbenchSessionId(sessionId);
|
||||
if (!id || id !== activeSessionId.value || realtimeSessionMessagesInFlight.has(id)) return;
|
||||
const lastRefreshAt = realtimeSessionMessagesLastRefreshAtById.get(id) ?? 0;
|
||||
if (!options.force && lastRefreshAt > 0 && Date.now() - lastRefreshAt < WORKBENCH_REALTIME_SESSION_MESSAGES_MIN_REFRESH_MS) return;
|
||||
realtimeSessionMessagesLastRefreshAtById.set(id, Date.now());
|
||||
realtimeSessionMessagesInFlight.add(id);
|
||||
try {
|
||||
if (!id || id !== activeSessionId.value) return;
|
||||
await realtimeSessionMessagesRuntime.run(composeWorkbenchScopedKey("workbench.realtime.session-messages", id), async () => {
|
||||
recordActivity(reason);
|
||||
await refreshSessionMessageProjectionPage(id, { force: true });
|
||||
} finally {
|
||||
realtimeSessionMessagesInFlight.delete(id);
|
||||
}
|
||||
}, { force: options.force, minIntervalMs: WORKBENCH_REALTIME_SESSION_MESSAGES_MIN_REFRESH_MS, reason });
|
||||
}
|
||||
|
||||
async function refreshMessageProjectionForTrace(sessionId: string | null | undefined, traceId: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
@@ -890,28 +812,27 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
async function hydrateTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
if (traceHydrationInFlight.has(traceId)) {
|
||||
if (options.force) scheduleForcedTraceHydrationRetry(message);
|
||||
return;
|
||||
}
|
||||
traceHydrationInFlight.add(traceId);
|
||||
try {
|
||||
let afterProjectedSeq = traceHydrationProjectedSeq(message.runnerTrace);
|
||||
for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) {
|
||||
const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq, { force: options.force });
|
||||
if (!result.ok || !result.data) {
|
||||
if (shouldSuppressTransientWorkbenchReadFailure(result)) return;
|
||||
if (messageHasCompletedFinalResponse(message)) return;
|
||||
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
|
||||
return;
|
||||
}
|
||||
applyTraceHydrationResult(traceId, result.data);
|
||||
const nextProjectedSeq = traceNextProjectedSeq(result.data, afterProjectedSeq);
|
||||
if (result.data.hasMore !== true || nextProjectedSeq <= afterProjectedSeq) return;
|
||||
afterProjectedSeq = nextProjectedSeq;
|
||||
await traceHydrationRuntime.run(traceId, () => hydrateTraceEventsForMessageNow(message, options), {
|
||||
onAlreadyRunning: options.force ? () => scheduleForcedTraceHydrationRetry(message) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async function hydrateTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
let afterProjectedSeq = traceHydrationProjectedSeq(message.runnerTrace);
|
||||
for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) {
|
||||
const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq, { force: options.force });
|
||||
if (!result.ok || !result.data) {
|
||||
if (shouldSuppressTransientWorkbenchReadFailure(result)) return;
|
||||
if (messageHasCompletedFinalResponse(message)) return;
|
||||
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
traceHydrationInFlight.delete(traceId);
|
||||
applyTraceHydrationResult(traceId, result.data);
|
||||
const nextProjectedSeq = traceNextProjectedSeq(result.data, afterProjectedSeq);
|
||||
if (result.data.hasMore !== true || nextProjectedSeq <= afterProjectedSeq) return;
|
||||
afterProjectedSeq = nextProjectedSeq;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,20 +860,17 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
|
||||
function scheduleForcedTraceHydrationRetry(message: ChatMessage): void {
|
||||
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
||||
if (!traceId || typeof window === "undefined" || forcedTraceHydrationRetryTimers.has(traceId)) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
forcedTraceHydrationRetryTimers.delete(traceId);
|
||||
if (!traceId || typeof window === "undefined") return;
|
||||
forcedTraceHydrationRetryRuntime.schedule(composeWorkbenchScopedKey("workbench.trace-hydration.retry", traceId), async () => {
|
||||
const ownerSessionId = traceOwnerSessionId(traceId, messageSessionAuthority(message));
|
||||
const ownerMessages = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value;
|
||||
const latest = latestMessageForTrace(traceId, ownerMessages) ?? message;
|
||||
void hydrateTraceEventsForMessage(latest, { force: true });
|
||||
}, TRACE_HYDRATION_RETRY_DELAY_MS);
|
||||
forcedTraceHydrationRetryTimers.set(traceId, timer);
|
||||
await hydrateTraceEventsForMessage(latest, { force: true });
|
||||
}, { delayMs: TRACE_HYDRATION_RETRY_DELAY_MS, reason: "trace-hydration-retry" });
|
||||
}
|
||||
|
||||
async function hydrateTraceEvents(source: ChatMessage[] = messages.value): Promise<void> {
|
||||
for (const message of traceHydrationCandidates(source)) queueTraceHydration(message);
|
||||
void pumpTraceHydrationQueue();
|
||||
}
|
||||
|
||||
function traceHydrationCandidates(source: ChatMessage[]): ChatMessage[] {
|
||||
@@ -962,30 +880,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
function queueTraceHydration(message: ChatMessage): void {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
if (traceHydrationInFlight.has(traceId) || traceHydrationQueued.has(traceId)) return;
|
||||
traceHydrationQueued.add(traceId);
|
||||
traceHydrationQueue.push(message);
|
||||
}
|
||||
|
||||
async function pumpTraceHydrationQueue(): Promise<void> {
|
||||
if (traceHydrationPumpActive) return;
|
||||
traceHydrationPumpActive = true;
|
||||
try {
|
||||
while (traceHydrationQueue.length > 0) {
|
||||
const batch = traceHydrationQueue.splice(0, TRACE_HYDRATION_BACKGROUND_CONCURRENCY);
|
||||
await Promise.all(batch.map(async (message) => {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
try {
|
||||
await hydrateTraceEventsForMessage(message);
|
||||
} finally {
|
||||
if (traceId) traceHydrationQueued.delete(traceId);
|
||||
}
|
||||
}));
|
||||
if (traceHydrationQueue.length > 0) await delayTraceHydrationRetry(TRACE_HYDRATION_BACKGROUND_DELAY_MS);
|
||||
}
|
||||
} finally {
|
||||
traceHydrationPumpActive = false;
|
||||
}
|
||||
traceHydrationRuntime.enqueue(traceId, () => hydrateTraceEventsForMessageNow(message));
|
||||
}
|
||||
|
||||
function messagesWithTraceAuthority(source: ChatMessage[]): ChatMessage[] {
|
||||
@@ -1135,26 +1030,21 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
function restartRealtime(reason: string): void {
|
||||
const traceId = realtimeTraceId();
|
||||
const sessionId = selectedSessionId.value ?? null;
|
||||
const key = workbenchRealtimeScopeKey(sessionId, traceId);
|
||||
const afterSeq = realtimeOutboxSeqByKey.get(key) ?? null;
|
||||
void reason;
|
||||
realtimeTransport.restart({
|
||||
sessionId,
|
||||
traceId,
|
||||
afterSeq,
|
||||
errorRecoveryMinMs: WORKBENCH_REALTIME_ERROR_GAP_FILL_MIN_MS,
|
||||
onOpen: () => undefined,
|
||||
onError: () => handleRealtimeStreamError(sessionId, traceId),
|
||||
onRecovery: (recovery) => handleRealtimeRecovery(recovery),
|
||||
onEvent: (event, eventName) => applyRealtimeEvent(event, eventName)
|
||||
});
|
||||
}
|
||||
|
||||
function handleRealtimeStreamError(sessionId: string | null, traceId: string | null): void {
|
||||
const activeId = normalizeWorkbenchSessionId(sessionId ?? selectedSessionId.value);
|
||||
const activeTraceId = firstNonEmptyString(traceId, realtimeTraceId());
|
||||
function handleRealtimeRecovery(recovery: WorkbenchStreamTransportRecovery): void {
|
||||
const activeId = normalizeWorkbenchSessionId(recovery.sessionId ?? selectedSessionId.value);
|
||||
const activeTraceId = firstNonEmptyString(recovery.traceId, realtimeTraceId());
|
||||
if (!activeId && !activeTraceId) return;
|
||||
const key = workbenchRealtimeScopeKey(activeId, activeTraceId);
|
||||
const lastAt = realtimeErrorGapFillLastAtByKey.get(key) ?? 0;
|
||||
if (lastAt > 0 && Date.now() - lastAt < WORKBENCH_REALTIME_ERROR_GAP_FILL_MIN_MS) return;
|
||||
realtimeErrorGapFillLastAtByKey.set(key, Date.now());
|
||||
if (activeId === activeSessionId.value) void refreshRealtimeSessionMessages(activeId, "realtime-error:messages", { force: true });
|
||||
if (activeId) scheduleSessionListRefresh(activeId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS);
|
||||
if (!activeTraceId || !shouldApplyActiveTraceAuthority(activeTraceId, activeId)) return;
|
||||
@@ -1166,20 +1056,13 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
function scheduleActiveTraceRestGapFill(traceId: string | null | undefined, reason: string, delayMs = WORKBENCH_ACTIVE_TRACE_REST_GAP_FILL_INITIAL_MS): void {
|
||||
const id = firstNonEmptyString(traceId);
|
||||
if (!id || typeof window === "undefined") return;
|
||||
clearActiveTraceRestGapFill(id);
|
||||
const timer = window.setTimeout(() => {
|
||||
activeTraceRestGapFillTimers.delete(id);
|
||||
void refreshActiveTraceFromRest(id, reason);
|
||||
}, Math.max(0, Math.trunc(delayMs)));
|
||||
activeTraceRestGapFillTimers.set(id, timer);
|
||||
activeTraceRestGapFillRuntime.schedule(composeWorkbenchScopedKey("workbench.active-trace-gap", id), () => refreshActiveTraceFromRest(id, reason), { delayMs, replaceTimer: true, reason });
|
||||
}
|
||||
|
||||
function clearActiveTraceRestGapFill(traceId: string | null | undefined): void {
|
||||
const id = firstNonEmptyString(traceId);
|
||||
if (!id || typeof window === "undefined") return;
|
||||
const timer = activeTraceRestGapFillTimers.get(id);
|
||||
if (timer) window.clearTimeout(timer);
|
||||
activeTraceRestGapFillTimers.delete(id);
|
||||
activeTraceRestGapFillRuntime.cancelTimer(composeWorkbenchScopedKey("workbench.active-trace-gap", id));
|
||||
}
|
||||
|
||||
async function refreshActiveTraceFromRest(traceId: string, reason: string): Promise<void> {
|
||||
@@ -1221,22 +1104,6 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
return normalizeWorkbenchSessionId(firstNonEmptyString(event.sessionId, turn?.sessionId, snapshot?.sessionId, traceEvent?.sessionId));
|
||||
}
|
||||
|
||||
function rememberRealtimeOutboxSeq(event: WorkbenchRealtimeEvent): void {
|
||||
const outboxSeq = realtimeOutboxSeq(event);
|
||||
if (outboxSeq === null) return;
|
||||
const sessionId = realtimeEventSessionId(event) ?? selectedSessionId.value ?? null;
|
||||
const traceId = firstNonEmptyString(event.traceId, event.event?.traceId, event.snapshot?.traceId, currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value));
|
||||
if (!sessionId && !traceId) return;
|
||||
const key = workbenchRealtimeScopeKey(sessionId, traceId);
|
||||
const previous = realtimeOutboxSeqByKey.get(key) ?? 0;
|
||||
if (outboxSeq > previous) realtimeOutboxSeqByKey.set(key, outboxSeq);
|
||||
}
|
||||
|
||||
function realtimeOutboxSeq(event: WorkbenchRealtimeEvent): number | null {
|
||||
const parsed = Number(event.cursor?.outboxSeq ?? event.outboxSeq);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
|
||||
}
|
||||
|
||||
function traceResultSessionId(result: AgentChatResultResponse | TraceSnapshot | Record<string, unknown> | null | undefined): string | null {
|
||||
const value = recordValue(result);
|
||||
const runnerTrace = recordValue(value?.runnerTrace);
|
||||
@@ -1299,31 +1166,33 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
}
|
||||
|
||||
function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void {
|
||||
recordActivity(event.type ? `realtime:${event.type}` : `realtime:${eventName}`);
|
||||
rememberRealtimeOutboxSeq(event);
|
||||
if (event.type === "trace.snapshot") {
|
||||
applyRealtimeTraceSnapshot(event.traceId, event.snapshot);
|
||||
return;
|
||||
}
|
||||
if (event.type === "trace.event") {
|
||||
applyRealtimeTraceEvent(event.traceId, event.event, event.snapshot, event);
|
||||
return;
|
||||
}
|
||||
if (event.type === "message.snapshot" && event.message) {
|
||||
applyRealtimeMessageSnapshot(event);
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn.snapshot" && event.turn) {
|
||||
applyRealtimeTurnSnapshot(event.turn);
|
||||
return;
|
||||
}
|
||||
if (eventName === "workbench.error" || event.type === "error") {
|
||||
applyRealtimeProjectionError(event);
|
||||
return;
|
||||
}
|
||||
if (event.type === "trace.unavailable") {
|
||||
const traceId = firstNonEmptyString(event.traceId);
|
||||
if (traceId) void clearActiveTrace(traceId, "realtime-trace-unavailable");
|
||||
const reduced = reduceWorkbenchRealtimeEvent(event, eventName);
|
||||
recordActivity(reduced.activityLabel);
|
||||
applyWorkbenchRealtimeAction(reduced.action);
|
||||
}
|
||||
|
||||
function applyWorkbenchRealtimeAction(action: WorkbenchRealtimeAction): void {
|
||||
switch (action.type) {
|
||||
case "trace.snapshot":
|
||||
applyRealtimeTraceSnapshot(action.traceId, action.snapshot);
|
||||
return;
|
||||
case "trace.event":
|
||||
applyRealtimeTraceEvent(action.traceId, action.event, action.snapshot, action.realtimeEvent);
|
||||
return;
|
||||
case "message.snapshot":
|
||||
applyRealtimeMessageSnapshot(action.realtimeEvent);
|
||||
return;
|
||||
case "turn.snapshot":
|
||||
applyRealtimeTurnSnapshot(action.turn);
|
||||
return;
|
||||
case "projection.error":
|
||||
applyRealtimeProjectionError(action.realtimeEvent);
|
||||
return;
|
||||
case "trace.unavailable":
|
||||
if (action.traceId) void clearActiveTrace(action.traceId, action.reason);
|
||||
return;
|
||||
case "ignore":
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1366,9 +1235,8 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
|
||||
async function refreshTerminalTraceFromRest(traceId: string, reason: string): Promise<void> {
|
||||
const id = firstNonEmptyString(traceId);
|
||||
if (!id || terminalRealtimeRefreshInFlight.has(id)) return;
|
||||
terminalRealtimeRefreshInFlight.add(id);
|
||||
try {
|
||||
if (!id) return;
|
||||
await terminalRealtimeRefreshRuntime.run(composeWorkbenchScopedKey("workbench.terminal-trace-refresh", id), async () => {
|
||||
const ownerBefore = traceOwnerSessionId(id, null);
|
||||
if (ownerBefore === activeSessionId.value) recordActivity(reason);
|
||||
await refreshTurnStatusByTraceId(id, { force: true });
|
||||
@@ -1382,9 +1250,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
if (!message && ownerSessionId) {
|
||||
await refreshRealtimeSessionMessages(ownerSessionId, `${reason}:message-gap`, { force: true });
|
||||
}
|
||||
} finally {
|
||||
terminalRealtimeRefreshInFlight.delete(id);
|
||||
}
|
||||
}, { force: true, reason });
|
||||
}
|
||||
|
||||
function installRealtimeVisibilityHandler(): void {
|
||||
@@ -1463,16 +1329,13 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
|
||||
async function refreshRealtimeSessionFromRest(sessionId: string, reason: string): Promise<void> {
|
||||
const id = normalizeWorkbenchSessionId(sessionId);
|
||||
if (!id || id !== activeSessionId.value || realtimeSessionRefreshInFlight.has(id)) return;
|
||||
realtimeSessionRefreshInFlight.add(id);
|
||||
try {
|
||||
if (!id || id !== activeSessionId.value) return;
|
||||
await realtimeSessionRefreshRuntime.run(composeWorkbenchScopedKey("workbench.realtime.session-refresh", id), async () => {
|
||||
recordActivity(reason);
|
||||
const existing = sessions.value.find((item) => item.sessionId === id) ?? null;
|
||||
const selected = await loadWorkbenchSession(id, existing);
|
||||
if (selected && activeSessionId.value === id) applySelectedSessionDetail(selected, "system");
|
||||
} finally {
|
||||
realtimeSessionRefreshInFlight.delete(id);
|
||||
}
|
||||
}, { force: true, reason });
|
||||
}
|
||||
|
||||
function completeTrace(traceId: string, result: AgentChatResultResponse, options: { forceRead?: boolean } = {}): void {
|
||||
@@ -2271,29 +2134,6 @@ function projectionFromMessage(message: ChatMessage, trace: ChatMessage["runnerT
|
||||
return normalizeProjectionDiagnostic(message.projection ?? message) ?? normalizeProjectionDiagnostic(trace?.projection ?? trace) ?? null;
|
||||
}
|
||||
|
||||
function normalizeProjectionDiagnostic(value: unknown): ProjectionDiagnostic | null {
|
||||
const record = recordValue(value);
|
||||
if (!record) return null;
|
||||
const nested = recordValue(record.projection);
|
||||
const source = nested ?? record;
|
||||
const rawBlocker = recordValue(source.blocker ?? record.blocker);
|
||||
const apiError = normalizeApiErrorRecord(source.apiError ?? record.apiError, null);
|
||||
const diagnostic = normalizeErrorDiagnostic(source.diagnostic, record.diagnostic, rawBlocker?.diagnostic, apiError?.diagnostic);
|
||||
const blocker = normalizeProjectionBlocker(rawBlocker, { diagnostic, apiError });
|
||||
const projectionStatus = firstNonEmptyString(source.projectionStatus, record.projectionStatus);
|
||||
const projectionHealth = firstNonEmptyString(source.projectionHealth, record.projectionHealth);
|
||||
const lastProjectedSeq = firstFiniteNumber(source.lastProjectedSeq, record.lastProjectedSeq);
|
||||
const staleMs = firstFiniteNumber(source.staleMs, record.staleMs);
|
||||
const sourceRunId = firstNonEmptyString(source.sourceRunId, record.sourceRunId);
|
||||
const sourceCommandId = firstNonEmptyString(source.sourceCommandId, record.sourceCommandId);
|
||||
if (!projectionStatus && !projectionHealth && !blocker && lastProjectedSeq == null && staleMs == null && !sourceRunId && !sourceCommandId) return null;
|
||||
return { ...source, projectionStatus, projectionHealth, lastProjectedSeq: lastProjectedSeq ?? null, staleMs: staleMs ?? null, sourceRunId: sourceRunId ?? null, sourceCommandId: sourceCommandId ?? null, blocker, diagnostic, apiError, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic;
|
||||
}
|
||||
|
||||
function projectionDiagnosticFromApiFailure(result: ApiResult<unknown>, input: { code: string; message: string; health: string }): ProjectionDiagnostic {
|
||||
return projectionDiagnosticFromFailure({ ...input, status: result.status, apiError: result.apiError, diagnostic: result.diagnostic });
|
||||
}
|
||||
|
||||
function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult<unknown>): boolean {
|
||||
const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic);
|
||||
const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code);
|
||||
@@ -2303,87 +2143,10 @@ function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult<unknown>)
|
||||
return result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network";
|
||||
}
|
||||
|
||||
function projectionDiagnosticFromFailure(input: { code: string; message: string; health: string; status?: number | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; route?: string | null; source?: string | null }): ProjectionDiagnostic {
|
||||
const apiError = input.apiError ?? null;
|
||||
const diagnostic = normalizeErrorDiagnostic(input.diagnostic, apiError?.diagnostic, {
|
||||
contractVersion: "hwlab-error-diagnostic-v1",
|
||||
traceId: apiError?.traceId ?? null,
|
||||
requestId: apiError?.requestId ?? null,
|
||||
route: firstNonEmptyString(apiError?.route, input.route),
|
||||
layer: apiError?.layer ?? "workbench",
|
||||
category: apiError?.category ?? "workbench-projection",
|
||||
code: firstStringOrNumber(apiError?.code, input.code),
|
||||
httpStatus: input.status ?? null,
|
||||
source: firstNonEmptyString(apiError?.source, input.source, "browser"),
|
||||
retryable: apiError?.retryable ?? true,
|
||||
valuesPrinted: false
|
||||
});
|
||||
const message = firstNonEmptyString(apiError?.userMessage, apiError?.message, input.message) ?? input.message;
|
||||
const blocker = normalizeProjectionBlocker({
|
||||
code: firstStringOrNumber(apiError?.code, diagnostic?.code, input.code),
|
||||
message,
|
||||
userMessage: firstNonEmptyString(apiError?.userMessage, message),
|
||||
layer: firstNonEmptyString(apiError?.layer, diagnostic?.layer, "workbench"),
|
||||
category: firstNonEmptyString(apiError?.category, diagnostic?.category, "workbench-projection"),
|
||||
retryable: firstBoolean(apiError?.retryable, diagnostic?.retryable, true),
|
||||
route: firstNonEmptyString(apiError?.route, diagnostic?.route, input.route),
|
||||
traceId: firstNonEmptyString(apiError?.traceId, diagnostic?.traceId),
|
||||
requestId: firstNonEmptyString(apiError?.requestId, diagnostic?.requestId),
|
||||
source: firstNonEmptyString(apiError?.source, diagnostic?.source, input.source),
|
||||
httpStatus: firstFiniteNumber(diagnostic?.httpStatus, input.status),
|
||||
diagnostic,
|
||||
valuesPrinted: false
|
||||
}, { diagnostic, apiError, fallbackCode: input.code, fallbackMessage: message });
|
||||
return { projectionStatus: "blocked", projectionHealth: input.health, blocker, diagnostic, apiError, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function normalizeProjectionBlocker(value: unknown, context: { diagnostic?: ErrorDiagnostic | null; apiError?: ApiError | null; fallbackCode?: string | null; fallbackMessage?: string | null } = {}): ProjectionBlocker | null {
|
||||
const record = recordValue(value);
|
||||
if (!record && !context.diagnostic && !context.apiError && !context.fallbackMessage && !context.fallbackCode) return null;
|
||||
const source = record ?? {};
|
||||
const diagnostic = context.diagnostic ?? normalizeErrorDiagnostic(source.diagnostic, context.apiError?.diagnostic);
|
||||
const code = firstStringOrNumber(source.code, context.apiError?.code, diagnostic?.code, context.fallbackCode);
|
||||
const message = firstNonEmptyString(source.message, source.userMessage, source.summary, context.apiError?.userMessage, context.apiError?.message, context.fallbackMessage);
|
||||
const retryable = firstBoolean(source.retryable, context.apiError?.retryable, diagnostic?.retryable);
|
||||
return {
|
||||
...source,
|
||||
code: code === null ? undefined : String(code),
|
||||
message: message ?? null,
|
||||
userMessage: firstNonEmptyString(source.userMessage, context.apiError?.userMessage, message),
|
||||
summary: firstNonEmptyString(source.summary),
|
||||
layer: firstNonEmptyString(source.layer, context.apiError?.layer, diagnostic?.layer),
|
||||
category: firstNonEmptyString(source.category, context.apiError?.category, diagnostic?.category, "workbench-projection"),
|
||||
retryable: retryable ?? true,
|
||||
route: firstNonEmptyString(source.route, context.apiError?.route, diagnostic?.route),
|
||||
traceId: firstNonEmptyString(source.traceId, context.apiError?.traceId, diagnostic?.traceId),
|
||||
requestId: firstNonEmptyString(source.requestId, context.apiError?.requestId, diagnostic?.requestId),
|
||||
source: firstNonEmptyString(source.source, context.apiError?.source, diagnostic?.source),
|
||||
httpStatus: firstFiniteNumber(source.httpStatus, source.http_status, diagnostic?.httpStatus) ?? null,
|
||||
diagnostic,
|
||||
valuesPrinted: source.valuesPrinted === true
|
||||
} as ProjectionBlocker;
|
||||
}
|
||||
|
||||
function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
|
||||
return normalizeRuntimeErrorDiagnostic(...values);
|
||||
}
|
||||
|
||||
function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null {
|
||||
return normalizeRuntimeApiErrorRecord(value, fallback, diagnostic);
|
||||
}
|
||||
|
||||
function agentErrorFromApiFailure(result: ApiResult<unknown>, projection: ProjectionDiagnostic, fallback: string): ChatMessage["error"] {
|
||||
return normalizeAgentError(result.apiError ?? projection.apiError ?? projection.blocker ?? { message: result.error ?? fallback, diagnostic: result.diagnostic ?? projection.diagnostic }) ?? { message: result.error ?? fallback, diagnostic: result.diagnostic ?? projection.diagnostic };
|
||||
}
|
||||
|
||||
function agentErrorFromProjection(projection: ProjectionDiagnostic): ChatMessage["error"] | null {
|
||||
const blocker = projection.blocker ?? null;
|
||||
const diagnostic = projection.diagnostic ?? blocker?.diagnostic ?? projection.apiError?.diagnostic ?? null;
|
||||
const message = firstNonEmptyString(blocker?.userMessage, blocker?.message, blocker?.summary, projection.apiError?.userMessage, projection.apiError?.message, diagnostic?.code ? String(diagnostic.code) : null);
|
||||
if (!blocker && !projection.apiError && !diagnostic && !message) return null;
|
||||
return normalizeAgentError({ ...(projection.apiError ?? {}), ...(blocker ?? {}), message, code: firstStringOrNumber(blocker?.code, projection.apiError?.code, diagnostic?.code), diagnostic, traceId: firstNonEmptyString(blocker?.traceId, projection.apiError?.traceId, diagnostic?.traceId), requestId: firstNonEmptyString(blocker?.requestId, projection.apiError?.requestId, diagnostic?.requestId) });
|
||||
}
|
||||
|
||||
function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
|
||||
if (message.role !== "agent") return false;
|
||||
if (!isTerminalMessageStatus(message.status)) return false;
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
// 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.
|
||||
// Responsibility: Workbench typed error, diagnostic normalization, projection blockers and readable server errors.
|
||||
// Mechanical source references:
|
||||
// - OpenCode utils/server-errors.ts:1-17 named error shapes.
|
||||
// - OpenCode utils/server-errors.ts:28-35 formatServerError.
|
||||
// - OpenCode utils/server-errors.ts:38-55 unwrap/type guards.
|
||||
// - OpenCode utils/server-errors.ts:57-88 readable config/provider errors.
|
||||
// - OpenCode stream.transport.ts:250-271 formatUnknownError.
|
||||
// - OpenCode session-data.ts:163-183 formatError boundary.
|
||||
|
||||
import type { ApiError, ErrorDiagnostic } from "@/types";
|
||||
import type { ApiError, ChatMessage, ErrorDiagnostic, ProjectionBlocker, ProjectionDiagnostic } from "@/types";
|
||||
|
||||
export type ConfigInvalidError = {
|
||||
name: "ConfigInvalidError";
|
||||
data: { path?: string; message?: string; issues?: Array<{ message: string; path: string[] }> };
|
||||
};
|
||||
|
||||
export type ProviderModelNotFoundError = {
|
||||
name: "ProviderModelNotFoundError";
|
||||
data: { providerID: string; modelID: string; suggestions?: string[] };
|
||||
};
|
||||
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export function formatServerError(error: unknown, translate?: Translator, fallback?: string): string {
|
||||
const unwrapped = unwrapNamedError(error);
|
||||
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate);
|
||||
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate);
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
if (fallback) return fallback;
|
||||
return tr(translate, "error.chain.unknown", "Unknown error");
|
||||
}
|
||||
|
||||
export function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
|
||||
for (const value of values) {
|
||||
@@ -51,6 +80,146 @@ export function normalizeApiErrorRecord(value: unknown, fallback: string | null
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
export function normalizeProjectionDiagnostic(value: unknown): ProjectionDiagnostic | null {
|
||||
const record = recordValue(value);
|
||||
if (!record) return null;
|
||||
const nested = recordValue(record.projection);
|
||||
const source = nested ?? record;
|
||||
const rawBlocker = recordValue(source.blocker ?? record.blocker);
|
||||
const apiError = normalizeApiErrorRecord(source.apiError ?? record.apiError, null);
|
||||
const diagnostic = normalizeErrorDiagnostic(source.diagnostic, record.diagnostic, rawBlocker?.diagnostic, apiError?.diagnostic);
|
||||
const blocker = normalizeProjectionBlocker(rawBlocker, { diagnostic, apiError });
|
||||
const projectionStatus = firstNonEmptyString(source.projectionStatus, record.projectionStatus);
|
||||
const projectionHealth = firstNonEmptyString(source.projectionHealth, record.projectionHealth);
|
||||
const lastProjectedSeq = firstFiniteNumber(source.lastProjectedSeq, record.lastProjectedSeq);
|
||||
const staleMs = firstFiniteNumber(source.staleMs, record.staleMs);
|
||||
const sourceRunId = firstNonEmptyString(source.sourceRunId, record.sourceRunId);
|
||||
const sourceCommandId = firstNonEmptyString(source.sourceCommandId, record.sourceCommandId);
|
||||
if (!projectionStatus && !projectionHealth && !blocker && lastProjectedSeq == null && staleMs == null && !sourceRunId && !sourceCommandId) return null;
|
||||
return { ...source, projectionStatus, projectionHealth, lastProjectedSeq: lastProjectedSeq ?? null, staleMs: staleMs ?? null, sourceRunId: sourceRunId ?? null, sourceCommandId: sourceCommandId ?? null, blocker, diagnostic, apiError, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic;
|
||||
}
|
||||
|
||||
export function projectionDiagnosticFromApiFailure(result: { status: number; error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null }, input: { code: string; message: string; health: string }): ProjectionDiagnostic {
|
||||
return projectionDiagnosticFromFailure({ ...input, status: result.status, apiError: result.apiError, diagnostic: result.diagnostic });
|
||||
}
|
||||
|
||||
export function projectionDiagnosticFromFailure(input: { code: string; message: string; health: string; status?: number | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; route?: string | null; source?: string | null }): ProjectionDiagnostic {
|
||||
const apiError = input.apiError ?? null;
|
||||
const diagnostic = normalizeErrorDiagnostic(input.diagnostic, apiError?.diagnostic, {
|
||||
contractVersion: "hwlab-error-diagnostic-v1",
|
||||
traceId: apiError?.traceId ?? null,
|
||||
requestId: apiError?.requestId ?? null,
|
||||
route: firstNonEmptyString(apiError?.route, input.route),
|
||||
layer: apiError?.layer ?? "workbench",
|
||||
category: apiError?.category ?? "workbench-projection",
|
||||
code: firstStringOrNumber(apiError?.code, input.code),
|
||||
httpStatus: input.status ?? null,
|
||||
source: firstNonEmptyString(apiError?.source, input.source, "browser"),
|
||||
retryable: apiError?.retryable ?? true,
|
||||
valuesPrinted: false
|
||||
});
|
||||
const message = firstNonEmptyString(apiError?.userMessage, apiError?.message, input.message) ?? input.message;
|
||||
const blocker = normalizeProjectionBlocker({
|
||||
code: firstStringOrNumber(apiError?.code, diagnostic?.code, input.code),
|
||||
message,
|
||||
userMessage: firstNonEmptyString(apiError?.userMessage, message),
|
||||
layer: firstNonEmptyString(apiError?.layer, diagnostic?.layer, "workbench"),
|
||||
category: firstNonEmptyString(apiError?.category, diagnostic?.category, "workbench-projection"),
|
||||
retryable: firstBoolean(apiError?.retryable, diagnostic?.retryable, true),
|
||||
route: firstNonEmptyString(apiError?.route, diagnostic?.route, input.route),
|
||||
traceId: firstNonEmptyString(apiError?.traceId, diagnostic?.traceId),
|
||||
requestId: firstNonEmptyString(apiError?.requestId, diagnostic?.requestId),
|
||||
source: firstNonEmptyString(apiError?.source, diagnostic?.source, input.source),
|
||||
httpStatus: firstFiniteNumber(diagnostic?.httpStatus, input.status),
|
||||
diagnostic,
|
||||
valuesPrinted: false
|
||||
}, { diagnostic, apiError, fallbackCode: input.code, fallbackMessage: message });
|
||||
return { projectionStatus: "blocked", projectionHealth: input.health, blocker, diagnostic, apiError, valuesRedacted: true };
|
||||
}
|
||||
|
||||
export function normalizeProjectionBlocker(value: unknown, context: { diagnostic?: ErrorDiagnostic | null; apiError?: ApiError | null; fallbackCode?: string | null; fallbackMessage?: string | null } = {}): ProjectionBlocker | null {
|
||||
const record = recordValue(value);
|
||||
if (!record && !context.diagnostic && !context.apiError && !context.fallbackMessage && !context.fallbackCode) return null;
|
||||
const source = record ?? {};
|
||||
const diagnostic = context.diagnostic ?? normalizeErrorDiagnostic(source.diagnostic, context.apiError?.diagnostic);
|
||||
const code = firstStringOrNumber(source.code, context.apiError?.code, diagnostic?.code, context.fallbackCode);
|
||||
const message = firstNonEmptyString(source.message, source.userMessage, source.summary, context.apiError?.userMessage, context.apiError?.message, context.fallbackMessage);
|
||||
const retryable = firstBoolean(source.retryable, context.apiError?.retryable, diagnostic?.retryable);
|
||||
return {
|
||||
...source,
|
||||
code: code === null ? undefined : String(code),
|
||||
message: message ?? null,
|
||||
userMessage: firstNonEmptyString(source.userMessage, context.apiError?.userMessage, message),
|
||||
summary: firstNonEmptyString(source.summary),
|
||||
layer: firstNonEmptyString(source.layer, context.apiError?.layer, diagnostic?.layer),
|
||||
category: firstNonEmptyString(source.category, context.apiError?.category, diagnostic?.category, "workbench-projection"),
|
||||
retryable: retryable ?? true,
|
||||
route: firstNonEmptyString(source.route, context.apiError?.route, diagnostic?.route),
|
||||
traceId: firstNonEmptyString(source.traceId, context.apiError?.traceId, diagnostic?.traceId),
|
||||
requestId: firstNonEmptyString(source.requestId, context.apiError?.requestId, diagnostic?.requestId),
|
||||
source: firstNonEmptyString(source.source, context.apiError?.source, diagnostic?.source),
|
||||
httpStatus: firstFiniteNumber(source.httpStatus, source.http_status, diagnostic?.httpStatus) ?? null,
|
||||
diagnostic,
|
||||
valuesPrinted: source.valuesPrinted === true
|
||||
} as ProjectionBlocker;
|
||||
}
|
||||
|
||||
export function agentErrorFromProjection(projection: ProjectionDiagnostic): ChatMessage["error"] | null {
|
||||
const blocker = projection.blocker ?? null;
|
||||
const diagnostic = projection.diagnostic ?? blocker?.diagnostic ?? projection.apiError?.diagnostic ?? null;
|
||||
const message = firstNonEmptyString(blocker?.userMessage, blocker?.message, blocker?.summary, projection.apiError?.userMessage, projection.apiError?.message, diagnostic?.code ? String(diagnostic.code) : null);
|
||||
if (!blocker && !projection.apiError && !diagnostic && !message) return null;
|
||||
return normalizeApiErrorRecord({ ...(projection.apiError ?? {}), ...(blocker ?? {}), message, code: firstStringOrNumber(blocker?.code, projection.apiError?.code, diagnostic?.code), diagnostic, traceId: firstNonEmptyString(blocker?.traceId, projection.apiError?.traceId, diagnostic?.traceId), requestId: firstNonEmptyString(blocker?.requestId, projection.apiError?.requestId, diagnostic?.requestId) }, message) as ChatMessage["error"];
|
||||
}
|
||||
|
||||
function tr(translator: Translator | undefined, key: string, text: string, vars?: Record<string, string | number>): string {
|
||||
if (!translator) return text;
|
||||
const out = translator(key, vars);
|
||||
if (!out || out === key) return text;
|
||||
return out;
|
||||
}
|
||||
|
||||
function unwrapNamedError(error: unknown): unknown {
|
||||
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) return (error.cause as Record<string, unknown>).body;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
const record = recordValue(error);
|
||||
return record?.name === "ConfigInvalidError" && typeof record.data === "object" && record.data !== null;
|
||||
}
|
||||
|
||||
function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError {
|
||||
const record = recordValue(error);
|
||||
return record?.name === "ProviderModelNotFoundError" && typeof record.data === "object" && record.data !== null;
|
||||
}
|
||||
|
||||
export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator): string {
|
||||
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config";
|
||||
const detail = errorInput.data.message?.trim() ?? "";
|
||||
const issues = (errorInput.data.issues ?? []).map((issue) => {
|
||||
const msg = issue.message.trim();
|
||||
if (!issue.path.length) return msg;
|
||||
return `${issue.path.join(".")}: ${msg}`;
|
||||
}).filter(Boolean);
|
||||
const msg = issues.length ? issues.join("\n") : detail;
|
||||
if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file });
|
||||
return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, { path: file, message: msg });
|
||||
}
|
||||
|
||||
function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator): string {
|
||||
const provider = errorInput.data.providerID.trim();
|
||||
const model = errorInput.data.modelID.trim();
|
||||
const list = (errorInput.data.suggestions ?? []).map((value) => value.trim()).filter(Boolean);
|
||||
const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${provider}/${model}`, { provider, model });
|
||||
const tail = tr(translator, "error.chain.checkConfig", "Check your config (opencode.json) provider/model names");
|
||||
if (list.length) {
|
||||
const suggestions = list.slice(0, 5).join(", ");
|
||||
return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join("\n");
|
||||
}
|
||||
return [body, tail].join("\n");
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-01060505 Workbench性能 draft-2026-06-19-p0.
|
||||
// Responsibility: Health probe retry/cache helpers. Mechanically migrated from OpenCode app/utils/server-health.ts with all timing supplied by runtime policy.
|
||||
|
||||
import { createScopedCache } from "@/utils/scoped-cache";
|
||||
|
||||
export type WorkbenchHealthState = "ok" | "degraded" | "unavailable" | "blocked";
|
||||
|
||||
export interface WorkbenchHealthSnapshot<T = unknown> {
|
||||
@@ -32,8 +34,12 @@ interface HealthCacheEntry<T> {
|
||||
promise: Promise<WorkbenchHealthSnapshot<T>>;
|
||||
}
|
||||
|
||||
interface HealthLatestEntry {
|
||||
snapshot: WorkbenchHealthSnapshot | null;
|
||||
}
|
||||
|
||||
export class WorkbenchHealthProbeCache {
|
||||
private readonly latest = new Map<string, WorkbenchHealthSnapshot>();
|
||||
private readonly latest: ReturnType<typeof createScopedCache<HealthLatestEntry>>;
|
||||
private readonly inflight = new Map<string, HealthCacheEntry<unknown>>();
|
||||
private readonly now: () => number;
|
||||
private readonly cacheMs: number | null;
|
||||
@@ -41,10 +47,11 @@ export class WorkbenchHealthProbeCache {
|
||||
constructor(options: WorkbenchHealthCacheOptions = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.cacheMs = positiveIntegerOrNull(options.cacheMs);
|
||||
this.latest = createScopedCache<HealthLatestEntry>(() => ({ snapshot: null }), { ttlMs: this.cacheMs, now: () => this.now() });
|
||||
}
|
||||
|
||||
get(key: string): WorkbenchHealthSnapshot | null {
|
||||
return this.latest.get(normalizeKey(key)) ?? null;
|
||||
return this.latest.peek(normalizeKey(key))?.snapshot ?? null;
|
||||
}
|
||||
|
||||
probe<T>(input: CheckWorkbenchHealthOptions<T>): Promise<WorkbenchHealthSnapshot<T>> {
|
||||
@@ -53,7 +60,7 @@ export class WorkbenchHealthProbeCache {
|
||||
const now = this.now();
|
||||
if (hit && (!hit.done || (this.cacheMs !== null && now - hit.at < this.cacheMs))) return hit.promise;
|
||||
const promise = checkWorkbenchHealth(input).then((snapshot) => {
|
||||
this.latest.set(key, snapshot);
|
||||
this.latest.get(key).snapshot = snapshot;
|
||||
return snapshot;
|
||||
}).finally(() => {
|
||||
const current = this.inflight.get(key);
|
||||
|
||||
@@ -1,4 +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";
|
||||
export { createWorkbenchStreamTransportRuntime, WorkbenchStreamTransportRuntime, type WorkbenchStreamTransportRecovery, type WorkbenchStreamTransportRestartInput, type WorkbenchStreamTransportRestartResult, type WorkbenchStreamTransportState, type WorkbenchRealtimeEvent } from "@/utils/workbench-stream-transport";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// 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.
|
||||
// Responsibility: Workbench REST refresh queue, keyed single-flight, cooldown, scheduled gap-fill and request-storm suppression.
|
||||
// Mechanical source references:
|
||||
// - OpenCode util/queue.ts:1-32 AsyncQueue and bounded work.
|
||||
// - OpenCode runtime.queue.ts:54-113 queue state/control.
|
||||
// - OpenCode runtime.queue.ts:113-230 drain/send/abort flow.
|
||||
// - OpenCode stream.transport.ts:83-130 per-turn wait/tick state consumed by recovery scheduling.
|
||||
|
||||
import { AsyncQueue } from "@/utils/scheduler/async-queue";
|
||||
import { createKeyedSingleflight } from "@/utils/scheduler/keyed-singleflight";
|
||||
@@ -29,6 +34,33 @@ export interface WorkbenchReadHydrationRunOptions {
|
||||
|
||||
type QueuedTask = () => void;
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface WorkbenchKeyedTaskRunOptions {
|
||||
minIntervalMs?: number;
|
||||
force?: boolean;
|
||||
replace?: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkbenchScheduledTaskOptions extends WorkbenchKeyedTaskRunOptions {
|
||||
delayMs?: number;
|
||||
replaceTimer?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkbenchScheduledTaskRuntimeOptions {
|
||||
now?: () => number;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => TimerHandle;
|
||||
clearTimeout?: (handle: TimerHandle) => void;
|
||||
}
|
||||
|
||||
export interface WorkbenchTraceHydrationQueueRuntimeOptions {
|
||||
concurrency: number;
|
||||
delayMs: number;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => TimerHandle;
|
||||
clearTimeout?: (handle: TimerHandle) => void;
|
||||
}
|
||||
|
||||
export class WorkbenchReadHydrationRuntime {
|
||||
private readonly queue = new AsyncQueue<QueuedTask>();
|
||||
private readonly singleflight = createKeyedSingleflight<unknown>();
|
||||
@@ -83,10 +115,142 @@ export class WorkbenchReadHydrationRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkbenchKeyedTaskRuntime {
|
||||
private readonly singleflight = createKeyedSingleflight<unknown>();
|
||||
private readonly lastStartedAtByKey = new Map<string, number>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: { now?: () => number } = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
run<T>(key: string, task: () => Promise<T>, options: WorkbenchKeyedTaskRunOptions = {}): Promise<T | undefined> {
|
||||
const normalized = normalizeRequiredKey(key);
|
||||
const now = this.now();
|
||||
const minIntervalMs = Math.max(0, Math.trunc(options.minIntervalMs ?? 0));
|
||||
const lastStartedAtMs = this.lastStartedAtByKey.get(normalized) ?? 0;
|
||||
if (options.force !== true && minIntervalMs > 0 && lastStartedAtMs > 0 && now - lastStartedAtMs < minIntervalMs) return Promise.resolve(undefined);
|
||||
this.lastStartedAtByKey.set(normalized, now);
|
||||
return this.singleflight.run(normalized, task, { reason: options.reason ?? null, replace: options.replace }) as Promise<T>;
|
||||
}
|
||||
|
||||
cancel(key: string, reason = "workbench-keyed-task-canceled"): void {
|
||||
this.singleflight.cancel(normalizeRequiredKey(key), reason);
|
||||
}
|
||||
|
||||
clear(reason = "workbench-keyed-task-cleared"): void {
|
||||
this.singleflight.clear(reason);
|
||||
this.lastStartedAtByKey.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkbenchScheduledTaskRuntime extends WorkbenchKeyedTaskRuntime {
|
||||
private readonly timers = new Map<string, TimerHandle>();
|
||||
private readonly setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
||||
private readonly clearTimer: (handle: TimerHandle) => void;
|
||||
|
||||
constructor(options: WorkbenchScheduledTaskRuntimeOptions = {}) {
|
||||
super({ now: options.now });
|
||||
this.setTimer = options.setTimeout ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
this.clearTimer = options.clearTimeout ?? ((handle) => clearTimeout(handle));
|
||||
}
|
||||
|
||||
schedule<T>(key: string, task: () => Promise<T>, options: WorkbenchScheduledTaskOptions = {}): boolean {
|
||||
const normalized = normalizeRequiredKey(key);
|
||||
const existing = this.timers.get(normalized);
|
||||
if (existing) {
|
||||
if (options.replaceTimer !== true) return false;
|
||||
this.clearTimer(existing);
|
||||
this.timers.delete(normalized);
|
||||
}
|
||||
const delayMs = Math.max(0, Math.trunc(options.delayMs ?? 0));
|
||||
const timer = this.setTimer(() => {
|
||||
this.timers.delete(normalized);
|
||||
void this.run(normalized, task, options);
|
||||
}, delayMs);
|
||||
this.timers.set(normalized, timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelTimer(key: string): void {
|
||||
const normalized = normalizeRequiredKey(key);
|
||||
const timer = this.timers.get(normalized);
|
||||
if (timer) this.clearTimer(timer);
|
||||
this.timers.delete(normalized);
|
||||
}
|
||||
|
||||
clearAll(reason = "workbench-scheduled-task-cleared"): void {
|
||||
for (const timer of this.timers.values()) this.clearTimer(timer);
|
||||
this.timers.clear();
|
||||
this.clear(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkbenchTraceHydrationQueueRuntime<T> {
|
||||
private readonly queue = new AsyncQueue<QueuedTask>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
private readonly queued = new Set<string>();
|
||||
private readonly delayMs: number;
|
||||
private readonly setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
||||
|
||||
constructor(options: WorkbenchTraceHydrationQueueRuntimeOptions) {
|
||||
const concurrency = Math.max(1, Math.trunc(options.concurrency));
|
||||
this.delayMs = Math.max(0, Math.trunc(options.delayMs));
|
||||
this.setTimer = options.setTimeout ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
for (let index = 0; index < concurrency; index += 1) void this.workerLoop();
|
||||
}
|
||||
|
||||
run(key: string, task: () => Promise<void>, options: { onAlreadyRunning?: () => void } = {}): Promise<boolean> {
|
||||
const normalized = normalizeRequiredKey(key);
|
||||
if (this.inFlight.has(normalized)) {
|
||||
options.onAlreadyRunning?.();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
this.inFlight.add(normalized);
|
||||
return task().then(() => true, (error) => {
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
this.inFlight.delete(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
enqueue(key: string, task: () => Promise<void>): boolean {
|
||||
const normalized = normalizeRequiredKey(key);
|
||||
if (this.inFlight.has(normalized) || this.queued.has(normalized)) return false;
|
||||
this.queued.add(normalized);
|
||||
this.queue.push(() => {
|
||||
void this.run(normalized, task).finally(() => {
|
||||
this.queued.delete(normalized);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async workerLoop(): Promise<void> {
|
||||
for (;;) {
|
||||
const run = await this.queue.next();
|
||||
run();
|
||||
if (this.delayMs > 0) await new Promise<void>((resolve) => this.setTimer(resolve, this.delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkbenchReadHydrationRuntime(options: WorkbenchReadHydrationRuntimeOptions): WorkbenchReadHydrationRuntime {
|
||||
return new WorkbenchReadHydrationRuntime(options);
|
||||
}
|
||||
|
||||
export function createWorkbenchKeyedTaskRuntime(options: { now?: () => number } = {}): WorkbenchKeyedTaskRuntime {
|
||||
return new WorkbenchKeyedTaskRuntime(options);
|
||||
}
|
||||
|
||||
export function createWorkbenchScheduledTaskRuntime(options: WorkbenchScheduledTaskRuntimeOptions = {}): WorkbenchScheduledTaskRuntime {
|
||||
return new WorkbenchScheduledTaskRuntime(options);
|
||||
}
|
||||
|
||||
export function createWorkbenchTraceHydrationQueueRuntime<T>(options: WorkbenchTraceHydrationQueueRuntimeOptions): WorkbenchTraceHydrationQueueRuntime<T> {
|
||||
return new WorkbenchTraceHydrationQueueRuntime<T>(options);
|
||||
}
|
||||
|
||||
export function shouldCooldownWorkbenchReadFailure(value: unknown): boolean {
|
||||
const result = value as ApiResultLike | null;
|
||||
if (!result || typeof result !== "object" || typeof result.status !== "number") return false;
|
||||
@@ -136,6 +300,12 @@ function normalizeOptionalKey(value: string | null | undefined): string | null {
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function normalizeRequiredKey(value: string): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) throw new Error("workbench runtime key is required");
|
||||
return text;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string") continue;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
|
||||
// Responsibility: Workbench SSE transport lifecycle and scope-key ownership.
|
||||
// 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 } from "@/api/workbench-events";
|
||||
import { workbenchRealtimeScopeKey } from "@/utils/workbench-key";
|
||||
@@ -10,8 +16,11 @@ export interface WorkbenchStreamTransportRestartInput {
|
||||
sessionId?: string | null;
|
||||
traceId?: string | null;
|
||||
afterSeq?: number | null;
|
||||
errorRecoveryMinMs?: number | null;
|
||||
onOpen?: () => void;
|
||||
onError?: (event: Event) => void;
|
||||
onState?: (state: WorkbenchStreamTransportState) => void;
|
||||
onRecovery?: (recovery: WorkbenchStreamTransportRecovery) => void;
|
||||
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
|
||||
}
|
||||
|
||||
@@ -21,38 +30,207 @@ export interface WorkbenchStreamTransportRestartResult {
|
||||
streamStarted: boolean;
|
||||
}
|
||||
|
||||
export type WorkbenchStreamTransportPhase = "idle" | "connecting" | "open" | "event" | "error" | "closed" | "blocked";
|
||||
|
||||
export type WorkbenchStreamTransportRecoveryAction = "refresh-session-messages" | "schedule-session-list" | "refresh-turn-status" | "hydrate-trace-events";
|
||||
|
||||
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[];
|
||||
diagnostic: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WorkbenchTransportWait {
|
||||
tick: number;
|
||||
armed: boolean;
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
interface WorkbenchTransportCursor {
|
||||
outboxSeq: number | null;
|
||||
traceSeq: number | null;
|
||||
}
|
||||
|
||||
export class WorkbenchStreamTransportRuntime {
|
||||
private stream: WorkbenchEventStream | null = null;
|
||||
private key = "";
|
||||
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) return { key, changed: false, streamStarted: true };
|
||||
this.stop();
|
||||
this.key = key;
|
||||
this.armWait(key);
|
||||
if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false };
|
||||
this.emitState(input, "connecting", null);
|
||||
this.stream = connectWorkbenchEvents({
|
||||
sessionId: input.sessionId ?? null,
|
||||
traceId: input.traceId ?? null,
|
||||
afterSeq: input.afterSeq ?? null,
|
||||
onOpen: input.onOpen,
|
||||
onError: input.onError,
|
||||
onEvent: input.onEvent
|
||||
afterSeq: input.afterSeq ?? this.cursorByKey.get(key)?.outboxSeq ?? null,
|
||||
onOpen: () => {
|
||||
this.markLive(key);
|
||||
this.emitState(input, "open", null);
|
||||
input.onOpen?.();
|
||||
},
|
||||
onError: (event) => {
|
||||
this.markBlocked(key);
|
||||
this.emitState(input, "error", null, event.type);
|
||||
input.onError?.(event);
|
||||
this.requestRecovery(input, event.type || "eventsource-error");
|
||||
},
|
||||
onEvent: (event, eventName) => {
|
||||
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.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 };
|
||||
}
|
||||
|
||||
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 actions: WorkbenchStreamTransportRecoveryAction[] = [];
|
||||
if (sessionId) actions.push("refresh-session-messages", "schedule-session-list");
|
||||
if (traceId) actions.push("refresh-turn-status", "hydrate-trace-events");
|
||||
const diagnostic = this.diagnosticEnvelope("workbench_sse_recovery", reason, key, actions);
|
||||
input.onRecovery?.({ key, sessionId, traceId, tick: this.wait?.tick ?? this.tick, reason, actions, 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user