diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 38c3cfcf73d..4920d4f55d2 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -36,6 +36,7 @@ const { mockFindLocalFile, mockReadLocalFileWithinLimit, mockCreateFileResponse, + mockCreateConditionalFileResponse, mockCreateErrorResponse, FileNotFoundError, serveLogger, @@ -64,6 +65,7 @@ const { mockFindLocalFile: vi.fn(), mockReadLocalFileWithinLimit: vi.fn(), mockCreateFileResponse: vi.fn(), + mockCreateConditionalFileResponse: vi.fn(), mockCreateErrorResponse: vi.fn(), FileNotFoundError: FileNotFoundErrorClass, } @@ -129,6 +131,7 @@ vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ vi.mock('@/app/api/files/utils', () => ({ FileNotFoundError, createFileResponse: mockCreateFileResponse, + createConditionalFileResponse: mockCreateConditionalFileResponse, createErrorResponse: mockCreateErrorResponse, getContentType: mockGetContentType, extractStorageKey: vi.fn().mockImplementation((path: string) => path.split('/').pop()), @@ -192,6 +195,11 @@ describe('File Serve API Route', () => { }) } ) + // Delegates so the existing assertions on the response payload — including its + // Cache-Control — read the same call list whichever helper the route reached for. + mockCreateConditionalFileResponse.mockImplementation((file: unknown) => + mockCreateFileResponse(file) + ) mockCreateErrorResponse.mockImplementation((error: Error) => { return new Response(JSON.stringify({ error: error.name, message: error.message }), { status: error.name === 'FileNotFoundError' ? 404 : 500, @@ -439,6 +447,70 @@ describe('File Serve API Route', () => { expect(mockVerifyFileAccess).not.toHaveBeenCalled() }) + describe('versioned cache lifetime', () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'test-user-id', + workspaceId: 'test-workspace-id', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2026-08-01T01:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + }, + } + + async function serveVersionedDoc(dependsOnReferencedFiles: boolean) { + mockResolveStoredFileContext.mockResolvedValue('workspace') + mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') + mockAuthenticateWorkspaceFile.mockResolvedValue(principal) + mockResolveServableDocBytes.mockResolvedValue({ + buffer: Buffer.from('compiled'), + contentType: 'application/pdf', + ...(dependsOnReferencedFiles ? { dependsOnReferencedFiles: true } : {}), + }) + + const req = new NextRequest( + 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf?v=1756684800000' + ) + await GET(req, { + params: Promise.resolve({ path: ['workspace', 'test-workspace-id', 'report.pdf'] }), + }) + return mockCreateFileResponse.mock.calls.at(-1)?.[0] + } + + it('caches a versioned document immutably when its bytes derive from the stored source alone', async () => { + expect(await serveVersionedDoc(false)).toEqual( + expect.objectContaining({ cacheControl: 'private, max-age=31536000, immutable' }) + ) + }) + + it('spends no validator on an immutable response, which is never revalidated', async () => { + await serveVersionedDoc(false) + expect(mockCreateConditionalFileResponse).not.toHaveBeenCalled() + expect(mockCreateFileResponse).toHaveBeenCalled() + }) + + it('attaches a validator to a revalidated response so the next check can be answered 304', async () => { + await serveVersionedDoc(true) + expect(mockCreateConditionalFileResponse).toHaveBeenCalled() + }) + + it('keeps a versioned document revalidated when it was compiled against referenced files', async () => { + /** + * The URL carries the file's own `updatedAt`, which does not move when a + * REFERENCED file changes — so an immutable lifetime would pin the stale + * render in the browser cache until the document itself is edited. + */ + expect(await serveVersionedDoc(true)).toEqual( + expect.objectContaining({ cacheControl: 'private, no-cache, must-revalidate' }) + ) + }) + }) + it('serves a mothership chat attachment stored under a workspace key', async () => { /** * The attachment shares the `workspace/…` prefix but is recorded as diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 1bd88eeffd9..e946266ec2e 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -30,9 +30,11 @@ import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/pa import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization' import { + createConditionalFileResponse, createErrorResponse, createFileResponse, FileNotFoundError, + type FileResponse, findLocalFile, getContentType, readLocalFileWithinLimit, @@ -64,8 +66,25 @@ interface ServeOptions { raw: boolean /** `preview=1` — the caller renders these bytes rather than saving them. */ preview: boolean - /** `v=` — the URL addresses content-immutable bytes. */ + /** `v=` — the caller asserts the URL addresses one fixed content revision. */ versioned: boolean + /** The request's `If-None-Match`, so a revalidation can be answered 304 instead of re-sending. */ + ifNoneMatch: string | null +} + +interface ServableBytes { + buffer: Buffer + contentType: string + /** + * These bytes were resolved against OTHER files' current content — a page inlining its images, + * or a document compiled against the files it references — so the same storage key can serve + * different bytes over time while this file's own key and `updatedAt` stay put. + * + * Required, so a branch added to the resolver cannot inherit the cacheable answer by saying + * nothing — the same reason the transfer ceiling is asserted where the branches converge rather + * than inside each one. + */ + dependsOnReferencedFiles: boolean } /** @@ -95,12 +114,16 @@ async function resolveServableBytes(params: { /** The stored record's content type, where the caller has the record. */ fileType?: string signal: AbortSignal | undefined -}): Promise<{ buffer: Buffer; contentType: string }> { +}): Promise { // `raw` is the stored source, already bounded by the read that produced it, but it // goes through the same check so the ceiling holds for everything this returns // rather than for every branch someone remembered to cover. - const resolved = params.options.raw - ? { buffer: params.buffer, contentType: getContentType(params.filename) } + const resolved: ServableBytes = params.options.raw + ? { + buffer: params.buffer, + contentType: getContentType(params.filename), + dependsOnReferencedFiles: false, + } : await resolveTransformedBytes(params) assertKnownSizeWithinLimit( resolved.buffer.length, @@ -120,7 +143,7 @@ async function resolveTransformedBytes(params: { filePrincipal?: Principal fileType?: string signal: AbortSignal | undefined -}): Promise<{ buffer: Buffer; contentType: string }> { +}): Promise { const { buffer, filename, @@ -146,7 +169,12 @@ async function resolveTransformedBytes(params: { await renderSimPageDocumentWithAssets(text, { workspaceId }), 'utf8' ) - return { buffer: rendered, contentType: 'text/html' } + // Inlines the workspace images the page references, read at their CURRENT content. + return { + buffer: rendered, + contentType: 'text/html', + dependsOnReferencedFiles: true, + } } } @@ -154,10 +182,11 @@ async function resolveTransformedBytes(params: { // Images resolve independently of the document path: a HEIF has no compiled-source // concept, so it never reaches the doc branch. const image = await resolveServableImageBytes(buffer, storageKey) - if (image) return image + // Transcoded from THIS file's stored bytes, so it lives and dies with the storage key. + if (image) return { ...image, dependsOnReferencedFiles: false } } - return resolveServableDocBytes({ + const doc = await resolveServableDocBytes({ rawBuffer: buffer, fileName: filename, workspaceId, @@ -165,6 +194,11 @@ async function resolveTransformedBytes(params: { ownerKey, signal, }) + return { + buffer: doc.buffer, + contentType: doc.contentType, + dependsOnReferencedFiles: doc.dependsOnReferencedFiles, + } } const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/ @@ -184,18 +218,41 @@ const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' /** - * Cache-Control for a served file. A versioned request (`?v=`) addresses - * content-immutable bytes — generated docs are content-addressed and the version - * bumps on every edit — so the browser may cache it indefinitely; re-opens and - * focus refetches then resolve from cache with no round trip. Unversioned workspace - * reads stay revalidated because the same storage key is edited in place. + * Cache-Control for a served file. + * + * A versioned request (`?v=`) normally addresses content-immutable bytes: a workspace + * file's content write stores the new bytes under a NEW storage key, so a given key's stored + * source never changes and the browser may cache it indefinitely — re-opens and focus refetches + * then resolve from cache with no round trip. + * + * That promise does NOT hold when the response was resolved against other files' current content + * (`derived-from-referenced-files`): a document compiled against the files it references, or a page + * inlining its images, recompiles per request, so the same key serves different bytes once a + * referenced file changes — while this file's key and `updatedAt`, and therefore the whole URL, + * stay put. Promising immutability there pins a stale render in the browser cache for a year, so + * those responses stay revalidated whether or not the request carried a version. */ function resolveServeCacheControl( versioned: boolean, - context: string | undefined + context: string | undefined, + dependsOnReferencedFiles: boolean ): string | undefined { - if (versioned) return IMMUTABLE_CACHE_CONTROL - return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined + if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL + return context === 'workspace' || dependsOnReferencedFiles + ? WORKSPACE_REVALIDATE_CACHE_CONTROL + : undefined +} + +/** + * Sends a resolved file, attaching a validator only where the client may actually revalidate. + * + * An immutable response is never revalidated, so digesting its buffer — a pass over up to the + * whole transfer ceiling — would cost the compute and never collect a single 304. + */ +function serveResolvedFile(file: FileResponse, ifNoneMatch: string | null): NextResponse { + return file.cacheControl === IMMUTABLE_CACHE_CONTROL + ? createFileResponse(file) + : createConditionalFileResponse(file, ifNoneMatch) } export const GET = withRouteHandler( @@ -281,6 +338,7 @@ export const GET = withRouteHandler( raw: query.raw === '1', preview: query.preview === '1', versioned: query.v != null, + ifNoneMatch: request.headers.get('if-none-match'), } if (workspacePrincipal) { @@ -381,12 +439,19 @@ async function handleWorkspaceFile( workspaceId, size: resolved.buffer.length, }) - return createFileResponse({ - buffer: resolved.buffer, - contentType: resolved.contentType, - filename: file.name, - cacheControl: resolveServeCacheControl(options.versioned, 'workspace'), - }) + return serveResolvedFile( + { + buffer: resolved.buffer, + contentType: resolved.contentType, + filename: file.name, + cacheControl: resolveServeCacheControl( + options.versioned, + 'workspace', + resolved.dependsOnReferencedFiles + ), + }, + options.ifNoneMatch + ) } async function handleLocalFile( @@ -427,7 +492,11 @@ async function handleLocalFile( const segment = filename.split('/').pop() || filename const displayName = stripStorageKeyPrefix(segment) const workspaceId = getWorkspaceIdForCompile(filename) - const { buffer: fileBuffer, contentType } = await resolveServableBytes({ + const { + buffer: fileBuffer, + contentType, + dependsOnReferencedFiles, + } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, storageKey: filename, @@ -439,12 +508,19 @@ async function handleLocalFile( logger.info('Local file served', { userId, filename, size: fileBuffer.length }) - return createFileResponse({ - buffer: fileBuffer, - contentType, - filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context), - }) + return serveResolvedFile( + { + buffer: fileBuffer, + contentType, + filename: displayName, + cacheControl: resolveServeCacheControl( + options.versioned, + context, + dependsOnReferencedFiles + ), + }, + options.ifNoneMatch + ) } catch (error) { logServeFailure('Error reading local file:', error) throw error @@ -494,7 +570,11 @@ async function handleCloudProxy( const segment = cloudKey.split('/').pop() || 'download' const displayName = stripStorageKeyPrefix(segment) const workspaceId = getWorkspaceIdForCompile(cloudKey) - const { buffer: fileBuffer, contentType } = await resolveServableBytes({ + const { + buffer: fileBuffer, + contentType, + dependsOnReferencedFiles, + } = await resolveServableBytes({ buffer: rawBuffer, filename: displayName, storageKey: cloudKey, @@ -511,12 +591,19 @@ async function handleCloudProxy( context, }) - return createFileResponse({ - buffer: fileBuffer, - contentType, - filename: displayName, - cacheControl: resolveServeCacheControl(options.versioned, context), - }) + return serveResolvedFile( + { + buffer: fileBuffer, + contentType, + filename: displayName, + cacheControl: resolveServeCacheControl( + options.versioned, + context, + dependsOnReferencedFiles + ), + }, + options.ifNoneMatch + ) } catch (error) { logServeFailure('Error downloading from cloud storage:', error) throw error diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index 7a01716b04e..bb745372d76 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + createConditionalFileResponse, createFileResponse, encodeFilenameForHeader, extractFilename, @@ -508,3 +509,61 @@ describe('findLocalFile - Path Traversal Security Tests', () => { ) }) }) + +describe('createConditionalFileResponse', () => { + const file = { + buffer: Buffer.from('compiled-document-bytes'), + contentType: 'application/pdf', + filename: 'report.pdf', + cacheControl: 'private, no-cache, must-revalidate', + } + + function etagOf(ifNoneMatch: string | null = null): string { + return createConditionalFileResponse(file, ifNoneMatch).headers.get('ETag') as string + } + + it('sends the body with a strong validator when the client holds nothing', () => { + const response = createConditionalFileResponse(file, null) + + expect(response.status).toBe(200) + expect(response.headers.get('ETag')).toMatch(/^"[A-Za-z0-9_-]+"$/) + expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + }) + + it('answers 304 without a body when the client already holds these bytes', async () => { + const response = createConditionalFileResponse(file, etagOf()) + + expect(response.status).toBe(304) + expect(await response.text()).toBe('') + // Repeated so the stored response is refreshed with this request's lifetime. + expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + }) + + it('sends the body when the client holds a validator for different bytes', () => { + const stale = createConditionalFileResponse( + { ...file, buffer: Buffer.from('an-earlier-render') }, + null + ).headers.get('ETag') as string + + expect(createConditionalFileResponse(file, stale).status).toBe(200) + }) + + it('matches weakly, so a cache that stored a weak validator still revalidates', () => { + expect(createConditionalFileResponse(file, `W/${etagOf()}`).status).toBe(304) + }) + + it('matches one entry out of a list, and the wildcard', () => { + expect(createConditionalFileResponse(file, `"other", ${etagOf()}`).status).toBe(304) + expect(createConditionalFileResponse(file, '*').status).toBe(304) + }) + + it('gives bytes that differ only in one byte different validators', () => { + const a = etagOf() + const b = createConditionalFileResponse( + { ...file, buffer: Buffer.from('compiled-document-byteS') }, + null + ).headers.get('ETag') + + expect(a).not.toBe(b) + }) +}) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index c7bd642db8e..0bd29f54288 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { @@ -267,6 +268,56 @@ export function createFileResponse(file: FileResponse): NextResponse { return new NextResponse(file.buffer as BodyInit, { status: 200, headers }) } +/** + * Whether an `If-None-Match` header claims the client already holds `etag`. + * + * Compared weakly, per RFC 9110: a cache that stored the response under a weak validator sends + * `W/"…"` back, and that still identifies the same bytes for a GET. + */ +function ifNoneMatchHolds(header: string | null, etag: string): boolean { + if (!header) return false + if (header.trim() === '*') return true + return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '').trim() === etag) +} + +/** + * A file response carrying a strong validator, answering 304 when the client already holds + * exactly these bytes. + * + * For responses the browser is told to revalidate, the alternative is re-sending the whole body on + * every check — and a document resolved against other files is re-resolved per request precisely + * BECAUSE its bytes may have changed, so it cannot be given a cache lifetime instead. The + * validator is the digest of the bytes about to be sent, which makes it exact by construction: it + * cannot claim freshness for a body that differs, however the body was produced. + * + * This is deliberately NOT folded into {@link createFileResponse}. Digesting costs a pass over the + * buffer — up to the full transfer ceiling — which is worth it only where a 304 can actually be + * returned. A response already served as immutable is never revalidated, so it would pay the pass + * and never collect. + */ +export function createConditionalFileResponse( + file: FileResponse, + ifNoneMatch: string | null +): NextResponse { + const etag = `"${createHash('sha256').update(file.buffer).digest('base64url')}"` + + if (ifNoneMatchHolds(ifNoneMatch, etag)) { + // A 304 repeats the headers that govern caching, so the stored response is refreshed with the + // lifetime this request would have granted it rather than keeping the one it was stored with. + return new NextResponse(null, { + status: 304, + headers: { + ETag: etag, + 'Cache-Control': file.cacheControl || 'private, no-cache', + }, + }) + } + + const response = createFileResponse(file) + response.headers.set('ETag', etag) + return response +} + export function createErrorResponse(error: Error, status = 500): NextResponse { const statusCode = error instanceof FileNotFoundError diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index c451938dd4e..9d918e79a80 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -373,13 +373,14 @@ export class DocNotReadyError extends Error { /** * Fetch compiled/binary file content via the serve URL. * - * A `version` (the file record's `updatedAt`) makes the URL content-immutable: the - * serve route marks versioned responses `immutable`, so the browser HTTP cache - * resolves re-opens and focus refetches with no round trip. Generated docs are - * edited in place (same storage key), so an unversioned caller cannot assume - * immutability and instead busts + bypasses the cache to always read fresh. A 409 - * means a generated doc is still compiling — surfaced as {@link DocNotReadyError} - * so the query keeps polling. + * A `version` (the file record's `updatedAt`) lets the serve route mark the response + * `immutable`, so the browser HTTP cache resolves re-opens and focus refetches with no + * round trip. The route withholds that promise for bytes it resolved against OTHER + * files — a doc compiled against its references, a page inlining its images — which the + * same key can serve differently over time. An unversioned caller makes no immutability + * claim at all and busts + bypasses the cache to always read fresh. A 409 means a + * generated doc is still compiling — surfaced as {@link DocNotReadyError} so the query + * keeps polling. */ async function fetchWorkspaceFileBinary( url: string, @@ -401,11 +402,10 @@ async function fetchWorkspaceFileBinary( * storage key (e.g. after a file is re-uploaded) correctly busts the cache. * * `options.version` is a content version (the record's `updatedAt`) folded into the - * query key. Generated docs are edited IN PLACE — `edit_content` keeps the SAME - * storage key — so without a version the cache is never busted and the open - * preview keeps showing the stale binary after a regenerate. Versioning the key - * makes the preview refetch whenever the file's content changes (and on first - * open, keyed to the current content rather than a stale cached entry). + * query key, and it is what lets the response be cached as immutable. A content write + * rotates the storage key, so `key` alone would already re-key the query; `version` + * additionally covers a recompile that leaves the key alone, and keys the first open to + * the current content rather than a stale cached entry. */ export function useWorkspaceFileBinary( workspaceId: string, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts index 0c2c5a0025e..a0a23033053 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts @@ -169,6 +169,7 @@ describe('collectReferencedFileIds', () => { ).resolves.toEqual({ buffer: Buffer.from('%PDF-built'), contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [ { fileId: ID, @@ -219,6 +220,7 @@ describe('collectReferencedFileIds', () => { ).resolves.toEqual({ buffer: Buffer.from('%PDF-cached'), contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [ { fileId: ID, diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 41900a665ab..1941d2b4353 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -147,6 +147,29 @@ export interface CompiledDocResult { buffer: Buffer contentType: string contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] + /** + * The artifact was resolved against OTHER files' current content, so these bytes are not a + * function of this file's stored source alone: the same storage key compiles to different bytes + * once a referenced file changes, with nothing about this file changing. A caller that assigns + * the response a cache lifetime must not promise immutability for it. + * + * Required, so a compile path added or changed later cannot omit it and silently inherit a + * cacheable-forever answer — which is exactly what the isolated-VM fallback did while this was + * optional, despite reading workspace files live through its broker. + */ + dependsOnReferencedFiles: boolean +} + +/** + * Whether a compile result must be treated as resolved against other files. + * + * The union of what the source ASKS for and what the compile actually TOUCHED. Neither alone is + * sufficient: a statically detectable reference whose read failed records no access, and the + * isolated-VM broker reaches files the static scan cannot see. Either one means these bytes can + * change while this file's own stored source does not. + */ +function touchesReferencedFiles(source: string, accessedCount: number): boolean { + return accessedCount > 0 || collectReferencedFileIds(source).size > 0 } function referencedImageIdentities( @@ -567,6 +590,7 @@ async function buildCompiledDoc( return { buffer, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(args.source, contributingFiles.length), ...(contributingFiles.length > 0 ? { contributingFiles } : {}), } } @@ -657,6 +681,10 @@ async function compileDocInLegacySandbox( return { buffer: cached.buffer, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles( + args.source, + cached.contributingFiles?.length ?? 0 + ), ...(cached.contributingFiles && cached.contributingFiles.length > 0 ? { contributingFiles: cached.contributingFiles } : {}), @@ -679,6 +707,7 @@ async function compileDocInLegacySandbox( return { buffer, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(args.source, contributingFiles.size), ...(contributingFiles.size > 0 ? { contributingFiles: [...contributingFiles.values()] } : {}), } } @@ -722,6 +751,7 @@ export async function compileDoc(args: CompileArgs): Promise return { buffer: existing, contentType: fmt.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(source, contributingFiles.length), ...(contributingFiles.length > 0 ? { contributingFiles } : {}), } } @@ -891,11 +921,19 @@ export async function resolveServableDocBytes(args: { // explicitly alongside the table-driven formats. const magic = format?.magic ?? (extNoDot === 'xlsx' ? ZIP_MAGIC : undefined) if (magic && bufferStartsWith(rawBuffer, magic)) { - return { buffer: rawBuffer, contentType: getContentType(fileName) } + return { + buffer: rawBuffer, + contentType: getContentType(fileName), + dependsOnReferencedFiles: false, + } } if (!format && extNoDot !== 'xlsx') { - return { buffer: rawBuffer, contentType: getContentType(fileName) } + return { + buffer: rawBuffer, + contentType: getContentType(fileName), + dependsOnReferencedFiles: false, + } } const source = rawBuffer.toString('utf-8') @@ -912,7 +950,7 @@ export async function resolveServableDocBytes(args: { const published = await loadCompiledDocByExt(workspaceId, source, extNoDot, { allowPublishedReferencedArtifact: true, }) - if (published) return published + if (published) return { ...published, dependsOnReferencedFiles: true } throw new Error( 'Referenced document resolution requires an authorized workspace file principal' ) @@ -923,7 +961,9 @@ export async function resolveServableDocBytes(args: { allowLegacyReferencedArtifact: true, filePrincipal, }) - if (stored) return stored + // Reached only where the source references nothing, so the artifact is keyed by the source + // alone and cannot change while that source does not. + if (stored) return { ...stored, dependsOnReferencedFiles: false } throw new DocCompileUserError('Document is still being generated', { pending: true }) } @@ -937,6 +977,12 @@ export async function resolveServableDocBytes(args: { return { buffer: cached.buffer, contentType: format.contentType, + // The process-local cache is shared with compiles that DID carry a workspace, so a cached + // entry can hold contributor identities even though this call passed none. + dependsOnReferencedFiles: touchesReferencedFiles( + source, + cached.contributingFiles?.length ?? 0 + ), ...(cached.contributingFiles && cached.contributingFiles.length > 0 ? { contributingFiles: cached.contributingFiles } : {}), @@ -949,5 +995,9 @@ export async function resolveServableDocBytes(args: { { ownerKey, signal } ) compiledCacheSet(cacheKey, compiled) - return { buffer: compiled, contentType: format.contentType } + return { + buffer: compiled, + contentType: format.contentType, + dependsOnReferencedFiles: touchesReferencedFiles(source, 0), + } } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index 87432887d62..288ee02cdff 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -168,6 +168,7 @@ describe('resolveServableDocBytes', () => { expect(result).toEqual({ buffer: Buffer.from('%PDF-rebuilt'), contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [ { fileId: 'reference-1', @@ -273,6 +274,7 @@ describe('resolveServableDocBytes', () => { ).resolves.toEqual({ buffer: publishedArtifact, contentType: 'application/pdf', + dependsOnReferencedFiles: true, }) expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() @@ -377,11 +379,49 @@ describe('resolveServableDocBytes', () => { fileName: 'report.pdf', workspaceId: WORKSPACE_ID, }) - ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf' }) + ).resolves.toEqual({ + buffer: compiled, + contentType: 'application/pdf', + // The isolated-VM path, but this source references nothing and the compile touched + // nothing — so it stays cacheable. The flag tracks dependency, not which backend ran. + dependsOnReferencedFiles: false, + }) expect(mockLoadCompiledDoc).not.toHaveBeenCalled() expect(mockStoreCompiledDoc).not.toHaveBeenCalled() }) + it('reports a reference dependency when the isolated-VM broker reads a workspace file', async () => { + /** + * The isolated-VM fallback returns before the static reference scan, so nothing about the + * SOURCE marks it as dependent — only the broker access does. Missing that is what let a + * versioned request take a one-year immutable lifetime for bytes that change when the + * referenced file changes. + */ + const compiled = Buffer.from('%PDF-broker-read') + const contributor = { + fileId: 'broker-reference-1', + key: 'workspace/workspace-1/broker-reference-1.png', + context: 'workspace' as const, + contentUpdatedAt: new Date('2026-08-07T01:00:00.000Z'), + } + setEnvFlags({ isDocSandboxEnabled: false }) + mockRunSandboxTask.mockImplementationOnce((...args: unknown[]) => { + const options = args[2] as { + onWorkspaceFileAccess?: (identity: typeof contributor) => void + } + options.onWorkspaceFileAccess?.(contributor) + return Promise.resolve(compiled) + }) + + const result = await resolveServableDocBytes({ + rawBuffer: Buffer.from('const viaBroker = true'), + fileName: 'report.pdf', + workspaceId: WORKSPACE_ID, + }) + + expect(result.dependsOnReferencedFiles).toBe(true) + }) + it('preserves contributor identities when a servable document hits the local compile cache', async () => { const source = 'const cacheContributor = true' const compiled = Buffer.from('%PDF-cached-with-contributor') @@ -411,6 +451,7 @@ describe('resolveServableDocBytes', () => { ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [contributor], }) @@ -423,6 +464,7 @@ describe('resolveServableDocBytes', () => { ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf', + dependsOnReferencedFiles: true, contributingFiles: [contributor], }) expect(mockRunSandboxTask).toHaveBeenCalledTimes(1) @@ -441,7 +483,11 @@ describe('resolveServableDocBytes', () => { workspaceId: WORKSPACE_ID, }) - expect(result).toEqual({ buffer: compiled, contentType: 'application/pdf' }) + expect(result).toEqual({ + buffer: compiled, + contentType: 'application/pdf', + dependsOnReferencedFiles: true, + }) expect(mockExecuteInSandbox).not.toHaveBeenCalled() expect(mockRunSandboxTask).toHaveBeenCalledWith( 'pdf-generate', @@ -465,7 +511,11 @@ describe('resolveServableDocBytes', () => { fileName: 'report.pdf', workspaceId: WORKSPACE_ID, }) - ).resolves.toEqual({ buffer: compiled, contentType: 'application/pdf' }) + ).resolves.toEqual({ + buffer: compiled, + contentType: 'application/pdf', + dependsOnReferencedFiles: true, + }) expect(mockRunSandboxTask).toHaveBeenCalledTimes(1) expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() expect(mockStoreCompiledDoc).not.toHaveBeenCalled()