import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'file_store_support.dart'; import 'runtime_models.dart'; enum SettingsSnapshotReloadStatus { applied, invalid } class SettingsSnapshotReloadResult { const SettingsSnapshotReloadResult({ required this.snapshot, required this.status, }); final SettingsSnapshot snapshot; final SettingsSnapshotReloadStatus status; bool get applied => status == SettingsSnapshotReloadStatus.applied; } enum SkippedTaskThreadReason { incompleteWorkspaceBinding, removedAutoExecutionMode, invalidPersistedThreadData, } class SkippedTaskThreadRecord { const SkippedTaskThreadRecord({required this.threadId, required this.reason}); final String threadId; final SkippedTaskThreadReason reason; } class SettingsStore { SettingsStore({ Future Function()? appDataRootPathResolver, Future Function()? supportRootPathResolver, StoreLayoutResolver? layoutResolver, }) : _layoutResolver = layoutResolver ?? StoreLayoutResolver( appDataRootPathResolver: appDataRootPathResolver, supportRootPathResolver: supportRootPathResolver, ); final StoreLayoutResolver _layoutResolver; bool _initialized = false; StoreLayout? _layout; File? _settingsFile; Directory? _settingsWatchDirectory; SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults(); List _threadRecords = const []; List _auditTrail = const []; PersistentWriteFailure? _settingsWriteFailure; PersistentWriteFailure? _tasksWriteFailure; PersistentWriteFailure? _auditWriteFailure; List _lastSkippedInvalidTaskThreadRecords = const []; PersistentWriteFailure? get settingsWriteFailure => _settingsWriteFailure; PersistentWriteFailure? get tasksWriteFailure => _tasksWriteFailure; PersistentWriteFailure? get auditWriteFailure => _auditWriteFailure; List get lastSkippedInvalidTaskThreadRecords => List.unmodifiable( _lastSkippedInvalidTaskThreadRecords, ); List get lastSkippedInvalidTaskThreadIds => List.unmodifiable( _lastSkippedInvalidTaskThreadRecords .map((item) => item.threadId) .toList(growable: false), ); Future initialize() async { if (_initialized) { return; } _initialized = true; try { _layout = await _layoutResolver.resolve(); _settingsFile = _layout!.settingsFile; _settingsWatchDirectory = _settingsFile!.parent; } catch (_) { _layout = null; _settingsFile = null; _settingsWatchDirectory = null; return; } _settingsSnapshot = await _readSettingsSnapshot(); _threadRecords = await _readTaskThreads(); _auditTrail = await _readAuditTrail(); } Future loadSettingsSnapshot() async { await initialize(); return _settingsSnapshot; } Future reloadSettingsSnapshot() async { final result = await reloadSettingsSnapshotResult(); return result.snapshot; } Future reloadSettingsSnapshotResult() async { await initialize(); final result = await _readSettingsSnapshotResult(); if (result.status == SettingsSnapshotReloadStatus.invalid) { return SettingsSnapshotReloadResult( snapshot: _settingsSnapshot, status: SettingsSnapshotReloadStatus.invalid, ); } _settingsSnapshot = result.snapshot; return SettingsSnapshotReloadResult( snapshot: _settingsSnapshot, status: SettingsSnapshotReloadStatus.applied, ); } Future resolvedSettingsFile() async { await initialize(); return _settingsFile; } Future resolvedSettingsWatchDirectory() async { await initialize(); return _settingsWatchDirectory; } Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { await initialize(); _settingsSnapshot = snapshot; final layout = _layout; if (layout == null) { _settingsWriteFailure = _buildWriteFailure( PersistentStoreScope.settings, 'saveSettingsSnapshot', StateError('Persistent settings path unavailable; using memory only.'), ); return; } try { final contents = encodeYamlDocument(snapshot.toJson()); await atomicWriteString(layout.settingsFile, contents); _settingsWriteFailure = null; } catch (error) { _settingsWriteFailure = _buildWriteFailure( PersistentStoreScope.settings, 'saveSettingsSnapshot', error, ); } } Future> loadTaskThreads() async { await initialize(); return List.from(_threadRecords); } Future saveTaskThreads(List records) async { await initialize(); final normalized = records .where((item) => item.threadId.trim().isNotEmpty) .toList(growable: false); _threadRecords = normalized; final layout = _layout; if (layout == null) { _tasksWriteFailure = _buildWriteFailure( PersistentStoreScope.tasks, 'saveTaskThreads', StateError('Persistent task path unavailable; using memory only.'), ); return; } final keptPaths = {}; try { for (final record in normalized) { final taskFile = layout.taskFileForSessionKey(record.threadId); keptPaths.add(taskFile.path); await atomicWriteString(taskFile, jsonEncode(record.toJson())); } await atomicWriteString( layout.taskIndexFile, jsonEncode({ 'version': taskThreadSchemaVersion, 'sessions': normalized .map((item) => item.threadId) .toList(growable: false), }), ); await for (final entity in layout.tasksDirectory.list()) { if (entity is! File) { continue; } if (entity.path == layout.taskIndexFile.path) { continue; } if (!entity.path.endsWith('.json')) { continue; } if (!keptPaths.contains(entity.path)) { await entity.delete(); } } _tasksWriteFailure = null; } catch (error) { _tasksWriteFailure = _buildWriteFailure( PersistentStoreScope.tasks, 'saveTaskThreads', error, ); } } Future clearAssistantLocalState() async { await initialize(); _threadRecords = const []; final layout = _layout; if (layout == null) { _tasksWriteFailure = _buildWriteFailure( PersistentStoreScope.tasks, 'clearAssistantLocalState', StateError('Persistent task path unavailable; reset kept in memory.'), ); return; } try { await deleteIfExists(layout.taskIndexFile); await for (final entity in layout.tasksDirectory.list()) { if (entity is File && entity.path.endsWith('.json')) { await entity.delete(); } } _tasksWriteFailure = null; } catch (error) { _tasksWriteFailure = _buildWriteFailure( PersistentStoreScope.tasks, 'clearAssistantLocalState', error, ); } } Future> loadAuditTrail() async { await initialize(); return List.from(_auditTrail); } Future appendAudit(SecretAuditEntry entry) async { await initialize(); final next = [entry, ..._auditTrail]; if (next.length > 40) { next.removeRange(40, next.length); } _auditTrail = next; final layout = _layout; if (layout == null) { _auditWriteFailure = _buildWriteFailure( PersistentStoreScope.audit, 'appendAudit', StateError('Persistent audit path unavailable; audit kept in memory.'), ); return; } try { await atomicWriteString( layout.auditFile, jsonEncode(next.map((item) => item.toJson()).toList(growable: false)), ); _auditWriteFailure = null; } catch (error) { _auditWriteFailure = _buildWriteFailure( PersistentStoreScope.audit, 'appendAudit', error, ); } } void dispose() {} Future _readSettingsSnapshot() async { final result = await _readSettingsSnapshotResult(); return result.status == SettingsSnapshotReloadStatus.invalid ? SettingsSnapshot.defaults() : result.snapshot; } Future _readSettingsSnapshotResult() async { final settingsFile = _settingsFile; if (settingsFile == null) { return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.defaults(), status: SettingsSnapshotReloadStatus.applied, ); } if (!await settingsFile.exists()) { return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.defaults(), status: SettingsSnapshotReloadStatus.applied, ); } try { final raw = await settingsFile.readAsString(); final decoded = decodeYamlDocument(raw); if (decoded is Map) { return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.fromJson(decoded), status: SettingsSnapshotReloadStatus.applied, ); } if (decoded is Map) { return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.fromJson(decoded.cast()), status: SettingsSnapshotReloadStatus.applied, ); } } catch (_) { return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.defaults(), status: SettingsSnapshotReloadStatus.invalid, ); } return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.defaults(), status: SettingsSnapshotReloadStatus.invalid, ); } Future> _readTaskThreads() async { final layout = _layout; if (layout == null) { _lastSkippedInvalidTaskThreadRecords = const []; return const []; } _lastSkippedInvalidTaskThreadRecords = const []; final index = await _readThreadIndex(layout); if (index.resetRequired) { await _resetTaskThreadState(layout); return const []; } final orderedKeys = index.sessions; final recordsByKey = {}; final skippedRecords = []; String inferThreadIdFromTaskFile(File file) { final name = file.uri.pathSegments.isEmpty ? file.path : file.uri.pathSegments.last; final encoded = name.endsWith('.json') ? name.substring(0, name.length - 5) : name; try { return utf8.decode(base64Url.decode(base64Url.normalize(encoded))); } catch (_) { return encoded; } } try { await for (final entity in layout.tasksDirectory.list()) { if (entity is! File || entity.path == layout.taskIndexFile.path || !entity.path.endsWith('.json')) { continue; } try { final raw = await entity.readAsString(); final decoded = jsonDecode(raw); if (decoded is Map) { final schemaVersion = decoded['schemaVersion']; if (schemaVersion is! int || schemaVersion != taskThreadSchemaVersion) { await _resetTaskThreadState(layout); return const []; } final record = TaskThread.fromJson(decoded); if (record.threadId.trim().isNotEmpty) { recordsByKey[record.threadId] = record; } } } catch (error) { skippedRecords.add( SkippedTaskThreadRecord( threadId: inferThreadIdFromTaskFile(entity), reason: _classifySkippedTaskThreadReason(error), ), ); continue; } } } catch (_) { return const []; } final ordered = []; for (final sessionKey in orderedKeys) { final record = recordsByKey.remove(sessionKey); if (record != null) { ordered.add(record); } } final leftovers = recordsByKey.keys.toList()..sort(); for (final sessionKey in leftovers) { final record = recordsByKey[sessionKey]; if (record != null) { ordered.add(record); } } skippedRecords.sort( (left, right) => left.threadId.compareTo(right.threadId), ); _lastSkippedInvalidTaskThreadRecords = skippedRecords; return ordered; } SkippedTaskThreadReason _classifySkippedTaskThreadReason(Object error) { final message = error.toString(); if (message.contains('"auto" is no longer supported')) { return SkippedTaskThreadReason.removedAutoExecutionMode; } if (message.contains('workspaceBinding')) { return SkippedTaskThreadReason.incompleteWorkspaceBinding; } return SkippedTaskThreadReason.invalidPersistedThreadData; } Future<_ThreadIndexReadResult> _readThreadIndex(StoreLayout layout) async { if (!await layout.taskIndexFile.exists()) { return const _ThreadIndexReadResult( sessions: [], resetRequired: false, ); } try { final raw = await layout.taskIndexFile.readAsString(); final decoded = jsonDecode(raw); if (decoded is Map) { final version = decoded['version']; if (version is! int || version != taskThreadSchemaVersion) { return const _ThreadIndexReadResult( sessions: [], resetRequired: true, ); } final sessions = decoded['sessions']; if (sessions is List) { return _ThreadIndexReadResult( sessions: sessions .map((item) => item.toString().trim()) .where((item) => item.isNotEmpty) .toList(growable: false), resetRequired: false, ); } } } catch (_) {} return const _ThreadIndexReadResult( sessions: [], resetRequired: true, ); } Future _resetTaskThreadState(StoreLayout layout) async { try { await deleteIfExists(layout.taskIndexFile); await for (final entity in layout.tasksDirectory.list()) { if (entity is File && entity.path.endsWith('.json')) { await entity.delete(); } } } catch (_) { // Best effort. A later save will normalize the directory. } } Future> _readAuditTrail() async { final layout = _layout; if (layout == null || !await layout.auditFile.exists()) { return const []; } try { final raw = await layout.auditFile.readAsString(); final decoded = jsonDecode(raw); if (decoded is List) { return decoded .whereType() .map( (item) => SecretAuditEntry.fromJson(item.cast()), ) .toList(growable: false); } } catch (_) {} return const []; } PersistentWriteFailure _buildWriteFailure( PersistentStoreScope scope, String operation, Object error, ) { return PersistentWriteFailure( scope: scope, operation: operation, message: error.toString(), timestampMs: DateTime.now().millisecondsSinceEpoch, ); } } class _ThreadIndexReadResult { const _ThreadIndexReadResult({ required this.sessions, required this.resetRequired, }); final List sessions; final bool resetRequired; }