Merge pull request #2675 from pikasTech/fix/workbench-nonblocking-runtime-metadata
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

修复 Workbench 非核心实时校验阻断与 Provider 默认选择
This commit is contained in:
Lyon
2026-07-18 23:36:11 +08:00
committed by GitHub
6 changed files with 138 additions and 21 deletions
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { providerProfileOptionsFromPayload } from "../src/stores/workbench-session.ts";
import { preferredProviderProfile, providerProfileOptionsFromPayload } from "../src/stores/workbench-session.ts";
test("Workbench provider catalog marks missing selected profile as unavailable", () => {
const options = providerProfileOptionsFromPayload({ profiles: [{ profile: "dsflash-go", label: "DeepSeek V4 Flash", configured: true }] }, "codex");
@@ -11,4 +11,11 @@ test("Workbench provider catalog marks missing selected profile as unavailable",
assert.equal(available?.configured, true);
assert.equal(missingSelected?.configured, false);
assert.equal(preferredProviderProfile(options, "codex"), "dsflash-go");
});
test("Workbench provider selection stays non-blocking without a configured catalog option", () => {
const options = providerProfileOptionsFromPayload({ profiles: [{ profile: "codex", configured: false }] }, "codex");
assert.equal(preferredProviderProfile(options, "codex"), "codex");
assert.equal(preferredProviderProfile(providerProfileOptionsFromPayload({}, "codex"), "codex"), "codex");
});
@@ -21,7 +21,7 @@ import { messageDiagnosticView } from "../src/utils/workbench-error-runtime.ts";
import { projectRejectedWorkbenchAdmission } from "../src/stores/workbench-admission-failure.ts";
import { WORKBENCH_TIMELINE_OPENCODE_PARITY, buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts";
import { reduceWorkbenchRealtimeEvent } from "../src/stores/workbench-event-reducer.ts";
import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../src/stores/workbench-realtime-plan.ts";
import { assessWorkbenchRealtimeConnected, planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../src/stores/workbench-realtime-plan.ts";
import { WORKBENCH_REALTIME_AUTHORITY_VERSION, workbenchRealtimePrimaryAuthorityDecision } from "../src/stores/workbench-realtime-authority.ts";
import { cleanupWorkbenchServerStateDroppedSessions, cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState } from "../src/stores/workbench-server-state.ts";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts";
@@ -93,6 +93,40 @@ test("the active projection trace scope follows the current request", () => {
assert.equal(workbenchRealtimeTraceIdForCapabilities({ liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: false }, "trc_current_request"), null);
});
test("connected realtime metadata drift warns without blocking the active session", () => {
const assessment = assessWorkbenchRealtimeConnected({
deliverySemantics: "live-only",
liveOnly: true,
replay: false,
filters: { sessionId: "ses_active" }
}, "ses_active", false);
assert.equal(assessment.ready, true);
assert.equal(assessment.connectedSessionId, "ses_active");
assert.equal(assessment.blocker, null);
assert.deepEqual(assessment.warning?.fields, [
"capabilities.liveKafkaSse",
"capabilities.kafkaRefreshReplay"
]);
assert.equal(assessment.warning?.severity, "warning");
});
test("connected realtime session scope mismatch remains blocking", () => {
const assessment = assessWorkbenchRealtimeConnected({
deliverySemantics: "live-only",
liveOnly: true,
replay: false,
capabilities: { liveKafkaSse: true, kafkaRefreshReplay: false, projectionRealtime: false },
filters: { sessionId: "ses_other" }
}, "ses_active", false);
assert.equal(assessment.ready, false);
assert.equal(assessment.warning, null);
assert.equal(assessment.blocker?.code, "workbench_realtime_session_scope_mismatch");
assert.equal(assessment.blocker?.expectedSessionId, "ses_active");
assert.equal(assessment.blocker?.connectedSessionId, "ses_other");
});
test("Error runtime owns Workbench message diagnostic view model", () => {
const degraded = messageDiagnosticView(agentMessage({
status: "running",
@@ -51,6 +51,23 @@ export interface WorkbenchRealtimeRecoveryPlan {
};
}
export interface WorkbenchRealtimeConnectedAssessment {
connectedSessionId: string | null;
ready: boolean;
warning: {
code: "workbench_realtime_metadata_drift_warning";
fields: string[];
severity: "warning";
valuesRedacted: true;
} | null;
blocker: {
code: "workbench_realtime_session_scope_mismatch";
expectedSessionId: string | null;
connectedSessionId: string | null;
valuesRedacted: true;
} | null;
}
export function planWorkbenchRealtimeApply(action: WorkbenchRealtimeAction): WorkbenchRealtimeApplyPlan {
const steps = applySteps(action);
return {
@@ -65,6 +82,40 @@ export function planWorkbenchRealtimeApply(action: WorkbenchRealtimeAction): Wor
};
}
export function assessWorkbenchRealtimeConnected(
event: WorkbenchRealtimeEvent,
expectedSessionId: string | null | undefined,
kafkaRefreshReplay: boolean
): WorkbenchRealtimeConnectedAssessment {
const filters = event.filters && typeof event.filters === "object" ? event.filters as Record<string, unknown> : null;
const connectedSessionId = normalizeWorkbenchSessionId(filters?.sessionId);
const normalizedExpectedSessionId = normalizeWorkbenchSessionId(expectedSessionId);
const scopeMatches = Boolean(connectedSessionId && normalizedExpectedSessionId && connectedSessionId === normalizedExpectedSessionId);
const fields: string[] = [];
const expectedDelivery = kafkaRefreshReplay ? "kafka-retention-then-live" : "live-only";
if (event.deliverySemantics !== expectedDelivery) fields.push("deliverySemantics");
if (event.liveOnly !== !kafkaRefreshReplay) fields.push("liveOnly");
if (event.replay !== kafkaRefreshReplay) fields.push("replay");
if (kafkaRefreshReplay && event.lossPossible !== false) fields.push("lossPossible");
if (event.capabilities?.liveKafkaSse !== true) fields.push("capabilities.liveKafkaSse");
if (event.capabilities?.kafkaRefreshReplay !== kafkaRefreshReplay) fields.push("capabilities.kafkaRefreshReplay");
return {
connectedSessionId,
ready: scopeMatches,
warning: fields.length > 0
? { code: "workbench_realtime_metadata_drift_warning", fields, severity: "warning", valuesRedacted: true }
: null,
blocker: scopeMatches
? null
: {
code: "workbench_realtime_session_scope_mismatch",
expectedSessionId: normalizedExpectedSessionId,
connectedSessionId,
valuesRedacted: true
}
};
}
export function planWorkbenchRealtimeRecovery(recovery: WorkbenchStreamTransportRecovery, context: WorkbenchRealtimeRecoveryContext): WorkbenchRealtimeRecoveryPlan {
const sessionId = normalizeWorkbenchSessionId(recovery.sessionId ?? context.selectedSessionId);
const traceId = firstNonEmptyString(recovery.traceId, context.fallbackTraceId);
@@ -259,6 +259,12 @@ export function defaultProviderProfileOptions(selectedProfile: ProviderProfile):
return ensureSelectedProviderProfile(BUILTIN_PROVIDER_PROFILE_ORDER.map((profile) => ({ value: profile, label: providerProfileLabel(profile) })), selectedProfile);
}
export function preferredProviderProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile): ProviderProfile {
const selected = options.find((option) => option.value === selectedProfile);
if (selected?.configured !== false) return selectedProfile;
return options.find((option) => option.configured === true)?.value ?? selectedProfile;
}
function activeSession(sessions: WorkbenchSessionRecord[], sessionId: string | null): WorkbenchSessionRecord | null {
return sessionId ? sessions.find((item) => item.sessionId === sessionId) ?? null : null;
}
+33 -15
View File
@@ -17,7 +17,7 @@ import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, Ap
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { composeWorkbenchScopedKey, workbenchRealtimeScopeKey } from "@/utils/workbench-key";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, preferredProviderProfile, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
@@ -65,7 +65,7 @@ import {
traceHasEvents,
traceResultHasTerminalEvidence,
} from "./workbench-message-projection-runtime";
import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan";
import { assessWorkbenchRealtimeConnected, planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan";
import { useWorkbenchColadaMutations } from "./workbench-colada-mutations";
import { useWorkbenchColadaQueries } from "./workbench-colada-queries";
import { useWorkbenchColadaReducer } from "./workbench-colada-reducer";
@@ -921,7 +921,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function refreshProviderOptions(): Promise<boolean> {
const response = await workbenchColadaQueries.fetchProviderProfiles({ minIntervalMs: runtimePolicy.workbenchReadFailureCooldownMs });
providerOptions.value = response.ok ? providerProfileOptionsFromPayload(response.data, providerProfile.value) : defaultProviderProfileOptions(providerProfile.value);
if (response.ok) {
providerOptions.value = providerProfileOptionsFromPayload(response.data, providerProfile.value);
const preferred = preferredProviderProfile(providerOptions.value, providerProfile.value);
if (preferred !== providerProfile.value) setProviderProfile(preferred);
} else {
providerOptions.value = defaultProviderProfileOptions(providerProfile.value);
}
return response.ok === true;
}
@@ -983,18 +989,30 @@ export const useWorkbenchStore = defineStore("workbench", () => {
onRecovery: (recovery) => handleRealtimeRecovery(recovery),
onEvent: (event, eventName) => {
if (realtimeCapabilities.liveKafkaSse && eventName === "workbench.connected") {
const filters = recordValue(event.filters);
const connectedSessionId = normalizeWorkbenchSessionId(filters?.sessionId);
const refreshReplay = realtimeCapabilities.kafkaRefreshReplay;
const deliveryValid = refreshReplay
? event.deliverySemantics === "kafka-retention-then-live" && event.liveOnly === false && event.replay === true && event.lossPossible === false
: event.deliverySemantics === "live-only" && event.liveOnly === true && event.replay === false;
const valid = deliveryValid
&& event.capabilities?.liveKafkaSse === true
&& event.capabilities?.kafkaRefreshReplay === refreshReplay
&& connectedSessionId === sessionId;
liveRealtimeReadySessionId.value = valid ? connectedSessionId : null;
if (!valid) error.value = "workbench_live_realtime_contract_invalid";
const assessment = assessWorkbenchRealtimeConnected(event, sessionId, realtimeCapabilities.kafkaRefreshReplay);
liveRealtimeReadySessionId.value = assessment.ready ? assessment.connectedSessionId : null;
if (assessment.warning) {
recordWorkbenchRuntimeDiagnostic({
module: "workbench-stream-contract",
sessionId,
traceId,
outcome: "partial",
level: "warning",
diagnostic: assessment.warning
});
}
if (assessment.blocker) {
error.value = assessment.blocker.code;
recordWorkbenchRuntimeDiagnostic({
module: "workbench-stream-contract",
sessionId,
traceId,
outcome: "error",
diagnostic: assessment.blocker
});
} else if (error.value === "workbench_live_realtime_contract_invalid" || error.value === "workbench_realtime_session_scope_mismatch") {
error.value = null;
}
}
applyRealtimeEvent(event, eventName);
}
@@ -365,25 +365,26 @@ export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" |
});
}
export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record<string, unknown> | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null }): void {
export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record<string, unknown> | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null; level?: "error" | "warning" | "sample" }): void {
ensureInstalled();
const diagnostic = recordValue(input.diagnostic);
const endedAt = wallNow();
const code = diagnosticText(diagnostic, "code") ?? "runtime_diagnostic";
const flushDurationMs = diagnosticNumber(diagnostic, "flushDurationMs");
const isSseFlush = code === "workbench_sse_flush";
const warning = input.level === "warning" || diagnosticText(diagnostic, "severity", "level") === "warning";
enqueueWorkbenchUiEvent({
eventType: "runtime_diagnostic",
loadingScope: "sse",
state: isSseFlush ? "sample" : "error",
reason: isSseFlush ? "sse_flush" : "sse_error",
state: isSseFlush || warning ? "sample" : "error",
reason: isSseFlush ? "sse_flush" : warning ? "unknown" : "sse_error",
route: pageRoute(),
valueMs: flushDurationMs ?? 0,
startedAtEpochMs: Math.max(0, endedAt - (flushDurationMs ?? 0)),
endedAtEpochMs: endedAt,
sessionHash: hashIdentifier(input.sessionId ?? diagnosticText(diagnostic, "sessionId"), "ses"),
traceHash: hashIdentifier(input.traceId ?? diagnosticText(diagnostic, "traceId"), "trc"),
outcome: input.outcome ?? normalizeUiOutcome(diagnosticText(diagnostic, "outcome")),
outcome: input.outcome ?? (warning ? "partial" : normalizeUiOutcome(diagnosticText(diagnostic, "outcome"))),
errorName: code.slice(0, 80),
module: diagnosticLabel(input.module ?? diagnosticText(diagnostic, "module") ?? diagnosticText(diagnostic, "layer")),
diagnosticCode: diagnosticLabel(code),