From 7b2f4fa45fc26faf3682043148b8360b6e7cd7d4 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:23:35 +0800 Subject: [PATCH] fix: wait for device-pod build verify output (#830) Co-authored-by: Codex Agent --- internal/cloud/access-control.test.ts | 54 +++++++++++++++++++++++++++ internal/cloud/access-control.ts | 7 +++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 0a5d8e0f..92f4f678 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -2054,6 +2054,60 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe } }); +test("cloud api waits longer for synchronous device-pod build verify output (HWLAB #821)", async () => { + const executor = createServer(async (request, response) => { + const body = await requestJson(request); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const text = JSON.stringify({ ok: true, action: "workspace.build.verify", data: { buildJob: { jobId: "keil-build-slow" }, artifacts: [], 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 } } + })); + }); + await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); + const executorPort = executor.address().port; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, + HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` + }, + now: () => "2026-05-28T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const userCreate = await postJson(port, "/v1/admin/users", { username: "slow-alice", password: "alice-pass" }, adminLogin.cookie); + await postJson(port, "/v1/admin/device-pods", { + devicePodId: "device-pod-71-freq", + profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } + }, adminLogin.cookie); + await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); + const aliceLogin = await postJson(port, "/auth/login", { username: "slow-alice", password: "alice-pass" }); + + const verifyJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.evidence", args: { kind: "verify", expected: "axf,hex" } }, aliceLogin.cookie); + assert.equal(verifyJob.status, 200); + assert.equal(verifyJob.body.status, "completed"); + const verify = JSON.parse(verifyJob.body.output.text); + assert.equal(verify.action, "workspace.build.verify"); + assert.equal(verify.data.buildJob.jobId, "keil-build-slow"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api bounds device-pod job output payloads", async () => { const longText = "x".repeat(65000); const executor = createServer(async (request, response) => { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index f3d4d77b..4d6a4df8 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -221,7 +221,7 @@ function accessStoreForRuntime(runtimeStore, options = {}) { } class AccessController { - constructor({ store, env = process.env, gatewayRegistry = null, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, devicePodExecutorUrl = env.HWLAB_DEVICE_POD_URL, devicePodExecutorTimeoutMs = 1200, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED) } = {}) { + constructor({ store, env = process.env, gatewayRegistry = null, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, devicePodExecutorUrl = env.HWLAB_DEVICE_POD_URL, devicePodExecutorTimeoutMs = 1200, devicePodSyncTimeoutMs = env.HWLAB_DEVICE_POD_SYNC_TIMEOUT_MS ?? 120000, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED) } = {}) { this.store = store; this.env = env; this.gatewayRegistry = gatewayRegistry; @@ -233,6 +233,7 @@ class AccessController { this.devicePodInternalToken = textOr(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); this.devicePodApiKey = textOr(env.HWLAB_DEVICE_POD_API_KEY, ""); this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200; + this.devicePodSyncTimeoutMs = Number.parseInt(String(devicePodSyncTimeoutMs), 10) || 120000; this.now = now; this.required = required; this.bootstrapAttempted = false; @@ -1587,7 +1588,7 @@ class AccessController { traceId: job.traceId, operationId: job.operationId }) - }, this.devicePodExecutorTimeoutMs); + }, devicePodExecutorCreateTimeoutMs(this, job)); const updated = await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); const status = updated.status; return { job: updated ?? job, httpStatus: response.status >= 400 ? response.status : status === "running" ? 202 : 200 }; @@ -2244,6 +2245,8 @@ function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaV 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 shouldSynchronouslyWaitForDevicePodJob(job) { return job?.intent === "workspace.evidence" && textOr(normalizeObject(job.args).kind, "") === "verify"; } +function devicePodExecutorCreateTimeoutMs(controller, job) { return shouldSynchronouslyWaitForDevicePodJob(job) ? controller.devicePodSyncTimeoutMs : controller.devicePodExecutorTimeoutMs; } 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(" "); }