Skip to content

Commit b48f9c2

Browse files
committed
fix(mothership): preserve composed code export outcomes
1 parent 347e721 commit b48f9c2

10 files changed

Lines changed: 404 additions & 58 deletions

File tree

‎apps/sim/lib/function-execution/execute-request.ts‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
} from '@/lib/execution/remote-sandbox/sandbox-paths'
8282
import type { SandboxCollectedFile, SandboxFile } from '@/lib/execution/remote-sandbox/types'
8383
import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors'
84+
import type { SandboxExportedFile } from '@/lib/function-execution/output'
8485
import { planUserFileMounts, resolveUserFileMounts } from '@/lib/function-execution/sandbox-mounts'
8586
import {
8687
FORMAT_TO_CONTENT_TYPE,
@@ -1464,18 +1465,6 @@ function workspaceFileExportErrorStatus(error: unknown): number {
14641465
return asOrchestrationError(error)?.code === 'forbidden' ? 403 : 400
14651466
}
14661467

1467-
interface SandboxExportedFile {
1468-
fileId: string
1469-
fileName: string
1470-
vfsPath: string
1471-
downloadUrl?: string
1472-
sandboxPath?: string
1473-
size: number
1474-
previousSize?: number
1475-
sha256: string
1476-
unchanged: boolean
1477-
}
1478-
14791468
/**
14801469
* Builds the success response for a sandbox file export.
14811470
*
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/** Receipt for a workspace file explicitly exported by code execution. */
2+
export interface SandboxExportedFile {
3+
fileId: string
4+
fileName: string
5+
vfsPath: string
6+
downloadUrl?: string
7+
sandboxPath?: string
8+
size: number
9+
previousSize?: number
10+
sha256: string
11+
unchanged: boolean
12+
}
13+
14+
export interface SandboxExportReceipt {
15+
message: string
16+
files: SandboxExportedFile[]
17+
}

‎apps/sim/lib/mothership/agent-cli/saved-run-read.postgres.test.ts‎

Lines changed: 257 additions & 5 deletions
Large diffs are not rendered by default.

‎apps/sim/lib/mothership/request/tools/files.test.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ describe('maybeWriteOutputToFile', () => {
159159
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
160160
})
161161

162-
it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => {
162+
it('passes through sandbox-only receipts without attempting another workspace write', async () => {
163163
const exportedResult = {
164164
success: true,
165165
output: {
@@ -175,7 +175,13 @@ describe('maybeWriteOutputToFile', () => {
175175

176176
const result = await maybeWriteOutputToFile(
177177
RunFunction.id,
178-
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
178+
{
179+
outputs: {
180+
files: [
181+
{ path: 'files/report.csv', mode: 'overwrite', sandboxPath: '/home/user/report.csv' },
182+
],
183+
},
184+
},
179185
exportedResult,
180186
buildContext({ userPermission: 'read' })
181187
)
@@ -188,18 +194,55 @@ describe('maybeWriteOutputToFile', () => {
188194
const result = await maybeWriteOutputToFile(
189195
RunFunction.id,
190196
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
191-
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
197+
{ success: true, output: { result: 'name,age\nAlice,30', stdout: 'Exported 1 row' } },
192198
buildContext()
193199
)
194200

195201
expect(result.success).toBe(true)
202+
expect(result.output).toMatchObject({ stdout: 'Exported 1 row' })
196203
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
197204
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
198205
expect.anything(),
199206
expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } })
200207
)
201208
})
202209

210+
it('writes returned data alongside a completed sandbox export and keeps both receipts', async () => {
211+
const existingFile = { fileId: 'raw-file', fileName: 'raw.csv', vfsPath: 'files/raw.csv' }
212+
const existingResource = {
213+
type: 'file' as const,
214+
id: 'raw-file',
215+
title: 'raw.csv',
216+
path: 'files/raw.csv',
217+
}
218+
const result = await maybeWriteOutputToFile(
219+
RunFunction.id,
220+
{
221+
outputs: {
222+
files: [
223+
{ path: 'files/raw.csv', sandboxPath: '/home/user/raw.csv' },
224+
{ path: 'files/report.csv' },
225+
],
226+
},
227+
},
228+
{
229+
success: true,
230+
output: { result: [{ name: 'Ada' }], stdout: '1 row', exported: { files: [existingFile] } },
231+
resources: [existingResource],
232+
},
233+
buildContext()
234+
)
235+
236+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
237+
expect(mockWriteWorkspaceFileByPath.mock.calls[0]?.[1].buffer.toString()).toBe('name\nAda')
238+
expect(result.output).toMatchObject({
239+
result: [{ name: 'Ada' }],
240+
stdout: '1 row',
241+
files: [existingFile, expect.objectContaining({ vfsPath: 'files/report.csv' })],
242+
})
243+
expect(result.resources).toContainEqual(existingResource)
244+
})
245+
203246
it('classifies large structured output from its serialized bytes instead of its object count', async () => {
204247
const registry = new ResolvedSecretTraceRegistry(
205248
[

‎apps/sim/lib/mothership/request/tools/files.ts‎

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr
2828
const logger = createLogger('CopilotToolResultFiles')
2929
const MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS = 10_000
3030

31+
/** Reads durable file receipts from code exports or returned-value file writes. */
32+
export function getOutputFileReceipts(output: unknown): Record<string, unknown>[] {
33+
if (!isRecordLike(output)) return []
34+
const files = isRecordLike(output.exported) ? output.exported.files : output.files
35+
return Array.isArray(files) ? files.filter(isRecordLike) : []
36+
}
37+
3138
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([RunFunction.id, UserTable.id])
3239

3340
export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
@@ -355,13 +362,7 @@ export async function maybeWriteOutputToFile(
355362
const outputObject = isRecordLike(result.output)
356363
? (result.output as Record<string, unknown>)
357364
: undefined
358-
if (isRecordLike(outputObject?.exported)) {
359-
logger.warn('Skipping returned-value output write because sandbox export response is active', {
360-
toolName,
361-
outputCount: outputFiles.length,
362-
})
363-
return result
364-
}
365+
const previousFiles = getOutputFileReceipts(result.output)
365366

366367
const denied = denyOutputWriteWithoutWritePermission(context)
367368
if (denied) return denied
@@ -467,35 +468,43 @@ export async function maybeWriteOutputToFile(
467468
})),
468469
})
469470

471+
const files = [
472+
...previousFiles,
473+
...writtenFiles.map((file) => ({
474+
fileId: file.id,
475+
fileName: file.name,
476+
vfsPath: file.vfsPath,
477+
size: file.bytes,
478+
downloadUrl: file.downloadUrl,
479+
})),
480+
]
470481
return {
471482
success: true,
472483
output: {
473484
// The computed value stays on the result so a table write declared on the
474485
// same call (`outputTable`) still has rows to read after the file write.
475486
result: outputObject?.result,
487+
...(typeof outputObject?.stdout === 'string' ? { stdout: outputObject.stdout } : {}),
476488
message:
477489
writtenFiles.length === 1
478490
? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)`
479491
: `Output written to ${writtenFiles.length} files`,
480-
files: writtenFiles.map((file) => ({
481-
fileId: file.id,
482-
fileName: file.name,
483-
vfsPath: file.vfsPath,
484-
size: file.bytes,
485-
downloadUrl: file.downloadUrl,
486-
})),
492+
files,
487493
fileId: firstWritten.id,
488494
fileName: firstWritten.name,
489495
vfsPath: firstWritten.vfsPath,
490496
size: firstWritten.bytes,
491497
downloadUrl: firstWritten.downloadUrl,
492498
},
493-
resources: writtenFiles.map((file) => ({
494-
type: 'file',
495-
id: file.id,
496-
title: file.name,
497-
path: file.vfsPath,
498-
})),
499+
resources: [
500+
...(result.resources ?? []),
501+
...writtenFiles.map((file) => ({
502+
type: 'file' as const,
503+
id: file.id,
504+
title: file.name,
505+
path: file.vfsPath,
506+
})),
507+
],
499508
}
500509
} catch (err) {
501510
const message = toError(err).message

‎apps/sim/lib/mothership/request/tools/tables.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,15 @@ const table: TableDefinition = {
5151
workspaceId: 'workspace-1',
5252
createdBy: 'user-1',
5353
archivedAt: null,
54+
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
5455
createdAt: new Date('2026-08-01T00:00:00.000Z'),
5556
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
5657
}
5758

5859
const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[
5960
vi
6061
.mocked(loggerMock.createLogger)
61-
.mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables')
62+
.mock.calls.findIndex((call: readonly unknown[]) => call[0] === 'CopilotToolResultTables')
6263
]?.value
6364

6465
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
@@ -256,6 +257,25 @@ describe('automatic Copilot tool-output table persistence', () => {
256257
expect(mocks.executeReplace).not.toHaveBeenCalled()
257258
})
258259

260+
it('keeps committed file receipts when the table application command throws', async () => {
261+
const files = [{ fileId: 'file-1', vfsPath: 'files/report.csv' }]
262+
mocks.executeReplace.mockRejectedValueOnce(new Error('database unavailable'))
263+
264+
const result = await maybeWriteOutputToTable(
265+
RunFunction.id,
266+
{ outputTable: 'table-1' },
267+
{ success: true, output: { result: [{ name: 'Ada' }], exported: { files } } },
268+
buildContext()
269+
)
270+
271+
expect(result).toEqual({
272+
success: false,
273+
error:
274+
'Failed to write to table: Table operation failed. The declared output files were already written.',
275+
output: { files },
276+
})
277+
})
278+
259279
it('tells each language how to hand rows back when the shape is wrong', async () => {
260280
const result = await maybeWriteOutputToTable(
261281
RunFunction.id,

‎apps/sim/lib/mothership/request/tools/tables.ts‎

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
99
import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1'
1010
import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1'
1111
import { withCopilotSpan } from '@/lib/mothership/request/otel'
12+
import { getOutputFileReceipts } from '@/lib/mothership/request/tools/files'
1213
import { denyOutputWriteWithoutWritePermission } from '@/lib/mothership/request/tools/permissions'
1314
import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
1415
import type { ExecutionContext, ToolCallResult } from '@/lib/mothership/request/types'
@@ -22,17 +23,6 @@ const MAX_OUTPUT_TABLE_ROWS = 10_000
2223
const RETURN_ROWS_HINT = 'JavaScript: `return [...]`; Python: assign `__sim_result__ = [...]`'
2324
const ARRAY_OF_OBJECTS_ERROR = `outputTable requires the code to return an array of objects (${RETURN_ROWS_HINT})`
2425

25-
/**
26-
* The sandbox export receipt `execute-request` places beside the returned value
27-
* (`output.exported.files`), or the bare `files` an older receipt carried at the
28-
* top level. Empty when the run exported nothing.
29-
*/
30-
function exportedFiles(rawOutput: unknown): Record<string, unknown>[] {
31-
if (!isRecordLike(rawOutput)) return []
32-
const files = isRecordLike(rawOutput.exported) ? rawOutput.exported.files : rawOutput.files
33-
return Array.isArray(files) ? files.filter(isRecordLike) : []
34-
}
35-
3626
/** What the code printed, when it printed anything. */
3727
function printedStdout(rawOutput: unknown): string | undefined {
3828
if (!isRecordLike(rawOutput)) return undefined
@@ -47,7 +37,7 @@ function printedStdout(rawOutput: unknown): string | undefined {
4737
* files so the caller sees what landed instead of re-running the code for it.
4838
*/
4939
function outputTableFailure(error: string, rawOutput: unknown): ToolCallResult {
50-
const files = exportedFiles(rawOutput)
40+
const files = getOutputFileReceipts(rawOutput)
5141
if (files.length === 0) return { success: false, error }
5242
return {
5343
success: false,
@@ -179,7 +169,7 @@ export async function maybeWriteOutputToTable(
179169
* The table result replaces the run's output, so the export receipt rides along
180170
* or the agent has to `files list` to confirm a write it already made.
181171
*/
182-
const exported = exportedFiles(rawOutput)
172+
const exported = getOutputFileReceipts(rawOutput)
183173
const stdout = printedStdout(rawOutput)
184174
const exportNote =
185175
exported.length > 0
@@ -217,10 +207,7 @@ export async function maybeWriteOutputToTable(
217207
span.addEvent(TraceEvent.CopilotTableError, {
218208
[TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500),
219209
})
220-
return {
221-
success: false,
222-
error: `Failed to write to table: ${projectedMessage}`,
223-
}
210+
return outputTableFailure(`Failed to write to table: ${projectedMessage}`, result.output)
224211
}
225212
}
226213
)

‎apps/sim/tools/function/execute.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,31 @@ describe('Function Execute Tool', () => {
137137
})
138138
})
139139

140+
it('preserves explicit workspace export receipts alongside the computed value', async () => {
141+
const exported = {
142+
message: 'Exported report.csv',
143+
files: [
144+
{
145+
fileId: 'file-1',
146+
fileName: 'report.csv',
147+
vfsPath: 'files/report.csv',
148+
size: 12,
149+
sha256: 'digest',
150+
unchanged: false,
151+
},
152+
],
153+
}
154+
const result = await functionExecuteTool.transformResponse?.(
155+
Response.json({
156+
success: true,
157+
output: { result: [{ count: 2 }], stdout: 'done', exported },
158+
}),
159+
{ code: 'return [{ count: 2 }]' }
160+
)
161+
expect(result?.output).toMatchObject({ result: [{ count: 2 }], stdout: 'done', exported })
162+
expect(result?.output.files).toEqual([])
163+
})
164+
140165
it('preserves sandbox cost in a failed Function result', async () => {
141166
const cost = { input: 0, output: 0, total: 0.00012345 }
142167
const result = await functionExecuteTool.transformResponse?.(

‎apps/sim/tools/function/execute.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ To return a file from a Function sandbox, write it to ${SANDBOX_OUTPUT_DIR}. In
264264
result: result.output.result,
265265
stdout: result.output.stdout,
266266
files: result.output.files ?? [],
267+
...(result.output.exported ? { exported: result.output.exported } : {}),
267268
...(result.output.cost ? { cost: result.output.cost } : {}),
268269
...(result.output.sandboxSession ? { sandboxSession: result.output.sandboxSession } : {}),
269270
},

‎apps/sim/tools/function/types.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CodeLanguage } from '@/lib/execution/languages'
22
import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance'
3+
import type { SandboxExportReceipt } from '@/lib/function-execution/output'
34
import type { UserFile } from '@/executor/types'
45
import type { ToolResponse } from '@/tools/types'
56

@@ -91,6 +92,8 @@ export interface CodeExecutionOutput extends ToolResponse {
9192
stdout: string
9293
/** Files harvested from the sandbox output directory, already persisted. */
9394
files: UserFile[]
95+
/** Explicit workspace exports survive adapter projection for Mothership post-processing. */
96+
exported?: SandboxExportReceipt
9497
cost?: {
9598
input: number
9699
output: number

0 commit comments

Comments
 (0)