|
15 | 15 | */ |
16 | 16 | package ru.nts.tools.mcp.core; |
17 | 17 |
|
| 18 | +import java.io.IOException; |
| 19 | +import java.nio.file.Files; |
| 20 | +import java.nio.file.Path; |
| 21 | +import java.time.Instant; |
18 | 22 | import java.util.Map; |
19 | 23 | import java.util.concurrent.ConcurrentHashMap; |
20 | 24 |
|
@@ -54,30 +58,73 @@ public class SessionContext { |
54 | 58 | // Текущий вызываемый инструмент (для диагностики) |
55 | 59 | private volatile String currentToolName; |
56 | 60 |
|
| 61 | + // Время создания сессии |
| 62 | + private final Instant createdAt; |
| 63 | + |
| 64 | + // Время последней активности |
| 65 | + private volatile Instant lastActivityAt; |
| 66 | + |
| 67 | + // Имя файла метаданных сессии |
| 68 | + private static final String SESSION_METADATA_FILE = "session.meta"; |
| 69 | + |
57 | 70 | /** |
58 | 71 | * Создает новый контекст сессии. |
59 | 72 | */ |
60 | 73 | private SessionContext(String sessionId) { |
61 | 74 | this.sessionId = sessionId; |
62 | | - this.transactionManager = new SessionTransactionManager(); |
| 75 | + this.transactionManager = new SessionTransactionManager(sessionId); |
| 76 | + this.lineAccessTracker = new SessionLineAccessTracker(); |
| 77 | + this.searchTracker = new SessionSearchTracker(); |
| 78 | + this.fileLineageTracker = new FileLineageTracker(); |
| 79 | + this.externalChangeTracker = new ExternalChangeTracker(); |
| 80 | + this.createdAt = Instant.now(); |
| 81 | + this.lastActivityAt = this.createdAt; |
| 82 | + } |
| 83 | + |
| 84 | + /** |
| 85 | + * Создает контекст сессии с восстановленными временными метками. |
| 86 | + * Используется при реактивации существующей сессии. |
| 87 | + */ |
| 88 | + private SessionContext(String sessionId, Instant createdAt) { |
| 89 | + this.sessionId = sessionId; |
| 90 | + this.transactionManager = new SessionTransactionManager(sessionId); |
63 | 91 | this.lineAccessTracker = new SessionLineAccessTracker(); |
64 | 92 | this.searchTracker = new SessionSearchTracker(); |
65 | 93 | this.fileLineageTracker = new FileLineageTracker(); |
66 | 94 | this.externalChangeTracker = new ExternalChangeTracker(); |
| 95 | + this.createdAt = createdAt; |
| 96 | + this.lastActivityAt = Instant.now(); |
67 | 97 | } |
68 | 98 |
|
69 | 99 | // ==================== Static API ==================== |
70 | 100 |
|
71 | 101 | /** |
72 | 102 | * Получает или создает контекст для указанной сессии. |
73 | 103 | * Если sessionId == null или пустой, используется "default" сессия. |
74 | | - * Это критично для совместимости с клиентами, не передающими sessionId. |
| 104 | + * Если сессия существует на диске, но не в памяти — реактивирует её. |
75 | 105 | */ |
76 | 106 | public static SessionContext getOrCreate(String sessionId) { |
77 | 107 | if (sessionId == null || sessionId.isBlank()) { |
78 | 108 | sessionId = "default"; |
79 | 109 | } |
80 | | - return sessions.computeIfAbsent(sessionId, SessionContext::new); |
| 110 | + String id = sessionId; |
| 111 | + return sessions.computeIfAbsent(id, key -> { |
| 112 | + // Проверяем, существует ли сессия на диске |
| 113 | + if (existsOnDisk(key)) { |
| 114 | + // Реактивируем без добавления в map (computeIfAbsent сам добавит) |
| 115 | + SessionMetadata meta = getSessionMetadata(key); |
| 116 | + if (meta != null) { |
| 117 | + SessionContext ctx = new SessionContext(key, meta.createdAt()); |
| 118 | + ctx.transactionManager.loadJournal(); |
| 119 | + if (meta.activeTodoFile() != null) { |
| 120 | + ctx.setActiveTodoFile(meta.activeTodoFile()); |
| 121 | + } |
| 122 | + return ctx; |
| 123 | + } |
| 124 | + } |
| 125 | + // Создаём новый контекст |
| 126 | + return new SessionContext(key); |
| 127 | + }); |
81 | 128 | } |
82 | 129 |
|
83 | 130 | /** |
@@ -146,6 +193,104 @@ public static int getActiveSessionCount() { |
146 | 193 | return sessions.size(); |
147 | 194 | } |
148 | 195 |
|
| 196 | + /** |
| 197 | + * Проверяет, существует ли сессия на диске (была создана ранее). |
| 198 | + * Это позволяет определить, можно ли реактивировать сессию после перезапуска сервера. |
| 199 | + * |
| 200 | + * @param sessionId ID сессии для проверки |
| 201 | + * @return true если директория сессии существует на диске |
| 202 | + */ |
| 203 | + public static boolean existsOnDisk(String sessionId) { |
| 204 | + if (sessionId == null || sessionId.isBlank() || "default".equals(sessionId)) { |
| 205 | + return false; |
| 206 | + } |
| 207 | + Path sessionDir = PathSanitizer.getRoot().resolve(".nts/sessions/" + sessionId); |
| 208 | + Path metaFile = sessionDir.resolve(SESSION_METADATA_FILE); |
| 209 | + return Files.exists(metaFile); |
| 210 | + } |
| 211 | + |
| 212 | + /** |
| 213 | + * Возвращает информацию о сессии на диске. |
| 214 | + * |
| 215 | + * @param sessionId ID сессии |
| 216 | + * @return информация о сессии или null если не найдена |
| 217 | + */ |
| 218 | + public static SessionMetadata getSessionMetadata(String sessionId) { |
| 219 | + if (sessionId == null || sessionId.isBlank()) { |
| 220 | + return null; |
| 221 | + } |
| 222 | + Path sessionDir = PathSanitizer.getRoot().resolve(".nts/sessions/" + sessionId); |
| 223 | + Path metaFile = sessionDir.resolve(SESSION_METADATA_FILE); |
| 224 | + |
| 225 | + if (!Files.exists(metaFile)) { |
| 226 | + return null; |
| 227 | + } |
| 228 | + |
| 229 | + try { |
| 230 | + String content = Files.readString(metaFile); |
| 231 | + String[] lines = content.split("\n"); |
| 232 | + Instant created = null; |
| 233 | + Instant lastActivity = null; |
| 234 | + String activeTodo = null; |
| 235 | + |
| 236 | + for (String line : lines) { |
| 237 | + if (line.startsWith("created=")) { |
| 238 | + created = Instant.parse(line.substring(8)); |
| 239 | + } else if (line.startsWith("lastActivity=")) { |
| 240 | + lastActivity = Instant.parse(line.substring(13)); |
| 241 | + } else if (line.startsWith("activeTodo=")) { |
| 242 | + activeTodo = line.substring(11); |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + return new SessionMetadata(sessionId, created, lastActivity, activeTodo); |
| 247 | + } catch (Exception e) { |
| 248 | + return null; |
| 249 | + } |
| 250 | + } |
| 251 | + |
| 252 | + /** |
| 253 | + * Реактивирует существующую сессию из директории на диске. |
| 254 | + * Восстанавливает контекст сессии и журнал транзакций (undo/redo стеки). |
| 255 | + * Токены доступа к файлам начинаются с чистого состояния. |
| 256 | + * |
| 257 | + * @param sessionId ID сессии для реактивации |
| 258 | + * @return восстановленный контекст сессии |
| 259 | + * @throws IllegalArgumentException если сессия не найдена на диске |
| 260 | + */ |
| 261 | + public static SessionContext reactivateSession(String sessionId) { |
| 262 | + SessionMetadata meta = getSessionMetadata(sessionId); |
| 263 | + if (meta == null) { |
| 264 | + throw new IllegalArgumentException("Session not found on disk: " + sessionId); |
| 265 | + } |
| 266 | + |
| 267 | + // Создаём контекст с восстановленным временем создания |
| 268 | + SessionContext ctx = new SessionContext(sessionId, meta.createdAt()); |
| 269 | + sessions.put(sessionId, ctx); |
| 270 | + |
| 271 | + // Восстанавливаем журнал транзакций (undo/redo стеки) |
| 272 | + ctx.transactionManager.loadJournal(); |
| 273 | + |
| 274 | + // Восстанавливаем активный TODO |
| 275 | + if (meta.activeTodoFile() != null) { |
| 276 | + ctx.setActiveTodoFile(meta.activeTodoFile()); |
| 277 | + } |
| 278 | + |
| 279 | + return ctx; |
| 280 | + } |
| 281 | + |
| 282 | + /** |
| 283 | + * Проверяет, активна ли сессия в памяти. |
| 284 | + */ |
| 285 | + public static boolean isActiveInMemory(String sessionId) { |
| 286 | + return sessions.containsKey(sessionId); |
| 287 | + } |
| 288 | + |
| 289 | + /** |
| 290 | + * Метаданные сессии для хранения на диске. |
| 291 | + */ |
| 292 | + public record SessionMetadata(String sessionId, Instant createdAt, Instant lastActivityAt, String activeTodoFile) {} |
| 293 | + |
149 | 294 | // ==================== Instance API ==================== |
150 | 295 |
|
151 | 296 | public String getSessionId() { |
@@ -178,6 +323,8 @@ public String getActiveTodoFile() { |
178 | 323 |
|
179 | 324 | public void setActiveTodoFile(String fileName) { |
180 | 325 | this.activeTodoFile = fileName; |
| 326 | + // Сохраняем метаданные для персистентности TODO между сессиями |
| 327 | + saveMetadata(); |
181 | 328 | } |
182 | 329 |
|
183 | 330 | /** |
@@ -216,6 +363,56 @@ public java.nio.file.Path getSnapshotsDir() { |
216 | 363 | return getSessionDir().resolve("snapshots"); |
217 | 364 | } |
218 | 365 |
|
| 366 | + /** |
| 367 | + * Возвращает время создания сессии. |
| 368 | + */ |
| 369 | + public Instant getCreatedAt() { |
| 370 | + return createdAt; |
| 371 | + } |
| 372 | + |
| 373 | + /** |
| 374 | + * Возвращает время последней активности. |
| 375 | + */ |
| 376 | + public Instant getLastActivityAt() { |
| 377 | + return lastActivityAt; |
| 378 | + } |
| 379 | + |
| 380 | + /** |
| 381 | + * Обновляет время последней активности. |
| 382 | + * Вызывается автоматически при каждом вызове инструмента. |
| 383 | + */ |
| 384 | + public void touchActivity() { |
| 385 | + this.lastActivityAt = Instant.now(); |
| 386 | + // Синхронно сохраняем метаданные для надёжности при перезапусках |
| 387 | + saveMetadata(); |
| 388 | + } |
| 389 | + |
| 390 | + /** |
| 391 | + * Сохраняет метаданные сессии на диск. |
| 392 | + * Вызывается при создании сессии и при обновлении активности. |
| 393 | + */ |
| 394 | + public void saveMetadata() { |
| 395 | + if ("default".equals(sessionId)) { |
| 396 | + return; // default сессия не сохраняется |
| 397 | + } |
| 398 | + |
| 399 | + Path metaFile = getSessionDir().resolve(SESSION_METADATA_FILE); |
| 400 | + try { |
| 401 | + Files.createDirectories(metaFile.getParent()); |
| 402 | + StringBuilder content = new StringBuilder(); |
| 403 | + content.append("sessionId=").append(sessionId).append("\n"); |
| 404 | + content.append("created=").append(createdAt).append("\n"); |
| 405 | + content.append("lastActivity=").append(lastActivityAt).append("\n"); |
| 406 | + if (activeTodoFile != null) { |
| 407 | + content.append("activeTodo=").append(activeTodoFile).append("\n"); |
| 408 | + } |
| 409 | + Files.writeString(metaFile, content.toString()); |
| 410 | + } catch (IOException e) { |
| 411 | + // Логируем, но не прерываем операцию |
| 412 | + System.err.println("Warning: Failed to save session metadata: " + e.getMessage()); |
| 413 | + } |
| 414 | + } |
| 415 | + |
219 | 416 | /** |
220 | 417 | * Очистка ресурсов сессии. |
221 | 418 | */ |
|
0 commit comments