Skip to content

Commit c2214f0

Browse files
committed
fix(files): keep render failures retryable and never relabel unrendered bytes
Addresses the first review round. - Only memoize a render failure when it is deterministic. A DocCompileUserError means the source will never render, so remembering it is safe; sandbox outages, timeouts, and cancellations are transient and were stranding valid documents for the life of the process. Infra failures now propagate, which also restores DocCompileUserError reaching callers that map it to 409. - Refuse to hand back bytes the resolver could not render. readUserFileContent returns a string, so the resolver's honest application/octet-stream could not travel with it and attachment builders re-inferred a document MIME from the filename — shipping generation source to a provider as a PDF. The file-serve route keeps the graceful passthrough, where a human downloading the bytes is useful. - Normalize the declared type once so a padded or upper-cased source marker cannot pass the resolver gate on one code path and fail it on the other. - Import the doc-not-ready guard lazily. The static import pulled the doc-compile module graph (remote sandbox, task runner, execution limits) into every hydration consumer and broke an unrelated test's module mock in CI.
1 parent ee582e0 commit c2214f0

5 files changed

Lines changed: 69 additions & 13 deletions

File tree

apps/sim/lib/copilot/tools/server/files/doc-compile.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ export async function resolveServableDocBytes(args: {
566566
isGeneratedSource?: boolean
567567
ownerKey?: string
568568
signal?: AbortSignal
569-
}): Promise<{ buffer: Buffer; contentType: string }> {
569+
}): Promise<{ buffer: Buffer; contentType: string; unrendered?: boolean }> {
570570
const { rawBuffer, fileName, workspaceId, isGeneratedSource, ownerKey, signal } = args
571571
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
572572
const extNoDot = ext.replace(/^\./, '')
@@ -595,7 +595,7 @@ export async function resolveServableDocBytes(args: {
595595
*/
596596
const unrendered = (reason: string) => {
597597
logger.warn('Serving stored bytes unrendered', { fileName, workspaceId, reason })
598-
return { buffer: rawBuffer, contentType: 'application/octet-stream' }
598+
return { buffer: rawBuffer, contentType: 'application/octet-stream', unrendered: true }
599599
}
600600

601601
if (workspaceId) {
@@ -626,8 +626,14 @@ export async function resolveServableDocBytes(args: {
626626
try {
627627
return await compileDoc({ source, fileName, workspaceId })
628628
} catch (error) {
629+
// Only a script error is deterministic — the same bytes will never render, so
630+
// remembering that is safe. Infra failures (sandbox create/timeout, S3, an
631+
// aborted request) are transient: memoizing them would strand a perfectly good
632+
// document for the life of the process, and swallowing them would report an
633+
// outage as an unrenderable file. Let those propagate.
634+
if (!(error instanceof DocCompileUserError)) throw error
629635
markUnrenderable(renderKey)
630-
return unrendered(getErrorMessage(error, 'sandbox render failed'))
636+
return unrendered(getErrorMessage(error, 'document source failed to render'))
631637
}
632638
}
633639

@@ -648,7 +654,12 @@ export async function resolveServableDocBytes(args: {
648654
compiledCacheSet(renderKey, compiled)
649655
return { buffer: compiled, contentType: format.contentType }
650656
} catch (error) {
657+
// Unlike the E2B engine, the isolated-vm task does not distinguish a script
658+
// error from an infra one, so the only signal available here is cancellation —
659+
// an aborted run says nothing about the source and must stay retryable rather
660+
// than stranding a renderable document for the life of the process.
661+
if (signal?.aborted) throw error
651662
markUnrenderable(renderKey)
652-
return unrendered(getErrorMessage(error, 'sandbox render failed'))
663+
return unrendered(getErrorMessage(error, 'document source failed to render'))
653664
}
654665
}

apps/sim/lib/execution/payloads/materialization.server.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ describe('readUserFileContent generated-document resolution', () => {
114114
expect(isExecutionResourceLimitError(error)).toBe(false)
115115
})
116116

117+
it('refuses bytes the resolver could not render rather than letting them be relabelled', async () => {
118+
// readUserFileContent returns only a string, so the resolver's honest
119+
// application/octet-stream cannot travel with it — an attachment builder
120+
// downstream would infer application/pdf from the name and ship source bytes
121+
// as a document. Refusing is the only way to keep that from happening.
122+
mockResolveServableDocBytes.mockResolvedValue({
123+
buffer: Buffer.from('<html>not a pdf</html>'),
124+
contentType: 'application/octet-stream',
125+
unrendered: true,
126+
})
127+
128+
await expect(
129+
readUserFileContent(generatedDoc(), { userId: 'user-1', encoding: 'base64' })
130+
).rejects.toThrow(/could not be rendered/)
131+
})
132+
117133
it('passes a plain file through without consulting the document resolver', async () => {
118134
const plainText = Buffer.from('just notes', 'utf8')
119135
mockDownloadFile.mockResolvedValue(plainText)

apps/sim/lib/execution/payloads/materialization.server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,19 @@ export async function readUserFileContent(
315315
if (!servable) {
316316
throw new Error(`File content for ${file.name} is unavailable.`)
317317
}
318+
if (servable.unrendered) {
319+
// The resolver could not produce the document these bytes claim to be and is
320+
// serving them under a generic type. Every consumer here feeds the result to
321+
// something that expects the real document — a provider attachment, a document
322+
// parser, a function-block read — and this function returns only a string, so
323+
// the honest content type cannot travel with it. Relabelling the source as a
324+
// PDF downstream is the corruption this module exists to prevent, so refuse.
325+
// (The file-serve route keeps the graceful passthrough: a human downloading
326+
// the bytes and seeing what they actually are is useful.)
327+
throw new Error(
328+
`File ${file.name} could not be rendered; its stored bytes are not the format its name claims.`
329+
)
330+
}
318331
const { buffer } = servable
319332
if (buffer.length > maxSourceBytes) {
320333
throw new ExecutionResourceLimitError({

apps/sim/lib/uploads/utils/file-utils.server.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,13 @@ export async function downloadFileFromStorage(
347347
export interface ServableFile {
348348
buffer: Buffer
349349
contentType: string
350+
/**
351+
* Set when the bytes could not be rendered and are being served as-is under a
352+
* generic content type. The bytes are NOT the document the filename claims, so a
353+
* consumer that hands them to something expecting that format (a provider
354+
* attachment, a document parser) must refuse rather than relabel them.
355+
*/
356+
unrendered?: boolean
350357
}
351358

352359
/**
@@ -384,8 +391,13 @@ export async function downloadServableFileFromStorage(
384391
// check on the actual stored bytes decides — real binaries pass through there
385392
// unchanged. (The files/download route gates type-first instead; its type is the
386393
// server-written DB record, which workflow state never is.)
387-
const needsRendering =
388-
isGeneratedDocumentSourceType(userFile.type) || isRenderableDocumentName(userFile.name)
394+
// Normalize once: a stored type can arrive padded or upper-cased, and deciding the
395+
// gate on the raw value while deciding `isGeneratedSource` on the normalized one
396+
// would let a padded marker skip the resolver entirely when the name is not a
397+
// renderable document.
398+
const declaredType = userFile.type?.trim().toLowerCase()
399+
const isGeneratedSource = isGeneratedDocumentSourceType(declaredType)
400+
const needsRendering = isGeneratedSource || isRenderableDocumentName(userFile.name)
389401
if (!needsRendering) {
390402
const ext = getFileExtension(userFile.name)
391403
return { buffer, contentType: userFile.type || getMimeTypeFromExtension(ext) }
@@ -402,10 +414,8 @@ export async function downloadServableFileFromStorage(
402414
fileName: userFile.name,
403415
workspaceId,
404416
// Only the positive case carries meaning: the resolver reads this solely to decide
405-
// whether compiling is warranted where no artifact lookup is possible. Normalize
406-
// first — `inferAttachmentMimeType` lowercases before the same Set lookup, and a
407-
// stored type can arrive padded or upper-cased.
408-
isGeneratedSource: isGeneratedDocumentSourceType(userFile.type?.trim().toLowerCase()),
417+
// whether compiling is warranted where no artifact lookup is possible.
418+
isGeneratedSource,
409419
ownerKey: options.ownerKey,
410420
signal: options.signal,
411421
})

apps/sim/lib/uploads/utils/user-file-base64.server.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import {
2727
ExecutionResourceLimitError,
2828
isExecutionResourceLimitError,
2929
} from '@/lib/execution/resource-errors'
30-
import { isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response'
3130
import type { UserFile } from '@/executor/types'
3231

3332
const INLINE_BASE64_JSON_OVERHEAD_BYTES = 512 * 1024
@@ -443,8 +442,15 @@ async function resolveBase64(
443442
// actionable "still being generated" message. Callers that only decorate an
444443
// already-finished result opt out, so a late compile cannot retroactively
445444
// fail completed work.
446-
if (options.throwOnDocNotReady && isDocNotReadyError(error)) {
447-
throw error
445+
if (options.throwOnDocNotReady) {
446+
// Imported lazily: `servable-file-response` pulls in the doc-compile module
447+
// graph (remote sandbox, sandbox task runner, execution limits), and a static
448+
// import here would load all of it for every hydration consumer — mirroring
449+
// the deliberate dynamic import in file-utils.server.ts.
450+
const { isDocNotReadyError } = await import('@/lib/uploads/utils/servable-file-response')
451+
if (isDocNotReadyError(error)) {
452+
throw error
453+
}
448454
}
449455
logger.warn(`[${requestId}] Failed to hydrate base64 for ${file.name}`, error)
450456
return null

0 commit comments

Comments
 (0)