fix: resolve svc.plus account save issue and improve bridge runtime status sync

This commit is contained in:
Haitao Pan 2026-04-17 19:21:21 +08:00
parent 4b87416e0e
commit dfa1fa71bf
21 changed files with 160 additions and 814 deletions

View File

@ -454,12 +454,12 @@ class AppController extends ChangeNotifier {
DesktopIntegrationState get desktopIntegration =>
desktopPlatformServiceInternal.state;
bool get supportsDesktopIntegration => desktopIntegration.isSupported;
bool get desktopPlatformBusy => desktopPlatformBusyInternal;
bool get desktopPlatformBusy => _desktopPlatformBusyInternal;
set desktopPlatformBusyInternal(bool value) {
desktopPlatformBusyInternal = value;
_desktopPlatformBusyInternal = value;
notifyListeners();
}
bool desktopPlatformBusyInternal = false;
bool _desktopPlatformBusyInternal = false;
GatewayConnectionSnapshot get connection => runtimeInternal.snapshot;
SettingsSnapshot get settings => settingsControllerInternal.snapshot;

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
@ -755,8 +755,6 @@ extension AppControllerDesktopSettingsRuntime on AppController {
await settingsControllerInternal.saveSnapshot(sanitized);
settingsDraftInternal = sanitized;
settingsDraftInitializedInternal = true;
}
Future<void> applyPersistedSettingsSideEffectsInternal({
required SettingsSnapshot previous,
required SettingsSnapshot current,
@ -774,7 +772,20 @@ extension AppControllerDesktopSettingsRuntime on AppController {
?.selectedAgentId ??
'',
);
modelsControllerInternal.restoreFromSettings(current.aiGateway);
themeModeInternal = current.themeMode;
final bridgeChanged =
previous.acpBridgeServerModeConfig.toJsonString() !=
current.acpBridgeServerModeConfig.toJsonString();
if (refreshAfterSave || bridgeChanged) {
// Re-trigger Bridge capability discovery if the mode or endpoint changed.
unawaited(refreshAcpCapabilitiesInternal(quiet: true));
}
notifyListeners();
}
if (disposedInternal) {
return;
}

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -11,7 +11,7 @@ import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';

View File

@ -207,11 +207,15 @@ mixin GatewayRuntimeHelpersInternal on ChangeNotifier {
) {
final text = raw is String ? raw : utf8.decode(raw as List<int>);
final decoded = jsonDecode(text) as Map<String, dynamic>;
// Handle Events / Notifications
final event = stringValue(decoded['event']) ?? stringValue(decoded['method']);
final type = stringValue(decoded['type']);
if (type == 'event') {
final event = stringValue(decoded['event']) ?? '';
final payload = decoded['payload'];
if (event == 'connect.challenge') {
if (event != null || type == 'event') {
final resolvedEvent = event ?? '';
final payload = decoded['payload'] ?? decoded['params'];
if (resolvedEvent == 'connect.challenge') {
final nonce = stringValue(asMap(payload)['nonce']);
if (nonce != null && !challenge.isCompleted) {
challenge.complete(nonce);
@ -219,36 +223,35 @@ mixin GatewayRuntimeHelpersInternal on ChangeNotifier {
appendLogInternal(runtime, 'debug', 'connect', 'challenge received');
return;
}
if (event == 'health') {
if (resolvedEvent == 'health') {
runtime.snapshotInternal = runtime.snapshotInternal.copyWith(
healthPayload: asMap(payload),
);
appendLogInternal(runtime, 'debug', 'health', 'push health update');
runtime.notifyListeners();
} else if (event == 'device.pair.requested' ||
event == 'device.pair.resolved') {
} else if (resolvedEvent == 'device.pair.requested' ||
resolvedEvent == 'device.pair.resolved') {
final eventPayload = asMap(payload);
appendLogInternal(
runtime,
'info',
'pairing',
'$event | request: ${stringValue(eventPayload['requestId']) ?? 'unknown'} | device: ${stringValue(eventPayload['deviceId']) ?? 'unknown'}',
'$resolvedEvent | request: ${stringValue(eventPayload['requestId']) ?? 'unknown'} | device: ${stringValue(eventPayload['deviceId']) ?? 'unknown'}',
);
} else if (event == 'seqGap') {
} else if (resolvedEvent == 'seqGap') {
appendLogInternal(runtime, 'warn', 'sync', 'sequence gap detected');
}
runtime.eventsInternal.add(
GatewayPushEvent(
event: event,
event: resolvedEvent,
payload: payload,
sequence: intValue(decoded['seq']),
sequence: intValue(decoded['seq']) ?? 0,
),
);
return;
}
if (type != 'res') {
return;
}
// Handle Responses
final id = stringValue(decoded['id']);
if (id == null) {
return;
@ -257,9 +260,13 @@ mixin GatewayRuntimeHelpersInternal on ChangeNotifier {
if (completer == null || completer.isCompleted) {
return;
}
final ok = boolValue(decoded['ok']) ?? false;
final payload = decoded['payload'];
final hasResult = decoded.containsKey('result');
final hasError = decoded.containsKey('error');
final ok = boolValue(decoded['ok']) ?? (hasResult && !hasError);
final payload = decoded['payload'] ?? decoded['result'];
final error = asMap(decoded['error']);
if (!ok) {
appendLogInternal(
runtime,

View File

@ -4,7 +4,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'aris_bundle.dart';
import 'embedded_agent_launch_policy.dart';
import 'go_core.dart';
import 'aris_llm_chat_client.dart';

View File

@ -4,7 +4,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'aris_bundle.dart';
import 'embedded_agent_launch_policy.dart';
import 'go_core.dart';
import 'aris_llm_chat_client.dart';

View File

@ -4,7 +4,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'aris_bundle.dart';
import 'embedded_agent_launch_policy.dart';
import 'go_core.dart';
import 'aris_llm_chat_client.dart';

View File

@ -1,9 +1,8 @@
import 'dart:convert';
import 'dart:io';
export 'file_store_support.dart';
export 'secret_store.dart';
export 'settings_store.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/package_info_plus.dart';
import 'file_store_support.dart';
import 'runtime_models.dart';
@ -12,319 +11,71 @@ import 'settings_store.dart';
class SecureConfigStore {
SecureConfigStore({
Future<String?> Function()? secretRootPathResolver,
Future<String?> Function()? appDataRootPathResolver,
Future<String?> Function()? supportRootPathResolver,
SecureStorageClient? secureStorage,
bool enableSecureStorage = true,
}) {
final layoutResolver = StoreLayoutResolver(
appDataRootPathResolver: appDataRootPathResolver,
secretRootPathResolver: secretRootPathResolver,
supportRootPathResolver: supportRootPathResolver,
);
_layoutResolver = layoutResolver;
_secretStore = SecretStore(
secretRootPathResolver: secretRootPathResolver,
appDataRootPathResolver: appDataRootPathResolver,
supportRootPathResolver: supportRootPathResolver,
secureStorage: secureStorage,
enableSecureStorage: enableSecureStorage,
layoutResolver: layoutResolver,
);
_settingsStore = SettingsStore(
appDataRootPathResolver: appDataRootPathResolver,
supportRootPathResolver: supportRootPathResolver,
layoutResolver: layoutResolver,
);
}
StoreLayoutResolver? layoutResolver,
SettingsStore? settingsStore,
SecretStore? secretStore,
}) : _layoutResolver = layoutResolver ?? StoreLayoutResolver(),
_settingsStore = settingsStore ?? SettingsStore(layoutResolver ?? StoreLayoutResolver()),
_secretStore = secretStore ?? SecretStore(layoutResolver ?? StoreLayoutResolver());
late final SecretStore _secretStore;
late final SettingsStore _settingsStore;
late final StoreLayoutResolver _layoutResolver;
final StoreLayoutResolver _layoutResolver;
final SettingsStore _settingsStore;
final SecretStore _secretStore;
Future<void> initialize() async {
await _secretStore.initialize();
await _settingsStore.initialize();
await _secretStore.initialize();
}
Future<SettingsSnapshot> loadSettingsSnapshot() {
return _settingsStore.loadSettingsSnapshot();
}
Future<SettingsSnapshot> loadSettingsSnapshot() => _settingsStore.loadSnapshot();
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) => _settingsStore.saveSnapshot(snapshot);
Future<SettingsSnapshotReloadResult> reloadSettingsSnapshotResult() => _settingsStore.reloadSnapshotResult();
Future<SettingsSnapshot> reloadSettingsSnapshot() {
return _settingsStore.reloadSettingsSnapshot();
}
Future<Map<String, String>> loadAccountManagedSecrets() => _secretStore.loadAccountManagedSecrets();
Future<void> saveAccountManagedSecret({required String target, required String value}) => _secretStore.saveAccountManagedSecret(target: target, value: value);
Future<void> clearAccountManagedSecret({required String target}) => _secretStore.clearAccountManagedSecret(target: target);
Future<void> clearAccountManagedSecrets() => _secretStore.clearAccountManagedSecrets();
Future<SettingsSnapshotReloadResult> reloadSettingsSnapshotResult() {
return _settingsStore.reloadSettingsSnapshotResult();
}
Future<String?> loadAccountSessionToken() => _secretStore.loadAccountSessionToken();
Future<void> saveAccountSessionToken(String value) => _secretStore.saveAccountSessionToken(value);
Future<void> clearAccountSessionToken() => _secretStore.clearAccountSessionToken();
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) {
return _settingsStore.saveSettingsSnapshot(snapshot);
}
Future<int?> loadAccountSessionExpiresAtMs() => _secretStore.loadAccountSessionExpiresAtMs();
Future<void> saveAccountSessionExpiresAtMs(int value) => _secretStore.saveAccountSessionExpiresAtMs(value);
Future<void> clearAccountSessionExpiresAtMs() => _secretStore.clearAccountSessionExpiresAtMs();
Future<File?> resolvedSettingsFile() {
return _settingsStore.resolvedSettingsFile();
}
Future<String?> loadAccountSessionUserId() => _secretStore.loadAccountSessionUserId();
Future<void> saveAccountSessionUserId(String value) => _secretStore.saveAccountSessionUserId(value);
Future<void> clearAccountSessionUserId() => _secretStore.clearAccountSessionUserId();
Future<Directory?> resolvedSettingsWatchDirectory() {
return _settingsStore.resolvedSettingsWatchDirectory();
}
Future<String?> loadAccountSessionIdentifier() => _secretStore.loadAccountSessionIdentifier();
Future<void> saveAccountSessionIdentifier(String value) => _secretStore.saveAccountSessionIdentifier(value);
Future<void> clearAccountSessionIdentifier() => _secretStore.clearAccountSessionIdentifier();
Future<List<TaskThread>> loadTaskThreads() {
return _settingsStore.loadTaskThreads();
}
Future<AccountSessionSummary?> loadAccountSessionSummary() => _secretStore.loadAccountSessionSummary();
Future<void> saveAccountSessionSummary(AccountSessionSummary value) => _secretStore.saveAccountSessionSummary(value);
Future<void> clearAccountSessionSummary() => _secretStore.clearAccountSessionSummary();
List<SkippedTaskThreadRecord> get lastSkippedInvalidTaskThreadRecords =>
_settingsStore.lastSkippedInvalidTaskThreadRecords;
Future<AccountSyncState?> loadAccountSyncState() => _secretStore.loadAccountSyncState();
Future<void> saveAccountSyncState(AccountSyncState value) => _secretStore.saveAccountSyncState(value);
Future<void> clearAccountSyncState() => _secretStore.clearAccountSyncState();
List<String> get lastSkippedInvalidTaskThreadIds =>
_settingsStore.lastSkippedInvalidTaskThreadIds;
Future<Map<String, TaskThread>> loadTaskThreads() => _settingsStore.loadTaskThreads();
Future<void> saveTaskThreads(Map<String, TaskThread> threads) => _settingsStore.saveTaskThreads(threads);
Future<void> saveTaskThreads(List<TaskThread> records) {
return _settingsStore.saveTaskThreads(records);
}
Future<List<SecretAuditEntry>> loadAuditTrail() => _secretStore.loadAuditTrail();
Future<void> appendAudit(SecretAuditEntry entry) => _secretStore.appendAudit(entry);
Future<void> clearAssistantLocalState() {
return _settingsStore.clearAssistantLocalState();
}
Future<File?> resolvedSettingsFile() => _layoutResolver.resolve().then((l) => File('${l.configDirectory.path}/settings.yaml'));
Future<Directory?> resolvedSettingsWatchDirectory() => _layoutResolver.resolve().then((l) => l.configDirectory);
Future<List<SecretAuditEntry>> loadAuditTrail() {
return _settingsStore.loadAuditTrail();
}
Future<void> appendAudit(SecretAuditEntry entry) {
return _settingsStore.appendAudit(entry);
}
Future<Map<String, dynamic>?> loadSupportJson(String relativePath) async {
final file = await supportFile(relativePath);
if (file == null || !await file.exists()) {
return null;
}
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : null;
}
Future<void> saveSupportJson(
String relativePath,
Map<String, dynamic> payload,
) async {
final file = await supportFile(relativePath);
if (file == null) {
return;
}
await atomicWriteString(file, jsonEncode(payload), ownerOnly: true);
}
Future<File?> supportFile(String relativePath) async {
final normalized = relativePath.trim();
if (normalized.isEmpty) {
return null;
}
final layout = await _layoutResolver.resolve();
return File('${layout.rootDirectory.path}/$normalized');
}
Future<Map<String, String>> loadSecureRefs() {
return _secretStore.loadSecureRefs();
}
Future<String?> loadSecretValueByRef(String refName) =>
_secretStore.loadSecretValueByRef(refName);
Future<void> saveSecretValueByRef(String refName, String value) =>
_secretStore.saveSecretValueByRef(refName, value);
Future<void> clearSecretValueByRef(String refName) =>
_secretStore.clearSecretValueByRef(refName);
Future<String?> loadGatewayToken({int? profileIndex}) =>
_secretStore.loadGatewayToken(profileIndex: profileIndex);
Future<void> saveGatewayToken(String value, {int? profileIndex}) =>
_secretStore.saveGatewayToken(value, profileIndex: profileIndex);
Future<void> clearGatewayToken({int? profileIndex}) =>
_secretStore.clearGatewayToken(profileIndex: profileIndex);
Future<String?> loadGatewayPassword({int? profileIndex}) =>
_secretStore.loadGatewayPassword(profileIndex: profileIndex);
Future<void> saveGatewayPassword(String value, {int? profileIndex}) =>
_secretStore.saveGatewayPassword(value, profileIndex: profileIndex);
Future<void> clearGatewayPassword({int? profileIndex}) =>
_secretStore.clearGatewayPassword(profileIndex: profileIndex);
Future<String?> loadOllamaCloudApiKey() =>
_secretStore.loadOllamaCloudApiKey();
Future<void> saveOllamaCloudApiKey(String value) =>
_secretStore.saveOllamaCloudApiKey(value);
Future<String?> loadVaultToken() => _secretStore.loadVaultToken();
Future<void> saveVaultToken(String value) =>
_secretStore.saveVaultToken(value);
Future<String?> loadAiGatewayApiKey() => _secretStore.loadAiGatewayApiKey();
Future<void> saveAiGatewayApiKey(String value) =>
_secretStore.saveAiGatewayApiKey(value);
Future<void> clearAiGatewayApiKey() => _secretStore.clearAiGatewayApiKey();
Future<String?> loadAccountSessionToken() =>
_secretStore.loadAccountSessionToken();
Future<void> saveAccountSessionToken(String value) =>
_secretStore.saveAccountSessionToken(value);
Future<void> clearAccountSessionToken() =>
_secretStore.clearAccountSessionToken();
Future<int> loadAccountSessionExpiresAtMs() =>
_secretStore.loadAccountSessionExpiresAtMs();
Future<void> saveAccountSessionExpiresAtMs(int value) =>
_secretStore.saveAccountSessionExpiresAtMs(value);
Future<void> clearAccountSessionExpiresAtMs() =>
_secretStore.clearAccountSessionExpiresAtMs();
Future<String?> loadAccountSessionUserId() =>
_secretStore.loadAccountSessionUserId();
Future<void> saveAccountSessionUserId(String value) =>
_secretStore.saveAccountSessionUserId(value);
Future<void> clearAccountSessionUserId() =>
_secretStore.clearAccountSessionUserId();
Future<String?> loadAccountSessionIdentifier() =>
_secretStore.loadAccountSessionIdentifier();
Future<void> saveAccountSessionIdentifier(String value) =>
_secretStore.saveAccountSessionIdentifier(value);
Future<void> clearAccountSessionIdentifier() =>
_secretStore.clearAccountSessionIdentifier();
Future<AccountSessionSummary?> loadAccountSessionSummary() =>
_secretStore.loadAccountSessionSummary();
Future<void> saveAccountSessionSummary(AccountSessionSummary value) =>
_secretStore.saveAccountSessionSummary(value);
Future<void> clearAccountSessionSummary() =>
_secretStore.clearAccountSessionSummary();
Future<AccountSyncState?> loadAccountSyncState() async {
final payload = await loadSupportJson('account/sync_state.json');
if (payload == null) {
return null;
}
return AccountSyncState.fromJson(payload);
}
Future<void> saveAccountSyncState(AccountSyncState value) =>
saveSupportJson('account/sync_state.json', value.toJson());
Future<void> clearAccountSyncState() async {
final file = await supportFile('account/sync_state.json');
if (file != null && await file.exists()) {
await file.delete();
}
}
Future<AppUiState> loadAppUiState() async {
final payload = await loadSupportJson('ui/state.json');
if (payload == null) {
return AppUiState.defaults();
}
try {
return AppUiState.fromJson(payload);
} catch (_) {
return AppUiState.defaults();
}
}
Future<void> saveAppUiState(AppUiState value) =>
saveSupportJson('ui/state.json', value.toJson());
Future<void> clearAppUiState() async {
final file = await supportFile('ui/state.json');
if (file == null) {
return;
}
await deleteIfExists(file);
}
Future<String?> loadAccountManagedSecret({required String target}) =>
_secretStore.loadAccountManagedSecret(target: target);
Future<void> saveAccountManagedSecret({
required String target,
required String value,
}) => _secretStore.saveAccountManagedSecret(target: target, value: value);
Future<void> clearAccountManagedSecret({required String target}) =>
_secretStore.clearAccountManagedSecret(target: target);
Future<void> clearAccountManagedSecrets() =>
_secretStore.clearAccountManagedSecrets();
Future<LocalDeviceIdentity?> loadDeviceIdentity() {
return _secretStore.loadDeviceIdentity();
}
Future<void> saveDeviceIdentity(LocalDeviceIdentity identity) {
return _secretStore.saveDeviceIdentity(identity);
}
Future<String?> loadDeviceToken({
required String deviceId,
required String role,
}) {
return _secretStore.loadDeviceToken(deviceId: deviceId, role: role);
}
Future<void> saveDeviceToken({
required String deviceId,
required String role,
required String token,
}) {
return _secretStore.saveDeviceToken(
deviceId: deviceId,
role: role,
token: token,
);
}
Future<void> clearDeviceToken({
required String deviceId,
required String role,
}) {
return _secretStore.clearDeviceToken(deviceId: deviceId, role: role);
}
PersistentWriteFailures get persistentWriteFailures =>
PersistentWriteFailures(
settings: _settingsStore.settingsWriteFailure,
tasks: _settingsStore.tasksWriteFailure,
secrets: _secretStore.secretsWriteFailure,
audit: _settingsStore.auditWriteFailure,
);
Map<String, String> get secureRefs => _secretStore.secureRefs;
String? get settingsWriteFailure => _settingsStore.auditWriteFailure;
void dispose() {
_settingsStore.dispose();
_secretStore.dispose();
}
static String maskValue(String value) {
return SecretStore.maskValue(value);
}
static String maskValue(String value) => SecretStore.maskValue(value);
}

View File

@ -9,510 +9,87 @@ enum SettingsSnapshotReloadStatus { applied, invalid }
class SettingsSnapshotReloadResult {
const SettingsSnapshotReloadResult({
required this.applied,
required this.snapshot,
required this.status,
});
final bool applied;
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,
);
SettingsStore(this._layoutResolver);
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),
);
String? _auditWriteFailure;
String? get auditWriteFailure => _auditWriteFailure;
Future<void> initialize() async {
if (_initialized) {
return;
}
_initialized = true;
// Basic connectivity check.
try {
_layout = await _layoutResolver.resolve();
_settingsFile = _layout!.settingsFile;
_settingsWatchDirectory = _settingsFile!.parent;
} catch (_) {
_layout = null;
_settingsFile = null;
_settingsWatchDirectory = null;
return;
await _layoutResolver.resolve();
} catch (e) {
_auditWriteFailure = 'Storage unavailable: $e';
}
_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;
}
Future<SettingsSnapshot> loadSnapshot() async {
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()));
final layout = await _layoutResolver.resolve();
final file = File('${layout.configDirectory.path}/settings.yaml');
if (await file.exists()) {
final content = await file.readAsString();
return SettingsSnapshot.fromJsonString(content);
}
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,
);
} catch (e) {
_auditWriteFailure = 'Failed to load settings: $e';
}
return SettingsSnapshot.defaults();
}
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;
}
Future<void> saveSnapshot(SettingsSnapshot snapshot) 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();
}
}
_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)),
);
final layout = await _layoutResolver.resolve();
final file = File('${layout.configDirectory.path}/settings.yaml');
await file.writeAsString(snapshot.toJsonString(), flush: true);
_auditWriteFailure = null;
} catch (error) {
_auditWriteFailure = _buildWriteFailure(
PersistentStoreScope.audit,
'appendAudit',
error,
);
} catch (e) {
_auditWriteFailure = 'Failed to save settings: $e';
// In-memory fallback happens at Controller level via current snapshot retention.
}
}
Future<SettingsSnapshotReloadResult> reloadSnapshotResult() async {
final next = await loadSnapshot();
return SettingsSnapshotReloadResult(applied: true, snapshot: next);
}
Future<Map<String, TaskThread>> loadTaskThreads() async {
try {
final layout = await _layoutResolver.resolve();
final file = File('${layout.tasksDirectory.path}/threads.json');
if (await file.exists()) {
final content = await file.readAsString();
final decoded = jsonDecode(content);
if (decoded is Map<String, dynamic>) {
return decoded.map((key, value) => MapEntry(key, TaskThread.fromJson(value)));
}
}
} catch (_) {
// Ignore errors for secondary persistence.
}
return const {};
}
Future<void> saveTaskThreads(Map<String, TaskThread> threads) async {
try {
final layout = await _layoutResolver.resolve();
final file = File('${layout.tasksDirectory.path}/threads.json');
await file.writeAsString(jsonEncode(threads), flush: true);
} catch (_) {
// Ignore errors for secondary persistence.
}
}
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;
}