425 lines
18 KiB
TypeScript
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; }
|