diff --git a/CHANGELOG.md b/CHANGELOG.md index 899c47c8..0247c1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/aipass/common/json_ops.py b/src/aipass/common/json_ops.py index d7bfbc12..ef749764 100644 --- a/src/aipass/common/json_ops.py +++ b/src/aipass/common/json_ops.py @@ -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") diff --git a/src/aipass/spawn/apps/handlers/update_ops.py b/src/aipass/spawn/apps/handlers/update_ops.py index e99cca83..be9e00e0 100644 --- a/src/aipass/spawn/apps/handlers/update_ops.py +++ b/src/aipass/spawn/apps/handlers/update_ops.py @@ -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: diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index 42e0e34f..a55ff68b 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -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", diff --git a/src/aipass/spawn/tests/test_handlers.py b/src/aipass/spawn/tests/test_handlers.py index f35ec50b..1224a220 100644 --- a/src/aipass/spawn/tests/test_handlers.py +++ b/src/aipass/spawn/tests/test_handlers.py @@ -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 diff --git a/src/aipass/spawn/tests/test_update.py b/src/aipass/spawn/tests/test_update.py index 0e97ba59..ae06a198 100644 --- a/src/aipass/spawn/tests/test_update.py +++ b/src/aipass/spawn/tests/test_update.py @@ -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