@@ -75,6 +75,62 @@ def _get_session_files_key(self, session_id: str) -> str:
7575 """Generate Redis key for session file list."""
7676 return f"session_files:{ session_id } "
7777
78+ def _get_file_links_key (self , session_id : str , file_id : str ) -> str :
79+ """Generate Redis key for aliases that reference a source file."""
80+ return f"file_links:{ session_id } :{ file_id } "
81+
82+ async def _register_link_reference (
83+ self ,
84+ source_session_id : str ,
85+ source_file_id : str ,
86+ linked_session_id : str ,
87+ linked_file_id : str ,
88+ ) -> None :
89+ """Track a linked-input alias for cleanup safety."""
90+ links_key = self ._get_file_links_key (source_session_id , source_file_id )
91+ ttl_seconds = settings .get_session_ttl_minutes () * 60
92+ await self .redis_client .sadd (links_key , f"{ linked_session_id } :{ linked_file_id } " )
93+ await self .redis_client .expire (links_key , ttl_seconds )
94+
95+ async def _remove_link_reference (
96+ self ,
97+ source_session_id : str ,
98+ source_file_id : str ,
99+ linked_session_id : str ,
100+ linked_file_id : str ,
101+ ) -> None :
102+ """Remove a linked-input alias reference."""
103+ links_key = self ._get_file_links_key (source_session_id , source_file_id )
104+ await self .redis_client .srem (links_key , f"{ linked_session_id } :{ linked_file_id } " )
105+
106+ async def _has_link_references (self , session_id : str , file_id : str ) -> bool :
107+ """Return True when other session aliases still reference a file."""
108+ links_key = self ._get_file_links_key (session_id , file_id )
109+ return bool (await self .redis_client .smembers (links_key ))
110+
111+ async def _find_linked_file (
112+ self , target_session_id : str , source_session_id : str , source_file_id : str
113+ ) -> Optional [str ]:
114+ """Return an existing linked-input alias for the given source file."""
115+ session_files_key = self ._get_session_files_key (target_session_id )
116+ file_ids = await self .redis_client .smembers (session_files_key )
117+
118+ for candidate_file_id in file_ids :
119+ metadata = await self .get_file_metadata (
120+ target_session_id , candidate_file_id
121+ )
122+ if not metadata :
123+ continue
124+
125+ if (
126+ metadata .get ("type" ) == "linked_input"
127+ and metadata .get ("source_session_id" ) == source_session_id
128+ and metadata .get ("source_file_id" ) == source_file_id
129+ ):
130+ return candidate_file_id
131+
132+ return None
133+
78134 async def _store_file_metadata (
79135 self , session_id : str , file_id : str , metadata : Dict [str , Any ]
80136 ) -> None :
@@ -317,6 +373,69 @@ async def list_files(self, session_id: str) -> List[FileInfo]:
317373 logger .error ("Failed to list files" , error = str (e ), session_id = session_id )
318374 return []
319375
376+ async def link_file_into_session (
377+ self , target_session_id : str , source_session_id : str , source_file_id : str
378+ ) -> Optional [FileInfo ]:
379+ """Create or reuse a read-only linked alias in the target session."""
380+ source_metadata = await self .get_file_metadata (
381+ source_session_id , source_file_id
382+ )
383+ if not source_metadata :
384+ logger .warning (
385+ "Cannot link missing source file" ,
386+ source_session_id = source_session_id ,
387+ source_file_id = source_file_id ,
388+ target_session_id = target_session_id ,
389+ )
390+ return None
391+
392+ existing_linked_file_id = await self ._find_linked_file (
393+ target_session_id , source_session_id , source_file_id
394+ )
395+ if existing_linked_file_id :
396+ return await self .get_file_info (target_session_id , existing_linked_file_id )
397+
398+ linked_file_id = generate_file_id ()
399+ metadata = {
400+ "file_id" : linked_file_id ,
401+ "filename" : source_metadata ["filename" ],
402+ "content_type" : source_metadata ["content_type" ],
403+ "object_key" : source_metadata ["object_key" ],
404+ "session_id" : target_session_id ,
405+ "created_at" : datetime .utcnow ().isoformat (),
406+ "size" : source_metadata ["size" ],
407+ "path" : source_metadata ["path" ],
408+ "type" : "linked_input" ,
409+ "source_session_id" : source_session_id ,
410+ "source_file_id" : source_file_id ,
411+ "is_read_only" : "1" ,
412+ }
413+
414+ await self ._store_file_metadata (target_session_id , linked_file_id , metadata )
415+ await self ._register_link_reference (
416+ source_session_id ,
417+ source_file_id ,
418+ target_session_id ,
419+ linked_file_id ,
420+ )
421+
422+ logger .debug (
423+ "Linked file into session" ,
424+ target_session_id = target_session_id ,
425+ linked_file_id = linked_file_id ,
426+ source_session_id = source_session_id ,
427+ source_file_id = source_file_id ,
428+ )
429+
430+ return FileInfo (
431+ file_id = linked_file_id ,
432+ filename = metadata ["filename" ],
433+ size = metadata ["size" ],
434+ content_type = metadata ["content_type" ],
435+ created_at = datetime .fromisoformat (metadata ["created_at" ]),
436+ path = metadata ["path" ],
437+ )
438+
320439 async def download_file (self , session_id : str , file_id : str ) -> Optional [str ]:
321440 """Generate download URL for a file."""
322441 metadata = await self .get_file_metadata (session_id , file_id )
@@ -353,6 +472,30 @@ async def delete_file(self, session_id: str, file_id: str) -> bool:
353472 if not metadata :
354473 return False
355474
475+ if metadata .get ("type" ) == "linked_input" :
476+ await self ._delete_file_metadata (session_id , file_id )
477+ await self ._remove_link_reference (
478+ metadata ["source_session_id" ],
479+ metadata ["source_file_id" ],
480+ session_id ,
481+ file_id ,
482+ )
483+ logger .debug (
484+ "Deleted linked file alias" ,
485+ session_id = session_id ,
486+ file_id = file_id ,
487+ )
488+ return True
489+
490+ if await self ._has_link_references (session_id , file_id ):
491+ await self ._delete_file_metadata (session_id , file_id )
492+ logger .debug (
493+ "Deleted file metadata but retained shared object" ,
494+ session_id = session_id ,
495+ file_id = file_id ,
496+ )
497+ return True
498+
356499 object_key = metadata ["object_key" ]
357500
358501 try :
@@ -753,6 +896,12 @@ async def cleanup_orphan_objects(self, batch_limit: int = 1000) -> int:
753896 if object_session_id in active_session_ids :
754897 continue
755898
899+ source_file_id = parts [3 ] if len (parts ) >= 4 else None
900+ if source_file_id and await self ._has_link_references (
901+ object_session_id , source_file_id
902+ ):
903+ continue
904+
756905 # Double-check via Redis existence in case index is stale
757906 if object_session_id not in checked_missing_sessions :
758907 try :
@@ -829,6 +978,14 @@ async def update_file_content(
829978 )
830979 return False
831980
981+ if metadata .get ("is_read_only" ) == "1" :
982+ logger .debug (
983+ "Skipping update for read-only file" ,
984+ session_id = session_id [:12 ],
985+ file_id = file_id ,
986+ )
987+ return False
988+
832989 object_key = metadata .get ("object_key" )
833990 if not object_key :
834991 logger .warning (
0 commit comments