485 lines
15 KiB
Dart
485 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'file_store_support.dart';
|
|
import 'runtime_models.dart';
|
|
|
|
typedef SecureConfigDatabaseOpener =
|
|
FutureOr<Object?> Function(String resolvedPath);
|
|
|
|
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;
|
|
}
|
|
|
|
class SettingsStore {
|
|
SettingsStore({
|
|
Future<String?> Function()? fallbackDirectoryPathResolver,
|
|
Future<String?> Function()? databasePathResolver,
|
|
Future<String?> Function()? defaultSupportDirectoryPathResolver,
|
|
SecureConfigDatabaseOpener? databaseOpener,
|
|
StoreLayoutResolver? layoutResolver,
|
|
}) : _layoutResolver =
|
|
layoutResolver ??
|
|
StoreLayoutResolver(
|
|
localRootPathResolver: databasePathResolver,
|
|
supportRootPathResolver: defaultSupportDirectoryPathResolver,
|
|
),
|
|
_enableUserSettingsMirror =
|
|
databasePathResolver == null &&
|
|
defaultSupportDirectoryPathResolver == null;
|
|
|
|
static const String settingsKey = 'xworkmate.settings.snapshot';
|
|
static const String auditKey = 'xworkmate.secrets.audit';
|
|
static const String assistantThreadsKey = 'xworkmate.assistant.threads';
|
|
static const String databaseFileName = 'config-store.sqlite3';
|
|
static const String databaseTableName = 'config_entries';
|
|
|
|
final StoreLayoutResolver _layoutResolver;
|
|
final bool _enableUserSettingsMirror;
|
|
bool _initialized = false;
|
|
StoreLayout? _layout;
|
|
List<File> _settingsFiles = const <File>[];
|
|
List<Directory> _settingsWatchDirectories = const <Directory>[];
|
|
SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults();
|
|
List<AssistantThreadRecord> _threadRecords = const <AssistantThreadRecord>[];
|
|
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
|
|
PersistentWriteFailure? _settingsWriteFailure;
|
|
PersistentWriteFailure? _tasksWriteFailure;
|
|
PersistentWriteFailure? _auditWriteFailure;
|
|
|
|
PersistentWriteFailure? get settingsWriteFailure => _settingsWriteFailure;
|
|
PersistentWriteFailure? get tasksWriteFailure => _tasksWriteFailure;
|
|
PersistentWriteFailure? get auditWriteFailure => _auditWriteFailure;
|
|
|
|
Future<void> initialize() async {
|
|
if (_initialized) {
|
|
return;
|
|
}
|
|
_initialized = true;
|
|
try {
|
|
_layout = await _layoutResolver.resolve();
|
|
_settingsFiles = _resolveSettingsFiles(_layout!);
|
|
_settingsWatchDirectories = _resolveSettingsWatchDirectories(
|
|
_settingsFiles,
|
|
);
|
|
} catch (_) {
|
|
_layout = null;
|
|
_settingsFiles = const <File>[];
|
|
_settingsWatchDirectories = const <Directory>[];
|
|
return;
|
|
}
|
|
_settingsSnapshot = await _readSettingsSnapshot();
|
|
_threadRecords = await _readAssistantThreadRecords();
|
|
_auditTrail = await _readAuditTrail();
|
|
}
|
|
|
|
Future<SettingsSnapshot> loadSettingsSnapshot() async {
|
|
await initialize();
|
|
return _settingsSnapshot;
|
|
}
|
|
|
|
Future<SettingsSnapshot> reloadSettingsSnapshot() async {
|
|
final result = await reloadSettingsSnapshotResult();
|
|
return result.snapshot;
|
|
}
|
|
|
|
Future<SettingsSnapshotReloadResult> 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<List<File>> resolvedSettingsFiles() async {
|
|
await initialize();
|
|
return List<File>.from(_settingsFiles);
|
|
}
|
|
|
|
Future<List<Directory>> resolvedSettingsWatchDirectories() async {
|
|
await initialize();
|
|
return List<Directory>.from(_settingsWatchDirectories);
|
|
}
|
|
|
|
Future<void> 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());
|
|
for (final file
|
|
in _settingsFiles.isEmpty
|
|
? <File>[layout.settingsFile]
|
|
: _settingsFiles) {
|
|
await atomicWriteString(file, contents);
|
|
}
|
|
_settingsWriteFailure = null;
|
|
} catch (error) {
|
|
_settingsWriteFailure = _buildWriteFailure(
|
|
PersistentStoreScope.settings,
|
|
'saveSettingsSnapshot',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
|
|
await initialize();
|
|
return List<AssistantThreadRecord>.from(_threadRecords);
|
|
}
|
|
|
|
Future<void> saveAssistantThreadRecords(
|
|
List<AssistantThreadRecord> records,
|
|
) async {
|
|
await initialize();
|
|
final normalized = records
|
|
.where((item) => item.sessionKey.trim().isNotEmpty)
|
|
.toList(growable: false);
|
|
_threadRecords = normalized;
|
|
final layout = _layout;
|
|
if (layout == null) {
|
|
_tasksWriteFailure = _buildWriteFailure(
|
|
PersistentStoreScope.tasks,
|
|
'saveAssistantThreadRecords',
|
|
StateError('Persistent task path unavailable; using memory only.'),
|
|
);
|
|
return;
|
|
}
|
|
final keptPaths = <String>{};
|
|
try {
|
|
for (final record in normalized) {
|
|
final taskFile = layout.taskFileForSessionKey(record.sessionKey);
|
|
keptPaths.add(taskFile.path);
|
|
await atomicWriteString(taskFile, jsonEncode(record.toJson()));
|
|
}
|
|
await atomicWriteString(
|
|
layout.taskIndexFile,
|
|
jsonEncode(<String, dynamic>{
|
|
'version': 1,
|
|
'sessions': normalized
|
|
.map((item) => item.sessionKey)
|
|
.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,
|
|
'saveAssistantThreadRecords',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> clearAssistantLocalState() async {
|
|
await initialize();
|
|
_settingsSnapshot = SettingsSnapshot.defaults();
|
|
_threadRecords = const <AssistantThreadRecord>[];
|
|
final layout = _layout;
|
|
if (layout == null) {
|
|
_settingsWriteFailure = _buildWriteFailure(
|
|
PersistentStoreScope.settings,
|
|
'clearAssistantLocalState',
|
|
StateError(
|
|
'Persistent settings path unavailable; reset kept in memory.',
|
|
),
|
|
);
|
|
_tasksWriteFailure = _buildWriteFailure(
|
|
PersistentStoreScope.tasks,
|
|
'clearAssistantLocalState',
|
|
StateError('Persistent task path unavailable; reset kept in memory.'),
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
final settingsFiles = _settingsFiles.isEmpty
|
|
? <File>[layout.settingsFile]
|
|
: _settingsFiles;
|
|
for (final file in settingsFiles) {
|
|
await deleteIfExists(file);
|
|
}
|
|
_settingsWriteFailure = null;
|
|
} catch (error) {
|
|
_settingsWriteFailure = _buildWriteFailure(
|
|
PersistentStoreScope.settings,
|
|
'clearAssistantLocalState',
|
|
error,
|
|
);
|
|
}
|
|
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<List<SecretAuditEntry>> loadAuditTrail() async {
|
|
await initialize();
|
|
return List<SecretAuditEntry>.from(_auditTrail);
|
|
}
|
|
|
|
Future<void> appendAudit(SecretAuditEntry entry) async {
|
|
await initialize();
|
|
final next = <SecretAuditEntry>[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<SettingsSnapshot> _readSettingsSnapshot() async {
|
|
final result = await _readSettingsSnapshotResult();
|
|
return result.status == SettingsSnapshotReloadStatus.invalid
|
|
? SettingsSnapshot.defaults()
|
|
: result.snapshot;
|
|
}
|
|
|
|
Future<SettingsSnapshotReloadResult> _readSettingsSnapshotResult() async {
|
|
if (_settingsFiles.isEmpty) {
|
|
return SettingsSnapshotReloadResult(
|
|
snapshot: SettingsSnapshot.defaults(),
|
|
status: SettingsSnapshotReloadStatus.applied,
|
|
);
|
|
}
|
|
var sawExistingFile = false;
|
|
var sawInvalidFile = false;
|
|
for (final file in _settingsFiles) {
|
|
if (!await file.exists()) {
|
|
continue;
|
|
}
|
|
sawExistingFile = true;
|
|
try {
|
|
final raw = await file.readAsString();
|
|
final decoded = decodeYamlDocument(raw);
|
|
if (decoded is Map) {
|
|
return SettingsSnapshotReloadResult(
|
|
snapshot: SettingsSnapshot.fromJson(
|
|
decoded.cast<String, dynamic>(),
|
|
),
|
|
status: SettingsSnapshotReloadStatus.applied,
|
|
);
|
|
}
|
|
sawInvalidFile = true;
|
|
} catch (_) {
|
|
sawInvalidFile = true;
|
|
}
|
|
}
|
|
return SettingsSnapshotReloadResult(
|
|
snapshot: SettingsSnapshot.defaults(),
|
|
status: sawExistingFile && sawInvalidFile
|
|
? SettingsSnapshotReloadStatus.invalid
|
|
: SettingsSnapshotReloadStatus.applied,
|
|
);
|
|
}
|
|
|
|
List<File> _resolveSettingsFiles(StoreLayout layout) {
|
|
final resolved = <File>[];
|
|
final seen = <String>{};
|
|
|
|
void addPath(String path) {
|
|
final normalized = path.trim();
|
|
if (normalized.isEmpty || !seen.add(normalized)) {
|
|
return;
|
|
}
|
|
resolved.add(File(normalized));
|
|
}
|
|
|
|
final userPath = _enableUserSettingsMirror
|
|
? defaultUserSettingsFilePath()
|
|
: null;
|
|
if ((userPath ?? '').isNotEmpty) {
|
|
addPath(userPath!);
|
|
}
|
|
addPath(layout.settingsFile.path);
|
|
return List<File>.unmodifiable(resolved);
|
|
}
|
|
|
|
List<Directory> _resolveSettingsWatchDirectories(List<File> files) {
|
|
final directories = <Directory>[];
|
|
final seen = <String>{};
|
|
for (final file in files) {
|
|
final path = file.parent.path.trim();
|
|
if (path.isEmpty || !seen.add(path)) {
|
|
continue;
|
|
}
|
|
directories.add(Directory(path));
|
|
}
|
|
return List<Directory>.unmodifiable(directories);
|
|
}
|
|
|
|
Future<List<AssistantThreadRecord>> _readAssistantThreadRecords() async {
|
|
final layout = _layout;
|
|
if (layout == null) {
|
|
return const <AssistantThreadRecord>[];
|
|
}
|
|
final orderedKeys = await _readThreadIndex(layout);
|
|
final recordsByKey = <String, AssistantThreadRecord>{};
|
|
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<String, dynamic>) {
|
|
final record = AssistantThreadRecord.fromJson(decoded);
|
|
if (record.sessionKey.trim().isNotEmpty) {
|
|
recordsByKey[record.sessionKey] = record;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
return const <AssistantThreadRecord>[];
|
|
}
|
|
final ordered = <AssistantThreadRecord>[];
|
|
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);
|
|
}
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
Future<List<String>> _readThreadIndex(StoreLayout layout) async {
|
|
if (!await layout.taskIndexFile.exists()) {
|
|
return const <String>[];
|
|
}
|
|
try {
|
|
final raw = await layout.taskIndexFile.readAsString();
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is Map<String, dynamic>) {
|
|
final sessions = decoded['sessions'];
|
|
if (sessions is List) {
|
|
return sessions
|
|
.map((item) => item.toString().trim())
|
|
.where((item) => item.isNotEmpty)
|
|
.toList(growable: false);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
return const <String>[];
|
|
}
|
|
|
|
Future<List<SecretAuditEntry>> _readAuditTrail() async {
|
|
final layout = _layout;
|
|
if (layout == null || !await layout.auditFile.exists()) {
|
|
return const <SecretAuditEntry>[];
|
|
}
|
|
try {
|
|
final raw = await layout.auditFile.readAsString();
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is List) {
|
|
return decoded
|
|
.whereType<Map>()
|
|
.map(
|
|
(item) => SecretAuditEntry.fromJson(item.cast<String, dynamic>()),
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
} catch (_) {}
|
|
return const <SecretAuditEntry>[];
|
|
}
|
|
|
|
PersistentWriteFailure _buildWriteFailure(
|
|
PersistentStoreScope scope,
|
|
String operation,
|
|
Object error,
|
|
) {
|
|
return PersistentWriteFailure(
|
|
scope: scope,
|
|
operation: operation,
|
|
message: error.toString(),
|
|
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
}
|
|
}
|