From 166f45e485d8be6d4fdc2a87edd6efa5e1b3b505 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 19:14:34 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20CaseRun=20=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E6=9D=83=E5=A8=81=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caserun-runtime-observation.test.ts | 41 +++++++++- tools/hwpod-io-probe-observation.mjs | 77 +++++++++++++++++++ tools/hwpod-node.test.ts | 28 +++++++ tools/src/hwlab-caserun-validation.ts | 3 +- tools/src/hwpod-harness-lib.ts | 21 +---- tools/src/hwpod-node-lib.ts | 19 ++++- 6 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 tools/hwpod-io-probe-observation.mjs diff --git a/tools/hwlab-cli/caserun-runtime-observation.test.ts b/tools/hwlab-cli/caserun-runtime-observation.test.ts index 2c09848a..f0264099 100644 --- a/tools/hwlab-cli/caserun-runtime-observation.test.ts +++ b/tools/hwlab-cli/caserun-runtime-observation.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { test } from "bun:test"; import { compileHwpodNodeOpsPlan } from "../src/hwpod-harness-lib.ts"; +import { executeHwpodNodeOpsPlan } from "../src/hwpod-node-lib.ts"; import { caseRunArtifactEntries, caseRunArtifactManifest, renderValidationObservationSummary } from "../src/hwlab-caserun-closeout.ts"; import { caseValidationPlanFromDefinition, normalizeValidationStepResult, validationReplayRelationship } from "../src/hwlab-caserun-validation.ts"; @@ -38,7 +42,10 @@ test("HWPOD compiles ioProbe and board-comm validation into service cmd.run oper const ioProbe = compileHwpodNodeOpsPlan({ document: DOCUMENT, intent: "io.probe.read", args: { probeId: "main41.ai0.current", count: 3 } }); assert.equal(ioProbe.ops[0].op, "cmd.run"); assert.equal(ioProbe.ops[0].args.commandBinding.kind, "io-probe"); + assert.equal(ioProbe.ops[0].args.commandBinding.nativeHelper, "io-probe-observation"); assert.equal(ioProbe.ops[0].args.commandBinding.probeId, "main41.ai0.current"); + assert.match(ioProbe.ops[0].args.argv[0], /hwpod-io-probe-observation\.mjs$/u); + assert.equal(ioProbe.ops[0].args.argv.includes("-e"), false); const boardComm = compileHwpodNodeOpsPlan({ document: DOCUMENT, intent: "io.board-comm.jrpctcp", args: { method: "get", path: "system/status" } }); assert.equal(boardComm.ops[0].op, "cmd.run"); @@ -46,6 +53,31 @@ test("HWPOD compiles ioProbe and board-comm validation into service cmd.run oper assert.equal(boardComm.ops[0].args.commandBinding.path, "system/status"); }); +test("HWPOD node owns ioProbe command output normalization before CaseRun projection", async () => { + const workspacePath = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-observation-authority-")); + try { + const fakeBoardComm = path.join(workspacePath, "fake-board-comm.mjs"); + await writeFile(fakeBoardComm, "console.log(JSON.stringify({ data: { value: 12 }, artifactRef: 'artifacts/io/raw.json' }));\n", "utf8"); + const document = structuredClone(DOCUMENT); + document.spec.workspace.path = workspacePath; + document.spec.ioProbe.endpoints.main41.command = `${process.execPath} ${fakeBoardComm}`; + const plan = compileHwpodNodeOpsPlan({ document, intent: "io.probe.read", args: { probeId: "main41.ai0.current", count: 3 } }); + const nodeResult = await executeHwpodNodeOpsPlan(plan, { now: () => "2026-07-16T00:00:00.000Z" }); + + assert.equal(nodeResult.ok, true); + assert.equal(nodeResult.results[0].output.observation.probeId, "main41.ai0.current"); + assert.deepEqual(nodeResult.results[0].output.observation.samples, [12, 12, 12]); + + const validationPlan = caseValidationPlanFromDefinition({ validation: { mode: "io-probe", ioProbe: { probeId: "main41.ai0.current", quantity: "current", unit: "mA" } } }); + const normalized = normalizeValidationStepResult({ step: validationPlan.steps[2], document, exitCode: 0, payload: { body: nodeResult } }); + assert.equal(normalized.blocker, undefined); + assert.equal(normalized.observation.authority, "hwpod-service"); + assert.equal(normalized.observation.mean, 12); + } finally { + await rm(workspacePath, { recursive: true, force: true }); + } +}); + test("CaseRun normalizes authoritative ioProbe statistics and replay relationships", () => { const plan = caseValidationPlanFromDefinition({ validation: { mode: "io-probe", ioProbe: { probeId: "main41.ai0.current", quantity: "current", unit: "mA" } } }); const step = plan.steps[2]; @@ -53,7 +85,7 @@ test("CaseRun normalizes authoritative ioProbe statistics and replay relationshi step, document: DOCUMENT, exitCode: 0, - payload: { body: { hwpodId: "constart-71freq-c", nodeId: "node-nc01-71freq", results: [{ opId: "op_probe", op: "cmd.run", ok: true, status: "completed", workspacePath: "C:\\work\\caserun", output: { stdout: JSON.stringify({ observationId: "obs-1", probeId: "main41.ai0.current", quantity: "current", unit: "mA", samples: [11, 12, 13], stats: { count: 3, mean: 12, min: 11, max: 13 }, rawArtifactRef: "artifacts/io/main41-ai0-current.json" }) } }] } } + payload: { body: { hwpodId: "constart-71freq-c", nodeId: "node-nc01-71freq", results: [{ opId: "op_probe", op: "cmd.run", ok: true, status: "completed", workspacePath: "C:\\work\\caserun", output: { observation: { observationId: "obs-1", probeId: "main41.ai0.current", quantity: "current", unit: "mA", samples: [11, 12, 13], stats: { count: 3, mean: 12, min: 11, max: 13 }, rawArtifactRef: "artifacts/io/main41-ai0-current.json" } } }] } } }); assert.equal(normalized.blocker, undefined); assert.equal(normalized.observation.sampleCount, 3); @@ -74,14 +106,15 @@ test("CaseRun records board response and returns typed blockers for missing obse step, document: DOCUMENT, exitCode: 0, - payload: { body: { hwpodId: "constart-71freq-c", nodeId: "node-nc01-71freq", results: [{ opId: "op_board", op: "cmd.run", ok: true, status: "completed", workspacePath: "C:\\work\\caserun", output: { stdout: JSON.stringify({ observationId: "obs-board", response: { ok: true, firmware: "1.2.3" }, rawArtifactRef: "artifacts/board/system-status.json" }) } }] } } + payload: { body: { hwpodId: "constart-71freq-c", nodeId: "node-nc01-71freq", results: [{ opId: "op_board", op: "cmd.run", ok: true, status: "completed", workspacePath: "C:\\work\\caserun", output: { observation: { observationId: "obs-board", response: { ok: true, firmware: "1.2.3" }, rawArtifactRef: "artifacts/board/system-status.json" } } }] } } }); assert.equal(observed.blocker, undefined); assert.equal(observed.observation.response.firmware, "1.2.3"); assert.equal(observed.observation.evidenceLevel, "board-internal-response"); - const missing = normalizeValidationStepResult({ step, document: DOCUMENT, exitCode: 0, payload: { body: { hwpodId: "constart-71freq-c", nodeId: "node-nc01-71freq", results: [{ opId: "op_board", op: "cmd.run", ok: true, status: "completed", workspacePath: "C:\\work\\caserun", output: {} }] } } }); - assert.equal(missing.blocker.code, "hwpod_validation_observation_inconsistent"); + const stdoutOnly = normalizeValidationStepResult({ step, document: DOCUMENT, exitCode: 0, payload: { body: { hwpodId: "constart-71freq-c", nodeId: "node-nc01-71freq", results: [{ opId: "op_board", op: "cmd.run", ok: true, status: "completed", workspacePath: "C:\\work\\caserun", output: { stdout: JSON.stringify({ observationId: "not-authoritative", response: { ok: true } }) } }] } } }); + assert.equal(stdoutOnly.observation, undefined); + assert.equal(stdoutOnly.blocker.code, "hwpod_validation_observation_missing"); }); test("manifest and aggregate project archived observation facts without grading Agent final", () => { diff --git a/tools/hwpod-io-probe-observation.mjs b/tools/hwpod-io-probe-observation.mjs new file mode 100644 index 00000000..93755ab3 --- /dev/null +++ b/tools/hwpod-io-probe-observation.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from "node:child_process"; + +const input = JSON.parse(process.argv[2] || "{}"); + +function readPath(value, expression) { + return String(expression || "") + .replace(/^\$\.?/u, "") + .split(".") + .filter(Boolean) + .reduce((current, key) => current == null ? undefined : current[key], value); +} + +function responseCandidates(value) { + return [value?.response?.response, value?.response, value].filter(Boolean); +} + +function observationValue(value) { + for (const candidate of responseCandidates(value)) { + const observed = readPath(candidate, input.valuePath); + if (observed !== undefined) return Number(observed); + } + return Number.NaN; +} + +function sleep(milliseconds) { + if (milliseconds > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function boardCommand() { + return [ + ...input.commandBase, + "jrpctcp", + "--host", + input.host, + "--port", + String(input.port), + ...(input.transport ? ["--transport", input.transport] : []), + ...(input.targetNodeId ? ["--target-node-id", String(input.targetNodeId)] : []), + input.method, + input.path, + ...input.params + ]; +} + +sleep(input.settleMs); +const samples = []; +let response = null; +let exitCode = 0; + +for (let index = 0; index < input.count; index += 1) { + const command = boardCommand(); + const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" }); + try { + response = JSON.parse(String(result.stdout || "").trim()); + } catch { + response = null; + } + const value = observationValue(response); + if (Number.isFinite(value)) samples.push(value); + if (result.status !== 0) exitCode = result.status || 1; + if (index < input.count - 1) sleep(input.intervalMs); +} + +const mean = samples.length > 0 ? samples.reduce((sum, value) => sum + value, 0) / samples.length : undefined; +const observation = { + observationId: `obs_${input.probeId}_${Date.now()}`, + probeId: input.probeId, + quantity: input.quantity, + unit: input.unit, + samples, + stats: samples.length > 0 ? { count: samples.length, mean, min: Math.min(...samples), max: Math.max(...samples) } : {}, + response, + rawArtifactRef: response?.log_path || response?.logPath || response?.artifactRef || null +}; + +console.log(JSON.stringify(observation)); +process.exit(exitCode === 0 && samples.length > 0 ? 0 : exitCode || 1); diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 48a25092..efc3e992 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -153,6 +153,34 @@ test("hwpod-node reports cmd.run non-zero exits as failed results", async () => } }); +test("hwpod-node projects bound command JSON into authoritative observation", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-observation-")); + try { + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_observation", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_observation", + op: "cmd.run", + args: { + workspacePath: root, + command: process.execPath, + argv: ["-e", "console.log(JSON.stringify({ observationId: 'obs-node', response: { ok: true } }))"], + commandBinding: { kind: "board-comm" } + } + }] + }, { now: () => "2026-07-16T00:00:00.000Z" }); + + assert.equal(result.ok, true); + assert.equal(result.results[0].output.observation.observationId, "obs-node"); + assert.equal(result.results[0].output.observation.response.ok, true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node refuses plans targeting another node id", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-id-mismatch-")); try { diff --git a/tools/src/hwlab-caserun-validation.ts b/tools/src/hwlab-caserun-validation.ts index 340f894d..b4467df0 100644 --- a/tools/src/hwlab-caserun-validation.ts +++ b/tools/src/hwlab-caserun-validation.ts @@ -58,7 +58,7 @@ export function normalizeValidationStepResult(input: { step: CaseValidationStep; const results = Array.isArray(response.results) ? response.results : []; const result = results.find((item: any) => text(item?.opId) === input.step.actionId || text(item?.op) === input.step.expectedOp) ?? results[0] ?? {}; const output = record(result?.output); - const outputJson = firstObject(result?.observation, output.observation, parseJsonMaybe(output.stdout), parseJsonMaybe(result?.stdout)); + const outputJson = firstObject(result?.observation, output.observation); const expected = { hwpodId: text(input.document?.metadata?.name ?? input.document?.metadata?.uid), nodeId: text(input.document?.spec?.nodeBinding?.nodeId), @@ -125,6 +125,7 @@ export function validationReplayRelationship(plan: CaseValidationPlan, results: function normalizeObservation(step: CaseValidationStep, result: any, body: any, operation: any) { if (step.kind !== "ioProbe" && step.kind !== "boardComm" && step.kind !== "uart") return undefined; + if (Object.keys(record(body)).length === 0) return undefined; const samples = numberArray(body.samples ?? body.values ?? body.rawSamples?.map?.((item: any) => item?.value)); const stats = record(body.stats ?? body.statistics); const sampleCount = firstNumberOption(body.sampleCount, stats.count, samples.length) ?? 0; diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 5c239f99..27f6b008 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -602,38 +602,21 @@ function ioProbeObservationOps(common: any, args: any, document: any) { intervalMs: Math.max(0, numberValue(args.intervalMs ?? sample.intervalMs) ?? 0), settleMs: Math.max(0, numberValue(args.settleMs ?? sample.settleMs) ?? 0) }; - const script = ioProbeObservationScript(); return [{ op: "cmd.run", args: clean({ ...common, workspacePath: text(endpoint.cwd ?? endpoint.boardCommDir) || common.workspacePath, command: "node", - argv: ["-e", script, JSON.stringify(input)], + argv: ["hwpod-io-probe-observation.mjs", JSON.stringify(input)], step: "io-probe-read", - commandBinding: { kind: "io-probe", probeId: probe.id, quantity: input.quantity, unit: input.unit, endpointKind: "boardCommJsonRpcTcp" }, + commandBinding: { kind: "io-probe", nativeHelper: "io-probe-observation", probeId: probe.id, quantity: input.quantity, unit: input.unit, endpointKind: "boardCommJsonRpcTcp" }, timeoutMs: numberValue(args.timeoutMs ?? endpoint.timeoutMs), reason: text(args.reason) }) }]; } -function ioProbeObservationScript() { - return [ - "const {spawnSync}=require('child_process');", - "const i=JSON.parse(process.argv[1]||'{}');", - "const read=(o,p)=>String(p||'').replace(/^\\$\\.?/,'').split('.').filter(Boolean).reduce((a,k)=>a==null?undefined:a[k],o);", - "const unwrap=o=>[o?.response?.response,o?.response,o].filter(Boolean);", - "const value=o=>{for(const c of unwrap(o)){const v=read(c,i.valuePath);if(v!==undefined)return Number(v);}return NaN;};", - "const sleep=ms=>{if(ms>0)Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);};", - "sleep(i.settleMs);const samples=[];let last=null;let code=0;", - "for(let n=0;na+b,0)/samples.length:undefined;", - "const body={observationId:`obs_${i.probeId}_${Date.now()}`,probeId:i.probeId,quantity:i.quantity,unit:i.unit,samples,stats:samples.length?{count:samples.length,mean,min:Math.min(...samples),max:Math.max(...samples)}:{},response:last,rawArtifactRef:last?.log_path||last?.logPath||last?.artifactRef||null};", - "console.log(JSON.stringify(body));process.exit(code===0&&samples.length>0?0:(code||1));" - ].join(""); -} - function boardCommEndpoint(document: any) { const boardComm = objectValue(document.spec?.boardComm); const ioProbe = objectValue(document.spec?.ioProbe); diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 63b09146..55673095 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -3,6 +3,7 @@ import { createServer } from "node:http"; import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts"; @@ -1318,10 +1319,24 @@ function resolvePatchFile(root: string, relativePath: string) { async function cmdRun(args: any) { const command = requiredText(args.command, "command"); - const argv = Array.isArray(args.argv) ? args.argv.map(String) : []; + const commandBinding = objectValue(args.commandBinding); + const argv = nativeHelperArgv(Array.isArray(args.argv) ? args.argv.map(String) : [], commandBinding); const cwd = workspaceRoot(args); const output = await spawnOutput([command, ...argv], { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 30000 }); - return { cwd, command: [command, ...argv], ...output }; + const observation = commandObservation(commandBinding, output); + return { cwd, command: [command, ...argv], ...output, ...(observation ? { observation } : {}) }; +} + +function nativeHelperArgv(argv: string[], commandBinding: Record) { + if (text(commandBinding.nativeHelper) !== "io-probe-observation") return argv; + const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../hwpod-io-probe-observation.mjs"); + return [helperPath, ...argv.slice(1)]; +} + +function commandObservation(commandBinding: Record, output: any) { + if (!["io-probe", "board-comm"].includes(text(commandBinding.kind))) return undefined; + const observation = parseJsonMaybe(output.stdout); + return observation && typeof observation === "object" && !Array.isArray(observation) ? observation : undefined; } async function uartRead(args: any) {