feat: sync g14 harness and device-pod cli
This commit is contained in:
@@ -336,6 +336,88 @@ test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async
|
||||
}
|
||||
});
|
||||
|
||||
test("device-pod executor maps absorbed G14 device-pod operations to host CLI argv", async () => {
|
||||
const commands = [];
|
||||
const cloudApi = createServer(async (request, response) => {
|
||||
const body = await requestJson(request);
|
||||
commands.push(body.params.input.command);
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: body.id,
|
||||
result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } }
|
||||
}));
|
||||
});
|
||||
await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve));
|
||||
const cloudPort = cloudApi.address().port;
|
||||
const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` });
|
||||
const baseBody = {
|
||||
profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } }
|
||||
};
|
||||
|
||||
try {
|
||||
await submitAndWait(service.port, "workspace.put", {
|
||||
path: "User/new.c",
|
||||
contentB64: Buffer.from("int x;\n").toString("base64"),
|
||||
createDirs: true
|
||||
}, "job_map_put", baseBody);
|
||||
await submitAndWait(service.port, "workspace.keil", {
|
||||
action: "add-source",
|
||||
base: "projects/app",
|
||||
path: "User/new.c",
|
||||
group: "User"
|
||||
}, "job_map_keil", baseBody);
|
||||
await submitAndWait(service.port, "io.uart.jsonrpc", {
|
||||
uartId: "uart/1",
|
||||
method: "gpio.read",
|
||||
params: "{\"pin\":\"PB5\"}",
|
||||
requireJsonrpcResult: true,
|
||||
expectResultField: ["pin", "value"]
|
||||
}, "job_map_jsonrpc", baseBody);
|
||||
|
||||
assert.match(commands[0], / workspace put User\/new\.c --content-b64 /u);
|
||||
assert.match(commands[0], / --create-dirs$/u);
|
||||
assert.match(commands[1], / workspace keil add-source User\/new\.c --base projects\/app --group User$/u);
|
||||
assert.match(commands[2], / io-probe uart\/1 jsonrpc gpio\.read /u);
|
||||
assert.match(commands[2], / --params "\{\\"pin\\":\\"PB5\\"\}" /u);
|
||||
assert.match(commands[2], / --require-jsonrpc-result --expect-result-field pin --expect-result-field value$/u);
|
||||
} finally {
|
||||
await service.stop();
|
||||
await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("device-pod executor fails mismatched gateway JSON-RPC response ids", async () => {
|
||||
const cloudApi = createServer(async (_request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: "wrong-id", result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } } }));
|
||||
});
|
||||
await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve));
|
||||
const cloudPort = cloudApi.address().port;
|
||||
const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` });
|
||||
|
||||
try {
|
||||
const output = await submitAndWait(service.port, "workspace.ls", { path: "." }, "job_bad_id", {
|
||||
profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } }
|
||||
}, "failed");
|
||||
assert.equal(output.status, "failed");
|
||||
assert.match(output.blocker.summary, /JSON-RPC id mismatch/u);
|
||||
} finally {
|
||||
await service.stop();
|
||||
await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
async function submitAndWait(port, intent, args, jobId, extraBody = {}, expectedStatus = "completed") {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-test/jobs`, {
|
||||
method: "POST",
|
||||
headers: internalHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ jobId, intent, args, traceId: `trc_${jobId}`, operationId: `op_${jobId}`, ...extraBody })
|
||||
});
|
||||
assert.equal(response.status, 202);
|
||||
return await waitForJobStatus(port, "device-pod-test", jobId, expectedStatus);
|
||||
}
|
||||
|
||||
async function startDevicePod(devicePodId, extraEnv = {}) {
|
||||
const port = await freePort();
|
||||
const child = spawn(bunCommand, ["run", "cmd/hwlab-device-pod/main.ts"], {
|
||||
|
||||
+164
-10
@@ -252,6 +252,7 @@ function handleCancelInternalJob(request, response, id, subpath) {
|
||||
|
||||
async function dispatchGatewayJob({ job, route }) {
|
||||
const target = `${cloudApiInternalUrl}/v1/internal/device-pod/gateway-dispatch`;
|
||||
const requestId = `req_${job.id}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), dispatchTimeoutMs + 10000);
|
||||
try {
|
||||
@@ -264,10 +265,10 @@ async function dispatchGatewayJob({ job, route }) {
|
||||
"x-hwlab-internal-service": SERVICE_ID,
|
||||
"x-hwlab-internal-token": internalToken,
|
||||
"x-trace-id": job.traceId,
|
||||
"x-request-id": `req_${job.id}`
|
||||
"x-request-id": requestId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: `req_${job.id}`,
|
||||
id: requestId,
|
||||
actorId: job.ownerUserId || "svc_hwlab-device-pod",
|
||||
params: {
|
||||
projectId: "prj_v02_device_pod",
|
||||
@@ -383,6 +384,12 @@ function gatewayAdapterState() {
|
||||
}
|
||||
|
||||
function jobFromGatewayDispatch(job, payload, httpStatus) {
|
||||
const expectedId = `req_${job.id}`;
|
||||
if (payload && Object.hasOwn(payload, "id") && String(payload.id) !== expectedId) {
|
||||
const blocker = gatewayDispatchFailedBlocker(`gateway JSON-RPC id mismatch: expected ${expectedId}, got ${String(payload.id)}`);
|
||||
const now = new Date().toISOString();
|
||||
return { ...job, status: "failed", updatedAt: now, completedAt: now, output: boundedOutput({ text: "", httpStatus, summary: blocker.summary }), blocker };
|
||||
}
|
||||
const result = normalizeObject(payload.result);
|
||||
const dispatch = normalizeObject(result.dispatch);
|
||||
const blocker = payload.blocker ?? result.blocker ?? (payload.error ? gatewayDispatchFailedBlocker(payload.error.message ?? "gateway dispatch failed") : null);
|
||||
@@ -470,19 +477,151 @@ function deviceHostArgs(intent, args = {}) {
|
||||
const pattern = textOr(args.pattern ?? args.query, "");
|
||||
return pattern ? ["workspace", "rg", pattern, textOr(args.path, "."), ...hostOptionArgs(args, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])] : null;
|
||||
}
|
||||
if (intent === "workspace.put") {
|
||||
const path = textOr(args.path, "");
|
||||
return path
|
||||
? ["workspace", "put", path, ...hostOptionArgs(args, [
|
||||
"contentB64",
|
||||
"textB64",
|
||||
"text",
|
||||
"encoding",
|
||||
"charset",
|
||||
"createOnly",
|
||||
"createDirs",
|
||||
"updateOnly"
|
||||
])]
|
||||
: null;
|
||||
}
|
||||
if (intent === "workspace.rm") {
|
||||
const path = textOr(args.path, "");
|
||||
return path ? ["workspace", "rm", path, ...hostOptionArgs(args, ["missingOk"])] : null;
|
||||
}
|
||||
if (intent === "workspace.rmdir") {
|
||||
const path = textOr(args.path, "");
|
||||
return path ? ["workspace", "rmdir", path] : null;
|
||||
}
|
||||
if (intent === "workspace.keil") {
|
||||
const action = textOr(args.action, "");
|
||||
return action
|
||||
? [
|
||||
"workspace",
|
||||
"keil",
|
||||
action,
|
||||
...optionalValue(args.path),
|
||||
...hostOptionArgs(args, ["base", "group", "groupName", "target", "timeoutMs"])
|
||||
]
|
||||
: null;
|
||||
}
|
||||
if (intent === "workspace.apply-patch") return ["workspace", "apply-patch", textOr(args.base, "."), "--patch-b64", patchBase64(args)];
|
||||
if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs"]), ...jobIdArgs(args)];
|
||||
if (intent === "workspace.build") {
|
||||
return [
|
||||
"workspace",
|
||||
"build",
|
||||
textOr(args.action, "start"),
|
||||
...hostOptionArgs(args, ["target", "timeoutMs", "clean", "dryRun"]),
|
||||
...jobIdArgs(args)
|
||||
];
|
||||
}
|
||||
if (intent === "debug.status") return ["debug-probe", "status"];
|
||||
if (intent === "debug.chip-id") return ["debug-probe", "chip-id"];
|
||||
if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"]), ...jobIdArgs(args)];
|
||||
if (intent === "debug.download") {
|
||||
return [
|
||||
"debug-probe",
|
||||
"download",
|
||||
textOr(args.action, "start"),
|
||||
...hostOptionArgs(args, [
|
||||
"target",
|
||||
"timeoutMs",
|
||||
"captureUart",
|
||||
"captureDurationMs",
|
||||
"durationMs",
|
||||
"port",
|
||||
"baudRate"
|
||||
]),
|
||||
...jobIdArgs(args)
|
||||
];
|
||||
}
|
||||
if (intent === "debug.reset") return ["debug-probe", "reset"];
|
||||
if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"];
|
||||
if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000)), ...hostOptionArgs(args, ["port", "baudRate"])] ;
|
||||
if (intent === "io.uart.read-after-launch-flash") return ["io-probe", textOr(args.uartId, "uart/1"), "read-after-launch-flash", ...hostOptionArgs(args, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])] ;
|
||||
if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), ...hostOptionArgs(args, ["port", "baudRate"]), textOr(args.message ?? args.text ?? args.data, "")];
|
||||
if (intent === "io.uart.read") {
|
||||
return [
|
||||
"io-probe",
|
||||
textOr(args.uartId, "uart/1"),
|
||||
"read",
|
||||
"--duration-ms",
|
||||
String(numberOr(args.durationMs ?? args["duration-ms"], 1000)),
|
||||
...hostOptionArgs(args, ["port", "baudRate"])
|
||||
];
|
||||
}
|
||||
if (intent === "io.uart.read-after-launch-flash") {
|
||||
return [
|
||||
"io-probe",
|
||||
textOr(args.uartId, "uart/1"),
|
||||
"read-after-launch-flash",
|
||||
...hostOptionArgs(args, uartLaunchFlashOptionKeys)
|
||||
];
|
||||
}
|
||||
if (intent === "io.uart.write") {
|
||||
return [
|
||||
"io-probe",
|
||||
textOr(args.uartId, "uart/1"),
|
||||
"write",
|
||||
...(args.hex ? ["--hex"] : []),
|
||||
...hostOptionArgs(args, ["port", "baudRate"]),
|
||||
textOr(args.message ?? args.text ?? args.data, "")
|
||||
];
|
||||
}
|
||||
if (intent === "io.uart.jsonrpc") {
|
||||
return [
|
||||
"io-probe",
|
||||
textOr(args.uartId, "uart/1"),
|
||||
"jsonrpc",
|
||||
...optionalValue(args.method),
|
||||
...hostOptionArgs(args, uartJsonRpcOptionKeys)
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const uartLaunchFlashOptionKeys = [
|
||||
"durationMs",
|
||||
"port",
|
||||
"baudRate",
|
||||
"flashBase",
|
||||
"skipHardwareReset",
|
||||
"connectMode",
|
||||
"timeoutMs"
|
||||
];
|
||||
const uartJsonRpcOptionKeys = [
|
||||
"id",
|
||||
"request",
|
||||
"requestB64",
|
||||
"params",
|
||||
"paramsB64",
|
||||
"responseTimeoutMs",
|
||||
"durationMs",
|
||||
"retry",
|
||||
"retries",
|
||||
"retryDelayMs",
|
||||
"lineEnding",
|
||||
"noNewline",
|
||||
"lineDelimited",
|
||||
"discardBefore",
|
||||
"requireResponse",
|
||||
"requireJson",
|
||||
"requireJsonrpc",
|
||||
"requireJsonrpcResult",
|
||||
"allowIdMismatch",
|
||||
"expectResultField",
|
||||
"port",
|
||||
"baudRate"
|
||||
];
|
||||
|
||||
function optionalValue(value) {
|
||||
const result = textOr(value, "");
|
||||
return result ? [result] : [];
|
||||
}
|
||||
|
||||
function jobIdArgs(args = {}) {
|
||||
const jobId = textOr(args.jobId, "");
|
||||
return jobId ? [jobId] : [];
|
||||
@@ -494,8 +633,12 @@ function hostOptionArgs(args = {}, keys = []) {
|
||||
const value = args[key];
|
||||
if (value === undefined || value === null || value === "" || value === false) continue;
|
||||
const name = `--${key.replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`)}`;
|
||||
if (value === true) out.push(name);
|
||||
else out.push(name, String(value));
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
if (item === undefined || item === null || item === "" || item === false) continue;
|
||||
if (item === true) out.push(name);
|
||||
else out.push(name, String(item));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -551,7 +694,18 @@ function jobOutputPayload(job) {
|
||||
}
|
||||
|
||||
function publicJob(job) {
|
||||
return { id: job.id, devicePodId: job.devicePodId, status: job.status, intent: job.intent, reason: job.reason, traceId: job.traceId, operationId: job.operationId, createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt };
|
||||
return {
|
||||
id: job.id,
|
||||
devicePodId: job.devicePodId,
|
||||
status: job.status,
|
||||
intent: job.intent,
|
||||
reason: job.reason,
|
||||
traceId: job.traceId,
|
||||
operationId: job.operationId,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
completedAt: job.completedAt
|
||||
};
|
||||
}
|
||||
|
||||
function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) {
|
||||
|
||||
Reference in New Issue
Block a user