fix: make hwpod workspace search node-native

This commit is contained in:
Codex Agent
2026-06-08 11:29:37 +08:00
parent 1798e1845e
commit 1160089d3b
7 changed files with 256 additions and 9 deletions
+21
View File
@@ -186,6 +186,27 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl
assert.equal(read.payload.plan.ops[0].op, "workspace.cat");
assert.equal(read.payload.plan.ops[0].args.path, "projects/01_baseline/User/main.c");
const rg = await runHwpodCli(["workspace", "rg", "--spec", specPath, "arm_2d_init", "projects/01_baseline/Middlewares/Arm-2D", "--context", "3", "--max-matches", "20", "--ignore-case", "--dry-run"], { now: () => NOW });
assert.equal(rg.exitCode, 0);
assert.equal(rg.payload.plan.ops[0].op, "workspace.rg");
assert.equal(rg.payload.plan.ops[0].args.pattern, "arm_2d_init");
assert.equal(rg.payload.plan.ops[0].args.path, "projects/01_baseline/Middlewares/Arm-2D");
assert.equal(rg.payload.plan.ops[0].args.context, 3);
assert.equal(rg.payload.plan.ops[0].args.maxMatches, 20);
assert.equal(rg.payload.plan.ops[0].args.ignoreCase, true);
const grepAlias = await runHwpodCli(["workspace", "grep", "--spec", specPath, "arm_2d_tile_t", "projects/01_baseline/Middlewares/Arm-2D", "--dry-run"], { now: () => NOW });
assert.equal(grepAlias.exitCode, 0);
assert.equal(grepAlias.payload.plan.ops[0].op, "workspace.rg");
assert.equal(grepAlias.payload.plan.ops[0].args.pattern, "arm_2d_tile_t");
const grepShortOptions = await runHwpodCli(["workspace", "grep", "--spec", specPath, "typedef struct", "projects/01_baseline/Middlewares/Arm-2D", "-B", "2", "-A5", "-i", "--dry-run"], { now: () => NOW });
assert.equal(grepShortOptions.exitCode, 0);
assert.equal(grepShortOptions.payload.plan.ops[0].op, "workspace.rg");
assert.equal(grepShortOptions.payload.plan.ops[0].args.beforeContext, 2);
assert.equal(grepShortOptions.payload.plan.ops[0].args.afterContext, 5);
assert.equal(grepShortOptions.payload.plan.ops[0].args.ignoreCase, true);
await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.port", "COM9", "--spec", specPath], { now: () => NOW });
await runHwpodCtl(["spec", "set", "spec.ioProbe.uart.baudrate", "115200", "--spec", specPath], { now: () => NOW });
const uart = await runHwpodCli(["uart", "read", "--spec", specPath, "--port", "uart1", "--max-bytes", "512", "--dry-run"], { now: () => NOW });
+33
View File
@@ -30,6 +30,39 @@ test("hwpod-node executes minimal workspace node ops", async () => {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-node searches workspace text without requiring host rg", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-rg-"));
try {
await mkdir(path.join(root, "Middlewares", "Arm-2D"), { recursive: true });
await mkdir(path.join(root, "Objects"), { recursive: true });
await writeFile(path.join(root, "Middlewares", "Arm-2D", "arm_2d.c"), "static int before;\nvoid arm_2d_init(void) {\n}\nstatic int after;\n", "utf8");
await writeFile(path.join(root, "Objects", "ignored.txt"), "arm_2d_init should not be scanned\n", "utf8");
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_rg",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
ops: [{ opId: "op_rg", op: "workspace.rg", args: { workspacePath: root, pattern: "arm_2d_init", path: "Middlewares", context: 1 } }]
}, { now: () => "2026-06-05T00:00:00.000Z" });
assert.equal(result.ok, true);
assert.equal(result.results[0].ok, true);
assert.equal(result.results[0].output.engine, "node-recursive-search");
assert.equal(result.results[0].output.matchCount, 1);
assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c-1-static int before"));
assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c:2:void arm_2d_init"));
assert.ok(result.results[0].output.stdout.includes("Middlewares/Arm-2D/arm_2d.c-3-}"));
assert.equal(result.results[0].output.matches[0].line, 2);
assert.equal(result.results[0].output.matches[0].before[0].line, 1);
assert.equal(result.results[0].output.matches[0].after[0].line, 3);
assert.equal(result.results[0].output.limits.beforeContext, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-node HTTP endpoint accepts hwpod-node-ops plans", async () => {
const server = createHwpodNodeServer({ now: () => "2026-06-05T00:00:00.000Z", nodeId: "pc-host-1" });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+42 -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(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "json", "noAuth", "sessionOnly", "wait"]);
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "ignoreCase", "json", "noAuth", "sessionOnly", "wait"]);
const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000;
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
@@ -188,6 +188,7 @@ 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",
"bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml",
"bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --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 */\"",
@@ -245,6 +246,9 @@ function hwpodWorkspaceHelp(subcommand = "") {
"hwpod workspace read <path> --spec .hwlab/hwpod-spec.yaml",
"hwpod workspace cat --file <path> --spec .hwlab/hwpod-spec.yaml",
"hwpod workspace cat --remote-path <path> --spec .hwlab/hwpod-spec.yaml",
"hwpod workspace rg <pattern> [path] --context 3 --max-matches 20 --spec .hwlab/hwpod-spec.yaml",
"hwpod workspace grep <pattern> [path] --spec .hwlab/hwpod-spec.yaml",
"hwpod workspace search <pattern> [path] --spec .hwlab/hwpod-spec.yaml",
"hwpod workspace write --path <path> --content <text> --expected-sha <sha>",
"hwpod workspace replace --path <path> --find <text> --replace <text> --expected-sha <sha>",
"hwpod workspace insert-after --path <path> --anchor <text> --line <text> --expected-sha <sha>",
@@ -253,6 +257,8 @@ function hwpodWorkspaceHelp(subcommand = "") {
],
aliases: {
read: "workspace.cat",
grep: "workspace.rg",
search: "workspace.rg",
file: "--path",
remotePath: "--path",
patchContent: "--patch"
@@ -424,7 +430,7 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
const subcommand = parsed._[1] || "ls";
if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } };
if (subcommand === "cat" || subcommand === "read") return { intent: "workspace.cat", args: { path: requiredWorkspacePath(parsed, 2) } };
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 === "rg" || subcommand === "grep" || subcommand === "search") return { intent: "workspace.rg", args: workspaceSearchArgs(parsed) };
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: patchText(parsed.patch ?? parsed.patchText ?? parsed.patchContent ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
if (subcommand === "write") return { intent: "workspace.write", args: clean({ path: requiredWorkspacePath(parsed, 2), 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: requiredWorkspacePath(parsed, 2), 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) }) };
@@ -453,6 +459,21 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
throw cliError("unsupported_hwpod_command", `unsupported hwpod-cli command: ${command}`);
}
function workspaceSearchArgs(parsed: ParsedArgs) {
return clean({
pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"),
path: text(parsed.path ?? parsed._[3] ?? "."),
ignoreCase: parsed.ignoreCase === true,
beforeContext: numberValue(parsed.beforeContext),
afterContext: numberValue(parsed.afterContext),
context: numberValue(parsed.context),
maxMatches: numberValue(parsed.maxMatches),
maxFiles: numberValue(parsed.maxFiles),
maxBytesPerFile: numberValue(parsed.maxBytesPerFile),
maxLineBytes: numberValue(parsed.maxLineBytes)
});
}
function opsForIntent(intent: string, args: any, document: any) {
const common = commonOpArgs(document);
if (intent === "inspect") return [
@@ -462,7 +483,7 @@ function opsForIntent(intent: string, args: any, document: any) {
if (intent === "node.health" || intent === "node.version" || intent === "node.inventory") return [{ op: intent, args: common }];
if (intent === "workspace.ls") return [{ op: "workspace.ls", args: { ...common, path: text(args.path) || "." } }];
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.rg") return [{ op: "workspace.rg", args: clean({ ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true, beforeContext: numberValue(args.beforeContext), afterContext: numberValue(args.afterContext), context: numberValue(args.context), maxMatches: numberValue(args.maxMatches), maxFiles: numberValue(args.maxFiles), maxBytesPerFile: numberValue(args.maxBytesPerFile), maxLineBytes: numberValue(args.maxLineBytes) }) }];
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) }) }];
@@ -1019,6 +1040,11 @@ function parseOptions(argv: string[]): ParsedArgs {
parsed.help = true;
continue;
}
if (["-i", "-A", "-B", "-C"].includes(token) || /^-[ABC]\d+$/u.test(token)) {
applyShortSearchOption(parsed, token, argv[index + 1]);
if (["-A", "-B", "-C"].includes(token)) index += 1;
continue;
}
const [rawKey, rawInline] = token.slice(2).split(/=(.*)/su, 2);
const key = camelCase(rawKey);
if (BOOLEAN_OPTIONS.has(key)) {
@@ -1040,6 +1066,19 @@ function parseOptions(argv: string[]): ParsedArgs {
return parsed;
}
function applyShortSearchOption(parsed: ParsedArgs, token: string, next: string | undefined) {
if (token === "-i") {
parsed.ignoreCase = true;
return;
}
const flag = token[1];
const inlineValue = token.length > 2 ? token.slice(2) : next;
const value = numberValue(inlineValue);
if (flag === "A") parsed.afterContext = value;
if (flag === "B") parsed.beforeContext = value;
if (flag === "C") parsed.context = value;
}
function parseJsonObject(value: unknown, name: string) {
if (value === undefined || value === null || value === "") return {};
if (isPlainObject(value)) return value;
+150 -4
View File
@@ -379,10 +379,156 @@ async function workspaceCat(args: any) {
async function workspaceRg(args: any) {
const pattern = requiredText(args.pattern, "pattern");
const cwd = workspaceRoot(args);
const rgPath = text(args.path) || ".";
const command = ["rg", "--line-number", "--color", "never", ...(args.ignoreCase === true ? ["--ignore-case"] : []), pattern, rgPath];
const output = await spawnOutput(command, { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 10000 });
return { cwd, command, ...output };
const searchPath = text(args.path) || ".";
const target = resolveWorkspacePath(args, searchPath);
const flags = args.ignoreCase === true ? "iu" : "u";
let matcher: RegExp;
try {
matcher = new RegExp(pattern, flags);
} catch (error) {
throw cliError("workspace_rg_invalid_pattern", `invalid workspace.rg pattern: ${pattern}`, { pattern, message: error?.message ?? String(error) });
}
const maxFiles = numberValue(args.maxFiles) ?? 5000;
const maxMatches = numberValue(args.maxMatches) ?? 200;
const maxBytesPerFile = numberValue(args.maxBytesPerFile) ?? 1024 * 1024;
const lineLimit = numberValue(args.maxLineBytes) ?? 600;
const contextLines = Math.max(numberValue(args.context) ?? 0, 0);
const beforeContext = Math.max(numberValue(args.beforeContext) ?? contextLines, 0);
const afterContext = Math.max(numberValue(args.afterContext) ?? contextLines, 0);
const matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }> = [];
const state = { scannedFiles: 0, skippedFiles: 0, skippedDirectories: 0, truncated: false };
async function scanFile(filePath: string) {
if (state.truncated || state.scannedFiles >= maxFiles || matches.length >= maxMatches) {
state.truncated = true;
return;
}
const info = await stat(filePath).catch(() => null);
if (!info?.isFile?.()) {
state.skippedFiles += 1;
return;
}
if (info.size > maxBytesPerFile) {
state.skippedFiles += 1;
return;
}
const buffer = await readFile(filePath).catch(() => null);
if (!buffer || buffer.includes(0)) {
state.skippedFiles += 1;
return;
}
state.scannedFiles += 1;
const displayPath = workspaceDisplayPath(cwd, filePath);
const lines = buffer.toString("utf8").split(/\r\n|\n|\r/u);
for (let index = 0; index < lines.length; index += 1) {
if (matcher.test(lines[index])) {
matches.push(workspaceSearchMatch(displayPath, lines, index, beforeContext, afterContext, lineLimit));
if (matches.length >= maxMatches) {
state.truncated = true;
return;
}
}
}
}
async function walk(directory: string) {
if (state.truncated) return;
const entries = await readdir(directory, { withFileTypes: true }).catch(() => null);
if (!entries) {
state.skippedDirectories += 1;
return;
}
for (const entry of entries) {
if (state.truncated) return;
const child = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (isWorkspaceSearchSkippedDirectory(entry.name)) {
state.skippedDirectories += 1;
continue;
}
await walk(child);
} else if (entry.isFile()) {
await scanFile(child);
} else {
state.skippedFiles += 1;
}
}
}
const info = await stat(target).catch(() => null);
if (!info) throw cliError("workspace_rg_path_not_found", `workspace.rg path not found: ${searchPath}`, { workspacePath: cwd, path: searchPath, target });
if (info.isDirectory()) await walk(target);
else await scanFile(target);
const stdout = renderWorkspaceSearchStdout(matches);
return {
ok: true,
engine: "node-recursive-search",
cwd,
path: searchPath,
target,
pattern,
ignoreCase: args.ignoreCase === true,
exitCode: 0,
stdout,
stderr: "",
scannedFiles: state.scannedFiles,
skippedFiles: state.skippedFiles,
skippedDirectories: state.skippedDirectories,
matchCount: matches.length,
matches,
truncated: state.truncated,
limits: { maxFiles, maxMatches, maxBytesPerFile, maxLineBytes: lineLimit, beforeContext, afterContext }
};
}
function workspaceSearchMatch(displayPath: string, lines: string[], index: number, beforeContext: number, afterContext: number, lineLimit: number) {
const before = [];
const after = [];
for (let lineIndex = Math.max(0, index - beforeContext); lineIndex < index; lineIndex += 1) {
before.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) });
}
for (let lineIndex = index + 1; lineIndex <= Math.min(lines.length - 1, index + afterContext); lineIndex += 1) {
after.push({ line: lineIndex + 1, text: truncateText(lines[lineIndex], lineLimit) });
}
const match: { path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> } = {
path: displayPath,
line: index + 1,
text: truncateText(lines[index], lineLimit)
};
if (before.length) match.before = before;
if (after.length) match.after = after;
return match;
}
function renderWorkspaceSearchStdout(matches: Array<{ path: string; line: number; text: string; before?: Array<{ line: number; text: string }>; after?: Array<{ line: number; text: string }> }>) {
const lines: string[] = [];
for (const match of matches) {
for (const context of match.before ?? []) lines.push(`${match.path}-${context.line}-${context.text}`);
lines.push(`${match.path}:${match.line}:${match.text}`);
for (const context of match.after ?? []) lines.push(`${match.path}-${context.line}-${context.text}`);
}
return lines.join("\n");
}
function isWorkspaceSearchSkippedDirectory(name: string) {
return new Set([".git", ".worktree", "node_modules", "Objects", "Listings", "Output", "build", "dist"]).has(name);
}
function workspaceDisplayPath(root: string, filePath: string) {
const relative = path.relative(root, filePath) || path.basename(filePath);
return relative.replace(/[\\]+/gu, "/");
}
function truncateText(value: string, maxBytes: number) {
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
let output = "";
for (const char of value) {
if (Buffer.byteLength(`${output}${char}`, "utf8") > maxBytes) break;
output += char;
}
return `${output}...<truncated>`;
}
async function workspaceApplyPatch(args: any) {