Files
pikasTech-HWLAB/tools/src/hwlab-caserun-lib.ts
T
2026-06-06 10:11:46 +08:00

425 lines
18 KiB
TypeScript

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<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
type FetchLike = typeof fetch;
type CaseContext = {
parsed: ParsedArgs;
env: EnvLike;
fetchImpl: FetchLike;
cwd: string;
now: () => string;
sleep: (ms: number) => Promise<void>;
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<string, unknown>;
};
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<string, string | undefined>) {
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<string, unknown> = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; }
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { 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<T extends Record<string, unknown>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; }