From 814ea533f30ba2e568d9a30744a705a0b9fcaeea Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 16:08:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20CaseRun=20HWPOD=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/hwlab-cli/caserun.test.ts | 23 ++++++++++++- tools/hwlab-node.py | 12 +++++-- tools/hwpod-harness.test.ts | 30 ++++++++--------- tools/hwpod-node.test.ts | 9 ++++-- tools/src/hwlab-caserun-diff.ts | 18 +++++++++++ tools/src/hwlab-caserun-runtime.ts | 52 ++++++++++++++++++++++++++++-- tools/src/hwpod-harness-lib.ts | 20 ++++++++---- tools/src/hwpod-node-lib.ts | 32 +++++++++--------- 8 files changed, 147 insertions(+), 49 deletions(-) diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 34fd6342..680ca66b 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -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" }]); } diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index c4468911..cd28065b 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -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: diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 73e118cc..f8789076 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -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 }); diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 064a5fe5..cdb3dfd7 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -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 }); diff --git a/tools/src/hwlab-caserun-diff.ts b/tools/src/hwlab-caserun-diff.ts index 53541538..89045444 100644 --- a/tools/src/hwlab-caserun-diff.ts +++ b/tools/src/hwlab-caserun-diff.ts @@ -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[0], paths: string[]) { const records = new Map(); if (paths.length === 0) return { records, requests: [] as Array<{ label: string; result: CommandResult }> }; diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index f5848113..d87c9bca 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -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")); diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index c3933f4a..065579d9 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -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, diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index cbe2a460..df1c2ed0 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -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 || ""}${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 }; }