diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts index a610b5b25bd..c0aec0471b4 100644 --- a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -384,6 +384,30 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false }, } }); + it("keeps cursor projection state out of public run responses", async () => { + const fixture = await seedFixture(); + const resultJson = { summary: "Maintenance complete", eventCursorProjectionSeq: "payload value" }; + await db.update(heartbeatRuns).set({ + eventCursorProjectionSeq: 41, + eventCursorRebuildPending: true, + resultJson, + }).where(eq(heartbeatRuns.id, fixture.maintenanceRunId)); + const app = createBoardApp(fixture.companyId, fixture.ownerUserId); + const detail = await request(app).get(`/api/heartbeat-runs/${fixture.maintenanceRunId}`).expect(200); + const history = await request(app).get(`/api/companies/${fixture.companyId}/heartbeat-runs`).expect(200); + const cancelled = await request(app).post(`/api/heartbeat-runs/${fixture.maintenanceRunId}/cancel`).expect(200); + const listed = history.body.find((run: { id: string }) => run.id === fixture.maintenanceRunId); + for (const response of [detail.body, listed, cancelled.body]) { + expect(response).toMatchObject({ id: fixture.maintenanceRunId, status: "succeeded" }); + expect(response).not.toHaveProperty("eventCursorProjectionSeq"); + expect(response).not.toHaveProperty("eventCursorRebuildPending"); + } + expect(detail.body.resultJson).toEqual(resultJson); + expect(cancelled.body.resultJson).toEqual(resultJson); + const [stored] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.maintenanceRunId)); + expect(stored).toMatchObject({ eventCursorProjectionSeq: 41, eventCursorRebuildPending: true, resultJson }); + }); + it("filters private task links from a readable company run while preserving authorized links", async () => { const fixture = await seedFixture(); const publicIssueId = randomUUID(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 756fed8a50e..08508aafaae 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -299,6 +299,7 @@ import { getHeartbeatRunReadDecisions, canActorReadWorkspaceOperation, redactHeartbeatRunListRow, + toPublicHeartbeatRun, } from "../services/heartbeat-run-privacy.js"; import { PaperclipRunnerProviderProfileError, @@ -6330,7 +6331,7 @@ export function agentRoutes( }); } - res.status(202).json(run); + res.status(202).json(toPublicHeartbeatRun(run)); }; router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => { @@ -6455,7 +6456,7 @@ export function agentRoutes( }); } - res.status(202).json(run); + res.status(202).json(toPublicHeartbeatRun(run)); }); router.post("/agents/:id/claude-login", async (req, res) => { @@ -7168,7 +7169,7 @@ export function agentRoutes( run.companyId, run.id, redactCurrentUserValue( - { ...decoratedRun, execution: await executionProjectionForRun(db, run.companyId, run.id), identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) }, + toPublicHeartbeatRun({ ...decoratedRun, execution: await executionProjectionForRun(db, run.companyId, run.id), identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) }), await getCurrentUserRedactionOptions(), ), req.actor.type === "board" ? getActorInfo(req).actorId : null, @@ -7205,7 +7206,7 @@ export function agentRoutes( }); } - res.json(run ? await runRedactions.redactForRun(run.companyId, run.id, run, + res.json(run ? await runRedactions.redactForRun(run.companyId, run.id, toPublicHeartbeatRun(run), getActorInfo(req).actorId) : null); }); diff --git a/server/src/services/heartbeat-run-privacy.ts b/server/src/services/heartbeat-run-privacy.ts index d1cb41a19a2..7e17d7ca177 100644 --- a/server/src/services/heartbeat-run-privacy.ts +++ b/server/src/services/heartbeat-run-privacy.ts @@ -10,6 +10,16 @@ type RunIssueBinding = { issueId?: string | null; }; +/** Keep derived ingestion state out of the public run response contract. */ +export function toPublicHeartbeatRun>(run: T) { + const { + eventCursorProjectionSeq: _eventCursorProjectionSeq, + eventCursorRebuildPending: _eventCursorRebuildPending, + ...response + } = run; + return response; +} + export async function canActorReadHeartbeatRun( db: Db, access: {