Coverage for src / osiris_cli / checkpoint.py: 0%

181 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-27 17:41 +0200

1""" 

2Session Checkpointing System 

3 

4Enables undo/rollback capability for AI changes. 

5Users can rewind to previous states and experiment safely. 

6""" 

7 

8import json 

9import shutil 

10from pathlib import Path 

11from datetime import datetime 

12from typing import List, Dict, Optional, Any 

13from dataclasses import dataclass, asdict 

14 

15from .logger import get_logger 

16from .config import CONFIG_DIR 

17 

18logger = get_logger() 

19 

20# Checkpoints directory 

21CHECKPOINTS_DIR = CONFIG_DIR / "checkpoints" 

22CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True) 

23 

24 

25@dataclass 

26class Checkpoint: 

27 """A checkpoint of session state""" 

28 id: str 

29 session_id: str 

30 timestamp: str 

31 description: str 

32 messages: List[Dict[str, Any]] 

33 context_files: List[str] 

34 file_snapshots: Dict[str, str] # file_path -> content 

35 metadata: Dict[str, Any] 

36 

37 def to_dict(self) -> Dict: 

38 return asdict(self) 

39 

40 @classmethod 

41 def from_dict(cls, data: Dict) -> 'Checkpoint': 

42 return cls(**data) 

43 

44 

45class CheckpointManager: 

46 """ 

47 Manage session checkpoints for undo/rollback. 

48  

49 Features: 

50 - Auto-checkpoint after AI responses 

51 - Manual checkpoint creation 

52 - Restore to previous states 

53 - File content snapshots 

54 - Checkpoint diff visualization 

55 """ 

56 

57 def __init__(self, session_id: str, max_checkpoints: int = 20): 

58 """ 

59 Initialize checkpoint manager. 

60  

61 Args: 

62 session_id: Session identifier 

63 max_checkpoints: Maximum number of checkpoints to keep 

64 """ 

65 self.session_id = session_id 

66 self.max_checkpoints = max_checkpoints 

67 self.session_dir = CHECKPOINTS_DIR / session_id 

68 self.session_dir.mkdir(parents=True, exist_ok=True) 

69 

70 self.checkpoints: List[Checkpoint] = [] 

71 self.load_checkpoints() 

72 

73 logger.info(f"Checkpoint manager initialized for session {session_id}") 

74 

75 def create_checkpoint( 

76 self, 

77 description: str, 

78 messages: List[Dict], 

79 context_files: List[str] = None, 

80 files_to_snapshot: List[Path] = None, 

81 metadata: Dict = None 

82 ) -> Checkpoint: 

83 """ 

84 Create a new checkpoint. 

85  

86 Args: 

87 description: Checkpoint description 

88 messages: Current conversation messages 

89 context_files: Context files list 

90 files_to_snapshot: Files to snapshot (save content) 

91 metadata: Additional metadata 

92  

93 Returns: 

94 Created checkpoint 

95 """ 

96 checkpoint_id = f"cp_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" 

97 

98 # Snapshot file contents 

99 file_snapshots = {} 

100 if files_to_snapshot: 

101 for file_path in files_to_snapshot: 

102 if file_path.exists(): 

103 try: 

104 with open(file_path, 'r', encoding='utf-8') as f: 

105 file_snapshots[str(file_path)] = f.read() 

106 except Exception as e: 

107 logger.warning(f"Failed to snapshot {file_path}: {e}") 

108 

109 checkpoint = Checkpoint( 

110 id=checkpoint_id, 

111 session_id=self.session_id, 

112 timestamp=datetime.now().isoformat(), 

113 description=description, 

114 messages=messages.copy(), 

115 context_files=context_files or [], 

116 file_snapshots=file_snapshots, 

117 metadata=metadata or {} 

118 ) 

119 

120 self.checkpoints.append(checkpoint) 

121 self.save_checkpoint(checkpoint) 

122 self.prune_checkpoints() 

123 

124 logger.info(f"Created checkpoint: {checkpoint_id} - {description}") 

125 return checkpoint 

126 

127 def save_checkpoint(self, checkpoint: Checkpoint): 

128 """Save checkpoint to disk""" 

129 checkpoint_file = self.session_dir / f"{checkpoint.id}.json" 

130 

131 try: 

132 with open(checkpoint_file, 'w') as f: 

133 json.dump(checkpoint.to_dict(), f, indent=2) 

134 logger.debug(f"Saved checkpoint: {checkpoint.id}") 

135 except Exception as e: 

136 logger.error(f"Failed to save checkpoint: {e}") 

137 

138 def load_checkpoints(self): 

139 """Load all checkpoints for this session""" 

140 self.checkpoints = [] 

141 

142 for checkpoint_file in sorted(self.session_dir.glob("cp_*.json")): 

143 try: 

