@@ -32,139 +32,124 @@ const logger = createLogger('ForkChatAPI')
3232 */
3333export const POST = withRouteHandler (
3434 async ( request : NextRequest , context : { params : Promise < { chatId : string } > } ) => {
35- try {
36- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly ( )
37- if ( ! isAuthenticated || ! userId ) {
38- return createUnauthorizedResponse ( )
39- }
35+ const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly ( )
36+ if ( ! isAuthenticated || ! userId ) {
37+ return createUnauthorizedResponse ( )
38+ }
4039
41- const parsed = await parseRequest ( forkMothershipChatContract , request , context , {
42- validationErrorResponse : ( ) => createBadRequestResponse ( 'upToMessageId is required' ) ,
43- } )
44- if ( ! parsed . success ) return parsed . response
45- const { chatId } = parsed . data . params
46- const { upToMessageId } = parsed . data . body
47-
48- // Load parent chat and verify ownership.
49- const [ parent ] = await db
50- . select ( )
51- . from ( copilotChats )
52- . where ( eq ( copilotChats . id , chatId ) )
53- . limit ( 1 )
54-
55- if ( ! parent || parent . userId !== userId || parent . type !== 'mothership' ) {
56- return createNotFoundResponse ( 'Chat not found' )
57- }
40+ const parsed = await parseRequest ( forkMothershipChatContract , request , context , {
41+ validationErrorResponse : ( ) => createBadRequestResponse ( 'upToMessageId is required' ) ,
42+ } )
43+ if ( ! parsed . success ) return parsed . response
44+ const { chatId } = parsed . data . params
45+ const { upToMessageId } = parsed . data . body
46+
47+ const [ parent ] = await db
48+ . select ( )
49+ . from ( copilotChats )
50+ . where ( eq ( copilotChats . id , chatId ) )
51+ . limit ( 1 )
52+
53+ if ( ! parent || parent . userId !== userId || parent . type !== 'mothership' ) {
54+ return createNotFoundResponse ( 'Chat not found' )
55+ }
5856
59- if ( parent . workspaceId ) {
60- await assertActiveWorkspaceAccess ( parent . workspaceId , userId )
61- }
57+ if ( parent . workspaceId ) {
58+ await assertActiveWorkspaceAccess ( parent . workspaceId , userId )
59+ }
6260
63- if ( parent . conversationId ) {
64- return createBadRequestResponse ( 'Cannot fork a chat with an active stream' )
65- }
61+ if ( parent . conversationId ) {
62+ return createBadRequestResponse ( 'Cannot fork a chat with an active stream' )
63+ }
6664
67- // Find the fork point in the Sim-side messages array.
68- const messages = Array . isArray ( parent . messages ) ? ( parent . messages as PersistedMessage [ ] ) : [ ]
69- const forkIdx = messages . findIndex ( ( m ) => m . id === upToMessageId )
70- if ( forkIdx < 0 ) {
71- return createBadRequestResponse ( 'Message not found in chat' )
72- }
73- const forkedMessages = messages . slice ( 0 , forkIdx + 1 )
74-
75- // Resources are stored as a jsonb array on the chat row — copy them directly.
76- const parentResources = Array . isArray ( parent . resources )
77- ? ( parent . resources as MothershipResource [ ] )
78- : [ ]
79-
80- const newId = generateId ( )
81- const baseTitle = ( parent . title ?? 'New task' ) . replace ( / ^ F o r k \| / , '' )
82- const title = `Fork | ${ baseTitle } `
83- const now = new Date ( )
84-
85- // Clone copilot-service conversation state first. If this fails we never
86- // insert the Sim row, so there is no orphaned UI entry to clean up.
87- // (The inverse order — Sim INSERT first — required a compensating delete
88- // and still left a brief window where the row was visible but Go state
89- // wasn't ready.)
90- const copilotHeaders : Record < string , string > = { 'Content-Type' : 'application/json' }
91- if ( env . COPILOT_API_KEY ) {
92- copilotHeaders [ 'x-api-key' ] = env . COPILOT_API_KEY
93- }
94- try {
95- const copilotRes = await fetchGo ( `${ SIM_AGENT_API_URL } /api/chats/fork` , {
96- method : 'POST' ,
97- headers : copilotHeaders ,
98- body : JSON . stringify ( {
99- sourceChatId : chatId ,
100- newChatId : newId ,
101- keepCount : forkedMessages . length ,
102- userId,
103- } ) ,
104- spanName : 'sim → go /api/chats/fork' ,
105- operation : 'fork_chat' ,
106- } )
107- if ( ! copilotRes . ok ) {
108- const text = await copilotRes . text ( ) . catch ( ( ) => '' )
109- logger . error ( 'Copilot fork returned non-OK' , { status : copilotRes . status , body : text } )
110- return createInternalServerErrorResponse ( 'Failed to fork chat' )
111- }
112- } catch ( err ) {
113- logger . error ( 'Failed to call copilot fork endpoint' , { err } )
114- return createInternalServerErrorResponse ( 'Failed to fork chat' )
115- }
65+ const messages = Array . isArray ( parent . messages ) ? ( parent . messages as PersistedMessage [ ] ) : [ ]
66+ const forkIdx = messages . findIndex ( ( m ) => m . id === upToMessageId )
67+ if ( forkIdx < 0 ) {
68+ return createBadRequestResponse ( 'Message not found in chat' )
69+ }
70+ const forkedMessages = messages . slice ( 0 , forkIdx + 1 )
11671
117- // Go state is ready — now persist the Sim metadata row. If this insert
118- // fails the Go conversation is orphaned but permanently inaccessible
119- // (no Sim row = no UI entry), which is harmless.
120- const [ newChat ] = await db
121- . insert ( copilotChats )
122- . values ( {
123- id : newId ,
124- userId,
125- workspaceId : parent . workspaceId ,
126- workflowId : parent . workflowId ,
127- type : parent . type ,
128- title,
129- model : parent . model ,
130- messages : forkedMessages ,
131- resources : parentResources ,
132- previewYaml : parent . previewYaml ,
133- planArtifact : parent . planArtifact ,
134- config : parent . config ,
135- conversationId : null ,
136- updatedAt : now ,
137- lastSeenAt : now ,
138- } )
139- . returning ( { id : copilotChats . id , workspaceId : copilotChats . workspaceId } )
140-
141- if ( ! newChat ) {
142- logger . error ( 'Failed to insert forked chat row after successful Go fork' , {
143- newId,
144- chatId,
145- } )
146- return createInternalServerErrorResponse ( 'Failed to create forked chat' )
147- }
72+ const parentResources = Array . isArray ( parent . resources )
73+ ? ( parent . resources as MothershipResource [ ] )
74+ : [ ]
14875
149- if ( newChat . workspaceId ) {
150- taskPubSub ?. publishStatusChanged ( {
151- workspaceId : newChat . workspaceId ,
152- chatId : newId ,
153- type : 'created' ,
154- } )
76+ const newId = generateId ( )
77+ const baseTitle = ( parent . title ?? 'New task' ) . replace ( / ^ F o r k \| / , '' )
78+ const now = new Date ( )
79+
80+ // Clone copilot-service conversation state first. If this fails we never
81+ // insert the Sim row, so there is no orphaned UI entry to clean up.
82+ const copilotHeaders : Record < string , string > = { 'Content-Type' : 'application/json' }
83+ if ( env . COPILOT_API_KEY ) {
84+ copilotHeaders [ 'x-api-key' ] = env . COPILOT_API_KEY
85+ }
86+ try {
87+ const copilotRes = await fetchGo ( `${ SIM_AGENT_API_URL } /api/chats/fork` , {
88+ method : 'POST' ,
89+ headers : copilotHeaders ,
90+ body : JSON . stringify ( {
91+ sourceChatId : chatId ,
92+ newChatId : newId ,
93+ keepCount : forkedMessages . length ,
94+ userId,
95+ } ) ,
96+ spanName : 'sim → go /api/chats/fork' ,
97+ operation : 'fork_chat' ,
98+ } )
99+ if ( ! copilotRes . ok ) {
100+ const text = await copilotRes . text ( ) . catch ( ( ) => '' )
101+ logger . error ( 'Copilot fork returned non-OK' , { status : copilotRes . status , body : text } )
102+ return createInternalServerErrorResponse ( 'Failed to fork chat' )
155103 }
104+ } catch ( err ) {
105+ logger . error ( 'Failed to call copilot fork endpoint' , { err } )
106+ return createInternalServerErrorResponse ( 'Failed to fork chat' )
107+ }
156108
157- captureServerEvent (
109+ // Go state is ready — now persist the Sim metadata row. If this insert
110+ // fails the Go conversation is orphaned but permanently inaccessible
111+ // (no Sim row = no UI entry), which is harmless.
112+ const [ newChat ] = await db
113+ . insert ( copilotChats )
114+ . values ( {
115+ id : newId ,
158116 userId,
159- 'task_forked' ,
160- { workspace_id : parent . workspaceId ?? '' , source_chat_id : chatId } ,
161- { groups : { workspace : parent . workspaceId ?? '' } }
162- )
163-
164- return NextResponse . json ( { success : true , id : newId } )
165- } catch ( error ) {
166- logger . error ( 'Error forking chat:' , error )
167- return createInternalServerErrorResponse ( 'Failed to fork chat' )
117+ workspaceId : parent . workspaceId ,
118+ workflowId : parent . workflowId ,
119+ type : parent . type ,
120+ title : `Fork | ${ baseTitle } ` ,
121+ model : parent . model ,
122+ messages : forkedMessages ,
123+ resources : parentResources ,
124+ previewYaml : parent . previewYaml ,
125+ planArtifact : parent . planArtifact ,
126+ config : parent . config ,
127+ conversationId : null ,
128+ updatedAt : now ,
129+ lastSeenAt : now ,
130+ } )
131+ . returning ( { id : copilotChats . id , workspaceId : copilotChats . workspaceId } )
132+
133+ if ( ! newChat ) {
134+ logger . error ( 'Failed to insert forked chat row after successful Go fork' , { newId, chatId } )
135+ return createInternalServerErrorResponse ( 'Failed to create forked chat' )
136+ }
137+
138+ if ( newChat . workspaceId ) {
139+ taskPubSub ?. publishStatusChanged ( {
140+ workspaceId : newChat . workspaceId ,
141+ chatId : newId ,
142+ type : 'created' ,
143+ } )
168144 }
145+
146+ captureServerEvent (
147+ userId ,
148+ 'task_forked' ,
149+ { workspace_id : parent . workspaceId ?? '' , source_chat_id : chatId } ,
150+ { groups : { workspace : parent . workspaceId ?? '' } }
151+ )
152+
153+ return NextResponse . json ( { success : true , id : newId } )
169154 }
170155)
0 commit comments