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:
AIOSAI
2026-06-07 00:24:31 -07:00
co-authored by Claude Opus 4.8
parent 59a6fcee13
commit e80e524dfe
6 changed files with 63 additions and 8 deletions
+9 -4
View File
@@ -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")
+3 -2
View File
@@ -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",
+16
View File
@@ -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
+23
View File
@@ -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