Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions apps/sim/lib/workflows/executor/execution-state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { isRecordLike } from '@sim/utils/object'
import { filterUndefined, isRecordLike } from '@sim/utils/object'
import { and, desc, eq, or, sql } from 'drizzle-orm'
import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store'
import type { SerializableExecutionState } from '@/executor/execution/types'
Expand Down Expand Up @@ -188,6 +188,19 @@ export async function getWorkflowExecutionCopilotToolCallId(
return typeof toolCallId === 'string' ? toolCallId : undefined
}

/**
* The message an error finalization (`force_failed`) stored as the run's `finalOutput.error`.
* Any other run's `finalOutput.error` is the workflow's own data, not a failure message.
*/
function readFailureMessage(executionData: Record<string, unknown>): string | undefined {
if (typeof executionData.error === 'string') return executionData.error
if (executionData.finalizationPath !== 'force_failed') return undefined
const { finalOutput } = executionData
return isRecordLike(finalOutput) && typeof finalOutput.error === 'string'
? finalOutput.error
: undefined
}

/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */
export async function getTrustedWorkflowToolExecution(
executionId: string,
Expand Down Expand Up @@ -232,7 +245,7 @@ export async function getTrustedWorkflowToolExecution(
...(Object.hasOwn(executionData, 'finalOutput')
? { finalOutput: executionData.finalOutput }
: {}),
...(typeof executionData.error === 'string' ? { error: executionData.error } : {}),
...filterUndefined({ error: readFailureMessage(executionData) }),
blockLogs: state.blockLogs,
provenance,
}
Expand Down
154 changes: 154 additions & 0 deletions apps/sim/lib/workflows/executor/trusted-tool-failure.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/** The failure message a browser-settled Copilot workflow tool reads back, against real PostgreSQL. */
import { db } from '@sim/db'
import {
user,
workflow,
workflowExecutionLogs,
workflowExecutionSnapshots,
workspace,
} from '@sim/db/schema'
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import type { TraceSpan, WorkflowState } from '@/lib/logs/types'
import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state'
import type { SerializableExecutionState } from '@/executor/execution/types'
import { emptyResolvedSecretTraceProvenance } from '@/executor/utils/resolved-secret-trace-registry'

const ids = {
owner: `trusted-tool-failure-${generateId()}`,
workspace: generateId(),
workflow: generateId(),
}

const workflowState: WorkflowState = {
blocks: {
start: {
id: 'start',
type: 'starter',
name: 'Start',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
},
},
edges: [],
loops: {},
parallels: {},
}

function executionState(): SerializableExecutionState {
return {
blockStates: {},
executedBlocks: ['start'],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
resolvedSecretTraceProvenance: emptyResolvedSecretTraceProvenance(),
}
}

/** Starts a run bound to a Copilot tool call, the way the browser settlement path launches one. */
async function startBoundRun() {
const executionId = generateId()
const toolCallId = generateId()
const session = new LoggingSession(ids.workflow, executionId, 'manual', 'trusted-tool-failure')
session.setTrustedExecutionCorrelation({
executionId,
requestId: 'trusted-tool-failure',
source: 'workflow',
workflowId: ids.workflow,
triggerType: 'copilot',
copilotToolCallId: toolCallId,
})
expect(
await session.safeStart({ userId: ids.owner, workspaceId: ids.workspace, workflowState })
).toBe(true)
return { session, executionId, toolCallId }
}

function readBack(run: { executionId: string; toolCallId: string }) {
return getTrustedWorkflowToolExecution(run.executionId, ids.workflow, run.toolCallId)
}

beforeAll(async () => {
const now = new Date()
await db.insert(user).values({
id: ids.owner,
name: 'Trusted Tool Failure',
email: `${ids.owner}@trusted-tool-failure.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
await insertWorkspaceFixture(db, {
id: ids.workspace,
name: 'Trusted Tool Failure',
ownerId: ids.owner,
billedAccountUserId: ids.owner,
})
await db.insert(workflow).values({
id: ids.workflow,
userId: ids.owner,
workspaceId: ids.workspace,
name: 'Trusted Tool Failure',
lastSynced: now,
createdAt: now,
updatedAt: now,
})
}, 30_000)

afterAll(async () => {
await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workflowId, ids.workflow))
await db
.delete(workflowExecutionSnapshots)
.where(eq(workflowExecutionSnapshots.workflowId, ids.workflow))
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspace))
await db.delete(user).where(eq(user.id, ids.owner))
})

describe('trusted workflow tool execution failure message', () => {
it('returns the message a run that threw was finalized with', async () => {
const run = await startBoundRun()

await run.session.safeCompleteWithError({
error: { message: 'Fetch Orders: Request failed with status 404' },
executionState: executionState(),
})

await expect(readBack(run)).resolves.toMatchObject({
status: 'failed',
contentAvailable: true,
error: 'Fetch Orders: Request failed with status 404',
})
})

it('returns no message for a run whose failed block left its own output', async () => {
const run = await startBoundRun()
const now = new Date().toISOString()
const failedSpan: TraceSpan = {
id: 'span-1',
name: 'Check',
type: 'function',
duration: 1,
startTime: now,
endTime: now,
status: 'error',
output: { error: 'boom' },
}

await run.session.safeComplete({
finalOutput: { error: 'a field the workflow returned' },
traceSpans: [failedSpan],
executionState: executionState(),
})

const result = await readBack(run)
expect(result).toMatchObject({ status: 'failed', contentAvailable: true })
expect(result).not.toHaveProperty('error')
})
})
Loading