From 77979025e74a3923a934633db38f0a3b46748280 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:00:23 +0800 Subject: [PATCH] feat: add web caserun compile path (#2117) --- cases/d601-f103-v2-compile/case.json | 28 ++ cases/d601-f103-v2-compile/hwpod-spec.yaml | 29 ++ internal/cloud/server-caserun-http.test.ts | 116 ++++++ internal/cloud/server-caserun-http.ts | 380 ++++++++++++++++++ internal/cloud/server.ts | 14 + tools/src/hwlab-caserun-lib.ts | 11 +- web/hwlab-cloud-web/src/api/caserun.ts | 15 + web/hwlab-cloud-web/src/api/index.ts | 3 + .../src/components/caserun/CaseRunPanel.vue | 83 ++++ web/hwlab-cloud-web/src/stores/caserun.ts | 66 +++ web/hwlab-cloud-web/src/stores/index.ts | 1 + web/hwlab-cloud-web/src/styles/workbench.css | 152 ++++++- web/hwlab-cloud-web/src/types/index.ts | 6 + .../src/views/workbench/CodeWorkbenchView.vue | 6 +- 14 files changed, 905 insertions(+), 5 deletions(-) create mode 100644 cases/d601-f103-v2-compile/case.json create mode 100644 cases/d601-f103-v2-compile/hwpod-spec.yaml create mode 100644 internal/cloud/server-caserun-http.test.ts create mode 100644 internal/cloud/server-caserun-http.ts create mode 100644 web/hwlab-cloud-web/src/api/caserun.ts create mode 100644 web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue create mode 100644 web/hwlab-cloud-web/src/stores/caserun.ts diff --git a/cases/d601-f103-v2-compile/case.json b/cases/d601-f103-v2-compile/case.json new file mode 100644 index 00000000..97c3764e --- /dev/null +++ b/cases/d601-f103-v2-compile/case.json @@ -0,0 +1,28 @@ +{ + "contractVersion": "hwpod-case-v1", + "caseId": "d601-f103-v2-compile", + "title": "D601-F103-V2 Keil compile-only CaseRun", + "mode": "compile-only", + "hwpodSpec": "hwpod-spec.yaml", + "subject": { + "repoLocalPath": "F:\\Work\\HWLAB-CASE-F103", + "commitId": "df7a4e6e551fa90d64bde5537cc000f89d63dd20", + "subdir": "projects/01_baseline" + }, + "runtime": { + "apiUrl": "http://74.48.78.17:19667" + }, + "expected": { + "compile": { + "success": true, + "artifactSuffixes": [ + "projects/01_baseline/Output/atk_f103.hex", + "projects/01_baseline/Output/atk_f103.axf" + ] + }, + "download": { + "skipped": true, + "reason": "current acceptance line only validates compile evidence" + } + } +} diff --git a/cases/d601-f103-v2-compile/hwpod-spec.yaml b/cases/d601-f103-v2-compile/hwpod-spec.yaml new file mode 100644 index 00000000..cfd7477c --- /dev/null +++ b/cases/d601-f103-v2-compile/hwpod-spec.yaml @@ -0,0 +1,29 @@ +apiVersion: hwlab.dev/v0alpha1 +kind: Hwpod +metadata: + uid: D601-F103-V2 + name: d601-f103-v2 +spec: + targetDevice: + board: D601-F103-V2 + mcu: STM32F103 + workspace: + path: "F:\\Work\\HWLAB-CASE-F103" + toolchain: keil-mdk + keilProject: projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx + keilTarget: USART + keilCliPath: "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py" + debugProbe: + type: daplink + adapter: keil + probeUid: 3FD750C63E342E24 + probeName: MicroLink CMSIS-DAP + programBackend: keil + ioProbe: + uart: + id: uart/1 + port: COM9 + baudrate: 115200 + nodeBinding: + nodeId: node-d601-f103-v2 + nodeType: pc-host diff --git a/internal/cloud/server-caserun-http.test.ts b/internal/cloud/server-caserun-http.test.ts new file mode 100644 index 00000000..b7856ee7 --- /dev/null +++ b/internal/cloud/server-caserun-http.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "bun:test"; + +import { createCloudApiServer } from "./server.ts"; + +test("cloud-api exposes web CaseRun cases, run status, events and aggregate", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-api-")); + const stateRoot = path.join(root, "state"); + const caseDir = path.join(root, "cases", "d601-f103-v2-compile"); + await mkdir(caseDir, { recursive: true }); + await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ + contractVersion: "hwpod-case-v1", + caseId: "d601-f103-v2-compile", + title: "D601 compile", + mode: "compile-only", + hwpodSpec: "hwpod-spec.yaml", + subject: { repoLocalPath: "F:\\Work\\HWLAB-CASE-F103", commitId: "abc", subdir: "projects/01_baseline" } + }), "utf8"); + await writeFile(path.join(caseDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8"); + const seen: any[] = []; + const server = createCloudApiServer({ + env: { PATH: process.env.PATH, HWLAB_CASERUN_CASE_REPO: root, HWLAB_CASERUN_STATE_DIR: stateRoot }, + caseRunExecutor: async (context: any, emit: any) => { + seen.push(context.parsed); + await emit("prepared", { runDir: context.parsed.runDir }); + await emit("build-completed", { jobId: "job-test" }); + return { + summary: { status: "recorded", jobId: "job-test", artifactCount: 2, artifacts: [{ path: "atk_f103.hex" }, { path: "atk_f103.axf" }] }, + evidence: { status: "recorded", keilJob: { jobId: "job-test", status: "completed" }, artifacts: [{ path: "atk_f103.hex" }, { path: "atk_f103.axf" }] }, + run: { runId: context.parsed.runId, caseId: context.parsed.caseId } + }; + } + }); + await listen(server); + try { + const indexResponse = await fetch(`${serverUrl(server)}/v1`); + const indexPayload = await indexResponse.json(); + assert.equal(indexPayload.caserun.route, "/v1/caserun"); + + const casesResponse = await fetch(`${serverUrl(server)}/v1/caserun/cases`); + const casesPayload = await casesResponse.json(); + assert.equal(casesResponse.status, 200); + assert.equal(casesPayload.count, 1); + assert.equal(casesPayload.cases[0].caseId, "d601-f103-v2-compile"); + + const startResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ caseId: "d601-f103-v2-compile" }) + }); + const startPayload = await startResponse.json(); + assert.equal(startResponse.status, 202); + assert.ok(["queued", "running"].includes(startPayload.status)); + assert.match(startPayload.runId, /^web-d601-f103-v2-compile-/u); + + const runPayload = await waitForRun(server, startPayload.runId, "completed"); + assert.equal(runPayload.summary.jobId, "job-test"); + assert.equal(seen[0].noCaseRepoRecord, true); + assert.equal(seen[0].caseRepo, root); + + const eventsResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs/${startPayload.runId}/events`); + const eventsPayload = await eventsResponse.json(); + assert.equal(eventsResponse.status, 200); + assert.ok(eventsPayload.events.some((event: any) => event.stage === "build-completed")); + + const aggregateResponse = await fetch(`${serverUrl(server)}/v1/caserun/runs/${startPayload.runId}/aggregate`); + const aggregatePayload = await aggregateResponse.json(); + assert.equal(aggregateResponse.status, 200); + assert.equal(aggregatePayload.summary.jobId, "job-test"); + assert.match(aggregatePayload.sha256, /^[a-f0-9]{64}$/u); + } finally { + await close(server); + await rm(root, { recursive: true, force: true }); + } +}); + +async function waitForRun(server: any, runId: string, status: string) { + const deadline = Date.now() + 3000; + let last: any = null; + while (Date.now() < deadline) { + const response = await fetch(`${serverUrl(server)}/v1/caserun/runs/${runId}`); + last = await response.json(); + if (last.status === status) return last; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.fail(`run did not reach ${status}: ${JSON.stringify(last)}`); +} + +async function listen(server: any) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +async function close(server: any) { + await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined)))); +} + +function serverUrl(server: any) { + return `http://127.0.0.1:${server.address().port}`; +} + +function sampleSpecYaml() { + return `apiVersion: hwlab.dev/v0alpha1 +kind: Hwpod +metadata: + uid: D601-F103-V2 + name: d601-f103-v2 +spec: + workspace: + path: "F:\\\\Work\\\\HWLAB-CASE-F103" + nodeBinding: + nodeId: node-d601-f103-v2 +`; +} diff --git a/internal/cloud/server-caserun-http.ts b/internal/cloud/server-caserun-http.ts new file mode 100644 index 00000000..cf49aa56 --- /dev/null +++ b/internal/cloud/server-caserun-http.ts @@ -0,0 +1,380 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { buildCaseRun, collectCaseRun, prepareCaseRun } from "../../tools/src/hwlab-caserun-lib.ts"; + +const CONTRACT_VERSION = "hwlab-caserun-web-v1"; +const CASES_CONTRACT_VERSION = "hwlab-caserun-cases-v1"; +const AGGREGATE_CONTRACT_VERSION = "hwlab-caserun-aggregate-v1"; +const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024; +const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u; + +const memoryRuns = new Map(); + +export async function handleCaseRunHttp(request: IncomingMessage, response: ServerResponse, url: URL, options: any = {}) { + const env = options.env ?? process.env; + const cwd = resolveCwd(env); + const stateRoot = resolveStateRoot(cwd, env); + const caseRepo = resolveCaseRepo(cwd, env); + + if (request.method === "GET" && url.pathname === "/v1/caserun") { + sendJson(response, 200, { + ok: true, + contractVersion: CONTRACT_VERSION, + casesRoute: "/v1/caserun/cases", + runsRoute: "/v1/caserun/runs", + caseAuthority: describeCaseAuthority(caseRepo, cwd), + compileOnly: true + }); + return; + } + + if (request.method === "GET" && url.pathname === "/v1/caserun/cases") { + const cases = await listCases(caseRepo); + sendJson(response, 200, { + ok: true, + contractVersion: CASES_CONTRACT_VERSION, + caseAuthority: describeCaseAuthority(caseRepo, cwd), + count: cases.length, + cases + }); + return; + } + + if (url.pathname === "/v1/caserun/runs") { + if (request.method !== "POST") return sendError(response, 405, "method_not_allowed", "POST required."); + const parsed = await readJsonBody(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + const body = parsed.body && typeof parsed.body === "object" && !Array.isArray(parsed.body) ? parsed.body as Record : {}; + const caseId = safeOpaqueId(body.caseId); + if (!caseId) return sendError(response, 400, "invalid_case_id", "caseId is required."); + const caseDefinition = await loadCase(caseRepo, caseId).catch(() => null); + if (!caseDefinition) return sendError(response, 404, "case_not_found", "CaseRun case was not found.", { caseId }); + const requestedRunId = safeOpaqueId(body.runId); + const runId = requestedRunId || nextRunId(caseId); + const runDir = path.join(stateRoot, "runs", runId); + const record = { + ok: true, + contractVersion: CONTRACT_VERSION, + runId, + caseId, + status: "queued", + stage: "queued", + runDir, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + caseAuthority: describeCaseAuthority(caseRepo, cwd), + runtimeApiUrl: runtimeApiUrlFromRequest(request, env), + events: [] as any[], + result: null as any + }; + await saveRecord(stateRoot, record); + remember(record); + appendEvent(record, "queued", "queued", { caseId }); + startBackgroundCaseRun(record, { ...options, env, cwd, stateRoot, caseRepo, caseDefinition }); + sendJson(response, 202, publicRecord(record)); + return; + } + + const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u); + if (runMatch) { + const runId = decodeURIComponent(runMatch[1] ?? ""); + if (!RUN_ID_PATTERN.test(runId)) return sendError(response, 400, "invalid_run_id", "runId is invalid."); + const suffix = runMatch[2] ?? ""; + const record = await findRecord(stateRoot, runId); + if (!record) return sendError(response, 404, "caserun_run_not_found", "CaseRun run was not found.", { runId }); + if (!suffix && request.method === "GET") return sendJson(response, 200, publicRecord(record)); + if (suffix === "events" && request.method === "GET") return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, runId, events: record.events ?? [] }); + if (suffix === "aggregate" && request.method === "GET") return sendJson(response, 200, await aggregateForRecord(record)); + if (suffix === "cancel" && request.method === "POST") return sendError(response, 409, "caserun_cancel_not_supported", "This compile-only CaseRun worker cannot be cancelled after dispatch.", { runId, status: record.status }); + return sendError(response, 405, "method_not_allowed", suffix === "cancel" ? "POST required." : "GET required."); + } + + sendError(response, 404, "caserun_route_not_found", "CaseRun REST route is not implemented.", { route: url.pathname }); +} + +function startBackgroundCaseRun(record: any, input: any) { + void runBackgroundCaseRun(record, input).catch(async (error) => { + appendEvent(record, "failed", "failed", { code: error?.code ?? error?.name ?? "caserun_failed", message: String(error?.message ?? error).slice(0, 500), details: error?.details ?? null }); + record.status = "failed"; + record.stage = "failed"; + record.error = { code: error?.code ?? error?.name ?? "caserun_failed", message: String(error?.message ?? error), details: error?.details ?? null }; + record.updatedAt = new Date().toISOString(); + await saveRecord(input.stateRoot, record).catch(() => undefined); + }); +} + +async function runBackgroundCaseRun(record: any, input: any) { + appendEvent(record, "running", "prepare", { caseId: record.caseId }); + record.status = "running"; + record.stage = "prepare"; + record.updatedAt = new Date().toISOString(); + await saveRecord(input.stateRoot, record); + const context = caseContextForRecord(record, input); + const emit = async (stage: string, payload: Record = {}) => { + appendEvent(record, "running", stage, payload); + record.stage = stage; + record.updatedAt = new Date().toISOString(); + await saveRecord(input.stateRoot, record); + }; + const executor = typeof input.caseRunExecutor === "function" ? input.caseRunExecutor : defaultCaseRunExecutor; + const result = await executor(context, emit, { record, caseDefinition: input.caseDefinition }); + record.status = "completed"; + record.stage = "completed"; + record.result = compactResult(result); + record.updatedAt = new Date().toISOString(); + appendEvent(record, "completed", "completed", { jobId: record.result?.summary?.jobId ?? record.result?.evidence?.keilJob?.jobId ?? null }); + await saveRecord(input.stateRoot, record); +} + +async function defaultCaseRunExecutor(context: any, emit: (stage: string, payload?: Record) => Promise) { + const prepared = await prepareCaseRun(context, "web"); + await emit("prepared", { runDir: prepared.runDir, specPath: prepared.specPath }); + const run = prepared.run; + const build = await buildCaseRun(context, run); + await emit("build-completed", { jobId: build.summary?.jobId ?? build.evidence?.keilJob?.jobId ?? null, hwpodExitCode: build.summary?.hwpodExitCode ?? null }); + const collected = await collectCaseRun(context, run, build.evidence); + await emit("collect-completed", { status: collected.status, artifactCount: Array.isArray(build.evidence?.artifacts) ? build.evidence.artifacts.length : 0 }); + return { prepared, build, collected, evidence: build.evidence, summary: build.summary, run: collected.run ?? build.run ?? run }; +} + +function caseContextForRecord(record: any, input: any) { + const parsed = { + _: [], + caseId: record.caseId, + runId: record.runId, + runDir: record.runDir, + caseRepo: input.caseRepo, + apiUrl: record.runtimeApiUrl, + noCaseRepoRecord: true, + stateDir: path.join(input.stateRoot, "cli") + }; + return { + parsed, + env: { + ...process.env, + ...input.env, + HWLAB_CASE_REPO: input.caseRepo, + HWLAB_RUNTIME_API_URL: record.runtimeApiUrl, + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1" + }, + fetchImpl: input.fetchImpl ?? globalThis.fetch, + cwd: input.cwd, + now: () => new Date().toISOString(), + sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + runProcess: input.caseRunProcess, + startProcess: input.caseRunStartProcess, + argv: [], + rest: ["web", record.caseId] + }; +} + +function resolveCwd(env: Record) { + return path.resolve(String(env.HWLAB_CASERUN_REPO_ROOT ?? process.cwd())); +} + +function resolveStateRoot(cwd: string, env: Record) { + return path.resolve(cwd, String(env.HWLAB_CASERUN_STATE_DIR ?? path.join(".state", "hwlab-cloud-api", "caserun"))); +} + +function resolveCaseRepo(cwd: string, env: Record) { + return path.resolve(cwd, String(env.HWLAB_CASERUN_CASE_REPO ?? env.HWLAB_CASE_REPO ?? ".")); +} + +function describeCaseAuthority(caseRepo: string, cwd: string) { + const rel = relativeOrAbsolute(cwd, caseRepo); + return { + kind: rel === "." ? "builtin-hwlab-v03" : "configured-case-repo", + repoPath: rel, + casesPath: path.join(rel, "cases") + }; +} + +async function listCases(caseRepo: string) { + const casesRoot = path.join(caseRepo, "cases"); + const entries = await readdir(casesRoot, { withFileTypes: true }).catch(() => []); + const cases = [] as any[]; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const loaded = await loadCase(caseRepo, entry.name).catch(() => null); + if (loaded) cases.push(loaded); + } + cases.sort((left, right) => String(left.caseId).localeCompare(String(right.caseId))); + return cases; +} + +async function loadCase(caseRepo: string, caseId: string) { + if (!safeOpaqueId(caseId)) throw new Error("invalid case id"); + const caseDir = path.join(caseRepo, "cases", caseId); + const caseFile = path.join(caseDir, "case.json"); + const definition = JSON.parse(await readFile(caseFile, "utf8")); + const hwpodSpec = String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml"; + const specPath = path.resolve(caseDir, hwpodSpec); + const specInfo = await stat(specPath).catch(() => null); + return { + caseId: String(definition.caseId ?? caseId).trim() || caseId, + title: String(definition.title ?? caseId).trim() || caseId, + mode: String(definition.mode ?? "compile-only").trim() || "compile-only", + hwpodSpec, + available: Boolean(specInfo?.isFile()), + subject: definition.subject ?? null, + expected: definition.expected ?? null, + runtime: definition.runtime ?? null + }; +} + +async function findRecord(stateRoot: string, runId: string) { + const memory = memoryRuns.get(runId); + if (memory) return memory; + const text = await readFile(recordPath(stateRoot, runId), "utf8").catch(() => ""); + if (!text) return null; + const record = JSON.parse(text); + remember(record); + return record; +} + +async function saveRecord(stateRoot: string, record: any) { + await mkdir(path.dirname(recordPath(stateRoot, record.runId)), { recursive: true }); + await mkdir(record.runDir, { recursive: true }); + await writeFile(recordPath(stateRoot, record.runId), `${JSON.stringify(record, null, 2)}\n`, "utf8"); +} + +function recordPath(stateRoot: string, runId: string) { + return path.join(stateRoot, "records", `${runId}.json`); +} + +function remember(record: any) { + memoryRuns.set(record.runId, record); +} + +function appendEvent(record: any, status: string, stage: string, payload: Record = {}) { + const event = { at: new Date().toISOString(), status, stage, payload }; + record.events = Array.isArray(record.events) ? [...record.events, event] : [event]; + record.updatedAt = event.at; + return event; +} + +async function aggregateForRecord(record: any) { + const evidence = record.result?.evidence ?? await readJson(path.join(record.runDir, "evidence.json")).catch(() => null); + const run = record.result?.run ?? await readJson(path.join(record.runDir, "run.json")).catch(() => null); + const summary = record.result?.summary ?? summarizeEvidence(evidence); + const source = { runId: record.runId, caseId: record.caseId, status: record.status, summary, evidence, run }; + return { + ok: true, + contractVersion: AGGREGATE_CONTRACT_VERSION, + runId: record.runId, + caseId: record.caseId, + status: record.status, + sha256: sha256Json(source), + summary, + evidence, + run + }; +} + +function compactResult(result: any) { + return { + summary: result?.summary ?? null, + evidence: result?.evidence ?? null, + run: result?.run ?? result?.collected?.run ?? result?.build?.run ?? result?.prepared?.run ?? null, + collected: result?.collected ? { status: result.collected.status, summary: result.collected.summary ?? null } : null + }; +} + +function summarizeEvidence(evidence: any) { + if (!evidence || typeof evidence !== "object") return null; + return { + status: evidence.status ?? null, + jobId: evidence.keilJob?.jobId ?? null, + keilStatus: evidence.keilJob?.status ?? null, + hwpodExitCode: evidence.hwpod?.exitCode ?? null, + artifactCount: Array.isArray(evidence.artifacts) ? evidence.artifacts.length : 0, + artifacts: evidence.artifacts ?? [] + }; +} + +function publicRecord(record: any) { + return { + ok: true, + contractVersion: CONTRACT_VERSION, + runId: record.runId, + caseId: record.caseId, + status: record.status, + stage: record.stage, + runDir: record.runDir, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + runtimeApiUrl: record.runtimeApiUrl, + caseAuthority: record.caseAuthority, + eventCount: Array.isArray(record.events) ? record.events.length : 0, + lastEvent: Array.isArray(record.events) ? record.events.at(-1) ?? null : null, + summary: record.result?.summary ?? summarizeEvidence(record.result?.evidence), + error: record.error ?? null + }; +} + +function runtimeApiUrlFromRequest(request: IncomingMessage, env: Record) { + const explicit = String(env.HWLAB_CASERUN_RUNTIME_API_URL ?? "").trim(); + if (explicit) return explicit.replace(/\/+$/u, ""); + const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "127.0.0.1:8080").split(",")[0]?.trim() || "127.0.0.1:8080"; + const proto = String(request.headers["x-forwarded-proto"] ?? "http").split(",")[0]?.trim() || "http"; + return `${proto}://${host}`.replace(/\/+$/u, ""); +} + +async function readJsonBody(request: IncomingMessage, limitBytes: number) { + let size = 0; + const chunks = [] as Buffer[]; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > limitBytes) return { ok: false, error: { ok: false, error: { code: "body_too_large", message: `body exceeds ${limitBytes} bytes` } } }; + chunks.push(buffer); + } + const body = Buffer.concat(chunks).toString("utf8").trim(); + if (!body) return { ok: true, body: {} }; + try { + return { ok: true, body: JSON.parse(body) }; + } catch (error) { + return { ok: false, error: { ok: false, error: { code: "invalid_json", message: error instanceof Error ? error.message : String(error) } } }; + } +} + +async function readJson(file: string) { + return JSON.parse(await readFile(file, "utf8")); +} + +function sendError(response: ServerResponse, statusCode: number, code: string, message: string, details: Record = {}) { + return sendJson(response, statusCode, { ok: false, error: { code, message, details } }); +} + +function sendJson(response: ServerResponse, statusCode: number, body: unknown) { + if (response.headersSent || response.writableEnded) return false; + response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(`${JSON.stringify(body)}\n`); + return true; +} + +function safeOpaqueId(value: unknown) { + const text = String(value ?? "").trim(); + return RUN_ID_PATTERN.test(text) ? text : ""; +} + +function nextRunId(caseId: string) { + const stamp = new Date().toISOString().replace(/[^0-9]/gu, "").slice(0, 14); + return `web-${slug(caseId)}-${stamp}-${randomUUID().slice(0, 8)}`; +} + +function slug(value: string) { + return String(value).trim().toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "-").replace(/^-+|-+$/gu, "") || "caserun"; +} + +function sha256Json(value: unknown) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function relativeOrAbsolute(cwd: string, target: string) { + const relative = path.relative(cwd, target) || "."; + return relative.startsWith("..") ? target : relative; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 1345d2f5..fc500aa7 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -86,6 +86,7 @@ import { hwpodSpecDiscoveryPayload, hwpodSpecWorkspaceProbePlan } from "./hwpod-spec-discovery.ts"; +import { handleCaseRunHttp } from "./server-caserun-http.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; @@ -613,6 +614,14 @@ async function handleRestAdapter(request, response, url, options) { readiness, blockers: readiness.blockers, blockerCodes: readiness.blockerCodes, + caserun: { + route: "/v1/caserun", + casesRoute: "/v1/caserun/cases", + runsRoute: "/v1/caserun/runs", + contractVersion: "hwlab-caserun-web-v1", + mode: "compile-only", + caseAuthority: "builtin-or-configured-case-repo" + }, hwpod: { route: "/v1/hwpod-node-ops", specDiscoveryRoute: "/v1/hwpod/specs", @@ -737,6 +746,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/caserun" || url.pathname.startsWith("/v1/caserun/")) { + await handleCaseRunHttp(request, response, url, options); + return; + } + if (request.method === "GET" && url.pathname === "/v1/hwpod/specs") { await handleHwpodSpecDiscoveryHttp(request, response, url, options); return; diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 4d411a37..0122e34b 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -707,12 +707,21 @@ async function resolveCaseRepo(context: CaseContext) { await readFile(path.join(candidate, ".git", "HEAD"), "utf8"); return candidate; } catch { - // Try next visible candidate. + if (context.parsed.noCaseRepoRecord === true && await statLikeDirectory(path.join(candidate, "cases"))) return candidate; } } throw cliError("case_repo_required", "case repo is required and must be a git checkout", { option: "--case-repo", standardCaseRepo: STANDARD_CASE_REPO_PATH, candidates }); } +async function statLikeDirectory(candidate: string) { + try { + const info = await stat(candidate); + return info.isDirectory(); + } catch { + return false; + } +} + function isRemovedCaseRepoPath(candidate: string) { const normalized = path.resolve(candidate); return normalized === REMOVED_CASE_REPO_PATH || normalized.endsWith("/hwpod-cases"); diff --git a/web/hwlab-cloud-web/src/api/caserun.ts b/web/hwlab-cloud-web/src/api/caserun.ts new file mode 100644 index 00000000..7fd589d6 --- /dev/null +++ b/web/hwlab-cloud-web/src/api/caserun.ts @@ -0,0 +1,15 @@ +import { fetchJson } from "./client"; +import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types"; + +export const caserunAPI = { + cases: (): Promise> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }), + startRun: (caseId: string): Promise> => fetchJson("/v1/caserun/runs", { + method: "POST", + body: JSON.stringify({ caseId }), + timeoutMs: 12000, + 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" }), + aggregate: (runId: string): Promise> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/aggregate`, { timeoutMs: 12000, timeoutName: "CaseRun aggregate" }) +}; diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 3c1a656e..987d7824 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -4,6 +4,7 @@ export { workbenchAPI } from "./workbench"; export { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "./workbench-events"; export { agentAPI } from "./agent"; export { hwpodAPI } from "./hwpod"; +export { caserunAPI } from "./caserun"; export { accessAPI } from "./access"; export { providerProfilesAPI } from "./providerProfiles"; export { apiKeysAPI } from "./apiKeys"; @@ -16,6 +17,7 @@ import { accessAPI } from "./access"; import { agentAPI } from "./agent"; import { apiKeysAPI } from "./apiKeys"; import { authAPI } from "./auth"; +import { caserunAPI } from "./caserun"; import { hwpodAPI } from "./hwpod"; import { providerProfilesAPI } from "./providerProfiles"; import { usageAPI } from "./usage"; @@ -29,6 +31,7 @@ export const api = { workbench: workbenchAPI, agent: agentAPI, hwpod: hwpodAPI, + caserun: caserunAPI, access: accessAPI, providerProfiles: providerProfilesAPI, apiKeys: apiKeysAPI, diff --git a/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue new file mode 100644 index 00000000..439d958c --- /dev/null +++ b/web/hwlab-cloud-web/src/components/caserun/CaseRunPanel.vue @@ -0,0 +1,83 @@ + + + diff --git a/web/hwlab-cloud-web/src/stores/caserun.ts b/web/hwlab-cloud-web/src/stores/caserun.ts new file mode 100644 index 00000000..b2a7d160 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/caserun.ts @@ -0,0 +1,66 @@ +import { computed, ref } from "vue"; +import { defineStore } from "pinia"; +import { caserunAPI } from "@/api"; +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); + const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? ""); + + async function refreshCases(): Promise { + loading.value = true; + error.value = null; + const result = await caserunAPI.cases(); + cases.value = result.data?.cases ?? []; + error.value = result.ok ? null : result.error ?? "CaseRun cases unavailable"; + loading.value = false; + } + + async function startRun(caseId: string): Promise { + starting.value = true; + error.value = null; + aggregate.value = null; + events.value = []; + const result = await caserunAPI.startRun(caseId); + starting.value = false; + if (!result.ok || !result.data) { + error.value = result.error ?? "CaseRun start failed"; + return null; + } + currentRun.value = result.data; + await refreshEvents(result.data.runId ?? ""); + return result.data; + } + + async function refreshRun(runId = currentRun.value?.runId ?? ""): Promise { + if (!runId) return; + const result = await caserunAPI.run(runId); + if (!result.ok || !result.data) { + error.value = result.error ?? "CaseRun run unavailable"; + return; + } + currentRun.value = result.data; + await refreshEvents(runId); + if (result.data.status === "completed") await refreshAggregate(runId); + } + + async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise { + if (!runId) return; + const result = await caserunAPI.events(runId); + if (result.ok) events.value = result.data?.events ?? []; + } + + async function refreshAggregate(runId = currentRun.value?.runId ?? ""): Promise { + if (!runId) return; + const result = await caserunAPI.aggregate(runId); + if (result.ok) aggregate.value = result.data; + } + + return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate }; +}); diff --git a/web/hwlab-cloud-web/src/stores/index.ts b/web/hwlab-cloud-web/src/stores/index.ts index b3ef694e..ce794399 100644 --- a/web/hwlab-cloud-web/src/stores/index.ts +++ b/web/hwlab-cloud-web/src/stores/index.ts @@ -3,4 +3,5 @@ export { useAuthStore } from "./auth"; export { useWorkbenchStore } from "./workbench"; export { useSessionsStore } from "./sessions"; export { useHwpodStore } from "./hwpod"; +export { useCaseRunStore } from "./caserun"; export { useAccessStore } from "./access"; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index b4283365..79c32b2f 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -643,6 +643,7 @@ .workbench-center, .session-rail, .hwpod-panel, +.caserun-panel, .data-panel, .empty-state, .metric-card, @@ -663,7 +664,8 @@ } .session-rail, -.hwpod-panel { +.hwpod-panel, +.caserun-panel { position: relative; display: grid; height: 100%; @@ -723,6 +725,146 @@ overscroll-behavior: contain; } +.workbench-tools-column { + display: grid; + min-height: 0; + min-width: 0; + grid-template-rows: minmax(260px, 0.95fr) minmax(240px, 1fr); + gap: 8px; + overflow: hidden; +} + +.caserun-panel { + align-content: stretch; + grid-template-rows: auto auto auto minmax(96px, 1fr) auto; + overflow: hidden; + overscroll-behavior: contain; +} + +.caserun-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + align-items: end; +} + +.caserun-controls label { + grid-column: 1 / -1; + color: #64748b; + font-size: 11px; + font-weight: 750; + text-transform: uppercase; +} + +.caserun-controls select { + min-width: 0; + height: 32px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: white; + padding: 0 8px; + color: #0f172a; + font-size: 12px; +} + +.caserun-controls button { + height: 32px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #f8fafc; + color: #0f172a; + font-size: 12px; + font-weight: 750; +} + +.caserun-controls button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.caserun-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin: 0; +} + +.caserun-meta div, +.caserun-aggregate { + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + padding: 7px 8px; +} + +.caserun-meta dt { + color: #64748b; + font-size: 10px; + font-weight: 750; + text-transform: uppercase; +} + +.caserun-meta dd { + margin: 2px 0 0; + color: #0f172a; + font-size: 12px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.caserun-event-panel { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + gap: 6px; +} + +.caserun-event-panel header, +.caserun-aggregate { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.caserun-event-panel header { + color: #64748b; + font-size: 11px; + font-weight: 750; + text-transform: uppercase; +} + +.caserun-event-panel pre { + min-height: 0; + margin: 0; + overflow: auto; + border: 1px solid #dbeafe; + border-radius: 6px; + background: #eff6ff; + padding: 8px; + color: #1e3a8a; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.caserun-aggregate { + color: #334155; + font-size: 11px; +} + +.caserun-aggregate strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .panel-header { display: flex; align-items: center; @@ -3179,7 +3321,9 @@ .workbench-grid, .session-rail, .workbench-center, - .hwpod-panel { + .hwpod-panel, + .caserun-panel, + .workbench-tools-column { min-height: 0; } @@ -3247,7 +3391,9 @@ grid-template-rows: auto minmax(0, 1fr); } - .hwpod-panel { + .workbench-tools-column, + .hwpod-panel, + .caserun-panel { display: none; } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 41712dab..bbc61d72 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -366,6 +366,12 @@ export type AgentChatResultResponse = AgentChatResponse & { export interface HwpodSpecsResponse { specs?: Array>; [key: string]: unknown } export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array>; [key: string]: unknown } +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 CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record; [key: string]: unknown } +export interface CaseRunRunResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; runDir?: string; runtimeApiUrl?: string; createdAt?: string; updatedAt?: string; eventCount?: number; lastEvent?: CaseRunEvent | null; summary?: Record | null; error?: Record | null; [key: string]: unknown } +export interface CaseRunEventsResponse { ok?: boolean; contractVersion?: string; runId?: string; events?: CaseRunEvent[]; [key: string]: unknown } +export interface CaseRunAggregateResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; 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 ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown } export interface GateDiagnosticRow extends Record { diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index 4821a3c1..cb696e87 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -7,6 +7,7 @@ import { useRoute, useRouter } from "vue-router"; import CommandComposer from "@/components/workbench/CommandComposer.vue"; import ConversationPanel from "@/components/workbench/ConversationPanel.vue"; import SessionRail from "@/components/workbench/SessionRail.vue"; +import CaseRunPanel from "@/components/caserun/CaseRunPanel.vue"; import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue"; import { useAutoRefresh } from "@/composables/useAutoRefresh"; import { shouldReflectWorkbenchSessionUrl } from "@/router/workbench-navigation"; @@ -92,7 +93,10 @@ async function reflectActiveSessionInUrl(value: string | null): Promise { - +
+ + +