fix(workbench): fail visibly on projection fact write errors (#2046)
This commit is contained in:
@@ -17,6 +17,8 @@ Workbench 页面和组件必须明确区分“未加载完成”和“已加载
|
||||
|
||||
Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单一权威 API 绑定。Session rail 的会话集合只允许消费 `/v1/agent/conversations` 成功返回的 conversation 集合;当前 selected conversation 如果需要出现在左侧列表中,必须通过显式 query/path/body 传入稳定 conversation id,由该列表 API 在同一响应中返回,后端不得隐藏读取 workspace selected、localStorage、Web snapshot 或上一轮页面状态。前端不得用 workspace selected snapshot、stub、localStorage 或 route 状态补出 session tab,也不得把前端拼接出的 status、final response、markdown、running 动效或 stub 传回后端变成事实。Session 运行状态必须按 `sessionId` 绑定到单一 session 状态 API;Code Agent turn、trace 阅读和 final response 必须按 `traceId` 绑定到单一 trace/result snapshot API。任何权威 API 失败时只能保留上一份成功结果或显示未加载/错误态,不得切换到另一条 fallback 路径形成一条会话、旧 running 态或劣化 markdown。
|
||||
|
||||
Workbench 投影写路径必须做到 0 隐式 fallback。Admission、projection event、terminal/finalizer 等上游写入如果无法把 session/message/turn/checkpoint facts 写入 durable read model,不能 `catch` 后返回空值继续表现为成功;admission 阶段必须显式失败并把错误传给调用方,后台投影阶段必须至少写入 trace diagnostic 和 OTel error span。只有成功落库的 Workbench facts 才能驱动控制页、观察页、session rail、耗时和 final response;前端或 read path 不得用内存 trace、local optimistic state、历史 snapshot 或多来源仲裁去修补失败写入。
|
||||
|
||||
Cloud Web 的通用加载态使用 `web/hwlab-cloud-web/src/components/common/LoadingState.vue`。新增或修复页面加载态时优先复用该组件,并通过明确的 ready/loading 状态控制展示;不要在每个组件里重新实现一套 spinner、点状动画或默认占位数据。紧凑区域可以使用组件的 compact 形态,文案默认保持“加载中”。
|
||||
|
||||
Session rail 是该规则的高频区域。`/v1/agent/conversations` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 conversations ready 后再渲染真实 session tabs,或在真实空集合时显示空态。
|
||||
|
||||
@@ -963,6 +963,12 @@ async function recordCodeAgentTurnAdmission({ payload = {}, params = {}, options
|
||||
let ownerRecord = null;
|
||||
try {
|
||||
ownerRecord = await recordCodeAgentSessionOwner({ payload, params, options, status: "running" });
|
||||
if (!ownerRecord) {
|
||||
const error = new Error("Code Agent turn admission did not durably persist Workbench session ownership.");
|
||||
error.code = "workbench_admission_owner_not_persisted";
|
||||
error.valuesRedacted = true;
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
throw codeAgentAdmissionUnavailableError(error, { params, traceId });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||||
import { emitCodeAgentOtelSpan } from "./otel-trace.ts";
|
||||
import { safeTraceId } from "./server-http-utils.ts";
|
||||
import { createWorkbenchTurnProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts";
|
||||
|
||||
@@ -23,7 +24,7 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim
|
||||
status,
|
||||
session
|
||||
});
|
||||
await writeWorkbenchProjectionFacts({
|
||||
const factsWrite = await writeWorkbenchProjectionFacts({
|
||||
runtimeStore,
|
||||
traceStore,
|
||||
traceId: safeId,
|
||||
@@ -38,8 +39,16 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim
|
||||
payload,
|
||||
params
|
||||
});
|
||||
if (!factsWrite) {
|
||||
throw workbenchProjectionError("workbench_projection_facts_not_written", "Workbench projection facts were not durably written.");
|
||||
}
|
||||
return ownerRecord;
|
||||
} catch (error) {
|
||||
emitWorkbenchProjectionOtel("workbench.projection.session.persist", safeId, "error", {
|
||||
"workbench.projection.phase": "session-owner",
|
||||
"workbench.projection.has_owner_user_id": Boolean(ownerUserId),
|
||||
"workbench.projection.has_session_id": Boolean(sessionId)
|
||||
}, error);
|
||||
if (safeId) appendProjectionDiagnostic(traceStore, safeId, {
|
||||
type: "session-owner",
|
||||
status: "degraded",
|
||||
@@ -48,7 +57,7 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim
|
||||
message: error?.message ?? "Workbench projection session persistence failed.",
|
||||
valuesPrinted: false
|
||||
});
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,12 +241,42 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event
|
||||
updatedAt: projectedAt
|
||||
}] : []
|
||||
};
|
||||
return runtimeStore.writeWorkbenchFacts({ facts }, {
|
||||
traceId,
|
||||
sessionId,
|
||||
agentSessionId: requestMeta.agentSessionId ?? sessionId,
|
||||
valuesPrinted: false
|
||||
});
|
||||
try {
|
||||
const result = await runtimeStore.writeWorkbenchFacts({ facts }, {
|
||||
traceId,
|
||||
sessionId,
|
||||
agentSessionId: requestMeta.agentSessionId ?? sessionId,
|
||||
valuesPrinted: false
|
||||
});
|
||||
emitWorkbenchProjectionOtel("workbench.projection.event.write", traceId, "ok", {
|
||||
...workbenchProjectionFactCountAttributes(facts),
|
||||
"workbench.projection.phase": terminal ? "event-terminal" : "event-running",
|
||||
"workbench.projection.terminal": terminal,
|
||||
"workbench.projection.suppressed_after_seal": suppressedAfterSeal,
|
||||
"workbench.projection.session_id": sessionId,
|
||||
"workbench.projection.turn_id": turnId
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
emitWorkbenchProjectionOtel("workbench.projection.event.write", traceId, "error", {
|
||||
...workbenchProjectionFactCountAttributes(facts),
|
||||
"workbench.projection.phase": terminal ? "event-terminal" : "event-running",
|
||||
"workbench.projection.terminal": terminal,
|
||||
"workbench.projection.suppressed_after_seal": suppressedAfterSeal,
|
||||
"workbench.projection.session_id": sessionId,
|
||||
"workbench.projection.turn_id": turnId
|
||||
}, error);
|
||||
appendProjectionDiagnostic(defaultCodeAgentTraceStore, traceId, {
|
||||
type: "facts",
|
||||
status: "degraded",
|
||||
label: "projection-writer:event-facts:persist-failed",
|
||||
errorCode: error?.code ?? "workbench_event_facts_persist_failed",
|
||||
message: error?.message ?? "Workbench event facts persistence failed.",
|
||||
terminal: false,
|
||||
valuesPrinted: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function eventProjectionAssistantMessageId(traceId, event = {}) {
|
||||
@@ -257,15 +296,30 @@ async function writeWorkbenchProjectionFacts({ runtimeStore = null, traceStore =
|
||||
const facts = buildWorkbenchProjectionFacts({ traceId, ownerUserId, ownerRole, sessionId, projectId, conversationId, threadId, status, session, payload, params, previousCheckpoint });
|
||||
if (isEmptyFacts(facts)) return null;
|
||||
try {
|
||||
return await runtimeStore.writeWorkbenchFacts({ facts }, {
|
||||
const result = await runtimeStore.writeWorkbenchFacts({ facts }, {
|
||||
traceId: facts.checkpoints[0]?.traceId ?? traceId,
|
||||
sessionId: facts.sessions[0]?.sessionId ?? sessionId,
|
||||
ownerUserId,
|
||||
projectId,
|
||||
valuesPrinted: false
|
||||
});
|
||||
emitWorkbenchProjectionOtel("workbench.projection.facts.write", safeId, "ok", {
|
||||
...workbenchProjectionFactCountAttributes(facts),
|
||||
"workbench.projection.phase": "session-facts",
|
||||
"workbench.projection.terminal": facts.turns.some((turn) => turn.terminal === true),
|
||||
"workbench.projection.session_id": facts.sessions[0]?.sessionId ?? sessionId ?? null,
|
||||
"workbench.projection.turn_id": facts.turns[0]?.turnId ?? null
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const safeId = safeTraceId(traceId);
|
||||
emitWorkbenchProjectionOtel("workbench.projection.facts.write", safeId, "error", {
|
||||
...workbenchProjectionFactCountAttributes(facts),
|
||||
"workbench.projection.phase": "session-facts",
|
||||
"workbench.projection.terminal": facts.turns.some((turn) => turn.terminal === true),
|
||||
"workbench.projection.session_id": facts.sessions[0]?.sessionId ?? sessionId ?? null,
|
||||
"workbench.projection.turn_id": facts.turns[0]?.turnId ?? null
|
||||
}, error);
|
||||
if (safeId) appendProjectionDiagnostic(traceStore, safeId, {
|
||||
type: "facts",
|
||||
status: "degraded",
|
||||
@@ -274,7 +328,7 @@ async function writeWorkbenchProjectionFacts({ runtimeStore = null, traceStore =
|
||||
message: error?.message ?? "Workbench facts persistence failed.",
|
||||
terminal: false
|
||||
});
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,8 +416,7 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own
|
||||
sealed: projection.terminal,
|
||||
finalResponse: projectedFinalResponse,
|
||||
diagnostic,
|
||||
finalResponse: checkpointFinalResponse,
|
||||
assistantText: messageFinalText,
|
||||
assistantText: finalText,
|
||||
userMessage: previousCheckpoint?.userMessage ?? null,
|
||||
timing,
|
||||
startedAt: timing.startedAt,
|
||||
@@ -399,6 +452,36 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own
|
||||
};
|
||||
}
|
||||
|
||||
function workbenchProjectionError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
error.valuesRedacted = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
function emitWorkbenchProjectionOtel(name, traceId, status = "ok", attributes = {}, error = null) {
|
||||
void emitCodeAgentOtelSpan(name, safeTraceId(traceId) ?? "trc_unassigned", process.env, {
|
||||
status,
|
||||
error,
|
||||
attributes: {
|
||||
...attributes,
|
||||
"workbench.projection.zero_implicit_fallback": true,
|
||||
"workbench.projection.error_visible": status === "error"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function workbenchProjectionFactCountAttributes(facts = {}) {
|
||||
return {
|
||||
"workbench.facts.sessions": Array.isArray(facts.sessions) ? facts.sessions.length : 0,
|
||||
"workbench.facts.messages": Array.isArray(facts.messages) ? facts.messages.length : 0,
|
||||
"workbench.facts.parts": Array.isArray(facts.parts) ? facts.parts.length : 0,
|
||||
"workbench.facts.turns": Array.isArray(facts.turns) ? facts.turns.length : 0,
|
||||
"workbench.facts.trace_events": Array.isArray(facts.traceEvents) ? facts.traceEvents.length : 0,
|
||||
"workbench.facts.checkpoints": Array.isArray(facts.checkpoints) ? facts.checkpoints.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
function workbenchProjectionInputMessages({ session = {}, payload = {}, params = {}, traceId = null, timestamp = null, terminal = false, terminalStatus = "completed", finalText = null } = {}) {
|
||||
if (Array.isArray(session?.messages) && session.messages.length > 0) return session.messages;
|
||||
if (Array.isArray(payload?.messages) && payload.messages.length > 0) return payload.messages;
|
||||
|
||||
Reference in New Issue
Block a user