From ac412b557ca6f4f44aa94d373b74a288caee42c8 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Jul 2026 06:05:42 +0200 Subject: [PATCH] fix(workbench): converge projection signal and detail read terminology --- docs/reference/cloud-workbench.md | 12 +- web/hwlab-cloud-web/scripts/check.ts | 4 +- .../scripts/workbench-e2e-server.ts | 34 +++--- .../workbench-realtime-runtime.test.ts | 18 ++- .../workbench/ConversationPanel.vue | 6 +- .../src/config/workbench-runtime-policy.ts | 48 ++++---- ...rkbench-message-projection-runtime.test.ts | 25 +++-- .../workbench-message-projection-runtime.ts | 8 +- .../workbench-session-messages-read-budget.ts | 8 +- .../src/stores/workbench-session.test.ts | 20 ++-- .../src/stores/workbench-session.ts | 2 +- ...test.ts => workbench-trace-detail.test.ts} | 6 +- ...hydration.ts => workbench-trace-detail.ts} | 8 +- web/hwlab-cloud-web/src/stores/workbench.ts | 103 +++++++++--------- web/hwlab-cloud-web/src/types/global.d.ts | 16 +++ 15 files changed, 178 insertions(+), 140 deletions(-) rename web/hwlab-cloud-web/src/stores/{workbench-trace-hydration.test.ts => workbench-trace-detail.test.ts} (85%) rename web/hwlab-cloud-web/src/stores/{workbench-trace-hydration.ts => workbench-trace-detail.ts} (82%) diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index e62d7502..ea05a78a 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -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/` 恢复时,都必须以当前 route/active conversation id 作为会话真相,并通过同一条 conversation detail hydration 路径得到 messages、turn state、trace/status 和 markdown 渲染输入;不得另写只消费列表 snapshot、workspace stub 或 localStorage selected id 的恢复分支。 +Workbench 只能维护一条会话恢复与提交路径。首次打开、新建后继续、从左侧 session rail 切换、直接进入 `/workbench/sessions/` 恢复时,都必须以当前 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/`;用户已经点击 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`,不要在业务组件里各自手写跳转判断。 diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index e4e7e4b4..49da44b8 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.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"); diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index e98a23f1..550643f1 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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[]), diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index f19b254c..036fe4d0 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -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"); diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 8baf94b7..238e18b8 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -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; } diff --git a/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts b/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts index 7ee397de..800f16bf 100644 --- a/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts +++ b/web/hwlab-cloud-web/src/config/workbench-runtime-policy.ts @@ -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), diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts index 5c7544e4..e4ef7799 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts @@ -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>/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); }); diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts index 4cbdc87d..69f702e3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts @@ -473,7 +473,7 @@ export function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult & Pick): 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", () => { diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index cd389f79..79f58bfd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -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); diff --git a/web/hwlab-cloud-web/src/stores/workbench-trace-hydration.test.ts b/web/hwlab-cloud-web/src/stores/workbench-trace-detail.test.ts similarity index 85% rename from web/hwlab-cloud-web/src/stores/workbench-trace-hydration.test.ts rename to web/hwlab-cloud-web/src/stores/workbench-trace-detail.test.ts index c6d45b6a..d5999179 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-trace-hydration.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-trace-detail.test.ts @@ -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", () => { diff --git a/web/hwlab-cloud-web/src/stores/workbench-trace-hydration.ts b/web/hwlab-cloud-web/src/stores/workbench-trace-detail.ts similarity index 82% rename from web/hwlab-cloud-web/src/stores/workbench-trace-hydration.ts rename to web/hwlab-cloud-web/src/stores/workbench-trace-detail.ts index 4ddc0046..2e6f1884 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-trace-hydration.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-trace-detail.ts @@ -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); } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index bd7b4d5c..01c6ad3a 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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>(); - const traceHydrationSingleflight = createKeyedSingleflight(); + const traceDetailReadSingleflight = createKeyedSingleflight(); const sessionMessagesReadSingleflight = createKeyedSingleflight>(); const sessionDetailReadSingleflight = createKeyedSingleflight>(); const traceEventsReadRanges = new Map(); @@ -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> { 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> { @@ -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 { + async function readTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise { 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 { + async function readTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise { 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 { + async function readTraceEventsForExplicitDetailPages(message: ChatMessage, options: { force?: boolean } = {}): Promise { 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> { + async function fetchTraceDetailEventsPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise> { 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 { - const candidates = traceHydrationCandidates(source); + async function readTraceEventsForMessages(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise { + 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 { 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); } diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 8e3f46d4..17b5c110 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -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;