144 with open(checkpoint_file, 'r') as f: 

145 data = json.load(f) 

146 checkpoint = Checkpoint.from_dict(data) 

147 self.checkpoints.append(checkpoint) 

148 except Exception as e: 

149 logger.error(f"Failed to load checkpoint {checkpoint_file}: {e}") 

150 

151 logger.debug(f"Loaded {len(self.checkpoints)} checkpoints") 

152 

153 def list_checkpoints(self) -> List[Dict[str, Any]]: 

154 """Get list of checkpoints with summary info""" 

155 return [ 

156 { 

157 "id": cp.id, 

158 "timestamp": cp.timestamp, 

159 "description": cp.description, 

160 "message_count": len(cp.messages), 

161 "files_snapshotted": len(cp.file_snapshots) 

162 } 

163 for cp in sorted(self.checkpoints, key=lambda x: x.timestamp, reverse=True) 

164 ] 

165 

166 def get_checkpoint(self, checkpoint_id: str) -> Optional[Checkpoint]: 

167 """Get specific checkpoint by ID""" 

168 for cp in self.checkpoints: 

169 if cp.id == checkpoint_id: 

170 return cp 

171 return None 

172 

173 def restore_checkpoint( 

174 self, 

175 checkpoint_id: str, 

176 restore_files: bool = True, 

177 restore_messages: bool = True 

178 ) -> Optional[Checkpoint]: 

179 """ 

180 Restore session to checkpoint state. 

181  

182 Args: 

183 checkpoint_id: Checkpoint ID to restore 

184 restore_files: Whether to restore file contents 

185 restore_messages: Whether to restore conversation 

186  

187 Returns: 

188 Restored checkpoint or None 

189 """ 

190 checkpoint = self.get_checkpoint(checkpoint_id) 

191 

192 if not checkpoint: 

193 logger.error(f"Checkpoint not found: {checkpoint_id}") 

194 return None 

195 

196 try: 

197 # Restore file contents 

198 if restore_files: 

199 for file_path_str, content in checkpoint.file_snapshots.items(): 

200 file_path = Path(file_path_str) 

201 

202 # Create backup of current file 

203 if file_path.exists(): 

204 backup_path = file_path.with_suffix(file_path.suffix + '.backup') 

205 shutil.copy2(file_path, backup_path) 

206 logger.debug(f"Backed up {file_path} to {backup_path}") 

207 

208 # Restore from checkpoint 

209 file_path.parent.mkdir(parents=True, exist_ok=True) 

210 with open(file_path, 'w', encoding='utf-8') as f: 

211 f.write(content) 

212 

213 logger.info(f"Restored file: {file_path}") 

214 

215 logger.info(f"Restored checkpoint: {checkpoint_id}") 

216 return checkpoint 

217 

218 except Exception as e: 

219 logger.error(f"Failed to restore checkpoint: {e}", exc_info=True) 

220 return None 

221 

222 def get_checkpoint_diff( 

223 self, 

224 checkpoint_id1: str, 

225 checkpoint_id2: str 

226 ) -> Dict[str, Any]: 

227 """ 

228 Get diff between two checkpoints. 

229  

230 Args: 

231 checkpoint_id1: First checkpoint ID 

232 checkpoint_id2: Second checkpoint ID 

233  

234 Returns: 

235 Diff summary 

236 """ 

237 cp1 = self.get_checkpoint(checkpoint_id1) 

238 cp2 = self.get_checkpoint(checkpoint_id2) 

239 

240 if not cp1 or not cp2: 

241 return {} 

242 

243 # Message diff 

244 message_diff = { 

245 "added": len(cp2.messages) - len(cp1.messages), 

246 "total_cp1": len(cp1.messages), 

247 "total_cp2": len(cp2.messages) 

248 } 

249 

250 # File diff 

251 files_changed = [] 

252 for file_path in set(cp1.file_snapshots.keys()) | set(cp2.file_snapshots.keys()): 

253 content1 = cp1.file_snapshots.get(file_path, "") 

254 content2 = cp2.file_snapshots.get(file_path, "") 

255 

256 if content1 != content2: 

257 files_changed.append({ 

258 "file": file_path, 

259 "lines_cp1": len(content1.splitlines()), 

260 "lines_cp2": len(content2.splitlines()) 

261 }) 

262 

263 return { 

264 "checkpoint1": { 

265 "id": cp1.id, 

266 "timestamp": cp1.timestamp, 

267 "description": cp1.description 

268 }, 

269 "checkpoint2": { 

270 "id": cp2.id, 

271 "timestamp": cp2.timestamp, 

272 "description": cp2.description 

273 }, 

274 "messages": message_diff, 

275 "files_changed": files_changed 

276 } 

277 

278 def prune_checkpoints(self): 

279 """Remove old checkpoints beyond max limit""" 

