diff --git a/tools/hwlab-cli/caserun-runtime-observation.test.ts b/tools/hwlab-cli/caserun-runtime-observation.test.ts new file mode 100644 index 00000000..2c09848a --- /dev/null +++ b/tools/hwlab-cli/caserun-runtime-observation.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { compileHwpodNodeOpsPlan } from "../src/hwpod-harness-lib.ts"; +import { caseRunArtifactEntries, caseRunArtifactManifest, renderValidationObservationSummary } from "../src/hwlab-caserun-closeout.ts"; +import { caseValidationPlanFromDefinition, normalizeValidationStepResult, validationReplayRelationship } from "../src/hwlab-caserun-validation.ts"; + +const DOCUMENT = { + metadata: { name: "constart-71freq-c" }, + spec: { + nodeBinding: { nodeId: "node-nc01-71freq" }, + workspace: { path: "C:\\work\\caserun" }, + targetDevice: { board: "71-FREQ" }, + debugProbe: { type: "daplink" }, + boardComm: { jsonrpcTcp: { host: "192.168.0.154", port: 8000, command: "py -3 board-comm-cli.py" } }, + ioProbe: { + probes: [{ id: "main41.ai0.current", quantity: "current", unit: "mA", endpointRef: "main41", read: { method: "get", path: "ai/current", valuePath: "data.value" }, sample: { count: 3 } }], + endpoints: { main41: { kind: "boardCommJsonRpcTcp", host: "192.168.0.154", port: 8000, command: "py -3 board-comm-cli.py" } } + } + } +}; + +test("CaseRun derives compile-only and runtime validation plans from case definition", () => { + const compile = caseValidationPlanFromDefinition({}); + assert.equal(compile.mode, "compile-only"); + assert.equal(compile.compileOnly, true); + assert.deepEqual(compile.steps.map((step) => step.kind), ["build"]); + + const ioProbe = caseValidationPlanFromDefinition({ validation: { mode: "io-probe", ioProbe: { probeId: "main41.ai0.current" } } }); + assert.equal(ioProbe.compileOnly, false); + assert.deepEqual(ioProbe.steps.map((step) => step.kind), ["build", "download", "ioProbe"]); + + const boardComm = caseValidationPlanFromDefinition({ validation: { mode: "board-comm", boardComm: { method: "get", path: "system/status" } } }); + assert.deepEqual(boardComm.steps.map((step) => step.kind), ["build", "download", "boardComm"]); +}); + +test("HWPOD compiles ioProbe and board-comm validation into service cmd.run operations", () => { + 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.probeId, "main41.ai0.current"); + + const boardComm = compileHwpodNodeOpsPlan({ document: DOCUMENT, intent: "io.board-comm.jrpctcp", args: { method: "get", path: "system/status" } }); + assert.equal(boardComm.ops[0].op, "cmd.run"); + assert.equal(boardComm.ops[0].args.commandBinding.kind, "board-comm"); + assert.equal(boardComm.ops[0].args.commandBinding.path, "system/status"); +}); + +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]; + const normalized = normalizeValidationStepResult({ + 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" }) } }] } } + }); + assert.equal(normalized.blocker, undefined); + assert.equal(normalized.observation.sampleCount, 3); + assert.equal(normalized.observation.mean, 12); + assert.equal(normalized.observation.min, 11); + assert.equal(normalized.observation.max, 13); + assert.equal(normalized.observation.evidenceLevel, "external-physical-reading"); + const replay = validationReplayRelationship(plan, [normalized]); + assert.deepEqual(replay.observationIds, ["obs-1"]); + assert.equal(replay.relationships[0].actionId, step.actionId); + assert.equal(replay.relationships[0].operationId, "op_probe"); +}); + +test("CaseRun records board response and returns typed blockers for missing observations", () => { + const plan = caseValidationPlanFromDefinition({ validation: { mode: "board-comm", boardComm: { method: "get", path: "system/status" } } }); + const step = plan.steps[2]; + const observed = 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: "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"); +}); + +test("manifest and aggregate project archived observation facts without grading Agent final", () => { + const run: any = { caseId: "case-1", runId: "run-1", runDir: "/tmp/run-1", caseRepo: "/tmp/registry", specPath: "/tmp/run-1/.hwlab/hwpod-spec.yaml", compileOnly: false }; + assert.equal(caseRunArtifactEntries(run).some((entry) => entry.rel === "validation-observations.json"), true); + const evidence = { validation: { plan: { mode: "io-probe", compileOnly: false }, results: [{ observation: { actionId: "validation_03_ioProbe", operationId: "op_probe", kind: "ioProbe", probeId: "main41.ai0.current", quantity: "current", unit: "mA", sampleCount: 3, mean: 12, min: 11, max: 13, evidenceLevel: "external-physical-reading", rawArtifactRef: "artifacts/io.json" } }] }, replay: { observationIds: ["obs-1"] }, agentFinal: { present: true, text: "agent answer" } }; + const manifest: any = caseRunArtifactManifest({ now: () => "2026-07-16T00:00:00.000Z" } as any, run, evidence, [], [], {} as any); + assert.equal(manifest.validation.plan.mode, "io-probe"); + assert.deepEqual(manifest.replay.observationIds, ["obs-1"]); + assert.equal(manifest.agentFinal.text, "agent answer"); + const markdown = renderValidationObservationSummary(evidence.validation); + assert.match(markdown, /external-physical-reading/u); + assert.match(markdown, /main41\.ai0\.current/u); +}); diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index f461cf37..c65c73d5 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -451,7 +451,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit if (command[0] === "git" && gitArgs[0] === "diff") return { command, exitCode: 1, stdout: "", stderr: "" }; if (command[0] === "git" && gitArgs[0] === "commit") return { command, exitCode: 0, stdout: "[main abc1234] data: archive caserun run-agent-flow\n", stderr: "" }; if (command[0] === "git" && gitArgs[0] === "push") return { command, exitCode: 0, stdout: "", stderr: "To github.com:pikasTech/hwlab-case-registry.git\n" }; - return { command, exitCode: 0, stdout: JSON.stringify({ body: { results: [{ output: { stdout: JSON.stringify({ job_id: "job-compile-1" }) } }] } }), stderr: "" }; + return { command, exitCode: 0, stdout: JSON.stringify({ body: { hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", results: [{ opId: "op_build", op: "debug.build", ok: true, status: "completed", workspacePath: `${SUBJECT_REPO_LOCAL_PATH}\\.worktree\\caserun-run-agent-flow`, output: { stdout: JSON.stringify({ job_id: "job-compile-1" }) } }] } }), stderr: "" }; }, fetchImpl: caseRunFlowFetch(requests) }); diff --git a/tools/src/hwlab-caserun-closeout.ts b/tools/src/hwlab-caserun-closeout.ts index 3272c792..4bd76fab 100644 --- a/tools/src/hwlab-caserun-closeout.ts +++ b/tools/src/hwlab-caserun-closeout.ts @@ -252,6 +252,7 @@ export async function renderCaseAggregateMarkdown(context: CaseContext, input: { const diffPatch = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-diff.patch")); const traceMarkdown = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-trace.md")); const specText = await readTextIfExists(path.join(input.caseRepoRunDir, ".hwlab", "hwpod-spec.yaml")); + const validationArchive = await readJsonIfExists(path.join(input.caseRepoRunDir, "validation-observations.json")); const evidence = input.evidence ?? {}; const run = input.run ?? {}; const manifest = input.manifest ?? {}; @@ -303,6 +304,10 @@ export async function renderCaseAggregateMarkdown(context: CaseContext, input: { ]), specText ? aggregateDetails("Run-local HWPOD spec", "yaml", specText) : `_Run-local HWPOD spec artifact is missing._`, ``, + `## Runtime Validation Observations`, + ``, + renderValidationObservationSummary(validationArchive ?? evidence.validation), + ``, `## Code Agent 信息`, ``, aggregateBullets([ @@ -818,6 +823,7 @@ export function caseRunArtifactEntries(run: PreparedCaseRun) { { rel: "agent-trace.json", source: path.join(run.runDir, "agent-trace.json") }, { rel: "agent-prompt.md", source: path.join(run.runDir, "agent-prompt.md") }, { rel: "agent-diff.patch", source: path.join(run.runDir, "agent-diff.patch") }, + { rel: "validation-observations.json", source: path.join(run.runDir, "validation-observations.json") }, { rel: path.join(".hwlab", "hwpod-spec.yaml"), source: run.specPath }, { rel: "worker.stdout.log", source: path.join(run.runDir, "worker.stdout.log") }, { rel: "worker.stderr.log", source: path.join(run.runDir, "worker.stderr.log") } @@ -907,6 +913,8 @@ export function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseR agentStage: agentStageSummary(evidence), agentFinal: evidence.agentFinal ?? null, postValidation: evidence.postValidation ?? null, + validation: evidence.validation ?? null, + replay: evidence.replay ?? null, warnings: evidence.warnings ?? null, keilJob: evidence.keilJob ?? null, artifacts: evidence.artifacts ?? [], @@ -921,6 +929,31 @@ export function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseR }); } +export function renderValidationObservationSummary(value: any) { + const plan = value?.plan ?? value?.validation?.plan ?? {}; + const results = Array.isArray(value?.results) ? value.results : Array.isArray(value?.validation?.results) ? value.validation.results : []; + const observations = results.map((item: any) => item?.observation).filter(Boolean); + const blockers = results.map((item: any) => item?.blocker).filter(Boolean); + const lines = [ + `- authority: HWPOD service operation/observation results`, + `- mode: ${plan.mode ?? ""}`, + `- compileOnly: ${plan.compileOnly ?? ""}`, + `- actionCount: ${Array.isArray(plan.steps) ? plan.steps.length : results.length}`, + `- observationCount: ${observations.length}`, + `- blockerCount: ${blockers.length}`, + `- agentFinalBoundary: independent-from-post-validation`, + `- replayRelationship: archived-observation-ids-to-action-and-operation` + ]; + if (observations.length === 0) return `${lines.join("\n")}\n\n_No runtime observation was archived._`; + const readings = observations.map((item: any) => [ + `- actionId: ${item.actionId ?? ""}`, + ` - relationship: operationId=${item.operationId ?? ""}; kind=${item.kind ?? ""}; stage=${item.stage ?? ""}`, + ` - reading: probeId=${item.probeId ?? ""}; quantity=${item.quantity ?? ""}; unit=${item.unit ?? ""}; samples=${item.sampleCount ?? ""}; mean=${item.mean ?? ""}; min=${item.min ?? ""}; max=${item.max ?? ""}`, + ` - evidence: level=${item.evidenceLevel ?? ""}; rawArtifactRef=${item.rawArtifactRef ?? ""}` + ].join("\n")); + return `${lines.join("\n")}\n\n${readings.join("\n")}`; +} + export function aggregateManifestEntry(files: CaseRegistryArchiveFile[]) { const file = files.find((entry) => entry.path === "aggregate.md"); if (!file) return undefined; diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index 6008b54b..7645a9d4 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -13,6 +13,7 @@ import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseReg import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; +import { caseValidationPlanFromDefinition, hwpodCommandForValidationStep, normalizeValidationStepResult, validationReplayRelationship } from "./hwlab-caserun-validation.ts"; import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom, slug } from "./hwlab-caserun-subject.ts"; import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts"; @@ -106,6 +107,7 @@ export async function promptCaseRun(context: CaseContext) { const subject = subjectFromDefinition(loaded.definition); const worktreePath = subjectWorktreePath(subject.repoLocalPath, runId); const agentTask = agentTaskFromDefinition(loaded.definition, caseId); + const validationPlan = caseValidationPlanFromDefinition(loaded.definition); const now = context.now(); const run: PreparedCaseRun = { caseId, @@ -117,7 +119,8 @@ export async function promptCaseRun(context: CaseContext) { sourceSpecPath: loaded.specPath, specPath: loaded.specPath, apiUrl: apiUrlFrom(context), - compileOnly: true, + compileOnly: validationPlan.compileOnly, + validationPlan, createdAt: now, updatedAt: now, definition: loaded.definition, @@ -131,7 +134,8 @@ export async function promptCaseRun(context: CaseContext) { runDir, caseRepo, sourceSpecPath: loaded.specPath, - compileOnly: true, + compileOnly: validationPlan.compileOnly, + validationPlan, serviceRuntime: false, previewOnly: true, promptSha256: sha256(prompt), @@ -184,7 +188,7 @@ export async function runCaseRun(context: CaseContext) { caseId: collected.run.caseId, runId: collected.run.runId, runDir: collected.run.runDir, - compileOnly: true, + compileOnly: (collected as any).run.compileOnly, prepare: prepared.summary, agent: agentSummary(agentStage.agent), agentTrace: traceSummary(finalAgentTrace), @@ -400,6 +404,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { const apiUrl = apiUrlFrom(context); const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }, collectSourceRootSnapshot); const agentTask = agentTaskFromDefinition(loaded.definition, caseId); + const validationPlan = caseValidationPlanFromDefinition(loaded.definition); const run: PreparedCaseRun = { caseId, runId, @@ -410,7 +415,8 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { sourceSpecPath: loaded.specPath, specPath, apiUrl, - compileOnly: true, + compileOnly: validationPlan.compileOnly, + validationPlan, createdAt: now, updatedAt: now, definition: loaded.definition, @@ -429,7 +435,8 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { specPath, sourceSpecPath: loaded.specPath, apiUrl, - compileOnly: true, + compileOnly: validationPlan.compileOnly, + validationPlan, agentTask: agentTaskSummary(agentTask), subject, summary: prepareSummary(run), @@ -439,7 +446,9 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) { const run = prepared ?? await loadOrPrepareCaseRun(context); - const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`]; + const validationPlan = run.validationPlan ?? caseValidationPlanFromDefinition(run.definition); + const buildStep = validationPlan.steps.find((step) => step.kind === "build")!; + const command = hwpodCommandForValidationStep(buildStep, { executable: process.execPath, cliPath: path.join(context.cwd, "tools/hwpod-cli.ts"), specPath: run.specPath, reason: `case-run ${run.caseId} ${run.runId} ${validationPlan.mode}` }); const invoked = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, @@ -452,13 +461,30 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase 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 buildValidation = normalizeValidationStepResult({ step: buildStep, payload: hwpodPayload, document: hwpodDocument, exitCode: invoked.exitCode, stderr: invoked.stderr }); + const validationResults = [{ ...buildValidation, blocker: buildWaitBlocker ?? buildValidation.blocker }]; + for (const step of validationPlan.steps.filter((item) => item.kind !== "build")) { + if (validationResults.some((item) => item.blocker)) break; + const stepCommand = hwpodCommandForValidationStep(step, { executable: process.execPath, cliPath: path.join(context.cwd, "tools/hwpod-cli.ts"), specPath: run.specPath, reason: `case-run ${run.caseId} ${run.runId} ${step.kind}` }); + const stepInvoked = await (context.runProcess ?? runProcess)(stepCommand, context.cwd, { + ...process.env, + ...context.env, + HWLAB_RUNTIME_API_URL: run.apiUrl, + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1" + }); + validationResults.push(normalizeValidationStepResult({ step, payload: parseJsonMaybe(stepInvoked.stdout), document: hwpodDocument, exitCode: stepInvoked.exitCode, stderr: stepInvoked.stderr })); + } + const validationBlocker = validationResults.find((item) => item.blocker)?.blocker ?? null; + const validationReplay = validationReplayRelationship(validationPlan, validationResults); + const validationArchive = clean({ contractVersion: "hwpod-case-validation-observations-v1", caseId: run.caseId, runId: run.runId, observedAt: context.now(), plan: validationPlan, results: validationResults, replay: validationReplay }); + await writeJson(path.join(run.runDir, "validation-observations.json"), validationArchive); const evidence = clean({ contractVersion: "hwpod-case-run-evidence-v1", caseId: run.caseId, runId: run.runId, - compileOnly: true, + compileOnly: validationPlan.compileOnly, autoEvaluation: false, - status: "recorded", + status: validationBlocker ? "blocked" : "recorded", observedAt: context.now(), apiUrl: run.apiUrl, subject: run.subject, @@ -477,11 +503,13 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase buildWaitBlocker }), keilJob: job ? job.summary : null, + validation: clean({ plan: validationPlan, results: validationResults, blocker: validationBlocker, archivePath: "validation-observations.json" }), + replay: validationReplay, artifacts: job?.summary?.artifacts ?? [], decisions: { autoEvaluation: false, runnerPostAgentCompileCheck: "recorded", - reason: "flow-only run: CaseRun records agent, diff and compile evidence without auto-grading them" + reason: "CaseRun records Agent final independently and projects only authoritative HWPOD post-validation observations" } }); applyCaseRunEvidenceRelationships(evidence, run); @@ -492,7 +520,8 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase caseId: run.caseId, runId: run.runId, runDir: run.runDir, - compileOnly: true, + compileOnly: validationPlan.compileOnly, + validationPlan, summary: buildSummary(evidence), evidence, run: completed.run @@ -560,7 +589,7 @@ export async function collectCaseRun(context: CaseContext, prepared?: PreparedCa status: evidence.status, autoEvaluation: false }; - return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary, evidence, run }, "completed"); + return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: run.compileOnly, summary, evidence, run }, "completed"); } export function extractKeilJobId(payload: any) { @@ -1013,6 +1042,10 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg const hwpodDocument = await readHwpodSpec(run.specPath); const hwpodId = text(hwpodDocument.metadata.name) || text(hwpodDocument.metadata.uid) || run.caseId; const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(run.subject.worktreePath)}`; + const validationPlan = run.validationPlan ?? caseValidationPlanFromDefinition(run.definition); + const validationModeLine = validationPlan.compileOnly + ? "验证模式: 仅执行编译构建检查;不下载、不做运行态冒烟验证。" + : `验证模式: ${validationPlan.mode};Agent final 与 CaseRun 后置 HWPOD validation 独立记录。`; const constraints = [ "思维过程和输出消息一律使用中文", ...agentTask.constraints, @@ -1036,7 +1069,8 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg `主体隔离工作区路径: ${run.subject.worktreePath}`, `hwpodId: ${hwpodId}`, `hwpodWorkspaceArgs: ${hwpodWorkspaceArgs}`, - `验证模式: 仅执行编译构建检查;除非案例明确要求,否则不下载、不做运行态冒烟验证。`, + validationModeLine, + `CaseRun 后置验证动作: ${validationPlan.steps.map((step) => `${step.actionId}:${step.kind}`).join(", ")}`, ``, `## 运行时装配`, `AgentRun 通过 ResourceBundleRef kind=gitbundle 装配 HWLAB 运行时资源:仓库子路径 \`tools\` 会复制到工作区 \`tools\`,仓库子路径 \`skills\` 会复制到工作区 \`.agents/skills\`。CaseRun 不再发送临时 workspaceFiles 或 seed-file 载荷。`, diff --git a/tools/src/hwlab-caserun-shared.ts b/tools/src/hwlab-caserun-shared.ts index dbf4639e..8e90e4be 100644 --- a/tools/src/hwlab-caserun-shared.ts +++ b/tools/src/hwlab-caserun-shared.ts @@ -4,6 +4,7 @@ import type { AgentDiffCollectionSummary } from "./hwlab-caserun-diff.ts"; import type { AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; import type { TraceEventRow } from "./hwlab-cli/trace-renderer.ts"; +import type { CaseValidationPlan } from "./hwlab-caserun-validation.ts"; export type EnvLike = Record; export type ParsedArgs = Record & { _: string[] }; @@ -35,6 +36,7 @@ export type PreparedCaseRun = { specPath: string; apiUrl: string; compileOnly: boolean; + validationPlan?: CaseValidationPlan; createdAt: string; updatedAt: string; definition: Record; diff --git a/tools/src/hwlab-caserun-validation.ts b/tools/src/hwlab-caserun-validation.ts new file mode 100644 index 00000000..340f894d --- /dev/null +++ b/tools/src/hwlab-caserun-validation.ts @@ -0,0 +1,221 @@ +// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e. +// Responsibility: derive CaseRun validation plans and normalize authoritative HWPOD operation observations. + +import { clean, firstNumberOption, firstTextOption, parseJsonMaybe, text } from "./hwlab-caserun-evidence.ts"; + +export type CaseValidationStepKind = "build" | "download" | "uart" | "ioProbe" | "boardComm"; + +export type CaseValidationStep = { + actionId: string; + stage: "post-agent-validation"; + kind: CaseValidationStepKind; + expectedOp: string; + args: Record; +}; + +export type CaseValidationPlan = { + contractVersion: "hwpod-case-validation-plan-v1"; + mode: "compile-only" | "download-uart" | "io-probe" | "board-comm" | "custom-runtime"; + compileOnly: boolean; + source: "case-definition.validation" | "case-definition.compileOnly" | "default-compile-only"; + steps: CaseValidationStep[]; +}; + +export function caseValidationPlanFromDefinition(definition: Record): CaseValidationPlan { + const validation = record(definition.validation); + const explicitSteps = Array.isArray(validation.steps) ? validation.steps.map(record).filter((item) => Object.keys(item).length > 0) : []; + const rawMode = text(validation.mode).toLowerCase(); + const compileOnlyValue = booleanValue(validation.compileOnly ?? definition.compileOnly ?? definition.compile_only); + const mode = validationMode(rawMode, compileOnlyValue, explicitSteps); + const source = Object.keys(validation).length > 0 + ? "case-definition.validation" + : compileOnlyValue !== undefined + ? "case-definition.compileOnly" + : "default-compile-only"; + const declaredSteps = explicitSteps.length > 0 ? explicitSteps : defaultSteps(mode, validation); + const steps = declaredSteps.map((step, index) => normalizeStep(step, index)); + if (!steps.some((step) => step.kind === "build")) steps.unshift(normalizeStep({ kind: "build" }, -1)); + return { + contractVersion: "hwpod-case-validation-plan-v1", + mode, + compileOnly: steps.every((step) => step.kind === "build"), + source, + steps + }; +} + +export function hwpodCommandForValidationStep(step: CaseValidationStep, input: { executable: string; cliPath: string; specPath: string; reason: string }) { + const common = [input.executable, input.cliPath]; + if (step.kind === "build") return [...common, "build", "--spec", input.specPath, "--reason", input.reason]; + if (step.kind === "download") return [...common, "download", "--spec", input.specPath, "--reason", input.reason]; + if (step.kind === "uart") return [...common, "uart", "read", "--spec", input.specPath, ...optionArgs(step.args, ["port", "maxBytes", "limit", "since", "timeoutMs"])]; + if (step.kind === "ioProbe") return [...common, "io-probe", "read", text(step.args.probeId), "--spec", input.specPath, ...optionArgs(step.args, ["count", "intervalMs", "settleMs"])].filter(Boolean); + return [...common, "board-comm", "jrpctcp", text(step.args.method) || "get", text(step.args.path) || "api", "--spec", input.specPath, ...repeatArgs(step.args.params)].filter(Boolean); +} + +export function normalizeValidationStepResult(input: { step: CaseValidationStep; payload: any; document: any; exitCode: number; stderr?: string }) { + const response = record(input.payload?.body?.body ?? input.payload?.body ?? input.payload); + 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 expected = { + hwpodId: text(input.document?.metadata?.name ?? input.document?.metadata?.uid), + nodeId: text(input.document?.spec?.nodeBinding?.nodeId), + workspacePath: text(input.document?.spec?.workspace?.path), + op: input.step.expectedOp, + actionId: input.step.actionId + }; + 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), + op: text(result?.op), + actionId: text(result?.opId) || input.step.actionId + }; + const mismatches = Object.entries(expected).flatMap(([field, value]) => { + if (!value || field === "actionId") return []; + const observedValue = observed[field as keyof typeof observed]; + return observedValue === value ? [] : [{ field, expected: value, observed: observedValue || null }]; + }); + const operation = clean({ + contractVersion: "hwpod-operation-result-v0.3", + authority: "hwpod-service", + actionId: input.step.actionId, + stage: input.step.stage, + kind: input.step.kind, + op: observed.op || input.step.expectedOp, + opId: text(result?.opId), + status: text(result?.status ?? response.status ?? input.payload?.status) || "unknown", + ok: input.exitCode === 0 && result?.ok === true && mismatches.length === 0, + expected, + observed, + topology: mismatches.length === 0 ? { ok: true } : { ok: false, mismatches }, + rawArtifactRef: artifactRef(result, outputJson), + blocker: result?.blocker ?? input.payload?.diagnostic ?? null + }); + const observation = normalizeObservation(input.step, result, outputJson, operation); + const blocker = validationBlocker(input.step, operation, observation, input.exitCode, input.stderr); + return clean({ operation, observation, blocker }); +} + +export function validationReplayRelationship(plan: CaseValidationPlan, results: any[]) { + const observations = results.map((item) => item?.observation).filter(Boolean); + return clean({ + contractVersion: "hwpod-case-validation-replay-v1", + source: "archived-hwpod-operation-observations", + planMode: plan.mode, + actionIds: plan.steps.map((step) => step.actionId), + operationIds: results.map((item) => text(item?.operation?.opId)).filter(Boolean), + observationIds: observations.map((item) => text(item.observationId)).filter(Boolean), + relationships: observations.map((item) => clean({ + observationId: item.observationId, + actionId: item.actionId, + operationId: item.operationId, + stage: item.stage, + kind: item.kind, + probeId: item.probeId, + quantity: item.quantity, + unit: item.unit, + rawArtifactRef: item.rawArtifactRef, + evidenceLevel: item.evidenceLevel + })) + }); +} + +function normalizeObservation(step: CaseValidationStep, result: any, body: any, operation: any) { + if (step.kind !== "ioProbe" && step.kind !== "boardComm" && step.kind !== "uart") 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; + const mean = firstNumberOption(stats.mean, body.mean, body.value, average(samples)); + const minimum = firstNumberOption(stats.min, body.min, samples.length > 0 ? Math.min(...samples) : undefined); + const maximum = firstNumberOption(stats.max, body.max, samples.length > 0 ? Math.max(...samples) : undefined); + const rawArtifactRef = artifactRef(result, body) || operation.rawArtifactRef; + const operationId = text(operation.opId) || step.actionId; + return clean({ + contractVersion: "hwpod-observation-result-v1", + authority: "hwpod-service", + observationId: text(body.observationId ?? body.id) || `${step.actionId}:${operationId}`, + actionId: step.actionId, + operationId, + stage: step.stage, + kind: step.kind, + status: text(body.status ?? operation.status), + probeId: firstTextOption(body.probeId, body.probe_id, step.args.probeId), + quantity: firstTextOption(body.quantity, step.args.quantity), + unit: firstTextOption(body.unit, step.args.unit), + sampleCount, + mean, + min: minimum, + max: maximum, + response: step.kind === "boardComm" ? firstObject(body.response, body.result, body.body, body) : undefined, + rawArtifactRef, + evidenceLevel: evidenceLevel(step.kind), + ok: operation.ok === true && (step.kind === "boardComm" ? Object.keys(firstObject(body.response, body.result, body.body, body)).length > 0 : step.kind === "uart" ? Boolean(rawArtifactRef || text(body.stdout ?? body.text ?? body.data)) : sampleCount > 0 && mean !== undefined) + }); +} + +function validationBlocker(step: CaseValidationStep, operation: any, observation: any, exitCode: number, stderr?: string) { + if (operation.topology?.ok === false) return { code: "hwpod_validation_topology_mismatch", summary: "HWPOD validation operation result does not match the case-selected topology", details: operation.topology.mismatches }; + if (operation.blocker) return operation.blocker; + if (exitCode !== 0 || operation.ok !== true) return { code: "hwpod_validation_operation_failed", summary: `HWPOD ${step.kind} validation operation failed`, details: clean({ actionId: step.actionId, op: step.expectedOp, exitCode, status: operation.status, stderr: text(stderr) }) }; + if ((step.kind === "ioProbe" || step.kind === "boardComm" || step.kind === "uart") && !observation) return { code: "hwpod_validation_observation_missing", summary: `HWPOD ${step.kind} operation returned no observation result`, details: { actionId: step.actionId, operationId: operation.opId || null } }; + if (observation && observation.ok !== true) return { code: "hwpod_validation_observation_inconsistent", summary: `HWPOD ${step.kind} observation is missing required authoritative fields`, details: clean({ actionId: step.actionId, operationId: operation.opId, probeId: observation.probeId, sampleCount: observation.sampleCount, rawArtifactRef: observation.rawArtifactRef }) }; + return null; +} + +function validationMode(rawMode: string, compileOnly: boolean | undefined, steps: Record[]) { + if (steps.length > 0) return steps.every((step) => text(step.kind) === "build") ? "compile-only" : "custom-runtime"; + if (["compile-only", "compile", "build-only"].includes(rawMode)) return "compile-only"; + if (["download-uart", "uart", "runtime-uart"].includes(rawMode)) return "download-uart"; + if (["io-probe", "ioprobe"].includes(rawMode)) return "io-probe"; + if (["board-comm", "boardcomm"].includes(rawMode)) return "board-comm"; + return compileOnly === false ? "custom-runtime" : "compile-only"; +} + +function defaultSteps(mode: CaseValidationPlan["mode"], validation: Record) { + const build = { kind: "build" }; + if (mode === "compile-only") return [build]; + if (mode === "download-uart") return [build, { kind: "download" }, { kind: "uart", ...record(validation.uart) }]; + if (mode === "io-probe") return [build, { kind: "download" }, { kind: "ioProbe", ...record(validation.ioProbe) }]; + if (mode === "board-comm") return [build, { kind: "download" }, { kind: "boardComm", ...record(validation.boardComm) }]; + return [build, ...[validation.download === false ? null : { kind: "download" }, validation.uart ? { kind: "uart", ...record(validation.uart) } : null, validation.ioProbe ? { kind: "ioProbe", ...record(validation.ioProbe) } : null, validation.boardComm ? { kind: "boardComm", ...record(validation.boardComm) } : null].filter(Boolean) as Record[]]; +} + +function normalizeStep(step: Record, index: number): CaseValidationStep { + const rawKind = text(step.kind).toLowerCase().replace(/[-_]/gu, ""); + const kind: CaseValidationStepKind = rawKind === "download" ? "download" : rawKind === "uart" || rawKind === "uartread" ? "uart" : rawKind === "ioprobe" || rawKind === "ioproberead" ? "ioProbe" : rawKind === "boardcomm" ? "boardComm" : "build"; + const sequence = index < 0 ? 1 : index + 1; + const expectedOp = kind === "download" ? "debug.download" : kind === "uart" ? "io.uart.read" : kind === "ioProbe" || kind === "boardComm" ? "cmd.run" : "debug.build"; + return { actionId: text(step.actionId) || `validation_${String(sequence).padStart(2, "0")}_${kind}`, stage: "post-agent-validation", kind, expectedOp, args: clean({ ...step, kind: undefined, actionId: undefined }) }; +} + +function optionArgs(args: Record, keys: string[]) { + return keys.flatMap((key) => args[key] === undefined ? [] : [`--${key.replace(/[A-Z]/gu, (value) => `-${value.toLowerCase()}`)}`, String(args[key])]); +} + +function repeatArgs(value: unknown) { + return Array.isArray(value) ? value.flatMap((item) => ["--param", String(item)]) : []; +} + +function artifactRef(...values: any[]) { + for (const value of values) { + const candidate = firstTextOption(value?.rawArtifactRef, value?.artifactRef, value?.artifact?.ref, value?.artifact?.path, value?.rawEvidence?.artifactRef, value?.rawEvidence?.logPath, value?.log_path, value?.logPath); + if (candidate) return candidate; + } + return ""; +} + +function evidenceLevel(kind: CaseValidationStepKind) { + if (kind === "ioProbe") return "external-physical-reading"; + if (kind === "boardComm") return "board-internal-response"; + if (kind === "uart") return "serial-log"; + return "operation-result"; +} + +function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } +function firstObject(...values: unknown[]) { return values.map(record).find((value) => Object.keys(value).length > 0) ?? {}; } +function booleanValue(value: unknown) { if (typeof value === "boolean") return value; const normalized = text(value).toLowerCase(); return normalized === "true" ? true : normalized === "false" ? false : undefined; } +function numberArray(value: unknown) { return Array.isArray(value) ? value.map(Number).filter(Number.isFinite) : []; } +function average(values: number[]) { return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : undefined; } diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 065579d9..5c239f99 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -486,6 +486,16 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) { if (subcommand === "write") return { intent: "io.uart.write", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), data: requiredText(parsed.data ?? parsed._[3] ?? stdinText, "data") }) }; throw cliError("unsupported_uart_command", `unsupported uart command: ${subcommand}`); } + if (command === "board-comm" || command === "boardcomm") { + const protocol = text(parsed._[1] || "jrpctcp").toLowerCase(); + if (protocol !== "jrpctcp") throw cliError("unsupported_board_comm_protocol", `unsupported board-comm protocol: ${protocol}`, { supportedProtocols: ["jrpctcp"] }); + return { intent: "io.board-comm.jrpctcp", args: clean({ method: text(parsed._[2]) || "get", path: requiredText(parsed._[3] ?? parsed.path, "path"), params: repeatedOption(parsed.param), timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) }; + } + if (command === "io-probe" || command === "ioprobe") { + const subcommand = text(parsed._[1]) || "read"; + if (subcommand !== "read") throw cliError("unsupported_io_probe_command", `unsupported io-probe command: ${subcommand}`, { supportedCommands: ["read"] }); + return { intent: "io.probe.read", args: clean({ probeId: requiredText(parsed.probeId ?? parsed._[2], "probeId"), count: numberValue(parsed.count), intervalMs: numberValue(parsed.intervalMs), settleMs: numberValue(parsed.settleMs), timeoutMs: numberValue(parsed.timeoutMs), reason: text(parsed.reason) }) }; + } if (command === "jsonrpc") { const method = requiredText(parsed.method ?? parsed._[2] ?? parsed._[1], "method"); return { intent: "io.uart.jsonrpc", args: clean({ port: text(parsed.port ?? "uart1"), method, params: parseJsonObject(parsed.paramsJson ?? parsed.params ?? "{}", "params") }) }; @@ -530,6 +540,8 @@ function opsForIntent(intent: string, args: any, document: any) { if (intent === "io.uart.read") return uartReadOps(common, args, document); if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }]; if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }]; + if (intent === "io.board-comm.jrpctcp") return boardCommObservationOps(common, args, document); + if (intent === "io.probe.read") return ioProbeObservationOps(common, args, document); if (intent === "cmd.run") return [{ op: "cmd.run", args: clean({ ...common, command: requiredText(args.command, "command"), argv: Array.isArray(args.argv) ? args.argv : [] }) }]; throw cliError("unsupported_hwpod_intent", `unsupported hwpod intent: ${intent}`, { supportedIntents: Array.from(HWPOD_NODE_OPS).concat(["inspect"]) }); } @@ -541,9 +553,113 @@ function commonOpArgs(document: any) { targetDevice: document.spec.targetDevice, debugProbe: document.spec.debugProbe, ioProbe: document.spec.ioProbe + ,boardComm: document.spec.boardComm }; } +function boardCommObservationOps(common: any, args: any, document: any) { + const endpoint = boardCommEndpoint(document); + const commandBase = splitCommandWords(text(endpoint.command) || text(endpoint.boardCommCommand) || "py -3 board-comm-cli.py"); + const method = text(args.method) || "get"; + const apiPath = requiredText(args.path, "path"); + const params = Array.isArray(args.params) ? args.params.map(String) : []; + const argv = [...commandBase.slice(1), "jrpctcp", "--host", requiredText(endpoint.host, "boardComm.host"), "--port", String(numberValue(endpoint.port) ?? 8000), ...(text(endpoint.transport) ? ["--transport", text(endpoint.transport)] : []), ...(numberValue(endpoint.targetNodeId) ? ["--target-node-id", String(numberValue(endpoint.targetNodeId))] : []), method, apiPath, ...params]; + return [{ + op: "cmd.run", + args: clean({ + ...common, + workspacePath: text(endpoint.cwd ?? endpoint.boardCommDir) || common.workspacePath, + command: commandBase[0], + argv, + step: "board-comm-jrpctcp", + commandBinding: { kind: "board-comm", protocol: "jrpctcp", method, path: apiPath }, + timeoutMs: numberValue(args.timeoutMs ?? endpoint.timeoutMs), + reason: text(args.reason) + }) + }]; +} + +function ioProbeObservationOps(common: any, args: any, document: any) { + const probe = ioProbeById(document, requiredText(args.probeId, "probeId")); + const endpoint = ioProbeEndpoint(document, probe); + const read = objectValue(probe.read); + const sample = objectValue(probe.sample); + const commandBase = splitCommandWords(text(endpoint.command) || text(endpoint.boardCommCommand) || "py -3 board-comm-cli.py"); + const input = { + commandBase, + host: requiredText(endpoint.host, "boardComm.host"), + port: numberValue(endpoint.port) ?? 8000, + transport: text(endpoint.transport), + targetNodeId: numberValue(endpoint.targetNodeId), + method: text(read.method) || "get", + path: requiredText(read.path ?? read.apiPath, "ioProbe.read.path"), + params: Array.isArray(read.params) ? read.params.map(String) : [], + valuePath: requiredText(read.valuePath, "ioProbe.read.valuePath"), + probeId: probe.id, + quantity: text(probe.quantity ?? objectValue(probe.channel).quantity), + unit: text(probe.unit ?? objectValue(probe.channel).unit), + count: Math.max(1, Math.min(numberValue(args.count ?? sample.count) ?? 1, 50)), + 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)], + step: "io-probe-read", + commandBinding: { kind: "io-probe", 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); + const endpoint = objectValue(boardComm.jsonrpcTcp ?? boardComm.jrpctcp ?? ioProbe.boardComm); + if (Object.keys(endpoint).length === 0) throw cliError("board_comm_endpoint_missing", "HWPOD spec does not declare boardComm.jsonrpcTcp"); + return endpoint; +} + +function ioProbeById(document: any, probeId: string) { + const probes = objectValue(document.spec?.ioProbe).probes; + const items = Array.isArray(probes) ? probes : Object.entries(objectValue(probes)).map(([id, value]) => ({ id, ...objectValue(value) })); + const probe = items.find((item: any) => text(item?.id ?? item?.name) === probeId); + if (!probe) throw cliError("probe_not_found", `ioProbe not found: ${probeId}`, { probeId, availableProbeIds: items.map((item: any) => text(item?.id ?? item?.name)).filter(Boolean) }); + return { ...probe, id: probeId }; +} + +function ioProbeEndpoint(document: any, probe: any) { + const ioProbe = objectValue(document.spec?.ioProbe); + const endpointRef = text(probe.endpointRef ?? probe.endpoint); + const endpoint = objectValue(objectValue(ioProbe.endpoints)[endpointRef]); + if (Object.keys(endpoint).length > 0) return endpoint; + return boardCommEndpoint(document); +} + +function repeatedOption(value: unknown) { return Array.isArray(value) ? value.map(String) : value === undefined ? [] : [String(value)]; } + function debugBuildOps(common: any, args: any, document: any) { const explicitCommand = text(args.command) || text(document.spec.workspace.buildCommand); const generated = explicitCommand ? null : keilCommandForIntent("debug.build", args, document);