diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 92cd87f0dbb..4282397e197 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -4,6 +4,7 @@ import { executionPreprocessingMock, executionPreprocessingMockFns, loggingSessionMock, + loggingSessionMockFns, resetDbChainMock, setEnv, workflowAuthzMockFns, @@ -329,6 +330,39 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { }) }) + it('holds the sync response until post-execution logging finalizes', async () => { + let releaseFinalization!: () => void + loggingSessionMockFns.mockWaitForPostExecution.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFinalization = resolve + }) + ) + + let responded = false + const pending = callExecute({ input: { hello: 'world' } }).then((res) => { + responded = true + return res + }) + + // Wait until execution is actually parked at the finalization gate, then + // prove the gate is what holds the response back. + await vi.waitFor(() => + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled() + ) + expect(responded).toBe(false) + + releaseFinalization() + const res = await pending + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toMatchObject({ + runId: 'execution-123', + status: 'completed', + output: { result: 'done' }, + }) + }) + it('streams an immediate heartbeat and the same sync result when NDJSON is accepted', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 5f6d7363863..5423bbe8e1f 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -877,6 +877,11 @@ export async function executeWorkflowService( } finally { abortSignal?.removeEventListener('abort', abortFromRequest) timeoutController.cleanup() + // The sync response doubles as the run's terminal receipt: callers read + // the log (status, endedAt) and cost ledger as soon as it lands. The + // core defers that finalization into the session's post-execution + // promise, so hold the response until the log is final. + await loggingSession.waitForPostExecution() } }