Coverage for src / moai_adk / core / context_manager.py: 23.60%

89 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Context Management Module for Commands Layer 

3 

4Provides utilities for: 

51. Path validation and absolute path conversion 

62. Atomic JSON file operations 

73. Phase result persistence and loading 

84. Template variable substitution 

9""" 

10 

11import json 

12import os 

13import re 

14import tempfile 

15from datetime import datetime, timezone 

16from typing import Any, Dict, Optional 

17 

18# Constants 

19PROJECT_ROOT_SAFETY_MSG = "Path outside project root: {}" 

20PARENT_DIR_MISSING_MSG = "Parent directory not found: {}" 

21 

22 

23def _is_path_within_root(abs_path: str, project_root: str) -> bool: 

24 """ 

25 Check if absolute path is within project root. 

26 

27 Resolves symlinks to prevent escape attacks. 

28 

29 Args: 

30 abs_path: Absolute path to check 

31 project_root: Project root directory 

32 

33 Returns: 

34 True if path is within root, False otherwise 

35 """ 

36 try: 

37 real_abs_path = os.path.realpath(abs_path) 

38 real_project_root = os.path.realpath(project_root) 

39 

40 return real_abs_path == real_project_root or real_abs_path.startswith( 

41 real_project_root + os.sep 

42 ) 

43 except OSError: 

44 return False 

45 

46 

47def validate_and_convert_path(relative_path: str, project_root: str) -> str: 

48 """ 

49 Convert relative path to absolute path and validate it. 

50 

51 Ensures path stays within project root and parent directories exist 

52 for file paths. 

53 

54 Args: 

55 relative_path: Path to validate and convert (relative or absolute) 

56 project_root: Project root directory for relative path resolution 

57 

58 Returns: 

59 Validated absolute path 

60 

61 Raises: 

62 ValueError: If path is outside project root 

63 FileNotFoundError: If parent directory doesn't exist for file paths 

64 """ 

65 # Convert to absolute path 

66 abs_path = os.path.abspath(os.path.join(project_root, relative_path)) 

67 project_root_abs = os.path.abspath(project_root) 

68 

69 # Security check: ensure path stays within project root 

70 if not _is_path_within_root(abs_path, project_root_abs): 

71 raise ValueError(PROJECT_ROOT_SAFETY_MSG.format(abs_path)) 

72 

73 # If it's a directory and exists, return it 

74 if os.path.isdir(abs_path): 

75 return abs_path 

76 

77 # For files, check if parent directory exists 

78 parent_dir = os.path.dirname(abs_path) 

79 if not os.path.exists(parent_dir): 

80 raise FileNotFoundError(PARENT_DIR_MISSING_MSG.format(parent_dir)) 

81 

82 return abs_path 

83 

84 

85def _cleanup_temp_file(temp_fd: Optional[int], temp_path: Optional[str]) -> None: 

86 """ 

87 Clean up temporary file handles and paths. 

88 

89 Silently ignores errors during cleanup. 

90 

91 Args: 

92 temp_fd: File descriptor to close, or None 

93 temp_path: Path to file to remove, or None 

94 """ 

95 if temp_fd is not None: 

96 try: 

97 os.close(temp_fd) 

98 except OSError: 

99 pass 

100 

101 if temp_path and os.path.exists(temp_path): 

102 try: 

103 os.unlink(temp_path) 

104 except OSError: 

105 pass 

106 

107 

108def save_phase_result(data: Dict[str, Any], target_path: str) -> None: 

109 """ 

110 Atomically save phase result to JSON file. 

111 

112 Uses temporary file and atomic rename to ensure data integrity 

113 even if write fails midway. 

114 

115 Args: 

116 data: Dictionary to save 

117 target_path: Full path where JSON should be saved 

118 

119 Raises: 

120 IOError: If write or rename fails 

121 OSError: If directory is not writable 

122 """ 

123 target_dir = os.path.dirname(target_path) 

124 os.makedirs(target_dir, exist_ok=True) 

125 

126 # Atomic write using temp file 

127 temp_fd = None 

128 temp_path = None 

129 

130 try: 

131 # Create temp file in target directory for atomic rename 

132 temp_fd, temp_path = tempfile.mkstemp( 

133 dir=target_dir, prefix=".tmp_phase_", suffix=".json" 

134 ) 

135 

136 # Write JSON to temp file 

137 with os.fdopen(temp_fd, "w", encoding="utf-8") as f: 

138 json.dump(data, f, indent=2, ensure_ascii=False) 

139 

140 temp_fd = None # File handle is now closed 

141 

142 # Atomic rename 

143 os.replace(temp_path, target_path) 

144 

145 except Exception as e: 

146 _cleanup_temp_file(temp_fd, temp_path) 

147 raise IOError(f"Failed to write {target_path}: {e}") 

148 

149 

150def load_phase_result(source_path: str) -> Dict[str, Any]: 

151 """ 

