@@ -2,11 +2,17 @@ import { dbFor } from '@sim/db'
22import { copilotChats , copilotMessages , workspaceFiles } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { chunkArray } from '@sim/utils/helpers'
5+ import { isRecordLike } from '@sim/utils/object'
56import { and , inArray , isNull } from 'drizzle-orm'
67import { env } from '@/lib/core/config/env'
8+ import {
9+ inlineChatImageKey ,
10+ inlineChatImageReferences ,
11+ } from '@/lib/mothership/chat/inline-image-key'
712import { SIM_AGENT_API_URL } from '@/lib/mothership/constants'
813import type { StorageContext } from '@/lib/uploads'
914import { isUsingCloudStorage , StorageService } from '@/lib/uploads'
15+ import { tryInferContextFromKey } from '@/lib/uploads/utils/file-utils'
1016
1117const logger = createLogger ( 'ChatCleanup' )
1218
@@ -33,9 +39,47 @@ interface FileRef {
3339}
3440
3541/**
36- * Collect all file storage keys for the given chat IDs from two sources:
42+ * The chat images an assistant message published under its request id, keyed by chat id,
43+ * so they are purged with the chat. A row this cannot read as such a message has none.
44+ */
45+ function inlineChatImageKeys ( chatId : string , content : Record < string , unknown > ) : string [ ] {
46+ if ( content . role !== 'assistant' || typeof content . requestId !== 'string' ) return [ ]
47+ const published = inlineChatImageReferences ( {
48+ role : 'assistant' ,
49+ requestId : content . requestId ,
50+ content : typeof content . content === 'string' ? content . content : '' ,
51+ contentBlocks : Array . isArray ( content . contentBlocks )
52+ ? content . contentBlocks . flatMap ( ( block ) =>
53+ isRecordLike ( block ) && block . type === 'text' && typeof block . content === 'string'
54+ ? [ { type : 'text' as const , content : block . content } ]
55+ : [ ]
56+ )
57+ : undefined ,
58+ } )
59+ if ( ! published ) return [ ]
60+ const keys : string [ ] = [ ]
61+ for ( const reference of published . references ) {
62+ try {
63+ keys . push ( inlineChatImageKey ( chatId , published . requestId , reference ) )
64+ } catch {
65+ // A chat or request id outside the key grammar never had an image stored under it.
66+ }
67+ }
68+ return keys
69+ }
70+
71+ /**
72+ * Collect all file storage keys for the given chat IDs from three sources:
3773 * 1. workspaceFiles rows with chatId FK (chat-scoped contexts only)
38- * 2. fileAttachments[].key inside each copilot_messages.content
74+ * 2. fileAttachments[].key inside each copilot_messages.content, for copilot-storage keys only
75+ * 3. the chat-scoped inline images each assistant message published
76+ *
77+ * An attachment whose key belongs to another storage context is owned by its
78+ * `workspace_files` row (source 1, or the workspace file lifecycle), never by the message:
79+ * a fork carries the same attachment when its copy failed or its file was deleted, and the
80+ * copilot bucket falls back to the workspace bucket on GCS (and may be configured to it on
81+ * S3), so deleting such a key as copilot storage could delete a file another chat or the
82+ * workspace still uses.
3983 */
4084export async function collectChatFiles ( chatIds : string [ ] ) : Promise < FileRef [ ] > {
4185 const files : FileRef [ ] = [ ]
@@ -77,6 +121,12 @@ export async function collectChatFiles(chatIds: string[]): Promise<FileRef[]> {
77121 for ( const row of messageRows ) {
78122 const msg = row . content
79123 if ( ! msg || typeof msg !== 'object' ) continue
124+ for ( const key of inlineChatImageKeys ( row . chatId , msg as Record < string , unknown > ) ) {
125+ if ( ! seen . has ( key ) ) {
126+ seen . add ( key )
127+ files . push ( { key, context : 'mothership' , chatId : row . chatId } )
128+ }
129+ }
80130 const attachments = ( msg as Record < string , unknown > ) . fileAttachments
81131 if ( ! Array . isArray ( attachments ) ) continue
82132 for ( const attachment of attachments ) {
@@ -86,6 +136,7 @@ export async function collectChatFiles(chatIds: string[]): Promise<FileRef[]> {
86136 ( attachment as Record < string , unknown > ) . key
87137 ) {
88138 const key = ( attachment as Record < string , unknown > ) . key as string
139+ if ( tryInferContextFromKey ( key ) !== 'copilot' ) continue
89140 if ( ! seen . has ( key ) ) {
90141 seen . add ( key )
91142 files . push ( { key, context : 'copilot' , chatId : row . chatId } )
0 commit comments