xworkmate-app/lib/runtime/settings_store.dart

519 lines
16 KiB
Dart

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<String?> Function()? appDataRootPathResolver,
Future<String?> 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<TaskThread> _threadRecords = const <TaskThread>[];
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
PersistentWriteFailure? _settingsWriteFailure;
PersistentWriteFailure? _tasksWriteFailure;
PersistentWriteFailure? _auditWriteFailure;
List<SkippedTaskThreadRecord> _lastSkippedInvalidTaskThreadRecords =
const <SkippedTaskThreadRecord>[];
PersistentWriteFailure? get settingsWriteFailure => _settingsWriteFailure;
PersistentWriteFailure? get tasksWriteFailure => _tasksWriteFailure;
PersistentWriteFailure? get auditWriteFailure => _auditWriteFailure;
List<SkippedTaskThreadRecord> get lastSkippedInvalidTaskThreadRecords =>
List<SkippedTaskThreadRecord>.unmodifiable(
_lastSkippedInvalidTaskThreadRecords,
);
List<String> get lastSkippedInvalidTaskThreadIds => List<String>.unmodifiable(
_lastSkippedInvalidTaskThreadRecords
.map((item) => item.threadId)
.toList(growable: false),
);
Future<void> 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<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<File?> resolvedSettingsFile() async {
await initialize();
return _settingsFile;
}
Future<Directory?> resolvedSettingsWatchDirectory() async {
await initialize();
return _settingsWatchDirectory;
}
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());
await atomicWriteString(layout.settingsFile, contents);
_settingsWriteFailure = null;
} catch (error) {
_settingsWriteFailure = _buildWriteFailure(
PersistentStoreScope.settings,
'saveSettingsSnapshot',
error,
);
}
}
Future<List<TaskThread>> loadTaskThreads() async {
await initialize();
return List<TaskThread>.from(_threadRecords);
}
Future<void> saveTaskThreads(List<TaskThread> 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 = <String>{};
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(<String, dynamic>{
'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<void> clearAssistantLocalState() async {
await initialize();
_threadRecords = const <TaskThread>[];
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<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 {
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<String, dynamic>) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.fromJson(decoded),
status: SettingsSnapshotReloadStatus.applied,
);
}
if (decoded is Map) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.fromJson(decoded.cast<String, dynamic>()),
status: SettingsSnapshotReloadStatus.applied,
);
}
} catch (_) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.defaults(),
status: SettingsSnapshotReloadStatus.invalid,
);
}
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.defaults(),
status: SettingsSnapshotReloadStatus.invalid,
);
}
Future<List<TaskThread>> _readTaskThreads() async {
final layout = _layout;
if (layout == null) {
_lastSkippedInvalidTaskThreadRecords = const <SkippedTaskThreadRecord>[];
return const <TaskThread>[];
}
_lastSkippedInvalidTaskThreadRecords = const <SkippedTaskThreadRecord>[];
final index = await _readThreadIndex(layout);
if (index.resetRequired) {
await _resetTaskThreadState(layout);
return const <TaskThread>[];
}
final orderedKeys = index.sessions;
final recordsByKey = <String, TaskThread>{};
final skippedRecords = <SkippedTaskThreadRecord>[];
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<String, dynamic>) {
final schemaVersion = decoded['schemaVersion'];
if (schemaVersion is! int ||
schemaVersion != taskThreadSchemaVersion) {
await _resetTaskThreadState(layout);
return const <TaskThread>[];
}
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 <TaskThread>[];
}
final ordered = <TaskThread>[];
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: <String>[],
resetRequired: false,
);
}
try {
final raw = await layout.taskIndexFile.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
final version = decoded['version'];
if (version is! int || version != taskThreadSchemaVersion) {
return const _ThreadIndexReadResult(
sessions: <String>[],
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: <String>[],
resetRequired: true,
);
}
Future<void> _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<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,
);
}
}
class _ThreadIndexReadResult {
const _ThreadIndexReadResult({
required this.sessions,
required this.resetRequired,
});
final List<String> sessions;
final bool resetRequired;
}