Skip to content

Commit 95282c9

Browse files
committed
fix(mothership): surface the real run error for browser-settled workflow tools
1 parent 2fc9739 commit 95282c9

2 files changed

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

0 commit comments

Comments
 (0)