fix: 优化CaseRun列表与恢复状态

This commit is contained in:
root
2026-07-25 21:07:43 +02:00
parent b347a96361
commit 6569aa1cb2
7 changed files with 169 additions and 25 deletions
+11 -1
View File
@@ -89,6 +89,16 @@ export type CaseRunListItem = {
launchContext: CaseRunLaunchContext | null;
};
export type CaseRunListRecord = Pick<CaseRunRecord,
"runId" | "caseId" | "workflowId" | "workflowRunId" | "status" | "stage" |
"terminal" | "caseAuthority" | "launchContext" | "createdAt" | "updatedAt"
> & {
artifactCount?: number;
evidenceStatus?: string | null;
artifactManifestSha256?: string | null;
result?: Record<string, unknown> | null;
};
export type ActivityResult = {
runId: string;
activityName: string;
@@ -100,7 +110,7 @@ export interface HarnessRLRegistry {
ensureSchema(): Promise<void>;
createRun(input: StartCaseRunInput): Promise<CaseRunRecord>;
getRun(runId: string): Promise<CaseRunRecord | null>;
listRuns(input: ListCaseRunsInput): Promise<CaseRunRecord[]>;
listRuns(input: ListCaseRunsInput): Promise<CaseRunListRecord[]>;
updateRun(runId: string, patch: Partial<Pick<CaseRunRecord, "workflowRunId" | "status" | "stage" | "result" | "error" | "blocker">>): Promise<CaseRunRecord>;
appendEvent(runId: string, status: CaseRunStatus, stage: string, payload?: Record<string, unknown>): Promise<CaseRunEvent>;
listEvents(runId: string): Promise<CaseRunEvent[]>;
+28
View File
@@ -10,6 +10,7 @@ import { caseRunAgentFailure, caseRunBuildFailure, caseRunWorkflowFailure } from
import type { ActivityResult, CaseRunEvent, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway, StartCaseRunInput } from "./contracts.ts";
import { terminalStatus } from "./contracts.ts";
import { createHarnessRLHttpApp } from "./http.ts";
import { PostgresHarnessRLRegistry } from "./registry.ts";
import { createHarnessRLService } from "./service.ts";
import { recoverAgentTaskStage } from "../../tools/src/hwlab-caserun-runtime.ts";
@@ -115,6 +116,33 @@ test("run history lists shared registry results newest first with an opaque curs
assert.equal((await invalidResponse.json() as any).error.code, "invalid_status");
});
test("PostgreSQL run history projects list facts without loading the full result JSONB", async () => {
const queries: Array<{ text: string; values: unknown[] }> = [];
const registry = Object.create(PostgresHarnessRLRegistry.prototype) as PostgresHarnessRLRegistry;
(registry as any).ready = Promise.resolve();
(registry as any).pool = {
async query(text: string, values: unknown[]) {
queries.push({ text, values });
return { rows: [{
run_id: "projected-run", case_id: "arm2d", workflow_id: "workflow-1", workflow_run_id: "workflow-run-1",
status: "completed", stage: "completed", case_authority: { repository: "repo", ref: "main", commit: "a".repeat(40), definitionPath: "case.yaml", definitionSha256: "b".repeat(64) },
launch_context: {}, artifact_count: 3, evidence_status: "recorded", artifact_manifest_sha256: "c".repeat(64),
created_at: "2026-07-25T10:00:00.000Z", updated_at: "2026-07-25T10:01:00.000Z"
}] };
}
};
const [record] = await registry.listRuns({ limit: 31 });
assert.doesNotMatch(queries[0].text, /SELECT\s+\*/iu);
assert.doesNotMatch(queries[0].text, /\bresult\s*(?:,|FROM)/iu);
assert.match(queries[0].text, /jsonb_array_length/iu);
assert.equal(record.artifactCount, 3);
assert.equal(record.evidenceStatus, "recorded");
assert.equal(record.artifactManifestSha256, "c".repeat(64));
assert.equal("result" in record, false);
});
test("analysis read model loads the current registry revision without runtime work", async () => {
const fixture = await softwareSmokeFixture();
const registry = new MemoryRegistry();
+27 -3
View File
@@ -5,7 +5,7 @@ import pg from "pg";
import { readFile } from "node:fs/promises";
import { buildPostgresPoolConfig, postgresSslModeFromConnectionString } from "../db/runtime-store-core.ts";
import type { ActivityResult, CaseRunEvent, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, ListCaseRunsInput, StartCaseRunInput } from "./contracts.ts";
import type { ActivityResult, CaseRunEvent, CaseRunListRecord, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, ListCaseRunsInput, StartCaseRunInput } from "./contracts.ts";
import { terminalStatus } from "./contracts.ts";
const { Pool } = pg;
@@ -76,11 +76,24 @@ export class PostgresHarnessRLRegistry implements HarnessRLRegistry {
}
values.push(input.limit);
const result = await this.pool.query(
`SELECT * FROM harnessrl_runs${where.length ? ` WHERE ${where.join(" AND ")}` : ""}
`SELECT run_id,case_id,workflow_id,workflow_run_id,status,stage,case_authority,launch_context,
created_at,updated_at,
CASE
WHEN jsonb_typeof(result #> '{summary,artifactCount}') = 'number'
THEN (result #>> '{summary,artifactCount}')::integer
WHEN jsonb_typeof(result #> '{summary,artifacts}') = 'array'
THEN jsonb_array_length(result #> '{summary,artifacts}')
WHEN jsonb_typeof(result #> '{evidence,artifacts}') = 'array'
THEN jsonb_array_length(result #> '{evidence,artifacts}')
ELSE 0
END AS artifact_count,
result #>> '{evidence,status}' AS evidence_status,
result ->> 'artifactManifestSha256' AS artifact_manifest_sha256
FROM harnessrl_runs${where.length ? ` WHERE ${where.join(" AND ")}` : ""}
ORDER BY created_at DESC,run_id DESC LIMIT $${values.length}`,
values
);
return result.rows.map(runRow);
return result.rows.map(runListRow);
}
async updateRun(runId: string, patch: Partial<Pick<CaseRunRecord, "workflowRunId" | "status" | "stage" | "result" | "error" | "blocker">>) {
@@ -154,6 +167,17 @@ function runRow(row: any): CaseRunRecord {
};
}
function runListRow(row: any): CaseRunListRecord {
const status = row.status as CaseRunStatus;
return {
runId: row.run_id, caseId: row.case_id, workflowId: row.workflow_id, workflowRunId: row.workflow_run_id,
status, stage: row.stage, terminal: terminalStatus(status), caseAuthority: row.case_authority,
launchContext: launchContextValue(row.launch_context), artifactCount: Number(row.artifact_count ?? 0),
evidenceStatus: row.evidence_status ?? null, artifactManifestSha256: row.artifact_manifest_sha256 ?? null,
createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString()
};
}
function launchContextValue(value: any) {
return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null;
}
+5 -5
View File
@@ -6,7 +6,7 @@ import { Buffer } from "node:buffer";
import { mkdir, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { CaseRunLaunchContext, CaseRunListItem, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
import type { CaseRunLaunchContext, CaseRunListItem, CaseRunListRecord, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
import type { CaseAuthorityResolver } from "./case-authority.ts";
import { readCurrentCaseRunAnalysis } from "./analysis-read-model.ts";
import { readCaseRunCaptureReview } from "../../tools/src/hwlab-caserun-diagnostics.ts";
@@ -122,7 +122,7 @@ async function readCaptureReview(record: CaseRunRecord) {
}
}
function runListItem(record: CaseRunRecord): CaseRunListItem {
function runListItem(record: CaseRunListRecord): CaseRunListItem {
const summary = objectValue(record.result?.summary);
const evidence = objectValue(record.result?.evidence);
const artifacts = Array.isArray(summary?.artifacts) ? summary.artifacts : Array.isArray(evidence?.artifacts) ? evidence.artifacts : [];
@@ -136,9 +136,9 @@ function runListItem(record: CaseRunRecord): CaseRunListItem {
terminal: record.terminal,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
artifactCount: finiteNumber(summary?.artifactCount) ?? artifacts.length,
evidenceStatus: stringValue(evidence?.status),
artifactManifestSha256: stringValue(record.result?.artifactManifestSha256),
artifactCount: finiteNumber(record.artifactCount) ?? finiteNumber(summary?.artifactCount) ?? artifacts.length,
evidenceStatus: record.evidenceStatus ?? stringValue(evidence?.status),
artifactManifestSha256: record.artifactManifestSha256 ?? stringValue(record.result?.artifactManifestSha256),
caseAuthority: {
repository: record.caseAuthority.repository,
ref: record.caseAuthority.ref,
@@ -113,3 +113,63 @@ test("persistent analysis failure stays isolated from the loaded run", async ()
expect(store.analysisError).toBe("HTTP 500");
expect(store.error).toBeNull();
});
test("aggregate retries one timeout while keeping the detail region loading", async () => {
const firstAggregate = deferred<{ ok: false; status: number; error: string }>();
const secondAggregate = deferred<{ ok: true; data: { runId: string; status: string; sha256: string; captureReview: { trace: { rows: never[] } } } }>();
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "arm2d", status: "completed", stage: "completed", terminal: true } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } });
aggregateRequest
.mockImplementationOnce(() => firstAggregate.promise)
.mockImplementationOnce(() => secondAggregate.promise);
const store = useCaseRunStore();
const refreshing = store.refreshRun("run-1");
await vi.waitFor(() => expect(store.runLoading).toBe(true));
firstAggregate.resolve({ ok: false, status: 504, error: "CaseRun aggregate timed out" });
await vi.waitFor(() => expect(aggregateRequest).toHaveBeenCalledTimes(2));
expect(store.detailsLoading).toBe(true);
secondAggregate.resolve({ ok: true, data: { runId: "run-1", status: "completed", sha256: "capture-sha", captureReview: { trace: { rows: [] } } } });
await refreshing;
expect(store.aggregate?.sha256).toBe("capture-sha");
expect(store.detailsError).toBeNull();
expect(store.runLoading).toBe(false);
expect(store.detailsLoading).toBe(false);
});
test("persistent aggregate timeout is visible without clearing the loaded run", async () => {
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "arm2d", status: "completed", stage: "completed", terminal: true } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } });
aggregateRequest.mockResolvedValue({ ok: false, status: 504, error: "CaseRun aggregate timed out" });
const store = useCaseRunStore();
await store.refreshRun("run-1");
expect(aggregateRequest).toHaveBeenCalledTimes(2);
expect(store.currentRun?.runId).toBe("run-1");
expect(store.aggregate).toBeNull();
expect(store.detailsError).toBe("无法读取运行审查数据:请求超时,请重试。");
expect(store.error).toBeNull();
});
test("background polling does not replace a stable detail view with first-load state", async () => {
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "arm2d", status: "running", stage: "agent-running", terminal: false } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } });
aggregateRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", status: "running", sha256: "capture-sha" } });
const store = useCaseRunStore();
await store.refreshRun("run-1");
const pendingRun = deferred<{ ok: true; data: { runId: string; caseId: string; status: string; stage: string; terminal: boolean } }>();
runRequest.mockImplementationOnce(() => pendingRun.promise);
const polling = store.refreshRun(undefined, { foreground: false });
expect(store.runLoading).toBe(false);
pendingRun.resolve({ ok: true, data: { runId: "run-1", caseId: "arm2d", status: "running", stage: "agent-running", terminal: false } });
await polling;
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
+26 -10
View File
@@ -17,7 +17,9 @@ export const useCaseRunStore = defineStore("caserun", () => {
const starting = ref(false);
const runsLoading = ref(false);
const error = ref<string | null>(null);
const casesError = ref<string | null>(null);
const runsError = ref<string | null>(null);
const detailsError = ref<string | null>(null);
const analysisError = ref<string | null>(null);
const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? "");
const shouldPoll = computed(() => Boolean(currentRun.value?.runId && currentRun.value.terminal === false));
@@ -25,10 +27,10 @@ export const useCaseRunStore = defineStore("caserun", () => {
async function refreshCases(): Promise<void> {
loading.value = true;
error.value = null;
casesError.value = null;
const result = await caserunAPI.cases();
cases.value = result.data?.cases ?? [];
error.value = result.ok ? null : result.error ?? "无法读取 CaseRun 用例";
casesError.value = result.ok ? null : visibleRequestError(result.error, "无法读取可执行用例");
loading.value = false;
}
@@ -54,6 +56,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
events.value = [];
aggregate.value = null;
analysis.value = null;
detailsError.value = null;
const result = await caserunAPI.startRun(caseId);
starting.value = false;
if (!result.ok || !result.data) {
@@ -66,11 +69,12 @@ export const useCaseRunStore = defineStore("caserun", () => {
return result.data;
}
async function refreshRun(runId = currentRun.value?.runId ?? ""): Promise<boolean> {
async function refreshRun(runId = currentRun.value?.runId ?? "", options: { foreground?: boolean } = {}): Promise<boolean> {
if (!runId) return false;
const requestId = ++runRequestId;
const switchingRun = currentRun.value?.runId !== runId;
if (switchingRun) runLoading.value = true;
const foreground = switchingRun || options.foreground !== false;
if (foreground) runLoading.value = true;
const result = await caserunAPI.run(runId);
if (requestId !== runRequestId) return false;
if (!result.ok || !result.data) {
@@ -80,6 +84,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
events.value = [];
aggregate.value = null;
analysis.value = null;
detailsError.value = null;
}
runLoading.value = false;
return false;
@@ -89,6 +94,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
events.value = [];
aggregate.value = null;
analysis.value = null;
detailsError.value = null;
}
currentRun.value = result.data;
runLoading.value = false;
@@ -107,14 +113,24 @@ export const useCaseRunStore = defineStore("caserun", () => {
async function refreshAggregate(runId = currentRun.value?.runId ?? ""): Promise<void> {
if (!runId) return;
const result = await caserunAPI.aggregate(runId);
if (result.ok && result.data?.runId === runId && currentRun.value?.runId === runId) aggregate.value = result.data;
let result = await caserunAPI.aggregate(runId);
if (currentRun.value?.runId === runId && isTransientRequestFailure(result)) {
await new Promise((resolve) => setTimeout(resolve, 150));
if (currentRun.value?.runId !== runId) return;
result = await caserunAPI.aggregate(runId);
}
if (result.ok && result.data?.runId === runId && currentRun.value?.runId === runId) {
aggregate.value = result.data;
detailsError.value = null;
} else if (currentRun.value?.runId === runId) {
detailsError.value = visibleRequestError(result.error, "无法读取运行审查数据");
}
}
async function refreshAnalysis(runId = currentRun.value?.runId ?? ""): Promise<void> {
if (!runId) return;
let result = await caserunAPI.analysis(runId);
if (currentRun.value?.runId === runId && isTransientAnalysisFailure(result)) {
if (currentRun.value?.runId === runId && isTransientRequestFailure(result)) {
await new Promise((resolve) => setTimeout(resolve, 150));
if (currentRun.value?.runId !== runId) return;
result = await caserunAPI.analysis(runId);
@@ -134,14 +150,14 @@ export const useCaseRunStore = defineStore("caserun", () => {
return message || fallback;
}
function isTransientAnalysisFailure(result: { ok: boolean; status?: number; error?: unknown }): boolean {
function isTransientRequestFailure(result: { ok: boolean; status?: number; error?: unknown }): boolean {
return !result.ok && ([500, 502, 503, 504].includes(result.status ?? 0)
|| /socket hang up|connection terminated|econnreset|fetch failed/iu.test(String(result.error ?? "")));
|| /timeout|timed out|socket hang up|connection terminated|econnreset|fetch failed/iu.test(String(result.error ?? "")));
}
return {
cases, runs, nextRunsCursor, currentRun, events, aggregate, analysis,
loading, runLoading, detailsLoading, runsLoading, starting, error, runsError, analysisError,
loading, runLoading, detailsLoading, runsLoading, starting, error, casesError, runsError, detailsError, analysisError,
selectedCaseId, shouldPoll,
refreshCases, refreshRuns, startRun, refreshRun, refreshEvents, refreshAggregate, refreshAnalysis
};
@@ -54,6 +54,8 @@ const analysisReport = computed(() => analysis.value?.report ?? null);
const analysisEvidence = computed(() => analysisReport.value?.evidence ?? null);
const review = computed(() => analysisReport.value?.review ?? aggregate.value?.captureReview ?? null);
const reviewSource = computed<"analysis" | "capture" | null>(() => analysisReport.value?.review ? "analysis" : aggregate.value?.captureReview ? "capture" : null);
const workspaceLoading = computed(() => caserun.runLoading || caserun.detailsLoading
|| (caserun.runsLoading && !caserun.runs.length && !review.value));
const analysisScoring = computed(() => analysisReport.value?.scoring ?? null);
const analysisFindings = computed(() => analysisReport.value?.findings ?? []);
const resultSummary = computed(() => aggregate.value?.result?.summary ?? aggregate.value?.summary ?? currentRun.value?.result?.summary ?? null);
@@ -106,7 +108,7 @@ onMounted(async () => {
await router.replace({ name: "CaseRunDetail", params: { runId: latestRun.runId }, query: { case: latestRun.caseId } });
}
pollTimer = setInterval(() => {
if (caserun.shouldPoll) void caserun.refreshRun();
if (caserun.shouldPoll) void caserun.refreshRun(undefined, { foreground: false });
if (caserun.runsError) void caserun.refreshRuns();
}, 5000);
});
@@ -123,8 +125,11 @@ async function startRun(): Promise<void> {
}
async function refresh(): Promise<void> {
await Promise.all([caserun.refreshCases(), caserun.refreshRuns()]);
if (runId.value) await caserun.refreshRun(runId.value);
await Promise.all([
caserun.refreshCases(),
caserun.refreshRuns(),
runId.value ? caserun.refreshRun(runId.value) : Promise.resolve(false),
]);
}
async function selectCase(caseId: string): Promise<void> {
@@ -213,13 +218,14 @@ function artifactSize(bytes?: number): string { return Number.isFinite(bytes) ?
<code>{{ item.runId }}</code>
<small>{{ stageLabel(item.stage) }}<time>{{ historyTime(item.createdAt) }}</time></small>
</button>
<p v-if="!caserun.runs.length && !caserun.runsLoading" class="pane-empty">{{ caserun.runsError || "共享 registry 中暂无运行。" }}</p>
<p v-if="!caserun.runs.length && !caserun.runsLoading" class="pane-empty" :class="{ 'pane-error': caserun.runsError }">{{ caserun.runsError || "共享 registry 中暂无运行。" }}</p>
<button v-if="caserun.nextRunsCursor" class="load-more" type="button" :disabled="caserun.runsLoading" @click="caserun.refreshRuns({ append: true })">{{ caserun.runsLoading ? "读取中" : "加载更早运行" }}</button>
</div>
</template>
<template v-else>
<div class="case-list">
<LoadingState v-if="caserun.loading && !caserun.cases.length" class="region-loading" label="正在加载可执行用例" compact />
<p v-else-if="caserun.casesError && !caserun.cases.length" class="pane-empty pane-error" role="alert">{{ caserun.casesError }}</p>
<button v-for="item in caserun.cases" :key="item.caseId" class="case-row" :data-selected="item.caseId === selectedCaseId" type="button" @click="selectCase(item.caseId)"><Cpu :size="15" aria-hidden="true" /><span><strong>{{ item.title || item.caseId }}</strong><small>{{ item.caseId }}</small></span><Check v-if="item.available !== false" :size="14" aria-hidden="true" /></button>
</div>
<button id="caserun-start" class="start-run" type="button" :disabled="!selectedCaseId || caserun.starting" @click="startRun"><TerminalSquare :size="16" aria-hidden="true" />{{ caserun.starting ? "提交中" : "启动 CaseRun" }}</button>
@@ -236,8 +242,8 @@ function artifactSize(bytes?: number): string { return Number.isFinite(bytes) ?
<button v-if="runId" class="icon-button" type="button" title="复制 runId" aria-label="复制 runId" @click="copy(runId)"><Clipboard :size="15" aria-hidden="true" /></button>
</div>
</header>
<LoadingState v-if="caserun.runLoading" class="main-region-loading" label="正在加载所选运行" />
<CaseRunReviewPane v-else :review="review" :review-source="reviewSource" :scoring="analysisScoring" :run-id="currentRun?.runId" :metadata="overviewMetadata" :hwpod-evidence="resultEvidence?.hwpod" :loading="caserun.detailsLoading" :error="caserun.error" />
<LoadingState v-if="workspaceLoading" class="main-region-loading" :label="runId ? '正在恢复运行审查数据' : '正在加载最近运行'" />
<CaseRunReviewPane v-else :review="review" :review-source="reviewSource" :scoring="analysisScoring" :run-id="currentRun?.runId" :metadata="overviewMetadata" :hwpod-evidence="resultEvidence?.hwpod" :error="caserun.error || caserun.detailsError" />
</main>
<aside class="caserun-inspector" aria-label="CaseRun 证据检查器">