feat: add device-pod build verify (#827)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 16:11:00 +08:00
committed by GitHub
parent c1960f1cbc
commit 9928954ea6
8 changed files with 391 additions and 19 deletions
+58 -4
View File
@@ -346,6 +346,53 @@ test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async
}
});
test("device-pod executor returns build verify synchronously for one-call artifact verification (HWLAB #821)", async () => {
let dispatchedBody = null;
const cloudApi = createServer(async (request, response) => {
dispatchedBody = await requestJson(request);
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: dispatchedBody.id,
result: {
status: "completed",
dispatch: {
dispatchStatus: "completed",
stdout: JSON.stringify({ ok: true, action: "workspace.build.verify", data: { buildJob: { jobId: "keil-job-1" }, artifacts: [], missing: [] } }),
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 response = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
method: "POST",
headers: internalHeaders({ "content-type": "application/json" }),
body: JSON.stringify({
jobId: "job_verify_sync",
intent: "workspace.evidence",
args: { kind: "verify", expected: "axf,hex", headBytes: 16 },
profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } }
})
});
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, "completed");
assert.equal(body.job.id, "job_verify_sync");
const verify = JSON.parse(body.output.text);
assert.equal(verify.action, "workspace.build.verify");
assert.equal(verify.data.buildJob.jobId, "keil-job-1");
assert.match(dispatchedBody.params.input.command, / workspace evidence verify --expected "axf,hex" --head-bytes 16$/u);
} finally {
await service.stop();
await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve())));
}
});
test("device-pod executor maps absorbed G14 device-pod operations to host CLI argv", async () => {
const commands = [];
const cloudApi = createServer(async (request, response) => {
@@ -382,6 +429,12 @@ test("device-pod executor maps absorbed G14 device-pod operations to host CLI ar
path: "User/new.c",
group: "User"
}, "job_map_keil", baseBody);
await submitAndWait(service.port, "workspace.evidence", {
kind: "verify",
expected: "axf,hex",
headBytes: 16,
target: "FREQ_Controller_FW"
}, "job_map_verify", baseBody);
await submitAndWait(service.port, "io.uart.jsonrpc", {
uartId: "uart/1",
method: "gpio.read",
@@ -394,9 +447,10 @@ test("device-pod executor maps absorbed G14 device-pod operations to host CLI ar
assert.match(commands[1], / workspace put User\/new\.c --content-b64 /u);
assert.match(commands[1], / --create-dirs$/u);
assert.match(commands[2], / workspace keil add-source User\/new\.c --base projects\/app --group User$/u);
assert.match(commands[3], / io-probe uart\/1 jsonrpc gpio\.read /u);
assert.match(commands[3], / --params "\{\\"pin\\":\\"PB5\\"\}" /u);
assert.match(commands[3], / --require-jsonrpc-result --expect-result-field pin --expect-result-field value$/u);
assert.match(commands[3], / workspace evidence verify --target FREQ_Controller_FW --expected "axf,hex" --head-bytes 16$/u);
assert.match(commands[4], / io-probe uart\/1 jsonrpc gpio\.read /u);
assert.match(commands[4], / --params "\{\\"pin\\":\\"PB5\\"\}" /u);
assert.match(commands[4], / --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())));
@@ -430,7 +484,7 @@ async function submitAndWait(port, intent, args, jobId, extraBody = {}, expected
headers: internalHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ jobId, intent, args, traceId: `trc_${jobId}`, operationId: `op_${jobId}`, ...extraBody })
});
assert.equal(response.status, 202);
assert.ok([200, 202].includes(response.status), `unexpected job create status ${response.status}`);
return await waitForJobStatus(port, "device-pod-test", jobId, expectedStatus);
}
+19 -4
View File
@@ -185,12 +185,13 @@ async function handleInternalJob(request, response, id) {
const now = new Date().toISOString();
const profile = normalizeObject(body.profile);
const route = normalizeObject(profile.route);
const args = normalizeObject(body.args);
const jobId = typeof body.jobId === "string" && body.jobId.startsWith("job_") ? body.jobId : `job_devicepod_${randomUUID()}`;
const command = deviceHostCommand(profile, {
id: jobId,
devicePodId: id,
intent: typeof body.intent === "string" ? body.intent : "unknown",
args: normalizeObject(body.args)
args
});
const dispatchBlocker = gatewayDispatchPreflightBlocker(route, command);
const job = {
@@ -198,6 +199,7 @@ async function handleInternalJob(request, response, id) {
devicePodId: id,
status: dispatchBlocker ? "blocked" : "running",
intent: typeof body.intent === "string" ? body.intent : "unknown",
args,
reason: typeof body.reason === "string" ? body.reason : "",
traceId,
operationId,
@@ -213,10 +215,20 @@ async function handleInternalJob(request, response, id) {
source: sourcePayload()
};
jobs.set(jobKey(id, job.id), job);
if (!dispatchBlocker && shouldSynchronouslyDispatch(job)) {
const dispatched = await dispatchGatewayJob({ job, route });
const finalJob = dispatched ?? job;
jsonResponse(response, finalJob.status === "completed" ? 200 : 409, jobOutputPayload(finalJob));
return;
}
if (!dispatchBlocker) dispatchGatewayJob({ job, route });
jsonResponse(response, dispatchBlocker ? 409 : 202, jobPayload(job, { accepted: !dispatchBlocker }));
}
function shouldSynchronouslyDispatch(job) {
return job.intent === "workspace.evidence" && textOr(job.args?.kind, "") === "verify";
}
function handleGetInternalJob(request, response, id, subpath) {
if (!isInternalCaller(request)) return rejectExternalJobRoute(response);
const parts = subpath.split("/");
@@ -290,18 +302,21 @@ async function dispatchGatewayJob({ job, route }) {
if (current && terminalJobStatus(current.status)) return;
const next = jobFromGatewayDispatch(job, payload, response.status);
jobs.set(jobKey(job.devicePodId, job.id), next);
return next;
} catch (error) {
const current = jobs.get(jobKey(job.devicePodId, job.id));
if (current && terminalJobStatus(current.status)) return;
const now = new Date().toISOString();
jobs.set(jobKey(job.devicePodId, job.id), {
const next = {
...job,
status: "failed",
updatedAt: now,
completedAt: now,
output: boundedOutput({ text: "", error: error?.message ?? "gateway dispatch failed" }),
blocker: gatewayDispatchFailedBlocker(error?.message ?? "gateway dispatch failed")
});
};
jobs.set(jobKey(job.devicePodId, job.id), next);
return next;
} finally {
clearTimeout(timer);
}
@@ -581,7 +596,7 @@ function deviceHostArgs(intent, args = {}) {
"evidence",
textOr(args.kind, "build"),
...jobIdArgs(args),
...hostOptionArgs(args, ["tail", "full", "path", "target"])
...hostOptionArgs(args, ["tail", "full", "path", "target", "expected", "headBytes"])
];
}
if (intent === "debug.evidence") {