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 SOFTWARE_SMOKE_CONTRACT_VERSION = "hwlab-caserun-software-smoke-v1"; const SOFTWARE_SMOKE_MODE = "software-smoke"; 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: false }); 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, acceptedRecord(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, await projectRunReadModel(record)); if (suffix === "events" && request.method === "GET") return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, runId, caseId: record.caseId, status: record.status, stage: record.stage, terminal: terminalStatus(record.status), sourceAuthority: sourceAuthorityForRecord(record), 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 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.blocker = typedBlocker(error?.blocker ?? error?.details?.blocker ?? 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, input: any = {}) { if (input.caseDefinition?.mode === SOFTWARE_SMOKE_MODE) return runSoftwareSmokeCase(context, emit, input.caseDefinition); 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 }; } async function runSoftwareSmokeCase(context: any, emit: (stage: string, payload?: Record) => Promise, caseDefinition: any) { const runDir = path.resolve(context.parsed.runDir); await mkdir(runDir, { recursive: true }); await emit("software-smoke-running", { mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false }); const artifact = { contractVersion: SOFTWARE_SMOKE_CONTRACT_VERSION, caseId: context.parsed.caseId, runId: context.parsed.runId, mode: SOFTWARE_SMOKE_MODE, status: "completed", hardwareRequired: false, checks: { caseDefinitionLoaded: caseDefinition.available === true, runDirectoryWritable: true, backgroundWorkerRunning: true } }; const artifactPath = path.join(runDir, "software-smoke.json"); const artifactText = `${JSON.stringify(artifact, null, 2)}\n`; await writeFile(artifactPath, artifactText, "utf8"); const artifacts = [{ path: "software-smoke.json", bytes: Buffer.byteLength(artifactText), sha256: createHash("sha256").update(artifactText).digest("hex") }]; const evidence = { status: "recorded", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, checks: artifact.checks, artifacts }; const summary = { status: "completed", mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false, artifactCount: artifacts.length, artifacts }; const run = { runId: context.parsed.runId, caseId: context.parsed.caseId, mode: SOFTWARE_SMOKE_MODE, hardwareRequired: false }; await emit("software-smoke-completed", { artifactCount: artifacts.length, artifactSha256: artifacts[0].sha256 }); return { summary, evidence, run }; } function caseContextForRecord(record: any, input: any) { const internalApiUrl = internalRuntimeApiUrlForCaseRun(record, input.env ?? {}); const fetchImpl = caseRunInternalFetch(input.fetchImpl ?? globalThis.fetch, internalApiUrl, input.env ?? {}); const parsed = { _: [], caseId: record.caseId, runId: record.runId, runDir: record.runDir, caseRepo: input.caseRepo, apiUrl: internalApiUrl, noCaseRepoRecord: true, stateDir: path.join(input.stateRoot, "cli") }; return { parsed, env: { ...process.env, ...input.env, HWLAB_CASE_REPO: input.caseRepo, HWLAB_RUNTIME_API_URL: internalApiUrl, HWLAB_CASERUN_PUBLIC_RUNTIME_API_URL: record.runtimeApiUrl, HWLAB_RUNTIME_ENDPOINT_LOCKED: "1" }, fetchImpl, 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] }; } export function caseRunInternalFetch(fetchImpl: typeof fetch, internalApiUrl: string, env: Record) { const apiKey = String(env.HWLAB_BOOTSTRAP_ADMIN_API_KEY ?? "").trim(); if (!apiKey) return fetchImpl; const internalOrigin = new URL(internalApiUrl).origin; return async (input: URL | RequestInfo, init: RequestInit = {}) => { const target = new URL(typeof input === "string" || input instanceof URL ? input : input.url); if (target.origin !== internalOrigin) return fetchImpl(input, init); const headers = new Headers(input instanceof Request ? input.headers : undefined); new Headers(init.headers).forEach((value, name) => headers.set(name, value)); if (!headers.has("authorization")) headers.set("authorization", `Bearer ${apiKey}`); return fetchImpl(input, { ...init, headers }); }; } 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 mode = String(definition.mode ?? "compile-only").trim() || "compile-only"; const hardwareRequired = mode !== SOFTWARE_SMOKE_MODE; const hwpodSpec = hardwareRequired ? String(definition.hwpodSpec ?? definition.specPath ?? "hwpod-spec.yaml").trim() || "hwpod-spec.yaml" : null; const specPath = hwpodSpec ? path.resolve(caseDir, hwpodSpec) : null; const specInfo = specPath ? await stat(specPath).catch(() => null) : null; return { caseId: String(definition.caseId ?? caseId).trim() || caseId, title: String(definition.title ?? caseId).trim() || caseId, mode, hwpodSpec, hardwareRequired, available: hardwareRequired ? Boolean(specInfo?.isFile()) : true, 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, stage: record.stage, terminal: terminalStatus(record.status), sourceAuthority: sourceAuthorityForRecord(record), 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 ?? [] }; } async function projectRunReadModel(record: any) { const aggregate = await aggregateForRecord(record); const summary = aggregate.summary && typeof aggregate.summary === "object" ? aggregate.summary : {}; const evidence = aggregate.evidence && typeof aggregate.evidence === "object" ? aggregate.evidence : {}; const archivedRun = aggregate.run && typeof aggregate.run === "object" ? aggregate.run : {}; const blocker = typedBlocker(record.blocker ?? archivedRun.blocker ?? evidence.validation?.blocker ?? evidence.hwpod?.buildWaitBlocker ?? evidence.hwpod?.operation?.blocker ?? record.error?.details?.blocker ?? null); const manifestPath = firstText(summary.artifactManifestPath, evidence.artifactManifest?.path, archivedRun.artifactManifestPath); const manifestSha256 = firstSha256(summary.artifactManifestSha256, evidence.artifactManifest?.sha256, archivedRun.artifactManifestSha256); const replay = evidence.replay && typeof evidence.replay === "object" ? evidence.replay : null; const replayPath = firstText(evidence.validation?.archivePath, archivedRun.replayPath); const trace = firstObject(summary.trace, summary.traceLookup, evidence.agentTrace, evidence.traceLookup, archivedRun.agentTrace); const hwpod = firstObject(evidence.hwpod?.operation?.rawArtifactRef, evidence.hwpod?.operation, evidence.keilJob, archivedRun.hwpod); return { ok: true, contractVersion: CONTRACT_VERSION, runId: record.runId, caseId: record.caseId, status: record.status, stage: record.stage, terminal: terminalStatus(record.status), blocker, createdAt: record.createdAt, updatedAt: record.updatedAt, facts: { jobId: firstText(summary.jobId, evidence.keilJob?.jobId, evidence.hwpod?.operation?.jobId) || null, artifactCount: Number(summary.artifactCount ?? (Array.isArray(evidence.artifacts) ? evidence.artifacts.length : 0)), evidenceStatus: firstText(evidence.status, summary.status) || null }, references: { status: { href: `/v1/caserun/runs/${encodeURIComponent(record.runId)}` }, events: { href: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`, count: Array.isArray(record.events) ? record.events.length : 0 }, aggregate: { href: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`, sha256: aggregate.sha256 }, manifest: manifestPath ? { ref: manifestPath, sha256: manifestSha256 } : null, replay: replay || replayPath ? { ref: replayPath || null, relationship: replay } : null, trace, hwpod }, sourceAuthority: sourceAuthorityForRecord(record), error: record.error ? { code: record.error.code ?? "caserun_failed", message: record.error.message ?? "CaseRun failed." } : null }; } function acceptedRecord(record: any) { return { ok: true, contractVersion: CONTRACT_VERSION, runId: record.runId, caseId: record.caseId, status: record.status, statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}` }; } function sourceAuthorityForRecord(record: any) { return { projector: "hwlab-cloud-api", authority: "caserun-archived-facts", runRecord: "cloud-api-caserun-record", archive: "caserun-run-directory", execution: "hwpod-agentrun-caserun", case: record.caseAuthority ?? null }; } function terminalStatus(status: unknown) { return ["completed", "failed", "cancelled", "canceled", "timed_out", "timeout"].includes(String(status ?? "").toLowerCase()); } function typedBlocker(value: any) { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const code = firstText(value.code); if (!code) return null; return { code, summary: firstText(value.summary, value.message) || code, layer: firstText(value.layer) || null, details: value.details && typeof value.details === "object" ? value.details : null }; } function firstObject(...values: any[]) { return values.find((value) => value && typeof value === "object" && !Array.isArray(value)) ?? null; } function firstText(...values: unknown[]) { return values.map((value) => String(value ?? "").trim()).find(Boolean) ?? ""; } function firstSha256(...values: unknown[]) { return values.map((value) => String(value ?? "").trim().toLowerCase()).find((value) => /^[a-f0-9]{64}$/u.test(value)) ?? 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, ""); } function internalRuntimeApiUrlForCaseRun(record: any, env: Record) { const explicit = String(env.HWLAB_CASERUN_INTERNAL_API_URL ?? env.HWLAB_RUNTIME_INTERNAL_API_URL ?? "").trim(); if (explicit) return explicit.replace(/\/+$/u, ""); const namespace = String(env.HWLAB_NAMESPACE ?? env.POD_NAMESPACE ?? env.KUBERNETES_NAMESPACE ?? namespaceFromEnvironment(env.HWLAB_ENVIRONMENT) ?? "").trim(); const port = String(env.HWLAB_CLOUD_API_PORT ?? env.HWLAB_PORT ?? env.PORT ?? "6667").trim() || "6667"; if (namespace) return `http://hwlab-cloud-api.${namespace}.svc.cluster.local:${port}`; return String(record.runtimeApiUrl ?? "").replace(/\/+$/u, ""); } function namespaceFromEnvironment(value: string | undefined) { const text = String(value ?? "").trim(); if (!text) return ""; if (/^hwlab-[a-z0-9-]+$/u.test(text)) return text; if (/^[a-z0-9-]+$/u.test(text)) return `hwlab-${text}`; return ""; } 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; }