feat: add device-pod build verify (#827)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(" "); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user