fix: 补齐 CaseRun native aggregate 合同

This commit is contained in:
root
2026-07-17 03:40:53 +02:00
parent 73bda4bf1e
commit c5ee759250
11 changed files with 147 additions and 27 deletions
+1
View File
@@ -12,6 +12,7 @@
"caserun:native:stop": "bun run scripts/caserun-native-service.ts stop",
"caserun:native:status": "bun run scripts/caserun-native-service.ts status",
"caserun:native:logs": "bun run scripts/caserun-native-service.ts logs",
"caserun:native:smoke": "bun run scripts/caserun-native-smoke.ts",
"check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts",
"check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts",
"check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict",
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { listNativeCases, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures";
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures";
test("native CaseRun fixtures expose six deterministic API-owned states", () => {
resetNativeFixtures();
@@ -20,6 +20,12 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () =>
assert.equal(completed?.terminal, true);
assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/);
const aggregate = readNativeAggregate(started!.runId);
assert.equal(aggregate?.mode, "native-test");
assert.equal(aggregate?.runId, started!.runId);
assert.equal(aggregate?.status, "completed");
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.equal(events?.mode, "native-test");
@@ -36,8 +36,42 @@ function timestamp(index: number): string {
return new Date(BASE_TIME + index * 1000).toISOString();
}
function aggregateSha(runId: string, status: NativeStatus): string {
return createHash("sha256").update(`${MODE}:${runId}:${status}`).digest("hex");
function aggregateResponse(run: NativeRunState) {
const step = run.steps[run.cursor] ?? run.steps.at(-1)!;
const summary = {
status: step.status,
jobId: `job-${run.runId}`,
artifactCount: step.status === "completed" ? 2 : 0
};
const evidence = {
status: step.status,
blocker: step.blocker ?? null,
artifacts: step.status === "completed" ? [{ name: "firmware.bin" }, { name: "caserun.log" }] : []
};
const archivedRun = {
runId: run.runId,
caseId: run.caseId,
status: step.status,
stage: step.stage,
terminal: step.terminal,
mode: MODE
};
const source = { runId: run.runId, caseId: run.caseId, status: step.status, summary, evidence, run: archivedRun };
return {
ok: true,
mode: MODE,
contractVersion: "caserun.aggregate.v1",
runId: run.runId,
caseId: run.caseId,
status: step.status,
stage: step.stage,
terminal: step.terminal,
sourceAuthority: { mode: MODE, sequence: "request-driven" },
sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"),
summary,
evidence,
run: archivedRun
};
}
function runResponse(run: NativeRunState) {
@@ -62,7 +96,7 @@ function runResponse(run: NativeRunState) {
references: {
status: { href: `/v1/caserun/runs/${run.runId}` },
events: { href: `/v1/caserun/runs/${run.runId}/events`, count: run.cursor + 1 },
aggregate: { href: `/v1/caserun/runs/${run.runId}/aggregate`, sha256: aggregateSha(run.runId, step.status) },
aggregate: { href: `/v1/caserun/runs/${run.runId}/aggregate`, sha256: aggregateResponse(run).sha256 },
manifest: step.status === "completed" ? { href: `/v1/caserun/runs/${run.runId}/manifest`, count: 2 } : null,
replay: null,
trace: { traceId: `trace-${run.runId}` },
@@ -116,6 +150,11 @@ export function readNativeEvents(runId: string) {
return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, runId, caseId: run.caseId, status: current.status, stage: current.stage, terminal: current.terminal, sourceAuthority: current.sourceAuthority, events };
}
export function readNativeAggregate(runId: string) {
const run = runs.get(runId);
return run ? aggregateResponse(run) : null;
}
export function resetNativeFixtures(): void {
runSequence = 0;
runs.clear();
@@ -1,4 +1,4 @@
import { listNativeCases, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
const port = Number(process.env.HWLAB_CASERUN_NATIVE_PORT || 4316);
const hostname = process.env.HWLAB_CASERUN_NATIVE_HOST || "127.0.0.1";
@@ -44,13 +44,19 @@ const server = Bun.serve({
}
const eventsMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/events$/);
if (request.method === "GET" && eventsMatch) {
const runId = decodeURIComponent(eventsMatch[1]);
const runId = decodeURIComponent(eventsMatch[1]!);
const events = readNativeEvents(runId);
return events ? json(events) : notFound("run", runId);
}
const aggregateMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/aggregate$/);
if (request.method === "GET" && aggregateMatch) {
const runId = decodeURIComponent(aggregateMatch[1]!);
const aggregate = readNativeAggregate(runId);
return aggregate ? json(aggregate) : notFound("run", runId);
}
const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)$/);
if (request.method === "GET" && runMatch) {
const runId = decodeURIComponent(runMatch[1]);
const runId = decodeURIComponent(runMatch[1]!);
const run = readNativeRun(runId);
return run ? json(run) : notFound("run", runId);
}
@@ -0,0 +1,47 @@
const baseUrl = process.env.HWLAB_CASERUN_NATIVE_URL || "http://127.0.0.1:4316";
export {};
function requireCondition(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
async function readJson(path: string, init?: RequestInit): Promise<{ response: Response; body: Record<string, unknown> }> {
const response = await fetch(`${baseUrl}${path}`, init);
const body = await response.json();
requireCondition(body && typeof body === "object" && !Array.isArray(body), `invalid JSON object from ${path}`);
return { response, body };
}
const started = await readJson("/v1/caserun/runs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ caseId: "native-completed" })
});
requireCondition(started.response.status === 202, `start status=${started.response.status}`);
requireCondition(typeof started.body.runId === "string", "start runId missing");
await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`);
const completed = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`);
requireCondition(completed.body.status === "completed", `run status=${completed.body.status}`);
const aggregate = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}/aggregate`);
requireCondition(aggregate.response.status === 200, `aggregate httpStatus=${aggregate.response.status}`);
requireCondition(aggregate.body.mode === "native-test", `aggregate mode=${aggregate.body.mode}`);
requireCondition(aggregate.body.status === "completed", `aggregate status=${aggregate.body.status}`);
requireCondition(aggregate.body.runId === started.body.runId, `aggregate runId=${aggregate.body.runId}`);
requireCondition(typeof aggregate.body.sha256 === "string" && /^[a-f0-9]{64}$/.test(aggregate.body.sha256), `aggregate sha256=${aggregate.body.sha256}`);
const references = completed.body.references;
requireCondition(references && typeof references === "object" && !Array.isArray(references), "run aggregate reference missing");
const aggregateReference = (references as Record<string, unknown>).aggregate;
requireCondition(aggregateReference && typeof aggregateReference === "object" && !Array.isArray(aggregateReference), "run aggregate reference invalid");
requireCondition(aggregate.body.sha256 === (aggregateReference as Record<string, unknown>).sha256, "aggregate SHA does not match run reference");
console.log(JSON.stringify({
ok: true,
mode: aggregate.body.mode,
path: `/v1/caserun/runs/${started.body.runId}/aggregate`,
status: aggregate.body.status,
runId: aggregate.body.runId,
sha256: aggregate.body.sha256
}));
@@ -8,16 +8,20 @@ export default async function caserunNativeSmoke({ page, goto, wait, screenshot,
await wait(300);
const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || "");
const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || "";
const events = await page.locator("#caserun-event-text").textContent();
const aggregate = await page.locator("#caserun-aggregate").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 conditions = {
mode: state.includes("native-test"),
terminal: state.includes("true"),
completed: aggregate?.includes("completed") === true,
aggregateStatus: aggregateStatus === "completed",
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,
aggregate: aggregate?.includes("aggregate pending") === false
completedEvent: events?.includes("completed") === true
};
const image = await screenshot("caserun-native-completed.png");
recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions });
+3 -2
View File
@@ -1,5 +1,5 @@
import { fetchJson } from "./client";
import type { ApiResult, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
export const caserunAPI = {
cases: (): Promise<ApiResult<CaseRunCasesResponse>> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }),
@@ -10,5 +10,6 @@ export const caserunAPI = {
timeoutName: "CaseRun start"
}),
run: (runId: string): Promise<ApiResult<CaseRunRunResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}`, { timeoutMs: 12000, timeoutName: "CaseRun run" }),
events: (runId: string): Promise<ApiResult<CaseRunEventsResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" })
events: (runId: string): Promise<ApiResult<CaseRunEventsResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }),
aggregate: (runId: string): Promise<ApiResult<CaseRunAggregateResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/aggregate`, { timeoutMs: 12000, timeoutName: "CaseRun aggregate" })
};
@@ -18,9 +18,9 @@ const statusTone = computed(() => {
const eventLines = computed(() => caserun.events.map((event) => `[${formatTimestamp(event.at)}] ${event.stage} ${event.status}${event.payload ? ` ${JSON.stringify(event.payload)}` : ""}`));
const artifactCount = computed(() => Number(caserun.currentRun?.facts?.artifactCount ?? 0));
const jobId = computed(() => String(caserun.currentRun?.facts?.jobId ?? ""));
const aggregateHash = computed(() => caserun.currentRun?.references?.aggregate?.sha256 ?? "");
const aggregateHash = computed(() => caserun.aggregate?.sha256 ?? "");
const blockerText = computed(() => caserun.currentRun?.blocker ? `${caserun.currentRun.blocker.code}: ${caserun.currentRun.blocker.summary}` : "");
const fixtureMode = computed(() => String(caserun.currentRun?.mode ?? caserun.cases[0]?.mode ?? "live"));
const fixtureMode = computed(() => String(caserun.aggregate?.mode ?? caserun.currentRun?.mode ?? caserun.cases[0]?.mode ?? "live"));
onMounted(async () => {
await caserun.refreshCases();
@@ -63,7 +63,7 @@ async function startRun(): Promise<void> {
<button id="caserun-refresh" type="button" :disabled="!caserun.currentRun?.runId" @click="caserun.refreshRun()">刷新</button>
</div>
<dl class="caserun-meta" id="caserun-status">
<div><dt>run</dt><dd>{{ caserun.currentRun?.runId || '-' }}</dd></div>
<div><dt>run</dt><dd id="caserun-run-id">{{ caserun.currentRun?.runId || '-' }}</dd></div>
<div><dt>stage</dt><dd>{{ caserun.currentRun?.stage || '-' }}</dd></div>
<div><dt>mode</dt><dd>{{ fixtureMode }}</dd></div>
<div><dt>terminal</dt><dd>{{ caserun.currentRun?.terminal ?? '-' }}</dd></div>
@@ -78,8 +78,9 @@ async function startRun(): Promise<void> {
<pre id="caserun-event-text">{{ eventLines.join('\n') || blockerText || caserun.error || '等待 CaseRun。' }}</pre>
</section>
<section class="caserun-aggregate" id="caserun-aggregate" aria-label="CaseRun aggregate">
<strong>{{ aggregateHash ? aggregateHash.slice(0, 16) : 'aggregate pending' }}</strong>
<span>{{ caserun.currentRun?.status || 'idle' }}</span>
<strong id="caserun-aggregate-sha">{{ aggregateHash || 'aggregate pending' }}</strong>
<span id="caserun-aggregate-status">{{ caserun.aggregate?.status || 'pending' }}</span>
<small id="caserun-aggregate-run-id">{{ caserun.aggregate?.runId || '-' }}</small>
</section>
</aside>
</template>
+12 -6
View File
@@ -1,15 +1,16 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, expect, test, vi } from "vitest";
const { casesRequest, startRequest, runRequest, eventsRequest } = vi.hoisted(() => ({
const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({
casesRequest: vi.fn(),
startRequest: vi.fn(),
runRequest: vi.fn(),
eventsRequest: vi.fn()
eventsRequest: vi.fn(),
aggregateRequest: vi.fn()
}));
vi.mock("@/api", () => ({
caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest }
caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest }
}));
import { useCaseRunStore } from "./caserun";
@@ -20,11 +21,13 @@ beforeEach(() => {
startRequest.mockReset();
runRequest.mockReset();
eventsRequest.mockReset();
aggregateRequest.mockReset();
});
test("polling follows API terminal instead of status names", async () => {
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "custom-progress", stage: "agent-running", terminal: false } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } });
aggregateRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", status: "custom-progress", sha256: "progress-sha" } });
const store = useCaseRunStore();
await store.refreshRun("run-1");
@@ -35,15 +38,18 @@ test("polling follows API terminal instead of status names", async () => {
expect(store.shouldPoll).toBe(false);
});
test("start keeps blocker and aggregate from API read model", async () => {
test("start keeps blocker and independent aggregate from API read models", async () => {
startRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "queued", stage: "queued", terminal: false } });
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "blocked" }, references: { aggregate: { sha256: "abc123" } } } });
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "blocked" }, references: { aggregate: { sha256: "embedded-sha" } } } });
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", events: [{ status: "blocked", stage: "preparing" }] } });
aggregateRequest.mockResolvedValue({ ok: true, data: { mode: "native-test", runId: "run-2", status: "blocked", sha256: "api-aggregate-sha" } });
const store = useCaseRunStore();
await store.startRun("native-blocked");
expect(store.currentRun?.blocker?.code).toBe("native-capability-blocked");
expect(store.currentRun?.references?.aggregate?.sha256).toBe("abc123");
expect(store.aggregate?.runId).toBe("run-2");
expect(store.aggregate?.status).toBe("blocked");
expect(store.aggregate?.sha256).toBe("api-aggregate-sha");
expect(store.shouldPoll).toBe(false);
});
+11 -3
View File
@@ -1,12 +1,13 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { caserunAPI } from "@/api";
import type { CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
export const useCaseRunStore = defineStore("caserun", () => {
const cases = ref<CaseRunCaseSummary[]>([]);
const currentRun = ref<CaseRunRunResponse | null>(null);
const events = ref<CaseRunEvent[]>([]);
const aggregate = ref<CaseRunAggregateResponse | null>(null);
const loading = ref(false);
const starting = ref(false);
const error = ref<string | null>(null);
@@ -26,6 +27,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
starting.value = true;
error.value = null;
events.value = [];
aggregate.value = null;
const result = await caserunAPI.startRun(caseId);
starting.value = false;
if (!result.ok || !result.data) {
@@ -45,7 +47,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
return;
}
currentRun.value = result.data;
await refreshEvents(runId);
await Promise.all([refreshEvents(runId), refreshAggregate(runId)]);
}
async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise<void> {
@@ -54,5 +56,11 @@ export const useCaseRunStore = defineStore("caserun", () => {
if (result.ok && result.data?.runId === runId) events.value = result.data.events ?? [];
}
return { cases, currentRun, events, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents };
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) aggregate.value = result.data;
}
return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate };
});
+1
View File
@@ -522,6 +522,7 @@ export interface CaseRunBlocker { code: string; summary: string; layer?: string
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 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 ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfileCatalogItem {
profile: string;