280 if len(self.checkpoints) <= self.max_checkpoints: 

281 return 

282 

283 # Sort by timestamp 

284 sorted_checkpoints = sorted(self.checkpoints, key=lambda x: x.timestamp) 

285 

286 # Remove oldest checkpoints 

287 to_remove = sorted_checkpoints[:len(sorted_checkpoints) - self.max_checkpoints] 

288 

289 for checkpoint in to_remove: 

290 checkpoint_file = self.session_dir / f"{checkpoint.id}.json" 

291 

292 try: 

293 if checkpoint_file.exists(): 

294 checkpoint_file.unlink() 

295 self.checkpoints.remove(checkpoint) 

296 logger.debug(f"Pruned checkpoint: {checkpoint.id}") 

297 except Exception as e: 

298 logger.error(f"Failed to prune checkpoint {checkpoint.id}: {e}") 

299 

300 def delete_checkpoint(self, checkpoint_id: str) -> bool: 

301 """Delete specific checkpoint""" 

302 checkpoint = self.get_checkpoint(checkpoint_id) 

303 

304 if not checkpoint: 

305 return False 

306 

307 try: 

308 checkpoint_file = self.session_dir / f"{checkpoint.id}.json" 

309 if checkpoint_file.exists(): 

310 checkpoint_file.unlink() 

311 

312 self.checkpoints.remove(checkpoint) 

313 logger.info(f"Deleted checkpoint: {checkpoint_id}") 

314 return True 

315 

316 except Exception as e: 

317 logger.error(f"Failed to delete checkpoint: {e}") 

318 return False 

319 

320 def export_checkpoint( 

321 self, 

322 checkpoint_id: str, 

323 output_path: Path 

324 ) -> bool: 

325 """Export checkpoint to file""" 

326 checkpoint = self.get_checkpoint(checkpoint_id) 

327 

328 if not checkpoint: 

329 return False 

330 

331 try: 

332 with open(output_path, 'w') as f: 

333 json.dump(checkpoint.to_dict(), f, indent=2) 

334 

335 logger.info(f"Exported checkpoint to {output_path}") 

336 return True 

337 

338 except Exception as e: 

339 logger.error(f"Failed to export checkpoint: {e}") 

340 return False 

341 

342 def import_checkpoint(self, input_path: Path) -> Optional[Checkpoint]: 

343 """Import checkpoint from file""" 

344 try: 

345 with open(input_path, 'r') as f: 

346 data = json.load(f) 

347 checkpoint = Checkpoint.from_dict(data) 

348 

349 # Update session ID 

350 checkpoint.session_id = self.session_id 

351 

352 self.checkpoints.append(checkpoint) 

353 self.save_checkpoint(checkpoint) 

354 

355 logger.info(f"Imported checkpoint: {checkpoint.id}") 

356 return checkpoint 

357 

358 except Exception as e: 

359 logger.error(f"Failed to import checkpoint: {e}") 

360 return None 

361 

362 def get_stats(self) -> Dict[str, Any]: 

363 """Get checkpoint statistics""" 

364 if not self.checkpoints: 

365 return { 

366 "total": 0, 

367 "oldest": None, 

368 "newest": None, 

369 "total_size_mb": 0 

370 } 

371 

372 # Calculate total size 

373 total_size = 0 

374 for cp_file in self.session_dir.glob("cp_*.json"): 

375 total_size += cp_file.stat().st_size 

376 

377 sorted_cps = sorted(self.checkpoints, key=lambda x: x.timestamp) 

378 

379 return { 

380 "total": len(self.checkpoints), 

381 "oldest": sorted_cps[0].timestamp if sorted_cps else None, 

382 "newest": sorted_cps[-1].timestamp if sorted_cps else None, 

383 "total_size_mb": total_size / (1024 * 1024), 

384 "max_checkpoints": self.max_checkpoints 

385 } 

386 

387 

388def create_auto_checkpoint( 

389 checkpoint_manager: CheckpointManager, 

390 description: str, 

391 messages: List[Dict], 

392 files_modified: List[Path] = None 

393) -> Optional[Checkpoint]: 

394 """ 

395 Create automatic checkpoint after AI response. 

396  

397 Args: 

398 checkpoint_manager: CheckpointManager instance 

399 description: Checkpoint description 

400 messages: Current messages 

401 files_modified: Files that were modified 

402  

403 Returns: 

404 Created checkpoint or None 

405 """ 

406 try: 

407 checkpoint = checkpoint_manager.create_checkpoint( 

408 description=f"Auto: {description}", 

409 messages=messages, 

410 files_to_snapshot=files_modified, 

411 metadata={"auto": True} 

412 ) 

413 return checkpoint 

414 except Exception as e: 

415 logger.error(f"Failed to create auto checkpoint: {e}") 

416 return None