Skip to content

Commit b6109c4

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/func-cli-resolver
# Conflicts: # apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts # apps/sim/lib/copilot/tools/handlers/materialize-file.ts
2 parents 5c58c02 + 5baa7a4 commit b6109c4

2 files changed

Lines changed: 255 additions & 41 deletions

File tree

apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,28 @@ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
8+
mockAllocateUniqueWorkspaceFileName,
89
mockCheckStorageQuotaForBillingContext,
910
mockDecompress,
1011
mockFetchBuffer,
1112
mockFindFolder,
1213
mockFindUpload,
1314
mockGetBoundWorkspaceFileSecretProvenance,
15+
mockGetWorkspaceFile,
1416
mockHasCloudStorage,
1517
mockHeadObject,
1618
mockIncrementStorageUsageForBillingContextInTx,
1719
mockMaybeNotifyStorageLimitForBillingContext,
1820
mockResolveStorageBillingContext,
1921
} = vi.hoisted(() => ({
22+
mockAllocateUniqueWorkspaceFileName: vi.fn(),
2023
mockCheckStorageQuotaForBillingContext: vi.fn(),
2124
mockDecompress: vi.fn(),
2225
mockFetchBuffer: vi.fn(),
2326
mockFindFolder: vi.fn(),
2427
mockFindUpload: vi.fn(),
2528
mockGetBoundWorkspaceFileSecretProvenance: vi.fn(),
29+
mockGetWorkspaceFile: vi.fn(),
2630
mockHasCloudStorage: vi.fn(),
2731
mockHeadObject: vi.fn(),
2832
mockIncrementStorageUsageForBillingContextInTx: vi.fn(),
@@ -43,7 +47,9 @@ vi.mock('@/lib/uploads', () => ({
4347
}))
4448

4549
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
50+
allocateUniqueWorkspaceFileName: mockAllocateUniqueWorkspaceFileName,
4651
fetchWorkspaceFileBuffer: mockFetchBuffer,
52+
getWorkspaceFile: mockGetWorkspaceFile,
4753
}))
4854

