From 73bda4bf1e617487fd73dd7fa6abfa796f8b40fc Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 03:11:45 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20CaseRun=20nati?= =?UTF-8?q?ve=20=E7=83=AD=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/hwlab-cloud-web/package.json | 6 + .../scripts/caserun-native-fixtures.test.ts | 40 ++++++ .../scripts/caserun-native-fixtures.ts | 122 ++++++++++++++++++ .../scripts/caserun-native-server.ts | 61 +++++++++ .../scripts/caserun-native-service.ts | 62 +++++++++ .../scripts/caserun-native-supervisor.ts | 49 +++++++ .../scripts/caserun-native-web-probe.mjs | 32 +++++ .../src/components/caserun/CaseRunPanel.vue | 5 +- .../src/stores/caserun.test.ts | 49 +++++++ web/hwlab-cloud-web/src/stores/caserun.ts | 3 +- web/hwlab-cloud-web/src/types/index.ts | 6 +- web/hwlab-cloud-web/vite.config.ts | 62 ++++++--- 12 files changed, 474 insertions(+), 23 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-server.ts create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-service.ts create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs create mode 100644 web/hwlab-cloud-web/src/stores/caserun.test.ts diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 85c6b041..75fded24 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -6,6 +6,12 @@ "scripts": { "deps": "node ../../scripts/worktree-deps.mjs --web", "dev": "bun run deps --quiet && vite", + "dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev", + "caserun:native": "bun scripts/caserun-native-supervisor.ts", + "caserun:native:start": "bun run scripts/caserun-native-service.ts start", + "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", "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", diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts new file mode 100644 index 00000000..0872c743 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { listNativeCases, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures"; + +test("native CaseRun fixtures expose six deterministic API-owned states", () => { + resetNativeFixtures(); + const cases = listNativeCases(); + assert.equal(cases.mode, "native-test"); + assert.deepEqual(cases.cases.map((item) => item.caseId), ["native-queued", "native-running", "native-completed", "native-failed", "native-blocked", "native-canceled"]); + + const started = startNativeRun("native-completed"); + assert.equal(started?.status, "queued"); + assert.equal(started?.terminal, false); + const running = readNativeRun(started!.runId); + assert.equal(running?.status, "running"); + assert.equal(running?.terminal, false); + const completed = readNativeRun(started!.runId); + assert.equal(completed?.status, "completed"); + assert.equal(completed?.terminal, true); + assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/); + + const events = readNativeEvents(started!.runId); + assert.deepEqual(events?.events.map((event) => event.status), ["queued", "running", "completed"]); + assert.equal(events?.mode, "native-test"); +}); + +test("native blockers and cancellation come from the API fixture", () => { + resetNativeFixtures(); + const blocked = startNativeRun("native-blocked")!; + const blockedTerminal = readNativeRun(blocked.runId)!; + assert.equal(blockedTerminal.terminal, true); + assert.equal(blockedTerminal.blocker?.code, "native-capability-blocked"); + + const canceled = startNativeRun("native-canceled")!; + readNativeRun(canceled.runId); + const canceledTerminal = readNativeRun(canceled.runId)!; + assert.equal(canceledTerminal.status, "canceled"); + assert.equal(canceledTerminal.terminal, true); +}); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts new file mode 100644 index 00000000..617708e6 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts @@ -0,0 +1,122 @@ +import { createHash } from "node:crypto"; + +type NativeStatus = "queued" | "running" | "completed" | "failed" | "blocked" | "canceled"; + +interface NativeStep { + status: NativeStatus; + stage: string; + terminal: boolean; + blocker?: { code: string; summary: string; layer: string }; +} + +interface NativeRunState { + runId: string; + caseId: string; + cursor: number; + steps: NativeStep[]; +} + +const MODE = "native-test"; +const CONTRACT_VERSION = "caserun.v1"; +const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z"); + +const scenarios: Record = { + "native-queued": { title: "Native queued", steps: [{ status: "queued", stage: "queued", terminal: false }] }, + "native-running": { title: "Native running", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] }, + "native-completed": { title: "Native completed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] }, + "native-failed": { title: "Native failed", 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: "Deterministic native build failure", layer: "build" } }] }, + "native-blocked": { title: "Native blocked", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "Deterministic native capability blocker", layer: "hwpod" } }] }, + "native-canceled": { title: "Native canceled", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] } +}; + +let runSequence = 0; +const runs = new Map(); + +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 runResponse(run: NativeRunState) { + const step = run.steps[run.cursor] ?? run.steps.at(-1)!; + return { + ok: true, + mode: MODE, + contractVersion: CONTRACT_VERSION, + runId: run.runId, + caseId: run.caseId, + status: step.status, + stage: step.stage, + terminal: step.terminal, + blocker: step.blocker ?? null, + createdAt: timestamp(0), + updatedAt: timestamp(run.cursor), + facts: { + jobId: `job-${run.runId}`, + artifactCount: step.status === "completed" ? 2 : 0, + evidenceStatus: step.status + }, + 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) }, + manifest: step.status === "completed" ? { href: `/v1/caserun/runs/${run.runId}/manifest`, count: 2 } : null, + replay: null, + trace: { traceId: `trace-${run.runId}` }, + hwpod: { hwpodId: "native-hwpod", nodeId: "native-node" } + }, + sourceAuthority: { mode: MODE, sequence: "request-driven" }, + error: null, + statusUrl: `/v1/caserun/runs/${run.runId}` + }; +} + +export function listNativeCases() { + const cases = Object.entries(scenarios).map(([caseId, scenario]) => ({ + caseId, + title: scenario.title, + mode: MODE, + available: true, + hwpodSpec: "native-test", + runtime: { sequence: scenario.steps.map((step) => step.status) } + })); + return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, count: cases.length, cases, caseAuthority: { mode: MODE } }; +} + +export function startNativeRun(caseId: string) { + const scenario = scenarios[caseId]; + if (!scenario) return null; + runSequence += 1; + const runId = `${caseId}-${runSequence}`; + const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps }; + runs.set(runId, run); + return runResponse(run); +} + +export function readNativeRun(runId: string, advance = true) { + const run = runs.get(runId); + if (!run) return null; + if (advance && run.cursor < run.steps.length - 1) run.cursor += 1; + return runResponse(run); +} + +export function readNativeEvents(runId: string) { + const run = runs.get(runId); + if (!run) return null; + const current = runResponse(run); + const events = run.steps.slice(0, run.cursor + 1).map((step, index) => ({ + at: timestamp(index), + status: step.status, + stage: step.stage, + payload: { mode: MODE, terminal: step.terminal, blocker: step.blocker ?? null } + })); + 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 resetNativeFixtures(): void { + runSequence = 0; + runs.clear(); +} diff --git a/web/hwlab-cloud-web/scripts/caserun-native-server.ts b/web/hwlab-cloud-web/scripts/caserun-native-server.ts new file mode 100644 index 00000000..1f5b5e65 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-server.ts @@ -0,0 +1,61 @@ +import { listNativeCases, 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"; + +function json(data: unknown, status = 200): Response { + return Response.json(data, { status, headers: { "cache-control": "no-store" } }); +} + +function notFound(kind: string, id?: string): Response { + return json({ ok: false, mode: "native-test", error: { code: `${kind}-not-found`, message: id ? `${kind} ${id} not found` : `${kind} not found` } }, 404); +} + +function authSession(): Record { + return { + ok: true, + authenticated: true, + mode: "native-test", + sessionKind: "native-test", + user: { id: "native-admin", username: "native-admin", displayName: "Native Admin", role: "admin", status: "active" }, + capabilities: { admin: "available" }, + access: { nav: { profileId: "native-test", allowedIds: ["*"], valuesRedacted: true } }, + valuesRedacted: true + }; +} + +const server = Bun.serve({ + hostname, + port, + development: true, + async fetch(request) { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, mode: "native-test" }); + if (request.method === "POST" && url.pathname === "/auth/login") { + return Response.json(authSession(), { headers: { "cache-control": "no-store", "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax" } }); + } + if (request.method === "GET" && url.pathname === "/auth/session") return json(authSession()); + if (request.method === "GET" && url.pathname === "/v1/caserun/cases") return json(listNativeCases()); + if (request.method === "POST" && url.pathname === "/v1/caserun/runs") { + const body = await request.json().catch(() => null) as { caseId?: string } | null; + if (!body?.caseId) return json({ ok: false, mode: "native-test", error: { code: "case-id-required", message: "caseId is required" } }, 400); + const run = startNativeRun(body.caseId); + return run ? json(run, 202) : notFound("case", body.caseId); + } + const eventsMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/events$/); + if (request.method === "GET" && eventsMatch) { + const runId = decodeURIComponent(eventsMatch[1]); + const events = readNativeEvents(runId); + return events ? json(events) : notFound("run", runId); + } + const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)$/); + if (request.method === "GET" && runMatch) { + const runId = decodeURIComponent(runMatch[1]); + const run = readNativeRun(runId); + return run ? json(run) : notFound("run", runId); + } + return notFound("route", url.pathname); + } +}); + +console.log(JSON.stringify({ event: "caserun-native-listening", mode: "native-test", hostname: server.hostname, port: server.port })); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-service.ts b/web/hwlab-cloud-web/scripts/caserun-native-service.ts new file mode 100644 index 00000000..e017fbbb --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-service.ts @@ -0,0 +1,62 @@ +import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; + +const action = process.argv[2]; +const root = process.cwd(); +const stateDir = resolve(root, ".state/caserun-native"); +const pidPath = resolve(stateDir, "server.pid"); +const logPath = resolve(stateDir, "server.log"); + +function readPid(): number | null { + try { + const pid = Number(readFileSync(pidPath, "utf8").trim()); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isRunning(pid: number | null): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +mkdirSync(stateDir, { recursive: true }); +const pid = readPid(); + +if (action === "start") { + if (isRunning(pid)) { + console.log(JSON.stringify({ ok: true, action, status: "already-running", pid, logPath })); + process.exit(0); + } + rmSync(pidPath, { force: true }); + const logFd = openSync(logPath, "a"); + const child = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], { cwd: root, stdin: "ignore", stdout: logFd, stderr: logFd }); + child.unref(); + await Bun.write(pidPath, `${child.pid}\n`); + console.log(JSON.stringify({ ok: true, action, status: "started", pid: child.pid, logPath })); +} else if (action === "stop") { + if (!isRunning(pid)) { + rmSync(pidPath, { force: true }); + console.log(JSON.stringify({ ok: true, action, status: "stopped" })); + } else { + process.kill(pid!, "SIGTERM"); + rmSync(pidPath, { force: true }); + console.log(JSON.stringify({ ok: true, action, status: "stopping", pid })); + } +} else if (action === "status") { + const running = isRunning(pid); + console.log(JSON.stringify({ ok: running, action, status: running ? "running" : "stopped", pid: running ? pid : null, logPath })); + process.exit(running ? 0 : 1); +} else if (action === "logs") { + const lines = readFileSync(logPath, "utf8").trimEnd().split("\n").slice(-80); + console.log(lines.join("\n")); +} else { + console.error("usage: bun run scripts/caserun-native-service.ts "); + process.exit(2); +} diff --git a/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts b/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts new file mode 100644 index 00000000..e470c17a --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts @@ -0,0 +1,49 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const watchedFiles = [ + resolve(root, "scripts/caserun-native-server.ts"), + resolve(root, "scripts/caserun-native-fixtures.ts") +]; + +let child: ReturnType | null = null; +let signature = sourceSignature(); +let stopping = false; + +function sourceSignature(): string { + return watchedFiles.map((path) => `${path}:${statSync(path).mtimeMs}:${statSync(path).size}`).join("|"); +} + +function startChild(reason: string): void { + child = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); + console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: child.pid })); + void child.exited.then((exitCode) => { + if (!stopping && child?.exitCode === exitCode) console.log(JSON.stringify({ event: "caserun-native-exit", exitCode })); + }); +} + +function stopChild(): void { + if (child && child.exitCode === null) child.kill("SIGTERM"); + child = null; +} + +startChild("initial"); + +const timer = setInterval(() => { + const nextSignature = sourceSignature(); + if (nextSignature === signature) return; + signature = nextSignature; + stopChild(); + startChild("source-change"); +}, 500); + +function shutdown(): void { + stopping = true; + clearInterval(timer); + stopChild(); + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs new file mode 100644 index 00000000..b799d977 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs @@ -0,0 +1,32 @@ +export default async function caserunNativeSmoke({ page, goto, wait, screenshot, recordStep }) { + await goto("/workbench"); + const panel = page.locator("#caserun-panel"); + await panel.waitFor({ state: "visible", timeout: 20000 }); + await page.locator("#caserun-case-select").selectOption("native-completed"); + await page.locator("#caserun-start").click(); + await page.locator("#caserun-refresh").click(); + await wait(300); + + const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || ""); + const events = await page.locator("#caserun-event-text").textContent(); + const aggregate = await page.locator("#caserun-aggregate").textContent(); + const conditions = { + mode: state.includes("native-test"), + terminal: state.includes("true"), + completed: aggregate?.includes("completed") === true, + queuedEvent: events?.includes("queued") === true, + runningEvent: events?.includes("running") === true, + completedEvent: events?.includes("completed") === true, + aggregate: aggregate?.includes("aggregate pending") === false + }; + const image = await screenshot("caserun-native-completed.png"); + recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions }); + return { + ok: Object.values(conditions).every(Boolean), + mode: "native-test", + conditions, + screenshot: image, + finalUrl: page.url(), + valuesRedacted: true + }; +} diff --git a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue index 7d76fbb5..85c2b87f 100644 --- a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue +++ b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue @@ -20,12 +20,13 @@ const artifactCount = computed(() => Number(caserun.currentRun?.facts?.artifactC const jobId = computed(() => String(caserun.currentRun?.facts?.jobId ?? "")); const aggregateHash = computed(() => caserun.currentRun?.references?.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")); onMounted(async () => { await caserun.refreshCases(); selectedCaseId.value = caserun.selectedCaseId; pollTimer = setInterval(() => { - if (["queued", "running"].includes(runStatus.value)) void caserun.refreshRun(); + if (caserun.shouldPoll) void caserun.refreshRun(); }, 5000); }); @@ -64,6 +65,8 @@ async function startRun(): Promise {
run
{{ caserun.currentRun?.runId || '-' }}
stage
{{ caserun.currentRun?.stage || '-' }}
+
mode
{{ fixtureMode }}
+
terminal
{{ caserun.currentRun?.terminal ?? '-' }}
job
{{ jobId || '-' }}
artifacts
{{ artifactCount }}
diff --git a/web/hwlab-cloud-web/src/stores/caserun.test.ts b/web/hwlab-cloud-web/src/stores/caserun.test.ts new file mode 100644 index 00000000..4d8f2675 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/caserun.test.ts @@ -0,0 +1,49 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, expect, test, vi } from "vitest"; + +const { casesRequest, startRequest, runRequest, eventsRequest } = vi.hoisted(() => ({ + casesRequest: vi.fn(), + startRequest: vi.fn(), + runRequest: vi.fn(), + eventsRequest: vi.fn() +})); + +vi.mock("@/api", () => ({ + caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest } +})); + +import { useCaseRunStore } from "./caserun"; + +beforeEach(() => { + setActivePinia(createPinia()); + casesRequest.mockReset(); + startRequest.mockReset(); + runRequest.mockReset(); + eventsRequest.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: [] } }); + const store = useCaseRunStore(); + + await store.refreshRun("run-1"); + expect(store.shouldPoll).toBe(true); + + runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "running", stage: "completed", terminal: true } }); + await store.refreshRun("run-1"); + expect(store.shouldPoll).toBe(false); +}); + +test("start keeps blocker and aggregate from API read model", 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" } } } }); + eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", events: [{ status: "blocked", stage: "preparing" }] } }); + 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.shouldPoll).toBe(false); +}); diff --git a/web/hwlab-cloud-web/src/stores/caserun.ts b/web/hwlab-cloud-web/src/stores/caserun.ts index 95f27515..3943925b 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.ts @@ -11,6 +11,7 @@ export const useCaseRunStore = defineStore("caserun", () => { const starting = ref(false); const error = ref(null); const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? ""); + const shouldPoll = computed(() => Boolean(currentRun.value?.runId && currentRun.value.terminal === false)); async function refreshCases(): Promise { loading.value = true; @@ -53,5 +54,5 @@ 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, refreshCases, startRun, refreshRun, refreshEvents }; + return { cases, currentRun, events, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents }; }); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 36f43521..38eae6a5 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -516,12 +516,12 @@ export interface HwlabNodeUpdateMetadata { observedAt: string; } export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record | null; expected?: Record | null; runtime?: Record | null; [key: string]: unknown } -export interface CaseRunCasesResponse { ok?: boolean; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record; [key: string]: unknown } +export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record; [key: string]: unknown } export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record; [key: string]: unknown } export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record | null } export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record | null; [key: string]: unknown } -export interface CaseRunRunResponse { ok?: boolean; 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 | null; hwpod?: Record | null }; sourceAuthority?: Record; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown } -export interface CaseRunEventsResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record; events?: CaseRunEvent[]; [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 | null; hwpod?: Record | null }; sourceAuthority?: Record; 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; events?: CaseRunEvent[]; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array>; [key: string]: unknown } export interface ProviderProfileCatalogItem { profile: string; diff --git a/web/hwlab-cloud-web/vite.config.ts b/web/hwlab-cloud-web/vite.config.ts index 1f404f7a..06055ca4 100644 --- a/web/hwlab-cloud-web/vite.config.ts +++ b/web/hwlab-cloud-web/vite.config.ts @@ -1,25 +1,51 @@ +import { Agent } from "node:http"; import { fileURLToPath, URL } from "node:url"; import vue from "@vitejs/plugin-vue"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; -export default defineConfig({ - plugins: [vue()], - resolve: { - alias: { - "@": fileURLToPath(new URL("./src", import.meta.url)) - } - }, - build: { - outDir: "dist", - emptyOutDir: true, - sourcemap: false, - rollupOptions: { - output: { - entryFileNames: "app.js", - chunkFileNames: "assets/[name].js", - assetFileNames: "assets/[name][extname]" +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1"; + const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316"; + const nativeCaseRunAgent = new Agent({ keepAlive: true }); + + return { + plugins: [vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)) + } + }, + server: nativeCaseRunEnabled ? { + watch: { + usePolling: true, + interval: 300 + }, + proxy: { + "/auth": { + target: nativeCaseRunTarget, + changeOrigin: false, + agent: nativeCaseRunAgent + }, + "/v1/caserun": { + target: nativeCaseRunTarget, + changeOrigin: false, + agent: nativeCaseRunAgent + } + } + } : undefined, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: false, + rollupOptions: { + output: { + entryFileNames: "app.js", + chunkFileNames: "assets/[name].js", + assetFileNames: "assets/[name][extname]" + } } } - } + }; }); From c5ee7592501ee7d36d49a80b28d45da52774f2a6 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 03:40:53 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20CaseRun=20nativ?= =?UTF-8?q?e=20aggregate=20=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/hwlab-cloud-web/package.json | 1 + .../scripts/caserun-native-fixtures.test.ts | 8 +++- .../scripts/caserun-native-fixtures.ts | 45 ++++++++++++++++-- .../scripts/caserun-native-server.ts | 12 +++-- .../scripts/caserun-native-smoke.ts | 47 +++++++++++++++++++ .../scripts/caserun-native-web-probe.mjs | 12 +++-- web/hwlab-cloud-web/src/api/caserun.ts | 5 +- .../src/components/caserun/CaseRunPanel.vue | 11 +++-- .../src/stores/caserun.test.ts | 18 ++++--- web/hwlab-cloud-web/src/stores/caserun.ts | 14 ++++-- web/hwlab-cloud-web/src/types/index.ts | 1 + 11 files changed, 147 insertions(+), 27 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-smoke.ts diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 75fded24..0ca7915e 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -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", diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts index 0872c743..62d6d4bb 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts @@ -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"); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts index 617708e6..817c3eb7 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts @@ -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(); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-server.ts b/web/hwlab-cloud-web/scripts/caserun-native-server.ts index 1f5b5e65..5ee57959 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-server.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-server.ts @@ -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); } diff --git a/web/hwlab-cloud-web/scripts/caserun-native-smoke.ts b/web/hwlab-cloud-web/scripts/caserun-native-smoke.ts new file mode 100644 index 00000000..922436df --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-smoke.ts @@ -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 }> { + 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).aggregate; +requireCondition(aggregateReference && typeof aggregateReference === "object" && !Array.isArray(aggregateReference), "run aggregate reference invalid"); +requireCondition(aggregate.body.sha256 === (aggregateReference as Record).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 +})); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs index b799d977..5e23c912 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs +++ b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs @@ -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 }); diff --git a/web/hwlab-cloud-web/src/api/caserun.ts b/web/hwlab-cloud-web/src/api/caserun.ts index a4028888..7fd589d6 100644 --- a/web/hwlab-cloud-web/src/api/caserun.ts +++ b/web/hwlab-cloud-web/src/api/caserun.ts @@ -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> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }), @@ -10,5 +10,6 @@ export const caserunAPI = { timeoutName: "CaseRun start" }), run: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}`, { timeoutMs: 12000, timeoutName: "CaseRun run" }), - events: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }) + events: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }), + aggregate: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/aggregate`, { timeoutMs: 12000, timeoutName: "CaseRun aggregate" }) }; diff --git a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue index 85c2b87f..7516c17e 100644 --- a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue +++ b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue @@ -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 {
-
run
{{ caserun.currentRun?.runId || '-' }}
+
run
{{ caserun.currentRun?.runId || '-' }}
stage
{{ caserun.currentRun?.stage || '-' }}
mode
{{ fixtureMode }}
terminal
{{ caserun.currentRun?.terminal ?? '-' }}
@@ -78,8 +78,9 @@ async function startRun(): Promise {
{{ eventLines.join('\n') || blockerText || caserun.error || '等待 CaseRun。' }}
- {{ aggregateHash ? aggregateHash.slice(0, 16) : 'aggregate pending' }} - {{ caserun.currentRun?.status || 'idle' }} + {{ aggregateHash || 'aggregate pending' }} + {{ caserun.aggregate?.status || 'pending' }} + {{ caserun.aggregate?.runId || '-' }}
diff --git a/web/hwlab-cloud-web/src/stores/caserun.test.ts b/web/hwlab-cloud-web/src/stores/caserun.test.ts index 4d8f2675..6dd6d3cb 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.test.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.test.ts @@ -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); }); diff --git a/web/hwlab-cloud-web/src/stores/caserun.ts b/web/hwlab-cloud-web/src/stores/caserun.ts index 3943925b..ab609a98 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.ts @@ -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([]); const currentRun = ref(null); const events = ref([]); + const aggregate = ref(null); const loading = ref(false); const starting = ref(false); const error = ref(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 { @@ -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 { + 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 }; }); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 38eae6a5..a7ddd0ab 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -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 | 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 | null; hwpod?: Record | null }; sourceAuthority?: Record; 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; 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; sha256?: string; summary?: Record | null; evidence?: Record | null; run?: Record | null; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array>; [key: string]: unknown } export interface ProviderProfileCatalogItem { profile: string; From ee09dabfeba3cec1681c87129cec5eec76db9429 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 04:06:59 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(web):=20=E4=B8=B2=E8=A1=8C=E5=8C=96=20n?= =?UTF-8?q?ative=20CaseRun=20=E7=83=AD=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/hwlab-cloud-web/package.json | 1 + .../scripts/caserun-native-reload-smoke.ts | 129 ++++++++++++++++++ .../scripts/caserun-native-service.ts | 15 +- .../scripts/caserun-native-supervisor.ts | 58 +++++--- 4 files changed, 184 insertions(+), 19 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 0ca7915e..bbed1ff6 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -13,6 +13,7 @@ "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", + "caserun:native:reload-smoke": "bun run scripts/caserun-native-reload-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", diff --git a/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts b/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts new file mode 100644 index 00000000..1cb2fe85 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/caserun-native-reload-smoke.ts @@ -0,0 +1,129 @@ +import { readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const watchedPath = resolve(root, "scripts/caserun-native-fixtures.ts"); +const displacedPath = `${watchedPath}.reload-smoke`; +const originalStat = statSync(watchedPath); +const port = Number(process.env.HWLAB_CASERUN_RELOAD_SMOKE_PORT || 14316); +const baseUrl = `http://127.0.0.1:${port}`; +const reloadEvents: Array<{ pid?: number; reason?: string }> = []; +let output = ""; +let displaced = false; + +function requireCondition(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function waitFor(description: string, condition: () => boolean | Promise, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await Bun.sleep(50); + } + throw new Error(`timeout waiting for ${description}`); +} + +async function readOutput(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let pending = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + output += chunk; + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop() || ""; + for (const line of lines) { + if (!line.includes('"event":"caserun-native-reload"')) continue; + const event = JSON.parse(line) as { pid?: number; reason?: string }; + if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event); + } + } + const finalChunk = decoder.decode(); + output += finalChunk; + pending += finalChunk; + if (pending.includes('"event":"caserun-native-reload"')) { + const event = JSON.parse(pending) as { pid?: number; reason?: string }; + if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event); + } +} + +async function healthAvailable(): Promise { + try { + const response = await fetch(`${baseUrl}/health`); + return response.ok; + } catch { + return false; + } +} + +function childPids(supervisorPid: number): number[] { + try { + return readFileSync(`/proc/${supervisorPid}/task/${supervisorPid}/children`, "utf8") + .trim() + .split(/\s+/) + .filter(Boolean) + .map(Number); + } catch { + return []; + } +} + +rmSync(displacedPath, { force: true }); +const supervisor = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], { + cwd: root, + env: { ...process.env, HWLAB_CASERUN_NATIVE_PORT: String(port) }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe" +}); +const stdoutTask = readOutput(supervisor.stdout); +const stderrTask = readOutput(supervisor.stderr); + +try { + await waitFor("initial native health", healthAvailable); + + renameSync(watchedPath, displacedPath); + displaced = true; + await Bun.sleep(300); + requireCondition(supervisor.exitCode === null, `supervisor exited during atomic save window: ${supervisor.exitCode}`); + renameSync(displacedPath, watchedPath); + displaced = false; + const restoredAt = new Date(Date.now() + 1000); + utimesSync(watchedPath, restoredAt, restoredAt); + await waitFor("reload after atomic save", () => reloadEvents.length >= 2); + + for (let index = 0; index < 5; index += 1) { + const changedAt = new Date(Date.now() + (index + 1) * 1000); + utimesSync(watchedPath, changedAt, changedAt); + await Bun.sleep(120); + } + + await waitFor("final native health", healthAvailable); + await Bun.sleep(300); + const liveChildren = childPids(supervisor.pid); + requireCondition(liveChildren.length === 1, `expected one native server child, found ${liveChildren.join(",") || "none"}`); + requireCondition(reloadEvents.length >= 3, `expected repeated reloads, found ${reloadEvents.length}`); + requireCondition(!output.includes("EADDRINUSE"), "native reload logged EADDRINUSE"); + + console.log(JSON.stringify({ + ok: true, + mode: "native-test", + supervisorPid: supervisor.pid, + serverPid: liveChildren[0], + reloadCount: reloadEvents.length, + reloadPids: reloadEvents.map((event) => event.pid), + atomicSaveMissingFileTolerated: true, + eaddrinuse: false + })); +} finally { + if (displaced) renameSync(displacedPath, watchedPath); + rmSync(displacedPath, { force: true }); + utimesSync(watchedPath, originalStat.atime, originalStat.mtime); + if (supervisor.exitCode === null) supervisor.kill("SIGTERM"); + await supervisor.exited; + await Promise.all([stdoutTask, stderrTask]); +} diff --git a/web/hwlab-cloud-web/scripts/caserun-native-service.ts b/web/hwlab-cloud-web/scripts/caserun-native-service.ts index e017fbbb..91871105 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-service.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-service.ts @@ -26,6 +26,15 @@ function isRunning(pid: number | null): boolean { } } +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (!isRunning(pid)) return true; + await Bun.sleep(25); + } + return !isRunning(pid); +} + mkdirSync(stateDir, { recursive: true }); const pid = readPid(); @@ -46,8 +55,12 @@ if (action === "start") { console.log(JSON.stringify({ ok: true, action, status: "stopped" })); } else { process.kill(pid!, "SIGTERM"); + if (!await waitForExit(pid!)) { + console.error(JSON.stringify({ ok: false, action, status: "stop-timeout", pid })); + process.exit(1); + } rmSync(pidPath, { force: true }); - console.log(JSON.stringify({ ok: true, action, status: "stopping", pid })); + console.log(JSON.stringify({ ok: true, action, status: "stopped", pid })); } } else if (action === "status") { const running = isRunning(pid); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts b/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts index e470c17a..7c5603fc 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-supervisor.ts @@ -10,40 +10,62 @@ const watchedFiles = [ let child: ReturnType | null = null; let signature = sourceSignature(); let stopping = false; +let restarting = false; -function sourceSignature(): string { - return watchedFiles.map((path) => `${path}:${statSync(path).mtimeMs}:${statSync(path).size}`).join("|"); +function sourceSignature(): string | null { + try { + return watchedFiles.map((path) => { + const source = statSync(path); + return `${path}:${source.mtimeMs}:${source.size}`; + }).join("|"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } } function startChild(reason: string): void { - child = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); - console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: child.pid })); - void child.exited.then((exitCode) => { - if (!stopping && child?.exitCode === exitCode) console.log(JSON.stringify({ event: "caserun-native-exit", exitCode })); + const spawned = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }); + child = spawned; + console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: spawned.pid })); + void spawned.exited.then((exitCode) => { + if (!stopping && child === spawned) console.log(JSON.stringify({ event: "caserun-native-exit", exitCode })); }); } -function stopChild(): void { - if (child && child.exitCode === null) child.kill("SIGTERM"); - child = null; +async function stopChild(): Promise { + const runningChild = child; + if (!runningChild) return; + if (runningChild.exitCode === null) runningChild.kill("SIGTERM"); + await runningChild.exited; + if (child === runningChild) child = null; } startChild("initial"); -const timer = setInterval(() => { +async function reloadIfChanged(): Promise { + if (stopping || restarting) return; const nextSignature = sourceSignature(); - if (nextSignature === signature) return; + if (nextSignature === null || nextSignature === signature) return; signature = nextSignature; - stopChild(); - startChild("source-change"); -}, 500); + restarting = true; + try { + await stopChild(); + if (!stopping) startChild("source-change"); + } finally { + restarting = false; + } +} -function shutdown(): void { +const timer = setInterval(() => void reloadIfChanged(), 100); + +async function shutdown(): Promise { + if (stopping) return; stopping = true; clearInterval(timer); - stopChild(); + await stopChild(); process.exit(0); } -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown());