import { randomUUID } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; const DEFAULT_API_URL = "http://74.48.78.17:19667"; const DEFAULT_POLL_INTERVAL_MS = 1000; const DEFAULT_JOB_TIMEOUT_MS = 50000; const MAX_JOB_TIMEOUT_MS = 50000; const STANDARD_CASE_REPO_PATH = "/root/hwlab-case-registry"; const REMOVED_CASE_REPO_PATH = "/root/hwpod-cases"; type EnvLike = Record; type ParsedArgs = Record & { _: string[] }; type FetchLike = typeof fetch; type CaseContext = { parsed: ParsedArgs; env: EnvLike; fetchImpl: FetchLike; cwd: string; now: () => string; sleep: (ms: number) => Promise; rest: string[]; }; type PreparedCaseRun = { caseId: string; runId: string; runDir: string; caseRepo: string; caseDir: string; caseFile: string; sourceSpecPath: string; specPath: string; apiUrl: string; compileOnly: boolean; createdAt: string; updatedAt: string; definition: Record; }; export async function caseCommand(context: CaseContext) { const subcommand = context.rest[0] || "help"; if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp(); if (subcommand === "prepare") return prepareCaseRun(context); if (subcommand === "build") return buildCaseRun(context); if (subcommand === "collect") return collectCaseRun(context); if (subcommand === "run") return runCaseRun(context); throw cliError("unsupported_case_command", `unsupported case command: ${subcommand}`, { subcommand }); } export function caseHelp() { return ok("case.help", { serviceRuntime: false, compileOnlyDefault: true, stateRoot: ".state/hwlab-cli/caserun", standardCaseRepo: STANDARD_CASE_REPO_PATH, commands: [ `case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, `case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`, `case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`, `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH}` ] }); } export async function runCaseRun(context: CaseContext) { const prepared = await prepareCaseRun(context, "run"); const built = await buildCaseRun(context, prepared.run); const collected = await collectCaseRun(context, built.run, built.evidence); return ok("case.run", { caseId: collected.run.caseId, runId: collected.run.runId, runDir: collected.run.runDir, compileOnly: true, prepare: prepared.summary, build: built.summary, collect: collected.summary, evidence: collected.evidence }, collected.evidence.ok === true ? "succeeded" : "failed"); } export async function prepareCaseRun(context: CaseContext, action = "prepare") { const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId"); const caseRepo = await resolveCaseRepo(context); const loaded = await loadCaseDefinition(caseRepo, caseId); const runId = runIdFrom(context, caseId); const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId)); const specPath = path.join(runDir, ".hwlab", "hwpod-spec.yaml"); const now = context.now(); const apiUrl = apiUrlFrom(context, loaded.definition); const run: PreparedCaseRun = { caseId, runId, runDir, caseRepo, caseDir: loaded.caseDir, caseFile: loaded.caseFile, sourceSpecPath: loaded.specPath, specPath, apiUrl, compileOnly: true, createdAt: now, updatedAt: now, definition: loaded.definition }; await mkdir(path.dirname(specPath), { recursive: true }); await writeFile(specPath, loaded.specText, "utf8"); await writeJson(path.join(runDir, "run.json"), run); return ok(`case.${action}.prepare`, { caseId, runId, runDir, caseRepo, specPath, sourceSpecPath: loaded.specPath, apiUrl, compileOnly: true, summary: prepareSummary(run), run }); } export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) { const run = prepared ?? await loadOrPrepareCaseRun(context); const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`]; const invoked = await runProcess(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_API_URL: run.apiUrl, HWLAB_RUNTIME_ENDPOINT_LOCKED: "1" }); const hwpodPayload = parseJsonMaybe(invoked.stdout); const jobId = extractKeilJobId(hwpodPayload); const job = jobId ? await pollKeilJobStatus(context, run, jobId) : null; const nodeOk = invoked.exitCode === 0 && hwpodPayload?.ok !== false && firstHwpodResultOk(hwpodPayload) !== false; const jobOk = job ? job.ok === true : false; const okStatus = nodeOk && jobOk; const evidence = clean({ contractVersion: "hwpod-case-run-evidence-v1", caseId: run.caseId, runId: run.runId, compileOnly: true, ok: okStatus, status: okStatus ? "succeeded" : "failed", observedAt: context.now(), apiUrl: run.apiUrl, hwpod: clean({ command: commandVisibility(command), exitCode: invoked.exitCode, stdoutJson: compactHwpodPayload(hwpodPayload), stderr: clipText(invoked.stderr) }), keilJob: job ? job.summary : null, artifacts: job?.summary?.artifacts ?? [], decisions: { downloadSkipped: true, reason: "compile-only validation is the current case-run acceptance line" } }); await writeJson(path.join(run.runDir, "evidence.json"), evidence); await writeJson(path.join(run.runDir, "run.json"), { ...run, updatedAt: context.now(), status: evidence.status, evidencePath: path.join(run.runDir, "evidence.json") }); return ok("case.build", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary: buildSummary(evidence), evidence, run }, evidence.status as string); } export async function collectCaseRun(context: CaseContext, prepared?: PreparedCaseRun, knownEvidence?: any) { const run = prepared ?? await loadRunFromDirOrFail(context); const evidence = knownEvidence ?? JSON.parse(await readFile(path.join(run.runDir, "evidence.json"), "utf8")); const files: string[] = []; if (context.parsed.noCaseRepoRecord !== true) { const evidenceRel = path.join("runs", run.caseId, run.runId, "evidence.json"); const summaryRel = path.join("runs", run.caseId, run.runId, "summary.md"); await writeJson(path.join(run.caseRepo, evidenceRel), evidence); await writeFile(path.join(run.caseRepo, summaryRel), renderEvidenceSummary(evidence), "utf8"); files.push(evidenceRel, summaryRel); } const summary = { caseRepo: run.caseRepo, caseRepoFiles: files, status: evidence.status, ok: evidence.ok === true }; return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary, evidence, run }, evidence.status); } export function extractKeilJobId(payload: any) { const candidates = hwpodResultTexts(payload); for (const candidate of candidates) { const parsed = parseJsonMaybe(candidate); const direct = text(parsed?.job_id ?? parsed?.jobId ?? parsed?.data?.job_id ?? parsed?.data?.jobId); if (direct) return direct; const match = String(candidate ?? "").match(/"job[_-]?id"\s*:\s*"([^"]+)"/iu); if (match?.[1]) return match[1]; } return ""; } async function loadOrPrepareCaseRun(context: CaseContext) { if (text(context.parsed.runDir)) return loadRunFromDirOrFail(context); const prepared = await prepareCaseRun(context, "build"); return prepared.run as PreparedCaseRun; } async function loadRunFromDirOrFail(context: CaseContext) { const runDir = path.resolve(context.cwd, requiredText(context.parsed.runDir, "runDir")); return JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")) as PreparedCaseRun; } async function resolveCaseRepo(context: CaseContext) { const explicit = text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO); if (explicit && isRemovedCaseRepoPath(path.resolve(context.cwd, explicit))) { throw cliError("removed_case_repo_path", "the CaseRun registry moved to /root/hwlab-case-registry", { caseRepo: explicit, standardCaseRepo: STANDARD_CASE_REPO_PATH }); } const candidates = explicit ? [path.resolve(context.cwd, explicit)] : [ path.resolve(context.cwd, "../hwlab-case-registry"), STANDARD_CASE_REPO_PATH ]; for (const candidate of candidates) { try { await readFile(path.join(candidate, ".git", "HEAD"), "utf8"); return candidate; } catch { // Try next visible 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 }); } function isRemovedCaseRepoPath(candidate: string) { const normalized = path.resolve(candidate); return normalized === REMOVED_CASE_REPO_PATH || normalized.endsWith("/hwpod-cases"); } async function loadCaseDefinition(caseRepo: string, caseId: string) { const caseDir = path.join(caseRepo, "cases", caseId); const caseFile = path.join(caseDir, "case.json"); const definition = JSON.parse(await readFile(caseFile, "utf8")); const specRel = text(definition.hwpodSpec ?? definition.specPath) || "hwpod-spec.yaml"; const specPath = path.resolve(caseDir, specRel); const specText = await readFile(specPath, "utf8"); return { caseDir, caseFile, definition, specPath, specText }; } async function pollKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { const timeoutMs = Math.min(numberOption(context.parsed.jobTimeoutMs ?? context.parsed.timeoutMs) ?? DEFAULT_JOB_TIMEOUT_MS, MAX_JOB_TIMEOUT_MS); const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS; const started = Date.now(); const polls: any[] = []; let last: any = null; while (Date.now() - started <= timeoutMs) { const response = await requestKeilJobStatus(context, run, jobId); const status = keilJobStatus(response.body); last = { response, status }; polls.push(status.summary); if (status.terminal) return { ok: status.ok, summary: { ...status.summary, jobId, polls: polls.length, timedOut: false } }; await context.sleep(pollIntervalMs); } return { ok: false, summary: { jobId, status: text(last?.status?.summary?.status) || "timeout", polls: polls.length, timedOut: true, last: last?.status?.summary ?? null } }; } async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, jobId: string) { const document = await readHwpodSpec(run.specPath); const keilCliPath = text(document.spec.workspace.keilCliPath) || text(document.spec.debugProbe.keilCliPath) || "keil-cli.py"; const pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3"; const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId); const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; const plan = { contractVersion: "hwpod-node-ops-v1", planId: `case_job_${randomUUID()}`, hwpodId, nodeId: document.spec.nodeBinding.nodeId, intent: "cmd.run", source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, ops: [{ opId: "op_01_keil_job_status", op: "cmd.run", args: { hwpodId, workspacePath: dirnameForCommandPath(keilCliPath), command: jobStatusCommand.command, argv: jobStatusCommand.argv } }] }; const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(plan) }); const textBody = await response.text(); return { status: response.status, body: parseJsonMaybe(textBody) ?? { raw: textBody }, plan }; } function keilJobStatus(body: any) { const parsed = parseJsonMaybe(hwpodResultTexts(body)[0]) ?? body; const status = text(parsed?.status ?? parsed?.data?.status ?? parsed?.job?.status) || "unknown"; const returnCode = numberOption(parsed?.return_code ?? parsed?.returnCode ?? parsed?.data?.return_code); const success = parsed?.success === true || parsed?.ok === true || returnCode === 0; const failed = parsed?.success === false || parsed?.ok === false || (returnCode !== undefined && returnCode !== 0) || ["failed", "error", "cancelled"].includes(status); const terminal = success || failed || ["completed", "succeeded"].includes(status); const artifacts = artifactsFrom(parsed); return { terminal, ok: success || ["completed", "succeeded"].includes(status), summary: clean({ status, returnCode, success, artifacts, raw: compactObject(parsed) }) }; } function apiUrlFrom(context: CaseContext, definition: any) { return text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? definition?.runtime?.apiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL; } function stateRoot(context: CaseContext) { return text(context.parsed.stateDir) || path.join(".state", "hwlab-cli", "caserun"); } function runIdFrom(context: CaseContext, caseId: string) { const explicit = text(context.parsed.runId); if (explicit) return explicit; return `${slug(caseId)}-${context.now().replace(/[^0-9]/gu, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; } function prepareSummary(run: PreparedCaseRun) { return { status: "prepared", caseId: run.caseId, runId: run.runId, specPath: run.specPath, apiUrl: run.apiUrl }; } function buildSummary(evidence: any) { return { status: evidence.status, ok: evidence.ok, jobId: evidence.keilJob?.jobId ?? null, artifacts: evidence.artifacts ?? [] }; } function renderEvidenceSummary(evidence: any) { return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- compileOnly: ${evidence.compileOnly}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- downloadSkipped: true\n`; } async function runProcess(command: string[], cwd: string, env: Record) { const proc = Bun.spawn(command, { cwd, env, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); return { command, stdout, stderr, exitCode }; } function hwpodResultTexts(payload: any) { const results = Array.isArray(payload?.body?.results) ? payload.body.results : Array.isArray(payload?.results) ? payload.results : []; return results.flatMap((item: any) => [item?.stdout, item?.stderr, item?.summary, item?.output?.stdout, item?.output?.stderr, item?.output?.summary]).filter((item: unknown) => text(item)); } function firstHwpodResultOk(payload: any) { const results = Array.isArray(payload?.body?.results) ? payload.body.results : []; return results[0]?.ok; } function compactHwpodPayload(payload: any) { if (!payload) return null; return compactObject({ ok: payload.ok, action: payload.action, status: payload.status, traceId: payload.body?.traceId, results: payload.body?.results }); } function artifactsFrom(value: any) { const candidates = [value?.artifacts, value?.artifact_paths, value?.artifactPaths, value?.data?.artifacts, value?.data?.artifact_paths]; for (const candidate of candidates) { if (Array.isArray(candidate)) return candidate; } return [text(value?.result?.hex_file), text(value?.result?.axf_file), text(value?.hex_file), text(value?.axf_file)].filter(Boolean); } async function writeJson(file: string, value: any) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function commandVisibility(command: string[]) { return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); } function dirnameForCommandPath(value: string) { if (/^[A-Za-z]:\\/u.test(value)) return value.replace(/\\[^\\]+$/u, "") || value; return path.dirname(value); } function splitCommandWords(value: string) { const words = String(value || "py -3").match(/"[^"]*"|'[^']*'|\S+/gu) ?? ["py", "-3"]; return words.map((word) => word.replace(/^"(.*)"$/su, "$1").replace(/^'(.*)'$/su, "$1")); } export function jobStatusCommandForTest(pythonCommand: string, keilCliPath: string, jobId: string) { const python = splitCommandWords(pythonCommand); return { command: python[0] ?? "py", argv: [...python.slice(1), keilCliPath, "job-status", jobId] }; } export function artifactsFromKeilStatusForTest(value: any) { return artifactsFrom(value); } function compactObject(value: any) { const json = JSON.stringify(value ?? null); if (json.length <= 4000) return value; return { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) }; } function parseJsonMaybe(value: unknown) { if (value && typeof value === "object") return value as any; const raw = String(value ?? "").trim(); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } function clipText(value: unknown, maxBytes = 2000) { const raw = String(value ?? ""); const buffer = Buffer.from(raw, "utf8"); if (buffer.length <= maxBytes) return raw; return `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`; } function ok(action: string, extra: Record = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; } function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; } function text(value: unknown) { return String(value ?? "").trim(); } function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; } function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; }