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") {
+35
View File
@@ -1942,6 +1942,29 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe
}));
return;
}
if (request.method === "POST" && body.intent === "workspace.evidence" && body.args?.kind === "verify") {
const text = JSON.stringify({
ok: true,
action: "workspace.build.verify",
data: {
buildJob: { jobId: "keil-build-latest", status: "completed", success: true },
artifacts: [{ kind: "hex", exists: true, size: 42, headMagicB64: "Og==" }],
missing: []
}
});
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({
accepted: true,
status: "completed",
contractVersion: "device-pod-executor-v1",
devicePodId: "device-pod-71-freq",
traceId: body.traceId,
operationId: body.operationId,
job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId },
output: { text, bytes: Buffer.byteLength(text, "utf8"), truncation: { maxBytes: 12000, truncated: false } }
}));
return;
}
response.writeHead(409, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({
accepted: false,
@@ -2009,6 +2032,18 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe
assert.equal(output.body.truncation.truncated, false);
assert.ok(executorRequests.some((item) => item.method === "GET" && item.url.endsWith(`/jobs/${runningJob.body.job.id}/output`)));
const verifyJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.evidence", args: { kind: "verify", expected: "axf,hex", headBytes: 16 } }, aliceLogin.cookie);
assert.equal(verifyJob.status, 200);
assert.equal(verifyJob.body.status, "completed");
assert.equal(verifyJob.body.job.intent, "workspace.evidence");
assert.equal(verifyJob.body.outputUrl.endsWith("/output"), true);
const verify = JSON.parse(verifyJob.body.output.text);
assert.equal(verify.action, "workspace.build.verify");
assert.equal(verify.data.buildJob.jobId, "keil-build-latest");
assert.equal(verify.data.artifacts[0].headMagicB64, "Og==");
assert.equal(verifyJob.body.truncation.truncated, false);
assert.equal(executorRequests.some((item) => item.method === "GET" && item.url.endsWith(`/jobs/${verifyJob.body.job.id}/output`)), false);
const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceLogin.cookie);
const completedEvent = events.body.events.find((event) => event.refs.jobId === runningJob.body.job.id);
assert.equal(completedEvent.status, "completed");
+5 -1
View File
@@ -1535,7 +1535,10 @@ class AccessController {
const dispatched = this.devicePodExecutorUrl
? await this.dispatchDevicePodExecutorJob({ job, pod, actor })
: await this.blockDevicePodJobWithoutExecutor({ job, pod });
sendJson(response, dispatched.httpStatus, this.jobPayload(dispatched.job, pod));
const payload = shouldReturnDevicePodCreateOutput(dispatched.job)
? this.jobOutputPayload(dispatched.job, pod)
: this.jobPayload(dispatched.job, pod);
sendJson(response, dispatched.httpStatus, payload);
}
async rejectDevicePodJob(response, pod, actor, { httpStatus, intent, reason = "", args = {}, blocker }) {
@@ -2240,6 +2243,7 @@ function publicDevicePod(pod, { includeAdmin = false } = {}) { return { devicePo
function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaVersion ?? null, target: { id: profile.target?.id ?? null }, projectWorkspace: { projectPath: profile.projectWorkspace?.projectPath ?? null, targetName: profile.projectWorkspace?.targetName ?? null, hexPath: profile.projectWorkspace?.hexPath ?? null }, debugInterface: { type: profile.debugInterface?.type ?? null }, ioInterface: { uartCount: Array.isArray(profile.ioInterface?.uart) ? profile.ioInterface.uart.length : 0 }, route: { configured: Boolean(profile.route?.gatewaySessionId), gatewaySessionId: "redacted", resourceId: profile.route?.resourceId ? "redacted" : null, capabilityId: profile.route?.capabilityId ? "redacted" : null } }; }
function targetIdFromProfile(profile = {}) { return profile.target?.id ?? profile.targetId ?? null; }
function terminalJobStatus(status) { return ["completed", "failed", "blocked", "canceled"].includes(status); }
function shouldReturnDevicePodCreateOutput(job) { return terminalJobStatus(job?.status) && job?.intent === "workspace.evidence" && textOr(normalizeObject(job.args).kind, "") === "verify"; }
function publicJob(job) { return { id: job.id, devicePodId: job.devicePodId, ownerUserId: job.ownerUserId, 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 jobRefs(job) { return { jobId: job.id, traceId: job.traceId, operationId: job.operationId }; }
function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", event.scope?.toUpperCase() ?? "JOB", event.status, event.intent, event.summary, event.refs?.traceId ? `trace=${event.refs.traceId}` : null, event.blocker?.code ? `blocker=${event.blocker.code}` : null].filter(Boolean).join(" "); }
+5 -1
View File
@@ -47,9 +47,10 @@ listed, that is a missing intent - file an issue instead of guessing.
| Surface | Selector path | Allowed operations (intent) | Notes |
| --- | --- | --- | --- |
| `workspace` | `/` or `<relpath>` | `ls`, `cat`, `rg`, `bootsharp`, `apply-patch`, `put`, `rm`, `rmdir`, `keil add-source`, `keil remove-source`, `build start\|status\|output\|cancel\|wait` | `keil` is for **project-file maintenance only**. Compile/link goes through `build start`, not through `keil`. |
| `workspace` | `/` or `<relpath>` | `ls`, `cat`, `rg`, `bootsharp`, `apply-patch`, `put`, `rm`, `rmdir`, `keil add-source`, `keil remove-source`, `build start\|status\|output\|cancel\|wait\|verify` | `keil` is for **project-file maintenance only**. Compile/link goes through `build start`, not through `keil`. `build verify` is read-only and returns latest build job plus artifact stats. |
| `debug-probe` | `:<probe>` (default `/`) | `status`, `chip-id`, `download start\|status\|output\|cancel\|wait`, `reset` | Firmware flash is `download`, not `flash`. There is no `debug-probe flash` op. |
| `workspace.evidence` | `/` | `build evidence [jobId] [--tail N] [--full] [--target <TargetName>]` | Read-only. Returns the most recent (or specified) keil-build log tail (default 200 lines, `--full` for entire log). No `--reason` needed. |
| `workspace.build.verify` | `/` | `build verify [jobId] [--target <TargetName>] [--expected axf,bin,hex] [--head-bytes N]` | Read-only. Returns the latest (or specified) keil-build result plus `.axf` / `.bin` / `.hex` artifact stat and head magic. No `--reason` needed. |
| `debug-probe.evidence` | `/` | `download evidence [jobId] [--tail N] [--full] [--target <TargetName>]` | Read-only. Returns the most recent (or specified) keil-download log tail. No `--reason` needed. |
| `io-probe` | `:/uart/<n>` | `read`, `write`, `jsonrpc`, `read-after-launch-flash`, `ports` | UART id is a strict path token (`/uart/1`); do not split it across argv. |
@@ -206,6 +207,9 @@ hwpod \
hwpod \
device-pod-71-freq:workspace:/ build start \
--reason "build smoke" --lease-token <lease-token>
hwpod \
device-pod-71-freq:workspace:/ build verify \
--expected axf,bin,hex
hwpod \
device-pod-71-freq:debug-probe download start \
--reason "DEV smoke" --lease-token <lease-token>
@@ -245,6 +245,11 @@ function hexPath(profile) {
return resolveWorkspacePath(profile, profile.projectWorkspace?.hexPath);
}
function profileHexPath(profile) {
const raw = profile.projectWorkspace?.hexPath;
return raw ? resolveWorkspacePath(profile, raw) : null;
}
function shortTail(text, max = 12000) {
const value = String(text || '');
return value.length <= max ? value : value.slice(value.length - max);
@@ -976,6 +981,128 @@ function readJobEvidence(kindPrefix, requestedId, { tail = 200, full = false, ta
});
}
const BUILD_VERIFY_KINDS = new Set(['axf', 'bin', 'hex']);
function expectedArtifactKinds(options = {}) {
const explicit = optionList(options, 'expected', 'expected-artifacts', 'expectedArtifacts')
.flatMap((item) => String(item).split(','))
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
const kinds = explicit.length > 0 ? explicit : ['axf', 'bin', 'hex'];
const unique = [];
for (const kind of kinds) {
if (!BUILD_VERIFY_KINDS.has(kind)) throw new Error(`unsupported build artifact kind: ${kind}. Use axf, bin, or hex.`);
if (!unique.includes(kind)) unique.push(kind);
}
return unique;
}
function keilOutputPlan(profile, project, target) {
const projectText = fs.readFileSync(project, 'utf8');
const targetBlock = selectedKeilTarget(projectText, target);
const outputName = xmlTextValue(targetBlock.text, 'OutputName') || targetBlock.targetName || target || path.basename(project, path.extname(project));
const outputDirRaw = xmlTextValue(targetBlock.text, 'OutputDirectory');
const profileHex = profileHexPath(profile);
const outputDir = outputDirRaw
? resolveKeilDirectory(profile, project, outputDirRaw)
: profileHex
? path.dirname(profileHex)
: path.dirname(project);
return { target: targetBlock.targetName || target || '', outputName, outputDir, profileHex };
}
function buildArtifactPath(kind, plan) {
if (kind === 'hex' && plan.profileHex) return plan.profileHex;
return path.join(plan.outputDir, `${plan.outputName}.${kind}`);
}
function artifactMagic(kind, head) {
if (kind === 'axf') return { expected: 'elf', ok: head.length > 0 && head[0] === 0x7f };
if (kind === 'hex') return { expected: 'intel-hex', ok: head.length > 0 && head[0] === 0x3a };
return { expected: null, ok: true };
}
function artifactStat(kind, file, headByteLimit) {
const exists = fs.existsSync(file);
if (!exists) return { kind, path: file, exists: false, bytes: 0, headBytes: 0, headMagicB64: null, magic: artifactMagic(kind, Buffer.alloc(0)) };
const stat = fs.statSync(file);
const headBytes = Math.min(headByteLimit, stat.size);
const handle = fs.openSync(file, 'r');
let head = Buffer.alloc(0);
try {
head = Buffer.alloc(headBytes);
if (headBytes > 0) fs.readSync(handle, head, 0, headBytes, 0);
} finally {
fs.closeSync(handle);
}
return { kind, path: file, exists: true, bytes: stat.size, headBytes, headMagicB64: headBytes > 0 ? head.toString('base64') : null, magic: artifactMagic(kind, head), mtimeMs: stat.mtimeMs };
}
function artifactMissingReasons(artifacts) {
const missing = [];
for (const artifact of artifacts) {
if (!artifact.exists) missing.push({ kind: artifact.kind, path: artifact.path, reason: 'not-found' });
else if (artifact.bytes <= 0) missing.push({ kind: artifact.kind, path: artifact.path, reason: 'zero-bytes' });
else if (artifact.magic && artifact.magic.ok === false) missing.push({ kind: artifact.kind, path: artifact.path, reason: `magic-mismatch:${artifact.magic.expected}` });
}
return missing;
}
function buildJobSnapshot(profile, requestedId, project, target) {
const id = requestedId || latestJobId(profile, 'keil-build');
if (!id) return null;
const job = readJson(jobFile(profile, id));
const result = job.result || {};
return {
jobId: id,
status: job.status || null,
success: result.success === undefined ? null : Boolean(result.success),
exitCode: result.exitCode ?? null,
elapsedMs: result.elapsedMs ?? null,
project: result.project || project,
target: result.target || job.payload?.target || target || null,
logFile: result.logFile || job.logFile || null,
buildSummary: result.buildSummary || null,
createdAt: job.createdAt || null,
updatedAt: job.updatedAt || null
};
}
function verifyKeilBuildArtifacts(options = {}, requestedId) {
const profile = loadProfile();
const project = projectPath(profile);
const target = optionValue(options, 'target', 't') || targetName(profile);
const headBytes = parsePositiveInt(optionValue(options, 'head-bytes', 'headBytes'), 16, 'verify head bytes', 0, 64);
const expected = expectedArtifactKinds(options);
const projectXml = validateXmlFile(project);
if (!projectXml.ok) {
emit('workspace.build.verify', { project, target, projectXml, buildJob: null, artifacts: [], missing: [], fromProfile: { hexPath: profile.projectWorkspace?.hexPath || null, expectedArtifacts: expected } }, false, 1);
return;
}
const plan = keilOutputPlan(profile, project, target);
const buildJob = buildJobSnapshot(profile, requestedId, project, plan.target || target);
const artifacts = expected.map((kind) => artifactStat(kind, buildArtifactPath(kind, plan), headBytes));
const missing = artifactMissingReasons(artifacts);
const buildJobOk = Boolean(buildJob && buildJob.status === 'completed' && buildJob.success === true);
const okValue = buildJobOk && missing.length === 0;
const blockers = [];
if (!buildJob) blockers.push({ code: 'keil_build_job_not_found', summary: 'no keil-build job found' });
else if (!buildJobOk) blockers.push({ code: 'keil_build_job_not_successful', summary: `keil-build job ${buildJob.jobId} is ${buildJob.status || 'unknown'} success=${buildJob.success}` });
if (missing.length > 0) blockers.push({ code: 'build_artifact_missing', summary: `${missing.length} expected artifact(s) missing or invalid` });
emit('workspace.build.verify', {
project,
target: plan.target || target || null,
buildJob,
artifacts,
missing,
blockers,
fromProfile: {
hexPath: profile.projectWorkspace?.hexPath || null,
expectedArtifacts: expected
}
}, okValue, okValue ? 0 : 1);
}
async function runJob(jobId) {
const profile = loadProfile();
const file = jobFile(profile, jobId);
@@ -1938,7 +2065,7 @@ function usage() {
'node tools/device-host-cli.mjs workspace rm <file> [--missing-ok]',
'node tools/device-host-cli.mjs workspace rmdir <empty-dir>',
'node tools/device-host-cli.mjs workspace build clean [--target <TargetName>]',
'node tools/device-host-cli.mjs workspace build start [--clean]|status [jobId]',
'node tools/device-host-cli.mjs workspace build start [--clean]|status [jobId]|verify [--target <TargetName>] [--expected axf,bin,hex]',
'node tools/device-host-cli.mjs workspace evidence [kind=build] [jobId] [--tail N] [--full] [--target <TargetName>]',
'node tools/device-host-cli.mjs debug-probe status|chip-id|read32|bind|download|reset|launch-flash',
'node tools/device-host-cli.mjs debug-probe evidence [kind=download] [jobId] [--tail N] [--full] [--target <TargetName>]',
@@ -1982,11 +2109,16 @@ async function main() {
if (sub === 'start') return startJob('keil-build', parseArgs(rest.slice(1)));
if (sub === 'status') return readJobStatus('keil-build', rest[1]);
if (sub === 'wait') return readJobStatus('keil-build', rest[1]);
if (sub === 'verify') {
const opts = parseArgs(rest.slice(1));
return verifyKeilBuildArtifacts(opts, opts._[0]);
}
}
if (command === 'evidence') {
const sub = rest[0] || 'build';
const opts = parseArgs(rest.slice(1));
return readJobEvidence(sub === 'download' ? 'keil-download' : 'keil-build', rest[1], {
if (sub === 'verify') return verifyKeilBuildArtifacts(opts, opts._[0]);
return readJobEvidence(sub === 'download' ? 'keil-download' : 'keil-build', opts._[0], {
tail: parsePositiveInt(opts.tail, 200),
full: opts.full === true || opts.full === 'true',
target: opts.target
@@ -2005,7 +2137,7 @@ async function main() {
}
if (command === 'evidence') {
const opts = parseArgs(rest.slice(1));
return readJobEvidence('keil-download', rest[1], {
return readJobEvidence('keil-download', opts._[0], {
tail: parsePositiveInt(opts.tail, 200),
full: opts.full === true || opts.full === 'true',
target: opts.target
+127
View File
@@ -20,6 +20,24 @@ function runAssembledDevicePodCli(argv: string[], options: any = {}) {
return runDevicePodCli(argv, { ...options, env: { ...ASSEMBLED_RUNTIME_ENV, ...(options.env ?? {}) } });
}
async function runHostCli(root: string, profile: any, args: string[]) {
const profileJsonB64 = Buffer.from(JSON.stringify(profile), "utf8").toString("base64");
const proc = Bun.spawn([
process.env.HWLAB_TEST_NODE_COMMAND || "node",
path.join(process.cwd(), "skills/device-pod-cli/assets/device-host-cli.mjs"),
"--profile-json-b64",
profileJsonB64,
"--pod-id",
profile.devicePodId || "device-pod-test",
...args
], { cwd: root, stdout: "pipe", stderr: "pipe" });
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
assert.equal(stderr, "");
return { exitCode, payload: JSON.parse(stdout) };
}
test("device-pod-cli first-admin setup can seed a cloud-api device pod without local profile authority", async () => {
const seen: any[] = [];
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
@@ -504,6 +522,115 @@ test("device-pod-cli maps build evidence / download evidence to first-class work
});
});
test("device-pod-cli maps build verify to read-only workspace.evidence verify intent (HWLAB #821)", async () => {
const seen: any[] = [];
const fetchImpl = async (url, init) => {
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(202, { ok: true, status: "running", job: { id: `job_${seen.length}`, status: "running" } });
};
const result = await runAssembledDevicePodCli([
"D601-71-FREQ:workspace:/",
"build",
"verify",
"--target",
"FREQ_Controller_FW",
"--expected",
"axf,hex",
"--head-bytes",
"16"
], { fetchImpl, now: () => "2026-06-04T00:00:00.000Z" });
assert.equal(result.exitCode, 0);
assert.deepEqual(seen[0].body, {
intent: "workspace.evidence",
args: {
kind: "verify",
target: "FREQ_Controller_FW",
expected: "axf,hex",
headBytes: 16
}
});
assert.equal(JSON.stringify(seen[0].body).includes("reason"), false);
});
test("device-host-cli build verify returns artifact stats, missing artifacts, and no-job blocker (HWLAB #821)", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-host-verify-"));
const projectRel = path.join("projects", "71-00075-11", "FirmWare", "MDK-ARM", "FREQ_Controller_FW.uvprojx");
const project = path.join(root, projectRel);
const outputDir = path.join(path.dirname(project), "FREQ_Controller_FW");
await mkdir(outputDir, { recursive: true });
await writeFile(project, [
"<Project>",
" <Targets>",
" <Target>",
" <TargetName>FREQ_Controller_FW</TargetName>",
" <TargetOption>",
" <TargetCommonOption>",
" <OutputDirectory>.\\FREQ_Controller_FW\\</OutputDirectory>",
" <OutputName>FREQ_Controller_FW</OutputName>",
" </TargetCommonOption>",
" </TargetOption>",
" </Target>",
" </Targets>",
"</Project>"
].join("\n"));
await writeFile(path.join(outputDir, "FREQ_Controller_FW.axf"), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1, 0]));
await writeFile(path.join(outputDir, "FREQ_Controller_FW.bin"), Buffer.from([1, 2, 3, 4]));
await writeFile(path.join(outputDir, "FREQ_Controller_FW.hex"), ":020000040800F2\n");
const jobsDir = path.join(root, ".device-pod", ".state", "device-host-cli", "jobs");
await mkdir(jobsDir, { recursive: true });
await writeFile(path.join(jobsDir, "20260604053140522-keil-build-09e72a.json"), JSON.stringify({
jobId: "20260604053140522-keil-build-09e72a",
kind: "keil-build",
status: "completed",
createdAt: "2026-06-04T05:31:40.522Z",
updatedAt: "2026-06-04T05:31:56.266Z",
result: {
success: true,
exitCode: 0,
elapsedMs: 15744,
project,
target: "FREQ_Controller_FW",
buildSummary: { hasSummary: true, errors: 0, warnings: 0, hasErrors: false }
}
}, null, 2));
const profile = {
devicePodId: "D601-71-FREQ",
workspaceRoot: root,
projectWorkspace: {
projectPath: projectRel,
targetName: "FREQ_Controller_FW",
hexPath: path.join("projects", "71-00075-11", "FirmWare", "MDK-ARM", "FREQ_Controller_FW", "FREQ_Controller_FW.hex")
}
};
const happy = await runHostCli(root, profile, ["workspace", "build", "verify", "--expected", "axf,bin,hex"]);
assert.equal(happy.exitCode, 0);
assert.equal(happy.payload.ok, true);
assert.equal(happy.payload.action, "workspace.build.verify");
assert.equal(happy.payload.data.buildJob.jobId, "20260604053140522-keil-build-09e72a");
assert.deepEqual(happy.payload.data.missing, []);
assert.deepEqual(happy.payload.data.artifacts.map((artifact) => artifact.kind), ["axf", "bin", "hex"]);
assert.equal(Buffer.from(happy.payload.data.artifacts[0].headMagicB64, "base64")[0], 0x7f);
await writeFile(path.join(outputDir, "FREQ_Controller_FW.bin"), Buffer.alloc(0));
const missing = await runHostCli(root, profile, ["workspace", "build", "verify", "--expected", "bin"]);
assert.equal(missing.exitCode, 1);
assert.equal(missing.payload.ok, false);
assert.deepEqual(missing.payload.data.missing, [{ kind: "bin", path: path.join(outputDir, "FREQ_Controller_FW.bin"), reason: "zero-bytes" }]);
const emptyRoot = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-host-verify-no-job-"));
const emptyProject = path.join(emptyRoot, projectRel);
const emptyOutputDir = path.join(path.dirname(emptyProject), "FREQ_Controller_FW");
await mkdir(emptyOutputDir, { recursive: true });
await writeFile(emptyProject, await Bun.file(project).text());
await writeFile(path.join(emptyOutputDir, "FREQ_Controller_FW.axf"), Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
const noJob = await runHostCli(emptyRoot, { ...profile, workspaceRoot: emptyRoot }, ["workspace", "build", "verify", "--expected", "axf"]);
assert.equal(noJob.exitCode, 1);
assert.equal(noJob.payload.ok, false);
assert.equal(noJob.payload.data.buildJob, null);
assert.equal(noJob.payload.data.blockers[0].code, "keil_build_job_not_found");
});
test("device-pod-cli keeps build start / download start on workspace.build / debug.download intents (HWLAB #801 regression)", async () => {
const seen: any[] = [];
const fetchImpl = async (url, init) => {
+7 -6
View File
@@ -72,6 +72,7 @@ function help() {
"hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT",
"hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT",
"hwpod device-pod-71-freq:workspace:/ build start --reason TEXT",
"hwpod device-pod-71-freq:workspace:/ build verify [--target TargetName] [--expected axf,bin,hex]",
"hwpod device-pod-71-freq:workspace:/ build evidence [jobId] [--tail N] [--full]",
"hwpod device-pod-71-freq:debug-probe download evidence [jobId] [--tail N] [--full]",
"hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\"}' --reason TEXT",
@@ -79,11 +80,11 @@ function help() {
],
startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path.",
selectorCheatSheet: [
"workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait,evidence}",
"workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait,evidence,verify}",
"debug-probe: status | chip-id | download {start,status,output,cancel,wait,evidence} | reset (no flash op; firmware flash = download)",
"evidence: <pod>:workspace:/ build evidence [jobId] and <pod>:debug-probe download evidence [jobId] are read-only; no --reason; tail defaults to 200 lines, --full for entire log",
"evidence: <pod>:workspace:/ build evidence [jobId], build verify [--expected axf,bin,hex], and <pod>:debug-probe download evidence [jobId] are read-only; no --reason",
"io-probe: ports | read | write | jsonrpc | read-after-launch-flash (path = /uart/<n>)",
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>:debug-probe download start; build status/output/wait/cancel/evidence do NOT need --reason (read-only)",
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>:debug-probe download start; build status/output/wait/cancel/evidence/verify do NOT need --reason (read-only)",
"full table: skills/device-pod-cli/SKILL.md Selector Cheat Sheet"
]
});
@@ -299,11 +300,11 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed
Object.assign(args, passthroughOptions(parsed, ["encoding", "charset", "createDirs"]));
}
else if (operation === "build") {
if (text(rest[1]) === "evidence") {
if (text(rest[1]) === "evidence" || text(rest[1]) === "verify") {
intent = "workspace.evidence";
args.kind = "build";
args.kind = text(rest[1]) === "verify" ? "verify" : "build";
args.jobId = text(rest[2] ?? parsed.jobId);
Object.assign(args, passthroughOptions(parsed, ["tail", "full", "target"]));
Object.assign(args, passthroughOptions(parsed, ["tail", "full", "target", "expected", "headBytes"]));
} else {
intent = "workspace.build";
args.action = rest[1] || "start";