From 6af6711fc3a86086bdbb0ab7d8ebe87b24fd3a32 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 16:01:54 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20CaseRun=20regi?= =?UTF-8?q?stry=20=E7=A6=BB=E7=BA=BF=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caserun-offline-diagnostics.test.ts | 128 +++++++++ tools/src/hwlab-caserun-closeout.ts | 24 +- tools/src/hwlab-caserun-diagnostics.ts | 253 ++++++++++++++++++ tools/src/hwlab-caserun-runtime.ts | 17 +- 4 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 tools/hwlab-cli/caserun-offline-diagnostics.test.ts create mode 100644 tools/src/hwlab-caserun-diagnostics.ts diff --git a/tools/hwlab-cli/caserun-offline-diagnostics.test.ts b/tools/hwlab-cli/caserun-offline-diagnostics.test.ts new file mode 100644 index 00000000..3126bedb --- /dev/null +++ b/tools/hwlab-cli/caserun-offline-diagnostics.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "bun:test"; + +import { runHwlabCli } from "../src/hwlab-cli-lib.ts"; + +test("case aggregate remains registry-only even when runtime credentials exist", async () => { + const fixture = await createRegistryFixture("aggregate-offline"); + try { + let runtimeCalls = 0; + const result = await runHwlabCli(["case", "aggregate", fixture.caseId, "--run-id", fixture.runId, "--case-repo", fixture.caseRepo, "--no-case-repo-git-sync"], { + cwd: fixture.root, + env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, + runProcess: async () => { + runtimeCalls += 1; + throw new Error("offline aggregate must not execute runtime commands"); + }, + }); + + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); + assert.equal(runtimeCalls, 0); + assert.equal(result.payload.action, "case.aggregate"); + assert.match(await readFile(path.join(fixture.runDir, "aggregate.md"), "utf8"), /registry artifacts/u); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("case refresh is explicit, registry-backed, and semantically idempotent", async () => { + const fixture = await createRegistryFixture("refresh-local"); + try { + const args = ["case", "refresh", fixture.caseId, "--run-id", fixture.runId, "--case-repo", fixture.caseRepo, "--source", "local", "--no-case-repo-git-sync"]; + const first = await runHwlabCli(args, { cwd: fixture.root, now: () => "2026-07-16T14:00:00.000Z", env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, runProcess: forbiddenRuntime }); + assert.equal(first.exitCode, 0, JSON.stringify(first.payload, null, 2)); + assert.deepEqual(first.payload.refreshed.provenance, { source: "local", authority: "registry", runtimeAccess: false }); + assert.equal(first.payload.refreshed.commandCount, 2); + assert.equal(first.payload.refreshed.hwpodCommandCount, 1); + assert.equal(first.payload.refreshed.hwpodBuildCommandCount, 1); + const before = await fileHashes(fixture.runDir, ["evidence.json", "agent-messages.json", "artifact-manifest.json", "aggregate.md"]); + + const second = await runHwlabCli(args, { cwd: fixture.root, now: () => "2026-07-16T15:00:00.000Z", env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, runProcess: forbiddenRuntime }); + assert.equal(second.exitCode, 0, JSON.stringify(second.payload, null, 2)); + assert.deepEqual(await fileHashes(fixture.runDir, Object.keys(before)), before); + const manifest = JSON.parse(await readFile(path.join(fixture.runDir, "artifact-manifest.json"), "utf8")); + assert.deepEqual(manifest.refresh, { source: "local", authority: "registry", runtimeAccess: false }); + assert.equal(manifest.trace.toolCallSummary.count, 2); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("case run offline diagnostics inspect trace and skill usage without runtime access", async () => { + const fixture = await createRegistryFixture("inspect-offline"); + try { + const options = { cwd: fixture.root, env: { HWLAB_API_KEY: "hwl_live_should_not_be_used" }, runProcess: forbiddenRuntime }; + const inspect = await runHwlabCli(["case", "run", "inspect", fixture.runId, "--case-repo", fixture.caseRepo], options); + assert.equal(inspect.exitCode, 0, JSON.stringify(inspect.payload, null, 2)); + assert.equal(inspect.payload.serviceRuntime, false); + assert.equal(inspect.payload.traceId, "trc_offline"); + assert.equal(inspect.payload.providerProfile, "deepseek"); + assert.equal(inspect.payload.terminalStatus, "completed"); + assert.equal(inspect.payload.toolCallSummary.count, 2); + + const trace = await runHwlabCli(["case", "run", "trace-summary", fixture.runId, "--case-repo", fixture.caseRepo], options); + assert.equal(trace.exitCode, 0, JSON.stringify(trace.payload, null, 2)); + assert.equal(trace.payload.commandCount, 2); + assert.equal(trace.payload.traceCommands.length, 2); + + const skill = await runHwlabCli(["case", "run", "skill-usage", fixture.runId, "--skill", "keil", "--case-repo", fixture.caseRepo], options); + assert.equal(skill.exitCode, 0, JSON.stringify(skill.payload, null, 2)); + assert.equal(skill.payload.serviceRuntime, false); + assert.equal(skill.payload.trace.readSkillFile, true); + assert.equal(skill.payload.resourceBundle.materialized, true); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("case refresh accesses runtime only with explicit runtime source", async () => { + const fixture = await createRegistryFixture("refresh-runtime"); + try { + let runtimeCalls = 0; + const result = await runHwlabCli(["case", "refresh", fixture.caseId, "--run-id", fixture.runId, "--case-repo", fixture.caseRepo, "--source", "runtime", "--no-case-repo-git-sync"], { + cwd: fixture.root, + env: { HWLAB_API_KEY: "hwl_live_runtime_test" }, + runProcess: async () => { + runtimeCalls += 1; + return { command: [], exitCode: 0, stderr: "", stdout: JSON.stringify({ body: { traceId: "trc_offline", source: "hwlab-cli.client.agent.trace", status: "completed", terminalStatus: "completed", rows: [{ rowId: "tool:runtime", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { rowId: "final", seq: 2, header: "助手最终消息", terminal: true, body: "runtime refreshed" }] } }) }; + }, + }); + + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); + assert.equal(runtimeCalls, 1); + assert.deepEqual(result.payload.refreshed.provenance, { source: "runtime", authority: "registry", runtimeAccess: true }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +async function createRegistryFixture(suffix: string) { + const root = await mkdtemp(path.join(os.tmpdir(), `hwlab-caserun-${suffix}-`)); + const caseRepo = path.join(root, "hwlab-case-registry"); + const caseId = `case-${suffix}`; + const runId = `run-${suffix}`; + const runDir = path.join(caseRepo, "runs", caseId, runId); + await mkdir(path.join(caseRepo, ".git"), { recursive: true }); + await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + await mkdir(path.join(runDir, ".hwlab"), { recursive: true }); + const agent = { stageStatus: "completed", baseUrl: "http://web.test", traceId: "trc_offline", sessionId: "ses_offline", conversationId: "cnv_offline", threadId: "thr_offline", providerProfile: "deepseek", requestedProviderProfile: "deepseek", resolvedBackendProfile: "deepseek-runtime", provider: "deepseek", model: "deepseek-chat", terminalStatus: "completed", commandStatus: "completed", agentRunStatus: "completed", promptSha256: "prompt-sha" }; + const toolCallSummary = { source: "agentrun-result.toolCallSummary", count: 2, statusCounts: { completed: 2 }, exitCodeCounts: { "0": 2 }, terminalStatus: "completed", commandStatus: "completed", agentRunStatus: "completed", items: [{ kind: "build", toolName: "commandExecution", status: "completed", exitCode: 0, command: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { kind: "other", toolName: "commandExecution", status: "completed", exitCode: 0, command: "cat ~/.agents/skills/keil/SKILL.md" }] }; + await writeJson(path.join(runDir, "run.json"), { caseId, runId, runDir, caseRepo, specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), compileOnly: true, createdAt: "2026-07-16T13:00:00.000Z", updatedAt: "2026-07-16T13:00:00.000Z", definition: {}, subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), subdir: "", worktreePath: "C:/work/fw/.worktree/case", workspacePath: "C:/work/fw/.worktree/case", setup: {} }, agent }); + await writeJson(path.join(runDir, "evidence.json"), { contractVersion: "hwpod-case-run-evidence-v1", caseId, runId, compileOnly: true, autoEvaluation: false, status: "recorded", subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), worktreePath: "C:/work/fw/.worktree/case" }, agent, agentTrace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, agentFinal: { status: "completed", terminalStatus: "completed" }, postValidation: { source: "case-run-runner-post-agent-compile-check", status: "completed", returnCode: 0 }, decisions: { autoEvaluation: false, runnerPostAgentCompileCheck: "recorded" } }); + await writeJson(path.join(runDir, "agent-trace.json"), { contractVersion: "hwpod-case-run-agent-trace-identity-v1", traceId: "trc_offline", status: "completed", terminalStatus: "completed", commandStatus: "completed", toolCallSummary }); + await writeJson(path.join(runDir, "agent-messages.json"), { renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows", sourceEventCount: 2, renderedRowCount: 2, rows: [{ rowId: "tool:build", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { rowId: "tool:skill", seq: 2, header: "工具调用", body: "cat ~/.agents/skills/keil/SKILL.md" }] }); + await writeJson(path.join(runDir, "artifact-manifest.json"), { contractVersion: "hwpod-case-run-artifact-manifest-v1", caseId, runId, agent, trace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, agentFinal: { status: "completed" }, postValidation: { status: "completed", returnCode: 0 }, files: [] }); + await writeFile(path.join(runDir, "agent-prompt.md"), "请读取 keil skill 后执行编译。\n", "utf8"); + await writeFile(path.join(runDir, "final-response.md"), "# CaseRun Final Response\n\ncompleted\n", "utf8"); + await writeFile(path.join(runDir, "agent-diff.patch"), "", "utf8"); + await writeFile(path.join(runDir, ".hwlab", "hwpod-spec.yaml"), "metadata:\n name: offline\n", "utf8"); + return { root, caseRepo, caseId, runId, runDir }; +} + +async function writeJson(file: string, value: unknown) { await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +async function fileHashes(root: string, files: string[]) { return Object.fromEntries(await Promise.all(files.map(async (file) => [file, createHash("sha256").update(await readFile(path.join(root, file))).digest("hex")]))); } +async function forbiddenRuntime() { throw new Error("offline diagnostics must not execute runtime commands"); } diff --git a/tools/src/hwlab-caserun-closeout.ts b/tools/src/hwlab-caserun-closeout.ts index e214749b..6c109caa 100644 --- a/tools/src/hwlab-caserun-closeout.ts +++ b/tools/src/hwlab-caserun-closeout.ts @@ -204,7 +204,7 @@ export async function renderCaseAggregateMarkdown(context: CaseContext, input: { const diff = manifest.diff ?? evidence.agentDiff ?? run.agentDiff ?? {}; const keilJob = manifest.keilJob ?? evidence.keilJob ?? null; const agentStage = manifest.agentStage ?? agentStageSummary(evidence); - const messages = await aggregateMessagesWithFreshFullTrace(context, { archivedMessages, traceLookup, traceId: agent.traceId ?? trace.traceId ?? traceLookup?.traceId, run, evidence, manifest }); + const messages = archivedMessages; const traceBody = aggregateTraceBody(messages, traceMarkdown); return `${[ `# HWPOD CaseRun Aggregate: ${input.caseId}`, @@ -391,7 +391,7 @@ export function aggregateFence(language: string, body: string) { } export function aggregateArtifactIndex(manifest: any) { - const files = arrayOfObjects(manifest?.files); + const files = arrayOfObjects(manifest?.files).filter((file) => text(file.path) !== "aggregate.md"); if (files.length === 0) return `_artifact-manifest.json is missing or has no files list._`; return [`| Path | Bytes | SHA-256 |`, `|---|---:|---|`, ...files.map((file) => `| ${markdownTableText(file.path, 220)} | ${file.bytes ?? ""} | ${markdownTableText(file.sha256, 80)} |`)].join("\n"); } @@ -534,7 +534,7 @@ export async function archivedAgentTraceSnapshot(context: CaseContext, run: Prep const bodyForSummary = traceBody?.body && typeof traceBody.body === "object" ? traceBody.body : traceBody; const events = arrayOfObjects(bodyForSummary?.events ?? bodyForSummary?.trace?.events ?? bodyForSummary?.sourceEvents); const rows = traceRowsFromBody(bodyForSummary, events); - const hasRenderableTrace = rows.length > 0 || events.length > 0; + const hasRenderableTrace = rows.length > 0 || events.length > 0 || Boolean(bodyForSummary?.toolCallSummary); const summary = hasRenderableTrace ? summarizeAgentTrace(text(bodyForSummary?.traceId ?? traceBody?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId), numberOption(traceBody?.httpStatus) ?? 0, traceBody) : existingAgentTraceSummary(run, evidence, bodyForSummary); @@ -558,20 +558,28 @@ export async function archivedAgentTraceBody(run: PreparedCaseRun, evidence: any export async function materializeFullAgentTraceWithExistingCli(context: CaseContext, run: PreparedCaseRun, evidence: any, initialTrace: any) { const body = initialTrace?.body && typeof initialTrace.body === "object" ? initialTrace.body : initialTrace; const existingEvents = arrayOfObjects(body?.events ?? body?.trace?.events ?? body?.sourceEvents); - if (existingEvents.length > 0) return initialTrace; + const forceRuntimeRefresh = context.parsed.caseRefreshSource === "runtime"; + if (existingEvents.length > 0 && !forceRuntimeRefresh) return initialTrace; if (!run.agent) return initialTrace; const traceId = text(body?.traceId ?? initialTrace?.traceId ?? evidence.agent?.traceId ?? run.agent?.traceId); const lookup = agentTraceLookupForRun(run, evidence); const apiKey = text(context.env.HWLAB_API_KEY ?? process.env.HWLAB_API_KEY); - if (!traceId || !lookup || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) return initialTrace; + if (!traceId || !lookup || !apiKey.startsWith(HWLAB_API_KEY_PREFIX)) { + if (forceRuntimeRefresh) throw cliError("case_refresh_runtime_unavailable", "explicit runtime refresh requires trace identity, runtime base URL, and HWLAB_API_KEY", { traceId, hasLookup: Boolean(lookup), hasApiKey: apiKey.startsWith(HWLAB_API_KEY_PREFIX) }); + return initialTrace; + } const baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context, run.definition); const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"]; const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl }); if (result.exitCode !== 0) { + if (forceRuntimeRefresh) throw cliError("case_refresh_trace_fetch_failed", "explicit runtime refresh failed through the existing trace CLI", { traceId, exitCode: result.exitCode, stderr: clipText(result.stderr, 1200) }); return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: result.exitCode, stderr: clipText(result.stderr, 1200), stdout: clipText(result.stdout, 1200) } }); } const parsed = parseJsonMaybe(result.stdout); - if (!parsed) return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: 0, error: "invalid_json_stdout", stdout: clipText(result.stdout, 1200) } }); + if (!parsed) { + if (forceRuntimeRefresh) throw cliError("case_refresh_trace_invalid_json", "explicit runtime refresh returned invalid trace JSON", { traceId, stdout: clipText(result.stdout, 1200) }); + return clean({ ...body, traceFetch: { status: "failed", source: "existing-hwlab-cli", command: commandVisibility(command), exitCode: 0, error: "invalid_json_stdout", stdout: clipText(result.stdout, 1200) } }); + } const parsedBody = parsed?.body && typeof parsed.body === "object" ? parsed.body : null; const preserved = clean({ toolCallSummary: body?.toolCallSummary, @@ -755,6 +763,9 @@ export function caseRunArtifactEntries(run: PreparedCaseRun) { } export async function copyCaseRunArtifact(source: string, destination: string) { + if (path.resolve(source) === path.resolve(destination)) { + try { return (await stat(source)).isFile(); } catch { return false; } + } try { const info = await stat(source); if (!info.isFile()) return false; @@ -842,6 +853,7 @@ export function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseR runnerPostAgentCompileCheck: "recorded", reason: "flow-only run: the manifest records artifacts and trace without auto-grading or gate decisions" }, + refresh: evidence.refresh ?? existingManifest?.refresh, files, skippedFiles }); diff --git a/tools/src/hwlab-caserun-diagnostics.ts b/tools/src/hwlab-caserun-diagnostics.ts new file mode 100644 index 00000000..43b72576 --- /dev/null +++ b/tools/src/hwlab-caserun-diagnostics.ts @@ -0,0 +1,253 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + archiveCaseRunArtifacts, + syncCaseRegistryRel, + writeCaseAggregateFromRegistry, +} from "./hwlab-caserun-closeout.ts"; +import { agentToolCallSummaryFromResult, arrayOfObjects, clean, clipText, text } from "./hwlab-caserun-evidence.ts"; +import type { CaseContext, PreparedCaseRun } from "./hwlab-caserun-shared.ts"; + +const MAX_DIAGNOSTIC_ROWS = 30; + +export async function refreshCaseRegistryArtifacts(context: CaseContext, caseRepo: string) { + const caseId = text(context.parsed.caseId ?? context.rest[1]); + const runId = text(context.parsed.runId ?? context.rest[2]); + if (!caseId) throw cliError("missing_required_value", "caseId is required", { field: "caseId" }); + if (!runId) throw cliError("missing_required_value", "runId is required", { field: "runId" }); + const caseRepoRunDir = path.join(caseRepo, "runs", caseId, runId); + const stored = await loadRegistryArtifacts(caseRepo, caseId, runId); + const source = text(context.parsed.source ?? context.parsed.from) || "local"; + if (source !== "local" && source !== "runtime") throw cliError("unsupported_case_refresh_source", "case refresh source must be local or runtime", { source }); + const run = registryPreparedRun(caseRepo, caseId, runId, caseRepoRunDir, stored.run, stored.evidence, stored.manifest, context.now()); + const evidence = { + ...stored.evidence, + caseId, + runId, + autoEvaluation: false, + refresh: { source, authority: "registry", runtimeAccess: source === "runtime" }, + }; + const offlineContext = { ...context, env: { ...context.env, HWLAB_API_KEY: undefined }, parsed: { ...context.parsed, noCaseRepoGitSync: true } }; + const archiveContext = source === "runtime" ? { ...context, parsed: { ...context.parsed, caseRefreshSource: "runtime" } } : offlineContext; + const first = await archiveCaseRunArtifacts(archiveContext, run, evidence, { sync: false }); + const aggregateContext = { ...offlineContext, parsed: { ...offlineContext.parsed, caseId, runId }, rest: ["aggregate", caseId, runId] }; + await writeCaseAggregateFromRegistry(aggregateContext, caseRepo, { sync: false }); + const final = await archiveCaseRunArtifacts(offlineContext, run, evidence, { sync: false }); + const sync = context.parsed.noCaseRepoGitSync === true + ? null + : await syncCaseRegistryRel(context, caseRepo, `runs/${caseId}/${runId}`, `data: refresh caserun artifacts ${runId}`); + const trace = final.manifest.trace ?? first.manifest.trace ?? {}; + return ok("case.refresh", { + caseId, + runId, + caseRepo, + caseRepoRunDir, + source, + refreshed: { + files: final.caseRepoFiles, + traceId: trace.traceId, + sourceEventCount: trace.sourceEventCount, + renderedRowCount: trace.renderedRowCount, + commandCount: trace.commandCount, + hwpodCommandCount: trace.hwpodCommandCount, + hwpodBuildCommandCount: trace.hwpodBuildCommandCount, + manifestPath: final.artifactManifestPath, + registrySync: sync, + provenance: evidence.refresh, + }, + aggregationOnly: false, + autoEvaluation: false, + }); +} + +export async function inspectCaseRegistryRun(context: CaseContext, caseRepo: string, view: "inspect" | "trace-summary") { + const artifacts = await loadDiagnosticByRunId(caseRepo, text(context.parsed.runId ?? context.rest[2]), text(context.parsed.caseId ?? context.parsed.case)); + const diagnostic = diagnosticFromArtifacts(artifacts); + const payload = view === "trace-summary" ? traceSummary(diagnostic) : diagnostic; + return ok(`case.run.${view}`, { ...payload, inspectionOnly: true, serviceRuntime: false, autoEvaluation: false }); +} + +export async function skillUsageCaseRegistryRun(context: CaseContext, caseRepo: string) { + const skill = text(context.parsed.skill ?? context.parsed.skillName ?? context.rest[3]); + if (!skill) throw cliError("missing_required_value", "skill is required", { field: "skill" }); + const artifacts = await loadDiagnosticByRunId(caseRepo, text(context.parsed.runId ?? context.rest[2]), text(context.parsed.caseId ?? context.parsed.case)); + const pattern = new RegExp(`(?:\\.agents[\\\\/]skills[\\\\/]${escapeRegExp(skill)}[\\\\/]SKILL\\.md|\\b${escapeRegExp(skill)}\\b)`, "iu"); + const sourceFiles = [ + ["agent-prompt.md", artifacts.promptText], + ["agent-trace.json", JSON.stringify(artifacts.agentTrace ?? {})], + ["agent-messages.json", JSON.stringify(artifacts.agentMessages ?? {})], + ["artifact-manifest.json", JSON.stringify(artifacts.manifest ?? {})], + ] as const; + const evidence = sourceFiles.filter(([, value]) => pattern.test(value)).map(([source, value]) => ({ source, preview: clipText(value.replace(/\s+/gu, " "), 500) })); + const rows = traceRows(artifacts); + const matches = rows.filter((row) => pattern.test(`${row.header ?? ""}\n${row.body ?? ""}`)).map((row) => ({ rowId: row.rowId, seq: row.seq, header: row.header, preview: clipText(row.body, 500) })); + return ok("case.run.skill-usage", { + caseId: artifacts.caseId, + runId: artifacts.runId, + caseRepo, + caseRepoRunDir: artifacts.caseRepoRunDir, + skill, + prompt: { mentionsSkill: pattern.test(artifacts.promptText), path: path.join(artifacts.caseRepoRunDir, "agent-prompt.md") }, + trace: { readSkillFile: matches.length > 0, matchCount: matches.length, matches }, + resourceBundle: { materialized: evidence.length > 0, evidence }, + inspectionOnly: true, + serviceRuntime: false, + autoEvaluation: false, + }); +} + +async function loadDiagnosticByRunId(caseRepo: string, runId: string, explicitCaseId: string) { + if (!runId) throw cliError("missing_required_value", "runId is required", { field: "runId" }); + const caseId = explicitCaseId || await resolveCaseId(caseRepo, runId); + return loadRegistryArtifacts(caseRepo, caseId, runId); +} + +async function loadRegistryArtifacts(caseRepo: string, caseId: string, runId: string) { + const caseRepoRunDir = path.join(caseRepo, "runs", caseId, runId); + const [run, evidence, manifest, agentTrace, agentMessages, finalResponseText, promptText] = await Promise.all([ + readJson(path.join(caseRepoRunDir, "run.json")), + readJson(path.join(caseRepoRunDir, "evidence.json")), + readJson(path.join(caseRepoRunDir, "artifact-manifest.json")), + readJson(path.join(caseRepoRunDir, "agent-trace.json")), + readJson(path.join(caseRepoRunDir, "agent-messages.json")), + readText(path.join(caseRepoRunDir, "final-response.md")), + readText(path.join(caseRepoRunDir, "agent-prompt.md")), + ]); + if (!run && !evidence && !manifest) throw cliError("case_registry_run_not_found", "case registry run artifacts were not found", { caseRepoRunDir, caseId, runId }); + return { caseRepo, caseId, runId, caseRepoRunDir, run: run ?? {}, evidence: evidence ?? {}, manifest: manifest ?? {}, agentTrace, agentMessages, finalResponseText, promptText }; +} + +async function resolveCaseId(caseRepo: string, runId: string) { + const root = path.join(caseRepo, "runs"); + const entries = await readdir(root).catch(() => []); + const matches: string[] = []; + for (const entry of entries.filter((item) => !item.startsWith("."))) { + if (await isDirectory(path.join(root, entry, runId))) matches.push(entry); + } + if (matches.length === 0) throw cliError("case_registry_run_not_found", "runId was not found in case registry runs", { caseRepo, runId }); + if (matches.length > 1) throw cliError("case_registry_run_ambiguous", "runId exists under multiple caseIds; pass --case-id", { caseRepo, runId, caseIds: matches }); + return matches[0]; +} + +function registryPreparedRun(caseRepo: string, caseId: string, runId: string, caseRepoRunDir: string, rawRun: any, rawEvidence: any, manifest: any, now: string): PreparedCaseRun { + const run = rawRun ?? {}; + const evidence = rawEvidence ?? {}; + const subject = { ...(run.subject ?? {}), ...(evidence.subject ?? {}), ...(manifest?.subject ?? {}) }; + const agent = { ...(run.agent ?? {}), ...(evidence.agent ?? {}), ...(manifest?.agent ?? {}) }; + return { + ...run, + caseId, + runId, + runDir: caseRepoRunDir, + caseRepo, + caseDir: run.caseDir ?? "", + caseFile: run.caseFile ?? "", + sourceSpecPath: run.sourceSpecPath ?? path.join(caseRepoRunDir, ".hwlab", "hwpod-spec.yaml"), + specPath: run.specPath ?? path.join(caseRepoRunDir, ".hwlab", "hwpod-spec.yaml"), + apiUrl: run.apiUrl ?? evidence.apiUrl ?? "", + compileOnly: run.compileOnly !== false, + createdAt: run.createdAt ?? now, + updatedAt: now, + definition: run.definition ?? {}, + subject: { repoLocalPath: subject.repoLocalPath ?? "", commitId: subject.commitId ?? "", subdir: subject.subdir ?? "", worktreePath: subject.worktreePath ?? "", workspacePath: subject.workspacePath ?? subject.worktreePath ?? "", setup: subject.setup ?? {} }, + agent: agent.traceId ? agent : null, + agentTrace: evidence.agentTrace ?? manifest?.trace ?? run.agentTrace, + agentDiff: evidence.agentDiff ?? manifest?.diff ?? run.agentDiff, + } as PreparedCaseRun; +} + +function diagnosticFromArtifacts(artifacts: any) { + const agent = { ...(artifacts.run.agent ?? {}), ...(artifacts.evidence.agent ?? {}), ...(artifacts.manifest.agent ?? {}) }; + const trace = { ...(artifacts.run.agentTrace ?? {}), ...(artifacts.evidence.agentTrace ?? {}), ...(artifacts.manifest.trace ?? {}) }; + const summary = boundedToolCallSummary(trace.toolCallSummary ?? agent.toolCallSummary ?? agentToolCallSummaryFromResult(artifacts.agentTrace?.body ?? artifacts.agentTrace) ?? null); + const rows = traceRows(artifacts); + const commands = summary?.items ?? rows.filter((row) => /commandExecution|tool_call|^tool:/iu.test(`${row.rowId ?? ""} ${row.header ?? ""}`)).map((row) => ({ rowId: row.rowId, seq: row.seq, command: row.command ?? row.body, status: row.status ?? row.tone })); + return clean({ + caseId: artifacts.caseId, + runId: artifacts.runId, + caseRepo: artifacts.caseRepo, + caseRepoRunDir: artifacts.caseRepoRunDir, + status: artifacts.run.status ?? artifacts.evidence.status ?? "recorded", + stage: artifacts.run.stage ?? artifacts.run._control?.stage, + compileOnly: artifacts.evidence.compileOnly ?? artifacts.run.compileOnly, + traceId: agent.traceId ?? trace.traceId, + sessionId: agent.sessionId ?? trace.sessionId, + conversationId: agent.conversationId ?? trace.conversationId, + threadId: agent.threadId ?? trace.threadId, + providerProfile: agent.providerProfile, + requestedProviderProfile: agent.requestedProviderProfile, + resolvedBackendProfile: agent.resolvedBackendProfile, + provider: agent.provider, + model: agent.model, + backend: agent.backend, + terminalStatus: trace.terminalStatus ?? agent.terminalStatus, + agentResultStatus: agent.result?.status ?? trace.status, + commandStatus: agent.commandStatus ?? summary?.commandStatus, + agentRunStatus: agent.agentRunStatus ?? summary?.agentRunStatus, + toolCallSummary: summary, + sourceEventCount: trace.sourceEventCount ?? artifacts.agentMessages?.sourceEventCount, + renderedRowCount: trace.renderedRowCount ?? artifacts.agentMessages?.renderedRowCount ?? rows.length, + commandCount: trace.commandCount ?? summary?.count ?? commands.length, + hwpodCommandCount: trace.hwpodCommandCount, + hwpodBuildCommandCount: trace.hwpodBuildCommandCount, + agentFinal: artifacts.manifest.agentFinal ?? artifacts.evidence.agentFinal, + postValidation: artifacts.manifest.postValidation ?? artifacts.evidence.postValidation, + refresh: artifacts.manifest.refresh ?? artifacts.evidence.refresh, + finalResponse: { present: Boolean(artifacts.finalResponseText.trim()), path: path.join(artifacts.caseRepoRunDir, "final-response.md"), textPreview: clipText(artifacts.finalResponseText, 800) }, + traceCommands: commands.slice(0, MAX_DIAGNOSTIC_ROWS), + paths: { runJson: path.join(artifacts.caseRepoRunDir, "run.json"), evidenceJson: path.join(artifacts.caseRepoRunDir, "evidence.json"), agentTraceJson: path.join(artifacts.caseRepoRunDir, "agent-trace.json"), agentMessagesJson: path.join(artifacts.caseRepoRunDir, "agent-messages.json"), artifactManifestJson: path.join(artifacts.caseRepoRunDir, "artifact-manifest.json"), aggregateMd: path.join(artifacts.caseRepoRunDir, "aggregate.md") }, + }); +} + +function traceSummary(diagnostic: any) { + return clean({ + caseId: diagnostic.caseId, + runId: diagnostic.runId, + caseRepo: diagnostic.caseRepo, + caseRepoRunDir: diagnostic.caseRepoRunDir, + traceId: diagnostic.traceId, + providerProfile: diagnostic.providerProfile, + requestedProviderProfile: diagnostic.requestedProviderProfile, + resolvedBackendProfile: diagnostic.resolvedBackendProfile, + provider: diagnostic.provider, + model: diagnostic.model, + backend: diagnostic.backend, + terminalStatus: diagnostic.terminalStatus, + agentResultStatus: diagnostic.agentResultStatus, + commandStatus: diagnostic.commandStatus, + agentRunStatus: diagnostic.agentRunStatus, + toolCallSummary: diagnostic.toolCallSummary, + sourceEventCount: diagnostic.sourceEventCount, + renderedRowCount: diagnostic.renderedRowCount, + commandCount: diagnostic.commandCount, + hwpodCommandCount: diagnostic.hwpodCommandCount, + hwpodBuildCommandCount: diagnostic.hwpodBuildCommandCount, + traceCommands: diagnostic.traceCommands, + paths: diagnostic.paths, + }); +} + +function boundedToolCallSummary(summary: any) { + if (!summary || typeof summary !== "object") return null; + return clean({ + ...summary, + items: arrayOfObjects(summary.items).slice(0, MAX_DIAGNOSTIC_ROWS).map((item) => clean({ + ...item, + command: clipText(item.command, 1000), + detail: clipText(item.detail, 1000), + })), + }); +} + +function traceRows(artifacts: any) { + const body = artifacts.agentTrace?.body && typeof artifacts.agentTrace.body === "object" ? artifacts.agentTrace.body : artifacts.agentTrace ?? {}; + return arrayOfObjects(artifacts.agentMessages?.rows ?? body.rows ?? body.renderedRows ?? body.traceRows).slice(0, MAX_DIAGNOSTIC_ROWS); +} + +async function readJson(file: string) { try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } } +async function readText(file: string) { try { return await readFile(file, "utf8"); } catch { return ""; } } +async function isDirectory(file: string) { try { return (await stat(file)).isDirectory(); } catch { return false; } } +function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } +function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } +function ok(action: string, extra: Record = {}) { return { ok: true, action, status: "completed", ...extra }; } diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index f5848113..5dadf83b 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts"; import { collectAgentWorkspaceDiff } from "./hwlab-caserun-diff.ts"; +import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseRegistryRun } from "./hwlab-caserun-diagnostics.ts"; import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, commandsFromToolCallSummary, summarizeAgentTrace, firstNumberOption, firstTextOption, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts"; import { readHwpodSpec } from "./hwpod-harness-lib.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 } from "./hwlab-caserun-subject.ts"; @@ -34,6 +35,7 @@ export async function caseCommand(context: CaseContext) { if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp(); if (subcommand === "prompt") return promptCaseRun(context); if (subcommand === "aggregate") return aggregateCaseRun(context); + if (subcommand === "refresh" || subcommand === "backfill") return refreshCaseRegistryArtifacts(context, await resolveCaseRepo(context)); if (subcommand === "prepare") return prepareCaseRun(context); if (subcommand === "build") return buildCaseRun(context); if (subcommand === "collect") return collectCaseRun(context); @@ -47,6 +49,9 @@ async function caseRunCommand(context: CaseContext) { if (mode === "status") return statusCaseRun(context); if (mode === "result") return resultCaseRun(context); if (mode === "logs") return logsCaseRun(context); + if (mode === "inspect") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "inspect"); + if (mode === "trace-summary" || mode === "trace") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "trace-summary"); + if (mode === "skill-usage" || mode === "skills") return skillUsageCaseRegistryRun(context, await resolveCaseRepo(context)); if (mode === "worker") return runCaseRunWorker(context); return runCaseRun(context); } @@ -69,17 +74,23 @@ export function caseHelp() { `case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`, `case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`, `case aggregate CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--output aggregate.md]`, + `case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`, `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`, `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, `case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`, `case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`, - `case run logs RUN_ID [--tail 8000]` + `case run logs RUN_ID [--tail 8000]`, + `case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`, + `case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`, + `case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]` ], notes: [ "case prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。", "case build remains a compile-only HWPOD smoke stage.", "case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.", - "case aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.", + "case aggregate is registry-only and offline by default; it reads existing artifacts without querying runtime.", + "case refresh/backfill defaults to local registry artifacts; only explicit --source runtime may query the existing trace CLI.", + "case run inspect/trace-summary/skill-usage are bounded, service-free registry diagnostics.", "case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.", "This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence." ] @@ -132,7 +143,7 @@ export async function promptCaseRun(context: CaseContext) { export async function aggregateCaseRun(context: CaseContext) { const caseRepo = await resolveCaseRepo(context); - const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: true }); + const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: context.parsed.noCaseRepoGitSync !== true }); return ok("case.aggregate", { caseId: aggregate.caseId, runId: aggregate.runId, From 814ea533f30ba2e568d9a30744a705a0b9fcaeea Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 16:08:05 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20CaseRun=20HWPO?= =?UTF-8?q?D=20=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 }; } From e97b767254421e1b03dbfac4b16f4b0b39610b9b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 16:33:50 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20CaseRun=20?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E8=AF=8A=E6=96=AD=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caserun-offline-diagnostics.test.ts | 67 ++++++++++++++++++- tools/src/hwlab-caserun-diagnostics.ts | 26 +++---- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/tools/hwlab-cli/caserun-offline-diagnostics.test.ts b/tools/hwlab-cli/caserun-offline-diagnostics.test.ts index 3126bedb..f6d76f0d 100644 --- a/tools/hwlab-cli/caserun-offline-diagnostics.test.ts +++ b/tools/hwlab-cli/caserun-offline-diagnostics.test.ts @@ -73,7 +73,70 @@ test("case run offline diagnostics inspect trace and skill usage without runtime assert.equal(skill.exitCode, 0, JSON.stringify(skill.payload, null, 2)); assert.equal(skill.payload.serviceRuntime, false); assert.equal(skill.payload.trace.readSkillFile, true); + assert.equal(skill.payload.trace.matchCount, 1); assert.equal(skill.payload.resourceBundle.materialized, true); + assert.equal(skill.payload.resourceBundle.evidence[0].source, "artifact-manifest.json"); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("case run skill usage keeps prompt mentions separate from materialized bundles", async () => { + const fixture = await createRegistryFixture("prompt-only-skill"); + try { + await writeJson(path.join(fixture.runDir, "agent-messages.json"), { rows: [{ rowId: "tool:build", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }] }); + const manifest = JSON.parse(await readFile(path.join(fixture.runDir, "artifact-manifest.json"), "utf8")); + delete manifest.resourceBundle; + await writeJson(path.join(fixture.runDir, "artifact-manifest.json"), manifest); + + const result = await runHwlabCli(["case", "run", "skill-usage", fixture.runId, "--skill", "keil", "--case-repo", fixture.caseRepo], { cwd: fixture.root, runProcess: forbiddenRuntime }); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); + assert.equal(result.payload.prompt.mentionsSkill, true); + assert.equal(result.payload.trace.readSkillFile, false); + assert.equal(result.payload.resourceBundle.materialized, false); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("case run inspect preserves missing authoritative agent final", async () => { + const fixture = await createRegistryFixture("missing-agent-final"); + try { + const reason = "finalResponse=null; provider returned no final assistant response"; + const evidence = JSON.parse(await readFile(path.join(fixture.runDir, "evidence.json"), "utf8")); + evidence.agentFinal = { present: false, reason, status: "blocked" }; + await writeJson(path.join(fixture.runDir, "evidence.json"), evidence); + const manifest = JSON.parse(await readFile(path.join(fixture.runDir, "artifact-manifest.json"), "utf8")); + manifest.agentFinal = { present: false, reason, status: "blocked" }; + await writeJson(path.join(fixture.runDir, "artifact-manifest.json"), manifest); + await writeFile(path.join(fixture.runDir, "final-response.md"), `# CaseRun Final Response\n\n${reason}\n`, "utf8"); + + const result = await runHwlabCli(["case", "run", "inspect", fixture.runId, "--case-repo", fixture.caseRepo], { cwd: fixture.root, runProcess: forbiddenRuntime }); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); + assert.equal(result.payload.agentFinal.present, false); + assert.equal(result.payload.finalResponse.present, false); + assert.equal(result.payload.finalResponse.reason, reason); + assert.match(result.payload.finalResponse.textPreview, /provider returned no final assistant response/u); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("case run skill usage scans all rows before bounding returned matches", async () => { + const fixture = await createRegistryFixture("late-skill-read"); + try { + const rows = [ + ...Array.from({ length: 30 }, (_, index) => ({ rowId: `tool:other:${index + 1}`, seq: index + 1, header: "工具调用", body: "echo unrelated" })), + ...Array.from({ length: 35 }, (_, index) => ({ rowId: `tool:skill:${index + 31}`, seq: index + 31, header: "工具调用", body: "cat ~/.agents/skills/keil/SKILL.md" })), + ]; + await writeJson(path.join(fixture.runDir, "agent-messages.json"), { sourceEventCount: rows.length, renderedRowCount: rows.length, rows }); + + const result = await runHwlabCli(["case", "run", "skill-usage", fixture.runId, "--skill", "keil", "--case-repo", fixture.caseRepo], { cwd: fixture.root, runProcess: forbiddenRuntime }); + assert.equal(result.exitCode, 0, JSON.stringify(result.payload, null, 2)); + assert.equal(result.payload.trace.readSkillFile, true); + assert.equal(result.payload.trace.matchCount, 35); + assert.equal(result.payload.trace.matches.length, 30); + assert.equal(result.payload.trace.matches[0].seq, 31); } finally { await rm(fixture.root, { recursive: true, force: true }); } @@ -112,10 +175,10 @@ async function createRegistryFixture(suffix: string) { const agent = { stageStatus: "completed", baseUrl: "http://web.test", traceId: "trc_offline", sessionId: "ses_offline", conversationId: "cnv_offline", threadId: "thr_offline", providerProfile: "deepseek", requestedProviderProfile: "deepseek", resolvedBackendProfile: "deepseek-runtime", provider: "deepseek", model: "deepseek-chat", terminalStatus: "completed", commandStatus: "completed", agentRunStatus: "completed", promptSha256: "prompt-sha" }; const toolCallSummary = { source: "agentrun-result.toolCallSummary", count: 2, statusCounts: { completed: 2 }, exitCodeCounts: { "0": 2 }, terminalStatus: "completed", commandStatus: "completed", agentRunStatus: "completed", items: [{ kind: "build", toolName: "commandExecution", status: "completed", exitCode: 0, command: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { kind: "other", toolName: "commandExecution", status: "completed", exitCode: 0, command: "cat ~/.agents/skills/keil/SKILL.md" }] }; await writeJson(path.join(runDir, "run.json"), { caseId, runId, runDir, caseRepo, specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), compileOnly: true, createdAt: "2026-07-16T13:00:00.000Z", updatedAt: "2026-07-16T13:00:00.000Z", definition: {}, subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), subdir: "", worktreePath: "C:/work/fw/.worktree/case", workspacePath: "C:/work/fw/.worktree/case", setup: {} }, agent }); - await writeJson(path.join(runDir, "evidence.json"), { contractVersion: "hwpod-case-run-evidence-v1", caseId, runId, compileOnly: true, autoEvaluation: false, status: "recorded", subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), worktreePath: "C:/work/fw/.worktree/case" }, agent, agentTrace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, agentFinal: { status: "completed", terminalStatus: "completed" }, postValidation: { source: "case-run-runner-post-agent-compile-check", status: "completed", returnCode: 0 }, decisions: { autoEvaluation: false, runnerPostAgentCompileCheck: "recorded" } }); + await writeJson(path.join(runDir, "evidence.json"), { contractVersion: "hwpod-case-run-evidence-v1", caseId, runId, compileOnly: true, autoEvaluation: false, status: "recorded", subject: { repoLocalPath: "C:/work/fw", commitId: "0".repeat(40), worktreePath: "C:/work/fw/.worktree/case" }, agent, agentTrace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, agentFinal: { present: true, status: "completed", terminalStatus: "completed" }, postValidation: { source: "case-run-runner-post-agent-compile-check", status: "completed", returnCode: 0 }, decisions: { autoEvaluation: false, runnerPostAgentCompileCheck: "recorded" } }); await writeJson(path.join(runDir, "agent-trace.json"), { contractVersion: "hwpod-case-run-agent-trace-identity-v1", traceId: "trc_offline", status: "completed", terminalStatus: "completed", commandStatus: "completed", toolCallSummary }); await writeJson(path.join(runDir, "agent-messages.json"), { renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows", sourceEventCount: 2, renderedRowCount: 2, rows: [{ rowId: "tool:build", seq: 1, header: "工具调用", body: "hwpod build --spec .hwlab/hwpod-spec.yaml" }, { rowId: "tool:skill", seq: 2, header: "工具调用", body: "cat ~/.agents/skills/keil/SKILL.md" }] }); - await writeJson(path.join(runDir, "artifact-manifest.json"), { contractVersion: "hwpod-case-run-artifact-manifest-v1", caseId, runId, agent, trace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, agentFinal: { status: "completed" }, postValidation: { status: "completed", returnCode: 0 }, files: [] }); + await writeJson(path.join(runDir, "artifact-manifest.json"), { contractVersion: "hwpod-case-run-artifact-manifest-v1", caseId, runId, agent, trace: { traceId: "trc_offline", terminalStatus: "completed", toolCallSummary, commandCount: 2, hwpodCommandCount: 1, hwpodBuildCommandCount: 1 }, resourceBundle: { materialized: true, skills: [{ name: "keil", manifest: ".agents/skills/keil/SKILL.md" }] }, agentFinal: { present: true, status: "completed" }, postValidation: { status: "completed", returnCode: 0 }, files: [] }); await writeFile(path.join(runDir, "agent-prompt.md"), "请读取 keil skill 后执行编译。\n", "utf8"); await writeFile(path.join(runDir, "final-response.md"), "# CaseRun Final Response\n\ncompleted\n", "utf8"); await writeFile(path.join(runDir, "agent-diff.patch"), "", "utf8"); diff --git a/tools/src/hwlab-caserun-diagnostics.ts b/tools/src/hwlab-caserun-diagnostics.ts index 43b72576..a951ba3d 100644 --- a/tools/src/hwlab-caserun-diagnostics.ts +++ b/tools/src/hwlab-caserun-diagnostics.ts @@ -73,15 +73,16 @@ export async function skillUsageCaseRegistryRun(context: CaseContext, caseRepo: if (!skill) throw cliError("missing_required_value", "skill is required", { field: "skill" }); const artifacts = await loadDiagnosticByRunId(caseRepo, text(context.parsed.runId ?? context.rest[2]), text(context.parsed.caseId ?? context.parsed.case)); const pattern = new RegExp(`(?:\\.agents[\\\\/]skills[\\\\/]${escapeRegExp(skill)}[\\\\/]SKILL\\.md|\\b${escapeRegExp(skill)}\\b)`, "iu"); - const sourceFiles = [ - ["agent-prompt.md", artifacts.promptText], - ["agent-trace.json", JSON.stringify(artifacts.agentTrace ?? {})], - ["agent-messages.json", JSON.stringify(artifacts.agentMessages ?? {})], - ["artifact-manifest.json", JSON.stringify(artifacts.manifest ?? {})], + const bundleSources = [ + ["artifact-manifest.json", artifacts.manifest.resourceBundle ?? artifacts.manifest.provenance?.resourceBundle], + ["evidence.json", artifacts.evidence.resourceBundle ?? artifacts.evidence.provenance?.resourceBundle], ] as const; - const evidence = sourceFiles.filter(([, value]) => pattern.test(value)).map(([source, value]) => ({ source, preview: clipText(value.replace(/\s+/gu, " "), 500) })); + const evidence = bundleSources + .filter(([, value]) => value && pattern.test(JSON.stringify(value))) + .map(([source, value]) => ({ source, materialized: value.materialized === true, preview: clipText(JSON.stringify(value).replace(/\s+/gu, " "), 500) })); const rows = traceRows(artifacts); - const matches = rows.filter((row) => pattern.test(`${row.header ?? ""}\n${row.body ?? ""}`)).map((row) => ({ rowId: row.rowId, seq: row.seq, header: row.header, preview: clipText(row.body, 500) })); + const allMatches = rows.filter((row) => pattern.test(`${row.header ?? ""}\n${row.body ?? ""}`)); + const matches = allMatches.slice(0, MAX_DIAGNOSTIC_ROWS).map((row) => ({ rowId: row.rowId, seq: row.seq, header: row.header, preview: clipText(row.body, 500) })); return ok("case.run.skill-usage", { caseId: artifacts.caseId, runId: artifacts.runId, @@ -89,8 +90,8 @@ export async function skillUsageCaseRegistryRun(context: CaseContext, caseRepo: caseRepoRunDir: artifacts.caseRepoRunDir, skill, prompt: { mentionsSkill: pattern.test(artifacts.promptText), path: path.join(artifacts.caseRepoRunDir, "agent-prompt.md") }, - trace: { readSkillFile: matches.length > 0, matchCount: matches.length, matches }, - resourceBundle: { materialized: evidence.length > 0, evidence }, + trace: { readSkillFile: allMatches.length > 0, matchCount: allMatches.length, matches }, + resourceBundle: { materialized: evidence.some((item) => item.materialized), evidence }, inspectionOnly: true, serviceRuntime: false, autoEvaluation: false, @@ -162,6 +163,7 @@ function diagnosticFromArtifacts(artifacts: any) { const trace = { ...(artifacts.run.agentTrace ?? {}), ...(artifacts.evidence.agentTrace ?? {}), ...(artifacts.manifest.trace ?? {}) }; const summary = boundedToolCallSummary(trace.toolCallSummary ?? agent.toolCallSummary ?? agentToolCallSummaryFromResult(artifacts.agentTrace?.body ?? artifacts.agentTrace) ?? null); const rows = traceRows(artifacts); + const agentFinal = clean({ ...(artifacts.evidence.agentFinal ?? {}), ...(artifacts.manifest.agentFinal ?? {}) }); const commands = summary?.items ?? rows.filter((row) => /commandExecution|tool_call|^tool:/iu.test(`${row.rowId ?? ""} ${row.header ?? ""}`)).map((row) => ({ rowId: row.rowId, seq: row.seq, command: row.command ?? row.body, status: row.status ?? row.tone })); return clean({ caseId: artifacts.caseId, @@ -191,10 +193,10 @@ function diagnosticFromArtifacts(artifacts: any) { commandCount: trace.commandCount ?? summary?.count ?? commands.length, hwpodCommandCount: trace.hwpodCommandCount, hwpodBuildCommandCount: trace.hwpodBuildCommandCount, - agentFinal: artifacts.manifest.agentFinal ?? artifacts.evidence.agentFinal, + agentFinal, postValidation: artifacts.manifest.postValidation ?? artifacts.evidence.postValidation, refresh: artifacts.manifest.refresh ?? artifacts.evidence.refresh, - finalResponse: { present: Boolean(artifacts.finalResponseText.trim()), path: path.join(artifacts.caseRepoRunDir, "final-response.md"), textPreview: clipText(artifacts.finalResponseText, 800) }, + finalResponse: { present: agentFinal.present === true, reason: agentFinal.reason, path: path.join(artifacts.caseRepoRunDir, "final-response.md"), textPreview: clipText(artifacts.finalResponseText, 800) }, traceCommands: commands.slice(0, MAX_DIAGNOSTIC_ROWS), paths: { runJson: path.join(artifacts.caseRepoRunDir, "run.json"), evidenceJson: path.join(artifacts.caseRepoRunDir, "evidence.json"), agentTraceJson: path.join(artifacts.caseRepoRunDir, "agent-trace.json"), agentMessagesJson: path.join(artifacts.caseRepoRunDir, "agent-messages.json"), artifactManifestJson: path.join(artifacts.caseRepoRunDir, "artifact-manifest.json"), aggregateMd: path.join(artifacts.caseRepoRunDir, "aggregate.md") }, }); @@ -242,7 +244,7 @@ function boundedToolCallSummary(summary: any) { function traceRows(artifacts: any) { const body = artifacts.agentTrace?.body && typeof artifacts.agentTrace.body === "object" ? artifacts.agentTrace.body : artifacts.agentTrace ?? {}; - return arrayOfObjects(artifacts.agentMessages?.rows ?? body.rows ?? body.renderedRows ?? body.traceRows).slice(0, MAX_DIAGNOSTIC_ROWS); + return arrayOfObjects(artifacts.agentMessages?.rows ?? body.rows ?? body.renderedRows ?? body.traceRows); } async function readJson(file: string) { try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } } From bb0e3230df18fe45785bb56d93c7a261b63f1aa6 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 16:40:31 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20CaseRun=20HWPOD?= =?UTF-8?q?=20=E5=90=88=E5=90=8C=E5=A4=8D=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/hwlab-cli/caserun.test.ts | 10 ++++- tools/hwlab-node.py | 21 ++++++++-- tools/hwpod-node.test.ts | 61 +++++++++++++++++++++++++++++- tools/src/hwlab-caserun-runtime.ts | 6 ++- tools/src/hwpod-node-lib.ts | 31 ++++++++++++--- 5 files changed, 116 insertions(+), 13 deletions(-) diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 680ca66b..f461cf37 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -204,7 +204,7 @@ test("case run exposes Keil hex and axf artifacts in evidence summary", () => { 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"); + const operation = summarizeHwpodOperationForTest({ body: { hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", results: [{ opId: "op_build", op: "debug.build", ok: true, status: "completed", workspacePath: "F:\\Work\\case", 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); @@ -214,6 +214,14 @@ test("case run normalizes v0.3 HWPOD build operation topology and typed wait blo 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"); + const missingObserved = summarizeHwpodOperationForTest({ body: { results: [{ op: "debug.build", ok: true, output: { stdout: '{"job_id":"job-3"}' } }] } }, document, "debug.build"); + assert.deepEqual(missingObserved.topology.blocker.details, [ + { field: "hwpodId", expected: "d601-f103-v2", observed: null }, + { field: "nodeId", expected: "node-d601-f103-v2", observed: null }, + { field: "workspacePath", expected: "F:\\Work\\case", observed: null } + ]); + const yamlEmpty = summarizeHwpodOperationForTest({ body: { hwpodId: "response-only", nodeId: "response-node", results: [{ op: "debug.build", ok: true, workspacePath: "response-path", output: { stdout: '{"job_id":"job-4"}' } }] } }, { metadata: {}, spec: {} }, "debug.build"); + assert.equal(yamlEmpty.topology.ok, true); }); test("case run start returns immediately and status/result/logs are short polling commands", async () => { diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index cd28065b..173d8d47 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -663,7 +663,7 @@ class NodeOpsExecutor: baud = args.get("baudRate") or args.get("baudrate") status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms) status_body = self._parse_json(status.get("stdout")) - data = status_body.get("data") if isinstance(status_body.get("data"), dict) else {} + data = status_body.get("data") if isinstance(status_body, dict) and isinstance(status_body.get("data"), dict) else {} active_port = str(data.get("port") or "") active_baud = data.get("baudRate") monitor_start = None @@ -672,10 +672,23 @@ class NodeOpsExecutor: 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: + monitor_start = start_body if isinstance(start_body, dict) and start_body else start + if not start.get("ok") or not isinstance(start_body, dict) 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}}} + 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": monitor_start, "ports": self._parse_json(ports.get("stdout")) or ports}}} + confirmed_status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms) + confirmed_body = self._parse_json(confirmed_status.get("stdout")) + confirmed_data = confirmed_body.get("data") if isinstance(confirmed_body, dict) and isinstance(confirmed_body.get("data"), dict) else {} + confirmed_port = str(confirmed_data.get("port") or "") + confirmed_baud = confirmed_data.get("baudRate") + confirmed_matches = confirmed_status.get("ok") and isinstance(confirmed_body, dict) and confirmed_body.get("success") is True and confirmed_data.get("isMonitoring") is True and (not port or confirmed_port.lower() == port.lower()) and (not baud or int(confirmed_baud or 0) == int(baud)) + if not confirmed_matches: + return {"ok": False, "blockerCode": "hwpod_uart_monitor_binding_mismatch", "summary": "serial-monitor did not confirm the requested UART binding after start", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": monitor_start, "confirmedStatus": confirmed_body or confirmed_status}}} + status = confirmed_status + status_body = confirmed_body + data = confirmed_data + active_port = confirmed_port + active_baud = confirmed_baud 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"): diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index cdb3dfd7..48a25092 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -242,9 +243,11 @@ test("hwpod-node reads UART through serial-monitor binding", async () => { try { const fixture = path.join(root, "serial-monitor-fixture.mjs"); await writeFile(fixture, ` +import { existsSync, writeFileSync } from "node:fs"; +const marker = ${JSON.stringify(path.join(root, "monitor-started"))}; const args = process.argv.slice(2).join(" "); -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 } })); +if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: existsSync(marker) ? { isMonitoring: true, port: "COM9", baudRate: 115200 } : { isMonitoring: false, port: null, baudRate: null, lastPort: "COM9", lastBaudRate: 115200 } })); +else if (args === "monitor start -p COM9 -b 115200") { writeFileSync(marker, "started"); 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); } @@ -276,12 +279,66 @@ else { console.error("unsupported " + args); process.exit(2); } 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.equal(result.results[0].output.monitorStatus.data.isMonitoring, true); + assert.equal(result.results[0].output.monitorStatus.data.port, "COM9"); + assert.equal(result.results[0].output.monitorStatus.data.baudRate, 115200); assert.deepEqual(result.results[0].output.command.slice(0, 2), [process.execPath, fixture]); } finally { await rm(root, { recursive: true, force: true }); } }); +test("hwpod-node blocks UART fetch when start does not establish requested binding", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-mismatch-")); + try { + const fixture = path.join(root, "serial-monitor-mismatch.mjs"); + await writeFile(fixture, ` +const args = process.argv.slice(2).join(" "); +if (args === "monitor status") console.log(JSON.stringify({ success: true, data: { isMonitoring: false, port: null, baudRate: null } })); +else if (args === "monitor start -p COM9 -b 115200") console.log(JSON.stringify({ success: true, data: { port: "COM9", baudRate: 115200 } })); +else if (args.startsWith("fetch")) { console.error("fetch must not run"); process.exit(3); } +else { console.error("unsupported " + args); process.exit(2); } +`, "utf8"); + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_uart_mismatch", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ opId: "op_uart", op: "io.uart.read", args: { workspacePath: root, port: "COM9", baudRate: 115200, serialMonitorDir: root, serialMonitorCommand: [process.execPath, fixture] } }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + assert.equal(result.ok, false); + assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_binding_mismatch"); + assert.equal(result.results[0].blocker.details.serialMonitor.confirmedStatus.data.isMonitoring, false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("python hwpod-node returns typed blocker for non-JSON UART start response", () => { + const script = ` +import importlib.util, json, logging +spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +class Executor(module.NodeOpsExecutor): + def _spawn_output(self, command, cwd, timeout_ms): + action = " ".join(command[-2:]) + if command[-2:] == ["monitor", "status"]: + return {"ok": True, "stdout": json.dumps({"success": True, "data": {"isMonitoring": False}}), "stderr": "", "exitCode": 0} + if "start" in command: + return {"ok": True, "stdout": "not-json", "stderr": "", "exitCode": 0} + return {"ok": True, "stdout": json.dumps({"success": True, "data": {"ports": []}}), "stderr": "", "exitCode": 0} +executor = Executor({"nodeId": "node-test"}, logging.getLogger("test")) +print(json.dumps(executor._uart_read({"workspacePath": ".", "port": "COM9", "baudRate": 115200, "serialMonitorCommand": ["serial-monitor"]}))) +`; + const completed = spawnSync("python3", ["-c", script], { cwd: process.cwd(), encoding: "utf8" }); + assert.equal(completed.status, 0, completed.stderr); + const result = JSON.parse(completed.stdout); + assert.equal(result.ok, false); + assert.equal(result.blockerCode, "hwpod_uart_monitor_start_failed"); + assert.equal(result.details.serialMonitor.start.stdout, "not-json"); +}); + test("hwpod-node resolves Git for Windows when service PATH omits git", async () => { const result = await resolveHwpodNodeCommand("git", { platform: "win32", diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index d87c9bca..f9214764 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -503,7 +503,11 @@ export function summarizeHwpodOperationForTest(payload: any, document: any, expe 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] }] : []); + const mismatch = Object.entries(expected).flatMap(([field, value]) => { + if (!value) return []; + const observedValue = observed[field as keyof typeof observed]; + return observedValue === value ? [] : [{ field, expected: value, observed: observedValue || null }]; + }); return clean({ contractVersion: "hwpod-operation-result-v0.3", authority: "case-hwpod-yaml", diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index df1c2ed0..63b09146 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -1331,8 +1331,9 @@ async function uartRead(args: any) { const timeoutMs = numberValue(args.timeoutMs) ?? 10000; const status = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs }) .catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] })); - const statusBody = parseJsonMaybe(status.stdout); - const statusData = objectValue(statusBody?.data); + let monitorStatus = status; + let statusBody = parseJsonMaybe(status.stdout); + let statusData = objectValue(statusBody?.data); const targetPort = text(diagnostics.resolvedPort) || text(diagnostics.requestedPort); const targetBaudRate = numberValue(diagnostics.baudRate); const activePort = text(statusData.port); @@ -1355,6 +1356,26 @@ async function uartRead(args: any) { details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: startBody ?? start, ports: parseJsonMaybe(ports.stdout) ?? ports } } }; } + const confirmedStatus = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs }) + .catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] })); + const confirmedBody = parseJsonMaybe(confirmedStatus.stdout); + const confirmedData = objectValue(confirmedBody?.data); + const confirmedPort = text(confirmedData.port); + const confirmedBaudRate = numberValue(confirmedData.baudRate); + const confirmedMatches = confirmedStatus.ok && confirmedBody?.success === true && confirmedData.isMonitoring === true + && (!targetPort || confirmedPort.toLowerCase() === targetPort.toLowerCase()) + && (!targetBaudRate || confirmedBaudRate === targetBaudRate); + if (!confirmedMatches) { + return { + ok: false, + blockerCode: "hwpod_uart_monitor_binding_mismatch", + summary: "serial-monitor did not confirm the requested UART binding after start", + details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, monitorStatus: statusBody ?? status, start: monitorStart, confirmedStatus: confirmedBody ?? confirmedStatus } } + }; + } + monitorStatus = confirmedStatus; + statusBody = confirmedBody; + statusData = confirmedData; } const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200)); const maxBytes = numberValue(args.maxBytes) ?? 16384; @@ -1377,8 +1398,8 @@ async function uartRead(args: any) { bindingSource: "serial-monitor-cli", serialMonitorDir, requestedPort: diagnostics.requestedPort, - resolvedPort: targetPort, - baudRate: targetBaudRate || activeBaudRate || null, + resolvedPort: text(statusData.port) || targetPort, + baudRate: numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null, command: [...commandBase, ...fetchArgs], data: rows, count: fetchBody.count ?? rows.length, @@ -1386,7 +1407,7 @@ async function uartRead(args: any) { hasMore: fetchBody.hasMore ?? false, text: truncatedText, truncated: joined.length > maxBytes || fetchBody.truncated === true, - monitorStatus: statusBody, + monitorStatus: statusBody ?? monitorStatus, monitorStarted: !monitoringMatches, monitorStart, sourceFile: fetchBody.sourceFile ?? null