Coverage for src / moai_adk / core / rollback_manager.py: 0.00%
463 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""
2Rollback Manager for Research Integration Changes
4Provides comprehensive rollback system for:
5- Configuration backup and restore
6- Version management
7- Safe rollback procedures
8- Integration with existing MoAI-ADK backup systems
9- Research-specific rollback operations
11Supports:
12- Full system rollback
13- Component-specific rollback
14- Incremental rollback
15- Emergency rollback
16- Rollback validation and verification
17"""
19import hashlib
20import json
21import logging
22import os
23import shutil
24import sys
25from dataclasses import asdict, dataclass
26from datetime import datetime, timezone
27from pathlib import Path
28from typing import Any, Dict, List, Tuple
30# Configure logging
31logging.basicConfig(level=logging.INFO)
32logger = logging.getLogger(__name__)
35@dataclass
36class RollbackPoint:
37 """Represents a rollback point with metadata"""
39 id: str
40 timestamp: datetime
41 description: str
42 changes: List[str]
43 backup_path: str
44 checksum: str
45 metadata: Dict[str, Any]
48@dataclass
49class RollbackResult:
50 """Result of a rollback operation"""
52 success: bool
53 rollback_point_id: str
54 message: str
55 restored_files: List[str]
56 failed_files: List[str] = None
57 validation_results: Dict[str, Any] = None
60class RollbackManager:
61 """Comprehensive rollback management system"""
63 def __init__(self, project_root: Path = None):
64 self.project_root = project_root or Path.cwd()
65 self.backup_root = self.project_root / ".moai" / "rollbacks"
66 self.config_backup_dir = self.backup_root / "config"
67 self.code_backup_dir = self.backup_root / "code"
68 self.docs_backup_dir = self.backup_root / "docs"
69 self.registry_file = self.backup_root / "rollback_registry.json"
71 # Create backup directories
72 self.backup_root.mkdir(parents=True, exist_ok=True)
73 self.config_backup_dir.mkdir(parents=True, exist_ok=True)
74 self.code_backup_dir.mkdir(parents=True, exist_ok=True)
75 self.docs_backup_dir.mkdir(parents=True, exist_ok=True)
77 # Load existing registry
78 self.registry = self._load_registry()
80 # Research-specific paths
81 self.research_dirs = [
82 self.project_root / ".claude" / "skills",
83 self.project_root / ".claude" / "agents",
84 self.project_root / ".claude" / "commands",
85 self.project_root / ".claude" / "hooks",
86 ]
88 def create_rollback_point(self, description: str, changes: List[str] = None) -> str:
89 """
90 Create a rollback point before making changes
92 Args:
93 description: Description of the changes being made
94 changes: List of specific changes (files modified, components updated)
96 Returns:
97 Rollback point ID
98 """
99 rollback_id = self._generate_rollback_id()
100 timestamp = datetime.now(timezone.utc)
102 logger.info(f"Creating rollback point {rollback_id}: {description}")
104 try:
105 # Create backup directory for this rollback point
106 rollback_dir = self.backup_root / rollback_id
107 rollback_dir.mkdir(parents=True, exist_ok=True)
109 # Backup configuration files
110 config_backup_path = self._backup_configuration(rollback_dir)
112 # Backup research components
113 research_backup_path = self._backup_research_components(rollback_dir)
115 # Backup project files
116 code_backup_path = self._backup_code_files(rollback_dir)
118 # Create checksum for integrity verification
119 checksum = self._calculate_backup_checksum(rollback_dir)
121 # Create rollback point record
122 rollback_point = RollbackPoint(
123 id=rollback_id,
124 timestamp=timestamp,
125 description=description,
126 changes=changes or [],
127 backup_path=str(rollback_dir),
128 checksum=checksum,
129 metadata={
130 "config_backup": config_backup_path,
131 "research_backup": research_backup_path,
132 "code_backup": code_backup_path,
133 "project_root": str(self.project_root),
134 "created_by": "rollback_manager",
135 "version": "1.0.0",
136 },
137 )
139 # Register rollback point
140 self.registry[rollback_id] = asdict(rollback_point)
141 self._save_registry()
143 logger.info(f"Rollback point {rollback_id} created successfully")
144 return rollback_id
146 except Exception as e:
147 logger.error(f"Failed to create rollback point: {str(e)}")
148 # Cleanup partial backup
149 self._cleanup_partial_backup(rollback_id)
150 raise
152 def rollback_to_point(
153 self,
154 rollback_id: str,
155 validate_before: bool = True,
156 validate_after: bool = True,
157 ) -> RollbackResult:
158 """
159 Rollback to a specific rollback point
161 Args:
162 rollback_id: ID of rollback point to restore
163 validate_before: Validate rollback point before restoration
164 validate_after: Validate system after restoration
166 Returns:
167 RollbackResult with operation details
168 """
169 if rollback_id not in self.registry:
170 return RollbackResult(
171 success=False,
172 rollback_point_id=rollback_id,
173 message=f"Rollback point {rollback_id} not found",
174 restored_files=[],
175 )
177 logger.info(f"Rolling back to point {rollback_id}")
179 try:
180 rollback_point = RollbackPoint(**self.registry[rollback_id])
182 # Pre-rollback validation
183 if validate_before:
184 validation_result = self._validate_rollback_point(rollback_point)
185 if not validation_result["valid"]:
186 return RollbackResult(
187 success=False,
188 rollback_point_id=rollback_id,
189 message=f"Rollback point validation failed: {validation_result['message']}",
190 restored_files=[],
191 )
193 # Perform rollback
194 restored_files, failed_files = self._perform_rollback(rollback_point)
196 # Post-rollback validation
197 validation_results = {}
198 if validate_after:
199 validation_results = self._validate_system_after_rollback()
201 # Update registry with rollback info
202 self._mark_rollback_as_used(rollback_id)
204 success = len(failed_files) == 0
206 result = RollbackResult(
207 success=success,
208 rollback_point_id=rollback_id,
209 message=f"Rollback {'completed successfully' if success else 'completed with errors'}",
210 restored_files=restored_files,
211 failed_files=failed_files or [],
212 validation_results=validation_results,
213 )
215 logger.info(f"Rollback {rollback_id} completed. Success: {success}")
216 return result
218 except Exception as e:
219 logger.error(f"Rollback failed: {str(e)}")
220 return RollbackResult(
221 success=False,
222 rollback_point_id=rollback_id,
223 message=f"Rollback failed with error: {str(e)}",
224 restored_files=[],
225 )
227 def rollback_research_integration(
228 self, component_type: str = None, component_name: str = None
229 ) -> RollbackResult:
230 """
231 Specialized rollback for research integration changes
233 Args:
234 component_type: Type of component (skills, agents, commands, hooks)
235 component_name: Specific component name to rollback
237 Returns:
238 RollbackResult with operation details
239 """
240 logger.info(
241 f"Rolling back research integration: {component_type}:{component_name}"
242 )
244 try:
245 # Find relevant rollback points for research integration
246 research_rollback_points = self._find_research_rollback_points(
247 component_type, component_name
248 )
250 if not research_rollback_points:
251 return RollbackResult(
252 success=False,
253 rollback_point_id="",
254 message="No suitable rollback points found for research integration",
255 restored_files=[],
256 )
258 # Use the most recent suitable rollback point
259 latest_rollback = max(
260 research_rollback_points, key=lambda x: x["timestamp"]
261 )
263 # Perform targeted rollback
264 restored_files, failed_files = self._perform_research_rollback(
265 latest_rollback, component_type, component_name
266 )
268 # Validate research components
269 validation_results = self._validate_research_components()
271 success = len(failed_files) == 0
273 return RollbackResult(
274 success=success,
275 rollback_point_id=latest_rollback["id"],
276 message=f"Research integration rollback {'completed successfully' if success else 'completed with errors'}",
277 restored_files=restored_files,
278 failed_files=failed_files or [],
279 validation_results=validation_results,
280 )
282 except Exception as e:
283 logger.error(f"Research integration rollback failed: {str(e)}")
284 return RollbackResult(
285 success=False,
286 rollback_point_id="",
287 message=f"Research integration rollback failed: {str(e)}",
288 restored_files=[],
289 )
291 def list_rollback_points(self, limit: int = 10) -> List[Dict[str, Any]]:
292 """
293 List available rollback points
295 Args:
296 limit: Maximum number of rollback points to return
298 Returns:
299 List of rollback point information
300 """
301 rollback_points = []
303 for rollback_id, rollback_data in self.registry.items():
304 rollback_points.append(
305 {
306 "id": rollback_id,
307 "timestamp": rollback_data["timestamp"],
308 "description": rollback_data["description"],
309 "changes_count": len(rollback_data.get("changes", [])),
310 "used": rollback_data.get("used", False),
311 }
312 )
314 # Sort by timestamp (newest first) and limit
315 rollback_points.sort(key=lambda x: x["timestamp"], reverse=True)
316 return rollback_points[:limit]
318 def validate_rollback_system(self) -> Dict[str, Any]:
319 """
320 Validate the rollback system integrity
322 Returns:
323 Validation results with system health information
324 """
325 validation_results = {
326 "system_healthy": True,
327 "issues": [],
328 "recommendations": [],
329 "rollback_points_count": len(self.registry),
330 "backup_size": self._calculate_backup_size(),
331 "last_rollback": None,
332 }
334 try:
335 # Check backup directories exist
336 required_dirs = [
337 self.backup_root,
338 self.config_backup_dir,
339 self.code_backup_dir,
340 self.docs_backup_dir,
341 ]
343 for dir_path in required_dirs:
344 if not dir_path.exists():
345 validation_results["issues"].append(
346 f"Missing backup directory: {dir_path}"
347 )
348 validation_results["system_healthy"] = False
350 # Validate rollback points
351 invalid_rollback_points = []
352 for rollback_id, rollback_data in self.registry.items():
353 backup_path = Path(rollback_data["backup_path"])
354 if not backup_path.exists():
355 invalid_rollback_points.append(rollback_id)
357 if invalid_rollback_points:
358 validation_results["issues"].append(
359 f"Invalid rollback points: {invalid_rollback_points}"
360 )
361 validation_results["system_healthy"] = False
363 # Check available disk space
364 backup_size = validation_results["backup_size"]
365 free_space = shutil.disk_usage(self.backup_root).free
366 if backup_size > free_space * 0.8: # Using more than 80% of free space
367 validation_results["recommendations"].append(
368 "Consider cleaning up old rollback points"
369 )
371 # Check last rollback
372 if self.registry:
373 last_rollback = max(
374 self.registry.values(), key=lambda x: x["timestamp"]
375 )
376 validation_results["last_rollback"] = last_rollback["timestamp"]
378 except Exception as e:
379 validation_results["system_healthy"] = False
380 validation_results["issues"].append(f"Validation error: {str(e)}")
382 return validation_results
384 def cleanup_old_rollbacks(
385 self, keep_count: int = 10, dry_run: bool = True
386 ) -> Dict[str, Any]:
387 """
388 Clean up old rollback points
390 Args:
391 keep_count: Number of recent rollback points to keep
392 dry_run: If True, only show what would be deleted
394 Returns:
395 Cleanup operation results
396 """
397 rollback_points = list(self.registry.values())
398 rollback_points.sort(key=lambda x: x["timestamp"], reverse=True)
400 # Keep the most recent rollback points
401 to_keep = rollback_points[:keep_count]
402 to_delete = rollback_points[keep_count:]
404 if dry_run:
405 return {
406 "dry_run": True,
407 "would_delete_count": len(to_delete),
408 "would_keep_count": len(to_keep),
409 "would_free_space": sum(
410 self._get_directory_size(Path(rp["backup_path"]))
411 for rp in to_delete
412 ),
413 }
415 # Perform actual cleanup
416 deleted_count = 0
417 freed_space = 0
419 for rollback_point in to_delete:
420 try:
421 backup_path = Path(rollback_point["backup_path"])
422 if backup_path.exists():
423 size = self._get_directory_size(backup_path)
424 shutil.rmtree(backup_path)
425 freed_space += size
427 # Remove from registry
428 del self.registry[rollback_point["id"]]
429 deleted_count += 1
431 except Exception as e:
432 logger.warning(
433 f"Failed to delete rollback point {rollback_point['id']}: {str(e)}"
434 )
436 # Save updated registry
437 self._save_registry()
439 return {
440 "dry_run": False,
441 "deleted_count": deleted_count,
442 "kept_count": len(to_keep),
443 "freed_space": freed_space,
444 }
446 def _generate_rollback_id(self) -> str:
447 """Generate unique rollback point ID"""
448 timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
449 random_suffix = hashlib.md5(os.urandom(4)).hexdigest()[:8]
450 return f"rollback_{timestamp}_{random_suffix}"
452 def _load_registry(self) -> Dict[str, Any]:
453 """Load rollback registry from file"""
454 if self.registry_file.exists():
455 try:
456 with open(self.registry_file, "r", encoding="utf-8") as f:
457 return json.load(f)
458 except Exception as e:
459 logger.warning(f"Failed to load rollback registry: {str(e)}")
461 return {}
463 def _save_registry(self):
464 """Save rollback registry to file"""
465 try:
466 with open(self.registry_file, "w", encoding="utf-8") as f:
467 json.dump(self.registry, f, indent=2, default=str, ensure_ascii=False)
468 except Exception as e:
469 logger.error(f"Failed to save rollback registry: {str(e)}")
470 raise
472 def _backup_configuration(self, rollback_dir: Path) -> str:
473 """Backup configuration files"""
474 config_backup_path = rollback_dir / "config"
475 config_backup_path.mkdir(parents=True, exist_ok=True)
477 # Backup .moai/config/config.json
478 config_file = self.project_root / ".moai" / "config" / "config.json"
479 if config_file.exists():
480 shutil.copy2(config_file, config_backup_path / "config.json")
482 # Backup .claude/settings.json
483 settings_file = self.project_root / ".claude" / "settings.json"
484 if settings_file.exists():
485 shutil.copy2(settings_file, config_backup_path / "settings.json")
487 # Backup .claude/settings.local.json
488 local_settings_file = self.project_root / ".claude" / "settings.local.json"
489 if local_settings_file.exists():
490 shutil.copy2(
491 local_settings_file, config_backup_path / "settings.local.json"
492 )
494 return str(config_backup_path)
496 def _backup_research_components(self, rollback_dir: Path) -> str:
497 """Backup research-specific components"""
498 research_backup_path = rollback_dir / "research"
499 research_backup_path.mkdir(parents=True, exist_ok=True)
501 for research_dir in self.research_dirs:
502 if research_dir.exists():
503 dir_name = research_dir.name
504 target_dir = research_backup_path / dir_name
505 shutil.copytree(research_dir, target_dir, dirs_exist_ok=True)
507 return str(research_backup_path)
509 def _backup_code_files(self, rollback_dir: Path) -> str:
510 """Backup important code files"""
511 code_backup_path = rollback_dir / "code"
512 code_backup_path.mkdir(parents=True, exist_ok=True)
514 # Backup source code
515 src_dir = self.project_root / "src"
516 if src_dir.exists():
517 shutil.copytree(src_dir, code_backup_path / "src", dirs_exist_ok=True)
519 # Backup tests
520 tests_dir = self.project_root / "tests"
521 if tests_dir.exists():
522 shutil.copytree(tests_dir, code_backup_path / "tests", dirs_exist_ok=True)
524 # Backup documentation
525 docs_dir = self.project_root / "docs"
526 if docs_dir.exists():
527 shutil.copytree(docs_dir, code_backup_path / "docs", dirs_exist_ok=True)
529 return str(code_backup_path)
531 def _calculate_backup_checksum(self, backup_dir: Path) -> str:
532 """Calculate checksum for backup integrity verification"""
533 checksum_hash = hashlib.sha256()
535 for file_path in backup_dir.rglob("*"):
536 if file_path.is_file():
537 with open(file_path, "rb") as f:
538 # Update hash with file content and path
539 checksum_hash.update(f.read())
540 checksum_hash.update(
541 str(file_path.relative_to(backup_dir)).encode()
542 )
544 return checksum_hash.hexdigest()
546 def _validate_rollback_point(self, rollback_point: RollbackPoint) -> Dict[str, Any]:
547 """Validate rollback point before restoration"""
548 validation_result = {
549 "valid": True,
550 "message": "Rollback point is valid",
551 "warnings": [],
552 }
554 try:
555 # Check backup directory exists
556 backup_path = Path(rollback_point.backup_path)
557 if not backup_path.exists():
558 validation_result["valid"] = False
559 validation_result["message"] = "Backup directory not found"
560 return validation_result
562 # Verify checksum
563 current_checksum = self._calculate_backup_checksum(backup_path)
564 if current_checksum != rollback_point.checksum:
565 validation_result["warnings"].append(
566 "Backup checksum mismatch - possible corruption"
567 )
569 # Check essential files exist
570 required_files = [
571 backup_path / "config" / "config.json",
572 backup_path / "research",
573 ]
575 missing_files = [f for f in required_files if not f.exists()]
576 if missing_files:
577 validation_result["warnings"].append(
578 f"Missing backup files: {missing_files}"
579 )
581 except Exception as e:
582 validation_result["valid"] = False
583 validation_result["message"] = f"Validation error: {str(e)}"
585 return validation_result
587 def _perform_rollback(
588 self, rollback_point: RollbackPoint
589 ) -> Tuple[List[str], List[str]]:
590 """Perform the actual rollback operation"""
591 backup_path = Path(rollback_point.backup_path)
592 restored_files = []
593 failed_files = []
595 try:
596 # Restore configuration
597 config_backup = backup_path / "config"
598 if config_backup.exists():
599 for config_file in config_backup.rglob("*"):
600 if config_file.is_file():
601 target_path = (
602 self.project_root
603 / ".moai"
604 / config_file.relative_to(config_backup)
605 )
606 target_path.parent.mkdir(parents=True, exist_ok=True)
607 try:
608 shutil.copy2(config_file, target_path)
609 restored_files.append(str(target_path))
610 except Exception as e:
611 failed_files.append((str(target_path), str(e)))
613 # Restore research components
614 research_backup = backup_path / "research"
615 if research_backup.exists():
616 for research_file in research_backup.rglob("*"):
617 if research_file.is_file():
618 target_path = self.project_root / research_file.relative_to(
619 research_backup
620 )
621 target_path.parent.mkdir(parents=True, exist_ok=True)
622 try:
623 shutil.copy2(research_file, target_path)
624 restored_files.append(str(target_path))
625 except Exception as e:
626 failed_files.append((str(target_path), str(e)))
628 # Restore code files
629 code_backup = backup_path / "code"
630 if code_backup.exists():
631 for code_file in code_backup.rglob("*"):
632 if code_file.is_file():
633 target_path = self.project_root / code_file.relative_to(
634 code_backup
635 )
636 target_path.parent.mkdir(parents=True, exist_ok=True)
637 try:
638 shutil.copy2(code_file, target_path)
639 restored_files.append(str(target_path))
640 except Exception as e:
641 failed_files.append((str(target_path), str(e)))
643 except Exception as e:
644 logger.error(f"Rollback operation failed: {str(e)}")
645 failed_files.append(("rollback_operation", str(e)))
647 return restored_files, failed_files
649 def _perform_research_rollback(
650 self,
651 rollback_point: Dict[str, Any],
652 component_type: str = None,
653 component_name: str = None,
654 ) -> Tuple[List[str], List[str]]:
655 """Perform targeted research component rollback"""
656 backup_path = Path(rollback_point["backup_path"])
657 research_backup = backup_path / "research"
659 restored_files = []
660 failed_files = []
662 if not research_backup.exists():
663 failed_files.append(("research_backup", "Research backup not found"))
664 return restored_files, failed_files
666 try:
667 # Restore specific component or all research components
668 if component_type:
669 component_backup_dir = research_backup / component_type
670 if component_backup_dir.exists():
671 target_dir = self.project_root / ".claude" / component_type
673 if component_name:
674 # Restore specific component
675 component_file = component_backup_dir / f"{component_name}.md"
676 if component_file.exists():
677 target_file = target_dir / f"{component_name}.md"
678 target_file.parent.mkdir(parents=True, exist_ok=True)
679 shutil.copy2(component_file, target_file)
680 restored_files.append(str(target_file))
681 else:
682 failed_files.append(
683 (component_name, "Component file not found in backup")
684 )
685 else:
686 # Restore entire component type
687 if target_dir.exists():
688 shutil.rmtree(target_dir)
689 shutil.copytree(component_backup_dir, target_dir)
690 restored_files.append(str(target_dir))
691 else:
692 failed_files.append(
693 (component_type, "Component type not found in backup")
694 )
695 else:
696 # Restore all research components
697 for research_dir in research_backup.iterdir():
698 if research_dir.is_dir():
699 target_dir = self.project_root / ".claude" / research_dir.name
700 if target_dir.exists():
701 shutil.rmtree(target_dir)
702 shutil.copytree(research_dir, target_dir)
703 restored_files.append(str(target_dir))
705 except Exception as e:
706 logger.error(f"Research rollback failed: {str(e)}")
707 failed_files.append(("research_rollback", str(e)))
709 return restored_files, failed_files
711 def _validate_system_after_rollback(self) -> Dict[str, Any]:
712 """Validate system state after rollback"""
713 validation_results = {
714 "config_valid": True,
715 "research_valid": True,
716 "issues": [],
717 }
719 try:
720 # Validate configuration
721 config_file = self.project_root / ".moai" / "config" / "config.json"
722 if config_file.exists():
723 try:
724 with open(config_file, "r", encoding="utf-8") as f:
725 json.load(f) # Validate JSON syntax
726 except json.JSONDecodeError:
727 validation_results["config_valid"] = False
728 validation_results["issues"].append("Invalid JSON in config.json")
729 else:
730 validation_results["config_valid"] = False
731 validation_results["issues"].append("config.json not found")
733 # Validate research components
734 for research_dir in self.research_dirs:
735 if research_dir.exists():
736 # Check for readable files
737 for file_path in research_dir.rglob("*.md"):
738 try:
739 with open(file_path, "r", encoding="utf-8") as f:
740 f.read() # Validate file can be read
741 except Exception as e:
742 validation_results["research_valid"] = False
743 validation_results["issues"].append(
744 f"Cannot read {file_path}: {str(e)}"
745 )
747 except Exception as e:
748 validation_results["issues"].append(f"Validation error: {str(e)}")
750 return validation_results
752 def _validate_research_components(self) -> Dict[str, Any]:
753 """Validate research components after rollback"""
754 validation_results = {
755 "skills_valid": True,
756 "agents_valid": True,
757 "commands_valid": True,
758 "hooks_valid": True,
759 "issues": [],
760 }
762 component_checks = [
763 ("skills", "Skills", self.project_root / ".claude" / "skills"),
764 ("agents", "Agents", self.project_root / ".claude" / "agents"),
765 ("commands", "Commands", self.project_root / ".claude" / "commands"),
766 ("hooks", "Hooks", self.project_root / ".claude" / "hooks"),
767 ]
769 for component_key, component_name, component_path in component_checks:
770 if component_path.exists():
771 # Check component structure
772 files = list(component_path.rglob("*.md"))
773 if not files:
774 validation_results[f"{component_key}_valid"] = False
775 validation_results["issues"].append(
776 f"{component_name} directory is empty"
777 )
779 # Validate file content
780 for file_path in files[:5]: # Check first 5 files
781 try:
782 with open(file_path, "r", encoding="utf-8") as f:
783 content = f.read()
784 if not content.strip():
785 validation_results[f"{component_key}_valid"] = False
786 validation_results["issues"].append(
787 f"Empty file: {file_path}"
788 )
789 except Exception as e:
790 validation_results[f"{component_key}_valid"] = False
791 validation_results["issues"].append(
792 f"Cannot read {file_path}: {str(e)}"
793 )
794 else:
795 validation_results[f"{component_key}_valid"] = False
796 validation_results["issues"].append(
797 f"{component_name} directory not found"
798 )
800 return validation_results
802 def _find_research_rollback_points(
803 self, component_type: str = None, component_name: str = None
804 ) -> List[Dict[str, Any]]:
805 """Find rollback points related to research integration"""
806 research_rollback_points = []
808 for rollback_id, rollback_data in self.registry.items():
809 # Check if rollback point has research backup
810 backup_path = Path(rollback_data["backup_path"])
811 research_backup = backup_path / "research"
813 if not research_backup.exists():
814 continue
816 # Check for specific component match
817 if component_type:
818 component_backup = research_backup / component_type
819 if component_backup.exists():
820 if component_name:
821 component_file = component_backup / f"{component_name}.md"
822 if component_file.exists():
823 research_rollback_points.append(rollback_data)
824 else:
825 research_rollback_points.append(rollback_data)
826 else:
827 # Include any rollback with research components
828 research_rollback_points.append(rollback_data)
830 return research_rollback_points
832 def _mark_rollback_as_used(self, rollback_id: str):
833 """Mark rollback point as used in registry"""
834 if rollback_id in self.registry:
835 self.registry[rollback_id]["used"] = True
836 self.registry[rollback_id]["used_timestamp"] = datetime.now(
837 timezone.utc
838 ).isoformat()
839 self._save_registry()
841 def _cleanup_partial_backup(self, rollback_id: str):
842 """Clean up partial backup if creation failed"""
843 try:
844 backup_dir = self.backup_root / rollback_id
845 if backup_dir.exists():
846 shutil.rmtree(backup_dir)
847 except Exception as e:
848 logger.warning(f"Failed to cleanup partial backup {rollback_id}: {str(e)}")
850 def _calculate_backup_size(self) -> int:
851 """Calculate total size of all backups"""
852 total_size = 0
853 for rollback_id, rollback_data in self.registry.items():
854 backup_path = Path(rollback_data["backup_path"])
855 if backup_path.exists():
856 total_size += self._get_directory_size(backup_path)
857 return total_size
859 def _get_directory_size(self, directory: Path) -> int:
860 """Get total size of directory in bytes"""
861 total_size = 0
862 try:
863 for file_path in directory.rglob("*"):
864 if file_path.is_file():
865 total_size += file_path.stat().st_size
866 except Exception:
867 pass # Ignore errors in size calculation
868 return total_size
871# Command-line interface for rollback manager
872def main():
873 """Command-line interface for rollback operations"""
874 import argparse
876 parser = argparse.ArgumentParser(description="MoAI-ADK Rollback Manager")
877 subparsers = parser.add_subparsers(dest="command", help="Available commands")
879 # Create rollback point
880 create_parser = subparsers.add_parser("create", help="Create rollback point")
881 create_parser.add_argument("description", help="Description of changes")
882 create_parser.add_argument("--changes", nargs="*", help="List of changes")
884 # List rollback points
885 list_parser = subparsers.add_parser("list", help="List rollback points")
886 list_parser.add_argument(
887 "--limit", type=int, default=10, help="Maximum number to show"
888 )
890 # Perform rollback
891 rollback_parser = subparsers.add_parser("rollback", help="Rollback to point")
892 rollback_parser.add_argument("rollback_id", help="Rollback point ID")
893 rollback_parser.add_argument(
894 "--no-validate", action="store_true", help="Skip validation"
895 )
897 # Research rollback
898 research_parser = subparsers.add_parser(
899 "research-rollback", help="Rollback research components"
900 )
901 research_parser.add_argument(
902 "--type", help="Component type (skills, agents, commands, hooks)"
903 )
904 research_parser.add_argument("--name", help="Component name")
906 # Validate system
907 subparsers.add_parser("validate", help="Validate rollback system")
909 # Cleanup
910 cleanup_parser = subparsers.add_parser(
911 "cleanup", help="Cleanup old rollback points"
912 )
913 cleanup_parser.add_argument("--keep", type=int, default=10, help="Number to keep")
914 cleanup_parser.add_argument(
915 "--execute", action="store_true", help="Execute cleanup (default: dry run)"
916 )
918 args = parser.parse_args()
920 if not args.command:
921 parser.print_help()
922 return
924 # Initialize rollback manager
925 rollback_manager = RollbackManager()
927 try:
928 if args.command == "create":
929 rollback_id = rollback_manager.create_rollback_point(
930 args.description, args.changes
931 )
932 print(f"Rollback point created: {rollback_id}")
934 elif args.command == "list":
935 rollback_points = rollback_manager.list_rollback_points(args.limit)
936 print(f"Available rollback points (showing {len(rollback_points)}):")
937 for rp in rollback_points:
938 status = "USED" if rp["used"] else "AVAILABLE"
939 print(f" {rp['id']} - {rp['description']} ({status})")
941 elif args.command == "rollback":
942 result = rollback_manager.rollback_to_point(
943 args.rollback_id, validate_before=not args.no_validate
944 )
945 if result.success:
946 print("Rollback completed successfully")
947 print(f"Restored {len(result.restored_files)} files")
948 else:
949 print(f"Rollback failed: {result.message}")
951 elif args.command == "research-rollback":
952 result = rollback_manager.rollback_research_integration(
953 args.type, args.name
954 )
955 if result.success:
956 print("Research rollback completed successfully")
957 else:
958 print(f"Research rollback failed: {result.message}")
960 elif args.command == "validate":
961 validation = rollback_manager.validate_rollback_system()
962 print(
963 f"Rollback system health: {'HEALTHY' if validation['system_healthy'] else 'UNHEALTHY'}"
964 )
965 if validation["issues"]:
966 print("Issues found:")
967 for issue in validation["issues"]:
968 print(f" - {issue}")
969 if validation["recommendations"]:
970 print("Recommendations:")
971 for rec in validation["recommendations"]:
972 print(f" - {rec}")
974 elif args.command == "cleanup":
975 result = rollback_manager.cleanup_old_rollback_points(
976 args.keep, dry_run=not args.execute
977 )
978 if result["dry_run"]:
979 print(
980 f"Dry run: Would delete {result['would_delete_count']} rollback points"
981 )
982 print(f"Would free {result['would_free_space'] / 1024 / 1024:.1f} MB")
983 else:
984 print(f"Deleted {result['deleted_count']} rollback points")
985 print(f"Freed {result['freed_space'] / 1024 / 1024:.1f} MB")
987 except Exception as e:
988 print(f"Error: {str(e)}")
989 sys.exit(1)
992if __name__ == "__main__":
993 main()