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

1""" 

2Rollback Manager for Research Integration Changes 

3 

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 

10 

11Supports: 

12- Full system rollback 

13- Component-specific rollback 

14- Incremental rollback 

15- Emergency rollback 

16- Rollback validation and verification 

17""" 

18 

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 

29 

30# Configure logging 

31logging.basicConfig(level=logging.INFO) 

32logger = logging.getLogger(__name__) 

33 

34 

35@dataclass 

36class RollbackPoint: 

37 """Represents a rollback point with metadata""" 

38 

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] 

46 

47 

48@dataclass 

49class RollbackResult: 

50 """Result of a rollback operation""" 

51 

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 

58 

59 

60class RollbackManager: 

61 """Comprehensive rollback management system""" 

62 

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" 

70 

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) 

76 

77 # Load existing registry 

78 self.registry = self._load_registry() 

79 

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 ] 

87 

88 def create_rollback_point(self, description: str, changes: List[str] = None) -> str: 

89 """ 

90 Create a rollback point before making changes 

91 

92 Args: 

93 description: Description of the changes being made 

94 changes: List of specific changes (files modified, components updated) 

95 

96 Returns: 

97 Rollback point ID 

98 """ 

99 rollback_id = self._generate_rollback_id() 

100 timestamp = datetime.now(timezone.utc) 

101 

102 logger.info(f"Creating rollback point {rollback_id}: {description}") 

103 

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) 

108 

109 # Backup configuration files 

110 config_backup_path = self._backup_configuration(rollback_dir) 

111 

112 # Backup research components 

113 research_backup_path = self._backup_research_components(rollback_dir) 

114 

115 # Backup project files 

116 code_backup_path = self._backup_code_files(rollback_dir) 

117 

118 # Create checksum for integrity verification 

119 checksum = self._calculate_backup_checksum(rollback_dir) 

120 

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 ) 

138 

139 # Register rollback point 

140 self.registry[rollback_id] = asdict(rollback_point) 

141 self._save_registry() 

142 

143 logger.info(f"Rollback point {rollback_id} created successfully") 

144 return rollback_id 

145 

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 

151 

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 

160 

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 

165 

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 ) 

176 

177 logger.info(f"Rolling back to point {rollback_id}") 

178 

179 try: 

180 rollback_point = RollbackPoint(**self.registry[rollback_id]) 

181 

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 ) 

192 

193 # Perform rollback 

194 restored_files, failed_files = self._perform_rollback(rollback_point) 

195 

196 # Post-rollback validation 

197 validation_results = {} 

198 if validate_after: 

199 validation_results = self._validate_system_after_rollback() 

200 

201 # Update registry with rollback info 

202 self._mark_rollback_as_used(rollback_id) 

203 

204 success = len(failed_files) == 0 

205 

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 ) 

214 

215 logger.info(f"Rollback {rollback_id} completed. Success: {success}") 

216 return result 

217 

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 ) 

226 

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 

232 

233 Args: 

234 component_type: Type of component (skills, agents, commands, hooks) 

235 component_name: Specific component name to rollback 

236 

237 Returns: 

238 RollbackResult with operation details 

239 """ 

240 logger.info( 

241 f"Rolling back research integration: {component_type}:{component_name}" 

242 ) 

243 

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 ) 

249 

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 ) 

257 

258 # Use the most recent suitable rollback point 

259 latest_rollback = max( 

260 research_rollback_points, key=lambda x: x["timestamp"] 

261 ) 

262 

263 # Perform targeted rollback 

264 restored_files, failed_files = self._perform_research_rollback( 

265 latest_rollback, component_type, component_name 

266 ) 

267 

268 # Validate research components 

269 validation_results = self._validate_research_components() 

270 

271 success = len(failed_files) == 0 

272 

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 ) 

281 

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 ) 

290 

291 def list_rollback_points(self, limit: int = 10) -> List[Dict[str, Any]]: 

292 """ 

293 List available rollback points 

294 

295 Args: 

296 limit: Maximum number of rollback points to return 

297 

298 Returns: 

299 List of rollback point information 

300 """ 

301 rollback_points = [] 

302 

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 ) 

313 

314 # Sort by timestamp (newest first) and limit 

315 rollback_points.sort(key=lambda x: x["timestamp"], reverse=True) 

316 return rollback_points[:limit] 

317 

318 def validate_rollback_system(self) -> Dict[str, Any]: 

319 """ 

320 Validate the rollback system integrity 

321 

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 } 

333 

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 ] 

342 

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 

349 

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) 

