Skip to content

Commit 4dd8a14

Browse files
authored
fix(mothership): surface the real run error for browser-settled workflow tools (#9634)
* fix(mothership): surface the real run error for browser-settled workflow tools * fix(mothership): clean up run logs and snapshots before the workspace in the test
1 parent ca7acbc commit 4dd8a14

2 files changed

Lines changed: 169 additions & 2 deletions

File tree

‎apps/sim/lib/workflows/executor/execution-state.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from '@sim/db'
22
import { workflowExecutionLogs } from '@sim/db/schema'
3-
import { isRecordLike } from '@sim/utils/object'
3+
import { filterUndefined, isRecordLike } from '@sim/utils/object'
44
import { and, desc, eq, or, sql } from 'drizzle-orm'
55
import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store'
66
import type { SerializableExecutionState } from '@/executor/execution/types'
@@ -188,6 +188,19 @@ export async function getWorkflowExecutionCopilotToolCallId(
188188
return typeof toolCallId === 'string' ? toolCallId : undefined
189189
}
190190

191+
/**
192+
* The message an error finalization (`force_failed`) stored as the run's `finalOutput.error`.
193+
* Any other run's `finalOutput.error` is the workflow's own data, not a failure message.
194+
*/
195+
function readFailureMessage(executionData: Record<string, unknown>): string | undefined {
196+
if (typeof executionData.error === 'string') return executionData.error
197+
if (executionData.finalizationPath !== 'force_failed') return undefined
198+
const { finalOutput } = executionData
199+
return isRecordLike(finalOutput) && typeof finalOutput.error === 'string'
200+
? finalOutput.error
201+
: undefined
202+
}
203+
191204
/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */
192205
export async function getTrustedWorkflowToolExecution(
193206
executionId: string,
@@ -232,7 +245,7 @@ export async function getTrustedWorkflowToolExecution(
232245
...(Object.hasOwn(executionData, 'finalOutput')
233246
? { finalOutput: executionData.finalOutput }
234247
: {}),
235-
...(typeof executionData.error === 'string' ? { error: executionData.error } : {}),
248+
...filterUndefined({ error: readFailureMessage(executionData) }),
236249
blockLogs: state.blockLogs,
237250
provenance,
238251
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/** The failure message a browser-settled Copilot workflow tool reads back, against real PostgreSQL. */
2+
import { db } from '@sim/db'
3+
import {
4+
user,
5+
workflow,
6+
workflowExecutionLogs,
7+
workflowExecutionSnapshots,
8+
workspace,
9+
} from '@sim/db/schema'
10+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
11+
import { generateId } from '@sim/utils/id'
12+
import { eq } from 'drizzle-orm'
13+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
14+
import { LoggingSession } from '@/lib/logs/execution/logging-session'
15+
import type { TraceSpan, WorkflowState } from '@/lib/logs/types'
16+
import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state'
17+
import type { SerializableExecutionState } from '@/executor/execution/types'
18+
import { emptyResolvedSecretTraceProvenance } from '@/executor/utils/resolved-secret-trace-registry'
19+
20+
const ids = {
21+
owner: `trusted-tool-failure-${generateId()}`,
22+
workspace: generateId(),
23+
workflow: generateId(),
24+
}
25+
26+
const workflowState: WorkflowState = {
27+
blocks: {
28+
start: {
29+
id: 'start',
30+
type: 'starter',
31+
name: 'Start',
32+
position: { x: 0, y: 0 },
33+
subBlocks: {},
34+
outputs: {},
35+
enabled: true,
36+
},
37+
},
38+
edges: [],
39+
loops: {},
40+
parallels: {},
41+
}
42+
43+
function executionState(): SerializableExecutionState {
44+
return {
45+
blockStates: {},
46+
executedBlocks: ['start'],
47+
blockLogs: [],
48+
decisions: { router: {}, condition: {} },
49+
completedLoops: [],
50+
activeExecutionPath: [],
51+
resolvedSecretTraceProvenance: emptyResolvedSecretTraceProvenance(),
52+
}
53+
}
54+
55+
/** Starts a run bound to a Copilot tool call, the way the browser settlement path launches one. */
56+
async function startBoundRun() {
57+
const executionId = generateId()
58+
const toolCallId = generateId()
59+
const session = new LoggingSession(ids.workflow, executionId, 'manual', 'trusted-tool-failure')
60+
session.setTrustedExecutionCorrelation({
61+
executionId,
62+
requestId: 'trusted-tool-failure',
63+
source: 'workflow',
64+
workflowId: ids.workflow,
65+
triggerType: 'copilot',
66+
copilotToolCallId: toolCallId,
67+
})
68+
expect(
69+
await session.safeStart({ userId: ids.owner, workspaceId: ids.workspace, workflowState })
70+
).toBe(true)
71+
return { session, executionId, toolCallId }
72+
}
73+
74+
function readBack(run: { executionId: string; toolCallId: string }) {
75+
return getTrustedWorkflowToolExecution(run.executionId, ids.workflow, run.toolCallId)
76+
}
77+
78+
beforeAll(async () => {
79+
const now = new Date()
80+
await db.insert(user).values({
81+
id: ids.owner,
82+
name: 'Trusted Tool Failure',
83+
email: `${ids.owner}@trusted-tool-failure.test`,
84+
emailVerified: true,
85+
createdAt: now,
86+
updatedAt: now,
87+
})
88+
await insertWorkspaceFixture(db, {
89+
id: ids.workspace,
90+
name: 'Trusted Tool Failure',
91+
ownerId: ids.owner,
92+
billedAccountUserId: ids.owner,
93+
})
94+
await db.insert(workflow).values({
95+
id: ids.workflow,
96+
userId: ids.owner,
97+
workspaceId: ids.workspace,
98+
name: 'Trusted Tool Failure',
99+
lastSynced: now,
100+
createdAt: now,
101+
updatedAt: now,
102+
})
103+
}, 30_000)
104+
105+
afterAll(async () => {
106+
await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workflowId, ids.workflow))
107+
await db
108+
.delete(workflowExecutionSnapshots)
109+
.where(eq(workflowExecutionSnapshots.workflowId, ids.workflow))
110+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspace))
111+
await db.delete(user).where(eq(user.id, ids.owner))
112+
})
113+
114+
describe('trusted workflow tool execution failure message', () => {
115+
it('returns the message a run that threw was finalized with', async () => {
116+
const run = await startBoundRun()
117+
118+
await run.session.safeCompleteWithError({
119+
error: { message: 'Fetch Orders: Request failed with status 404' },
120+
executionState: executionState(),
121+
})
122+
123+
await expect(readBack(run)).resolves.toMatchObject({
124+
status: 'failed',
125+
contentAvailable: true,
126+
error: 'Fetch Orders: Request failed with status 404',
127+
})
128+
})
129+
130+
it('returns no message for a run whose failed block left its own output', async () => {
131+
const run = await startBoundRun()
132+
const now = new Date().toISOString()
133+
const failedSpan: TraceSpan = {
134+
id: 'span-1',
135+
name: 'Check',
136+
type: 'function',
137+
duration: 1,
138+
startTime: now,
139+
endTime: now,
140+
status: 'error',
141+
output: { error: 'boom' },
142+
}
143+
144+
await run.session.safeComplete({
145+
finalOutput: { error: 'a field the workflow returned' },
146+
traceSpans: [failedSpan],
147+
executionState: executionState(),
148+
})
149+
150+
const result = await readBack(run)
151+
expect(result).toMatchObject({ status: 'failed', contentAvailable: true })
152+
expect(result).not.toHaveProperty('error')
153+
})
154+
})

0 commit comments

Comments
 (0)