diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index a9844368..6f30a29e 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -121,7 +121,10 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil` | `workspace.ls` | 列目录 | | `workspace.cat` | 读文件 | | `workspace.rg` | 搜索文件内容 | -| `workspace.apply-patch` | 应用源码补丁 | +| `workspace.apply-patch` | 应用 Codex apply_patch envelope 源码补丁 | +| `workspace.write` | 带 `expectedSha`、换行策略和 diff 摘要的整文件写入 | +| `workspace.replace` | 带 `expectedSha`、唯一匹配检查和 diff 摘要的精确文本替换 | +| `workspace.insert-after` | 按精确 anchor 插入文本,避免 PowerShell/cmd quoting | | `debug.build` | 编译工程 | | `debug.download` | 下载或烧录目标设备 | | `debug.reset` | 复位目标设备 | @@ -132,6 +135,8 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil` `hwpod-node` 是 HWPOD 的唯一受控节点执行器。`cmd.run` 的命令解析、PATH 稳定性和跨平台差异必须在 `hwpod-node` 本体内处理;当 Windows service、后台进程或 D601 host 的环境变量与交互 shell 不一致时,不得用 gateway shell、手工 PowerShell、预先手工创建 worktree 或其他旁路代替 `hwpod-node-ops -> hwpod-node`。D601/Windows 实地调试应使用 UniDesk SSH 透传 `D601:win` 观察进程、PATH、工具安装位置和日志,但修复必须回到 `hwpod-node` 源码、配置或启动入口,并用 `/v1/hwpod-node-ops` 原链路复测。 +Windows subject worktree 文本修改优先使用 `workspace.apply-patch`、`workspace.replace`、`workspace.insert-after` 或 `workspace.write`,不要把 `cmd.run` 的 PowerShell/cmd quoting 当成标准编辑路径。文本编辑结果必须返回编辑前后 SHA、文件字节数、换行类型、diff 摘要和 dry-run 状态;`apply-patch` 匹配失败时必须返回 normalized preview、候选行号、CRLF/LF 统计、文件 SHA/bytes 和 hwpod-node implementation version,便于判断是上下文不匹配、CRLF 问题还是远端实现版本问题。`workspace.apply-patch` 应保留原文件换行风格,尤其不能把 CRLF subject 文件静默改写成 LF。 + 标准 plan 形态: ```json @@ -172,6 +177,8 @@ bun tools/hwpod-ctl.ts spec init --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml bun tools/hwpod-compiler-cli.ts compile --spec .hwlab/hwpod-spec.yaml --intent workspace.ls --args '{"path":"."}' bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml --dry-run +bun tools/hwpod-cli.ts workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find "old text" --replace "new text" --expected-sha +bun tools/hwpod-cli.ts workspace insert-after --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --anchor "while (1)" --line " /* marker */" bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678 ``` @@ -248,3 +255,4 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run logs --tail 8000 7. 长任务验收必须覆盖 `hwlab-cli case run start ` 立即返回,以及 `status/result/logs ` 在 worker 运行中能短查询到阶段、耗时、stdout/stderr byte count、trace/session/conversation/thread 信息和下一条轮询命令;不得把同步命令长时间无输出视为有效 CaseRun 操作体验。 8. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。 9. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。 +10. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.insert-after` 或 `workspace.replace` 完成一行源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`。 diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index 6fb0df51..8039107d 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -19,6 +19,7 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => { assert.equal(payload.hwpod.apiRole, "node-ops-forwarder"); assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs"); assert.ok(payload.hwpod.supportedOps.includes("workspace.ls")); + assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after")); } finally { await close(server); } diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 07be2195..44fe9fda 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -149,6 +149,19 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl assert.equal(build.payload.plan.ops[0].args.target, "Debug"); assert.equal(build.payload.plan.ops[0].args.command, "printf cli-build"); assert.equal(build.payload.plan.ops[0].args.reason, "target flow smoke"); + + const insert = await runHwpodCli(["workspace", "insert-after", "--spec", specPath, "--path", "projects/01_baseline/User/main.c", "--anchor", " while (1) {", "--line", " /* MARKER_OK */", "--expected-sha", "abc123", "--dry-run"], { now: () => NOW }); + assert.equal(insert.exitCode, 0); + assert.equal(insert.payload.plan.ops[0].op, "workspace.insert-after"); + assert.equal(insert.payload.plan.ops[0].args.anchor, " while (1) {"); + assert.equal(insert.payload.plan.ops[0].args.line, " /* MARKER_OK */"); + assert.equal(insert.payload.plan.ops[0].args.expectedSha, "abc123"); + + const replace = await runHwpodCli(["workspace", "replace", "--spec", specPath, "--path", "projects/01_baseline/User/main.c", "--find", " /* MARKER_OK */", "--replace", "", "--dry-run"], { now: () => NOW }); + assert.equal(replace.exitCode, 0); + assert.equal(replace.payload.plan.ops[0].op, "workspace.replace"); + assert.equal(replace.payload.plan.ops[0].args.find, " /* MARKER_OK */"); + assert.equal(replace.payload.plan.ops[0].args.replace, ""); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index 58a828a2..a1856e68 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +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"; @@ -153,6 +154,142 @@ test("hwpod-node applies workspace patches through the stable node op", async () } }); +test("hwpod-node preserves CRLF when applying workspace patches", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-crlf-")); + try { + await writeFile(path.join(root, "main.c"), "int main(void) {\r\n while (1) {\r\n }\r\n}\r\n", "utf8"); + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_patch_crlf", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_patch", + op: "workspace.apply-patch", + args: { + workspacePath: root, + patch: [ + "*** Begin Patch", + "*** Update File: main.c", + "@@", + " while (1) {", + "+ /* MARKER_OK */", + " }", + "*** End Patch", + "" + ].join("\n") + } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + const content = await readFile(path.join(root, "main.c"), "utf8"); + assert.equal(result.ok, true); + assert.match(content, /\r\n while \(1\) \{\r\n \/\* MARKER_OK \*\/\r\n \}\r\n/u); + assert.doesNotMatch(content, /[^\r]\n/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("hwpod-node returns apply-patch diagnostics when context is missing", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-diagnostics-")); + try { + const original = "int main(void) {\r\n while (1) {\r\n }\r\n}\r\n"; + await writeFile(path.join(root, "main.c"), original, "utf8"); + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_patch_diag", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_patch", + op: "workspace.apply-patch", + args: { + workspacePath: root, + patch: [ + "*** Begin Patch", + "*** Update File: main.c", + "@@", + "- while (2) {", + "+ while (3) {", + "*** End Patch", + "" + ].join("\n") + } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + assert.equal(result.ok, false); + assert.equal(result.results[0].blocker.code, "apply_patch_context_not_found"); + assert.equal(result.results[0].blocker.details.fileSha256, sha256(original)); + assert.equal(result.results[0].blocker.details.lineEnding, "\r\n"); + assert.equal(result.results[0].blocker.details.lineEndingCounts.crlf, 4); + assert.equal(result.results[0].blocker.details.normalized, true); + assert.match(result.results[0].blocker.details.nodeVersion, /thin-node-ops/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("hwpod-node edits CRLF workspaces with insert-after replace and write ops", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-text-edit-")); + try { + const filePath = path.join(root, "projects", "01_baseline", "User", "main.c"); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, "int main(void) {\r\n while (1) {\r\n }\r\n}\r\n", "utf8"); + const before = await readFile(filePath, "utf8"); + const insert = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_insert", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_insert", + op: "workspace.insert-after", + args: { workspacePath: root, path: "projects/01_baseline/User/main.c", anchor: " while (1) {", line: " /* MARKER_OK */", expectedSha: sha256(before) } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + assert.equal(insert.ok, true); + assert.equal(insert.results[0].output.before.sha256, sha256(before)); + assert.equal(insert.results[0].output.after.lineEnding, "\r\n"); + assert.deepEqual(insert.results[0].output.diff.preview, ["+ /* MARKER_OK */"]); + const inserted = await readFile(filePath, "utf8"); + assert.match(inserted, /\r\n \/\* MARKER_OK \*\/\r\n/u); + assert.doesNotMatch(inserted, /[^\r]\n/u); + + const replace = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_replace", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_replace", + op: "workspace.replace", + args: { workspacePath: root, path: "projects/01_baseline/User/main.c", find: " /* MARKER_OK */", replace: " /* MARKER_REPLACED */", expectedSha: sha256(inserted) } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + assert.equal(replace.ok, true); + assert.equal(replace.results[0].output.occurrences, 1); + assert.match(await readFile(filePath, "utf8"), /MARKER_REPLACED/u); + + const dryRun = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_write_dry", + hwpodId: "hwpod-local", + nodeId: "pc-host-1", + ops: [{ + opId: "op_write", + op: "workspace.write", + args: { workspacePath: root, path: "new.txt", content: "alpha\nbeta", lineEnding: "crlf", finalNewline: true, dryRun: true } + }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + assert.equal(dryRun.ok, true); + assert.equal(dryRun.results[0].output.dryRun, true); + assert.equal(dryRun.results[0].output.after.lineEnding, "\r\n"); + await assert.rejects(() => readFile(path.join(root, "new.txt"), "utf8"), /ENOENT/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("hwpod-node preserves Windows absolute workspace paths for PC-host nodes", async () => { const result = await executeHwpodNodeOpsPlan({ contractVersion: "hwpod-node-ops-v1", @@ -167,3 +304,7 @@ test("hwpod-node preserves Windows absolute workspace paths for PC-host nodes", assert.match(result.results[0].blocker.summary, /scandir 'F:\\Work\\D601-HWLAB'/u); assert.doesNotMatch(result.results[0].blocker.summary, /\/root\//u); }); + +function sha256(content: string) { + return createHash("sha256").update(content, "utf8").digest("hex"); +} diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index d51914c9..ba9d4e00 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -14,7 +14,7 @@ const COMPILER_NAME = "hwpod-compiler-cli"; const CTL_NAME = "hwpod-ctl"; const CLI_NAME = "hwpod-cli"; const DEFAULT_TIMEOUT_MS = 30000; -const BOOLEAN_OPTIONS = new Set(["dryRun", "force", "full", "help", "h", "json", "noAuth"]); +const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "json", "noAuth"]); const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000; type EnvLike = Record; type ParsedArgs = Record & { _: string[] }; @@ -43,7 +43,7 @@ async function readCliStdinForCommand(argv: string[]): Promise", + "bun tools/hwpod-cli.ts workspace insert-after --path projects/01_baseline/User/main.c --anchor \"while (1)\" --line \" /* marker */\"", "bun tools/hwpod-cli.ts build --dry-run", "bun tools/hwpod-cli.ts uart read --port uart1 --dry-run", "bun tools/hwpod-cli.ts --api-base-url http://74.48.78.17:19667 inspect" ], workspace: { cat: "Read a subject workspace file through hwpod-node; path is relative to spec.workspace.path.", - applyPatch: "Apply a Codex apply_patch envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText, or --patchBase64." + applyPatch: "Apply a Codex apply_patch envelope through hwpod-node. Patch content may come from stdin, --patch/--patchText, or --patchBase64.", + write: "Write a whole file with optional --expected-sha, --line-ending preserve|lf|crlf and dry-run diff output.", + replace: "Replace exact text in a subject file with SHA/diff diagnostics; pass --all for global replacement.", + insertAfter: "Insert one or more lines after an exact anchor without shell quoting." }, applyPatchExample: [ "*** Begin Patch", @@ -324,6 +329,9 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) { if (subcommand === "cat") return { intent: "workspace.cat", args: { path: requiredText(parsed.path ?? parsed._[2], "path") } }; if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } }; if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } }; + if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredText(parsed.path ?? parsed._[2], "path"), content: patchText(parsed.content ?? parsed.contentText ?? stdinText), contentBase64: text(parsed.contentBase64), expectedSha: text(parsed.expectedSha), lineEnding: text(parsed.lineEnding), finalNewline: parsed.finalNewline === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; + if (subcommand === "replace") return { intent: "workspace.replace", args: cleanTextEditArgs({ path: requiredText(parsed.path ?? parsed._[2], "path"), find: requiredRawString(parsed.find ?? parsed._[3], "find"), replace: rawString(parsed.replace ?? parsed.replacement ?? parsed._[4]), expectedSha: text(parsed.expectedSha), all: parsed.all === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; + if (subcommand === "insert-after") return { intent: "workspace.insert-after", args: cleanTextEditArgs({ path: requiredText(parsed.path ?? parsed._[2], "path"), anchor: requiredRawString(parsed.anchor ?? parsed._[3], "anchor"), line: requiredRawString(parsed.line ?? parsed.insert ?? parsed.text ?? parsed._[4], "line"), expectedSha: text(parsed.expectedSha), allowMultiple: parsed.allowMultiple === true, dryRun: parsed.dryRun === true, reason: text(parsed.reason) }) }; throw cliError("unsupported_workspace_command", `unsupported workspace command: ${subcommand}`); } if (command === "build") return { intent: "debug.build", args: clean({ target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) }; @@ -354,6 +362,9 @@ function opsForIntent(intent: string, args: any, document: any) { if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }]; if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }]; if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: patchText(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }]; + if (intent === "workspace.write") return [{ op: "workspace.write", args: clean({ ...common, path: requiredText(args.path, "path"), content: patchText(args.content), contentBase64: text(args.contentBase64), expectedSha: text(args.expectedSha), lineEnding: text(args.lineEnding), finalNewline: args.finalNewline === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; + if (intent === "workspace.replace") return [{ op: "workspace.replace", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), find: requiredRawString(args.find, "find"), replace: rawString(args.replace), expectedSha: text(args.expectedSha), all: args.all === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; + if (intent === "workspace.insert-after") return [{ op: "workspace.insert-after", args: cleanTextEditArgs({ ...common, path: requiredText(args.path, "path"), anchor: requiredRawString(args.anchor, "anchor"), line: requiredRawString(args.line, "line"), expectedSha: text(args.expectedSha), allowMultiple: args.allowMultiple === true, dryRun: args.dryRun === true, reason: text(args.reason) }) }]; if (intent === "debug.build") return debugBuildOps(common, args, document); if (intent === "debug.download") return [{ op: "debug.download", args: debugDownloadArgs(common, args, document) }]; if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode), command: text(args.command) || text(document.spec.debugProbe.resetCommand), reason: text(args.reason) }) }]; @@ -822,10 +833,20 @@ function requiredText(value: unknown, name: string) { return normalized; } +function requiredRawString(value: unknown, name: string) { + const normalized = rawString(value); + if (!normalized) throw cliError("required_option_missing", `${name} is required`, { name }); + return normalized; +} + function text(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : ""; } +function rawString(value: unknown) { + return typeof value === "string" ? value : ""; +} + function patchText(value: unknown) { return typeof value === "string" && value.length > 0 ? value : ""; } @@ -846,6 +867,10 @@ function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } +function cleanTextEditArgs>(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([key, item]) => item !== undefined && item !== null && (item !== "" || ["replace", "line"].includes(key)))) as T; +} + function objectValue(value: any) { return isPlainObject(value) ? value : {}; } diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 72f23d7f..25899d50 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -96,7 +97,7 @@ export function connectHwpodNodeWs(config: any, options: any = {}) { type: "register", nodeId, name: text(config.name) || nodeId, - capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "debug.build", "debug.download", "debug.reset"], + capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "debug.build", "debug.download", "debug.reset"], labels: { platform: process.platform, arch: process.arch, hostname: os.hostname(), version: NODE_VERSION, startedAt: now() } }); sendHeartbeat(); @@ -269,6 +270,9 @@ async function executeOp(op: any, context: any) { if (name === "workspace.cat") return opOk(opId, name, await workspaceCat(args)); if (name === "workspace.rg") return opOk(opId, name, await workspaceRg(args)); if (name === "workspace.apply-patch") return opOk(opId, name, await workspaceApplyPatch(args)); + if (name === "workspace.write") return opOk(opId, name, await workspaceWrite(args)); + if (name === "workspace.replace") return opOk(opId, name, await workspaceReplace(args)); + if (name === "workspace.insert-after") return opOk(opId, name, await workspaceInsertAfter(args)); if (name === "cmd.run") { const output = await cmdRun(args); return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `cmd.run node-side command exited with ${output.exitCode}`); @@ -282,7 +286,7 @@ async function executeOp(op: any, context: any) { } return opBlocked(opId, name, "unsupported_hwpod_node_op", `unsupported hwpod-node op: ${name}`); } catch (error) { - return opBlocked(opId, name, error?.code || "hwpod_node_op_failed", error?.message || String(error)); + return opBlockedFromError(opId, name, error); } } @@ -325,6 +329,78 @@ async function workspaceApplyPatch(args: any) { return { cwd, changes: await applyPatchEnvelope(cwd, patch) }; } +async function workspaceWrite(args: any) { + const relativePath = requiredText(args.path, "path"); + const target = resolveWorkspacePath(args, relativePath); + const before = await readTextFileState(target).catch((error) => { + if (error?.code === "ENOENT") return null; + throw error; + }); + assertExpectedSha(before, args.expectedSha, relativePath); + const content = contentFromArgs(args, "content"); + const lineEnding = lineEndingFromArgs(args, before?.lineEnding ?? "\n"); + const finalContent = normalizeContentLineEndings(content, lineEnding, args.finalNewline === true); + const afterSha = sha256Text(finalContent); + const dryRun = args.dryRun === true; + if (!dryRun) { + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, finalContent, "utf8"); + } + return editResult({ action: "write", path: relativePath, before, afterContent: finalContent, dryRun }); +} + +async function workspaceReplace(args: any) { + const relativePath = requiredText(args.path, "path"); + const target = resolveWorkspacePath(args, relativePath); + const before = await readTextFileState(target); + assertExpectedSha(before, args.expectedSha, relativePath); + const find = requiredRawString(args.find, "find"); + const replace = rawString(args.replace ?? args.replacement); + const normalizedFind = normalizeContentLineEndings(find, "\n", false); + const normalizedReplace = normalizeContentLineEndings(replace, "\n", false); + const normalizedContent = before.normalizedContent; + const occurrences = countOccurrences(normalizedContent, normalizedFind); + if (occurrences === 0) { + throw cliError("workspace_replace_anchor_not_found", `replace text not found for ${relativePath}`, diagnosticDetails(before, normalizedFind, relativePath)); + } + if (args.all !== true && occurrences !== 1) { + throw cliError("workspace_replace_ambiguous", `replace text matched ${occurrences} times for ${relativePath}; pass --all for global replacement`, { path: relativePath, occurrences, fileSha256: before.sha256 }); + } + const afterNormalized = args.all === true ? normalizedContent.split(normalizedFind).join(normalizedReplace) : normalizedContent.replace(normalizedFind, normalizedReplace); + const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false); + const dryRun = args.dryRun === true; + if (!dryRun) await writeFile(target, afterContent, "utf8"); + return editResult({ action: "replace", path: relativePath, before, afterContent, dryRun, extra: { occurrences, all: args.all === true } }); +} + +async function workspaceInsertAfter(args: any) { + const relativePath = requiredText(args.path, "path"); + const target = resolveWorkspacePath(args, relativePath); + const before = await readTextFileState(target); + assertExpectedSha(before, args.expectedSha, relativePath); + const anchor = requiredRawString(args.anchor, "anchor"); + const insertText = requiredRawString(args.line ?? args.insert ?? args.text, "line"); + const normalizedAnchor = normalizeContentLineEndings(anchor, "\n", false); + const normalizedInsert = normalizeContentLineEndings(insertText, "\n", false); + const lines = splitNormalizedLines(before.normalizedContent, before.hadFinalNewline); + const anchorLines = splitNormalizedLines(normalizedAnchor, false); + const at = findLineSequence(lines, anchorLines, 0); + if (at < 0) { + throw cliError("workspace_insert_anchor_not_found", `insert anchor not found for ${relativePath}`, diagnosticDetails(before, normalizedAnchor, relativePath)); + } + const secondAt = findLineSequence(lines, anchorLines, at + 1); + if (secondAt >= 0 && args.allowMultiple !== true) { + throw cliError("workspace_insert_anchor_ambiguous", `insert anchor matched more than once for ${relativePath}; pass --allow-multiple to use the first match`, { path: relativePath, firstLine: at + 1, secondLine: secondAt + 1, fileSha256: before.sha256 }); + } + const insertLines = splitNormalizedLines(normalizedInsert, false); + const outputLines = [...lines.slice(0, at + anchorLines.length), ...insertLines, ...lines.slice(at + anchorLines.length)]; + const afterNormalized = `${outputLines.join("\n")}${before.hadFinalNewline ? "\n" : ""}`; + const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false); + const dryRun = args.dryRun === true; + if (!dryRun) await writeFile(target, afterContent, "utf8"); + return editResult({ action: "insert-after", path: relativePath, before, afterContent, dryRun, extra: { anchorLine: at + 1, insertedLines: insertLines.length } }); +} + async function debugCommand(op: string, args: any) { 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`); @@ -391,23 +467,173 @@ async function applyPatchEnvelope(root: string, patch: string) { } async function applyUpdateHunks(filePath: string, hunks: string[][], relativePath: string) { - const original = await readFile(filePath, "utf8"); - const hadFinalNewline = original.endsWith("\n"); - let fileLines = original.replace(/\r\n?/gu, "\n").split("\n"); - if (hadFinalNewline) fileLines.pop(); + const before = await readTextFileState(filePath); + let fileLines = splitNormalizedLines(before.normalizedContent, before.hadFinalNewline); let searchStart = 0; for (const hunk of hunks) { const oldLines = hunk.filter((line) => line[0] !== "+").map((line) => line.slice(1)); const newLines = hunk.filter((line) => line[0] !== "-").map((line) => line.slice(1)); const at = findLineSequence(fileLines, oldLines, searchStart); - if (at < 0) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`); + if (at < 0) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, diagnosticDetails(before, oldLines.join("\n"), relativePath)); fileLines = [...fileLines.slice(0, at), ...newLines, ...fileLines.slice(at + oldLines.length)]; searchStart = at + newLines.length; } - await writeFile(filePath, `${fileLines.join("\n")}${hadFinalNewline ? "\n" : ""}`, "utf8"); + const afterNormalized = `${fileLines.join("\n")}${before.hadFinalNewline ? "\n" : ""}`; + await writeFile(filePath, normalizeContentLineEndings(afterNormalized, before.lineEnding, false), "utf8"); return hunks.length; } +async function readTextFileState(filePath: string) { + const content = await readFile(filePath, "utf8"); + const crlfCount = (content.match(/\r\n/gu) || []).length; + const loneLfCount = (content.replace(/\r\n/gu, "").match(/\n/gu) || []).length; + const loneCrCount = (content.replace(/\r\n/gu, "").match(/\r/gu) || []).length; + const lineEnding = crlfCount > 0 && loneLfCount === 0 && loneCrCount === 0 ? "\r\n" : "\n"; + const normalizedContent = content.replace(/\r\n?/gu, "\n"); + return { + content, + normalizedContent, + bytes: Buffer.byteLength(content, "utf8"), + sha256: sha256Text(content), + lineEnding, + lineEndingCounts: { crlf: crlfCount, lf: loneLfCount, cr: loneCrCount }, + hadFinalNewline: normalizedContent.endsWith("\n") + }; +} + +function splitNormalizedLines(content: string, hadFinalNewline: boolean) { + const lines = content.split("\n"); + if (hadFinalNewline) lines.pop(); + return lines; +} + +function diagnosticDetails(fileState: any, expected: string, relativePath: string) { + const expectedLines = splitNormalizedLines(normalizeContentLineEndings(expected, "\n", false), false).filter((line) => line.length > 0); + const normalizedLines = splitNormalizedLines(fileState.normalizedContent, fileState.hadFinalNewline); + const candidates = expectedLines.slice(0, 4).flatMap((line) => candidateLines(normalizedLines, line)).slice(0, 12); + return { + path: relativePath, + fileSha256: fileState.sha256, + fileBytes: fileState.bytes, + lineEnding: fileState.lineEnding, + lineEndingCounts: fileState.lineEndingCounts, + expectedPreview: previewLines(expectedLines.length ? expectedLines : [expected]), + candidates, + nodeVersion: NODE_VERSION, + normalized: true + }; +} + +function candidateLines(lines: string[], needle: string) { + const trimmed = needle.trim(); + if (!trimmed) return []; + return lines + .map((line, index) => ({ lineNumber: index + 1, line })) + .filter((item) => item.line.includes(trimmed) || item.line.trim() === trimmed) + .slice(0, 4) + .map((item) => ({ lineNumber: item.lineNumber, preview: item.line.slice(0, 160) })); +} + +function previewLines(lines: string[]) { + return lines.slice(0, 8).map((line, index) => ({ offset: index + 1, text: line.slice(0, 160) })); +} + +function editResult({ action, path: relativePath, before, afterContent, dryRun, extra = {} }: any) { + const afterSha = sha256Text(afterContent); + const summary = diffSummary(before?.normalizedContent ?? "", afterContent.replace(/\r\n?/gu, "\n")); + return { + action, + path: relativePath, + dryRun: Boolean(dryRun), + before: before ? { sha256: before.sha256, bytes: before.bytes, lineEnding: before.lineEnding, lineEndingCounts: before.lineEndingCounts } : null, + after: { sha256: afterSha, bytes: Buffer.byteLength(afterContent, "utf8"), lineEnding: detectLineEnding(afterContent) }, + diff: summary, + ...extra + }; +} + +function diffSummary(beforeNormalized: string, afterNormalized: string) { + const beforeLines = splitNormalizedLines(beforeNormalized, beforeNormalized.endsWith("\n")); + const afterLines = splitNormalizedLines(afterNormalized, afterNormalized.endsWith("\n")); + let prefix = 0; + while (prefix < beforeLines.length && prefix < afterLines.length && beforeLines[prefix] === afterLines[prefix]) prefix += 1; + let suffix = 0; + while (suffix < beforeLines.length - prefix && suffix < afterLines.length - prefix && beforeLines[beforeLines.length - 1 - suffix] === afterLines[afterLines.length - 1 - suffix]) suffix += 1; + const removed = beforeLines.slice(prefix, beforeLines.length - suffix); + const added = afterLines.slice(prefix, afterLines.length - suffix); + return { + firstChangedLine: prefix + 1, + removedLines: removed.length, + addedLines: added.length, + preview: [ + ...removed.slice(0, 6).map((line) => `-${line}`), + ...added.slice(0, 6).map((line) => `+${line}`) + ] + }; +} + +function assertExpectedSha(before: any, expectedSha: unknown, relativePath: string) { + const expected = text(expectedSha); + if (!expected) return; + if (!before) throw cliError("workspace_expected_sha_missing_file", `expectedSha was provided but ${relativePath} does not exist`, { path: relativePath, expectedSha: expected }); + if (before.sha256 !== expected) { + throw cliError("workspace_expected_sha_mismatch", `expectedSha mismatch for ${relativePath}`, { path: relativePath, expectedSha: expected, actualSha: before.sha256, bytes: before.bytes }); + } +} + +function contentFromArgs(args: any, name: string) { + const direct = typeof args[name] === "string" ? args[name] : typeof args.contentText === "string" ? args.contentText : ""; + if (typeof direct === "string" && direct.length > 0) return direct; + const base64 = text(args.contentBase64); + if (base64) return Buffer.from(base64, "base64").toString("utf8"); + throw cliError("content_required", `${name} or contentBase64 is required`); +} + +function lineEndingFromArgs(args: any, fallback: string) { + const value = text(args.lineEnding).toLowerCase(); + if (!value || value === "preserve") return fallback; + if (value === "crlf" || value === "\\r\\n") return "\r\n"; + if (value === "lf" || value === "\\n") return "\n"; + throw cliError("invalid_line_ending", "lineEnding must be preserve, lf, or crlf", { lineEnding: value }); +} + +function normalizeContentLineEndings(content: string, lineEnding: string, finalNewline: boolean) { + const normalized = String(content).replace(/\r\n?/gu, "\n"); + const withFinal = finalNewline && !normalized.endsWith("\n") ? `${normalized}\n` : normalized; + return lineEnding === "\r\n" ? withFinal.replace(/\n/gu, "\r\n") : withFinal; +} + +function detectLineEnding(content: string) { + const state = content.includes("\r\n") && !content.replace(/\r\n/gu, "").includes("\n") ? "\r\n" : "\n"; + return state; +} + +function sha256Text(content: string) { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +function countOccurrences(content: string, needle: string) { + if (!needle) return 0; + let count = 0; + let index = 0; + for (;;) { + const found = content.indexOf(needle, index); + if (found < 0) return count; + count += 1; + index = found + needle.length; + } +} + +function requiredRawString(value: unknown, name: string) { + const normalized = rawString(value); + if (!normalized) throw cliError("required_option_missing", `${name} is required`, { name }); + return normalized; +} + +function rawString(value: unknown) { + return typeof value === "string" ? value : ""; +} + function findLineSequence(lines: string[], sequence: string[], start: number) { if (sequence.length === 0) return start; for (let index = start; index <= lines.length - sequence.length; index += 1) { @@ -566,6 +792,10 @@ function opBlocked(opId: string, op: string, code: string, summary: string) { return { opId, op, ok: false, status: "blocked", blocker: { code, layer: "hwpod-node", retryable: true, summary } }; } +function opBlockedFromError(opId: string, op: string, error: any) { + return { opId, op, ok: false, status: "blocked", blocker: { code: error?.code || "hwpod_node_op_failed", layer: "hwpod-node", retryable: true, summary: error?.message || String(error), details: error?.details || undefined } }; +} + function opFailed(opId: string, op: string, output: any, code: string, summary: string) { return { opId, op, ok: false, status: "failed", output, blocker: { code, layer: "hwpod-node", retryable: false, summary } }; } diff --git a/tools/src/hwpod-node-ops-contract.ts b/tools/src/hwpod-node-ops-contract.ts index 797b9ffe..d860dc0a 100644 --- a/tools/src/hwpod-node-ops-contract.ts +++ b/tools/src/hwpod-node-ops-contract.ts @@ -8,6 +8,9 @@ export const HWPOD_NODE_OPS = new Set([ "workspace.cat", "workspace.rg", "workspace.apply-patch", + "workspace.write", + "workspace.replace", + "workspace.insert-after", "debug.build", "debug.download", "debug.reset",