refactor(spawn): backups to single .spawn/.recovery namespace (TDPLAN-0006 P4)
Spawn's pre-merge JSON backups dropped a .recovery/ dir at each branch root, which had accumulated 242 stale auto-gen DASHBOARD backups across 10 branches (the original .recovery report that started this whole investigation). - aipass.common.json_ops.backup_json gained optional backup_dir param (default unchanged = file_path.parent/.recovery, backward-compatible). - spawn update engine (update_ops.py _merge_json) now passes branch_dir/.spawn/.recovery as the backup dest -> backups land under the spawn-managed .spawn/ dir, one namespace, not cluttering branch roots. - Memory stays in the safety net: no memory-exclusion added; the engine just never touches .trinity/DASHBOARD on update so it never backs them up. - 2 new tests (unit: custom backup_dir; integration: backup lands in .spawn/.recovery). 315 spawn + 438 aipass tests green; seedgo 100% both. Stale .recovery backups swept separately (untracked/gitignored, local hygiene). .recovery/ gitignore pattern already covers .spawn/.recovery/. TDPLAN-0006 P4 — final phase. Closes the spawn update-safety + consolidation work (P0 dry-run-default, P1 #636 engine, P2 shared lib, P3 import kill, P4 backup relocate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
59a6fcee13
commit
e80e524dfe
@@ -12,6 +12,16 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
### Changed
|
||||
|
||||
- **Spawn backups land in one namespace `.spawn/.recovery/` (TDPLAN-0006 P4).**
|
||||
Spawn's pre-merge JSON backups previously dropped a `.recovery/` directory at
|
||||
each branch root (which had accumulated 242 stale auto-generated `DASHBOARD`
|
||||
backups across 10 branches). `aipass.common.json_ops.backup_json` gained an
|
||||
optional `backup_dir` parameter (default unchanged), and spawn's update engine
|
||||
now directs backups to `{branch}/.spawn/.recovery/` — tucked under the
|
||||
spawn-managed `.spawn/` dir instead of cluttering the branch root. Memory stays
|
||||
in the safety net (the engine simply never touches `.trinity/`/`DASHBOARD` on
|
||||
update, so it never needs to back them up). Stale `.recovery/` backups cleaned
|
||||
up. (315 tests, seedgo 100%.)
|
||||
- **No more cross-branch engine imports — `aipass init update` calls spawn via
|
||||
subprocess (TDPLAN-0006 P3).** `init_flow.py` previously did
|
||||
`from aipass.spawn.apps.modules.sync_registry import sync_registry` — the one
|
||||
|
||||
@@ -74,14 +74,17 @@ def deep_merge(template_data: Any, existing_data: Any) -> Any:
|
||||
return template_data
|
||||
|
||||
|
||||
def backup_json(file_path: Path) -> Path:
|
||||
def backup_json(file_path: Path, backup_dir: Path | None = None) -> Path:
|
||||
"""Create a timestamped backup of a JSON file.
|
||||
|
||||
The backup is placed in a ``.recovery/`` directory alongside the file,
|
||||
named with a timestamp suffix.
|
||||
By default the backup is placed in a ``.recovery/`` directory alongside the
|
||||
file. Callers can override with ``backup_dir`` to consolidate backups
|
||||
elsewhere (e.g. ``.spawn/.recovery/``).
|
||||
|
||||
Args:
|
||||
file_path: Path to the JSON file to back up.
|
||||
backup_dir: Optional override for the backup destination directory.
|
||||
Defaults to ``file_path.parent / ".recovery"``.
|
||||
|
||||
Returns:
|
||||
Path to the backup file.
|
||||
@@ -95,7 +98,9 @@ def backup_json(file_path: Path) -> Path:
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Cannot backup — file not found: {file_path}")
|
||||
|
||||
backup_dir = file_path.parent / ".recovery"
|
||||
if backup_dir is None:
|
||||
backup_dir = file_path.parent / ".recovery"
|
||||
backup_dir = Path(backup_dir)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
@@ -161,7 +161,7 @@ def update_branch(branch_name: str, dry_run: bool = False, trace: bool = False)
|
||||
logger.info("[update] SKIP .py: %s", resolved_path)
|
||||
|
||||
elif dest.suffix == ".json":
|
||||
result = _merge_json(template_file, dest, replacements, dry_run, trace)
|
||||
result = _merge_json(template_file, dest, replacements, dry_run, trace, branch_dir / ".spawn" / ".recovery")
|
||||
if result == "updated":
|
||||
counts["updates"] += 1
|
||||
updates_detail.append({"template_path": rel_path, "branch_path": resolved_path})
|
||||
@@ -301,6 +301,7 @@ def _merge_json(
|
||||
replacements: dict,
|
||||
dry_run: bool,
|
||||
trace: bool,
|
||||
backup_dest: Path | None = None,
|
||||
) -> str:
|
||||
"""Deep-merge a template JSON file into the branch copy.
|
||||
|
||||
@@ -323,7 +324,7 @@ def _merge_json(
|
||||
return "unchanged"
|
||||
|
||||
if not dry_run:
|
||||
backup_json(dest)
|
||||
backup_json(dest, backup_dir=backup_dest)
|
||||
dest.write_text(merged_text, encoding="utf-8")
|
||||
|
||||
if trace:
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
"content_hash": "a4cf0a8e3b4f",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f026": {
|
||||
"f015": {
|
||||
"path": "apps/modules/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc",
|
||||
@@ -269,7 +269,7 @@
|
||||
"content_hash": "28e9ae373563",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f015": {
|
||||
"f026": {
|
||||
"path": "apps/plugins/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc",
|
||||
|
||||
@@ -338,6 +338,22 @@ class TestBackupJson:
|
||||
# Content should match
|
||||
assert backup_path.read_text(encoding="utf-8") == '{"key": "value"}'
|
||||
|
||||
def test_custom_backup_dir(self, tmp_path):
|
||||
"""backup_dir override should place backup in the specified directory."""
|
||||
from aipass.spawn.apps.handlers.json_ops import backup_json
|
||||
|
||||
source = tmp_path / "config.json"
|
||||
source.write_text('{"a": 1}', encoding="utf-8")
|
||||
custom_dir = tmp_path / ".spawn" / ".recovery"
|
||||
|
||||
backup_path = backup_json(source, backup_dir=custom_dir)
|
||||
|
||||
assert backup_path.exists()
|
||||
assert backup_path.parent == custom_dir
|
||||
assert "config.json" in backup_path.name
|
||||
assert ".backup" in backup_path.name
|
||||
assert backup_path.read_text(encoding="utf-8") == '{"a": 1}'
|
||||
|
||||
def test_raises_on_missing_source(self, tmp_path):
|
||||
"""Should raise FileNotFoundError for non-existent source."""
|
||||
from aipass.spawn.apps.handlers.json_ops import backup_json
|
||||
|
||||
@@ -426,6 +426,29 @@ class TestNeverUpdateGuard:
|
||||
assert result["renames"] == 0
|
||||
assert result.get("_renames_detail", []) == []
|
||||
|
||||
def test_backup_lands_in_spawn_recovery(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""JSON merge backups should land in .spawn/.recovery/, not branch root .recovery/."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
config_tpl = template_dir / "config.json"
|
||||
config_tpl.write_text(json.dumps({"version": "2.0", "new_key": "added"}, indent=2))
|
||||
config_branch = branch_dir / "config.json"
|
||||
config_branch.write_text(json.dumps({"version": "1.0"}, indent=2))
|
||||
|
||||
with (
|
||||
patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir),
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry),
|
||||
):
|
||||
result = update_branch("test_branch")
|
||||
|
||||
assert result["updates"] >= 1
|
||||
spawn_recovery = branch_dir / ".spawn" / ".recovery"
|
||||
assert spawn_recovery.is_dir()
|
||||
backups = list(spawn_recovery.glob("config.json.*.backup"))
|
||||
assert len(backups) == 1
|
||||
root_recovery = branch_dir / ".recovery"
|
||||
assert not root_recovery.exists()
|
||||
|
||||
def test_create_update_invariant(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""Fresh branch from template should show 0 changes on update."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
Reference in New Issue
Block a user