152 Load phase result from JSON file. 

153 

154 Args: 

155 source_path: Full path to JSON file to load 

156 

157 Returns: 

158 Dictionary containing phase result 

159 

160 Raises: 

161 FileNotFoundError: If file doesn't exist 

162 json.JSONDecodeError: If file is not valid JSON 

163 """ 

164 if not os.path.exists(source_path): 

165 raise FileNotFoundError(f"Phase result file not found: {source_path}") 

166 

167 with open(source_path, "r", encoding="utf-8") as f: 

168 data = json.load(f) 

169 

170 return data 

171 

172 

173def substitute_template_variables(text: str, context: Dict[str, str]) -> str: 

174 """ 

175 Replace template variables in text with values from context. 

176 

177 Performs safe string substitution of {{VARIABLE}} placeholders. 

178 

179 Args: 

180 text: Text containing {{VARIABLE}} placeholders 

181 context: Dictionary mapping variable names to values 

182 

183 Returns: 

184 Text with variables substituted 

185 """ 

186 result = text 

187 

188 for key, value in context.items(): 

189 placeholder = f"{{{{{key}}}}}" 

190 result = result.replace(placeholder, str(value)) 

191 

192 return result 

193 

194 

195# Regex pattern for detecting unsubstituted template variables 

196# Matches {{VARIABLE}}, {{VAR_NAME}}, {{VAR1}}, etc. 

197TEMPLATE_VAR_PATTERN = r"\{\{[A-Z_][A-Z0-9_]*\}\}" 

198 

199 

200def validate_no_template_vars(text: str) -> None: 

201 """ 

202 Validate that text contains no unsubstituted template variables. 

203 

204 Raises error if any {{VARIABLE}} patterns are found. 

205 

206 Args: 

207 text: Text to validate 

208 

209 Raises: 

210 ValueError: If unsubstituted variables are found 

211 """ 

212 matches = re.findall(TEMPLATE_VAR_PATTERN, text) 

213 

214 if matches: 

215 raise ValueError(f"Unsubstituted template variables found: {matches}") 

216 

217 

218class ContextManager: 

219 """ 

220 Manages context passing and state persistence for Commands layer. 

221 

222 Handles saving and loading phase results, managing state directory, 

223 and providing convenient access to command state. 

224 """ 

225 

226 def __init__(self, project_root: str): 

227 """ 

228 Initialize ContextManager. 

229 

230 Args: 

231 project_root: Root directory of the project 

232 """ 

233 self.project_root = project_root 

234 self.state_dir = os.path.join(project_root, ".moai", "memory", "command-state") 

235 os.makedirs(self.state_dir, exist_ok=True) 

236 

237 def save_phase_result(self, data: Dict[str, Any]) -> str: 

238 """ 

239 Save phase result with timestamp. 

240 

241 Args: 

242 data: Phase result data 

243 

244 Returns: 

245 Path to saved file 

246 """ 

247 # Generate filename with timestamp 

248 timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") 

249 phase_name = data.get("phase", "unknown") 

250 filename = f"{phase_name}-{timestamp}.json" 

251 target_path = os.path.join(self.state_dir, filename) 

252 

253 save_phase_result(data, target_path) 

254 return target_path 

255 

256 def load_latest_phase(self) -> Optional[Dict[str, Any]]: 

257 """ 

258 Load the most recent phase result. 

259 

260 Returns: 

261 Phase result dictionary or None if no phase files exist 

262 """ 

263 # List all phase files 

264 phase_files = sorted( 

265 [f for f in os.listdir(self.state_dir) if f.endswith(".json")] 

266 ) 

267 

268 if not phase_files: 

269 return None 

270 

271 # Load the latest (last in sorted order) 

272 latest_file = phase_files[-1] 

273 latest_path = os.path.join(self.state_dir, latest_file) 

274 

275 return load_phase_result(latest_path) 

276 

277 def get_state_dir(self) -> str: 

278 """Get the command state directory path.""" 

279 return self.state_dir