feat: sync g14 harness and device-pod cli

This commit is contained in:
Codex
2026-05-31 05:07:16 +08:00
parent 1ebbfe2bfe
commit 5bf4bf0f47
17 changed files with 2086 additions and 210 deletions
+82
View File
@@ -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
View File
@@ -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) {