fix: add hwpod workspace text edit ops

This commit is contained in:
Codex Agent
2026-06-06 17:28:06 +08:00
parent 28ffe3cd5d
commit f67e658321
7 changed files with 434 additions and 13 deletions
+9 -1
View File
@@ -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 <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 <runId> --tail 8000
7. 长任务验收必须覆盖 `hwlab-cli case run start <caseId>` 立即返回,以及 `status/result/logs <runId>` 在 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`
+1
View File
@@ -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);
}
+13
View File
@@ -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 });
}
+142 -1
View File
@@ -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");
}
+28 -3
View File
@@ -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<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
@@ -43,7 +43,7 @@ async function readCliStdinForCommand(argv: string[]): Promise<string | undefine
const parsed = parseOptions(argv);
const command = parsed._[0] || "help";
const subcommand = parsed._[1] || "";
if (command === "workspace" && subcommand === "apply-patch" && parsed.patch === undefined && parsed.patchText === undefined && parsed.patchBase64 === undefined && !process.stdin.isTTY) {
if (command === "workspace" && ["apply-patch", "write"].includes(subcommand) && parsed.patch === undefined && parsed.patchText === undefined && parsed.patchBase64 === undefined && parsed.content === undefined && parsed.contentText === undefined && parsed.contentBase64 === undefined && !process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
@@ -185,13 +185,18 @@ function cliHelp() {
"bun tools/hwpod-cli.ts workspace ls . --dry-run",
"bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml",
"cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason \"edit subject workspace through hwpod-node\"",
"bun tools/hwpod-cli.ts workspace replace --path projects/01_baseline/User/main.c --find \"old text\" --replace \"new text\" --expected-sha <sha>",
"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<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T;
}
function cleanTextEditArgs<T extends Record<string, unknown>>(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 : {};
}
+238 -8
View File
@@ -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 } };
}
+3
View File
@@ -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",