Skip to content

Commit c05c34e

Browse files
committed
fix(mothership): preserve file intents and workflow result contracts
1 parent df64b0e commit c05c34e

14 files changed

Lines changed: 442 additions & 103 deletions

File tree

apps/sim/app/api/copilot/confirm/route.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,41 @@ describe('Copilot Confirm API Route', () => {
667667
expect(publishToolConfirmation).not.toHaveBeenCalled()
668668
})
669669

670+
it.each(['run_workflow', 'run_block', 'run_from_block', 'run_workflow_until_block'])(
671+
'preserves a safe busy reason for an unlaunched %s without trusting client text',
672+
async (toolName) => {
673+
getAsyncToolCall.mockResolvedValue({
674+
...existingRow,
675+
toolName,
676+
args: { workflowId: 'workflow-1' },
677+
claimedBy: null,
678+
})
679+
const response = await POST(
680+
createMockPostRequest({
681+
toolCallId: 'tool-call-123',
682+
status: 'error',
683+
message: 'untrusted detail',
684+
data: { code: 'WORKFLOW_EXECUTION_BUSY', error: 'untrusted detail' },
685+
})
686+
)
687+
expect(response.status).toBe(200)
688+
expect(completeAsyncToolCall).toHaveBeenCalledWith(
689+
expect.objectContaining({
690+
status: 'failed',
691+
result: {
692+
success: false,
693+
workflowId: 'workflow-1',
694+
code: 'WORKFLOW_EXECUTION_BUSY',
695+
error:
696+
'Workflow is already executing. Wait for the current execution to finish before running it again.',
697+
},
698+
})
699+
)
700+
expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted detail')
701+
expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled()
702+
}
703+
)
704+
670705
it('preserves a canonical preflight failure before an execution is bound', async () => {
671706
getAsyncToolCall.mockResolvedValue({
672707
...existingRow,

apps/sim/app/api/copilot/confirm/route.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,15 @@ import {
4343
sealClientToolCompletion,
4444
} from '@/lib/mothership/request/tools/client-completion-seal.server'
4545
import {
46-
type AsyncWorkflowDeploymentError,
4746
createStructuralWorkflowToolCompletionData,
48-
getAsyncWorkflowDeploymentError,
4947
getWorkflowToolCompletionExecutionId,
5048
getWorkflowToolCompletionMessage,
5149
getWorkflowToolConfirmationStatus,
50+
getWorkflowToolLaunchError,
5251
isWorkflowToolName,
5352
resolveWorkflowToolTargetId,
53+
WORKFLOW_EXECUTION_BUSY,
54+
type WorkflowToolLaunchError,
5455
} from '@/lib/mothership/tools/workflow-tools'
5556
import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state'
5657

@@ -313,7 +314,7 @@ export const POST = withRouteHandler((req: NextRequest) => {
313314

314315
let effectiveStatus = status
315316
let executionId = submittedExecutionId
316-
let deploymentError: AsyncWorkflowDeploymentError | undefined
317+
let launchError: WorkflowToolLaunchError | undefined
317318

318319
if (isWorkflowTool) {
319320
const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy)
@@ -368,25 +369,28 @@ export const POST = withRouteHandler((req: NextRequest) => {
368369

369370
if (
370371
effectiveStatus === ASYNC_TOOL_CONFIRMATION_STATUS.error &&
371-
executionId === undefined &&
372-
existing.toolName === 'run_workflow' &&
373-
isPlainRecord(existing.args) &&
374-
existing.args.async === true
372+
executionId === undefined
375373
) {
376-
deploymentError = getAsyncWorkflowDeploymentError(data)
374+
const submittedError = getWorkflowToolLaunchError(data)
375+
if (
376+
submittedError?.code === WORKFLOW_EXECUTION_BUSY.code ||
377+
(existing.toolName === 'run_workflow' &&
378+
isPlainRecord(existing.args) &&
379+
existing.args.async === true)
380+
)
381+
launchError = submittedError
377382
}
378383
}
379384

380385
span.setAttribute(TraceAttr.ToolConfirmationStatus, effectiveStatus)
381386
const projected = isWorkflowTool
382387
? {
383-
message:
384-
deploymentError?.message ?? getWorkflowToolCompletionMessage(effectiveStatus),
388+
message: launchError?.message ?? getWorkflowToolCompletionMessage(effectiveStatus),
385389
data: createStructuralWorkflowToolCompletionData(
386390
effectiveStatus,
387391
workflowId,
388392
executionId,
389-
deploymentError
393+
launchError
390394
),
391395
}
392396
: {

apps/sim/lib/mothership/request/handlers/tool.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,12 @@ async function dispatchToolExecution(
852852
if (isWorkflowToolName(toolName)) {
853853
const race = await raceWorkflowToolClientPickup({
854854
toolCallId,
855+
select:
856+
args &&
857+
Array.isArray(args.select) &&
858+
args.select.every((value) => typeof value === 'string')
859+
? args.select
860+
: undefined,
855861
workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId),
856862
timeoutMs,
857863
// The caller declared its executors at turn setup (ChatRequest.clientCapabilities):

apps/sim/lib/mothership/request/tools/client.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,67 @@ describe('workflow client tool completion', () => {
106106
replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' })
107107
})
108108

109+
it('keeps a safe busy reason when no execution was launched', async () => {
110+
waitForToolConfirmation.mockResolvedValue({
111+
status: 'error',
112+
data: { code: 'WORKFLOW_EXECUTION_BUSY', error: 'untrusted text' },
113+
})
114+
const completion = await waitForWorkflowToolCompletion({
115+
toolCallId: 'tool-1',
116+
workflowId: 'workflow-1',
117+
timeoutMs: 1_000,
118+
})
119+
expect(completion?.data).toEqual({
120+
success: false,
121+
workflowId: 'workflow-1',
122+
code: 'WORKFLOW_EXECUTION_BUSY',
123+
error:
124+
'Workflow is already executing. Wait for the current execution to finish before running it again.',
125+
})
126+
expect(completion?.message).toBe(
127+
'Workflow is already executing. Wait for the current execution to finish before running it again.'
128+
)
129+
expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled()
130+
})
131+
132+
it('selects requested trusted outputs after redaction and omits full client logs', async () => {
133+
waitForToolConfirmation.mockResolvedValue({
134+
status: 'success',
135+
data: { executionId: 'execution-1' },
136+
})
137+
getTrustedWorkflowToolExecution.mockResolvedValue({
138+
...trustedExecution('execution-1'),
139+
blockLogs: [
140+
{ blockId: 'b1', blockName: 'Read Value', output: { result: 'earlier' } },
141+
{
142+
blockId: 'b1',
143+
blockName: 'Read Value',
144+
output: { result: 'parent-secret-value', count: 3 },
145+
},
146+
],
147+
})
148+
const completion = await waitForWorkflowToolCompletion({
149+
toolCallId: 'tool-1',
150+
workflowId: 'workflow-1',
151+
timeoutMs: 1_000,
152+
registry: createParentRegistry(),
153+
select: ['readvalue.result', 'b1.count', 'Missing.result'],
154+
})
155+
expect(completion?.data).toMatchObject({
156+
selected: {
157+
'readvalue.result': '{{PARENT_SECRET}}',
158+
'b1.count': 3,
159+
'Missing.result': { unresolved: 'no executed block named "Missing"' },
160+
},
161+
logsOmitted: true,
162+
})
163+
expect(completion?.data).not.toHaveProperty('logs')
164+
expect(JSON.stringify(completion)).not.toContain('parent-secret-value')
165+
expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith(
166+
expect.objectContaining({ result: completion?.data })
167+
)
168+
})
169+
109170
it('projects a parent secret laundered through a child workflow before every live sink', async () => {
110171
const registry = createParentRegistry()
111172
waitForToolConfirmation.mockResolvedValue({

apps/sim/lib/mothership/request/tools/client.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {
1414
unsealClientToolContext,
1515
} from '@/lib/mothership/request/tools/client-completion-seal.server'
1616
import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
17+
import { presentWorkflowLogs } from '@/lib/mothership/tools/workflow-output'
1718
import {
18-
type AsyncWorkflowDeploymentError,
1919
createStructuralWorkflowToolCompletionData,
20-
getAsyncWorkflowDeploymentError,
2120
getWorkflowToolCompletionExecutionId,
2221
getWorkflowToolCompletionMessage,
2322
getWorkflowToolConfirmationStatus,
23+
getWorkflowToolLaunchError,
24+
type WorkflowToolLaunchError,
2425
} from '@/lib/mothership/tools/workflow-tools'
2526
import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state'
2627
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -211,6 +212,7 @@ export async function waitForClientToolCompletion({
211212
}
212213

213214
interface WaitForWorkflowToolCompletionOptions {
215+
select?: string[]
214216
toolCallId: string
215217
workflowId?: string
216218
timeoutMs: number
@@ -222,17 +224,12 @@ function structuralWorkflowCompletion(
222224
status: AsyncTerminalCompletionSnapshot['status'],
223225
workflowId?: string,
224226
executionId?: string,
225-
deploymentError?: AsyncWorkflowDeploymentError
227+
launchError?: WorkflowToolLaunchError
226228
): AsyncTerminalCompletionSnapshot {
227229
return {
228230
status,
229-
message: deploymentError?.message ?? getWorkflowToolCompletionMessage(status),
230-
data: createStructuralWorkflowToolCompletionData(
231-
status,
232-
workflowId,
233-
executionId,
234-
deploymentError
235-
),
231+
message: launchError?.message ?? getWorkflowToolCompletionMessage(status),
232+
data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId, launchError),
236233
}
237234
}
238235

@@ -241,6 +238,7 @@ function structuralWorkflowCompletion(
241238
* The browser confirmation is only a wakeup and structural identity carrier.
242239
*/
243240
export async function waitForWorkflowToolCompletion({
241+
select,
244242
toolCallId,
245243
workflowId,
246244
timeoutMs,
@@ -260,7 +258,7 @@ export async function waitForWorkflowToolCompletion({
260258
}
261259

262260
const executionId = getWorkflowToolCompletionExecutionId(completion.data)
263-
const deploymentError = getAsyncWorkflowDeploymentError(completion.data)
261+
const launchError = getWorkflowToolLaunchError(completion.data)
264262
if (completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
265263
toolRegistry?.markIncomplete('client-tool-completion-deferred')
266264
return structuralWorkflowCompletion(completion.status, workflowId, executionId)
@@ -271,12 +269,7 @@ export async function waitForWorkflowToolCompletion({
271269
completion.status === MothershipStreamV1ToolOutcome.success
272270
? MothershipStreamV1ToolOutcome.error
273271
: completion.status
274-
return structuralWorkflowCompletion(
275-
structuralStatus,
276-
workflowId,
277-
executionId,
278-
deploymentError
279-
)
272+
return structuralWorkflowCompletion(structuralStatus, workflowId, executionId, launchError)
280273
}
281274

282275
try {
@@ -381,8 +374,10 @@ export async function waitForWorkflowToolCompletion({
381374
)
382375
const projected = projection.result
383376
const projectedData = isPlainRecord(projected.output) ? projected.output : {}
377+
const { logs, ...projectedFields } = projectedData
384378
const data = {
385-
...projectedData,
379+
...projectedFields,
380+
...(Object.hasOwn(projectedData, 'logs') ? presentWorkflowLogs(logs, select) : {}),
386381
...createStructuralWorkflowToolCompletionData(status, workflowId, executionId),
387382
}
388383
const message =

apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ describe('raceWorkflowToolClientPickup', () => {
6666
vi.useRealTimers()
6767
})
6868

69+
it('forwards requested output selectors to the client completion boundary', async () => {
70+
waitForWorkflowToolCompletion.mockResolvedValue({ status: 'success', data: {} })
71+
await raceWorkflowToolClientPickup({ ...baseParams(), select: ['Result.value'] })
72+
expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith(
73+
expect.objectContaining({ select: ['Result.value'] })
74+
)
75+
})
76+
6977
it('lets the client win without ever attempting a claim', async () => {
7078
waitForWorkflowToolCompletion.mockResolvedValue({ status: 'success', data: { ok: true } })
7179
const params = baseParams()

apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface WorkflowToolRaceOutcome {
2828
}
2929

3030
interface RaceWorkflowToolClientPickupParams {
31+
select?: string[]
3132
toolCallId: string
3233
workflowId?: string
3334
timeoutMs: number
@@ -68,6 +69,7 @@ export async function raceWorkflowToolClientPickup(
6869
// Exactly one waiter for the whole race — a second one would double-consume
6970
// the confirmation and emit a duplicate tool result.
7071
const clientWait = waitForWorkflowToolCompletion({
72+
select: params.select,
7173
toolCallId,
7274
workflowId,
7375
timeoutMs,

apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,19 @@ describe('run tool execution cancellation', () => {
147147
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
148148
})
149149

150+
it('reports a typed busy reason before launching into an already-running workflow', async () => {
151+
getWorkflowExecution.mockReturnValueOnce({ isExecuting: true })
152+
await executeRunToolOnClient('busy-tool', 'run_workflow', { workflowId: 'wf-1' })
153+
expect(executeWorkflowWithFullLogging).not.toHaveBeenCalled()
154+
const [url, options] = vi.mocked(fetch).mock.calls[0]!
155+
expect(url).toBe('/api/copilot/confirm')
156+
expect(JSON.parse(String(options?.body))).toMatchObject({
157+
toolCallId: 'busy-tool',
158+
status: 'error',
159+
data: { code: 'WORKFLOW_EXECUTION_BUSY' },
160+
})
161+
})
162+
150163
it('passes an abort signal into executeWorkflowWithFullLogging and aborts it', async () => {
151164
let capturedSignal: AbortSignal | undefined
152165
executeWorkflowWithFullLogging.mockImplementationOnce(async (options: any) => {

apps/sim/lib/mothership/tools/client/run-tool-execution.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ import {
2424
reportClientToolCompletion as reportCompletion,
2525
} from '@/lib/mothership/tools/client/completion'
2626
import {
27-
type AsyncWorkflowDeploymentError,
28-
getAsyncWorkflowDeploymentError,
2927
getWorkflowToolCompletionMessage,
28+
getWorkflowToolLaunchError,
29+
WORKFLOW_EXECUTION_BUSY,
30+
type WorkflowToolLaunchError,
3031
} from '@/lib/mothership/tools/workflow-tools'
3132
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
3233
import {
@@ -106,7 +107,7 @@ async function enqueueAsyncWorkflowRun(
106107

107108
let responseExecutionId = requestedExecutionId
108109
let acceptanceIsAmbiguous = false
109-
let deploymentError: AsyncWorkflowDeploymentError | undefined
110+
let launchError: WorkflowToolLaunchError | undefined
110111
try {
111112
// boundary-raw-fetch: this execution endpoint switches to a JSON 202 response via X-Execution-Mode
112113
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
@@ -126,7 +127,7 @@ async function enqueueAsyncWorkflowRun(
126127
}),
127128
})
128129
const responseBody: unknown = await response.json().catch(() => undefined)
129-
deploymentError = getAsyncWorkflowDeploymentError(responseBody)
130+
launchError = getWorkflowToolLaunchError(responseBody)
130131
responseExecutionId =
131132
isPlainRecord(responseBody) && typeof responseBody.executionId === 'string'
132133
? responseBody.executionId
@@ -148,7 +149,7 @@ async function enqueueAsyncWorkflowRun(
148149

149150
if (!response.ok && !acceptanceIsAmbiguous) {
150151
const responseError =
151-
deploymentError?.message ??
152+
launchError?.message ??
152153
(isPlainRecord(responseBody) && typeof responseBody.error === 'string'
153154
? responseBody.error
154155
: `Async workflow queue request failed with status ${response.status}`)
@@ -164,7 +165,7 @@ async function enqueueAsyncWorkflowRun(
164165
await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, message, {
165166
success: false,
166167
workflowId,
167-
...(deploymentError ? { code: deploymentError.code } : {}),
168+
...(launchError ? { code: launchError.code } : {}),
168169
})
169170
return
170171
}
@@ -456,7 +457,8 @@ async function doExecuteRunTool(
456457
await reportCompletion(
457458
toolCallId,
458459
MothershipStreamV1ToolOutcome.error,
459-
'Workflow is already being executed by another tool. Wait for it to complete.'
460+
WORKFLOW_EXECUTION_BUSY.message,
461+
{ code: WORKFLOW_EXECUTION_BUSY.code }
460462
)
461463
return
462464
}
@@ -474,7 +476,8 @@ async function doExecuteRunTool(
474476
await reportCompletion(
475477
toolCallId,
476478
MothershipStreamV1ToolOutcome.error,
477-
'Workflow is already executing. Try again later'
479+
WORKFLOW_EXECUTION_BUSY.message,
480+
{ code: WORKFLOW_EXECUTION_BUSY.code }
478481
)
479482
return
480483
}

0 commit comments

Comments
 (0)