fix(workbench): 收敛 Trace 运行状态展示

This commit is contained in:
root
2026-07-21 10:31:48 +02:00
parent e099b106f6
commit b4f12c86e2
4 changed files with 17 additions and 148 deletions
@@ -229,6 +229,14 @@ describe("TraceTimeline sequence authority", () => {
expect(wrapper.text()).not.toContain("基础设施故障");
expect(wrapper.findAll('[data-row-id^="event:semantic-retry:"]')).toHaveLength(0);
const preparationRows = wrapper.findAll('[data-row-id^="event:runtime-preparation:"]');
expect(preparationRows).toHaveLength(2);
expect(preparationRows[0].attributes("data-event-seq")).toBe("5");
expect(preparationRows[0].text()).toContain("运行环境准备 · 源码获取中");
expect(preparationRows[0].text()).toContain("第 1/3 次源码获取进行中");
expect(preparationRows[1].attributes("data-event-seq")).toBe("6");
expect(preparationRows[1].text()).toContain("运行环境准备 · 源码获取完成");
expect(preparationRows[1].text()).toContain("正在启动 Runner");
wrapper.unmount();
});
@@ -75,14 +75,14 @@ describe("WorkbenchMessageCard metadata layout", () => {
expect(wrapper.find("article > header").exists()).toBe(false);
expect(footer.get(".message-activity-meta").text()).toMatch(/^\d+ $/u);
expect(footer.get(".message-duration-meta").text()).toMatch(/^ \d+ $/u);
expect(footer.get(".message-footer-timing").element.firstElementChild).toBe(footer.get(".message-activity-meta").element);
expect(footer.get(".message-footer-timing").element.lastElementChild).toBe(footer.get(".message-duration-meta").element);
expect(footer.get(".message-activity-group").element.firstElementChild).toBe(footer.get(".agent-running-indicator").element);
expect(footer.get(".message-header-actions").element.lastElementChild).toBe(footer.get(".message-duration-meta").element);
wrapper.unmount();
});
});
describe("WorkbenchMessageCard semantic retry visibility", () => {
it("shows Git mirror clone progress as runtime preparation", () => {
describe("WorkbenchMessageCard trace projection", () => {
it("does not duplicate Git source preparation outside the Trace timeline", () => {
const wrapper = mount(WorkbenchMessageCard, {
props: {
detailEnabled: false,
@@ -119,15 +119,12 @@ describe("WorkbenchMessageCard semantic retry visibility", () => {
},
global: { stubs: componentStubs() }
});
expect(wrapper.text()).toContain("运行环境准备:正在从 Git mirror 获取 Runner 源码");
expect(wrapper.text()).toContain("第 1/3 次源码获取进行中");
expect(wrapper.text()).not.toContain("基础设施故障");
expect(wrapper.get(".semantic-failure-banner").attributes("data-emphasis")).toBe("quiet");
expect(wrapper.find(".semantic-failure-banner code").exists()).toBe(false);
expect(wrapper.find(".semantic-failure-banner").exists()).toBe(false);
expect(wrapper.findComponent({ name: "TraceTimeline" }).exists()).toBe(true);
wrapper.unmount();
});
it("shows an infrastructure retry banner without opening Trace details", () => {
it("does not duplicate infrastructure retry outside the Trace timeline", () => {
const wrapper = mount(WorkbenchMessageCard, {
props: {
detailEnabled: false,
@@ -168,12 +165,8 @@ describe("WorkbenchMessageCard semantic retry visibility", () => {
}
});
const banner = wrapper.get(".semantic-failure-banner");
expect(banner.attributes("data-emphasis")).toBe("alert");
expect(banner.text()).toContain("基础设施故障:Runner 镜像仓库暂时不可达");
expect(banner.text()).toContain("第 2/5 次重试已安排");
expect(banner.text()).toMatch(/ \d+ /u);
expect(banner.text()).toContain("runner-image-registry-unreachable");
expect(wrapper.find(".semantic-failure-banner").exists()).toBe(false);
expect(wrapper.findComponent({ name: "TraceTimeline" }).exists()).toBe(true);
wrapper.unmount();
});
});
@@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage } from "@/types";
import { semanticRuntimeStatusView } from "./workbench-semantic-runtime-status";
test("long provider retry remains semantically visible while Kafka terminal authority stays open", () => {
const message = {
id: "msg_long_retry",
role: "agent",
title: "Code Agent",
text: "",
status: "running",
createdAt: "2026-07-20T19:26:00.000Z",
timing: {
startedAt: "2026-07-20T19:26:00.000Z",
lastEventAt: "2026-07-20T19:37:49.000Z",
finishedAt: null,
durationMs: null
},
runnerTrace: {
events: [{
type: "error",
failureDomain: "upstream",
failureCode: "provider-stream-disconnected",
summary: "Provider stream disconnected",
retryPhase: "retryScheduled",
retryAttempt: 2,
retryMaxAttempts: 5,
nextRetryAt: "2026-07-20T19:41:49.000Z",
createdAt: "2026-07-20T19:36:16.000Z"
}]
}
} as ChatMessage;
const view = semanticRuntimeStatusView(message, Date.parse("2026-07-20T19:43:13.000Z"));
assert.equal(view?.title, "上游故障:Provider stream disconnected");
assert.equal(view?.detail, "第 2/5 次重试等待已持续 5 分 24 秒,仍未收到新事件");
assert.equal(view?.emphasis, "alert");
assert.equal(view?.code, "provider-stream-disconnected");
assert.equal(message.status, "running", "view-only visibility must not synthesize a terminal state");
});
@@ -1,89 +0,0 @@
import type { ChatMessage, TraceEvent } from "@/types";
export interface WorkbenchSemanticRuntimeStatus {
title: string;
detail: string;
tone: string;
emphasis: "quiet" | "alert";
code: string | null;
}
export function semanticRuntimeStatusView(message: ChatMessage, currentTimeMs: number): WorkbenchSemanticRuntimeStatus | null {
const events = Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : [];
const event = [...events].reverse().find(isSemanticRuntimeEvent);
if (!event) return null;
const phase = String(event.retryPhase).toLowerCase().replace(/[^a-z]/gu, "");
const domain = event.failureDomain === "upstream" ? "上游故障" : "基础设施故障";
const summary = firstText(event.summary, event.message, event.failureCode, event.code, "执行依赖暂时不可用");
const attempt = finiteInteger(event.retryAttempt ?? event.attempt);
const maxAttempts = finiteInteger(event.retryMaxAttempts ?? event.maxAttempts);
const progress = attempt !== null && maxAttempts !== null ? `${attempt}/${maxAttempts}` : "本次";
const code = firstText(event.failureCode, event.code) || null;
if (code === "git-mirror-fetch-in-progress") {
return { title: `运行环境准备:${summary}`, detail: `${progress}源码获取进行中`, tone: "info", emphasis: "quiet", code };
}
if (code === "git-mirror-fetch-completed") {
return { title: `运行环境准备:${summary}`, detail: "源码获取完成,正在启动 Runner", tone: "recovered", emphasis: "quiet", code };
}
let detail = "正在判定有限重试";
let tone = "warning";
if (phase.includes("scheduled")) {
detail = scheduledRetryDetail(message, event, currentTimeMs, progress);
} else if (phase.includes("started")) {
detail = `${progress}重试已开始`;
} else if (phase.includes("recovered")) {
detail = "故障已恢复,继续执行";
tone = "recovered";
} else if (phase.includes("exhausted")) {
detail = "有限重试已耗尽,执行已失败";
tone = "failed";
}
return { title: `${domain}${summary}`, detail, tone, emphasis: "alert", code };
}
function scheduledRetryDetail(message: ChatMessage, event: TraceEvent, currentTimeMs: number, progress: string): string {
const nextRetryMs = timestampMs(event.nextRetryAt);
if (nextRetryMs !== null && nextRetryMs > currentTimeMs) {
return `${progress}重试已安排,预计 ${Math.max(0, Math.ceil((nextRetryMs - currentTimeMs) / 1_000))} 秒后开始`;
}
if (nextRetryMs !== null) {
const lastEventMs = timestampMs(message.timing?.lastEventAt ?? message.lastEventAt ?? event.createdAt);
if (lastEventMs !== null) {
return `${progress}重试等待已持续 ${formatDuration(Math.max(0, currentTimeMs - lastEventMs))},仍未收到新事件`;
}
}
return `${progress}重试已安排,等待上游恢复`;
}
function isSemanticRuntimeEvent(event: TraceEvent): boolean {
return typeof event.failureDomain === "string" && typeof event.retryPhase === "string";
}
function timestampMs(value: unknown): number | null {
if (typeof value !== "string" || !value.trim()) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function finiteInteger(value: unknown): number | null {
const number = Number(value);
return Number.isSafeInteger(number) && number >= 0 ? number : null;
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function formatDuration(ms: number): string {
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}${seconds % 60}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时 ${minutes % 60}`;
const days = Math.floor(hours / 24);
return `${days}${hours % 24} 小时`;
}