4955
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({
@@ -82,7 +88,9 @@ vi.mock('@/lib/billing/storage', () => ({
8288
}))
8389

8490
vi.mock('@/lib/copilot/vfs/path-utils', () => ({
85-
canonicalWorkspaceFilePath: vi.fn(() => 'files/report.txt'),
91+
canonicalWorkspaceFilePath: vi.fn(
92+
({ name }: { name: string }) => `files/${encodeURIComponent(name)}`
93+
),
8694
encodeVfsPathSegments: (segments: string[]) =>
8795
segments.map((s) => encodeURIComponent(s)).join('/'),
8896
}))
@@ -243,6 +251,8 @@ describe('executeMaterializeFile - save storage transition', () => {
243251
vi.clearAllMocks()
244252
resetDbChainMock()
245253
mockFindUpload.mockResolvedValue(mothershipRow)
254+
mockAllocateUniqueWorkspaceFileName.mockResolvedValue('report.txt')
255+
mockGetWorkspaceFile.mockResolvedValue({ id: 'file-1', name: 'report.txt' })
246256
mockHeadObject.mockResolvedValue({ size: 250, contentType: 'text/plain' })
247257
mockHasCloudStorage.mockReturnValue(true)
248258
mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
@@ -284,6 +294,11 @@ describe('executeMaterializeFile - save storage transition', () => {
284294
expect(result.success).toBe(true)
285295
expect(mockHeadObject).toHaveBeenCalledWith('mothership/file-1', 'mothership')
286296
expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith(STORAGE_CONTEXT, 250)
297+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledWith(
298+
context.workspaceId,
299+
'report.txt',
300+
null
301+
)
287302
expect(dbChainMockFns.set).toHaveBeenCalledWith(
288303
expect.objectContaining({ context: 'workspace', chatId: null, size: 250 })
289304
)
@@ -293,15 +308,167 @@ describe('executeMaterializeFile - save storage transition', () => {
293308
)
294309
})
295310

311+
it('materializes with an available root-level copy name', async () => {
312+
mockFindUpload.mockResolvedValueOnce({
313+
...mothershipRow,
314+
originalName: 'image.png',
315+
displayName: 'image.png',
316+
})
317+
mockAllocateUniqueWorkspaceFileName.mockResolvedValueOnce('image (1).png')
318+
dbChainMockFns.returning.mockResolvedValueOnce([
319+
{ id: 'file-1', originalName: 'image (1).png' },
320+
])
321+
322+
const result = await executeMaterializeFile(
323+
{ fileNames: ['image.png'], operation: 'save' },
324+
context
325+
)
326+
327+
expect(result.success).toBe(true)
328+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledWith(
329+
context.workspaceId,
330+
'image.png',
331+
null
332+
)
333+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
334+
expect.objectContaining({
335+
context: 'workspace',
336+
originalName: 'image (1).png',
337+
displayName: 'image (1).png',
338+
})
339+
)
340+
expect(result.output).toEqual({ succeeded: ['image (1).png'], failed: [] })
341+
expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'image (1).png' }])
342+
})
343+
344+
it('reallocates and retries when a concurrent root-level write claims the name', async () => {
345+
const nameCollision = Object.assign(new Error('duplicate workspace file name'), {
346+
code: '23505',
347+
constraint_name: 'workspace_files_workspace_folder_name_active_unique',
348+
})
349+
mockFindUpload.mockResolvedValueOnce({
350+
...mothershipRow,
351+
originalName: 'image.png',
352+
displayName: 'image.png',
353+
})
354+
mockAllocateUniqueWorkspaceFileName
355+
.mockResolvedValueOnce('image (1).png')
356+
.mockResolvedValueOnce('image (2).png')
357+
dbChainMockFns.returning
358+
.mockRejectedValueOnce(nameCollision)
359+
.mockResolvedValueOnce([{ id: 'file-1', originalName: 'image (2).png' }])
360+
361+
const result = await executeMaterializeFile(
362+
{ fileNames: ['image.png'], operation: 'save' },
363+
context
364+
)
365+
366+
expect(result.success).toBe(true)
367+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(2)
368+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenNthCalledWith(
369+
1,
370+
context.workspaceId,
371+
'image.png',
372+
null
373+
)
374+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenNthCalledWith(
375+
2,
376+
context.workspaceId,
377+
'image.png',
378+
null
379+
)
380+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2)
381+
expect(dbChainMockFns.set).toHaveBeenNthCalledWith(
382+
1,
383+
expect.objectContaining({ originalName: 'image (1).png' })
384+
)
385+
expect(dbChainMockFns.set).toHaveBeenNthCalledWith(
386+
2,
387+
expect.objectContaining({ originalName: 'image (2).png' })
388+
)
389+
expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledTimes(1)
390+
expect(result.output).toEqual({ succeeded: ['image (2).png'], failed: [] })
391+
expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'image (2).png' }])
392+
})
393+
394+
it('stops after the bounded number of root-level name collisions', async () => {
395+
const nameCollision = Object.assign(new Error('duplicate workspace file name'), {
396+
code: '23505',
397+
constraint_name: 'workspace_files_workspace_folder_name_active_unique',
398+
})
399+
dbChainMockFns.returning.mockRejectedValue(nameCollision)
400+
401+
const result = await executeMaterializeFile(
402+
{ fileNames: ['report.txt'], operation: 'save' },
403+
context
404+
)
405+
406+
expect(result.success).toBe(false)
407+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(8)
408+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(8)
409+
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
410+
expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled()
411+
})
412+
413+
it('does not retry unique violations from a different constraint', async () => {
414+
const keyCollision = Object.assign(new Error('duplicate workspace file key'), {
415+
code: '23505',
416+
constraint_name: 'workspace_files_key_active_unique',
417+
})
418+
dbChainMockFns.returning.mockRejectedValueOnce(keyCollision)
419+
420+
const result = await executeMaterializeFile(
421+
{ fileNames: ['report.txt'], operation: 'save' },
422+
context
423+
)
424+
425+
expect(result.success).toBe(false)
426+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(1)
427+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
428+
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
429+
})
430+
296431
it('treats a lost conditional transition as a replay no-op', async () => {
297432
dbChainMockFns.returning.mockResolvedValueOnce([])
433+
mockGetWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'report (1).txt' })
298434

299435
const result = await executeMaterializeFile(
300436
{ fileNames: ['report.txt'], operation: 'save' },
301437
context
302438
)
303439

304440
expect(result.success).toBe(true)
441+
expect(mockGetWorkspaceFile).toHaveBeenCalledWith(context.workspaceId, 'file-1', {
442+
throwOnError: true,
443+
})
444+
expect(result.output).toEqual({ succeeded: ['report (1).txt'], failed: [] })
445+
expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'report (1).txt' }])
446+
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
447+
expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled()
448+
})
449+
450+
it('fails a replay when the materialized workspace file no longer exists', async () => {
451+
dbChainMockFns.returning.mockResolvedValueOnce([])
452+
mockGetWorkspaceFile.mockResolvedValueOnce(null)
453+
454+
const result = await executeMaterializeFile(
455+
{ fileNames: ['report.txt'], operation: 'save' },
456+
context
457+
)
458+
459+
expect(result.success).toBe(false)
460+
expect(result.output).toEqual({
461+
succeeded: [],
462+
failed: [
463+
{
464+
fileName: 'report.txt',
465+
error: 'Upload no longer available: "report.txt".',
466+
},
467+
],
468+
})
469+
expect(mockGetWorkspaceFile).toHaveBeenCalledWith(context.workspaceId, 'file-1', {
470+
throwOnError: true,
471+
})
305472
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
306473
expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled()
307474
})

0 commit comments

Comments
 (0)