Merge remote-tracking branch 'origin/v0.3' into feat/caserun-static-audit
# Conflicts: # tools/src/hwlab-caserun-closeout.ts # tools/src/hwlab-caserun-runtime.ts
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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.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 });
|
||||
}
|
||||
});
|
||||
|
||||
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: { 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 }, 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");
|
||||
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"); }
|
||||
@@ -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,28 @@ 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", 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);
|
||||
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");
|
||||
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 () => {
|
||||
const root = await mkdtempCaseRoot();
|
||||
const caseRepo = path.join(root, "hwlab-case-registry");
|
||||
@@ -470,6 +492,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 +1027,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" }]);
|
||||
}
|
||||
|
||||
+23
-4
@@ -663,13 +663,32 @@ 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
|
||||
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 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": 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"):
|
||||
@@ -681,7 +700,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:
|
||||
|
||||
+13
-17
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
@@ -226,12 +227,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 });
|
||||
}
|
||||
@@ -242,8 +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: 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: 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); }
|
||||
@@ -273,12 +277,68 @@ 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.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",
|
||||
|
||||
@@ -264,7 +264,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}`,
|
||||
@@ -452,7 +452,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");
|
||||
}
|
||||
@@ -595,7 +595,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);
|
||||
@@ -619,20 +619,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);
|
||||
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,
|
||||
@@ -817,6 +825,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;
|
||||
@@ -904,6 +915,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
|
||||
});
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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 bundleSources = [
|
||||
["artifact-manifest.json", artifacts.manifest.resourceBundle ?? artifacts.manifest.provenance?.resourceBundle],
|
||||
["evidence.json", artifacts.evidence.resourceBundle ?? artifacts.evidence.provenance?.resourceBundle],
|
||||
] as const;
|
||||
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 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,
|
||||
caseRepo,
|
||||
caseRepoRunDir: artifacts.caseRepoRunDir,
|
||||
skill,
|
||||
prompt: { mentionsSkill: pattern.test(artifacts.promptText), path: path.join(artifacts.caseRepoRunDir, "agent-prompt.md") },
|
||||
trace: { readSkillFile: allMatches.length > 0, matchCount: allMatches.length, matches },
|
||||
resourceBundle: { materialized: evidence.some((item) => item.materialized), 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 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,
|
||||
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,
|
||||
postValidation: artifacts.manifest.postValidation ?? artifacts.evidence.postValidation,
|
||||
refresh: artifacts.manifest.refresh ?? artifacts.evidence.refresh,
|
||||
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") },
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
|
||||
function ok(action: string, extra: Record<string, unknown> = {}) { return { ok: true, action, status: "completed", ...extra }; }
|
||||
@@ -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<typeof collectAgentWorkspaceDiff>[0], paths: string[]) {
|
||||
const records = new Map<string, { bytes?: number; sha256?: string }>();
|
||||
if (paths.length === 0) return { records, requests: [] as Array<{ label: string; result: CommandResult }> };
|
||||
|
||||
@@ -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, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
|
||||
import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts";
|
||||
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
|
||||
@@ -33,6 +34,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);
|
||||
@@ -46,6 +48,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 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 audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]",
|
||||
`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,
|
||||
@@ -436,8 +447,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 +472,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 +499,53 @@ 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]) => {
|
||||
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",
|
||||
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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
+42
-21
@@ -1331,32 +1331,51 @@ 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);
|
||||
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 || "<PORT>"}${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 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;
|
||||
@@ -1379,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,
|
||||
@@ -1388,7 +1407,9 @@ 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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user