1616from .control_plane .runtime .time import now_local_iso , utc_timestamp
1717from .file_lock import exclusive_cross_runtime_file_lock
1818from .global_registry import GlobalRegistryReduction , mutate_global_registry
19- from .paths import global_registry_path , resolve_runtime_root , select_default_runtime_root
19+ from .paths import (
20+ global_registry_path ,
21+ resolve_runtime_root ,
22+ select_default_runtime_root ,
23+ )
2024from .registry import read_json , registry_goals
2125from .runtime import validate_goal_id_path_segment
2226
@@ -51,6 +55,14 @@ def _same_path(left: str | Path | None, right: str | Path | None) -> bool:
5155 return _resolve_path (Path (str (left ))) == _resolve_path (Path (str (right )))
5256
5357
58+ def _path_is_within (path : Path , directory : Path ) -> bool :
59+ try :
60+ path .expanduser ().resolve ().relative_to (directory .expanduser ().resolve ())
61+ except (OSError , ValueError ):
62+ return False
63+ return True
64+
65+
5466def _project_root_from_registry (registry_path : Path ) -> Path :
5567 parent = registry_path .expanduser ().parent
5668 if parent .name == ".loopx" :
@@ -85,6 +97,7 @@ def _unique_destination(path: Path) -> Path:
8597def _archive_state_directory (
8698 * ,
8799 goal : dict [str , Any ],
100+ retained_goals : list [dict [str , Any ]],
88101 registry_path : Path ,
89102 archive_root : Path ,
90103 timestamp : str ,
@@ -111,6 +124,33 @@ def _archive_state_directory(
111124 "archived" : False ,
112125 "warning" : "state file is outside the project root; kept in place" ,
113126 }
127+ retained_state_owners = [
128+ (str (retained .get ("id" ) or "" ), retained_state )
129+ for retained in retained_goals
130+ if (
131+ retained_state := _resolve_state_file (
132+ retained ,
133+ registry_path = registry_path ,
134+ )
135+ )
136+ is not None
137+ and _path_is_within (retained_state , state_dir_resolved )
138+ ]
139+ shared_state_owners = [
140+ retained_goal_id
141+ for retained_goal_id , retained_state in retained_state_owners
142+ if _same_path (retained_state , state_file )
143+ ]
144+ if shared_state_owners :
145+ return {
146+ "goal_id" : goal_id ,
147+ "state_file" : str (state_file ),
148+ "state_dir" : str (state_dir ),
149+ "action" : "kept-shared-state-file" ,
150+ "archived" : False ,
151+ "retained_goal_ids" : sorted (shared_state_owners ),
152+ "warning" : "state file is still registered by a retained goal; kept in place" ,
153+ }
114154 if not state_dir .exists ():
115155 return {
116156 "goal_id" : goal_id ,
@@ -120,6 +160,40 @@ def _archive_state_directory(
120160 "archived" : False ,
121161 }
122162 destination = _unique_destination (archive_root / timestamp / "goals" / goal_id )
163+ if retained_state_owners :
164+ if not state_file .exists ():
165+ return {
166+ "goal_id" : goal_id ,
167+ "state_file" : str (state_file ),
168+ "state_dir" : str (state_dir ),
169+ "action" : "state-file-missing" ,
170+ "archived" : False ,
171+ "retained_goal_ids" : sorted (
172+ retained_goal_id for retained_goal_id , _ in retained_state_owners
173+ ),
174+ "warning" : (
175+ "state directory is still registered by a retained goal; "
176+ "kept in place"
177+ ),
178+ }
179+ if not dry_run :
180+ destination .mkdir (parents = True , exist_ok = True )
181+ shutil .move (str (state_file ), str (destination / state_file .name ))
182+ return {
183+ "goal_id" : goal_id ,
184+ "state_file" : str (state_file ),
185+ "state_dir" : str (state_dir ),
186+ "archive_path" : str (destination ),
187+ "action" : "would-move-state-file" if dry_run else "moved-state-file" ,
188+ "archived" : not dry_run ,
189+ "retained_goal_ids" : sorted (
190+ retained_goal_id for retained_goal_id , _ in retained_state_owners
191+ ),
192+ "warning" : (
193+ "state directory is still registered by a retained goal; "
194+ "archived only the selected state file"
195+ ),
196+ }
123197 if not dry_run :
124198 destination .parent .mkdir (parents = True , exist_ok = True )
125199 shutil .move (str (state_dir ), str (destination ))
@@ -270,6 +344,7 @@ def uninstall_project(
270344 project_registry ,
271345 target_goal_ids = target_goal_ids ,
272346 )
347+ retained_goals = registry_goals (new_project_registry )
273348
274349 global_preview = _uninstall_global_registry_reduction (
275350 read_json (global_path ) if global_path .exists () else {},
@@ -289,6 +364,7 @@ def uninstall_project(
289364 [
290365 _archive_state_directory (
291366 goal = goal ,
367+ retained_goals = retained_goals ,
292368 registry_path = registry_path ,
293369 archive_root = archive_root ,
294370 timestamp = timestamp ,
@@ -345,6 +421,7 @@ def uninstall_project(
345421 state_actions = [
346422 _archive_state_directory (
347423 goal = goal ,
424+ retained_goals = retained_goals ,
348425 registry_path = registry_path ,
349426 archive_root = archive_root ,
350427 timestamp = timestamp ,
0 commit comments