feat: 实现 CaseRun HWPOD 执行合同
This commit is contained in:
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { runHwlabCli } from "../src/hwlab-cli-lib.ts";
|
||||
import { artifactsFromKeilStatusForTest, extractKeilJobId, jobStatusCommandForTest } from "../src/hwlab-caserun-lib.ts";
|
||||
import { artifactsFromKeilStatusForTest, buildWaitBlockerForTest, extractKeilJobId, jobStatusCommandForTest, summarizeHwpodOperationForTest } from "../src/hwlab-caserun-lib.ts";
|
||||
|
||||
const SUBJECT_COMMIT_ID = "df7a4e6e551fa90d64bde5537cc000f89d63dd20";
|
||||
const SUBJECT_REPO_LOCAL_PATH = "F:\\Work\\HWLAB-CASE-F103";
|
||||
@@ -202,6 +202,20 @@ test("case run exposes Keil hex and axf artifacts in evidence summary", () => {
|
||||
assert.deepEqual(artifactsFromKeilStatusForTest({ result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf" } }), ["F:\\out.hex", "F:\\out.axf"]);
|
||||
});
|
||||
|
||||
test("case run normalizes v0.3 HWPOD build operation topology and typed wait blockers", () => {
|
||||
const document = { metadata: { name: "d601-f103-v2" }, spec: { nodeBinding: { nodeId: "node-d601-f103-v2" }, workspace: { path: "F:\\Work\\case" } } };
|
||||
const operation = summarizeHwpodOperationForTest({ body: { hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", results: [{ opId: "op_build", op: "debug.build", ok: true, status: "completed", output: { stdout: '{"job_id":"job-1"}' } }] } }, document, "debug.build");
|
||||
assert.equal(operation.topology.ok, true);
|
||||
assert.equal(operation.jobId, "job-1");
|
||||
assert.equal(buildWaitBlockerForTest(operation, { ok: true, status: "completed", warningCount: 7, artifacts: ["F:\\out.hex"] }, 0), null);
|
||||
assert.equal(buildWaitBlockerForTest({ ...operation, jobId: "" }, null, 0).code, "hwpod_build_job_id_missing");
|
||||
assert.equal(buildWaitBlockerForTest(operation, { ok: false, status: "timeout", timedOut: true, polls: 3 }, 0).code, "hwpod_build_wait_timeout");
|
||||
assert.equal(buildWaitBlockerForTest(operation, { ok: false, status: "failed", returnCode: 2 }, 0).code, "hwpod_build_failed");
|
||||
assert.equal(buildWaitBlockerForTest(operation, { ok: true, status: "completed", artifacts: ["F:\\out.axf"] }, 0).code, "hwpod_build_hex_missing");
|
||||
const mismatch = summarizeHwpodOperationForTest({ body: { nodeId: "wrong-node", results: [{ op: "debug.build", ok: true, output: { stdout: '{"job_id":"job-2"}' } }] } }, document, "debug.build");
|
||||
assert.equal(buildWaitBlockerForTest(mismatch, null, 0).code, "hwpod_topology_mismatch");
|
||||
});
|
||||
|
||||
test("case run start returns immediately and status/result/logs are short polling commands", async () => {
|
||||
const root = await mkdtempCaseRoot();
|
||||
const caseRepo = path.join(root, "hwlab-case-registry");
|
||||
@@ -470,6 +484,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
|
||||
assert.equal(result.payload.evidence.hwpod.source, "case-run-runner-post-agent-compile-check");
|
||||
assert.match(result.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u);
|
||||
assert.deepEqual(result.payload.agentDiff.diffCollection.untracked, { total: 2, included: 1, omitted: 1 });
|
||||
assert.deepEqual(result.payload.agentDiff.diffCollection.submodules, [{ path: "vendor/lib", commit: "0123456789abcdef", state: "modified" }]);
|
||||
assert.equal(result.payload.agentDiff.diffCollection.included[0].path, "projects/01_baseline/new.c");
|
||||
assert.equal(result.payload.agentDiff.diffCollection.omitted[0].path, "projects/01_baseline/build/out.bin");
|
||||
assert.equal(result.payload.agentDiff.diffCollection.omitted[0].reason, "generated output");
|
||||
@@ -1004,6 +1019,12 @@ function caseRunFlowFetch(requests: any[]) {
|
||||
JSON.stringify({ path: "projects/01_baseline/build/out.bin", bytes: 4096, sha256: "b".repeat(64) })
|
||||
].join("\n") }]);
|
||||
}
|
||||
if (body.ops?.[0]?.args?.argv?.includes("submodule")) {
|
||||
return hwpodResponse([{ exitCode: 0, stdout: "+0123456789abcdef vendor/lib (heads/main)\n" }]);
|
||||
}
|
||||
if (body.ops?.[0]?.args?.argv?.includes("submodule")) {
|
||||
return hwpodResponse([{ exitCode: 0, stdout: "+0123456789abcdef vendor/lib (heads/main)\n" }]);
|
||||
}
|
||||
if (body.ops?.[0]?.args?.argv?.includes("ls-files")) {
|
||||
return hwpodResponse([{ exitCode: 0, stdout: "projects/01_baseline/new.c\nprojects/01_baseline/build/out.bin\n" }]);
|
||||
}
|
||||
|
||||
+9
-3
@@ -666,10 +666,16 @@ class NodeOpsExecutor:
|
||||
data = status_body.get("data") if isinstance(status_body.get("data"), dict) else {}
|
||||
active_port = str(data.get("port") or "")
|
||||
active_baud = data.get("baudRate")
|
||||
monitor_start = None
|
||||
matches = status.get("ok") and status_body.get("success") is True and data.get("isMonitoring") is True and (not port or active_port.lower() == port.lower()) and (not baud or int(active_baud or 0) == int(baud))
|
||||
if not matches:
|
||||
ports = self._spawn_output([*command_base, "ports"], serial_dir, timeout_ms)
|
||||
return {"ok": False, "blockerCode": "hwpod_uart_monitor_not_active", "summary": f"io.uart.read requires serial-monitor to be active on {port or 'the requested UART'}{('/' + str(baud)) if baud else ''}", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "ports": self._parse_json(ports.get("stdout")) or ports}}}
|
||||
start_args = ["monitor", "start", *(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])]
|
||||
start = self._spawn_output([*command_base, *start_args], serial_dir, timeout_ms)
|
||||
start_body = self._parse_json(start.get("stdout"))
|
||||
monitor_start = start_body or start
|
||||
if not start.get("ok") or start_body.get("success") is not True:
|
||||
ports = self._spawn_output([*command_base, "ports"], serial_dir, timeout_ms)
|
||||
return {"ok": False, "blockerCode": "hwpod_uart_monitor_start_failed", "summary": f"serial-monitor could not start on {port or 'the requested UART'}{('/' + str(baud)) if baud else ''}", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": start_body or start, "ports": self._parse_json(ports.get("stdout")) or ports}}}
|
||||
limit = max(1, min(safe_int(args.get("limit"), max(1, safe_int(args.get("maxBytes"), 4096) // 80)), 200))
|
||||
fetch_args = ["fetch", "-l", str(limit), "--session-only"]
|
||||
if args.get("since"):
|
||||
@@ -681,7 +687,7 @@ class NodeOpsExecutor:
|
||||
rows = fetch_body.get("data") if isinstance(fetch_body.get("data"), list) else []
|
||||
text = "\n".join(str(row.get("data")) for row in rows if isinstance(row, dict) and row.get("data"))
|
||||
max_bytes = safe_int(args.get("maxBytes"), 16384)
|
||||
return {"ok": True, "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": active_port or port or None, "baudRate": baud or active_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": text[:max_bytes], "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "sourceFile": fetch_body.get("sourceFile")}
|
||||
return {"ok": True, "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": active_port or port or None, "baudRate": baud or active_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": text[:max_bytes], "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": fetch_body.get("sourceFile")}
|
||||
|
||||
def _parse_json(self, text: object) -> dict:
|
||||
try:
|
||||
|
||||
+13
-17
@@ -140,14 +140,14 @@ test("hwpod-compiler-cli generates Keil build and download commands from structu
|
||||
|
||||
const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW });
|
||||
assert.equal(build.exitCode, 0);
|
||||
assert.equal(build.payload.plan.ops[0].op, "cmd.run");
|
||||
assert.equal(build.payload.plan.ops[0].op, "debug.build");
|
||||
assert.equal(build.payload.plan.ops[0].args.target, "USART");
|
||||
assert.equal(build.payload.plan.ops[0].args.command, "py");
|
||||
assert.deepEqual(build.payload.plan.ops[0].args.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "build", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]);
|
||||
assert.deepEqual(build.payload.plan.ops[0].args.argv.slice(-2), ["-t", "USART"]);
|
||||
assert.match(build.payload.plan.ops[0].args.commandLine, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u);
|
||||
assert.match(build.payload.plan.ops[0].args.commandLine, /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u);
|
||||
assert.match(build.payload.plan.ops[0].args.commandLine, / -t USART$/u);
|
||||
assert.equal(build.payload.plan.ops[0].args.commandRun.command, "py");
|
||||
assert.deepEqual(build.payload.plan.ops[0].args.commandRun.argv.slice(0, 5), ["-3", "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py", "build", "-p", "F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103.uvprojx"]);
|
||||
assert.deepEqual(build.payload.plan.ops[0].args.commandRun.argv.slice(-2), ["-t", "USART"]);
|
||||
assert.match(build.payload.plan.ops[0].args.command, /^py -3 C:\\Users\\liang\\\.agents\\skills\\keil\\keil-cli\.py build -p/u);
|
||||
assert.match(build.payload.plan.ops[0].args.command, /F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Projects\\MDK-ARM\\atk_f103\.uvprojx/u);
|
||||
assert.match(build.payload.plan.ops[0].args.command, / -t USART$/u);
|
||||
assert.equal(build.payload.plan.ops[0].args.commandBinding.source, "hwpod-compiler.keil-mdk");
|
||||
assert.equal(build.payload.plan.ops[0].args.commandBinding.action, "build");
|
||||
|
||||
@@ -258,19 +258,15 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl
|
||||
await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.baudrate", "115200", "--spec", specPath], { now: () => NOW });
|
||||
const uart = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--dry-run"], { now: () => NOW });
|
||||
assert.equal(uart.exitCode, 0);
|
||||
assert.deepEqual(uart.payload.plan.ops.map((op: any) => op.op), ["cmd.run"]);
|
||||
assert.equal(uart.payload.plan.ops[0].args.step, "serial-monitor-read");
|
||||
assert.equal(uart.payload.plan.ops[0].args.workspacePath, "C:\\Users\\liang\\.agents\\skills\\serial-monitor");
|
||||
assert.equal(uart.payload.plan.ops[0].args.command, "node");
|
||||
const uartSequence = JSON.parse(uart.payload.plan.ops[0].args.argv[2]);
|
||||
assert.deepEqual(uartSequence[0], ["bun", "scripts/serial-monitor-cli.ts", "monitor", "start", "-p", "COM9", "-b", "115200"]);
|
||||
assert.deepEqual(uartSequence[1].slice(0, 5), ["bun", "scripts/serial-monitor-cli.ts", "fetch", "-l", "7"]);
|
||||
assert.equal(uartSequence[1].includes("--session-only"), false);
|
||||
assert.deepEqual(uart.payload.plan.ops.map((op: any) => op.op), ["io.uart.read"]);
|
||||
assert.equal(uart.payload.plan.ops[0].args.serialMonitorDir, "C:\\Users\\liang\\.agents\\skills\\serial-monitor");
|
||||
assert.deepEqual(uart.payload.plan.ops[0].args.serialMonitorCommand, ["bun", "scripts/serial-monitor-cli.ts"]);
|
||||
assert.equal(uart.payload.plan.ops[0].args.port, "COM9");
|
||||
assert.equal(uart.payload.plan.ops[0].args.sessionOnly, false);
|
||||
assert.equal(uart.payload.plan.ops[0].args.commandBinding.sessionOnly, false);
|
||||
|
||||
const uartSessionOnly = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--session-only", "--dry-run"], { now: () => NOW });
|
||||
const uartSessionSequence = JSON.parse(uartSessionOnly.payload.plan.ops[0].args.argv[2]);
|
||||
assert.equal(uartSessionSequence[1].includes("--session-only"), true);
|
||||
assert.equal(uartSessionOnly.payload.plan.ops[0].args.sessionOnly, true);
|
||||
assert.equal(uartSessionOnly.payload.plan.ops[0].args.commandBinding.sessionOnly, true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
||||
@@ -226,12 +226,12 @@ else { console.error("unsupported " + args); process.exit(2); }
|
||||
if (process.platform === "win32") return;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.results[0].status, "blocked");
|
||||
assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_not_active");
|
||||
assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_start_failed");
|
||||
assert.equal(result.results[0].blocker.details.requestedPort, "uart1");
|
||||
assert.equal(result.results[0].blocker.details.resolvedPort, "COM9");
|
||||
assert.equal(result.results[0].blocker.details.baudRate, 115200);
|
||||
assert.equal(result.results[0].blocker.details.serialMonitor.monitorStatus.data.isMonitoring, false);
|
||||
assert.match(result.results[0].blocker.details.serialMonitor.startCommand, /monitor start -p COM9 -b 115200/u);
|
||||
assert.equal(result.results[0].blocker.details.serialMonitor.start.success, undefined);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -243,7 +243,8 @@ test("hwpod-node reads UART through serial-monitor binding", async () => {
|
||||
const fixture = path.join(root, "serial-monitor-fixture.mjs");
|
||||
await writeFile(fixture, `
|
||||
const args = process.argv.slice(2).join(" ");
|
||||
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: true, port: "COM9", baudRate: 115200, startTime: "2026-06-06T10:00:00Z" } }));
|
||||
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: false, port: null, baudRate: null, lastPort: "COM9", lastBaudRate: 115200 } }));
|
||||
else if (args === "monitor start -p COM9 -b 115200") console.log(JSON.stringify({ action: "monitor_start", success: true, data: { port: "COM9", baudRate: 115200 } }));
|
||||
else if (args === "ports") console.log(JSON.stringify({ action: "list_ports", success: true, data: { ports: [{ path: "COM9" }] } }));
|
||||
else if (args.startsWith("fetch")) console.log(JSON.stringify({ action: "fetch_data", success: true, data: [{ timestamp: "2026-06-06T10:00:01Z", port: "COM9", data: "UART OK" }], count: 1, totalCount: 1, hasMore: false, truncated: false, sourceFile: "fixture.jsonl" }));
|
||||
else { console.error("unsupported " + args); process.exit(2); }
|
||||
@@ -273,6 +274,8 @@ else { console.error("unsupported " + args); process.exit(2); }
|
||||
assert.equal(result.results[0].output.bindingSource, "serial-monitor-cli");
|
||||
assert.equal(result.results[0].output.resolvedPort, "COM9");
|
||||
assert.equal(result.results[0].output.text, "UART OK");
|
||||
assert.equal(result.results[0].output.monitorStarted, true);
|
||||
assert.equal(result.results[0].output.monitorStart.success, true);
|
||||
assert.deepEqual(result.results[0].output.command.slice(0, 2), [process.execPath, fixture]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
||||
@@ -19,6 +19,7 @@ export type AgentDiffCollectionSummary = {
|
||||
omitted: AgentDiffFileRecord[];
|
||||
config: { include: DiffCollectionRule[]; ignore: DiffCollectionRule[] };
|
||||
tracked: { paths: string[]; diffStat: string; diffPatchSha256: string };
|
||||
submodules: Array<{ path: string; commit: string; state: "clean" | "modified" | "uninitialized" | "conflicted" }>;
|
||||
untracked: { total: number; included: number; omitted: number };
|
||||
};
|
||||
|
||||
@@ -37,6 +38,9 @@ export async function collectAgentWorkspaceDiff(input: {
|
||||
const requests: Array<{ label: string; result: CommandResult }> = [];
|
||||
const included: AgentDiffFileRecord[] = [];
|
||||
const omitted: AgentDiffFileRecord[] = [];
|
||||
const submoduleStatus = await input.requestGit(["submodule", "status", "--recursive"]);
|
||||
requests.push({ label: "submodule-status", result: submoduleStatus });
|
||||
const submodules = parseSubmodules(text(submoduleStatus.stdout));
|
||||
|
||||
if (roots.length > 0) {
|
||||
const lsFiles = await input.requestGit(["ls-files", "--others", "--exclude-standard", "--", ...roots]);
|
||||
@@ -67,6 +71,7 @@ export async function collectAgentWorkspaceDiff(input: {
|
||||
omitted,
|
||||
config,
|
||||
tracked: { paths: patchPaths(input.trackedPatchText), diffStat: input.trackedDiffStat, diffPatchSha256: sha256(input.trackedPatchText) },
|
||||
submodules,
|
||||
untracked: { total: included.length + omitted.length, included: included.length, omitted: omitted.length }
|
||||
};
|
||||
return {
|
||||
@@ -77,6 +82,19 @@ export async function collectAgentWorkspaceDiff(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubmodules(value: string) {
|
||||
return value.split(/\r?\n/u).flatMap((line) => {
|
||||
const match = line.match(/^([ +\-U])([0-9a-f]{7,64})\s+(.+?)(?:\s+\(.*\))?$/iu);
|
||||
if (!match) return [];
|
||||
const marker = match[1];
|
||||
return [{
|
||||
path: repoPath(match[3]),
|
||||
commit: match[2],
|
||||
state: marker === "-" ? "uninitialized" as const : marker === "+" ? "modified" as const : marker === "U" ? "conflicted" as const : "clean" as const
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
async function fileMetadata(input: Parameters<typeof collectAgentWorkspaceDiff>[0], paths: string[]) {
|
||||
const records = new Map<string, { bytes?: number; sha256?: string }>();
|
||||
if (paths.length === 0) return { records, requests: [] as Array<{ label: string; result: CommandResult }> };
|
||||
|
||||
@@ -436,8 +436,11 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase
|
||||
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
|
||||
});
|
||||
const hwpodPayload = parseJsonMaybe(invoked.stdout);
|
||||
const jobId = extractKeilJobId(hwpodPayload);
|
||||
const hwpodDocument = await readHwpodSpec(run.specPath);
|
||||
const operation = summarizeHwpodOperationForTest(hwpodPayload, hwpodDocument, "debug.build");
|
||||
const jobId = operation.jobId;
|
||||
const job = jobId ? await pollKeilJobStatus(context, run, jobId, caseTimeoutsFromDefinition(run.definition)) : null;
|
||||
const buildWaitBlocker = buildWaitBlockerForTest(operation, job?.summary ?? null, invoked.exitCode);
|
||||
const evidence = clean({
|
||||
contractVersion: "hwpod-case-run-evidence-v1",
|
||||
caseId: run.caseId,
|
||||
@@ -458,7 +461,9 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase
|
||||
command: commandVisibility(command),
|
||||
exitCode: invoked.exitCode,
|
||||
stdoutJson: compactHwpodPayload(hwpodPayload),
|
||||
stderr: clipText(invoked.stderr)
|
||||
stderr: clipText(invoked.stderr),
|
||||
operation,
|
||||
buildWaitBlocker
|
||||
}),
|
||||
keilJob: job ? job.summary : null,
|
||||
artifacts: job?.summary?.artifacts ?? [],
|
||||
@@ -483,6 +488,49 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase
|
||||
}, "completed");
|
||||
}
|
||||
|
||||
export function summarizeHwpodOperationForTest(payload: any, document: any, expectedOp = "") {
|
||||
const response = objectRecord(payload?.body?.body ?? payload?.body ?? payload);
|
||||
const results = Array.isArray(response.results) ? response.results : [];
|
||||
const result = results.find((item: any) => !expectedOp || text(item?.op) === expectedOp) ?? results[0] ?? {};
|
||||
const output = objectRecord(result?.output);
|
||||
const expected = {
|
||||
hwpodId: text(document?.metadata?.name ?? document?.metadata?.uid),
|
||||
nodeId: text(document?.spec?.nodeBinding?.nodeId),
|
||||
workspacePath: text(document?.spec?.workspace?.path)
|
||||
};
|
||||
const observed = {
|
||||
hwpodId: firstTextOption(response.hwpodId, result?.hwpodId, output.hwpodId),
|
||||
nodeId: firstTextOption(response.nodeId, result?.nodeId, output.nodeId),
|
||||
workspacePath: firstTextOption(result?.workspacePath, output.workspacePath, output.cwd)
|
||||
};
|
||||
const mismatch = Object.entries(expected).flatMap(([field, value]) => observed[field as keyof typeof observed] && observed[field as keyof typeof observed] !== value ? [{ field, expected: value, observed: observed[field as keyof typeof observed] }] : []);
|
||||
return clean({
|
||||
contractVersion: "hwpod-operation-result-v0.3",
|
||||
authority: "case-hwpod-yaml",
|
||||
op: text(result?.op) || expectedOp,
|
||||
opId: text(result?.opId),
|
||||
status: text(result?.status ?? response.status ?? payload?.status) || "unknown",
|
||||
ok: result?.ok === true && mismatch.length === 0,
|
||||
jobId: extractKeilJobId(payload),
|
||||
expected,
|
||||
observed: clean(observed),
|
||||
topology: mismatch.length === 0 ? { ok: true } : { ok: false, blocker: { code: "hwpod_topology_mismatch", summary: "HWPOD operation result does not match YAML-selected topology", details: mismatch } },
|
||||
blocker: result?.blocker ?? payload?.diagnostic ?? null
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWaitBlockerForTest(operation: any, job: any, buildExitCode = 0) {
|
||||
if (operation?.topology?.ok === false) return operation.topology.blocker;
|
||||
if (operation?.blocker) return operation.blocker;
|
||||
if (buildExitCode !== 0) return { code: "hwpod_build_operation_failed", summary: "HWPOD build operation failed before a waitable job was returned" };
|
||||
if (!text(operation?.jobId)) return { code: "hwpod_build_job_id_missing", summary: "HWPOD build operation result did not return a jobId" };
|
||||
if (job?.timedOut === true) return { code: "hwpod_build_wait_timeout", summary: "HWPOD build job did not reach a terminal state within the bounded wait", details: { jobId: operation.jobId, polls: job.polls } };
|
||||
if (job && job.ok === false) return { code: "hwpod_build_failed", summary: "HWPOD build job reached a failed terminal state", details: { jobId: operation.jobId, status: job.status, returnCode: job.returnCode } };
|
||||
const artifacts = Array.isArray(job?.artifacts) ? job.artifacts.map(String) : [];
|
||||
if (job && !artifacts.some((item: string) => /\.hex$/iu.test(item))) return { code: "hwpod_build_hex_missing", summary: "HWPOD build completed without a HEX artifact", details: { jobId: operation.jobId, artifacts } };
|
||||
return null;
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
@@ -555,7 +555,7 @@ function debugBuildOps(common: any, args: any, document: any) {
|
||||
reason: text(args.reason)
|
||||
});
|
||||
if (generated?.commandRun) {
|
||||
return [{ op: "cmd.run", args: clean({ ...opArgs, command: generated.commandRun.command, argv: generated.commandRun.argv, commandLine: generated.command }) }];
|
||||
return [{ op: "debug.build", args: clean({ ...opArgs, commandRun: generated.commandRun, command: generated.command }) }];
|
||||
}
|
||||
return [{ op: "debug.build", args: clean({ ...opArgs, command: explicitCommand || generated?.command }) }];
|
||||
}
|
||||
@@ -623,14 +623,17 @@ function debugJobStatusOps(common: any, args: any, document: any) {
|
||||
function uartReadOps(common: any, args: any, document: any) {
|
||||
const generated = serialMonitorReadCommand(args, document);
|
||||
return [{
|
||||
op: "cmd.run",
|
||||
op: "io.uart.read",
|
||||
args: clean({
|
||||
...common,
|
||||
workspacePath: generated.serialMonitorDir,
|
||||
command: generated.commandRun.command,
|
||||
argv: generated.commandRun.argv,
|
||||
commandLine: generated.command,
|
||||
step: "serial-monitor-read",
|
||||
port: generated.physicalPort,
|
||||
baudRate: generated.baudRate,
|
||||
maxBytes: numberValue(args.maxBytes),
|
||||
limit: numberValue(args.limit),
|
||||
since: text(args.since),
|
||||
sessionOnly: args.sessionOnly === true,
|
||||
serialMonitorDir: generated.serialMonitorDir,
|
||||
serialMonitorCommand: generated.commandBase,
|
||||
commandBinding: generated.binding,
|
||||
timeoutMs: numberValue(args.timeoutMs) ?? generated.timeoutMs,
|
||||
reason: text(args.reason)
|
||||
@@ -751,7 +754,10 @@ function serialMonitorReadCommand(args: any, document: any) {
|
||||
[...commandBase, ...fetchArgs]
|
||||
]);
|
||||
return {
|
||||
physicalPort,
|
||||
baudRate,
|
||||
serialMonitorDir,
|
||||
commandBase,
|
||||
command: sequenceRun.commandLine,
|
||||
commandRun: sequenceRun.commandRun,
|
||||
timeoutMs,
|
||||
|
||||
+16
-16
@@ -1337,26 +1337,24 @@ async function uartRead(args: any) {
|
||||
const targetBaudRate = numberValue(diagnostics.baudRate);
|
||||
const activePort = text(statusData.port);
|
||||
const activeBaudRate = numberValue(statusData.baudRate);
|
||||
let monitorStart: any = null;
|
||||
const monitoringMatches = status.ok && statusBody?.success === true && statusData.isMonitoring === true
|
||||
&& (!targetPort || activePort.toLowerCase() === targetPort.toLowerCase())
|
||||
&& (!targetBaudRate || activeBaudRate === targetBaudRate);
|
||||
if (!monitoringMatches) {
|
||||
const ports = await runSerialMonitor(commandBase, ["ports"], { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [] }));
|
||||
return {
|
||||
ok: false,
|
||||
blockerCode: "hwpod_uart_monitor_not_active",
|
||||
summary: `io.uart.read requires serial-monitor to be active on ${targetPort || "the requested UART"}${targetBaudRate ? `/${targetBaudRate}` : ""}`,
|
||||
details: {
|
||||
...diagnostics,
|
||||
serialMonitor: {
|
||||
dir: serialMonitorDir,
|
||||
command: commandBase,
|
||||
monitorStatus: statusBody ?? { parseOk: false, stdout: status.stdout, stderr: status.stderr, exitCode: status.exitCode },
|
||||
ports: parseJsonMaybe(ports.stdout) ?? { parseOk: false, stdout: ports.stdout, stderr: ports.stderr, exitCode: ports.exitCode },
|
||||
startCommand: `cd ${serialMonitorDir} && bun scripts/serial-monitor-cli.ts monitor start -p ${targetPort || "<PORT>"}${targetBaudRate ? ` -b ${targetBaudRate}` : ""}`
|
||||
}
|
||||
}
|
||||
};
|
||||
const startArgs = ["monitor", "start", ...(targetPort ? ["-p", targetPort] : []), ...(targetBaudRate ? ["-b", String(targetBaudRate)] : [])];
|
||||
const start = await runSerialMonitor(commandBase, startArgs, { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, ...startArgs] }));
|
||||
const startBody = parseJsonMaybe(start.stdout);
|
||||
monitorStart = startBody ?? { parseOk: false, stdout: start.stdout, stderr: start.stderr, exitCode: start.exitCode };
|
||||
if (!start.ok || startBody?.success !== true) {
|
||||
const ports = await runSerialMonitor(commandBase, ["ports"], { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [] }));
|
||||
return {
|
||||
ok: false,
|
||||
blockerCode: "hwpod_uart_monitor_start_failed",
|
||||
summary: `serial-monitor could not start on ${targetPort || "the requested UART"}${targetBaudRate ? `/${targetBaudRate}` : ""}`,
|
||||
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: startBody ?? start, ports: parseJsonMaybe(ports.stdout) ?? ports } }
|
||||
};
|
||||
}
|
||||
}
|
||||
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
|
||||
const maxBytes = numberValue(args.maxBytes) ?? 16384;
|
||||
@@ -1389,6 +1387,8 @@ async function uartRead(args: any) {
|
||||
text: truncatedText,
|
||||
truncated: joined.length > maxBytes || fetchBody.truncated === true,
|
||||
monitorStatus: statusBody,
|
||||
monitorStarted: !monitoringMatches,
|
||||
monitorStart,
|
||||
sourceFile: fetchBody.sourceFile ?? null
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user