Skip to content

Commit 03c303f

Browse files
committed
fix(mothership): retain completed files after export failure
1 parent b48f9c2 commit 03c303f

5 files changed

Lines changed: 106 additions & 28 deletions

File tree

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,10 @@ import {
132132
resolveInputFiles,
133133
} from '@/lib/mothership/tools/handlers/function-execute'
134134
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
135+
import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer'
135136
import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state'
136137
import { fileOperations } from '@/lib/workspace-files/application/operations'
138+
import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact'
137139
import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text'
138140
import {
139141
POST as addChatResourceRoute,
@@ -2222,7 +2224,13 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
22222224

22232225
it
22242226
.skipIf(!process.env.MSHIP_LOCAL_COMPUTE_IMAGE || !process.env.MSHIP_WORKER_ROOT)
2225-
.each(['returned-value', 'sandbox-file', 'mixed-files', 'missing-table'] as const)(
2227+
.each([
2228+
'returned-value',
2229+
'sandbox-file',
2230+
'mixed-files',
2231+
'missing-table',
2232+
'partial-files',
2233+
] as const)(
22262234
'composes attachment computation, table replacement, durable export and fresh-chat re-import: %s',
22272235
async (mode) => {
22282236
fixture.permission = 'write'
@@ -2360,6 +2368,15 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
23602368
expect(prepared.storedAttachments).toHaveLength(1)
23612369
await vi.mocked(executeShellInSandbox).withImplementation(compute, async () => {
23622370
const callContext = context(chatId)
2371+
if (mode === 'partial-files') {
2372+
await writeCopilotWorkspaceFileByPath(callContext, {
2373+
workspaceId,
2374+
target: { path: `files/existing-${exportName}`, mode: 'create' },
2375+
buffer: Buffer.from('Existing report must survive'),
2376+
inferredMimeType: 'text/csv',
2377+
secretProvenance: { status: 'exact', entries: [] },
2378+
})
2379+
}
23632380
const params = {
23642381
language: 'shell',
23652382
timeout: 30,
@@ -2376,16 +2393,17 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
23762393
...(mode === 'mixed-files'
23772394
? [{ path: `files/raw-${exportName}`, sandboxPath: '/home/user/totals.csv' }]
23782395
: []),
2396+
...(mode === 'partial-files' ? [{ path: `files/existing-${exportName}` }] : []),
23792397
],
23802398
},
23812399
outputTable: mode === 'missing-table' ? generateId() : tableId,
23822400
}
23832401
let result = await executeFunctionExecute(params, callContext)
23842402
expect(result.success, JSON.stringify(result)).toBe(true)
23852403
result = await maybeWriteOutputToFile('run_function', params, result, callContext)
2386-
expect(result.success, JSON.stringify(result)).toBe(true)
2404+
expect(result.success, JSON.stringify(result)).toBe(mode !== 'partial-files')
23872405
result = await maybeWriteOutputToTable('run_function', params, result, callContext)
2388-
if (mode === 'missing-table') {
2406+
if (mode === 'missing-table' || mode === 'partial-files') {
23892407
expect(result.success).toBe(false)
23902408
expect(result.error).toContain('already written')
23912409
expect(result.output).toMatchObject({
@@ -2414,7 +2432,7 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
24142432
.from(userTableRows)
24152433
.where(eq(userTableRows.tableId, tableId))
24162434
const expectedRows =
2417-
mode === 'missing-table'
2435+
mode === 'missing-table' || mode === 'partial-files'
24182436
? []
24192437
: [
24202438
{ col_account: 'alpha', col_total: 22 },
@@ -2443,6 +2461,13 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
24432461
sandboxSession: 'created',
24442462
})
24452463
expect(sessions.size).toBe(2)
2464+
if (mode === 'partial-files') {
2465+
const existing = await readWorkspaceFileArtifact.execute({
2466+
principal: { kind: 'personal_api_key', userId: 'run-reader', keyId: 'local-key' },
2467+
input: { workspaceId, reference: `files/existing-${exportName}`, maxBytes: 1024 },
2468+
})
2469+
expect(existing.buffer.toString('utf8')).toBe('Existing report must survive')
2470+
}
24462471
})
24472472
} finally {
24482473
setEnvFlags({ isMothershipSandboxEnabled: previousWorkbenchEnabled })

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,54 @@ describe('maybeWriteOutputToFile', () => {
243243
expect(result.resources).toContainEqual(existingResource)
244244
})
245245

246+
it.each(['before-write', 'after-first-write', 'after-sandbox-export'] as const)(
247+
'reports only committed files when export fails: %s',
248+
async (failurePoint) => {
249+
const receipt = { fileId: 'file-1', fileName: 'report.csv', vfsPath: 'files/report.csv' }
250+
if (failurePoint === 'after-first-write') {
251+
mockWriteWorkspaceFileByPath.mockResolvedValueOnce({
252+
id: 'file-1',
253+
name: 'report.csv',
254+
vfsPath: 'files/report.csv',
255+
mode: 'create',
256+
})
257+
}
258+
mockWriteWorkspaceFileByPath.mockRejectedValueOnce(new Error('Destination already exists'))
259+
const result = await maybeWriteOutputToFile(
260+
RunFunction.id,
261+
{
262+
outputs: {
263+
files: [
264+
{ path: 'files/report.csv' },
265+
...(failurePoint === 'after-first-write' ? [{ path: 'files/second.csv' }] : []),
266+
],
267+
},
268+
},
269+
{
270+
success: true,
271+
output: {
272+
result: [{ name: 'Ada' }],
273+
stdout: '1 row',
274+
...(failurePoint === 'after-sandbox-export' ? { exported: { files: [receipt] } } : {}),
275+
},
276+
},
277+
buildContext()
278+
)
279+
expect(result.success).toBe(false)
280+
expect(result.error).toContain('Destination already exists')
281+
if (failurePoint === 'before-write') {
282+
expect(result.output).toBeUndefined()
283+
expect(result.error).not.toContain('already written')
284+
} else {
285+
expect(result.error).toContain('already written')
286+
expect(result.output).toMatchObject({ files: [expect.objectContaining(receipt)] })
287+
}
288+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(
289+
failurePoint === 'after-first-write' ? 2 : 1
290+
)
291+
}
292+
)
293+
246294
it('classifies large structured output from its serialized bytes instead of its object count', async () => {
247295
const registry = new ResolvedSecretTraceRegistry(
248296
[

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

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ export function getOutputFileReceipts(output: unknown): Record<string, unknown>[
3535
return Array.isArray(files) ? files.filter(isRecordLike) : []
3636
}
3737

38+
/** Completed writes remain usable even when a later output operation fails. */
39+
export function outputWriteFailure(
40+
error: string,
41+
files: Record<string, unknown>[]
42+
): ToolCallResult {
43+
if (files.length === 0) return { success: false, error }
44+
return {
45+
success: false,
46+
error: `${error}. The listed output files were already written.`,
47+
output: { files },
48+
}
49+
}
50+
3851
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([RunFunction.id, UserTable.id])
3952

4053
export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
@@ -379,6 +392,7 @@ export async function maybeWriteOutputToFile(
379392
[TraceAttr.WorkspaceId]: workspaceId,
380393
},
381394
async (span) => {
395+
const committedFiles = [...previousFiles]
382396
try {
383397
const preparedByFormat = new Map<
384398
OutputFormat,
@@ -439,6 +453,13 @@ export async function maybeWriteOutputToFile(
439453
inferredMimeType: contentType,
440454
secretProvenance,
441455
})
456+
committedFiles.push({
457+
fileId: written.id,
458+
fileName: written.name,
459+
vfsPath: written.vfsPath,
460+
size: buffer.length,
461+
downloadUrl: written.downloadUrl,
462+
})
442463
writtenFiles.push({
443464
...written,
444465
bytes: buffer.length,
@@ -468,16 +489,6 @@ export async function maybeWriteOutputToFile(
468489
})),
469490
})
470491

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-
]
481492
return {
482493
success: true,
483494
output: {
@@ -489,7 +500,7 @@ export async function maybeWriteOutputToFile(
489500
writtenFiles.length === 1
490501
? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)`
491502
: `Output written to ${writtenFiles.length} files`,
492-
files,
503+
files: committedFiles,
493504
fileId: firstWritten.id,
494505
fileName: firstWritten.name,
495506
vfsPath: firstWritten.vfsPath,
@@ -521,10 +532,10 @@ export async function maybeWriteOutputToFile(
521532
span.addEvent(TraceEvent.CopilotOutputFileError, {
522533
[TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500),
523534
})
524-
return {
525-
success: false,
526-
error: `Failed to write output file: ${message}`,
527-
}
535+
return outputWriteFailure(
536+
`Failed to write output file: ${projectedMessage}`,
537+
committedFiles
538+
)
528539
}
529540
}
530541
)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ describe('automatic Copilot tool-output table persistence', () => {
271271
expect(result).toEqual({
272272
success: false,
273273
error:
274-
'Failed to write to table: Table operation failed. The declared output files were already written.',
274+
'Failed to write to table: Table operation failed. The listed output files were already written.',
275275
output: { files },
276276
})
277277
})

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +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'
12+
import { getOutputFileReceipts, outputWriteFailure } from '@/lib/mothership/request/tools/files'
1313
import { denyOutputWriteWithoutWritePermission } from '@/lib/mothership/request/tools/permissions'
1414
import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
1515
import type { ExecutionContext, ToolCallResult } from '@/lib/mothership/request/types'
@@ -37,13 +37,7 @@ function printedStdout(rawOutput: unknown): string | undefined {
3737
* files so the caller sees what landed instead of re-running the code for it.
3838
*/
3939
function outputTableFailure(error: string, rawOutput: unknown): ToolCallResult {
40-
const files = getOutputFileReceipts(rawOutput)
41-
if (files.length === 0) return { success: false, error }
42-
return {
43-
success: false,
44-
error: `${error}. The declared output files were already written.`,
45-
output: { files },
46-
}
40+
return outputWriteFailure(error, getOutputFileReceipts(rawOutput))
4741
}
4842
/**
4943
* Replaces a table's rows with wire rows keyed by column name. Translates the

0 commit comments

Comments
 (0)