Skip to content

Commit 7974d84

Browse files
authored
Merge pull request #6202 from Duang777/codex/fix-project-uninstall-shared-state-20261011
fix: preserve retained state during project uninstall
2 parents 3054cdc + 5844eff commit 7974d84

3 files changed

Lines changed: 329 additions & 8 deletions

File tree

‎loopx/project_uninstall.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
from .control_plane.runtime.time import now_local_iso, utc_timestamp
1717
from .file_lock import exclusive_cross_runtime_file_lock
1818
from .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+
)
2024
from .registry import read_json, registry_goals
2125
from .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+
5466
def _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:
8597
def _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,

‎loopx/semantics/project_registry_io_manifest_v1.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,15 +2127,15 @@
21272127
},
21282128
{
21292129
"site": "loopx/project_uninstall.py::<module>.uninstall_project::codec_read:load_registry#1",
2130-
"line": 253,
2130+
"line": 327,
21312131
"column": 24,
21322132
"kind": "codec_read",
21332133
"api": "load_registry",
21342134
"classification": "codec_api"
21352135
},
21362136
{
21372137
"site": "loopx/project_uninstall.py::<module>.uninstall_project::codec_transaction:project_registry_transaction#1",
2138-
"line": 360,
2138+
"line": 437,
21392139
"column": 18,
21402140
"kind": "codec_transaction",
21412141
"api": "project_registry_transaction",

0 commit comments

Comments
 (0)