diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index f5b7734d..aca5821e 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -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); } diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index 5c2726ba..e0ca69d0 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -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") { diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 18af557a..e9487609 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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"); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 44f5002f..962223c9 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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(" "); } diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index fb0d282b..fad48866 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -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 `` | `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 `` | `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` | `:` (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 ]` | 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 ] [--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 ]` | Read-only. Returns the most recent (or specified) keil-download log tail. No `--reason` needed. | | `io-probe` | `:/uart/` | `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 +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 diff --git a/skills/device-pod-cli/assets/device-host-cli.mjs b/skills/device-pod-cli/assets/device-host-cli.mjs index 09f3a94d..4a4d0a6b 100644 --- a/skills/device-pod-cli/assets/device-host-cli.mjs +++ b/skills/device-pod-cli/assets/device-host-cli.mjs @@ -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 [--missing-ok]', 'node tools/device-host-cli.mjs workspace rmdir ', 'node tools/device-host-cli.mjs workspace build clean [--target ]', - '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 ] [--expected axf,bin,hex]', 'node tools/device-host-cli.mjs workspace evidence [kind=build] [jobId] [--tail N] [--full] [--target ]', '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 ]', @@ -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 diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 00cdf000..0c333e78 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -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, [ + "", + " ", + " ", + " FREQ_Controller_FW", + " ", + " ", + " .\\FREQ_Controller_FW\\", + " FREQ_Controller_FW", + " ", + " ", + " ", + " ", + "" + ].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) => { diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index a1cf4ab1..9a0e609a 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -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: :workspace:/ build evidence [jobId] and :debug-probe download evidence [jobId] are read-only; no --reason; tail defaults to 200 lines, --full for entire log", + "evidence: :workspace:/ build evidence [jobId], build verify [--expected axf,bin,hex], and :debug-probe download evidence [jobId] are read-only; no --reason", "io-probe: ports | read | write | jsonrpc | read-after-launch-flash (path = /uart/)", - "common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use :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 :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";