fix: 修正 CaseRun 观测权威边界
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;n<i.count;n++){const argv=[...i.commandBase.slice(1),'jrpctcp','--host',i.host,'--port',String(i.port),...(i.transport?['--transport',i.transport]:[]),...(i.targetNodeId?['--target-node-id',String(i.targetNodeId)]:[]),i.method,i.path,...i.params];const r=spawnSync(i.commandBase[0],argv,{encoding:'utf8'});try{last=JSON.parse(String(r.stdout||'').trim());}catch{last=null;}const v=value(last);if(Number.isFinite(v))samples.push(v);if(r.status!==0)code=r.status||1;if(n<i.count-1)sleep(i.intervalMs);}",
|
||||
"const mean=samples.length?samples.reduce((a,b)=>a+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);
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>, 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) {
|
||||
|
||||
Reference in New Issue
Block a user