feat: wire hwpod target node-ops flow

This commit is contained in:
Codex Agent
2026-06-05 13:39:20 +08:00
parent 7157e59c48
commit c1df458c85
6 changed files with 169 additions and 50 deletions
+1 -17
View File
@@ -67,25 +67,9 @@ import {
configureSkillRuntime,
ensureCodexSkillsAggregationSync
} from "./skills-store.ts";
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
const HWPOD_NODE_OPS_CONTRACT_VERSION = "hwpod-node-ops-v1";
const HWPOD_NODE_OPS = new Set([
"node.health",
"node.version",
"node.inventory",
"workspace.ls",
"workspace.cat",
"workspace.rg",
"workspace.apply-patch",
"debug.build",
"debug.download",
"debug.reset",
"io.uart.read",
"io.uart.write",
"io.uart.jsonrpc",
"cmd.run"
]);
function runtimeEnvironment(env = process.env) {
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
+18 -2
View File
@@ -65,11 +65,17 @@ test("hwpod-compiler-cli compiles workspace-local spec into node ops", async ()
assert.equal(result.payload.plan.ops[0].op, "workspace.ls");
assert.equal(result.payload.plan.ops[0].args.path, "src");
assert.equal(result.payload.plan.ops[0].args.workspacePath, "/workspace/fw");
await runHwpodCtl(["spec", "set", "spec.workspace.buildCommand", "printf spec-build", "--spec", specPath], { now: () => NOW });
const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW });
assert.equal(build.exitCode, 0);
assert.equal(build.payload.plan.ops[0].op, "debug.build");
assert.equal(build.payload.plan.ops[0].args.command, "printf spec-build");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-cli dry-run keeps high-level intent out of hwlab-api and exposes hwpod-node-ops plan", async () => {
test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops plan", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-"));
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
try {
@@ -77,12 +83,19 @@ test("hwpod-cli dry-run keeps high-level intent out of hwlab-api and exposes hwp
const inspect = await runHwpodCli(["inspect", "--spec", specPath, "--dry-run"], { now: () => NOW });
assert.equal(inspect.exitCode, 0);
assert.equal(inspect.payload.dryRun, true);
assert.equal(inspect.payload.compilerInvocation.compiler, "hwpod-compiler-cli");
assert.equal(inspect.payload.compilerInvocation.mode, "subprocess");
assert.equal(inspect.payload.compilerInvocation.action, "hwpod-compiler.compile");
assert.equal(inspect.payload.plan.source.compiler, "hwpod-compiler-cli");
assert.deepEqual(inspect.payload.plan.ops.map((op: any) => op.op), ["node.health", "node.inventory"]);
const build = await runHwpodCli(["build", "--spec", specPath, "--dry-run", "--target", "Debug"], { now: () => NOW });
const build = await runHwpodCli(["build", "--spec", specPath, "--dry-run", "--target", "Debug", "--command", "printf cli-build", "--reason", "target flow smoke"], { now: () => NOW });
assert.equal(build.exitCode, 0);
assert.equal(build.payload.compilerInvocation.mode, "subprocess");
assert.equal(build.payload.plan.ops[0].op, "debug.build");
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");
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -108,9 +121,12 @@ test("hwpod-cli submits compiled node ops to hwlab-api when not dry-run", async
now: () => NOW
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.compilerInvocation.compiler, "hwpod-compiler-cli");
assert.equal(result.payload.compilerInvocation.mode, "subprocess");
assert.equal(seen[0].url, "http://cloud.test/v1/hwpod-node-ops");
assert.equal(seen[0].init.headers["x-hwlab-device-pod-api-key"], "hwl_live_test");
assert.equal(seen[0].body.contractVersion, "hwpod-node-ops-v1");
assert.equal(seen[0].body.source.compiler, "hwpod-compiler-cli");
assert.equal(seen[0].body.ops[0].op, "workspace.ls");
} finally {
await rm(root, { recursive: true, force: true });
+36
View File
@@ -47,12 +47,48 @@ test("hwpod-node HTTP endpoint accepts hwpod-node-ops plans", async () => {
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.specAuthority, "none");
assert.equal(payload.results[0].op, "node.version");
} finally {
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
}
});
test("hwpod-node does not fall back to legacy device-pod for debug ops", async () => {
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_debug_unbound",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
ops: [{ opId: "op_build", op: "debug.build", args: { workspacePath: ".", target: "Debug" } }]
}, { now: () => "2026-06-05T00:00:00.000Z" });
assert.equal(result.ok, false);
assert.equal(result.specAuthority, "none");
assert.equal(result.results[0].ok, false);
assert.equal(result.results[0].blocker.code, "hwpod_node_op_not_configured");
});
test("hwpod-node executes debug ops only through explicit node-side command bindings", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-debug-"));
try {
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_debug_bound",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
ops: [{ opId: "op_build", op: "debug.build", args: { workspacePath: root, command: "printf build-ok > build.ok" } }]
}, { 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.bindingSource, "hwpod-node-ops.command");
assert.match(await readFile(path.join(root, "build.ok"), "utf8"), /build-ok/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-node applies workspace patches through the stable node op", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-"));
try {
+64 -28
View File
@@ -1,10 +1,11 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts";
import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts";
export const HWPOD_NODE_OPS_CONTRACT_VERSION = "hwpod-node-ops-v1";
export const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml";
const COMPILER_NAME = "hwpod-compiler-cli";
@@ -12,23 +13,6 @@ 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 HWPOD_NODE_OPS = new Set([
"node.health",
"node.version",
"node.inventory",
"workspace.ls",
"workspace.cat",
"workspace.rg",
"workspace.apply-patch",
"debug.build",
"debug.download",
"debug.reset",
"io.uart.read",
"io.uart.write",
"io.uart.jsonrpc",
"cmd.run"
]);
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
type FetchLike = typeof fetch;
@@ -99,15 +83,15 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc
if (command === "closeout") return result(0, closeout(parsed), now);
const specPath = specPathFrom(parsed);
const document = await readHwpodSpec(specPath);
const { intent, args } = commandToIntent(parsed, options.stdinText);
const plan = compileHwpodNodeOpsPlan({ document, specPath, intent, args, now });
const compiled = await compilePlanWithCompilerCli({ specPath, intent, args });
const plan = compiled.plan;
if (parsed.dryRun === true) {
return result(0, ok("hwpod-cli.plan", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, plan, dryRun: true }), now);
return result(0, ok("hwpod-cli.plan", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, plan, dryRun: true }), now);
}
const response = await submitHwpodNodeOpsPlan({ parsed, env, fetchImpl: options.fetchImpl, plan });
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
return result(exitCode, ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")), now);
return result(exitCode, ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")), now);
} catch (error) {
return result(1, failure(CLI_NAME, error), now);
}
@@ -247,6 +231,51 @@ function closeout(parsed: ParsedArgs) {
});
}
async function compilePlanWithCompilerCli({ specPath, intent, args }: { specPath: string; intent: string; args: Record<string, unknown> }) {
const compilerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../hwpod-compiler-cli.ts");
const argsJson = JSON.stringify(args ?? {});
const command = [process.execPath, compilerPath, "compile", "--spec", specPath, "--intent", intent, "--args", argsJson];
const proc = Bun.spawn(command, { cwd: process.cwd(), env: process.env, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited
]);
const compilerInvocation = clean({
compiler: COMPILER_NAME,
mode: "subprocess",
entrypoint: "tools/hwpod-compiler-cli.ts",
argv: ["compile", "--spec", specPath, "--intent", intent, "--args", "<json>"],
exitCode,
stderr: clipText(stderr)
});
if (exitCode !== 0) {
throw cliError("hwpod_compiler_failed", "hwpod-compiler-cli failed to compile hwpod-node-ops plan", {
compilerInvocation,
stdout: clipText(stdout),
stderr: clipText(stderr)
});
}
const payload = parseJsonObject(stdout, "compiler_stdout");
const plan = isPlainObject(payload.plan) ? payload.plan : null;
if (payload.ok === false || !plan) {
throw cliError("hwpod_compiler_invalid_output", "hwpod-compiler-cli did not return a valid hwpod-node-ops plan", {
compilerInvocation,
payload: clean({ ok: payload.ok, action: payload.action, status: payload.status, error: payload.error })
});
}
return {
plan,
compilerInvocation: clean({
...compilerInvocation,
action: payload.action,
contractVersion: payload.contractVersion,
planId: plan.planId,
source: plan.source
})
};
}
function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
const command = parsed._[0];
if (command === "inspect") return { intent: "inspect", args: {} };
@@ -262,9 +291,9 @@ function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: text(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), 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) }) };
if (command === "download") return { intent: "debug.download", args: clean({ artifact: text(parsed.artifact ?? parsed._[1]), target: text(parsed.target) }) };
if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]) }) };
if (command === "build") return { intent: "debug.build", args: clean({ target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "download") return { intent: "debug.download", args: clean({ artifact: text(parsed.artifact ?? parsed._[1]), target: text(parsed.target), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]), command: text(parsed.command ?? parsed.commandLine), reason: text(parsed.reason) }) };
if (command === "uart") {
const subcommand = parsed._[1] || "read";
if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), since: text(parsed.since) }) };
@@ -290,9 +319,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: text(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }];
if (intent === "debug.build") return [{ op: "debug.build", args: clean({ ...common, target: text(args.target), command: text(args.command) }) }];
if (intent === "debug.download") return [{ op: "debug.download", args: clean({ ...common, artifact: text(args.artifact), target: text(args.target) }) }];
if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode) }) }];
if (intent === "debug.build") return [{ op: "debug.build", args: clean({ ...common, target: text(args.target), command: text(args.command) || text(document.spec.workspace.buildCommand), reason: text(args.reason) }) }];
if (intent === "debug.download") return [{ op: "debug.download", args: clean({ ...common, artifact: text(args.artifact), target: text(args.target), command: text(args.command) || text(document.spec.debugProbe.downloadCommand), reason: text(args.reason) }) }];
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) }) }];
if (intent === "io.uart.read") return [{ op: "io.uart.read", args: clean({ ...common, port: text(args.port) || "uart1", maxBytes: numberValue(args.maxBytes), since: text(args.since) }) }];
if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }];
if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }];
@@ -579,6 +608,13 @@ function numberValue(value: unknown) {
return Number.isInteger(parsed) ? parsed : undefined;
}
function clipText(value: unknown, maxBytes = 4000) {
const textValue = typeof value === "string" ? value : String(value ?? "");
const buffer = Buffer.from(textValue, "utf8");
if (buffer.length <= maxBytes) return textValue;
return `${buffer.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/u, "")}\n... clipped ...`;
}
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;
}
+31 -3
View File
@@ -3,7 +3,7 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os";
import path from "node:path";
import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-harness-lib.ts";
import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts";
const NODE_VERSION = "0.1.0-thin-node-ops";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
@@ -54,6 +54,9 @@ export function createHwpodNodeServer(options: any = {}) {
serviceId: "hwpod-node",
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
version: NODE_VERSION,
nodeRole: "single-host-hwpod-node",
acceptedInput: "hwpod-node-ops",
specAuthority: "none",
nodeId: process.env.HWPOD_NODE_ID || os.hostname(),
observedAt: now()
});
@@ -86,6 +89,9 @@ export async function executeHwpodNodeOpsPlan(plan: any, options: any = {}) {
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
serviceId: "hwpod-node",
nodeVersion: NODE_VERSION,
nodeRole: "single-host-hwpod-node",
acceptedInput: "hwpod-node-ops",
specAuthority: "none",
planId: plan.planId ?? null,
hwpodId: plan.hwpodId ?? null,
nodeId: plan.nodeId ?? process.env.HWPOD_NODE_ID ?? os.hostname(),
@@ -107,7 +113,11 @@ async function executeOp(op: any, context: any) {
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 === "cmd.run") return opOk(opId, name, await cmdRun(args));
if (["debug.build", "debug.download", "debug.reset", "io.uart.read", "io.uart.write", "io.uart.jsonrpc"].includes(name)) {
if (["debug.build", "debug.download", "debug.reset"].includes(name)) {
const output = await debugCommand(name, args);
return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `${name} node-side command exited with ${output.exitCode}`);
}
if (["io.uart.read", "io.uart.write", "io.uart.jsonrpc"].includes(name)) {
return opBlocked(opId, name, "hwpod_node_op_not_configured", `${name} requires node-side tool binding; the thin node contract is present but this local executor has no binding yet`);
}
return opBlocked(opId, name, "unsupported_hwpod_node_op", `unsupported hwpod-node op: ${name}`);
@@ -155,6 +165,14 @@ async function workspaceApplyPatch(args: any) {
return { cwd, changes: await applyPatchEnvelope(cwd, patch) };
}
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`);
const cwd = workspaceRoot(args);
const output = await spawnShellOutput(command, { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 120000 });
return { cwd, command, bindingSource: "hwpod-node-ops.command", ...output };
}
async function applyPatchEnvelope(root: string, patch: string) {
const lines = patch.replace(/\r\n?/gu, "\n").split("\n");
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
@@ -251,6 +269,12 @@ async function cmdRun(args: any) {
return { cwd, command: [command, ...argv], ...output };
}
async function spawnShellOutput(command: string, { cwd, timeoutMs }: any) {
const shellCommand = process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", command] : ["sh", "-lc", command];
const output = await spawnOutput(shellCommand, { cwd, timeoutMs });
return { shell: shellCommand.slice(0, -1), exitCode: output.exitCode, stdout: output.stdout, stderr: output.stderr, ok: output.ok };
}
async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs }: any) {
const proc = Bun.spawn(command, { cwd, stdin: stdinText ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe" });
if (stdinText && proc.stdin) {
@@ -293,7 +317,7 @@ function help() {
status: "succeeded",
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
version: NODE_VERSION,
role: "Thin node-side executor for stable hwpod-node-ops.",
role: "Single host-side executor for stable hwpod-node-ops; hwpod-spec, hwpod-cli, hwpod-ctl, and hwpod-compiler stay in the Code Agent workspace.",
usage: [
"bun tools/hwpod-node.ts run --plan-json '{...}'",
"bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678"
@@ -313,6 +337,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 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 } };
}
function failure(action: string, error: any) {
return { ok: false, action, status: "failed", error: { code: error?.code || "hwpod_node_error", message: error?.message || String(error), details: error?.details || undefined } };
}
+19
View File
@@ -0,0 +1,19 @@
export const HWPOD_NODE_OPS_CONTRACT_VERSION = "hwpod-node-ops-v1";
export const HWPOD_NODE_OPS = new Set([
"node.health",
"node.version",
"node.inventory",
"workspace.ls",
"workspace.cat",
"workspace.rg",
"workspace.apply-patch",
"debug.build",
"debug.download",
"debug.reset",
"io.uart.read",
"io.uart.write",
"io.uart.jsonrpc",
"cmd.run"
]);