fix: add hwpod workspace text edit ops
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user