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
+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",