Merge pull request #1014 from pikasTech/fix/caserun-workspace-seed-1011
fix: 预装 CaseRun HWPOD workspace spec
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user