Skip to content

Commit 435c49f

Browse files
committed
fix(files): surface download aborts and sync the copilot artifact bucket to workers
1 parent fdca606 commit 435c49f

3 files changed

Lines changed: 125 additions & 3 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockDownloadFile } = vi.hoisted(() => ({
7+
mockDownloadFile: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/billing/storage', () => ({
11+
decrementStorageUsageForBillingContextInTx: vi.fn(),
12+
incrementStorageUsageForBillingContextInTx: vi.fn(),
13+
maybeNotifyStorageLimitForBillingContext: vi.fn(),
14+
resolveStorageBillingContext: vi.fn(),
15+
}))
16+
17+
vi.mock('@/lib/uploads', () => ({
18+
getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'),
19+
}))
20+
21+
vi.mock('@/lib/uploads/core/storage-service', () => ({
22+
deleteFile: vi.fn(),
23+
downloadFile: mockDownloadFile,
24+
hasCloudStorage: vi.fn(() => false),
25+
headObject: vi.fn(),
26+
uploadFile: vi.fn(),
27+
}))
28+
29+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
30+
assertWorkspaceFileFolderTarget: vi.fn(async () => null),
31+
buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()),
32+
fileNameExistsInWorkspaceFolder: vi.fn(async () => false),
33+
findWorkspaceFileFolderIdByPath: vi.fn(),
34+
getWorkspaceFileFolderPath: vi.fn(),
35+
listWorkspaceFileFolders: vi.fn(async () => []),
36+
normalizeWorkspaceFileItemName: vi.fn((name: string) => name),
37+
resolveWorkspaceFileFolderTarget: vi.fn(async () => null),
38+
}))
39+
40+
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
41+
import {
42+
fetchWorkspaceFileBuffer,
43+
type WorkspaceFileRecord,
44+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
45+
46+
const FILE: WorkspaceFileRecord = {
47+
id: 'file-1',
48+
workspaceId: 'workspace-1',
49+
name: 'notes.txt',
50+
key: 'workspace/workspace-1/notes.txt',
51+
path: '/api/files/serve/workspace/workspace-1/notes.txt',
52+
size: 5,
53+
type: 'text/plain',
54+
uploadedBy: 'user-1',
55+
uploadedAt: new Date('2026-09-01T00:00:00.000Z'),
56+
updatedAt: new Date('2026-09-01T00:00:00.000Z'),
57+
}
58+
59+
function sizeLimitError(): unknown {
60+
try {
61+
assertKnownSizeWithinLimit(2, 1, 'test')
62+
} catch (error) {
63+
return error
64+
}
65+
throw new Error('assertKnownSizeWithinLimit did not throw')
66+
}
67+
68+
describe('fetchWorkspaceFileBuffer', () => {
69+
beforeEach(() => {
70+
vi.clearAllMocks()
71+
})
72+
73+
it('forwards the byte ceiling and the cancellation signal to storage', async () => {
74+
const bytes = Buffer.from('hello')
75+
mockDownloadFile.mockResolvedValue(bytes)
76+
const signal = new AbortController().signal
77+
78+
await expect(fetchWorkspaceFileBuffer(FILE, { maxBytes: 10, signal })).resolves.toBe(bytes)
79+
expect(mockDownloadFile).toHaveBeenCalledWith({
80+
key: FILE.key,
81+
context: 'workspace',
82+
maxBytes: 10,
83+
signal,
84+
})
85+
})
86+
87+
it('surfaces a cancelled read as the abort rather than a download failure', async () => {
88+
const controller = new AbortController()
89+
mockDownloadFile.mockImplementation(async () => {
90+
controller.abort()
91+
throw new Error('read interrupted')
92+
})
93+
94+
await expect(
95+
fetchWorkspaceFileBuffer(FILE, { maxBytes: 10, signal: controller.signal })
96+
).rejects.toMatchObject({ name: 'AbortError' })
97+
})
98+
99+
it('rethrows a byte-ceiling breach unwrapped', async () => {
100+
mockDownloadFile.mockRejectedValue(sizeLimitError())
101+
102+
await expect(fetchWorkspaceFileBuffer(FILE, { maxBytes: 10 })).rejects.toSatisfy(
103+
isPayloadSizeLimitError
104+
)
105+
})
106+
107+
it('wraps other transport failures', async () => {
108+
mockDownloadFile.mockRejectedValue(new Error('socket hang up'))
109+
110+
await expect(fetchWorkspaceFileBuffer(FILE, { maxBytes: 10 })).rejects.toThrow(
111+
'Failed to download file: socket hang up'
112+
)
113+
})
114+
})

‎apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,6 +1694,9 @@ export async function fetchWorkspaceFileBuffer(
16941694
)
16951695
return buffer
16961696
} catch (error) {
1697+
// A cancelled read is not a download failure: surface the abort itself so the
1698+
// caller sees cancellation, not a transport error it might retry or record.
1699+
options.signal?.throwIfAborted()
16971700
logger.error(`Failed to download workspace file ${fileRecord.name}:`, error)
16981701
// Rethrow a `maxBytes` breach unwrapped: callers distinguish "too large" from a
16991702
// transport failure to answer with their own placeholder, and re-wrapping it in a

‎apps/sim/trigger.config.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ if (grafanaConfigured && !grafanaFullyConfigured) {
3333
* sandbox's artifact store or the isolated-vm fallback. The app authors
3434
* documents for whichever compiler it sees, so a worker missing the doc
3535
* template falls back to isolated-vm and tries to run Python or Node-style
36-
* sources as sandbox JavaScript. The values still have to exist in the
37-
* Trigger.dev environment; syncing only keeps the worker's view of them
38-
* aligned with the app's.
36+
* sources as sandbox JavaScript. Reading a generated document under the doc
37+
* sandbox means loading its compiled artifact from the copilot storage
38+
* context, so that bucket has to be visible to the run as well. The values
39+
* still have to exist in the Trigger.dev environment; syncing only keeps the
40+
* worker's view of them aligned with the app's.
3941
*/
4042
const FUNCTION_EXECUTION_ENV = [
4143
{ name: 'REDIS_URL', secret: true },
@@ -49,6 +51,9 @@ const FUNCTION_EXECUTION_ENV = [
4951
{ name: 'DAYTONA_API_KEY', secret: true },
5052
{ name: 'DAYTONA_FUNCTION_SNAPSHOT_ID', secret: false },
5153
{ name: 'DAYTONA_DOC_SNAPSHOT_ID', secret: false },
54+
{ name: 'S3_COPILOT_BUCKET_NAME', secret: false },
55+
{ name: 'AZURE_STORAGE_COPILOT_CONTAINER_NAME', secret: false },
56+
{ name: 'GCS_COPILOT_BUCKET_NAME', secret: false },
5257
] as const
5358

5459
function getFunctionExecutionEnvVars() {

0 commit comments

Comments
 (0)