Merge pull request #2675 from pikasTech/fix/workbench-nonblocking-runtime-metadata
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
修复 Workbench 非核心实时校验阻断与 Provider 默认选择
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user