From 73bda4bf1e617487fd73dd7fa6abfa796f8b40fc Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 03:11:45 +0200 Subject: [PATCH 1/5] =?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/5] =?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/5] =?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()); From bc5cd8d4ccf11509ef2501c64171cc69e5f3347e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 05:51:52 +0200 Subject: [PATCH 4/5] feat: add CaseRun local and API transports --- .gitignore | 4 + internal/harnessrl/http.ts | 9 +- internal/harnessrl/transport.ts | 117 ++++++++++++++++ tools/hwlab-cli/caserun-transport.test.ts | 61 +++++++++ tools/hwlab-cli/caserun.test.ts | 160 ---------------------- tools/src/hwlab-caserun-runtime.ts | 53 ++++++- tools/src/hwlab-caserun-shared.ts | 1 + tools/src/hwlab-cli-lib.ts | 2 + 8 files changed, 234 insertions(+), 173 deletions(-) create mode 100644 internal/harnessrl/transport.ts create mode 100644 tools/hwlab-cli/caserun-transport.test.ts diff --git a/.gitignore b/.gitignore index be4a82c5..e4d6de38 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ tmp/ *.out vendor/ +# Python +__pycache__/ +*.pyc + # Node package managers, for future web work node_modules/ node_modules diff --git a/internal/harnessrl/http.ts b/internal/harnessrl/http.ts index 13aef424..5460b31c 100644 --- a/internal/harnessrl/http.ts +++ b/internal/harnessrl/http.ts @@ -1,3 +1,5 @@ +import { caseRunAccepted } from "./transport.ts"; + export function createHarnessRLHttpApp(options: { service: ReturnType }) { return { async fetch(request: Request) { @@ -13,7 +15,7 @@ export function createHarnessRLHttpApp(options: { service: ReturnType null); if (!body || typeof body !== "object" || Array.isArray(body)) throw Object.assign(new Error("JSON object body is required"), { code: "invalid_json" }); return body as Record; } function runtimeApiUrl(request: Request) { const url = new URL(request.url); return `${url.protocol}//${url.host}`; } function errorBody(code: string, message: string, details?: unknown) { return { ok: false, error: { code, message, details } }; } diff --git a/internal/harnessrl/transport.ts b/internal/harnessrl/transport.ts new file mode 100644 index 00000000..7eaa7deb --- /dev/null +++ b/internal/harnessrl/transport.ts @@ -0,0 +1,117 @@ +import type { CaseRunRecord } from "./contracts.ts"; + +export type CaseRunCommand = + | { kind: "start"; caseId: string; runId?: string } + | { kind: "status"; runId: string } + | { kind: "events"; runId: string } + | { kind: "aggregate"; runId: string } + | { kind: "cancel"; runId: string }; + +export type HarnessRLService = { + submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }): Promise; + getRun(runId: string): Promise; + events(runId: string): Promise>; + aggregate(runId: string): Promise>; + cancel(runId: string): Promise; +}; + +export type CaseRunTransport = { + mode: "local" | "api"; + execute(command: CaseRunCommand): Promise>; + close(): Promise; +}; + +export class CaseRunTransportError extends Error { + code: string; + details?: unknown; + + constructor(code: string, message: string, details?: unknown) { + super(message); + this.name = "CaseRunTransportError"; + this.code = code; + this.details = details; + } +} + +export function createLocalCaseRunTransport(options: { service: HarnessRLService; close?: () => Promise }): CaseRunTransport { + return { + mode: "local", + async execute(command) { + try { + if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, runtimeApiUrl: "local://harnessrl" })); + if (command.kind === "status") return options.service.getRun(command.runId); + if (command.kind === "events") return options.service.events(command.runId); + if (command.kind === "aggregate") return options.service.aggregate(command.runId); + return acceptedRecord(await options.service.cancel(command.runId)); + } catch (error) { + throw typedError(error, "harnessrl_error"); + } + }, + close: options.close ?? (async () => {}) + }; +} + +export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string }): CaseRunTransport { + const baseUrl = apiOrigin(options.baseUrl); + const fetchImpl = options.fetchImpl ?? fetch; + return { + mode: "api", + async execute(command) { + const runPath = command.kind === "start" ? "/v1/caserun/runs" : `/v1/caserun/runs/${encodeURIComponent(command.runId)}${command.kind === "status" ? "" : `/${command.kind}`}`; + const response = await fetchImpl(`${baseUrl}${runPath}`, { + method: command.kind === "start" || command.kind === "cancel" ? "POST" : "GET", + headers: { + ...(command.kind === "start" ? { "content-type": "application/json" } : {}), + ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}) + }, + body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}) }) : undefined + }).catch((error) => { throw typedError(error, "caserun_api_unavailable"); }); + const body = await response.json().catch(() => null) as any; + if (!response.ok || !body || typeof body !== "object") { + throw new CaseRunTransportError(body?.error?.code ?? "caserun_api_error", body?.error?.message ?? `CaseRun API returned HTTP ${response.status}`, body?.error?.details ?? { status: response.status }); + } + return body; + }, + close: async () => {} + }; +} + +export function caseRunAccepted(record: CaseRunRecord | null) { + if (!record) throw new CaseRunTransportError("caserun_start_failed", "CaseRun start did not return a run record"); + return { + ok: true, + contractVersion: "hwlab-caserun-web-v1", + runId: record.runId, + caseId: record.caseId, + workflowId: record.workflowId, + workflowRunId: record.workflowRunId, + status: record.status, + stage: record.stage, + statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`, + eventsUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`, + aggregateUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`, + cancelUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/cancel` + }; +} + +function acceptedRecord(record: CaseRunRecord | null) { + if (!record) throw new CaseRunTransportError("caserun_cancel_failed", "CaseRun cancel did not return a run record"); + return record; +} + +function apiOrigin(value: string) { + const text = String(value ?? "").trim(); + if (!text) throw new CaseRunTransportError("caserun_base_url_required", "--base-url is required with --over-api"); + try { + const url = new URL(text); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.pathname !== "/" || url.search || url.hash) throw new Error("origin required"); + return url.origin; + } catch { + throw new CaseRunTransportError("invalid_caserun_base_url", "--base-url must be an absolute HTTP(S) origin", { baseUrl: text }); + } +} + +function typedError(error: any, fallbackCode: string) { + if (error instanceof CaseRunTransportError) return error; + return new CaseRunTransportError(error?.code ?? fallbackCode, error?.message ?? String(error), error?.details); +} diff --git a/tools/hwlab-cli/caserun-transport.test.ts b/tools/hwlab-cli/caserun-transport.test.ts new file mode 100644 index 00000000..fb31db07 --- /dev/null +++ b/tools/hwlab-cli/caserun-transport.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import type { CaseRunRecord } from "../../internal/harnessrl/contracts.ts"; +import { CaseRunTransportError, createApiCaseRunTransport, createLocalCaseRunTransport } from "../../internal/harnessrl/transport.ts"; +import { runHwlabCli } from "../src/hwlab-cli-lib.ts"; + +const record: CaseRunRecord = { + runId: "run-2617", caseId: "case-2617", workflowId: "harnessrl-caserun-run-2617", workflowRunId: "workflow-run-2617", + status: "queued", stage: "queued", terminal: false, caseRepo: "/cases", runDir: "/state/run-2617", runtimeApiUrl: "local://harnessrl", + result: null, error: null, blocker: null, createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" +}; + +test("L0 local adapter executes the shared CaseRun command contract without HTTP", async () => { + const calls: string[] = []; + const transport = createLocalCaseRunTransport({ service: { + async submit() { calls.push("start"); return record; }, + async getRun() { calls.push("status"); return record; }, + async events() { calls.push("events"); return { ...record, events: [] }; }, + async aggregate() { calls.push("aggregate"); return { ok: true, runId: record.runId, status: record.status }; }, + async cancel() { calls.push("cancel"); return { ...record, status: "cancel_requested", stage: "cancel_requested" }; } + }}); + + const started = await transport.execute({ kind: "start", caseId: record.caseId, runId: record.runId }); + assert.equal(started.runId, record.runId); + assert.equal(started.workflowId, record.workflowId); + await transport.execute({ kind: "status", runId: record.runId }); + await transport.execute({ kind: "events", runId: record.runId }); + await transport.execute({ kind: "aggregate", runId: record.runId }); + await transport.execute({ kind: "cancel", runId: record.runId }); + assert.deepEqual(calls, ["start", "status", "events", "aggregate", "cancel"]); +}); + +test("CLI defaults to local and requires explicit over-api for base-url", async () => { + const local = createLocalCaseRunTransport({ service: { + async submit() { return record; }, async getRun() { return record; }, async events() { return { ...record, events: [] }; }, + async aggregate() { return { ok: true, runId: record.runId }; }, async cancel() { return record; } + }}); + const result = await runHwlabCli(["case", "run", "start", record.caseId, "--run-id", record.runId], { caseRunTransportFactory: () => local }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.transport, "local"); + assert.equal(result.payload.workflowRunId, record.workflowRunId); + + const rejected = await runHwlabCli(["case", "run", "status", record.runId, "--base-url", "http://api.test"]); + assert.equal(rejected.exitCode, 1); + assert.equal(rejected.payload.error.code, "caserun_over_api_required"); +}); + +test("API adapter uses only the explicit CaseRun origin and preserves typed errors", async () => { + const requests: Array<{ url: string; method: string; authorization: string | null }> = []; + const transport = createApiCaseRunTransport({ baseUrl: "http://api.test", apiKey: "hwl_live_test", fetchImpl: async (input, init) => { + requests.push({ url: String(input), method: String(init?.method), authorization: new Headers(init?.headers).get("authorization") }); + return Response.json({ ok: true, ...record }); + }}); + const response = await transport.execute({ kind: "events", runId: record.runId }); + assert.equal(response.workflowId, record.workflowId); + assert.deepEqual(requests, [{ url: `http://api.test/v1/caserun/runs/${record.runId}/events`, method: "GET", authorization: "Bearer hwl_live_test" }]); + + const failed = createApiCaseRunTransport({ baseUrl: "http://api.test", fetchImpl: async () => Response.json({ ok: false, error: { code: "caserun_run_not_found", message: "missing", details: { runId: record.runId } } }, { status: 404 }) }); + await assert.rejects(failed.execute({ kind: "status", runId: record.runId }), (error: any) => error instanceof CaseRunTransportError && error.code === "caserun_run_not_found"); +}); diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index c65c73d5..5d231a6a 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -224,79 +224,6 @@ test("case run normalizes v0.3 HWPOD build operation topology and typed wait blo assert.equal(yamlEmpty.topology.ok, true); }); -test("case run start returns immediately and status/result/logs are short polling commands", async () => { - const root = await mkdtempCaseRoot(); - const caseRepo = path.join(root, "hwlab-case-registry"); - const cwd = path.join(root, "hwlab"); - const caseId = "d601-f103-v2-compile"; - try { - await createCaseRepo(caseRepo, caseId); - await mkdir(cwd, { recursive: true }); - let workerArgs: string[] = []; - const started = await runHwlabCli([ - "case", - "run", - "start", - caseId, - "--case-repo", - caseRepo, - "--run-id", - "run-async", - "--agent-timeout-ms", - "180000", - "--job-timeout-ms", - "70000" - ], { - cwd, - now: () => "2026-06-05T00:00:00.000Z", - env: TEST_RUNTIME_ENV, - startProcess: async (input) => { - workerArgs = input.args; - await writeFile(input.stdoutPath, "worker accepted\n", "utf8"); - await writeFile(input.stderrPath, "", "utf8"); - return { pid: 4242 }; - } - }); - - assert.equal(started.exitCode, 0); - assert.equal(started.payload.action, "case.run.start"); - assert.equal(started.payload.status, "submitted"); - assert.equal(started.payload.startReturned, true); - assert.equal(started.payload.pid, 4242); - assert.match(started.payload.statusCommand, /case run status run-async/u); - assert.match(started.payload.resultCommand, /case run result run-async/u); - assert.match(started.payload.logsCommand, /case run logs run-async/u); - assert.equal(workerArgs.includes("--agent-timeout-ms"), true); - assert.equal(workerArgs[workerArgs.indexOf("--agent-timeout-ms") + 1], "180000"); - assert.equal(workerArgs.includes("--job-timeout-ms"), true); - assert.equal(workerArgs[workerArgs.indexOf("--job-timeout-ms") + 1], "70000"); - - const status = await runHwlabCli(["case", "run", "status", "run-async"], { cwd, now: () => "2026-06-05T00:00:01.000Z" }); - assert.equal(status.exitCode, 0); - assert.equal(status.payload.action, "case.run.status"); - assert.equal(status.payload.status, "running"); - assert.equal(status.payload.stage, "started"); - assert.equal(status.payload.pid, 4242); - assert.equal(status.payload.stdoutBytes, Buffer.byteLength("worker accepted\n")); - assert.equal(status.payload.stderrBytes, 0); - assert.match(status.payload.nextPollCommand, /case run status run-async/u); - - const result = await runHwlabCli(["case", "run", "result", "run-async"], { cwd, now: () => "2026-06-05T00:00:02.000Z" }); - assert.equal(result.exitCode, 0); - assert.equal(result.payload.action, "case.run.result"); - assert.equal(result.payload.ready, false); - assert.equal(result.payload.status, "running"); - assert.match(result.payload.nextPollCommand, /case run status run-async/u); - - const logs = await runHwlabCli(["case", "run", "logs", "run-async", "--tail", "200"], { cwd, now: () => "2026-06-05T00:00:03.000Z" }); - assert.equal(logs.exitCode, 0); - assert.equal(logs.payload.action, "case.run.logs"); - assert.equal(logs.payload.stdout, "worker accepted\n"); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test("case run result finalizes legacy build-completed state and refreshes registry artifacts", async () => { const root = await mkdtempCaseRoot(); const caseRepo = path.join(root, "hwlab-case-registry"); @@ -738,56 +665,6 @@ test("case run archives rendered trace transcript when finalResponse is null", a } }); -test("case run status reports latest run stage when async control stage is stale", async () => { - const root = await mkdtempCaseRoot(); - const cwd = path.join(root, "hwlab"); - const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-stale-stage"); - try { - await mkdir(runDir, { recursive: true }); - await writeFile(path.join(runDir, "worker.stdout.log"), "agent finished\n", "utf8"); - await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8"); - await writeFile(path.join(runDir, "run.json"), JSON.stringify({ - caseId: "d601-f103-v2-compile", - runId: "run-stale-stage", - runDir, - caseRepo: "", - caseDir: "", - caseFile: "", - sourceSpecPath: "", - specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), - apiUrl: "http://api.test", - compileOnly: true, - createdAt: "2026-06-05T00:00:00.000Z", - updatedAt: "2026-06-05T00:00:10.000Z", - status: "running", - stage: "agent-running", - definition: {}, - subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-stale-stage", workspacePath: "", setup: {} }, - agent: { stageStatus: "completed", baseUrl: "http://web.test", projectId: "prj_hwpod_workbench", providerProfile: "deepseek", conversationId: "cnv_case", sessionId: "ses_case", threadId: "thread-case", traceId: "trc_case", promptPath: path.join(runDir, "agent-prompt.md"), promptSha256: "sha", polls: 3, lastHttpStatus: 200 }, - agentDiff: { statusShort: " M projects/01_baseline/main.c\n", diffStat: " projects/01_baseline/main.c | 1 +\n", diffPatchPath: path.join(runDir, "agent-diff.patch"), diffPatchSha256: "sha" }, - _control: { status: "running", stage: "prepared", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" } - }, null, 2), "utf8"); - - const status = await runHwlabCli(["case", "run", "status", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:12.000Z" }); - assert.equal(status.exitCode, 0); - assert.equal(status.payload.stage, "agent-diff-collected"); - assert.equal(status.payload.staleControlWarning.code, "stale_control_stage"); - assert.equal(status.payload.staleControlWarning.controlStage, "prepared"); - assert.equal(status.payload.staleControlWarning.selectedStage, "agent-diff-collected"); - assert.equal(status.payload.agent.stageStatus, "completed"); - assert.match(status.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u); - - const result = await runHwlabCli(["case", "run", "result", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:13.000Z" }); - assert.equal(result.exitCode, 0); - assert.equal(result.payload.ready, false); - assert.equal(result.payload.stage, "agent-diff-collected"); - assert.equal(result.payload.traceLookup.commands.trace, "hwlab-cli client agent trace trc_case --render web"); - assert.equal(result.payload.staleControlWarning.code, "stale_control_stage"); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test("case aggregate writes one registry markdown entry without grading or broad registry commits", async () => { const root = await mkdtempCaseRoot(); const caseRepo = path.join(root, "hwlab-case-registry"); @@ -909,43 +786,6 @@ test("case aggregate writes one registry markdown entry without grading or broad } }); -test("case run status keeps worker control stage ahead of prepared worktree evidence", async () => { - const root = await mkdtempCaseRoot(); - const cwd = path.join(root, "hwlab"); - const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-worker-started"); - try { - await mkdir(runDir, { recursive: true }); - await writeFile(path.join(runDir, "worker.stdout.log"), "", "utf8"); - await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8"); - await writeFile(path.join(runDir, "run.json"), JSON.stringify({ - caseId: "d601-f103-v2-compile", - runId: "run-worker-started", - runDir, - caseRepo: "", - caseDir: "", - caseFile: "", - sourceSpecPath: "", - specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), - apiUrl: "http://api.test", - compileOnly: true, - createdAt: "2026-06-05T00:00:00.000Z", - updatedAt: "2026-06-05T00:00:10.000Z", - status: "running", - stage: "queued", - definition: {}, - subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-worker-started", workspacePath: "", setup: {} }, - _control: { status: "running", stage: "started", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" } - }, null, 2), "utf8"); - - const status = await runHwlabCli(["case", "run", "status", "run-worker-started"], { cwd, now: () => "2026-06-05T00:00:12.000Z" }); - assert.equal(status.exitCode, 0); - assert.equal(status.payload.stage, "started"); - assert.equal(status.payload.staleControlWarning, undefined); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - async function mkdtempCaseRoot() { return await import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-"))); } diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index 820ad213..d44a51fb 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -13,6 +13,8 @@ import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseReg import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; +import { harnessRLRuntime } from "../../internal/harnessrl/runtime.ts"; +import { createApiCaseRunTransport, createLocalCaseRunTransport, type CaseRunCommand, type CaseRunTransport } from "../../internal/harnessrl/transport.ts"; import { caseValidationPlanFromDefinition, hwpodCommandForValidationStep, normalizeValidationStepResult, validationReplayRelationship } from "./hwlab-caserun-validation.ts"; import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom, slug } from "./hwlab-caserun-subject.ts"; import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts"; @@ -45,8 +47,7 @@ export async function caseCommand(context: CaseContext) { async function caseRunCommand(context: CaseContext) { const mode = context.rest[1] || ""; - if (mode === "start") return startCaseRun(context); - if (mode === "status") return statusCaseRun(context); + if (["start", "status", "events", "aggregate", "cancel"].includes(mode)) return harnessRLCaseRunCommand(context, mode as CaseRunCommand["kind"]); if (mode === "result") return resultCaseRun(context); if (mode === "logs") return logsCaseRun(context); if (mode === "inspect") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "inspect"); @@ -58,7 +59,11 @@ async function caseRunCommand(context: CaseContext) { export function caseHelp() { return ok("case.help", { - serviceRuntime: false, + runTransport: { + default: "local HarnessRL application service", + overApi: "explicit --over-api --base-url ", + fallback: false + }, compileOnlyDefault: true, agentTaskRequiredForRun: false, autoEvaluation: false, @@ -78,9 +83,11 @@ export function caseHelp() { `case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`, `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`, `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, - `case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`, - `case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`, - `case run logs RUN_ID [--tail 8000]`, + `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url [--run-id RUN_ID]`, + "case run status RUN_ID [--over-api --base-url ]", + "case run events RUN_ID [--over-api --base-url ]", + "case run aggregate RUN_ID [--over-api --base-url ]", + "case run cancel RUN_ID [--over-api --base-url ]", `case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`, `case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`, `case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]` @@ -92,12 +99,44 @@ export function caseHelp() { "case aggregate is registry-only and offline by default; it reads existing artifacts without querying runtime.", "case refresh/backfill defaults to local registry artifacts; only explicit --source runtime may query the existing trace CLI.", "case run inspect/trace-summary/skill-usage are bounded, service-free registry diagnostics.", - "case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.", + "case run start/status/events/aggregate/cancel share one HarnessRL command/DTO/error contract; start returns immediately and the remaining commands progressively disclose durable state.", + "The default local adapter calls the native HarnessRL application service directly; only explicit --over-api calls HTTP, and neither transport falls back to the other.", "This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence." ] }); } +async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunCommand["kind"]) { + const command: CaseRunCommand = kind === "start" + ? { kind, caseId: requiredText(context.parsed.caseId ?? context.rest[2], "caseId"), ...(text(context.parsed.runId) ? { runId: text(context.parsed.runId) } : {}) } + : { kind, runId: requiredText(context.parsed.runId ?? context.rest[2], "runId") }; + const transport = await (context.caseRunTransportFactory?.(context) ?? createHarnessRLTransport(context)); + try { + const response = await transport.execute(command); + return ok(`case.run.${kind}`, { + transport: transport.mode, + command: kind, + ...response, + ...(kind === "start" ? { startReturned: true } : {}) + }, kind === "start" ? "submitted" : "succeeded"); + } finally { + await transport.close(); + } +} + +function createHarnessRLTransport(context: CaseContext): CaseRunTransport { + if (context.parsed.overApi === true) return createApiCaseRunTransport({ baseUrl: text(context.parsed.baseUrl), fetchImpl: context.fetchImpl, apiKey: text(context.env.HWLAB_API_KEY) || undefined }); + if (context.parsed.baseUrl) throw cliError("caserun_over_api_required", "--base-url is only valid with explicit --over-api"); + const env = { + ...context.env, + HWLAB_CASERUN_REPO_ROOT: context.cwd, + ...(text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) ? { HWLAB_CASERUN_CASE_REPO: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) } : {}), + ...(text(context.parsed.stateDir) ? { HARNESSRL_STATE_DIR: text(context.parsed.stateDir) } : {}) + }; + const runtime = harnessRLRuntime(env); + return createLocalCaseRunTransport({ service: runtime.service, close: () => runtime.service.close() }); +} + export async function promptCaseRun(context: CaseContext) { const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId"); const caseRepo = await resolveCaseRepo(context); diff --git a/tools/src/hwlab-caserun-shared.ts b/tools/src/hwlab-caserun-shared.ts index 8e90e4be..f53e7a4d 100644 --- a/tools/src/hwlab-caserun-shared.ts +++ b/tools/src/hwlab-caserun-shared.ts @@ -21,6 +21,7 @@ export type CaseContext = { sleep: (ms: number) => Promise; runProcess?: RunProcessLike; startProcess?: StartProcessLike; + caseRunTransportFactory?: (context: CaseContext) => Promise | import("../../internal/harnessrl/transport.ts").CaseRunTransport; argv?: string[]; rest: string[]; }; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 09ff8d28..411e4c7c 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -36,6 +36,7 @@ type CliOptions = { sleep?: (ms: number) => Promise; runProcess?: (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; startProcess?: (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; + caseRunTransportFactory?: (context: any) => Promise | any; }; export async function main(argv = process.argv.slice(2), options: CliOptions = {}) { @@ -60,6 +61,7 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) { sleep: options.sleep ?? wait, runProcess: options.runProcess, startProcess: options.startProcess, + caseRunTransportFactory: options.caseRunTransportFactory, argv }; const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : target === "case" ? await caseCommand({ ...context, rest: parsed._.slice(1) }) : help(); From de2d1f0d0c225b30c87487fcb05d6d22e4f40afa Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 06:18:25 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E6=94=B6=E5=8F=A3=20CaseRun=20API?= =?UTF-8?q?=20=E8=B6=85=E6=97=B6=E4=B8=8E=E8=B7=AF=E5=BE=84=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/harnessrl/transport.ts | 65 ++++++++++++++++++----- tools/hwlab-cli/caserun-transport.test.ts | 30 +++++++++++ tools/src/hwlab-caserun-runtime.ts | 13 ++--- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/internal/harnessrl/transport.ts b/internal/harnessrl/transport.ts index 7eaa7deb..90d122c2 100644 --- a/internal/harnessrl/transport.ts +++ b/internal/harnessrl/transport.ts @@ -17,10 +17,13 @@ export type HarnessRLService = { export type CaseRunTransport = { mode: "local" | "api"; + visibility(command: CaseRunCommand): Record; execute(command: CaseRunCommand): Promise>; close(): Promise; }; +const DEFAULT_API_TIMEOUT_MS = 30000; + export class CaseRunTransportError extends Error { code: string; details?: unknown; @@ -36,6 +39,9 @@ export class CaseRunTransportError extends Error { export function createLocalCaseRunTransport(options: { service: HarnessRLService; close?: () => Promise }): CaseRunTransport { return { mode: "local", + visibility() { + return { service: "native-harnessrl-application-service", baseUrl: null, route: null, method: null }; + }, async execute(command) { try { if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, runtimeApiUrl: "local://harnessrl" })); @@ -51,26 +57,45 @@ export function createLocalCaseRunTransport(options: { service: HarnessRLService }; } -export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string }): CaseRunTransport { +export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string; timeoutMs?: number }): CaseRunTransport { const baseUrl = apiOrigin(options.baseUrl); const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = apiTimeoutMs(options.timeoutMs); return { mode: "api", + visibility(command) { + return apiRequestVisibility(baseUrl, command); + }, async execute(command) { - const runPath = command.kind === "start" ? "/v1/caserun/runs" : `/v1/caserun/runs/${encodeURIComponent(command.runId)}${command.kind === "status" ? "" : `/${command.kind}`}`; - const response = await fetchImpl(`${baseUrl}${runPath}`, { - method: command.kind === "start" || command.kind === "cancel" ? "POST" : "GET", - headers: { - ...(command.kind === "start" ? { "content-type": "application/json" } : {}), - ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}) - }, - body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}) }) : undefined - }).catch((error) => { throw typedError(error, "caserun_api_unavailable"); }); - const body = await response.json().catch(() => null) as any; - if (!response.ok || !body || typeof body !== "object") { - throw new CaseRunTransportError(body?.error?.code ?? "caserun_api_error", body?.error?.message ?? `CaseRun API returned HTTP ${response.status}`, body?.error?.details ?? { status: response.status }); + const request = apiRequestVisibility(baseUrl, command); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${baseUrl}${request.route}`, { + method: request.method, + signal: controller.signal, + headers: { + ...(command.kind === "start" ? { "content-type": "application/json" } : {}), + ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}) + }, + body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}) }) : undefined + }); + const body = await response.json().catch((error) => { + if (controller.signal.aborted) throw error; + return null; + }) as any; + if (!response.ok || !body || typeof body !== "object") { + throw new CaseRunTransportError(body?.error?.code ?? "caserun_api_error", body?.error?.message ?? `CaseRun API returned HTTP ${response.status}`, body?.error?.details ?? { status: response.status }); + } + return body; + } catch (error) { + if (controller.signal.aborted) { + throw new CaseRunTransportError("caserun_api_timeout", `CaseRun API request timed out after ${timeoutMs}ms`, { timeoutMs, baseUrl, route: request.route, method: request.method }); + } + throw typedError(error, "caserun_api_unavailable"); + } finally { + clearTimeout(timer); } - return body; }, close: async () => {} }; @@ -111,6 +136,18 @@ function apiOrigin(value: string) { } } +function apiTimeoutMs(value: number | undefined) { + const timeoutMs = value ?? DEFAULT_API_TIMEOUT_MS; + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new CaseRunTransportError("invalid_caserun_timeout", "--timeout-ms must be a positive integer", { timeoutMs }); + return timeoutMs; +} + +function apiRequestVisibility(baseUrl: string, command: CaseRunCommand) { + const route = command.kind === "start" ? "/v1/caserun/runs" : `/v1/caserun/runs/${encodeURIComponent(command.runId)}${command.kind === "status" ? "" : `/${command.kind}`}`; + const method = command.kind === "start" || command.kind === "cancel" ? "POST" : "GET"; + return { baseUrl, route, method }; +} + function typedError(error: any, fallbackCode: string) { if (error instanceof CaseRunTransportError) return error; return new CaseRunTransportError(error?.code ?? fallbackCode, error?.message ?? String(error), error?.details); diff --git a/tools/hwlab-cli/caserun-transport.test.ts b/tools/hwlab-cli/caserun-transport.test.ts index fb31db07..5be1110a 100644 --- a/tools/hwlab-cli/caserun-transport.test.ts +++ b/tools/hwlab-cli/caserun-transport.test.ts @@ -39,6 +39,10 @@ test("CLI defaults to local and requires explicit over-api for base-url", async const result = await runHwlabCli(["case", "run", "start", record.caseId, "--run-id", record.runId], { caseRunTransportFactory: () => local }); assert.equal(result.exitCode, 0); assert.equal(result.payload.transport, "local"); + assert.equal(result.payload.service, "native-harnessrl-application-service"); + assert.equal(result.payload.baseUrl, null); + assert.equal(result.payload.route, null); + assert.equal(result.payload.method, null); assert.equal(result.payload.workflowRunId, record.workflowRunId); const rejected = await runHwlabCli(["case", "run", "status", record.runId, "--base-url", "http://api.test"]); @@ -59,3 +63,29 @@ test("API adapter uses only the explicit CaseRun origin and preserves typed erro const failed = createApiCaseRunTransport({ baseUrl: "http://api.test", fetchImpl: async () => Response.json({ ok: false, error: { code: "caserun_run_not_found", message: "missing", details: { runId: record.runId } } }, { status: 404 }) }); await assert.rejects(failed.execute({ kind: "status", runId: record.runId }), (error: any) => error instanceof CaseRunTransportError && error.code === "caserun_run_not_found"); }); + +test("API adapter times out through its real AbortSignal path with typed route details", async () => { + const transport = createApiCaseRunTransport({ baseUrl: "http://api.test", timeoutMs: 5, fetchImpl: async (_input, init) => { + await new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true })); + throw new Error("unreachable"); + }}); + await assert.rejects(transport.execute({ kind: "status", runId: record.runId }), (error: any) => { + assert.equal(error instanceof CaseRunTransportError, true); + assert.equal(error.code, "caserun_api_timeout"); + assert.deepEqual(error.details, { timeoutMs: 5, baseUrl: "http://api.test", route: `/v1/caserun/runs/${record.runId}`, method: "GET" }); + return true; + }); +}); + +test("over-api CLI discloses bounded origin route and method without credentials", async () => { + const result = await runHwlabCli(["case", "run", "events", record.runId, "--over-api", "--base-url", "http://api.test", "--timeout-ms", "25"], { + env: { HWLAB_API_KEY: "hwl_live_secret" }, + fetchImpl: async () => Response.json({ ok: true, ...record, events: [] }) + }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.transport, "api"); + assert.equal(result.payload.baseUrl, "http://api.test"); + assert.equal(result.payload.route, `/v1/caserun/runs/${record.runId}/events`); + assert.equal(result.payload.method, "GET"); + assert.equal(JSON.stringify(result.payload).includes("hwl_live_secret"), false); +}); diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index d44a51fb..c531cf48 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -83,11 +83,11 @@ export function caseHelp() { `case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`, `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`, `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, - `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url [--run-id RUN_ID]`, - "case run status RUN_ID [--over-api --base-url ]", - "case run events RUN_ID [--over-api --base-url ]", - "case run aggregate RUN_ID [--over-api --base-url ]", - "case run cancel RUN_ID [--over-api --base-url ]", + `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url [--timeout-ms 30000] [--run-id RUN_ID]`, + "case run status RUN_ID [--over-api --base-url --timeout-ms 30000]", + "case run events RUN_ID [--over-api --base-url --timeout-ms 30000]", + "case run aggregate RUN_ID [--over-api --base-url --timeout-ms 30000]", + "case run cancel RUN_ID [--over-api --base-url --timeout-ms 30000]", `case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`, `case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`, `case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]` @@ -116,6 +116,7 @@ async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunComman return ok(`case.run.${kind}`, { transport: transport.mode, command: kind, + ...transport.visibility(command), ...response, ...(kind === "start" ? { startReturned: true } : {}) }, kind === "start" ? "submitted" : "succeeded"); @@ -125,7 +126,7 @@ async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunComman } function createHarnessRLTransport(context: CaseContext): CaseRunTransport { - if (context.parsed.overApi === true) return createApiCaseRunTransport({ baseUrl: text(context.parsed.baseUrl), fetchImpl: context.fetchImpl, apiKey: text(context.env.HWLAB_API_KEY) || undefined }); + if (context.parsed.overApi === true) return createApiCaseRunTransport({ baseUrl: text(context.parsed.baseUrl), fetchImpl: context.fetchImpl, apiKey: text(context.env.HWLAB_API_KEY) || undefined, timeoutMs: firstNumberOption(context.parsed.timeoutMs) }); if (context.parsed.baseUrl) throw cliError("caserun_over_api_required", "--base-url is only valid with explicit --over-api"); const env = { ...context.env,