Merge pull request #2444 from pikasTech/issue-1608-projection-doc-cleanup
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
fix(workbench): 清理投影一致性与 detail read 口径残留
This commit is contained in:
@@ -15,13 +15,13 @@
|
||||
|
||||
Workbench 页面和组件必须明确区分“未加载完成”和“已加载但为空”。未加载完成时不得渲染由 workspace、localStorage、默认对象或 stub 记录拼出来的伪数据;这类数据只能作为内部恢复线索,不能在列表、表格、卡片或状态栏中冒充真实加载结果。已经确认后端返回且集合为空时,才显示空态文案。
|
||||
|
||||
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 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单一权威 API 绑定。Session rail 的会话集合只允许消费 `/v1/workbench/sessions` 成功返回的 Workbench session read model;当前 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 运行状态、turn 投影、trace 终态和 final response 的自动恢复必须以 Workbench read model 和 `/v1/workbench/sync` replay 为权威;`/v1/workbench/turns/:traceId`、`/v1/workbench/traces/:traceId/events` 与旧 `/v1/agent/*` detail 入口只能服务显式详情、历史查看或兼容调用,不能成为 Workbench 自动投影的第二条事实来源。任何权威 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 或多来源仲裁去修补失败写入。
|
||||
|
||||
Workbench 的 trace/message/projection 运行时必须是独立模块边界。`workbench.ts` 只保留 session/route authority 校验、Pinia state commit、刷新调度和用户动作编排;trace snapshot、terminal result、message timing/status patch、agent error normalize、projection diagnostic 裁剪和 final response 文本提取等纯算法统一由 `web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts` 提供。新增或修复浏览器 smoke 时不得把这些 helper 重新散写回 store 或组件,也不得通过 reload、repair、localStorage truth、GET read-through、测试专用后门或删除 guard 来绕过真实投影问题。web-probe origin、视口、采样、命令超时、provider/lane 和报警阈值只从选中 node/lane 的受控 YAML/source-of-truth 进入验证命令,不在 SPEC 或前端 runtime 中写第二份数值。
|
||||
|
||||
Workbench terminal 三态必须作为一个不可拆分的投影不变量维护:session rail 状态、turn 卡片状态和 final response object 要么同时表现为完成且 final response 存在,要么同时表现为运行且 final response 不存在。持久 read model 是 session、messages、turn、trace tuple 的 source of truth;前端 `workbench-server-state` 只能做归一化缓存和单调合并,不能把 stale running 刷新覆盖到同一 trace 的 terminal authority 上。`session.status`、`turn.status`、`session.list/detail/messages`、`message.snapshot`、projection page merge、REST trace hydration 和 SSE trace snapshot 都必须保留已 sealed terminal 的 `status`、`traceAutoLifecycle`、`text`、`finalResponse` 和 terminal timing;新 trace 的 running 可以让同一 session 进入下一轮运行,但同 trace 的 running/non-terminal 只能作为旧事件丢弃或合并为 runnerTrace 证据。判断 final response 是否存在时必须能从 `text`、`content`、`finalResponse` 或 `reply/finalText` 等权威字段提取非空文本,空对象、进度 assistant trace 文本或仅有 AgentRun completed 状态都不能 seal completed;`/v1/agent/turns/:traceId`、Workbench read model、projection writer、runtime store invariant 和前端缓存层必须共用这个 terminal seal gate。新增修复应优先补 `workbench-message-projection-runtime`、`workbench-server-state` 或对应后端 read-model 的最小单元测试,而不是用 UI 特判、reload 或额外 fallback 掩盖投影分叉。
|
||||
Workbench terminal 三态必须作为一个不可拆分的投影不变量维护:session rail 状态、turn 卡片状态和 final response object 要么同时表现为完成且 final response 存在,要么同时表现为运行且 final response 不存在。持久 read model 是 session、messages、turn、trace tuple 的 source of truth;前端 `workbench-server-state` 只能做归一化缓存和单调合并,不能把 stale running 刷新覆盖到同一 trace 的 terminal authority 上。`session.status`、`turn.status`、`session.list/detail/messages`、`message.snapshot`、projection page merge、detail/history trace reads 和 SSE trace snapshot 都必须保留已 sealed terminal 的 `status`、`traceAutoLifecycle`、`text`、`finalResponse` 和 terminal timing;新 trace 的 running 可以让同一 session 进入下一轮运行,但同 trace 的 running/non-terminal 只能作为旧事件丢弃或合并为 runnerTrace 证据。判断 final response 是否存在时必须能从 `text`、`content`、`finalResponse` 或 `reply/finalText` 等权威字段提取非空文本,空对象、进度 assistant trace 文本或仅有 AgentRun completed 状态都不能 seal completed;Workbench read model、`/v1/workbench/sync` replay、projection writer、runtime store invariant、显式 turn/detail API 和前端缓存层必须共用这个 terminal seal gate。新增修复应优先补 `workbench-message-projection-runtime`、`workbench-server-state` 或对应后端 read-model 的最小单元测试,而不是用 UI 特判、reload 或额外 fallback 掩盖投影分叉。
|
||||
|
||||
MDTODO 发起 Workbench 执行时,HWPOD 执行上下文的唯一权威来源是 Project Management source registry。Workbench Launch 服务端必须通过 `taskRef -> sourceId/fileRef -> source` 解析 `launchContext.executionContext`,并把同一份 `contextFingerprint` 写入 session owner、Workbench facts、project-management link 和 OTel span;浏览器传入的 HWPOD 字段只能作为任务元数据,不能作为权威执行上下文。`sourceKind=hwpod-workspace` 但缺少 `hwpodId`、`nodeId` 或 `workspaceRootRef` 时,launch 必须显式失败,不能创建“看似成功但无法执行”的空 session。
|
||||
|
||||
@@ -29,15 +29,15 @@ MDTODO 首轮 Code Agent prompt 必须包含 `hwpodId`、`mdtodoRootRef` 和 `hw
|
||||
|
||||
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,或在真实空集合时显示空态。
|
||||
Session rail 是该规则的高频区域。`/v1/workbench/sessions` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 Workbench session list ready 后再渲染真实 session tabs,或在真实空集合时显示空态。
|
||||
|
||||
Session rail 的后台恢复刷新必须有硬边界。显式用户动作或强一致操作(例如选择会话、删除当前会话)可以立即刷新会话列表;SSE error、active trace sync replay、terminal refresh、trace hydration 等后台补偿路径不得绕过 session list 的冷却/合并机制去强制刷新完整列表。后台路径应优先补当前 trace、turn status、message projection 和必要的 trace events;需要刷新 session rail 时走统一的 scheduled refresh,并按 session/list key 合并已有 timer,避免网络抖动或 EventSource error storm 把 `/v1/workbench/sessions` 放大成浏览器内存和 CDP responsiveness 红灯。
|
||||
Session rail 的后台恢复刷新必须有硬边界。显式用户动作或强一致操作(例如选择会话、删除当前会话)可以立即刷新会话列表;SSE error、active trace sync replay、terminal refresh、detail/history trace reads 等后台补偿路径不得绕过 session list 的冷却/合并机制去强制刷新完整列表。后台路径应优先补当前 trace、turn status、message projection 和必要的 trace events;需要刷新 session rail 时走统一的 scheduled refresh,并按 session/list key 合并已有 timer,避免网络抖动或 EventSource error storm 把 `/v1/workbench/sessions` 放大成浏览器内存和 CDP responsiveness 红灯。
|
||||
|
||||
Workbench realtime 恢复与 sync replay 必须统一接入已经迁移的 OpenCode-style runtime 模块。`workbench-stream-transport` 只拥有 SSE lifecycle、cursor 和 recovery reason;`workbench-realtime-plan` 只把 transport action 转成纯 plan;`workbench-refresh-runtime`、keyed singleflight、scheduled task runtime、trace hydration queue、server-state reducer 和 session cache 是恢复读取的唯一 substrate。`workbench.ts` 不得再持有新的 in-flight map、timer map、cursor map、REST recovery queue 或第二套 recovery coordinator。`force` 是用户显式操作和恢复优先级语义,不得绕过同 key 的 singleflight、cooldown、min interval 或 storm budget;这些预算、退避、并发、页数、重试和窗口参数只由 node/lane YAML-backed runtime policy 注入,SPEC 只声明字段族、责任边界和验收读取方式,不写死数值。
|
||||
Workbench realtime 恢复与 sync replay 必须统一接入已经迁移的 OpenCode-style runtime 模块。`workbench-stream-transport` 只拥有 SSE lifecycle、cursor 和 recovery reason;`workbench-realtime-plan` 只把 transport action 转成纯 plan;`workbench-refresh-runtime`、keyed singleflight、scheduled task runtime、trace history queue、server-state reducer 和 session cache 是恢复读取的唯一 substrate。`workbench.ts` 不得再持有新的 in-flight map、timer map、cursor map、detail-read recovery queue 或第二套 recovery coordinator。`force` 是用户显式操作和恢复优先级语义,不得绕过同 key 的 singleflight、cooldown、min interval 或 storm budget;这些预算、退避、并发、页数、重试和窗口参数只由 node/lane YAML-backed runtime policy 注入,SPEC 只声明字段族、责任边界和验收读取方式,不写死数值。
|
||||
|
||||
Workbench Realtime Authority v2 的自动恢复只允许消费 SSE typed event 和 `/v1/workbench/sync` replay。`/workbench/sync` 返回的 durable `delta.messages`、`delta.turns` 等 family object 必须在前端 authority 层投影成带 `realtimeAuthority`、entity family/id/version 和 projection revision 的 `message.snapshot`、`turn.snapshot` 等 typed event,再进入统一 reducer;不得把 family delta 当成不可应用的普通对象,也不得用 `/v1/workbench/sessions/:id/messages`、`/v1/workbench/turns/:id` 或 `/v1/workbench/traces/:id/events` 自动 fan-out 补洞。跨 tab/page 的 `session-projection` signal 只能触发同一 `/workbench/sync` replay 或临时 optimistic echo,最终必须由 durable sync delta 覆盖并让 control/observer 页在同一个 session 上收敛到相同 messages/turn projection。`workbench-server-state` 的 `message.snapshot` guard 只能拒绝无 messageId、无 trace 或当前会话没有同 trace 上下文的孤儿 agent snapshot;durable user fact 之后到达的同 trace agent snapshot 必须可追加。新增修复应覆盖“observer 从空消息状态应用 sync replay 后得到 user/agent messages 与 turn status”的最小测试,并用 web-probe `observe analyze` 确认没有 persistent `cross-page-projection-divergence` 或 automatic recovery legacy fan-out 红项。
|
||||
|
||||
Workbench 只能维护一条会话恢复与提交路径。首次打开、新建后继续、从左侧 session rail 切换、直接进入 `/workbench/sessions/<conversationId>` 恢复时,都必须以当前 route/active conversation id 作为会话真相,并通过同一条 conversation detail hydration 路径得到 messages、turn state、trace/status 和 markdown 渲染输入;不得另写只消费列表 snapshot、workspace stub 或 localStorage selected id 的恢复分支。
|
||||
Workbench 只能维护一条会话恢复与提交路径。首次打开、新建后继续、从左侧 session rail 切换、直接进入 `/workbench/sessions/<conversationId>` 恢复时,都必须以当前 route/active conversation id 作为会话真相,并通过同一条 conversation detail read 路径得到 messages、turn state、trace/status 和 markdown 渲染输入;不得另写只消费列表 snapshot、workspace stub 或 localStorage selected id 的恢复分支。
|
||||
|
||||
Workbench 的 URL 反射必须服从用户当前导航和组件生命周期。`activeConversationId`、hydrate、select conversation 或列表刷新等异步状态只能在当前 route 仍属于 Workbench section、路径仍是 `/workbench`/`/workspace` 系列且 Workbench 组件仍 active 时,才允许把 URL 反射到 `/workbench/sessions/<conversationId>`;用户已经点击 Dashboard、API Keys、Admin、Settings 或其他非 Workbench 导航后,晚到的 Workbench 响应只能更新 store,不得再调用 `router.replace`/`router.push` 把全局 route 拉回 Workbench。新增 session 恢复或 URL 反射入口时必须复用共享路由守卫,例如 `web/hwlab-cloud-web/src/router/workbench-navigation.ts`,不要在业务组件里各自手写跳转判断。
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ assertIncludes(workbenchColadaSource, "useMutation", "Workbench mutations must e
|
||||
assertIncludes(workbenchStoreSource, "useWorkbenchColadaQueries", "Workbench store must read server-state through Colada query facade");
|
||||
assertIncludes(workbenchStoreSource, "useWorkbenchColadaMutations", "Workbench store must run admission/cancel through Colada mutations");
|
||||
assertIncludes(workbenchStoreSource, "useWorkbenchColadaReducer", "Workbench store must use Colada cache as projection reducer authority");
|
||||
assert.doesNotMatch(workbenchStoreSource, /createWorkbench(?:ReadHydration|ScheduledTask|TraceHydrationQueue)Runtime|runWorkbenchReadHydration/u, "Workbench store must not use legacy Workbench request runtimes");
|
||||
assert.doesNotMatch(workbenchStoreSource, /createWorkbench(?:ReadHydration|ReadDetail|ScheduledTask|TraceHydrationQueue|TraceDetailReadQueue)Runtime|runWorkbench(?:ReadHydration|ReadDetail)/u, "Workbench store must not use legacy Workbench request runtimes");
|
||||
assert.doesNotMatch(workbenchStoreSource, /api\.workbench\.(?:sessions|sessionMessages|turn|traceEvents)/u, "Workbench store must not call Workbench read APIs outside Colada queries");
|
||||
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");
|
||||
@@ -212,7 +212,7 @@ assert.doesNotMatch(serverWorkbenchHttpSource, /return\s+turnSnapshot\(context\)
|
||||
assertIncludes(serverWorkbenchHttpSource, "blockedRealtimeTurnSnapshot", "Realtime read-model gaps must produce blocked diagnostic turn snapshots");
|
||||
assertIncludes(serverWorkbenchHttpSource, "workbench_facts_session_missing", "Realtime read-model gaps must expose a facts-missing blocker");
|
||||
assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint");
|
||||
assertIncludes(appSource, "/v1/workbench/traces/", "trace hydration must use Workbench read-model trace API");
|
||||
assertIncludes(appSource, "/v1/workbench/traces/", "trace detail read must use Workbench read-model trace API");
|
||||
assert.doesNotMatch(appSource, /\/v1\/agent\/(?:turns|traces|chat\/result)\//u, "Cloud Web must not call legacy Code Agent read-through turn/trace/result APIs");
|
||||
assert.doesNotMatch(workbenchStoreSource, /api\.(?:agent|workbench)\.(?:getAgentTurn|getAgentTrace|turn|traceEvents|sessions|sessionMessages)/u, "Workbench store turn/trace/session reads must go through Colada queries");
|
||||
assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API");
|
||||
|
||||
@@ -169,8 +169,8 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (traceMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(traceMatch[1] ?? "");
|
||||
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
|
||||
if (state.scenarioId === "trace-hydration-timeout-diagnostic" && traceId === "trc_trace_hydration_timeout") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "33333333333333333333333333333333", requestId: "req_e2e_trace_hydration_timeout", layer: "workbench-read-model", category: "trace-hydration" });
|
||||
if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "55555555555555555555555555555555", requestId: "req_e2e_sealed_final_trace_timeout", layer: "workbench-read-model", category: "trace-hydration" });
|
||||
if (state.scenarioId === "trace-detail-read-timeout-diagnostic" && traceId === "trc_trace_detail_read_timeout") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_detail_read_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "33333333333333333333333333333333", requestId: "req_e2e_trace_detail_read_timeout", layer: "workbench-read-model", category: "trace-detail-read" });
|
||||
if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_detail_read_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "55555555555555555555555555555555", requestId: "req_e2e_sealed_final_trace_timeout", layer: "workbench-read-model", category: "trace-detail-read" });
|
||||
if (traceId === state.staleTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
|
||||
return json(response, 200, workbenchTracePayload(traceId, url));
|
||||
}
|
||||
@@ -1128,9 +1128,9 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
sessions.unshift(projectionSseErrorSession());
|
||||
traces.trc_projection_sse_error = projectionSseErrorTrace();
|
||||
}
|
||||
if (id === "trace-hydration-timeout-diagnostic") {
|
||||
sessions.unshift(traceHydrationTimeoutSession());
|
||||
traces.trc_trace_hydration_timeout = traceHydrationTimeoutTrace();
|
||||
if (id === "trace-detail-read-timeout-diagnostic") {
|
||||
sessions.unshift(traceDetailReadTimeoutSession());
|
||||
traces.trc_trace_detail_read_timeout = traceDetailReadTimeoutTrace();
|
||||
}
|
||||
if (id === "sealed-final-response-diagnostics") {
|
||||
sessions.unshift(sealedFinalResponseDiagnosticSession());
|
||||
@@ -1179,8 +1179,8 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
? "ses_projection_degraded"
|
||||
: id === "projection-sse-error"
|
||||
? "ses_projection_sse_error"
|
||||
: id === "trace-hydration-timeout-diagnostic"
|
||||
? "ses_trace_hydration_timeout"
|
||||
: id === "trace-detail-read-timeout-diagnostic"
|
||||
? "ses_trace_detail_read_timeout"
|
||||
: id === "sealed-final-response-diagnostics"
|
||||
? "ses_sealed_final_diag"
|
||||
: id === "trace-final-response-not-message-authority"
|
||||
@@ -1540,23 +1540,23 @@ function projectionSseErrorSession(): SessionRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function traceHydrationTimeoutTrace(): JsonRecord {
|
||||
return { traceId: "trc_trace_hydration_timeout", status: "running", sessionId: "ses_trace_hydration_timeout", threadId: "thr_trace_hydration_timeout", turnId: "turn_trace_hydration_timeout", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: true };
|
||||
function traceDetailReadTimeoutTrace(): JsonRecord {
|
||||
return { traceId: "trc_trace_detail_read_timeout", status: "running", sessionId: "ses_trace_detail_read_timeout", threadId: "thr_trace_detail_read_timeout", turnId: "turn_trace_detail_read_timeout", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: true };
|
||||
}
|
||||
|
||||
function traceHydrationTimeoutSession(): SessionRecord {
|
||||
function traceDetailReadTimeoutSession(): SessionRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
sessionId: "ses_trace_hydration_timeout",
|
||||
threadId: "thr_trace_hydration_timeout",
|
||||
sessionId: "ses_trace_detail_read_timeout",
|
||||
threadId: "thr_trace_detail_read_timeout",
|
||||
status: "running",
|
||||
lastTraceId: "trc_trace_hydration_timeout",
|
||||
lastTraceId: "trc_trace_detail_read_timeout",
|
||||
updatedAt: now,
|
||||
messageCount: 2,
|
||||
firstUserMessagePreview: "trace hydration timeout visibility",
|
||||
firstUserMessagePreview: "trace detail read timeout visibility",
|
||||
messages: [
|
||||
{ id: "msg_trace_hydration_timeout_user", messageId: "msg_trace_hydration_timeout_user", role: "user", title: "用户", text: "trace hydration timeout visibility", status: "sent", createdAt: now, sessionId: "ses_trace_hydration_timeout", threadId: "thr_trace_hydration_timeout", turnId: "turn_trace_hydration_timeout" },
|
||||
{ id: "msg_trace_hydration_timeout_agent", messageId: "msg_trace_hydration_timeout_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_trace_hydration_timeout", threadId: "thr_trace_hydration_timeout", traceId: "trc_trace_hydration_timeout", turnId: "turn_trace_hydration_timeout", runnerTrace: traceHydrationTimeoutTrace() }
|
||||
{ id: "msg_trace_detail_read_timeout_user", messageId: "msg_trace_detail_read_timeout_user", role: "user", title: "用户", text: "trace detail read timeout visibility", status: "sent", createdAt: now, sessionId: "ses_trace_detail_read_timeout", threadId: "thr_trace_detail_read_timeout", turnId: "turn_trace_detail_read_timeout" },
|
||||
{ id: "msg_trace_detail_read_timeout_agent", messageId: "msg_trace_detail_read_timeout_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_trace_detail_read_timeout", threadId: "thr_trace_detail_read_timeout", traceId: "trc_trace_detail_read_timeout", turnId: "turn_trace_detail_read_timeout", runnerTrace: traceDetailReadTimeoutTrace() }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -2063,7 +2063,7 @@ function liveBackfillEarlyTrace(sessionId: string, threadId: string | null, trac
|
||||
|
||||
function liveBackfillCompletedTrace(sessionId: string, threadId: string | null, traceId: string): JsonRecord {
|
||||
const createdAt = new Date().toISOString();
|
||||
const finalText = "fake AgentRun completed after REST backfill without terminal SSE.";
|
||||
const finalText = "fake AgentRun completed after detail backfill without terminal SSE.";
|
||||
const agentRun = liveBackfillAgentRun("completed", "completed", 23);
|
||||
const events = [
|
||||
...(liveBackfillEarlyTrace(sessionId, threadId, traceId).events as JsonRecord[]),
|
||||
|
||||
@@ -34,7 +34,7 @@ test("Workbench scoped keys encode delimiter characters", () => {
|
||||
test("Workbench runtime policy reads injected config while preserving defaults", () => {
|
||||
const policy = workbenchRuntimePolicy({
|
||||
sessionListPageLimit: 7,
|
||||
traceHydrationMaxPages: 2,
|
||||
traceDetailMaxPages: 2,
|
||||
workbenchSessionDetailMinRefreshMs: 1234,
|
||||
workbenchSessionMessagesWindowLimit: 9,
|
||||
workbenchTraceMessagesWindowLimit: 4,
|
||||
@@ -46,7 +46,7 @@ test("Workbench runtime policy reads injected config while preserving defaults",
|
||||
});
|
||||
|
||||
assert.equal(policy.sessionListPageLimit, 7);
|
||||
assert.equal(policy.traceHydrationMaxPages, 2);
|
||||
assert.equal(policy.traceDetailMaxPages, 2);
|
||||
assert.equal(policy.workbenchSessionDetailMinRefreshMs, 1234);
|
||||
assert.equal(policy.workbenchSessionMessagesWindowLimit, 9);
|
||||
assert.equal(policy.workbenchTraceMessagesWindowLimit, 4);
|
||||
@@ -57,6 +57,20 @@ test("Workbench runtime policy reads injected config while preserving defaults",
|
||||
assert.equal(policy.defaultGatewayTimeoutMs, 120_000);
|
||||
});
|
||||
|
||||
test("Workbench runtime policy keeps deprecated trace hydration aliases compatible", () => {
|
||||
const policy = workbenchRuntimePolicy({
|
||||
traceHydrationPageLimit: 13,
|
||||
traceHydrationMaxPages: 2,
|
||||
traceHydrationAutoQueueLimit: 3,
|
||||
workbenchReadHydrationConcurrency: 4
|
||||
});
|
||||
|
||||
assert.equal(policy.traceDetailPageLimit, 13);
|
||||
assert.equal(policy.traceDetailMaxPages, 2);
|
||||
assert.equal(policy.traceDetailAutoQueueLimit, 3);
|
||||
assert.equal(policy.workbenchDetailReadConcurrency, 4);
|
||||
});
|
||||
|
||||
test("Workbench API uses metadata-only session detail and bounded messages paths independently", () => {
|
||||
assert.equal(workbenchSessionDetailPathForTest("ses_metadata"), "/v1/workbench/sessions/ses_metadata?includeMessages=false");
|
||||
assert.equal(workbenchSessionDetailPathForTest("ses_metadata", { timeoutMs: 8000 }), "/v1/workbench/sessions/ses_metadata?includeMessages=false");
|
||||
|
||||
@@ -128,9 +128,9 @@ function messageActivityMeta(message: ChatMessage): { text: string; label: strin
|
||||
|
||||
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
|
||||
// The main message card consumes only the durable message timing projection.
|
||||
// Trace timing belongs to TraceTimeline/details; letting trace hydration drive the
|
||||
// card timer makes running turns jump when a later trace snapshot carries an
|
||||
// older startedAt/lastEventAt window.
|
||||
// Trace timing belongs to TraceTimeline detail/history reads; letting those
|
||||
// reads drive the card timer makes running turns jump when a later trace
|
||||
// snapshot carries an older startedAt/lastEventAt window.
|
||||
return message.timing ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
export interface WorkbenchRuntimePolicy {
|
||||
defaultCodeAgentTimeoutMs: number;
|
||||
defaultGatewayTimeoutMs: number;
|
||||
traceHydrationPageLimit: number;
|
||||
traceHydrationMaxPages: number;
|
||||
traceHydrationMaxAttempts: number;
|
||||
traceHydrationRetryDelayMs: number;
|
||||
traceHydrationAutoQueueLimit: number;
|
||||
traceHydrationBackgroundConcurrency: number;
|
||||
traceHydrationBackgroundDelayMs: number;
|
||||
traceDetailPageLimit: number;
|
||||
traceDetailMaxPages: number;
|
||||
traceDetailMaxAttempts: number;
|
||||
traceDetailRetryDelayMs: number;
|
||||
traceDetailAutoQueueLimit: number;
|
||||
traceDetailBackgroundConcurrency: number;
|
||||
traceDetailBackgroundDelayMs: number;
|
||||
sessionListPageLimit: number;
|
||||
workbenchReadHydrationConcurrency: number;
|
||||
workbenchDetailReadConcurrency: number;
|
||||
workbenchReadFailureCooldownMs: number;
|
||||
workbenchTurnStatusMinRefreshMs: number;
|
||||
workbenchTraceEventsMinRefreshMs: number;
|
||||
@@ -36,15 +36,15 @@ export interface WorkbenchRuntimePolicy {
|
||||
const DEFAULT_WORKBENCH_RUNTIME_POLICY: WorkbenchRuntimePolicy = Object.freeze({
|
||||
defaultCodeAgentTimeoutMs: 1_800_000,
|
||||
defaultGatewayTimeoutMs: 120_000,
|
||||
traceHydrationPageLimit: 50,
|
||||
traceHydrationMaxPages: 4,
|
||||
traceHydrationMaxAttempts: 3,
|
||||
traceHydrationRetryDelayMs: 700,
|
||||
traceHydrationAutoQueueLimit: 4,
|
||||
traceHydrationBackgroundConcurrency: 1,
|
||||
traceHydrationBackgroundDelayMs: 400,
|
||||
traceDetailPageLimit: 50,
|
||||
traceDetailMaxPages: 4,
|
||||
traceDetailMaxAttempts: 3,
|
||||
traceDetailRetryDelayMs: 700,
|
||||
traceDetailAutoQueueLimit: 4,
|
||||
traceDetailBackgroundConcurrency: 1,
|
||||
traceDetailBackgroundDelayMs: 400,
|
||||
sessionListPageLimit: 20,
|
||||
workbenchReadHydrationConcurrency: 3,
|
||||
workbenchDetailReadConcurrency: 3,
|
||||
workbenchReadFailureCooldownMs: 5_000,
|
||||
workbenchTurnStatusMinRefreshMs: 2_000,
|
||||
workbenchTraceEventsMinRefreshMs: 4_000,
|
||||
@@ -70,15 +70,15 @@ export function workbenchRuntimePolicy(input: unknown = runtimePolicyConfig()):
|
||||
return {
|
||||
defaultCodeAgentTimeoutMs: positiveNumber(source.defaultCodeAgentTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.defaultCodeAgentTimeoutMs),
|
||||
defaultGatewayTimeoutMs: positiveNumber(source.defaultGatewayTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.defaultGatewayTimeoutMs),
|
||||
traceHydrationPageLimit: positiveInteger(source.traceHydrationPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationPageLimit),
|
||||
traceHydrationMaxPages: positiveInteger(source.traceHydrationMaxPages, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationMaxPages),
|
||||
traceHydrationMaxAttempts: positiveInteger(source.traceHydrationMaxAttempts, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationMaxAttempts),
|
||||
traceHydrationRetryDelayMs: positiveNumber(source.traceHydrationRetryDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationRetryDelayMs),
|
||||
traceHydrationAutoQueueLimit: positiveInteger(source.traceHydrationAutoQueueLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationAutoQueueLimit),
|
||||
traceHydrationBackgroundConcurrency: positiveInteger(source.traceHydrationBackgroundConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationBackgroundConcurrency),
|
||||
traceHydrationBackgroundDelayMs: nonNegativeNumber(source.traceHydrationBackgroundDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationBackgroundDelayMs),
|
||||
traceDetailPageLimit: positiveInteger(source.traceDetailPageLimit ?? source.traceHydrationPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailPageLimit),
|
||||
traceDetailMaxPages: positiveInteger(source.traceDetailMaxPages ?? source.traceHydrationMaxPages, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailMaxPages),
|
||||
traceDetailMaxAttempts: positiveInteger(source.traceDetailMaxAttempts ?? source.traceHydrationMaxAttempts, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailMaxAttempts),
|
||||
traceDetailRetryDelayMs: positiveNumber(source.traceDetailRetryDelayMs ?? source.traceHydrationRetryDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailRetryDelayMs),
|
||||
traceDetailAutoQueueLimit: positiveInteger(source.traceDetailAutoQueueLimit ?? source.traceHydrationAutoQueueLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailAutoQueueLimit),
|
||||
traceDetailBackgroundConcurrency: positiveInteger(source.traceDetailBackgroundConcurrency ?? source.traceHydrationBackgroundConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailBackgroundConcurrency),
|
||||
traceDetailBackgroundDelayMs: nonNegativeNumber(source.traceDetailBackgroundDelayMs ?? source.traceHydrationBackgroundDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceDetailBackgroundDelayMs),
|
||||
sessionListPageLimit: positiveInteger(source.sessionListPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListPageLimit),
|
||||
workbenchReadHydrationConcurrency: positiveInteger(source.workbenchReadHydrationConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadHydrationConcurrency),
|
||||
workbenchDetailReadConcurrency: positiveInteger(source.workbenchDetailReadConcurrency ?? source.workbenchReadHydrationConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchDetailReadConcurrency),
|
||||
workbenchReadFailureCooldownMs: positiveNumber(source.workbenchReadFailureCooldownMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadFailureCooldownMs),
|
||||
workbenchTurnStatusMinRefreshMs: nonNegativeNumber(source.workbenchTurnStatusMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTurnStatusMinRefreshMs),
|
||||
workbenchTraceEventsMinRefreshMs: nonNegativeNumber(source.workbenchTraceEventsMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceEventsMinRefreshMs),
|
||||
|
||||
@@ -141,7 +141,7 @@ test("workbench active terminal paths seal final response from turn authority",
|
||||
const completeBlock = source.slice(source.indexOf("function completeTrace"), source.indexOf("async function hydrateTerminalMessageDiagnostics"));
|
||||
const realtimeSessionSyncBlock = source.slice(source.indexOf("async function refreshRealtimeSessionFromSyncReplay"), source.indexOf("function completeTrace"));
|
||||
const sessionDetailReadBlock = source.slice(source.indexOf("function fetchSessionDetailPage"), source.indexOf("function sessionMessageProjectionWindowLimit"));
|
||||
const traceHydrationBlock = source.slice(source.indexOf("async function hydrateTraceEventsForExplicitDetailPages"), source.indexOf("async function fetchTraceHydrationPage"));
|
||||
const traceDetailReadBlock = source.slice(source.indexOf("async function readTraceEventsForExplicitDetailPages"), source.indexOf("async function fetchTraceDetailEventsPage"));
|
||||
const loadBlock = source.slice(source.indexOf("async function loadWorkbenchSession"), source.indexOf("async function sealRestoredActiveTurnMessages"));
|
||||
const restoreSealBlock = source.slice(source.indexOf("async function sealRestoredActiveTurnMessages"), source.indexOf("function reattachRestoredActiveTrace"));
|
||||
const activeSyncReplayBlock = source.slice(source.indexOf("async function refreshActiveTraceFromSyncReplay"), source.indexOf("function stopRealtime"));
|
||||
@@ -150,7 +150,7 @@ test("workbench active terminal paths seal final response from turn authority",
|
||||
assert.match(projectBlock, /type:\s*"message\.upsert"/u);
|
||||
assert.match(projectBlock, /\.\.\.terminalPatch/u);
|
||||
assert.match(source, /createKeyedSingleflight<ApiResult<AgentChatResultResponse>>/u);
|
||||
assert.match(source, /traceHydrationSingleflight\.run/u);
|
||||
assert.match(source, /traceDetailReadSingleflight\.run/u);
|
||||
assert.doesNotMatch(source, new RegExp(["refresh", "RealtimeSessionMessages"].join(""), "u"));
|
||||
assert.match(realtimeTurnBlock, /rememberTurnStatus\(traceId, result\)[\s\S]*scheduleRealtimeTurnProjection\(\{ traceId, result, terminalTurn \}\)/u);
|
||||
assert.doesNotMatch(realtimeTurnBlock, new RegExp(`applyTurnStatusSnapshot\\(|${["refresh", "TerminalTraceFromRest"].join("")}\\(`, "u"));
|
||||
@@ -158,7 +158,7 @@ test("workbench active terminal paths seal final response from turn authority",
|
||||
assert.match(realtimeTurnProjectionBlock, /next\.terminalTurn[\s\S]*refreshTerminalTraceFromSyncReplay\(next\.traceId, "realtime-turn-snapshot"\)/u);
|
||||
assert.match(realtimeTurnProjectionBlock, /workbench_realtime_turn_projection_budget/u);
|
||||
assert.match(terminalSyncBlock, /refreshWorkbenchSyncReplay\(ownerSessionId, id, null, `terminal-sync-replay:\$\{reason\}`\)/u);
|
||||
assert.doesNotMatch(terminalSyncBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|hydrateTraceEventsForMessage/u);
|
||||
assert.doesNotMatch(terminalSyncBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|readTraceEventsForMessage/u);
|
||||
assert.match(completeBlock, /projectTurnAuthorityToMessages\(traceId, result, "complete-trace"\)/u);
|
||||
assert.match(completeBlock, /options\.forceRead[\s\S]*refreshMessageProjectionForTrace\(ownerSessionId, traceId, \{ force: true \}\)/u);
|
||||
assert.match(completeBlock, /else scheduleActiveTraceSyncReplay\(traceId, "complete-trace-sync-replay", 0\)/u);
|
||||
@@ -167,10 +167,10 @@ test("workbench active terminal paths seal final response from turn authority",
|
||||
assert.doesNotMatch(realtimeSessionSyncBlock, /hydrateTurnStatusAuthority|sessionDetailAutoReadDecision|fetchSessionDetailPage|fetchSessionMessagesPage|refreshMessageProjectionForTrace/u);
|
||||
assert.doesNotMatch(realtimeSessionSyncBlock, /loadWorkbenchSession|applySelectedSessionDetail/u);
|
||||
assert.match(activeSyncReplayBlock, /refreshWorkbenchSyncReplay\(ownerSessionId, id, null, `active-sync-replay:\$\{reason\}`\)/u);
|
||||
assert.doesNotMatch(activeSyncReplayBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|hydrateTraceEventsForMessage/u);
|
||||
assert.match(traceHydrationBlock, /traceAuthorityById\.value\[traceId\] \?\? message\.runnerTrace/u);
|
||||
assert.match(traceHydrationBlock, /traceEventsHydrationReadDecision\(traceId, afterProjectedSeq, message, options\)/u);
|
||||
assert.match(traceHydrationBlock, /trace_events_auto_read_skip/u);
|
||||
assert.doesNotMatch(activeSyncReplayBlock, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|fetchSessionMessagesPage|readTraceEventsForMessage/u);
|
||||
assert.match(traceDetailReadBlock, /traceAuthorityById\.value\[traceId\] \?\? message\.runnerTrace/u);
|
||||
assert.match(traceDetailReadBlock, /traceEventsDetailReadDecision\(traceId, afterProjectedSeq, message, options\)/u);
|
||||
assert.match(traceDetailReadBlock, /trace_events_auto_read_skip/u);
|
||||
assert.match(sessionDetailReadBlock, /workbenchSessionDetailReadKey\(\{ sessionId, force: options\.force \}\)/u);
|
||||
assert.match(sessionDetailReadBlock, /fetchSession\(sessionId, \{ includeMessages: false,/u);
|
||||
assert.match(loadBlock, /const messageLimit = sessionMessageProjectionWindowLimit\(\);/u);
|
||||
@@ -196,5 +196,14 @@ test("workbench automatic live repair does not call legacy REST fan-out", () =>
|
||||
|
||||
assert.match(automaticBlocks, /refreshWorkbenchSyncReplay/u);
|
||||
assert.doesNotMatch(automaticBlocks, /fetchWorkbenchTurnStatus|fetchWorkbenchTraceEvents|fetchSessionMessagesPage|fetchSessionDetailPage/u);
|
||||
assert.doesNotMatch(automaticBlocks, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|hydrateTraceEventsForMessage\(/u);
|
||||
assert.doesNotMatch(automaticBlocks, /refreshTurnStatusByTraceId|refreshMessageProjectionForTrace|readTraceEventsForMessage\(/u);
|
||||
});
|
||||
|
||||
test("cross page projection signal always replays durable sync", () => {
|
||||
const source = fs.readFileSync(path.join(storeDir, "workbench.ts"), "utf8");
|
||||
const signalBlock = source.slice(source.indexOf("function handleWorkbenchProjectionSignal"), source.indexOf("function applyTraceSnapshot"));
|
||||
|
||||
assert.match(signalBlock, /refreshRealtimeSessionFromSyncReplay\(sessionId, `cross-tab-session-projection:/u);
|
||||
assert.doesNotMatch(signalBlock, /messages\.value\.some/u);
|
||||
assert.doesNotMatch(signalBlock, /return;\s*\n\s*void refreshRealtimeSessionFromSyncReplay/u);
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ export function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult<un
|
||||
const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code);
|
||||
const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category);
|
||||
const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source);
|
||||
if (code === "workbench_read_hydration_cooldown" || code === "workbench_read_hydration_throttled") return true;
|
||||
if (code === "workbench_detail_read_cooldown" || code === "workbench_detail_read_throttled" || code === "workbench_read_hydration_cooldown" || code === "workbench_read_hydration_throttled") return true;
|
||||
return result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network";
|
||||
}
|
||||
|
||||
@@ -492,11 +492,11 @@ export function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
|
||||
return !agentRun || (message.status !== "completed" && !error);
|
||||
}
|
||||
|
||||
export function messageNeedsTraceHydration(message: ChatMessage): boolean {
|
||||
export function messageNeedsTraceDetailRead(message: ChatMessage): boolean {
|
||||
if (message.role !== "agent") return false;
|
||||
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
||||
if (!traceId) return false;
|
||||
if (!messageHasActiveTraceForHydration(message)) return false;
|
||||
if (!messageHasActiveTraceForDetailRead(message)) return false;
|
||||
const trace = message.runnerTrace;
|
||||
const events = Array.isArray(trace?.events) ? trace.events : [];
|
||||
const eventCount = firstFiniteNumber(trace?.eventCount) ?? events.length;
|
||||
@@ -504,7 +504,7 @@ export function messageNeedsTraceHydration(message: ChatMessage): boolean {
|
||||
return events.length === 0 || trace?.eventsCompacted === true || trace?.fullTraceLoaded !== true;
|
||||
}
|
||||
|
||||
function messageHasActiveTraceForHydration(message: ChatMessage): boolean {
|
||||
function messageHasActiveTraceForDetailRead(message: ChatMessage): boolean {
|
||||
const trace = message.runnerTrace;
|
||||
return isTraceActiveStatus(message.status)
|
||||
|| isTraceActiveStatus(trace?.status)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPEC: pikasTech/HWLAB#2356 Workbench bounded request storm follow-up.
|
||||
// Responsibility: Pure budgeting helpers for Workbench read hydration de-duplication and projection merge writes.
|
||||
// Responsibility: Pure budgeting helpers for Workbench detail read de-duplication and projection merge writes.
|
||||
|
||||
import type { ChatMessage } from "@/types";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
@@ -47,7 +47,7 @@ export interface SessionDetailAutoReadDecisionInput {
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface ReadHydrationDecision {
|
||||
export interface ReadDetailDecision {
|
||||
read: boolean;
|
||||
reason: string;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function workbenchTraceEventsReadKey(input: TraceEventsReadRangeInput): s
|
||||
);
|
||||
}
|
||||
|
||||
export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionInput): ReadHydrationDecision {
|
||||
export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionInput): ReadDetailDecision {
|
||||
if (input.force === true) return { read: true, reason: "force" };
|
||||
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
|
||||
const afterProjectedSeq = readSeqPart(input.afterProjectedSeq);
|
||||
@@ -89,7 +89,7 @@ export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionIn
|
||||
return { read: true, reason: "delta-range" };
|
||||
}
|
||||
|
||||
export function sessionDetailAutoReadDecision(input: SessionDetailAutoReadDecisionInput): ReadHydrationDecision {
|
||||
export function sessionDetailAutoReadDecision(input: SessionDetailAutoReadDecisionInput): ReadDetailDecision {
|
||||
if (input.force === true) return { read: true, reason: "force" };
|
||||
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
|
||||
if (firstNonEmptyString(input.traceId)) return { read: false, reason: "turn-authority-first" };
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import type { ChatMessage, WorkbenchSessionRecord } from "../types";
|
||||
import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, sessionToSessionTab, shouldHydrateTerminalTraceFromRest } from "./workbench-session";
|
||||
import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, sessionToSessionTab, shouldReadTerminalTraceDetail } from "./workbench-session";
|
||||
import type { TurnStatusAuthority } from "./workbench-session";
|
||||
|
||||
function agentMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id" | "traceId" | "sessionId">): ChatMessage {
|
||||
@@ -94,22 +94,22 @@ test("turn status refresh selector keeps automatic hydrate bounded to the active
|
||||
});
|
||||
|
||||
test("turn status refresh selector skips sealed terminal traces", () => {
|
||||
const sessionId = "ses_turn_hydrate_sealed";
|
||||
const sessionId = "ses_turn_detail_sealed";
|
||||
const sealed = agentMessage({ id: "msg_sealed", traceId: "trc_sealed", sessionId, status: "completed", text: "final" });
|
||||
|
||||
assert.deepEqual(selectActiveTurnStatusRefreshTraceIds({ messages: [sealed], currentRequestTraceId: sealed.traceId, turnStatusAuthority: { [sealed.traceId]: turnStatus({ traceId: sealed.traceId, sessionId, status: "completed", running: false, terminal: true }) }, limit: 1 }), []);
|
||||
});
|
||||
|
||||
test("terminal REST hydrate skips sealed terminal trace authority", () => {
|
||||
const sessionId = "ses_terminal_hydrate_sealed";
|
||||
const traceId = "trc_terminal_hydrate_sealed";
|
||||
const sealed = agentMessage({ id: "msg_terminal_hydrate_sealed", traceId, sessionId, status: "completed", text: "final response" });
|
||||
const unsealed = agentMessage({ id: "msg_terminal_hydrate_unsealed", traceId, sessionId, status: "completed" });
|
||||
test("terminal detail read skips sealed terminal trace authority", () => {
|
||||
const sessionId = "ses_terminal_detail_sealed";
|
||||
const traceId = "trc_terminal_detail_sealed";
|
||||
const sealed = agentMessage({ id: "msg_terminal_detail_sealed", traceId, sessionId, status: "completed", text: "final response" });
|
||||
const unsealed = agentMessage({ id: "msg_terminal_detail_unsealed", traceId, sessionId, status: "completed" });
|
||||
const turnStatusAuthority = { [traceId]: turnStatus({ traceId, sessionId, status: "completed", running: false, terminal: true }) };
|
||||
|
||||
assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [sealed], turnStatusAuthority }), false);
|
||||
assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [unsealed], turnStatusAuthority }), true);
|
||||
assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [], turnStatusAuthority }), true);
|
||||
assert.equal(shouldReadTerminalTraceDetail({ traceId, messages: [sealed], turnStatusAuthority }), false);
|
||||
assert.equal(shouldReadTerminalTraceDetail({ traceId, messages: [unsealed], turnStatusAuthority }), true);
|
||||
assert.equal(shouldReadTerminalTraceDetail({ traceId, messages: [], turnStatusAuthority }), true);
|
||||
});
|
||||
|
||||
test("session rail status prefers newer running trace over stale completed authority", () => {
|
||||
|
||||
@@ -129,7 +129,7 @@ export function selectActiveTurnStatusRefreshTraceIds(input: { messages: ChatMes
|
||||
return [...traceIds];
|
||||
}
|
||||
|
||||
export function shouldHydrateTerminalTraceFromRest(input: { traceId?: string | null; messages: ChatMessage[]; turnStatusAuthority?: TurnStatusAuthorityMap }): boolean {
|
||||
export function shouldReadTerminalTraceDetail(input: { traceId?: string | null; messages: ChatMessage[]; turnStatusAuthority?: TurnStatusAuthorityMap }): boolean {
|
||||
const traceId = firstNonEmptyString(input.traceId);
|
||||
if (!traceId) return false;
|
||||
const message = latestMessageForTrace(input.messages, traceId);
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import type { AgentChatResultResponse, ChatMessage } from "../types";
|
||||
import { terminalSealResultWithoutTraceEvents, traceHydrationProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-hydration";
|
||||
import { terminalSealResultWithoutTraceEvents, traceDetailProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-detail";
|
||||
|
||||
test("trace hydration cursor prefers range metadata over scanning events", () => {
|
||||
test("trace detail read cursor prefers range metadata over scanning events", () => {
|
||||
const trace: ChatMessage["runnerTrace"] = {
|
||||
traceId: "trc_cursor_metadata",
|
||||
nextProjectedSeq: 42,
|
||||
@@ -12,7 +12,7 @@ test("trace hydration cursor prefers range metadata over scanning events", () =>
|
||||
events: [{ projectedSeq: 1, type: "event" }]
|
||||
};
|
||||
|
||||
assert.equal(traceHydrationProjectedSeq(trace), 42);
|
||||
assert.equal(traceDetailProjectedSeq(trace), 42);
|
||||
});
|
||||
|
||||
test("trace result next cursor uses metadata before event fallback", () => {
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
import type { AgentChatResultResponse, ChatMessage, TraceEvent } from "../types";
|
||||
|
||||
export function traceHydrationProjectedSeq(trace: ChatMessage["runnerTrace"]): number {
|
||||
const cursor = traceHydrationCursor(trace);
|
||||
export function traceDetailProjectedSeq(trace: ChatMessage["runnerTrace"]): number {
|
||||
const cursor = traceDetailCursor(trace);
|
||||
if (cursor !== null) return cursor;
|
||||
const events = Array.isArray(trace?.events) ? trace.events : [];
|
||||
return eventsProjectedSeq(events, 0);
|
||||
}
|
||||
|
||||
export function traceNextProjectedSeq(result: AgentChatResultResponse, fallback: number): number {
|
||||
const cursor = traceHydrationCursor(result);
|
||||
const cursor = traceDetailCursor(result);
|
||||
if (cursor !== null) return cursor;
|
||||
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
|
||||
return eventsProjectedSeq(events, fallback);
|
||||
@@ -21,7 +21,7 @@ export function terminalSealResultWithoutTraceEvents(result: AgentChatResultResp
|
||||
return { ...rest, runnerTrace: traceRest };
|
||||
}
|
||||
|
||||
function traceHydrationCursor(source: { nextProjectedSeq?: unknown; range?: { toProjectedSeq?: unknown } | null } | null | undefined): number | null {
|
||||
function traceDetailCursor(source: { nextProjectedSeq?: unknown; range?: { toProjectedSeq?: unknown } | null } | null | undefined): number | null {
|
||||
return finiteProjectedSeq(source?.nextProjectedSeq) ?? finiteProjectedSeq(source?.range?.toProjectedSeq);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, Ap
|
||||
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
|
||||
import { composeWorkbenchScopedKey } 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, selectActiveTurnStatusRefreshTraceIds, shouldHydrateTerminalTraceFromRest, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
|
||||
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, 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";
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
isTraceActiveStatus,
|
||||
messageHasTerminalResponse,
|
||||
messageNeedsTerminalDiagnostics,
|
||||
messageNeedsTraceHydration,
|
||||
messageNeedsTraceDetailRead,
|
||||
messageStatusPatchForTerminalMerge,
|
||||
terminalAuthorityMessageFromTurnResult,
|
||||
terminalMessagePatchFromTurnResult,
|
||||
@@ -72,7 +72,7 @@ import { useWorkbenchColadaMutations } from "./workbench-colada-mutations";
|
||||
import { useWorkbenchColadaQueries } from "./workbench-colada-queries";
|
||||
import { useWorkbenchColadaReducer } from "./workbench-colada-reducer";
|
||||
import { projectionMergeCommitSummary, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey, type TraceEventsReadRangeRecord } from "./workbench-session-messages-read-budget";
|
||||
import { terminalSealResultWithoutTraceEvents, traceHydrationProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-hydration";
|
||||
import { terminalSealResultWithoutTraceEvents, traceDetailProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-detail";
|
||||
|
||||
const WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL = "hwlab.workbench.sessionProjection.v1";
|
||||
const WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY = "hwlab.workbench.sessionProjectionSignal.v1";
|
||||
@@ -111,7 +111,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const workbenchColadaQueries = useWorkbenchColadaQueries();
|
||||
const workbenchColadaMutations = useWorkbenchColadaMutations();
|
||||
const turnStatusReadSingleflight = createKeyedSingleflight<ApiResult<AgentChatResultResponse>>();
|
||||
const traceHydrationSingleflight = createKeyedSingleflight<void>();
|
||||
const traceDetailReadSingleflight = createKeyedSingleflight<void>();
|
||||
const sessionMessagesReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchMessagePageResponse>>();
|
||||
const sessionDetailReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchSessionDetailResponse>>();
|
||||
const traceEventsReadRanges = new Map<string, TraceEventsReadRangeRecord>();
|
||||
@@ -479,7 +479,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (!id) return false;
|
||||
const ownerSessionId = normalizeWorkbenchSessionId(sessionId) ?? traceOwnerSessionId(id, null);
|
||||
const source = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value;
|
||||
return !shouldHydrateTerminalTraceFromRest({ traceId: id, messages: source, turnStatusAuthority: turnStatusAuthority.value });
|
||||
return !shouldReadTerminalTraceDetail({ traceId: id, messages: source, turnStatusAuthority: turnStatusAuthority.value });
|
||||
}
|
||||
|
||||
function appendActiveMessages(...items: ChatMessage[]): void {
|
||||
@@ -513,7 +513,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
function fetchWorkbenchTraceEvents(traceId: string, afterProjectedSeq: number, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
|
||||
const activitySource = useActivityTimeout ? () => activityRef.value : null;
|
||||
return workbenchColadaQueries.fetchTraceEvents(traceId, { timeoutMs: runtimePolicy.workbenchTraceEventsTimeoutMs, activityRef: activitySource, afterProjectedSeq, limit: runtimePolicy.traceHydrationPageLimit, minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force });
|
||||
return workbenchColadaQueries.fetchTraceEvents(traceId, { timeoutMs: runtimePolicy.workbenchTraceEventsTimeoutMs, activityRef: activitySource, afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force });
|
||||
}
|
||||
|
||||
function shouldUseActivityTimeoutForTrace(traceId: string | null | undefined): boolean {
|
||||
@@ -543,7 +543,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const merged = mergeMessageProjectionPage(id, pageMessages, { traceId, limit: traceMessageProjectionWindowLimit() });
|
||||
commitMergedSessionMessages(id, pageMessages, merged, { traceId, limit: traceMessageProjectionWindowLimit(), reason: "trace-message-page" });
|
||||
await hydrateTurnStatusAuthority(merged, { traceId, limit: 1, reason: "trace-message-page" });
|
||||
if (!traceProjectionIsTerminalSealed(traceId, merged)) hydrateTerminalTraceGaps(merged, `trace-message-page:${traceId}`);
|
||||
if (!traceProjectionIsTerminalSealed(traceId, merged)) readTerminalTraceDetailGaps(merged, `trace-message-page:${traceId}`);
|
||||
}
|
||||
|
||||
function fetchSessionMessagesPage(sessionId: string, options: SessionMessagesReadOptions): Promise<ApiResult<WorkbenchMessagePageResponse>> {
|
||||
@@ -561,7 +561,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function traceMessageProjectionWindowLimit(): number {
|
||||
return boundedProjectionMessageLimit(runtimePolicy.workbenchTraceMessagesWindowLimit, runtimePolicy.traceHydrationAutoQueueLimit);
|
||||
return boundedProjectionMessageLimit(runtimePolicy.workbenchTraceMessagesWindowLimit, runtimePolicy.traceDetailAutoQueueLimit);
|
||||
}
|
||||
|
||||
function mergeMessageProjectionPage(sessionId: string, pageMessages: ChatMessage[], options: { traceId?: string | null; limit: number }): ChatMessage[] {
|
||||
@@ -803,7 +803,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
threadId,
|
||||
status: "running"
|
||||
};
|
||||
void hydrateTraceEvents(messages.value);
|
||||
void readTraceEventsForMessages(messages.value);
|
||||
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
||||
restartRealtime("steer");
|
||||
return true;
|
||||
@@ -865,57 +865,57 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
await submitMessage(retryInput);
|
||||
}
|
||||
|
||||
async function hydrateTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
async function readTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
if (options.force !== true) {
|
||||
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-hydration", valuesRedacted: true } });
|
||||
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-detail-read", valuesRedacted: true } });
|
||||
return;
|
||||
}
|
||||
await hydrateTraceEventsForMessageNow(message, options);
|
||||
await readTraceEventsForMessageNow(message, options);
|
||||
}
|
||||
|
||||
async function hydrateTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
async function readTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) {
|
||||
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: message.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_trace_skip", source: "trace-hydration", valuesRedacted: true } });
|
||||
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: message.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_trace_skip", source: "trace-detail-read", valuesRedacted: true } });
|
||||
return;
|
||||
}
|
||||
await traceHydrationSingleflight.run(traceId, async () => {
|
||||
await traceDetailReadSingleflight.run(traceId, async () => {
|
||||
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
|
||||
await hydrateTraceEventsForExplicitDetailPages(message, options);
|
||||
}, { reason: options.force ? "force-trace-hydration" : "trace-hydration" });
|
||||
await readTraceEventsForExplicitDetailPages(message, options);
|
||||
}, { reason: options.force ? "force-trace-detail-read" : "trace-detail-read" });
|
||||
}
|
||||
|
||||
async function hydrateTraceEventsForExplicitDetailPages(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
async function readTraceEventsForExplicitDetailPages(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
let afterProjectedSeq = traceHydrationProjectedSeq(traceAuthorityById.value[traceId] ?? message.runnerTrace);
|
||||
for (let page = 0; page < runtimePolicy.traceHydrationMaxPages; page += 1) {
|
||||
let afterProjectedSeq = traceDetailProjectedSeq(traceAuthorityById.value[traceId] ?? message.runnerTrace);
|
||||
for (let page = 0; page < runtimePolicy.traceDetailMaxPages; page += 1) {
|
||||
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
|
||||
const decision = traceEventsHydrationReadDecision(traceId, afterProjectedSeq, message, options);
|
||||
const decision = traceEventsDetailReadDecision(traceId, afterProjectedSeq, message, options);
|
||||
if (!decision.read) {
|
||||
recordTraceEventsHydrationSkip(traceId, message, decision.reason, afterProjectedSeq);
|
||||
recordTraceEventsDetailReadSkip(traceId, message, decision.reason, afterProjectedSeq);
|
||||
return;
|
||||
}
|
||||
const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq, { force: options.force });
|
||||
const result = await fetchTraceDetailEventsPage(traceId, afterProjectedSeq, { force: options.force });
|
||||
if (!result.ok || !result.data) {
|
||||
if (shouldSuppressTransientWorkbenchReadFailure(result)) return;
|
||||
if (messageHasTerminalResponse(message)) return;
|
||||
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
|
||||
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_detail_read_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
|
||||
return;
|
||||
}
|
||||
applyTraceHydrationResult(traceId, result.data);
|
||||
applyTraceDetailEventsResult(traceId, result.data);
|
||||
const nextProjectedSeq = traceNextProjectedSeq(result.data, afterProjectedSeq);
|
||||
rememberTraceEventsHydrationRead(traceId, afterProjectedSeq, nextProjectedSeq, result.data);
|
||||
rememberTraceEventsDetailRead(traceId, afterProjectedSeq, nextProjectedSeq, result.data);
|
||||
if (result.data.hasMore !== true || nextProjectedSeq <= afterProjectedSeq) return;
|
||||
afterProjectedSeq = nextProjectedSeq;
|
||||
}
|
||||
}
|
||||
|
||||
function traceEventsHydrationReadDecision(traceId: string, afterProjectedSeq: number, message: ChatMessage, options: { force?: boolean }): { read: boolean; reason: string } {
|
||||
const limit = runtimePolicy.traceHydrationPageLimit;
|
||||
function traceEventsDetailReadDecision(traceId: string, afterProjectedSeq: number, message: ChatMessage, options: { force?: boolean }): { read: boolean; reason: string } {
|
||||
const limit = runtimePolicy.traceDetailPageLimit;
|
||||
const key = workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit });
|
||||
return traceEventsAutoReadDecision({
|
||||
traceId,
|
||||
@@ -927,46 +927,46 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function rememberTraceEventsHydrationRead(traceId: string, afterProjectedSeq: number, nextProjectedSeq: number, result: AgentChatResultResponse): void {
|
||||
const limit = runtimePolicy.traceHydrationPageLimit;
|
||||
function rememberTraceEventsDetailRead(traceId: string, afterProjectedSeq: number, nextProjectedSeq: number, result: AgentChatResultResponse): void {
|
||||
const limit = runtimePolicy.traceDetailPageLimit;
|
||||
traceEventsReadRanges.set(workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit }), { traceId, afterProjectedSeq, limit, nextProjectedSeq, hasMore: result.hasMore === true });
|
||||
}
|
||||
|
||||
function recordTraceEventsHydrationSkip(traceId: string, message: ChatMessage, reason: string, afterProjectedSeq: number): void {
|
||||
function recordTraceEventsDetailReadSkip(traceId: string, message: ChatMessage, reason: string, afterProjectedSeq: number): void {
|
||||
recordWorkbenchRuntimeDiagnostic({
|
||||
module: "workbench-trace-events-read",
|
||||
sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null,
|
||||
traceId,
|
||||
outcome: "ok",
|
||||
diagnostic: { code: "trace_events_auto_read_skip", reason, source: "trace-hydration", afterProjectedSeq, limit: runtimePolicy.traceHydrationPageLimit, valuesRedacted: true }
|
||||
diagnostic: { code: "trace_events_auto_read_skip", reason, source: "trace-detail-read", afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, valuesRedacted: true }
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTraceHydrationPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
|
||||
async function fetchTraceDetailEventsPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
|
||||
return fetchWorkbenchTraceEvents(traceId, afterProjectedSeq, shouldUseActivityTimeoutForTrace(traceId), { force: options.force });
|
||||
}
|
||||
|
||||
function hydrateTerminalTraceGaps(source: ChatMessage[], reason: string): void {
|
||||
function readTerminalTraceDetailGaps(source: ChatMessage[], reason: string): void {
|
||||
// Terminal trace rows are loaded on explicit force/detail paths. Auto-filling
|
||||
// historical terminal traces makes multi-turn Workbench sessions main-thread bound.
|
||||
void source;
|
||||
void reason;
|
||||
}
|
||||
|
||||
async function hydrateTraceEvents(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise<void> {
|
||||
const candidates = traceHydrationCandidates(source);
|
||||
async function readTraceEventsForMessages(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise<void> {
|
||||
const candidates = traceDetailReadCandidates(source);
|
||||
if (options.force !== true) {
|
||||
if (candidates.length > 0) {
|
||||
const traceId = firstNonEmptyString(candidates[0]?.traceId, candidates[0]?.runnerTrace?.traceId);
|
||||
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: candidates[0]?.sessionId ?? candidates[0]?.runnerTrace?.sessionId ?? null, traceId: traceId ?? null, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-hydration", candidateCount: candidates.length, valuesRedacted: true } });
|
||||
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: candidates[0]?.sessionId ?? candidates[0]?.runnerTrace?.sessionId ?? null, traceId: traceId ?? null, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "sync-replay-authority", source: "trace-detail-read", candidateCount: candidates.length, valuesRedacted: true } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const message of candidates) void hydrateTraceEventsForMessage(message, { force: true });
|
||||
for (const message of candidates) void readTraceEventsForMessage(message, { force: true });
|
||||
}
|
||||
|
||||
function traceHydrationCandidates(source: ChatMessage[]): ChatMessage[] {
|
||||
return source.filter(messageNeedsTraceHydration).slice(-runtimePolicy.traceHydrationAutoQueueLimit).reverse();
|
||||
function traceDetailReadCandidates(source: ChatMessage[]): ChatMessage[] {
|
||||
return source.filter(messageNeedsTraceDetailRead).slice(-runtimePolicy.traceDetailAutoQueueLimit).reverse();
|
||||
}
|
||||
|
||||
function messagesWithTraceAuthority(source: ChatMessage[]): ChatMessage[] {
|
||||
@@ -989,7 +989,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
reduceServerState({ type: "trace.snapshot", traceId, trace: nextTrace });
|
||||
}
|
||||
|
||||
function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void {
|
||||
function applyTraceDetailEventsResult(traceId: string, result: AgentChatResultResponse): void {
|
||||
const authoritySessionId = traceResultSessionId(result);
|
||||
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
|
||||
if (!ownerSessionId) return;
|
||||
@@ -1000,7 +1000,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (turnResultIsTerminalForMerge(result)) {
|
||||
const terminalSeal = terminalSealResultWithoutTraceEvents(result);
|
||||
rememberTurnStatus(traceId, terminalSeal);
|
||||
projectTurnAuthorityToMessages(traceId, terminalSeal, "trace-hydration-terminal-seal");
|
||||
projectTurnAuthorityToMessages(traceId, terminalSeal, "trace-detail-read-terminal-seal");
|
||||
}
|
||||
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
|
||||
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
|
||||
@@ -1050,15 +1050,15 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}));
|
||||
markWorkbenchTraceProjected(traceId);
|
||||
if (traceResultHasTerminalEvidence(result) && !turnResultIsTerminalForMerge(result)) {
|
||||
void refreshTerminalTraceFromSyncReplay(traceId, "trace-hydration-terminal-evidence");
|
||||
void refreshTerminalTraceFromSyncReplay(traceId, "trace-detail-read-terminal-evidence");
|
||||
}
|
||||
if (turnResultIsTerminalForMerge(result)) {
|
||||
rememberTurnStatus(traceId, result);
|
||||
if (ownerSessionId === activeSessionId.value) {
|
||||
chatPending.value = false;
|
||||
currentRequest.value = null;
|
||||
void clearActiveTrace(traceId, "trace-hydration-terminal");
|
||||
restartRealtime("trace-hydration-terminal");
|
||||
void clearActiveTrace(traceId, "trace-detail-read-terminal");
|
||||
restartRealtime("trace-detail-read-terminal");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1477,7 +1477,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const sessionId = normalizeWorkbenchSessionId(record.sessionId);
|
||||
if (!sessionId || sessionId !== activeSessionId.value) return;
|
||||
const traceId = firstNonEmptyString(record.traceId);
|
||||
if (traceId && messages.value.some((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === traceId)) return;
|
||||
void refreshRealtimeSessionFromSyncReplay(sessionId, `cross-tab-session-projection:${firstNonEmptyString(record.reason, traceId, "session") ?? "session"}`);
|
||||
}
|
||||
|
||||
@@ -1537,7 +1536,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (options.forceRead) void refreshMessageProjectionForTrace(ownerSessionId, traceId, { force: true });
|
||||
else scheduleActiveTraceSyncReplay(traceId, "complete-trace-sync-replay", 0);
|
||||
}
|
||||
if (options.forceRead && terminalMessage && !traceProjectionIsTerminalSealed(traceId, ownerMessages)) void hydrateTraceEventsForMessage(terminalMessage, { force: true });
|
||||
if (options.forceRead && terminalMessage && !traceProjectionIsTerminalSealed(traceId, ownerMessages)) void readTraceEventsForMessage(terminalMessage, { force: true });
|
||||
if (ownerSessionId === activeSessionId.value) {
|
||||
chatPending.value = false;
|
||||
currentRequest.value = null;
|
||||
@@ -1550,7 +1549,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
|
||||
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
|
||||
void targets;
|
||||
void hydrateTraceEvents(messages.value);
|
||||
void readTraceEventsForMessages(messages.value);
|
||||
}
|
||||
|
||||
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
|
||||
@@ -1572,7 +1571,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null);
|
||||
return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
||||
}));
|
||||
void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
|
||||
void readTraceEventsForMessages(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
|
||||
scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
||||
}
|
||||
|
||||
@@ -1649,7 +1648,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
hasMore: false,
|
||||
truncated: false,
|
||||
nextProjectedSeq: 1,
|
||||
range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: runtimePolicy.traceHydrationPageLimit, returned: 1, total: 1 },
|
||||
range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: runtimePolicy.traceDetailPageLimit, returned: 1, total: 1 },
|
||||
lastEventLabel: "workbench:admission:failed",
|
||||
error: input.error ?? undefined,
|
||||
projection: input.projection,
|
||||
@@ -1832,7 +1831,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
currentRequest.value = null;
|
||||
void hydrateTurnStatusAuthority(messages.value);
|
||||
void hydrateTerminalMessageDiagnostics();
|
||||
void hydrateTraceEvents(messages.value);
|
||||
void readTraceEventsForMessages(messages.value);
|
||||
reattachRestoredActiveTrace();
|
||||
restartRealtime("apply-selected-session");
|
||||
}
|
||||
@@ -1891,7 +1890,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function reattachRestoredActiveTrace(): void {
|
||||
void hydrateTraceEvents(messages.value);
|
||||
void readTraceEventsForMessages(messages.value);
|
||||
const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
|
||||
if (traceId) reattachTrace(traceId);
|
||||
}
|
||||
|
||||
+16
@@ -16,14 +16,30 @@ declare global {
|
||||
runtimePolicy?: {
|
||||
defaultCodeAgentTimeoutMs?: number;
|
||||
defaultGatewayTimeoutMs?: number;
|
||||
traceDetailPageLimit?: number;
|
||||
traceDetailMaxPages?: number;
|
||||
traceDetailMaxAttempts?: number;
|
||||
traceDetailRetryDelayMs?: number;
|
||||
traceDetailAutoQueueLimit?: number;
|
||||
traceDetailBackgroundConcurrency?: number;
|
||||
traceDetailBackgroundDelayMs?: number;
|
||||
/** @deprecated Use traceDetailPageLimit. */
|
||||
traceHydrationPageLimit?: number;
|
||||
/** @deprecated Use traceDetailMaxPages. */
|
||||
traceHydrationMaxPages?: number;
|
||||
/** @deprecated Use traceDetailMaxAttempts. */
|
||||
traceHydrationMaxAttempts?: number;
|
||||
/** @deprecated Use traceDetailRetryDelayMs. */
|
||||
traceHydrationRetryDelayMs?: number;
|
||||
/** @deprecated Use traceDetailAutoQueueLimit. */
|
||||
traceHydrationAutoQueueLimit?: number;
|
||||
/** @deprecated Use traceDetailBackgroundConcurrency. */
|
||||
traceHydrationBackgroundConcurrency?: number;
|
||||
/** @deprecated Use traceDetailBackgroundDelayMs. */
|
||||
traceHydrationBackgroundDelayMs?: number;
|
||||
sessionListPageLimit?: number;
|
||||
workbenchDetailReadConcurrency?: number;
|
||||
/** @deprecated Use workbenchDetailReadConcurrency. */
|
||||
workbenchReadHydrationConcurrency?: number;
|
||||
workbenchReadFailureCooldownMs?: number;
|
||||
workbenchTurnStatusMinRefreshMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user