fix: seed caserun hwpod workspace files

This commit is contained in:
Codex Agent
2026-06-06 18:30:06 +08:00
parent a38fa1a907
commit e1c35509e1
7 changed files with 117 additions and 17 deletions
+18 -1
View File
@@ -796,6 +796,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
const threadId = safeOpaqueId(params.threadId);
const hwlabProjectId = firstNonEmpty(params.projectId);
const toolAliases = filteredResourceToolAliases(toolCapabilities);
const workspaceFiles = normalizeResourceWorkspaceFiles(params.workspaceFiles ?? params.resourceWorkspaceFiles);
const resourceBundleRef = {
kind: "git",
repoUrl: resolveAgentRunRepoUrl(env),
@@ -804,7 +805,8 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
lfs: false,
toolAliases,
promptRefs: HWLAB_RESOURCE_PROMPT_REFS,
skillRefs: HWLAB_RESOURCE_SKILL_REFS
skillRefs: HWLAB_RESOURCE_SKILL_REFS,
...(workspaceFiles.length > 0 ? { workspaceFiles } : {})
};
return {
tenantId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TENANT_ID, DEFAULT_TENANT_ID),
@@ -858,6 +860,21 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
};
}
function normalizeResourceWorkspaceFiles(value) {
if (value == null || value === false || value === "") return [];
if (!Array.isArray(value)) throw adapterError("invalid_workspace_files", "workspaceFiles must be an array when provided");
return value.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) throw adapterError("invalid_workspace_file", `workspaceFiles[${index}] must be an object`);
const pathValue = text(item.path);
const content = typeof item.content === "string" ? item.content : "";
const encoding = text(item.encoding) || "utf8";
if (!pathValue || pathValue === "." || pathValue.startsWith("/") || pathValue.includes("..") || pathValue.includes("\\")) throw adapterError("invalid_workspace_file_path", `workspaceFiles[${index}].path must be a safe relative POSIX path`);
if (typeof item.content !== "string") throw adapterError("invalid_workspace_file_content", `workspaceFiles[${index}].content must be a utf8 string`);
if (encoding !== "utf8") throw adapterError("invalid_workspace_file_encoding", `workspaceFiles[${index}].encoding must be utf8`);
return { path: pathValue, content, encoding };
});
}
function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) {
const prompt = String(params.message ?? params.prompt ?? "").trim();
const threadId = safeOpaqueId(params.threadId);
+5 -1
View File
@@ -307,6 +307,9 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
{ name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" },
{ name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" }
]);
assert.deepEqual(body.resourceBundleRef.workspaceFiles, [
{ path: ".hwlab/hwpod-spec.yaml", content: "kind: Hwpod\nspec:\n workspace:\n path: F:\\Work\\CASE\n", encoding: "utf8" }
]);
const toolCredentials = body.executionPolicy.secretScope.toolCredentials;
assert.equal(toolCredentials.some((item) => item.tool === "github" && item.projection.envName === "GH_TOKEN" && item.secretRef.name === "agentrun-v01-tool-github-pr"), true);
assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN" && item.secretRef.name === "agentrun-v01-tool-unidesk-ssh"), true);
@@ -527,7 +530,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
ownerRole: "user",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
retryOf: "trc_previous-agentrun-web",
message: "AgentRun adapter smoke"
message: "AgentRun adapter smoke",
workspaceFiles: [{ path: ".hwlab/hwpod-spec.yaml", content: "kind: Hwpod\nspec:\n workspace:\n path: F:\\Work\\CASE\n", encoding: "utf8" }]
})
});
assert.equal(submit.status, 202);
+11 -2
View File
@@ -406,13 +406,22 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
assert.match(prompt, /Run-local HWPOD spec/u);
assert.match(prompt, /verificationMode: compile-only build check/u);
assert.doesNotMatch(prompt, /compile-only subject /u);
assert.doesNotMatch(prompt, /Write exactly this YAML/u);
assert.doesNotMatch(prompt, /```yaml/u);
assert.doesNotMatch(prompt, /apiVersion: hwlab\.pikastech\.com/u);
assert.doesNotMatch(prompt, /path: "F:\\\\Work\\\\HWLAB-CASE-F103/u);
assert.match(prompt, /CaseRun has already placed the run-local spec/u);
assert.match(prompt, /hwpod-ctl spec validate --spec \.hwlab\/hwpod-spec\.yaml/u);
assert.match(prompt, /hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u);
assert.match(prompt, /path: "F:\\\\Work\\\\HWLAB-CASE-F103\\\\\.worktree\\\\caserun-run-agent-flow"/u);
assert.match(prompt, /standard hwpod\/hwpod-ctl commands/u);
const diff = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-diff.patch"), "utf8");
assert.match(diff, /printf\("hello"\)/u);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/sessions"), true);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat"), true);
const agentChatRequest = requests.find((item) => item.url === "http://web.test/v1/agent/chat");
assert.equal(agentChatRequest.body.workspaceFiles[0].path, ".hwlab/hwpod-spec.yaml");
assert.equal(agentChatRequest.body.workspaceFiles[0].encoding, "utf8");
assert.match(agentChatRequest.body.workspaceFiles[0].content, /HWLAB-CASE-F103.*caserun-run-agent-flow/u);
assert.doesNotMatch(agentChatRequest.body.message, /path: "F:\\Work\\HWLAB-CASE-F103\\\.worktree\\caserun-run-agent-flow"/u);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/result/" + result.payload.agent.traceId), true);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/trace/" + result.payload.agent.traceId), true);
} finally {
+25 -1
View File
@@ -75,6 +75,29 @@ test("hwpod-node executes debug ops only through explicit node-side command bind
}
});
test("hwpod-node executes debug commandRuns without shell splitting", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-debug-runs-"));
try {
const writer = path.join(root, "writer.js");
await writeFile(writer, "const { writeFileSync } = require('node:fs'); writeFileSync(process.argv[2], process.argv.slice(3).join('|'));\n", "utf8");
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_debug_runs",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
ops: [{ opId: "op_download", op: "debug.download", args: { workspacePath: root, commandRuns: [{ command: process.execPath, argv: [writer, path.join(root, "args.txt"), "CherryUSB MicroLink CMSIS-DAP"] }] } }]
}, { now: () => "2026-06-05T00:00:00.000Z" });
assert.equal(result.ok, true);
assert.equal(result.results[0].ok, true);
assert.equal(result.results[0].output.bindingSource, "hwpod-node-ops.commandRuns");
assert.equal(result.results[0].output.commands[0].command.at(-1), "CherryUSB MicroLink CMSIS-DAP");
assert.equal(await readFile(path.join(root, "args.txt"), "utf8"), "CherryUSB MicroLink CMSIS-DAP");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-node reports cmd.run non-zero exits as failed results", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-cmd-fail-"));
try {
@@ -96,7 +119,7 @@ test("hwpod-node reports cmd.run non-zero exits as failed results", async () =>
}
});
test("hwpod-node reports structured UART binding diagnostics", async () => {
test("hwpod-node includes structured UART diagnostics when platform blocks serial access", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-"));
try {
const fixture = path.join(root, "serial-monitor-idle.mjs");
@@ -124,6 +147,7 @@ else { console.error("unsupported " + args); process.exit(2); }
}]
}, { now: () => "2026-06-05T00:00:00.000Z" });
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");
+16 -12
View File
@@ -25,6 +25,7 @@ type ParsedArgs = Record<string, unknown> & { _: string[] };
type FetchLike = typeof fetch;
type RunProcessLike = (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
type StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number };
type AgentWorkspaceFile = { path: string; content: string; encoding: "utf8" };
type CaseContext = {
parsed: ParsedArgs;
@@ -643,6 +644,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
}
const promptPath = path.join(run.runDir, "agent-prompt.md");
const prompt = await renderAgentTaskPrompt(run, run.agentTask);
const workspaceFiles = await agentWorkspaceFilesForRun(run);
await writeFile(promptPath, prompt, "utf8");
const promptSha256 = sha256(prompt);
const baseUrl = webUrlFrom(context, run.definition);
@@ -660,7 +662,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
const threadId = session.threadId;
const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`;
run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) });
const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt });
const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt, workspaceFiles });
if (!accepted.ok) {
const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body });
const nextRun = await updateRun(context, run, { agent });
@@ -784,7 +786,7 @@ async function createAgentSession(context: CaseContext, input: { baseUrl: string
};
}
async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) {
async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string; workspaceFiles?: AgentWorkspaceFile[] }) {
return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, {
method: "POST",
headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" },
@@ -796,12 +798,18 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p
traceId: input.traceId,
providerProfile: input.providerProfile,
shortConnection: true,
workspaceFiles: input.workspaceFiles,
projectId: input.projectId,
updatedByClient: "hwlab-cli.case-run"
}))
}, "agent_task_submit");
}
async function agentWorkspaceFilesForRun(run: PreparedCaseRun): Promise<AgentWorkspaceFile[]> {
const content = await readFile(run.specPath, "utf8");
return [{ path: ".hwlab/hwpod-spec.yaml", content, encoding: "utf8" }];
}
async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) {
const requestedTimeoutMs = numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS;
const timeoutMs = Math.max(Math.min(requestedTimeoutMs, MAX_AGENT_TIMEOUT_MS), 1000);
@@ -891,12 +899,12 @@ async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Part
}
async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAgentTask) {
const specText = await readFile(run.specPath, "utf8");
const constraints = [
...agentTask.constraints,
"只能修改 isolated subject worktree,不得修改 case registry repo。",
"不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。",
"允许且必须在 AgentRun workspace 内写入/覆盖 .hwlab/hwpod-spec.yaml,用于本次 run-local HWPOD 编译验证。",
"CaseRun 已在 AgentRun workspace 预装 .hwlab/hwpod-spec.yaml;必须使用这个 run-local HWPOD spec,不要从 prompt 重建 spec。",
"如果 .hwlab/hwpod-spec.yaml 缺失或内容明显不是本次 case,请报告 CaseRun workspace setup 错误,不要自行编造或迁移 spec。",
"若 case prompt 要求源码修改,必须只改 subjectWorktreePath;若 prompt 未要求源码修改,则保持 subject 源码不变。",
"不要运行 CaseRun 答案执行器;你本人必须通过 hwpod/hwpod-ctl 标准入口触发编译验证。"
].filter(Boolean);
@@ -908,15 +916,11 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg
`subjectRepoLocalPath: ${run.subject.repoLocalPath}`,
`subjectCommitId: ${run.subject.commitId}`,
`subjectWorktreePath: ${run.subject.worktreePath}`,
`runLocalHwpodSpec: ${run.specPath}`,
`runLocalHwpodSpecWorkspacePath: .hwlab/hwpod-spec.yaml`,
`verificationMode: compile-only build check; no download or runtime smoke unless the case explicitly asks for it`,
``,
`## Run-local HWPOD spec`,
`Write exactly this YAML to \`.hwlab/hwpod-spec.yaml\` in your AgentRun workspace before running HWPOD commands. The Windows subjectWorktreePath is consumed by hwpod-node through this spec; do not try to cd into that Windows path from the Linux runner.`,
``,
`\`\`\`yaml`,
specText.trimEnd(),
`\`\`\``,
`CaseRun has already placed the run-local spec at \`.hwlab/hwpod-spec.yaml\` in this AgentRun workspace before your turn starts. Treat that file as the authority for workspace/toolchain/debug/IO bindings. Do not create or overwrite it from this prompt.`,
``,
`## Task`,
agentTask.prompt,
@@ -925,10 +929,10 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg
...constraints.map((item) => `- ${item}`),
``,
`## Flow`,
`- Create or overwrite \`.hwlab/hwpod-spec.yaml\` in your AgentRun workspace with the exact run-local spec above.`,
`- Confirm \`.hwlab/hwpod-spec.yaml\` exists. If it is missing, report a CaseRun workspace setup error instead of reconstructing it.`,
`- Run \`hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml\`.`,
`- Run \`hwpod inspect --spec .hwlab/hwpod-spec.yaml\`.`,
`- Run \`hwpod build --spec .hwlab/hwpod-spec.yaml\` for compile-only verification and report the returned JSON/job/artifact summary.`,
`- Follow the case task using standard hwpod/hwpod-ctl commands. Run build/download/UART steps only when the case explicitly asks for them, and report returned JSON/job/artifact/serial summaries.`,
`- CaseRun will inspect git diff under subjectWorktreePath after your turn completes and may run a runner post-check compile as separate evidence.`,
`- CaseRun records trace/session/conversation, agent commandExecution, workspace diff and Keil build evidence without auto-grading them.`
].join("\n");
+1
View File
@@ -409,6 +409,7 @@ function debugDownloadArgs(common: any, args: any, document: any) {
artifact: text(args.artifact),
target: text(args.target) || generated?.target,
command: explicitCommand || generated?.command,
commandRuns: generated?.commandRuns,
commandBinding: generated?.binding,
timeoutMs: numberValue(args.timeoutMs) ?? generated?.timeoutMs,
reason: text(args.reason)
+41
View File
@@ -406,6 +406,8 @@ async function workspaceInsertAfter(args: any) {
}
async function debugCommand(op: string, args: any) {
const commandRuns = commandRunsFromArgs(args);
if (commandRuns.length > 0) return await runCommandRuns(op, args, commandRuns);
const command = text(args.commandLine) || text(args.command);
if (!command) throw cliError("hwpod_node_op_not_configured", `${op} requires an explicit node-side command binding in the compiled hwpod-node-ops plan`);
const cwd = workspaceRoot(args);
@@ -413,6 +415,45 @@ async function debugCommand(op: string, args: any) {
return { cwd, command, bindingSource: "hwpod-node-ops.command", ...output };
}
function commandRunsFromArgs(args: any) {
if (Array.isArray(args.commandRuns)) return args.commandRuns;
if (args.commandRun && typeof args.commandRun === "object" && !Array.isArray(args.commandRun)) return [args.commandRun];
return [];
}
async function runCommandRuns(op: string, args: any, commandRuns: any[]) {
const cwd = workspaceRoot(args);
const outputs = [];
let exitCode = 0;
for (let index = 0; index < commandRuns.length; index += 1) {
const run = normalizeCommandRun(commandRuns[index], index);
const timeoutMs = numberValue(run.timeoutMs) ?? numberValue(args.timeoutMs) ?? 120000;
const output = await spawnOutput([run.command, ...run.argv], { cwd, timeoutMs });
const item = { index, command: [run.command, ...run.argv], commandLine: run.commandLine || null, timeoutMs, ...output };
outputs.push(item);
exitCode = output.exitCode;
if (!output.ok) break;
}
return {
cwd,
op,
bindingSource: "hwpod-node-ops.commandRuns",
commandCount: outputs.length,
commands: outputs,
exitCode,
stdout: outputs.map((item) => item.stdout).join(""),
stderr: outputs.map((item) => item.stderr).join(""),
ok: outputs.length > 0 && outputs.every((item) => item.ok)
};
}
function normalizeCommandRun(value: any, index: number) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw cliError("invalid_command_run", `commandRuns[${index}] must be an object`);
const command = requiredText(value.command, `commandRuns[${index}].command`);
const argv = Array.isArray(value.argv) ? value.argv.map(String) : [];
return { command, argv, commandLine: text(value.commandLine), timeoutMs: value.timeoutMs };
}
async function applyPatchEnvelope(root: string, patch: string) {
const lines = patch.replace(/\r\n?/gu, "\n").split("\n");
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();