feat: wire hwpod target node-ops flow
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user