356 

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 

362 

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 ) 

370 

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"] 

377 

378 except Exception as e: 

379 validation_results["system_healthy"] = False 

380 validation_results["issues"].append(f"Validation error: {str(e)}") 

381 

382 return validation_results 

383 

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 

389 

390 Args: 

391 keep_count: Number of recent rollback points to keep 

392 dry_run: If True, only show what would be deleted 

393 

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) 

399 

400 # Keep the most recent rollback points 

401 to_keep = rollback_points[:keep_count] 

402 to_delete = rollback_points[keep_count:] 

403 

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 } 

414 

415 # Perform actual cleanup 

416 deleted_count = 0 

417 freed_space = 0 

418 

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 

426 

427 # Remove from registry 

428 del self.registry[rollback_point["id"]] 

429 deleted_count += 1 

430 

431 except Exception as e: 

432 logger.warning( 

433 f"Failed to delete rollback point {rollback_point['id']}: {str(e)}" 

434 ) 

435 

436 # Save updated registry 

437 self._save_registry() 

438 

439 return { 

440 "dry_run": False, 

441 "deleted_count": deleted_count, 

442 "kept_count": len(to_keep), 

443 "freed_space": freed_space, 

444 } 

445 

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}" 

451 

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)}") 

460 

461 return {} 

462 

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 

471 

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) 

476 

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") 

481 

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") 

486 

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 ) 

493 

494 return str(config_backup_path) 

495 

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) 

500 

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) 

506 

507 return str(research_backup_path) 

508 

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) 

513 

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) 

518 

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) 

523 

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) 

528 

529 return str(code_backup_path) 

530 

531 def _calculate_backup_checksum(self, backup_dir: Path) -> str: 

532 """Calculate checksum for backup integrity verification""" 

533 checksum_hash = hashlib.sha256() 

534 

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 ) 

543 

544 return checksum_hash.hexdigest() 

545 

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 } 

553 

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 

561 

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 ) 

568 

569 # Check essential files exist 

570 required_files = [ 

571 backup_path / "config" / "config.json", 

572 backup_path / "research", 

573 ] 

574 

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 ) 

580 

581 except Exception as e: 

582 validation_result["valid"] = False 

583 validation_result["message"] = f"Validation error: {str(e)}" 

584 

585 return validation_result 

586 

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 = [] 

594 

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))) 

612 

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))) 

627 

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))) 

642 

643 except Exception as e: 

644 logger.error(f"Rollback operation failed: {str(e)}") 

645 failed_files.append(("rollback_operation", str(e))) 

646 

647 return restored_files, failed_files 

648 

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" 

658 

659 restored_files = [] 

660 failed_files = [] 

661 

662 if not research_backup.exists(): 

663 failed_files.append(("research_backup", "Research backup not found")) 

664 return restored_files, failed_files 

665 

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 

672 

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)) 

704 

705 except Exception as e: 

706 logger.error(f"Research rollback failed: {str(e)}") 

707 failed_files.append(("research_rollback", str(e))) 

708 

709 return restored_files, failed_files 

710 

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 } 

718 

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") 

732 

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 ) 

746 

747 except Exception as e: 

748 validation_results["issues"].append(f"Validation error: {str(e)}") 

749 

750 return validation_results 

751 

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 } 

761 

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 ] 

768 

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 ) 

778 

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 ) 

799 

800 return validation_results 

801 

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 = [] 

807 

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" 

812 

813 if not research_backup.exists(): 

814 continue 

815 

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) 

829 

830 return research_rollback_points 

831 

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() 

840 

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)}") 

849 

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 

858 

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 

869 

870 

871# Command-line interface for rollback manager 

872def main(): 

873 """Command-line interface for rollback operations""" 

874 import argparse 

875 

876 parser = argparse.ArgumentParser(description="MoAI-ADK Rollback Manager") 

877 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

878 

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") 

883 

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 ) 

889 

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 ) 

896 

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") 

905 

906 # Validate system 

907 subparsers.add_parser("validate", help="Validate rollback system") 

908 

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 ) 

917 

918 args = parser.parse_args() 

919 

920 if not args.command: 

921 parser.print_help() 

922 return 

923 

924 # Initialize rollback manager 

925 rollback_manager = RollbackManager() 

926 

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}") 

933 

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})") 

940 

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}") 

950 

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}") 

959 

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}") 

973 

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") 

986 

987 except Exception as e: 

988 print(f"Error: {str(e)}") 

989 sys.exit(1) 

990 

991 

992if __name__ == "__main__": 

993 main()