diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index dfa77716..a9692e0f 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -458,9 +458,16 @@ test("cloud api dispatches authorized device jobs to the internal device-pod exe const output = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${runningJob.body.job.id}/output`, aliceLogin.cookie); assert.equal(output.status, 200); assert.equal(output.body.status, "completed"); + assert.equal(output.body.blocker, null); + assert.equal(output.body.freshness.stale, false); assert.equal(output.body.output.text, "executor output"); 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 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"); + assert.equal(completedEvent.blocker, null); } 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()))); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 2b131afb..e733ef68 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -675,8 +675,8 @@ class AccessController { scope: "job", intent: job.intent, status: job.status, - summary: job.blocker?.summary ?? `job ${job.id} ${job.status}`, - blocker: job.blocker, + summary: normalizeBlocker(job.blocker)?.summary ?? `job ${job.id} ${job.status}`, + blocker: normalizeBlocker(job.blocker), refs: jobRefs(job) })); return { @@ -952,6 +952,7 @@ class AccessController { } jobPayload(job, pod, { lease = null } = {}) { + const blocker = normalizeBlocker(job.blocker); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: DEVICE_JOB_CONTRACT_VERSION, @@ -964,8 +965,8 @@ class AccessController { operationId: job.operationId, job: publicJob(job), lease: lease ? publicDeviceLease(lease) : null, - blocker: job.blocker, - freshness: freshness(job.updatedAt, job.blocker), + blocker, + freshness: freshness(job.updatedAt, blocker), outputUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/output`, cancelUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/cancel` }; @@ -1273,10 +1274,11 @@ function pgUser(row) { return row ? { id: row.id, username: row.username, displa function pgSession(row) { return { id: row.id, userId: row.user_id, tokenHash: row.session_token_hash, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, user: { id: row.user_id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.user_created_at, updatedAt: row.user_updated_at } }; } function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.status, profile: parseJson(row.profile_json, {}), profileHash: row.profile_hash, createdAt: row.created_at, updatedAt: row.updated_at }; } function pgLease(row) { return row ? { devicePodId: row.device_pod_id, holderSessionId: row.holder_session_id, holderUserId: row.holder_user_id, leaseTokenHash: row.lease_token_hash, createdAt: row.created_at, expiresAt: row.expires_at, releasedAt: row.released_at } : null; } -function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: parseJson(row.blocker_json, null), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } +function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: normalizeBlocker(parseJson(row.blocker_json, null)), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } function pgAgentSession(row) { return row ? { id: row.id, projectId: row.project_id, agentId: row.agent_id, status: row.status, startedAt: row.started_at, endedAt: row.ended_at, ownerUserId: row.owner_user_id, conversationId: row.conversation_id, threadId: row.thread_id, lastTraceId: row.last_trace_id, session: parseJson(row.session_json, {}), updatedAt: row.updated_at } : null; } function jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; } function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } } +function normalizeBlocker(value) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null; } async function fetchJsonWithTimeout(fetchImpl, url, options, timeoutMs) { const controller = new AbortController();