11import { db } from '@sim/db'
2- import { copilotChats } from '@sim/db/schema'
2+ import { copilotChats , workspaceFiles } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { generateId } from '@sim/utils/id'
5- import { eq } from 'drizzle-orm'
5+ import { eq , inArray } from 'drizzle-orm'
66import { type NextRequest , NextResponse } from 'next/server'
77import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
88import { parseRequest } from '@/lib/api/server'
9+ import { checkStorageQuota } from '@/lib/billing/storage'
10+ import {
11+ executeChatFileBlobCopies ,
12+ filterForkableChatFiles ,
13+ listForkableChatFiles ,
14+ planChatFileCopies ,
15+ } from '@/lib/copilot/chat/fork-chat-files'
916import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
1017import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
18+ import {
19+ rewriteMessageFileRefs ,
20+ rewriteResourceFileRefs ,
21+ } from '@/lib/copilot/chat/rewrite-file-references'
1122import { chatPubSub } from '@/lib/copilot/chat-status'
1223import { fetchGo } from '@/lib/copilot/request/go/fetch'
1324import {
@@ -18,6 +29,7 @@ import {
1829 createNotFoundResponse ,
1930 createUnauthorizedResponse ,
2031} from '@/lib/copilot/request/http'
32+ import { removeChatResources } from '@/lib/copilot/resources/persistence'
2133import type { MothershipResource } from '@/lib/copilot/resources/types'
2234import { getMothershipBaseURL , getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
2335import { env } from '@/lib/core/config/env'
@@ -33,7 +45,16 @@ const logger = createLogger('ForkChatAPI')
3345/**
3446 * POST /api/mothership/chats/[chatId]/fork
3547 * Creates a new chat branched from the given chat, keeping messages up to and
36- * including the specified message. Resources and copilot-side state are copied.
48+ * including the specified message, along with the chat's uploads born
49+ * at-or-before the fork point (a file travels iff the user message that
50+ * carried it is kept). Resources and copilot-side state are copied.
51+ *
52+ * Every copied file gets a fresh row id and storage key, bytes are physically
53+ * copied and counted against the storage quota, and every in-transcript file
54+ * reference is re-pointed at the copies so the new chat survives deletion of
55+ * the source chat. File resources whose chat-owned file was NOT copied
56+ * (uploads born after the cut) are dropped from the new chat's resources
57+ * rather than left as ghosts pointing at the source chat's files.
3758 */
3859export const POST = withRouteHandler (
3960 async ( request : NextRequest , context : { params : Promise < { chatId : string } > } ) => {
@@ -82,17 +103,49 @@ export const POST = withRouteHandler(
82103 }
83104 const forkedMessages = messages . slice ( 0 , forkIdx + 1 )
84105
85- // Resources are stored as a jsonb array on the chat row — copy them directly.
106+ // Single workspace_files read per fork: every chat-owned upload. The
107+ // copied set is timeline-cut to the kept message slice in memory (files
108+ // born after the fork point stay behind).
109+ const chatOwnedFiles = await listForkableChatFiles ( db , chatId )
110+ const sourceFiles = filterForkableChatFiles (
111+ chatOwnedFiles ,
112+ new Set ( forkedMessages . map ( ( m ) => m . id ) )
113+ )
114+ // Sum only rows the plan will actually copy — planChatFileCopies skips
115+ // rows with no workspaceId, so counting their bytes could reject a fork
116+ // whose real copies fit within quota.
117+ const totalFileBytes = sourceFiles . reduce (
118+ ( sum , row ) => ( row . workspaceId ? sum + row . size : sum ) ,
119+ 0
120+ )
121+ if ( totalFileBytes > 0 ) {
122+ const quotaCheck = await checkStorageQuota ( userId , totalFileBytes )
123+ if ( ! quotaCheck . allowed ) {
124+ return createBadRequestResponse ( quotaCheck . error || 'Storage limit exceeded' )
125+ }
126+ }
127+
128+ // Resources are stored as a jsonb array on the chat row. They carry no
129+ // timestamps, so they can't be timeline-cut like messages — instead,
130+ // file resources whose chat-owned file is NOT copied (uploads born
131+ // after the cut) are dropped in the rewrite below; everything else is
132+ // copied.
86133 const parentResources = Array . isArray ( parent . resources )
87134 ? ( parent . resources as MothershipResource [ ] )
88135 : [ ]
89136
137+ // The source chat's chat-owned file ids (no cut) — the "is this
138+ // resource a ghost?" test set for the rewrite.
139+ const chatOwnedFileIds = new Set ( chatOwnedFiles . map ( ( row ) => row . id ) )
140+
90141 const newId = generateId ( )
142+ // Strip a leading "Fork | " so titles don't stack prefixes when forking
143+ // a forked chat.
91144 const baseTitle = ( parent . title ?? 'New chat' ) . replace ( / ^ F o r k \| / , '' )
92145 const title = `Fork | ${ baseTitle } `
93146 const now = new Date ( )
94147
95- const newChat = await db . transaction ( async ( tx ) => {
148+ const result = await db . transaction ( async ( tx ) => {
96149 const [ row ] = await tx
97150 . insert ( copilotChats )
98151 . values ( {
@@ -114,13 +167,72 @@ export const POST = withRouteHandler(
114167
115168 if ( ! row ) return null
116169
117- await appendCopilotChatMessages ( newId , forkedMessages , { chatModel : parent . model } , tx )
118- return row
170+ // File rows FK the new chat row, so the plan runs after the insert.
171+ const { idMap, keyMap, blobTasks } = await planChatFileCopies ( {
172+ tx,
173+ rows : sourceFiles ,
174+ newChatId : newId ,
175+ userId,
176+ now,
177+ } )
178+
179+ const maps = { fileIds : idMap , fileKeys : keyMap }
180+ const newChatResources = rewriteResourceFileRefs ( parentResources , maps , chatOwnedFileIds )
181+ // Skip the redundant update only when the rewrite changed nothing:
182+ // no ids re-pointed AND no ghost resources dropped. (idMap and keyMap
183+ // are populated in lockstep, so idMap alone decides the first half.)
184+ if ( idMap . size > 0 || newChatResources . length !== parentResources . length ) {
185+ await tx
186+ . update ( copilotChats )
187+ . set ( { resources : newChatResources } )
188+ . where ( eq ( copilotChats . id , newId ) )
189+ }
190+
191+ await appendCopilotChatMessages (
192+ newId ,
193+ rewriteMessageFileRefs ( forkedMessages , maps ) ,
194+ { chatModel : parent . model } ,
195+ tx
196+ )
197+ return { row, blobTasks }
119198 } )
120199
121- if ( ! newChat ) {
200+ if ( ! result ) {
122201 return createInternalServerErrorResponse ( 'Failed to create forked chat' )
123202 }
203+ const newChat = result . row
204+
205+ const { copied, failed, failedCopyIds } = await executeChatFileBlobCopies ( result . blobTasks , {
206+ userId,
207+ workspaceId : parent . workspaceId ?? undefined ,
208+ } )
209+ if ( failed > 0 ) {
210+ // A failed blob copy leaves a committed row with no bytes behind it.
211+ // Cleanly absent beats present-but-broken: hard-delete the dead rows
212+ // (they vanish from the VFS listings and name resolution) and drop
213+ // their resource chips from the new chat. Inline transcript embeds
214+ // can't be healed — those 404 — which is what `failedFileCopies` in
215+ // the response warns the user about.
216+ try {
217+ await db . delete ( workspaceFiles ) . where ( inArray ( workspaceFiles . id , failedCopyIds ) )
218+ await removeChatResources (
219+ newId ,
220+ failedCopyIds . map ( ( id ) => ( { type : 'file' as const , id, title : '' } ) )
221+ )
222+ } catch ( cleanupError ) {
223+ logger . error ( 'Failed to clean up dead file rows after blob-copy failure' , {
224+ newChatId : newId ,
225+ failedCopyIds,
226+ error : cleanupError ,
227+ } )
228+ }
229+ logger . warn ( 'Some chat file blobs failed to copy during fork' , {
230+ chatId,
231+ newChatId : newId ,
232+ copied,
233+ failed,
234+ } )
235+ }
124236
125237 // Clone copilot-service conversation state (messages, active_messages, memory files).
126238 // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip.
@@ -168,7 +280,11 @@ export const POST = withRouteHandler(
168280 { groups : { workspace : parent . workspaceId ?? '' } }
169281 )
170282
171- return NextResponse . json ( { success : true , id : newId } )
283+ return NextResponse . json ( {
284+ success : true ,
285+ id : newId ,
286+ ...( failed > 0 ? { failedFileCopies : failed } : { } ) ,
287+ } )
172288 } catch ( error ) {
173289 if ( isWorkspaceAccessDeniedError ( error ) ) {
174290 return createForbiddenResponse ( 'Workspace access denied' )
0 commit comments