feat: 完善 CaseRun 原生证据视图
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-18 06:01:58 +02:00
parent e64eb95cd4
commit 2bc5fc653a
7 changed files with 255 additions and 47 deletions
@@ -15,9 +15,13 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () =>
const running = readNativeRun(started!.runId);
assert.equal(running?.status, "running");
assert.equal(running?.terminal, false);
for (let index = 0; index < 3; index += 1) readNativeRun(started!.runId);
const completed = readNativeRun(started!.runId);
assert.equal(completed?.status, "completed");
assert.equal(completed?.terminal, true);
assert.equal(completed?.workflowId, `harnessrl-caserun-${started!.runId}`);
assert.equal(completed?.result.summary.artifactCount, 1);
assert.equal(completed?.result.summary.artifacts[0].path, "native-result.json");
assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/);
const aggregate = readNativeAggregate(started!.runId);
@@ -27,7 +31,8 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () =>
assert.equal(aggregate?.sha256, completed?.references.aggregate.sha256);
const events = readNativeEvents(started!.runId);
assert.deepEqual(events?.events.map((event) => event.status), ["queued", "running", "completed"]);
assert.deepEqual(events?.events.map((event) => event.stage), ["queued", "prepare", "prepared", "build-completed", "collect-completed", "completed"]);
assert.equal(events?.events[0]?.createdAt, events?.events[0]?.at);
assert.equal(events?.mode, "native-test");
});
@@ -23,7 +23,14 @@ const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z");
const scenarios: Record<string, { title: string; steps: NativeStep[] }> = {
"native-queued": { title: "原生排队中", steps: [{ status: "queued", stage: "queued", terminal: false }] },
"native-running": { title: "原生运行中", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] },
"native-completed": { title: "原生已完成", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] },
"native-completed": { title: "原生已完成", steps: [
{ status: "queued", stage: "queued", terminal: false },
{ status: "running", stage: "prepare", terminal: false },
{ status: "running", stage: "prepared", terminal: false },
{ status: "running", stage: "build-completed", terminal: false },
{ status: "running", stage: "collect-completed", terminal: false },
{ status: "completed", stage: "completed", terminal: true }
] },
"native-failed": { title: "原生失败", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "原生构建按确定性场景失败", layer: "build" } }] },
"native-blocked": { title: "原生受阻", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "原生能力按确定性场景受阻", layer: "hwpod" } }] },
"native-canceled": { title: "原生已取消", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] }
@@ -38,15 +45,23 @@ function timestamp(index: number): string {
function aggregateResponse(run: NativeRunState) {
const step = run.steps[run.cursor] ?? run.steps.at(-1)!;
const artifact = {
path: "native-result.json",
bytes: 335,
sha256: createHash("sha256").update(`${run.runId}:native-result.json`).digest("hex")
};
const summary = {
mode: MODE,
status: step.status,
jobId: `job-${run.runId}`,
artifactCount: step.status === "completed" ? 2 : 0
artifacts: step.status === "completed" ? [artifact] : [],
artifactCount: step.status === "completed" ? 1 : 0
};
const evidence = {
mode: MODE,
status: step.status,
blocker: step.blocker ?? null,
artifacts: step.status === "completed" ? [{ name: "firmware.bin" }, { name: "caserun.log" }] : []
artifacts: step.status === "completed" ? [artifact] : []
};
const archivedRun = {
runId: run.runId,
@@ -54,7 +69,8 @@ function aggregateResponse(run: NativeRunState) {
status: step.status,
stage: step.stage,
terminal: step.terminal,
mode: MODE
mode: MODE,
hardwareRequired: false
};
const source = { runId: run.runId, caseId: run.caseId, status: step.status, summary, evidence, run: archivedRun };
return {
@@ -68,6 +84,7 @@ function aggregateResponse(run: NativeRunState) {
terminal: step.terminal,
sourceAuthority: { mode: MODE, sequence: "request-driven" },
sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"),
result: { run: archivedRun, summary, evidence },
summary,
evidence,
run: archivedRun
@@ -82,6 +99,8 @@ function runResponse(run: NativeRunState) {
contractVersion: CONTRACT_VERSION,
runId: run.runId,
caseId: run.caseId,
workflowId: `harnessrl-caserun-${run.runId}`,
workflowRunId: `native-workflow-${run.runId}`,
status: step.status,
stage: step.stage,
terminal: step.terminal,
@@ -90,9 +109,10 @@ function runResponse(run: NativeRunState) {
updatedAt: timestamp(run.cursor),
facts: {
jobId: `job-${run.runId}`,
artifactCount: step.status === "completed" ? 2 : 0,
artifactCount: step.status === "completed" ? 1 : 0,
evidenceStatus: step.status
},
result: aggregateResponse(run).result,
references: {
status: { href: `/v1/caserun/runs/${run.runId}` },
events: { href: `/v1/caserun/runs/${run.runId}/events`, count: run.cursor + 1 },
@@ -142,7 +162,9 @@ export function readNativeEvents(runId: string) {
if (!run) return null;
const current = runResponse(run);
const events = run.steps.slice(0, run.cursor + 1).map((step, index) => ({
id: index + 1,
at: timestamp(index),
createdAt: timestamp(index),
status: step.status,
stage: step.stage,
payload: { mode: MODE, terminal: step.terminal, blocker: step.blocker ?? null }
@@ -9,23 +9,29 @@ export default async function caserunNativeSmoke({ page, goto, wait, screenshot,
await page.locator("#caserun-refresh").click();
await wait(300);
const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || "");
const mode = (await page.locator("#caserun-mode").textContent())?.trim() || "";
const terminal = (await page.locator("#caserun-terminal").textContent())?.trim() || "";
const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || "";
const events = await page.locator("#caserun-event-text").textContent();
const aggregateStatus = (await page.locator("#caserun-aggregate-status").textContent())?.trim() || "";
const aggregateRunId = (await page.locator("#caserun-aggregate-run-id").textContent())?.trim() || "";
const aggregateSha = (await page.locator("#caserun-aggregate-sha").textContent())?.trim() || "";
const evidenceStatus = (await page.locator("#caserun-evidence-status").textContent())?.trim() || "";
await page.getByRole("tab", { name: "产物" }).click();
const artifactText = (await page.locator(".caserun-artifact-row").textContent()) || "";
await page.getByRole("tab", { name: "编排" }).click();
const workflowId = (await page.locator("#caserun-workflow-id").textContent())?.trim() || "";
await page.getByRole("tab", { name: "结果" }).click();
const conditions = {
mode: mode.includes("native-test"),
terminal: terminal === "是",
aggregateStatus: aggregateStatus === "已完成",
aggregateRunId: aggregateRunId === runId && runId.startsWith("native-completed-"),
aggregateSha: /^[a-f0-9]{64}$/.test(aggregateSha),
queuedEvent: events?.includes("queued") === true,
runningEvent: events?.includes("running") === true,
completedEvent: events?.includes("completed") === true
evidenceStatus: evidenceStatus === "已完成",
artifact: artifactText.includes("native-result.json") && artifactText.includes("335 B"),
workflow: workflowId === `harnessrl-caserun-${runId}`,
queuedEvent: events?.includes("进入队列") === true,
buildEvent: events?.includes("构建完成") === true,
completedEvent: events?.includes("运行完成") === true
};
const image = await screenshot("caserun-native-completed.png");
recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions });
+3 -3
View File
@@ -19,7 +19,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
error.value = null;
const result = await caserunAPI.cases();
cases.value = result.data?.cases ?? [];
error.value = result.ok ? null : result.error ?? "CaseRun cases unavailable";
error.value = result.ok ? null : result.error ?? "无法读取 CaseRun 用例";
loading.value = false;
}
@@ -31,7 +31,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
const result = await caserunAPI.startRun(caseId);
starting.value = false;
if (!result.ok || !result.data) {
error.value = result.error ?? "CaseRun start failed";
error.value = result.error ?? "CaseRun 启动失败";
return null;
}
currentRun.value = result.data;
@@ -43,7 +43,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
if (!runId) return;
const result = await caserunAPI.run(runId);
if (!result.ok || !result.data) {
error.value = result.error ?? "CaseRun run unavailable";
error.value = result.error ?? "无法读取 CaseRun 运行";
return;
}
currentRun.value = result.data;
+5 -3
View File
@@ -517,12 +517,14 @@ export interface HwlabNodeUpdateMetadata {
}
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunEvent { id?: number; at?: string; createdAt?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record<string, unknown> | null }
export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown }
export interface CaseRunArtifact { path?: string; name?: string; bytes?: number; sha256?: string; [key: string]: unknown }
export interface CaseRunResult { run?: { mode?: string; hardwareRequired?: boolean; [key: string]: unknown } | null; summary?: { mode?: string; status?: string; artifactCount?: number; artifacts?: CaseRunArtifact[]; [key: string]: unknown } | null; evidence?: { mode?: string; status?: string; artifacts?: CaseRunArtifact[]; [key: string]: unknown } | null; [key: string]: unknown }
export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; workflowId?: string; workflowRunId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; caseRepo?: string; runDir?: string; result?: CaseRunResult | null; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown }
export interface CaseRunEventsResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; events?: CaseRunEvent[]; [key: string]: unknown }
export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; sha256?: string; summary?: Record<string, unknown> | null; evidence?: Record<string, unknown> | null; run?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; sha256?: string | null; result?: CaseRunResult | null; summary?: CaseRunResult["summary"]; evidence?: CaseRunResult["evidence"]; run?: CaseRunResult["run"]; [key: string]: unknown }
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfileCatalogItem {
profile: string;
@@ -6,10 +6,14 @@ import {
Activity,
Archive,
Check,
CheckCircle2,
Clock3,
Clipboard,
Cpu,
ExternalLink,
FileCheck2,
FileCode2,
GitBranch,
RefreshCw,
ShieldAlert,
TerminalSquare,
@@ -19,17 +23,17 @@ import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import StatusStrip, { type ConsoleStatusItem } from "@/components/layout/StatusStrip.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import { useCaseRunStore } from "@/stores/caserun";
import type { CaseRunCaseSummary, CaseRunEvent } from "@/types";
import type { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent } from "@/types";
const route = useRoute();
const router = useRouter();
const caserun = useCaseRunStore();
const selectedCaseId = ref("");
type EvidenceTab = "aggregate" | "artifacts" | "trace" | "hwpod";
type EvidenceTab = "aggregate" | "artifacts" | "workflow" | "hwpod";
const evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [
{ id: "aggregate", label: "汇总" },
{ id: "aggregate", label: "结果" },
{ id: "artifacts", label: "产物" },
{ id: "trace", label: "链路" },
{ id: "workflow", label: "编排" },
{ id: "hwpod", label: "HWPOD" },
];
const selectedTab = ref<EvidenceTab>("aggregate");
@@ -46,25 +50,28 @@ const currentEvents = computed(() => caserun.events ?? []);
const currentBlocker = computed(() => currentRun.value?.blocker ?? null);
const aggregate = computed(() => caserun.aggregate);
const terminal = computed(() => currentRun.value?.terminal === true);
const resultSummary = computed(() => aggregate.value?.result?.summary ?? aggregate.value?.summary ?? currentRun.value?.result?.summary ?? null);
const resultEvidence = computed(() => aggregate.value?.result?.evidence ?? aggregate.value?.evidence ?? currentRun.value?.result?.evidence ?? null);
const resultRun = computed(() => aggregate.value?.result?.run ?? aggregate.value?.run ?? currentRun.value?.result?.run ?? null);
const artifacts = computed<CaseRunArtifact[]>(() => resultSummary.value?.artifacts ?? resultEvidence.value?.artifacts ?? []);
const artifactCount = computed(() => resultSummary.value?.artifactCount ?? currentRun.value?.facts?.artifactCount ?? artifacts.value.length);
const evidenceStatus = computed(() => String(resultEvidence.value?.status ?? currentRun.value?.facts?.evidenceStatus ?? "pending"));
const duration = computed(() => formatDuration(currentRun.value?.createdAt, currentRun.value?.updatedAt));
const statusTone = computed(() => {
if (currentStatusCode.value === "completed") return "ok";
if (["failed", "blocked"].includes(currentStatusCode.value)) return "error";
if (["queued", "running", "cancel_requested"].includes(currentStatusCode.value)) return "running";
return "pending";
});
const fixtureMode = computed(() => String(aggregate.value?.mode ?? currentRun.value?.mode ?? selectedCase.value?.mode ?? "live"));
const fixtureMode = computed(() => String(resultSummary.value?.mode ?? resultRun.value?.mode ?? aggregate.value?.mode ?? currentRun.value?.mode ?? selectedCase.value?.mode ?? "live"));
const fixtureModeLabel = computed(() => modeLabel(fixtureMode.value));
const stages = ["queued", "preparing", "agent-running", "building", "downloading", "uart-reading", "aggregating", "completed"];
const stageIndex = computed(() => {
const index = stages.indexOf(String(currentStageCode.value));
return index >= 0 ? index : currentStatusCode.value === "failed" || currentStatusCode.value === "blocked" ? 2 : -1;
});
const statusItems = computed<ConsoleStatusItem[]>(() => [
{ id: "cases", label: "可用用例", value: caserun.cases.length, tone: "info" },
{ id: "status", label: "当前状态", value: currentStatus.value, tone: statusTone.value === "ok" ? "ok" : statusTone.value === "error" ? "error" : "info" },
{ id: "stage", label: "当前阶段", value: currentStage.value, tone: "neutral" },
{ id: "duration", label: "运行耗时", value: duration.value, tone: "neutral" },
{ id: "events", label: "事件", value: currentEvents.value.length, tone: "neutral" },
{ id: "artifacts", label: "产物", value: currentRun.value?.facts?.artifactCount ?? 0, tone: "neutral" },
{ id: "artifacts", label: "产物", value: artifactCount.value, tone: "neutral" },
{ id: "evidence", label: "证据", value: statusLabel(evidenceStatus.value), tone: evidenceStatus.value === "recorded" ? "ok" : "neutral" },
]);
onMounted(async () => {
@@ -72,7 +79,10 @@ onMounted(async () => {
selectedTab.value = parseTab(route.query.tab);
await caserun.refreshCases();
if (!selectedCaseId.value) selectedCaseId.value = caserun.cases[0]?.caseId ?? "";
if (runId.value) await caserun.refreshRun(runId.value);
if (runId.value) {
await caserun.refreshRun(runId.value);
if (caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId;
}
pollTimer = setInterval(() => {
if (caserun.shouldPoll) void caserun.refreshRun();
}, 5000);
@@ -89,7 +99,7 @@ watch(() => route.params.runId, async (value) => {
watch(() => route.query.tab, (value) => { selectedTab.value = parseTab(value); });
function parseTab(value: unknown): EvidenceTab {
return value === "artifacts" || value === "trace" || value === "hwpod" ? value : "aggregate";
return value === "artifacts" || value === "workflow" || value === "hwpod" ? value : "aggregate";
}
async function startRun(): Promise<void> {
@@ -121,14 +131,24 @@ function eventLabel(event: CaseRunEvent): string {
return `${stageLabel(event.stage)} · ${statusLabel(event.status)}`;
}
function eventDetail(event: CaseRunEvent): string {
const payload = event.payload ?? {};
if (typeof payload.artifactSha256 === "string") return `产物 SHA256 · ${payload.artifactSha256}`;
if (typeof payload.worker === "string") return `执行者 · ${payload.worker}`;
if (typeof payload.mode === "string") return `运行模式 · ${modeLabel(payload.mode)}`;
if (typeof payload.caseId === "string") return `用例 · ${payload.caseId}`;
const entries = Object.entries(payload).filter(([, value]) => value !== null && value !== undefined && typeof value !== "object");
return entries.length > 0 ? entries.map(([key, value]) => `${key}=${String(value)}`).join(" · ") : "阶段事实已记录";
}
function statusLabel(value: unknown): string {
const code = String(value ?? "unknown");
return ({ idle: "空闲", pending: "等待中", queued: "已排队", running: "运行中", completed: "已完成", failed: "失败", blocked: "受阻", canceled: "已取消", cancelled: "已取消", cancel_requested: "正在取消", unknown: "未知" } as Record<string, string>)[code] ?? code;
return ({ idle: "空闲", pending: "等待中", queued: "已排队", running: "运行中", completed: "已完成", recorded: "已记录", failed: "失败", blocked: "受阻", canceled: "已取消", cancelled: "已取消", cancel_requested: "正在取消", unknown: "未知" } as Record<string, string>)[code] ?? code;
}
function stageLabel(value: unknown): string {
const code = String(value ?? "unknown");
return ({ "pending-selection": "等待选择用例", queued: "排队", preparing: "准备", prepared: "准备完成", "agent-running": "代理执行", building: "构建", downloading: "下载", "uart-reading": "串口读取", aggregating: "汇总", completed: "完成", failed: "失败", blocked: "受阻", canceled: "取消", cancelled: "取消", unknown: "未知阶段" } as Record<string, string>)[code] ?? code;
return ({ "pending-selection": "等待选择用例", queued: "进入队列", prepare: "准备运行", preparing: "准备运行", prepared: "准备完成", "agent-running": "代理执行", building: "构建中", "build-completed": "构建完成", downloading: "下载", "uart-reading": "串口读取", "collect-completed": "证据归集", aggregating: "结果汇总", completed: "运行完成", failed: "运行失败", blocked: "运行受阻", canceled: "取消", cancelled: "取消", unknown: "未知阶段" } as Record<string, string>)[code] ?? code;
}
function modeLabel(value: string): string {
@@ -143,6 +163,25 @@ function eventTime(value?: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toLocaleTimeString("zh-CN", { hour12: false });
}
function formatDuration(start?: string, end?: string): string {
if (!start || !end) return "-";
const elapsed = new Date(end).getTime() - new Date(start).getTime();
if (!Number.isFinite(elapsed) || elapsed < 0) return "-";
if (elapsed < 10_000) return `${(elapsed / 1000).toFixed(1)}`;
if (elapsed < 60_000) return `${Math.round(elapsed / 1000)}`;
return `${Math.floor(elapsed / 60_000)}${Math.round((elapsed % 60_000) / 1000)}`;
}
function artifactName(artifact: CaseRunArtifact): string {
return artifact.path || artifact.name || "未命名产物";
}
function artifactSize(bytes?: number): string {
if (!Number.isFinite(bytes)) return "大小未知";
if (Number(bytes) < 1024) return `${bytes} B`;
return `${(Number(bytes) / 1024).toFixed(1)} KiB`;
}
</script>
<template>
@@ -191,18 +230,23 @@ function eventTime(value?: string): string {
<p id="caserun-run-id">{{ currentRun?.runId || "启动 CaseRun 后,这里会显示稳定的运行标识。" }}</p>
</div>
<dl class="run-facts">
<div><dt>阶段</dt><dd id="caserun-status">{{ currentStage }}</dd></div>
<div><dt>终态</dt><dd id="caserun-terminal">{{ currentRun ? (currentRun.terminal ? "是" : "否") : "-" }}</dd></div>
<div><dt>作业</dt><dd>{{ currentRun?.facts?.jobId || "-" }}</dd></div>
<div><dt>产物</dt><dd>{{ currentRun?.facts?.artifactCount ?? 0 }}</dd></div>
<div><dt>模式</dt><dd>{{ modeLabel(fixtureMode) }}</dd></div>
<div><dt>耗时</dt><dd><Clock3 :size="12" aria-hidden="true" />{{ duration }}</dd></div>
<div><dt>证据</dt><dd>{{ statusLabel(evidenceStatus) }}</dd></div>
<div><dt>产物</dt><dd>{{ artifactCount }} </dd></div>
</dl>
</section>
<section class="caserun-timeline" aria-label="CaseRun 执行阶段">
<div class="section-heading"><div><span class="caserun-kicker">执行路径</span><h3>执行阶段</h3></div><span class="timeline-note">API 读取模型 · {{ currentRun?.updatedAt || "等待读取" }}</span></div>
<div class="stage-track">
<div v-for="(stage, index) in stages" :key="stage" class="stage-node" :class="{ reached: index <= stageIndex, active: index === stageIndex }"><span class="stage-dot"><Check v-if="index < stageIndex || terminal" :size="12" aria-hidden="true" /></span><span>{{ stageLabel(stage) }}</span></div>
<div class="section-heading"><div><span class="caserun-kicker">实际路径</span><h3>服务端阶段</h3></div><span class="timeline-note">{{ currentEvents.length }} 个事实节点 · {{ duration }}</span></div>
<div v-if="currentEvents.length" class="stage-track" :style="{ gridTemplateColumns: `repeat(${currentEvents.length}, minmax(96px, 1fr))` }">
<div v-for="(event, index) in currentEvents" :key="event.id ?? `${event.createdAt || event.at}-${index}`" class="stage-node reached" :class="{ active: index === currentEvents.length - 1 }">
<span class="stage-dot"><Check :size="12" aria-hidden="true" /></span>
<span>{{ stageLabel(event.stage) }}</span>
<time>{{ eventTime(event.createdAt || event.at) }}</time>
</div>
</div>
<div v-else class="timeline-empty">运行事件到达后将按 API 返回顺序显示真实执行路径</div>
<div v-if="currentBlocker" class="caserun-blocker" role="status"><ShieldAlert :size="18" aria-hidden="true" /><div><strong>{{ currentBlocker.code }}</strong><p>{{ currentBlocker.summary }}</p><small>{{ currentBlocker.layer || "runtime" }}</small></div></div>
<div v-else-if="caserun.error" class="caserun-error" role="alert"><XCircle :size="18" aria-hidden="true" /><span>{{ caserun.error }}</span></div>
</section>
@@ -211,7 +255,7 @@ function eventTime(value?: string): string {
<div class="section-heading"><div><span class="caserun-kicker">事件流</span><h3>运行事件</h3></div><span class="timeline-note">{{ currentEvents.length }} 条事件</span></div>
<div v-if="!currentEvents.length" class="event-empty">尚未读取事件启动 CaseRun 或从深链载入 run 服务端事件会显示在这里</div>
<ol v-else class="event-list" id="caserun-event-text">
<li v-for="(event, index) in currentEvents" :key="`${event.at}-${index}`"><time>{{ eventTime(event.at) }}</time><span class="event-dot" /><div><strong>{{ eventLabel(event) }}</strong><small>{{ event.payload ? JSON.stringify(event.payload) : "服务端状态事件" }}</small></div></li>
<li v-for="(event, index) in currentEvents" :key="event.id ?? `${event.createdAt || event.at}-${index}`"><time>{{ eventTime(event.createdAt || event.at) }}</time><span class="event-dot" /><div><strong>{{ eventLabel(event) }}</strong><small>{{ eventDetail(event) }}</small></div></li>
</ol>
</section>
</main>
@@ -223,12 +267,40 @@ function eventTime(value?: string): string {
</div>
<div class="evidence-body" id="caserun-aggregate">
<template v-if="selectedTab === 'aggregate'">
<div class="evidence-state"><span class="state-label">汇总状态</span><strong id="caserun-aggregate-status">{{ statusLabel(aggregate?.status || "pending") }}</strong><small id="caserun-aggregate-run-id">{{ aggregate?.runId || "等待 CaseRun" }}</small></div>
<div class="hash-row"><span>SHA256</span><button type="button" title="复制汇总 SHA" aria-label="复制汇总 SHA" @click="copy(aggregate?.sha256 || '')"><code id="caserun-aggregate-sha">{{ aggregate?.sha256 || "-" }}</code><Clipboard :size="14" aria-hidden="true" /></button></div>
<dl class="evidence-facts"><div><dt>摘要</dt><dd>{{ aggregate?.summary ? "已读取" : "-" }}</dd></div><div><dt>证据</dt><dd>{{ aggregate?.evidence ? "已读取" : "-" }}</dd></div><div><dt>事实来源</dt><dd>{{ aggregate?.sourceAuthority?.mode || currentRun?.sourceAuthority?.mode || fixtureMode }}</dd></div></dl>
<div class="result-banner" :class="{ complete: terminal }">
<CheckCircle2 :size="22" aria-hidden="true" />
<div><span class="state-label">运行结果</span><strong id="caserun-aggregate-status">{{ statusLabel(aggregate?.status || currentRun?.status || "pending") }}</strong><small id="caserun-aggregate-run-id">{{ aggregate?.runId || currentRun?.runId || "等待 CaseRun" }}</small></div>
</div>
<dl class="evidence-facts result-facts">
<div><dt>运行模式</dt><dd>{{ modeLabel(fixtureMode) }}</dd></div>
<div><dt>执行耗时</dt><dd>{{ duration }}</dd></div>
<div><dt>证据状态</dt><dd id="caserun-evidence-status">{{ statusLabel(evidenceStatus) }}</dd></div>
<div><dt>产物数量</dt><dd>{{ artifactCount }} </dd></div>
<div><dt>需要硬件</dt><dd>{{ resultRun?.hardwareRequired === false ? "否" : resultRun?.hardwareRequired === true ? "是" : "-" }}</dd></div>
</dl>
<div v-if="aggregate?.sha256" class="hash-row"><span>汇总 SHA256</span><button type="button" title="复制汇总 SHA" aria-label="复制汇总 SHA" @click="copy(aggregate.sha256 || '')"><code id="caserun-aggregate-sha">{{ aggregate.sha256 }}</code><Clipboard :size="14" aria-hidden="true" /></button></div>
</template>
<template v-else-if="selectedTab === 'artifacts'">
<div class="evidence-section-heading"><span class="state-label">产物清单</span><strong>{{ artifactCount }} </strong></div>
<div v-if="artifacts.length" class="artifact-list">
<article v-for="artifact in artifacts" :key="artifactName(artifact)" class="artifact-row caserun-artifact-row">
<FileCheck2 :size="17" aria-hidden="true" />
<div><strong>{{ artifactName(artifact) }}</strong><small>{{ artifactSize(artifact.bytes) }}</small><code>{{ artifact.sha256 || "未提供 SHA256" }}</code></div>
<button v-if="artifact.sha256" type="button" title="复制产物 SHA256" aria-label="复制产物 SHA256" @click="copy(artifact.sha256)"><Clipboard :size="14" aria-hidden="true" /></button>
</article>
</div>
<div v-else class="evidence-empty">当前运行尚未登记产物</div>
</template>
<template v-else-if="selectedTab === 'workflow'">
<div class="workflow-identity"><GitBranch :size="20" aria-hidden="true" /><div><span class="state-label">Temporal Workflow</span><strong id="caserun-workflow-id">{{ currentRun?.workflowId || "暂无 workflowId" }}</strong></div></div>
<dl class="identity-list">
<div><dt>workflowRunId</dt><dd>{{ currentRun?.workflowRunId || "-" }}</dd></div>
<div><dt>runId</dt><dd>{{ currentRun?.runId || "-" }}</dd></div>
<div><dt>创建时间</dt><dd>{{ currentRun?.createdAt || "-" }}</dd></div>
<div><dt>更新时间</dt><dd>{{ currentRun?.updatedAt || "-" }}</dd></div>
<div v-if="currentRun?.references?.trace?.traceId"><dt>traceId</dt><dd>{{ currentRun.references.trace.traceId }}</dd></div>
</dl>
</template>
<template v-else-if="selectedTab === 'artifacts'"><div class="evidence-state"><span class="state-label">产物清单</span><strong>{{ currentRun?.references?.manifest?.count ?? 0 }} 项</strong><small>{{ currentRun?.references?.manifest?.href || "暂无清单引用" }}</small></div></template>
<template v-else-if="selectedTab === 'trace'"><div class="evidence-state"><span class="state-label">链路标识</span><strong>{{ currentRun?.references?.trace?.traceId || "暂无链路" }}</strong><small>只读链路引用执行证据仍以 CaseRun API 为准</small></div></template>
<template v-else><div class="evidence-state"><span class="state-label">HWPOD 操作</span><strong>{{ currentRun?.references?.hwpod?.hwpodId || "暂无 HWPOD" }}</strong><small>节点 {{ currentRun?.references?.hwpod?.nodeId || "-" }}</small></div></template>
</div>
<div class="inspector-footer"><FileCode2 :size="15" aria-hidden="true" /><span>证据来自当前 CaseRun API不在客户端合成终态</span><ExternalLink :size="14" aria-hidden="true" /></div>