From 37cefdfec61a7d82e4cc6301a87daf1081cb6996 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 12:02:32 +0800 Subject: [PATCH] refactor: move app settings to v1 single-file snapshot --- ...1-settings-config-state-workflow-design.md | 101 ++++++++ lib/app/app_controller_desktop_core.dart | 21 +- ...ler_desktop_runtime_coordination_impl.dart | 2 +- ...pp_controller_desktop_runtime_helpers.dart | 24 +- lib/app/app_controller_desktop_settings.dart | 37 +-- ...p_controller_desktop_settings_runtime.dart | 17 +- ...pp_controller_desktop_thread_sessions.dart | 12 +- ...app_controller_desktop_thread_storage.dart | 56 ++--- ...ontroller_desktop_workspace_execution.dart | 45 +--- .../assistant_page_state_actions.dart | 6 +- lib/runtime/file_store_support.dart | 12 +- lib/runtime/runtime_controllers_settings.dart | 25 +- .../runtime_controllers_settings_account.dart | 6 - ...ime_controllers_settings_account_impl.dart | 163 +------------ lib/runtime/runtime_models.dart | 1 + lib/runtime/runtime_models_account.dart | 32 --- lib/runtime/runtime_models_connection.dart | 8 +- .../runtime_models_settings_snapshot.dart | 217 +++--------------- lib/runtime/runtime_models_ui_state.dart | 143 ++++++++++++ lib/runtime/secret_store.dart | 12 +- lib/runtime/secure_config_store.dart | 56 +++-- lib/runtime/settings_store.dart | 201 ++++------------ ...ontroller_desktop_thread_binding_test.dart | 6 +- ...t_execution_target_picker_widget_test.dart | 11 +- test/runtime/account_sync_overwrite_test.dart | 181 +++++++++++++++ ...ime_controllers_settings_account_test.dart | 217 +++++++++--------- .../secure_config_store_ui_state_test.dart | 88 +++++++ ...apshot_provider_sync_definitions_test.dart | 85 +++++++ test/runtime/settings_store_v1_test.dart | 65 ++++++ 29 files changed, 1046 insertions(+), 804 deletions(-) create mode 100644 docs/plans/2026-04-11-settings-config-state-workflow-design.md create mode 100644 lib/runtime/runtime_models_ui_state.dart create mode 100644 test/runtime/account_sync_overwrite_test.dart create mode 100644 test/runtime/secure_config_store_ui_state_test.dart create mode 100644 test/runtime/settings_snapshot_provider_sync_definitions_test.dart create mode 100644 test/runtime/settings_store_v1_test.dart diff --git a/docs/plans/2026-04-11-settings-config-state-workflow-design.md b/docs/plans/2026-04-11-settings-config-state-workflow-design.md new file mode 100644 index 00000000..0159ebf6 --- /dev/null +++ b/docs/plans/2026-04-11-settings-config-state-workflow-design.md @@ -0,0 +1,101 @@ +# Settings Config / State / Workflow Redesign + +Status: Implementing V1 + +Date: 2026-04-11 + +Scope: +- `xworkmate-app` +- Settings / account sync / local UI state / task thread persistence + +## V1 Decision + +This worktree implements the first app-side simplification: + +- keep a single persisted config file: `config/settings.yaml` +- move local recoverable UI state to `ui/state.json` +- keep task title/archive in `tasks/*.json` +- make account sync one-way overwrite for sync-owned fields +- keep bridge provider catalog / runtime capabilities runtime-only + +## Overview Workflow + +```mermaid +flowchart TD + UI["Settings UI / App Startup"] --> INIT["SettingsController.initialize()"] + + subgraph LocalStores["APP Local Stores"] + YAML["config/settings.yaml"] + UISTATE["ui/state.json"] + SYNCJSON["account/sync_state.json"] + SECRET["secrets/*.secret\naccount session token / managed secrets"] + TASKS["tasks/*.json\nthread title / archived / thread-owned state"] + end + + INIT --> LOAD["SecureConfigStore.loadSettingsSnapshot()"] + LOAD --> YAML + + INIT --> LOADUI["SecureConfigStore.loadAppUiState()"] + LOADUI --> UISTATE + + INIT --> LOADTHREADS["loadTaskThreads()"] + LOADTHREADS --> TASKS + + INIT --> RESTORE["restoreAccountSession()"] + RESTORE --> TOKEN["loadAccountSessionToken()"] + TOKEN --> SECRET + + TOKEN --> CHECK{"baseUrl + session token ready?"} + CHECK -->|no| BLOCK["blocked\nAccount session is unavailable"] + CHECK -->|yes| SYNC["syncAccountSettingsInternal(baseUrl)"] + + SYNC --> API["AccountRuntimeClient.loadProfile(token)"] + API --> SAVE_SYNC["saveAccountSyncState(nextState)"] + SAVE_SYNC --> SYNCJSON + + API --> MODECFG["saveSnapshot(\naccountLocalMode=false,\nacpBridgeServerModeConfig.cloudSynced=remote summary\n)"] + MODECFG --> YAML + + API --> APPLY["applyAccountSyncedDefaultsSettingsInternal(state)"] + + APPLY --> O1["overwrite remote gateway endpoint"] + APPLY --> O2["overwrite gateway tokenRef"] + APPLY --> O3["overwrite vault address / namespace"] + APPLY --> O4["overwrite aiGateway baseUrl / apiKeyRef"] + APPLY --> O5["overwrite ollamaCloud apiKeyRef"] + APPLY --> O6["update cloudSynced metadata"] + + O1 --> SAVE["saveSnapshot(next settings)"] + O2 --> SAVE + O3 --> SAVE + O4 --> SAVE + O5 --> SAVE + O6 --> SAVE + + SAVE --> YAML + SAVE --> DERIVED["reloadDerivedStateInternal()"] + DERIVED --> VIEW["Settings / Runtime ViewModel"] + + VIEW --> NOTE1["does not auto-connect gateway"] + APPLY -. not touched .-> NOTE2["providerSyncDefinitions\n(sync payload definitions)\nnot overwritten here"] + + UI --> LOCAL_EDIT["local settings edit"] + LOCAL_EDIT --> SAVE_LOCAL["saveSnapshot()"] + SAVE_LOCAL --> YAML + + UI --> UI_EDIT["local ui restore edit"] + UI_EDIT --> SAVE_UI["saveAppUiState()"] + SAVE_UI --> UISTATE + + UI --> THREAD_EDIT["rename / archive / restore thread"] + THREAD_EDIT --> SAVE_THREAD["saveTaskThreads()"] + SAVE_THREAD --> TASKS +``` + +## V1 Boundaries + +- `settings.yaml` only stores current schema V1 config intent and sync-owned local snapshots. +- `ui/state.json` stores `assistantLastSessionKey`, `assistantNavigationDestinations`, and `savedGatewayTargets`. +- `tasks/*.json` stores thread-owned display facts such as `title` and `archived`. +- `account/sync_state.json` stores sync metadata only, not local override policy. +- bridge-advertised providers and ACP capability state stay runtime-only. diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 0af2433b..ff884b21 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -336,6 +336,7 @@ class AppController extends ChangeNotifier { SettingsDetailPage? settingsDetailInternal; SettingsNavigationContext? settingsNavigationContextInternal; DetailPanelData? detailPanelInternal; + AppUiState appUiStateInternal = AppUiState.defaults(); SettingsSnapshot settingsDraftInternal = SettingsSnapshot.defaults(); SettingsSnapshot lastAppliedSettingsInternal = SettingsSnapshot.defaults(); final Map draftSecretValuesInternal = {}; @@ -390,6 +391,8 @@ class AppController extends ChangeNotifier { return resolvedRoots; } + AppUiState get appUiState => appUiStateInternal; + WorkspaceDestination get destination => destinationInternal; UiFeatureManifest get uiFeatureManifest => uiFeatureManifestInternal; AppCapabilities get capabilities => AppCapabilities.fromFeatureAccess( @@ -594,10 +597,20 @@ class AppController extends ChangeNotifier { List visibleAssistantExecutionTargets( Iterable supportedTargets, ) { - final visible = settings.visibleAssistantExecutionTargets( - supportedTargets: supportedTargets, - availableSingleAgentProviders: availableSingleAgentProviders, - ); + final supported = supportedTargets.toSet(); + final visible = []; + if (supported.contains(AssistantExecutionTarget.singleAgent) && + availableSingleAgentProviders.isNotEmpty) { + visible.add(AssistantExecutionTarget.singleAgent); + } + if (supported.contains(AssistantExecutionTarget.local) && + appUiState.isGatewayTargetSaved(AssistantExecutionTarget.local)) { + visible.add(AssistantExecutionTarget.local); + } + if (supported.contains(AssistantExecutionTarget.remote) && + appUiState.isGatewayTargetSaved(AssistantExecutionTarget.remote)) { + visible.add(AssistantExecutionTarget.remote); + } if (!supportedTargets.contains(AssistantExecutionTarget.singleAgent) || visible.contains(AssistantExecutionTarget.singleAgent)) { return visible; diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index be9c1268..34c8bb77 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -384,7 +384,7 @@ Uri? resolveSingleAgentEndpointRuntimeInternal( SingleAgentProvider provider, ) { final endpoint = controller.settings - .externalAcpEndpointForProvider(provider) + .providerSyncDefinitionForProvider(provider) .endpoint .trim(); if (endpoint.isEmpty) { diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index b9f27ded..8bc68df9 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -49,6 +49,17 @@ import 'app_controller_desktop_runtime_exceptions.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member extension AppControllerDesktopRuntimeHelpers on AppController { + Future saveAppUiStateInternal( + AppUiState next, { + bool notify = false, + }) async { + appUiStateInternal = next; + await storeInternal.saveAppUiState(next); + if (notify) { + notifyIfActiveInternal(); + } + } + Future persistAssistantLastSessionKeyInternal(String sessionKey) async { if (disposedInternal) { return; @@ -57,13 +68,12 @@ extension AppControllerDesktopRuntimeHelpers on AppController { sessionKey, ); if (normalizedSessionKey.isEmpty || - settings.assistantLastSessionKey == normalizedSessionKey) { + appUiState.assistantLastSessionKey == normalizedSessionKey) { return; } try { - await AppControllerDesktopSettings(this).saveSettings( - settings.copyWith(assistantLastSessionKey: normalizedSessionKey), - refreshAfterSave: false, + await saveAppUiStateInternal( + appUiState.copyWith(assistantLastSessionKey: normalizedSessionKey), ); } catch (_) { // Best effort only during teardown-sensitive transitions. @@ -653,7 +663,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Uri? resolveSingleAgentEndpointInternal(SingleAgentProvider provider) { final endpoint = settings - .externalAcpEndpointForProvider(provider) + .providerSyncDefinitionForProvider(provider) .endpoint .trim(); if (endpoint.isEmpty) { @@ -685,7 +695,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (normalizedEndpoint == null) { return ''; } - for (final profile in settings.externalAcpEndpoints) { + for (final profile in settings.providerSyncDefinitions) { final profileEndpoint = _normalizeExternalAcpEndpointInternal( profile.endpoint, ); @@ -716,7 +726,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Future> buildExternalAcpSyncedProvidersInternal() async { final providers = []; - for (final profile in settings.externalAcpEndpoints) { + for (final profile in settings.providerSyncDefinitions) { final provider = settings.singleAgentProviderForId(profile.providerKey); if (provider == SingleAgentProvider.auto) { continue; diff --git a/lib/app/app_controller_desktop_settings.dart b/lib/app/app_controller_desktop_settings.dart index a57c70c3..912f7cf7 100644 --- a/lib/app/app_controller_desktop_settings.dart +++ b/lib/app/app_controller_desktop_settings.dart @@ -46,24 +46,24 @@ import 'app_controller_desktop_runtime_helpers.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member extension AppControllerDesktopSettings on AppController { - SettingsSnapshot _markSavedGatewayTargetsForChangedProfiles( + AppUiState _markSavedGatewayTargetsForChangedProfiles( SettingsSnapshot previous, SettingsSnapshot snapshot, ) { - var nextSnapshot = snapshot; + var nextState = appUiState; if (jsonEncode(previous.primaryLocalGatewayProfile.toJson()) != jsonEncode(snapshot.primaryLocalGatewayProfile.toJson())) { - nextSnapshot = nextSnapshot.markGatewayTargetSaved( + nextState = nextState.markGatewayTargetSaved( AssistantExecutionTarget.local, ); } if (jsonEncode(previous.primaryRemoteGatewayProfile.toJson()) != jsonEncode(snapshot.primaryRemoteGatewayProfile.toJson())) { - nextSnapshot = nextSnapshot.markGatewayTargetSaved( + nextState = nextState.markGatewayTargetSaved( AssistantExecutionTarget.remote, ); } - return nextSnapshot; + return nextState; } Future saveSettingsDraft(SettingsSnapshot snapshot) async { @@ -195,10 +195,13 @@ extension AppControllerDesktopSettings on AppController { settings, settingsDraft, ); - markPendingApplyDomainsInternal(settings, nextSettings); + markPendingApplyDomainsInternal(settings, settingsDraft); await persistDraftSecretsInternal(); - if (nextSettings.toJsonString() != settings.toJsonString()) { - await persistSettingsSnapshotInternal(nextSettings); + if (nextSettings.toJsonString() != appUiState.toJsonString()) { + await saveAppUiStateInternal(nextSettings); + } + if (settingsDraft.toJsonString() != settings.toJsonString()) { + await persistSettingsSnapshotInternal(settingsDraft); } settingsDraftInternal = settings; settingsDraftInitializedInternal = true; @@ -262,7 +265,10 @@ extension AppControllerDesktopSettings on AppController { previous, snapshot, ); - await persistSettingsSnapshotInternal(nextSnapshot); + if (nextSnapshot.toJsonString() != appUiState.toJsonString()) { + await saveAppUiStateInternal(nextSnapshot); + } + await persistSettingsSnapshotInternal(snapshot); if (disposedInternal) { return; } @@ -284,8 +290,10 @@ extension AppControllerDesktopSettings on AppController { Future clearAssistantLocalState() async { await flushAssistantThreadPersistenceInternal(); await storeInternal.clearAssistantLocalState(); + await storeInternal.clearAppUiState(); await storeInternal.saveTaskThreads(const []); - final defaults = SettingsSnapshot.defaults(); + final currentSettings = settings; + appUiStateInternal = AppUiState.defaults(); taskThreadRepositoryInternal.clear(); assistantThreadMessagesInternal.clear(); localSessionMessagesInternal.clear(); @@ -297,17 +305,10 @@ extension AppControllerDesktopSettings on AppController { singleAgentExternalCliPendingSessionKeysInternal.clear(); assistantThreadTurnQueuesInternal.clear(); multiAgentRunPendingInternal = false; - setActiveAppLanguage(defaults.appLanguage); - await settingsControllerInternal.saveSnapshot(defaults); - multiAgentOrchestratorInternal.updateConfig(defaults.multiAgent); - agentsControllerInternal.restoreSelection( - defaults.primaryRemoteGatewayProfile.selectedAgentId, - ); - modelsControllerInternal.restoreFromSettings(defaults.aiGateway); initializeAssistantThreadContext( 'main', executionTarget: sanitizePersistedExecutionTargetInternal( - defaults.assistantExecutionTarget, + currentSettings.assistantExecutionTarget, ), messageViewMode: AssistantMessageViewMode.rendered, singleAgentProvider: SingleAgentProvider.auto, diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index fc4b8ab0..9409d575 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -231,9 +231,9 @@ extension AppControllerDesktopSettingsRuntime on AppController { final next = current.contains(destination) ? current.where((item) => item != destination).toList(growable: false) : [...current, destination]; - await AppControllerDesktopSettings(this).saveSettings( - settings.copyWith(assistantNavigationDestinations: next), - refreshAfterSave: false, + await saveAppUiStateInternal( + appUiState.copyWith(assistantNavigationDestinations: next), + notify: true, ); } @@ -301,9 +301,9 @@ extension AppControllerDesktopSettingsRuntime on AppController { ); final temporaryStore = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => + appDataRootPathResolver: () async => '${temporaryRoot.path}/settings.sqlite3', - fallbackDirectoryPathResolver: () async => temporaryRoot.path, + secretRootPathResolver: () async => temporaryRoot.path, ); final runtime = GatewayRuntime( store: temporaryStore, @@ -463,6 +463,13 @@ extension AppControllerDesktopSettingsRuntime on AppController { resolvedUserHomeDirectoryInternal = await skillDirectoryAccessServiceInternal.resolveUserHomeDirectory(); await settingsControllerInternal.initialize(); + final loadedAppUiState = await storeInternal.loadAppUiState(); + final sanitizedAppUiState = sanitizeAppUiStateInternal(loadedAppUiState); + appUiStateInternal = sanitizedAppUiState; + if (sanitizedAppUiState.toJsonString() != + loadedAppUiState.toJsonString()) { + await storeInternal.saveAppUiState(sanitizedAppUiState); + } final storedAssistantThreads = await storeInternal.loadTaskThreads(); final skippedInvalidThreadRecords = storeInternal.lastSkippedInvalidTaskThreadRecords; diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 90078adc..659d8899 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -499,7 +499,7 @@ extension AppControllerDesktopThreadSessions on AppController { List get runtimeLogs => runtimeInternal.logs; List get assistantNavigationDestinations => normalizeAssistantNavigationDestinations( - settings.assistantNavigationDestinations, + appUiState.assistantNavigationDestinations, ).where(supportsAssistantFocusEntry).toList(growable: false); bool supportsAssistantFocusEntry(AssistantFocusEntry entry) { @@ -593,16 +593,13 @@ extension AppControllerDesktopThreadSessions on AppController { } List assistantSessionsInternal() { - final archivedKeys = settings.assistantArchivedTaskKeys - .map(normalizedAssistantSessionKeyInternal) - .toSet(); final byKey = {}; for (final session in sessionsControllerInternal.sessions) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( session.key, ); - if (archivedKeys.contains(normalizedSessionKey)) { + if (isAssistantTaskArchived(normalizedSessionKey)) { continue; } byKey[normalizedSessionKey] = session; @@ -613,7 +610,7 @@ extension AppControllerDesktopThreadSessions on AppController { record.sessionKey, ); if (normalizedSessionKey.isEmpty || - archivedKeys.contains(normalizedSessionKey) || + isAssistantTaskArchived(normalizedSessionKey) || record.archived) { continue; } @@ -627,7 +624,8 @@ extension AppControllerDesktopThreadSessions on AppController { } final currentKey = normalizedAssistantSessionKeyInternal(currentSessionKey); - if (!archivedKeys.contains(currentKey) && !byKey.containsKey(currentKey)) { + if (!isAssistantTaskArchived(currentKey) && + !byKey.containsKey(currentKey)) { byKey[currentKey] = assistantSessionSummaryForInternal(currentKey); } diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index c1104da5..0b1e3035 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -98,7 +98,7 @@ extension AppControllerDesktopThreadStorage on AppController { Future restoreInitialAssistantSessionSelectionInternal() async { final normalized = normalizedAssistantSessionKeyInternal( - settings.assistantLastSessionKey, + appUiState.assistantLastSessionKey, ); final known = normalized == 'main' || @@ -146,20 +146,6 @@ extension AppControllerDesktopThreadStorage on AppController { SettingsSnapshot snapshot, ) { final features = featuresFor(hostUiFeaturePlatformInternal); - final allowedNavigation = - normalizeAssistantNavigationDestinations( - snapshot.assistantNavigationDestinations, - ) - .where((entry) { - final destination = entry.destination; - if (destination != null) { - return features.allowedDestinations.contains(destination); - } - return features.allowedDestinations.contains( - WorkspaceDestination.settings, - ); - }) - .toList(growable: false); final sanitizedExecutionTarget = features.sanitizeExecutionTarget( snapshot.assistantExecutionTarget, ); @@ -186,7 +172,6 @@ extension AppControllerDesktopThreadStorage on AppController { : false; return snapshot.copyWith( assistantExecutionTarget: sanitizedExecutionTarget, - assistantNavigationDestinations: allowedNavigation, multiAgent: multiAgentConfig, experimentalCanvas: experimentalCanvas, experimentalBridge: experimentalBridge, @@ -194,6 +179,25 @@ extension AppControllerDesktopThreadStorage on AppController { ); } + AppUiState sanitizeAppUiStateInternal(AppUiState state) { + final features = featuresFor(hostUiFeaturePlatformInternal); + final allowedNavigation = + normalizeAssistantNavigationDestinations( + state.assistantNavigationDestinations, + ) + .where((entry) { + final destination = entry.destination; + if (destination != null) { + return features.allowedDestinations.contains(destination); + } + return features.allowedDestinations.contains( + WorkspaceDestination.settings, + ); + }) + .toList(growable: false); + return state.copyWith(assistantNavigationDestinations: allowedNavigation); + } + SettingsSnapshot sanitizeOllamaCloudSettingsInternal( SettingsSnapshot snapshot, ) { @@ -267,7 +271,6 @@ extension AppControllerDesktopThreadStorage on AppController { final key = normalizedAssistantSessionKeyInternal(sessionKey); final existingTitle = assistantThreadRecordsInternal[key]?.title.trim() ?? ''; - final customTitle = settings.assistantCustomTaskTitles[key]?.trim() ?? ''; final next = List.from( assistantThreadMessagesInternal[key] ?? const [], )..add(message); @@ -278,7 +281,7 @@ extension AppControllerDesktopThreadStorage on AppController { existingTitle, next, fallback: key, - hasCustomTitle: customTitle.isNotEmpty, + hasCustomTitle: existingTitle.isNotEmpty, ), messages: next, updatedAtMs: @@ -329,16 +332,13 @@ extension AppControllerDesktopThreadStorage on AppController { } List assistantSessionSummariesInternal() { - final archivedKeys = settings.assistantArchivedTaskKeys - .map(normalizedAssistantSessionKeyInternal) - .toSet(); final items = []; for (final record in assistantThreadRecordsInternal.values) { final sessionKey = normalizedAssistantSessionKeyInternal( record.sessionKey, ); - if (archivedKeys.contains(sessionKey) || record.archived) { + if (record.archived) { continue; } items.add(assistantSessionSummaryForInternal(sessionKey, record: record)); @@ -350,7 +350,7 @@ extension AppControllerDesktopThreadStorage on AppController { final hasCurrent = items.any( (item) => matchesSessionKey(item.key, currentSessionKey), ); - if (!hasCurrent && !archivedKeys.contains(currentSessionKey)) { + if (!hasCurrent && !isAssistantTaskArchived(currentSessionKey)) { items.add(assistantSessionSummaryForInternal(currentSessionKey)); } @@ -673,9 +673,6 @@ extension AppControllerDesktopThreadStorage on AppController { singleAgentSharedImportedSkillsInternal = const []; singleAgentLocalSkillsHydratedInternal = false; - final archivedKeys = settings.assistantArchivedTaskKeys - .map(normalizedAssistantSessionKeyInternal) - .toSet(); for (final record in records) { final sessionKey = normalizedAssistantSessionKeyInternal( record.sessionKey, @@ -686,7 +683,6 @@ extension AppControllerDesktopThreadStorage on AppController { if (!record.workspaceBinding.isComplete) { continue; } - final titleFromSettings = assistantCustomTaskTitle(sessionKey); final recordExecutionTarget = assistantExecutionTargetFromExecutionMode( record.executionBinding.executionMode, ); @@ -708,10 +704,8 @@ extension AppControllerDesktopThreadStorage on AppController { ); final normalizedRecord = record.copyWith( threadId: sessionKey, - title: titleFromSettings.isEmpty - ? record.title.trim() - : titleFromSettings, - archived: record.archived || archivedKeys.contains(sessionKey), + title: record.title.trim(), + archived: record.archived, messageViewMode: record.messageViewMode, selectedSkillKeys: record.selectedSkillKeys .where( diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index 67610488..0650b829 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -316,11 +316,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - final settingsTitle = - settings.assistantCustomTaskTitles[normalizedSessionKey]?.trim() ?? ''; - if (settingsTitle.isNotEmpty) { - return settingsTitle; - } return assistantThreadRecordsInternal[normalizedSessionKey]?.title.trim() ?? ''; } @@ -510,23 +505,12 @@ extension AppControllerDesktopWorkspaceExecution on AppController { return; } final normalizedTitle = title.trim(); - final next = Map.from(settings.assistantCustomTaskTitles); - final current = next[normalizedSessionKey]?.trim() ?? ''; - if (normalizedTitle.isEmpty) { - if (current.isEmpty) { - return; - } - next.remove(normalizedSessionKey); - } else { - if (current == normalizedTitle) { - return; - } - next[normalizedSessionKey] = normalizedTitle; + final current = + assistantThreadRecordsInternal[normalizedSessionKey]?.title.trim() ?? + ''; + if (current == normalizedTitle) { + return; } - await AppControllerDesktopSettings(this).saveSettings( - settings.copyWith(assistantCustomTaskTitles: next), - refreshAfterSave: false, - ); upsertTaskThreadInternal( normalizedSessionKey, title: normalizedTitle, @@ -540,10 +524,8 @@ extension AppControllerDesktopWorkspaceExecution on AppController { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); - return settings.assistantArchivedTaskKeys.any( - (item) => - normalizedAssistantSessionKeyInternal(item) == normalizedSessionKey, - ); + return assistantThreadRecordsInternal[normalizedSessionKey]?.archived ?? + false; } Future saveAssistantTaskArchived( @@ -556,19 +538,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController { if (normalizedSessionKey.isEmpty) { return; } - final next = [ - ...settings.assistantArchivedTaskKeys.where( - (item) => - normalizedAssistantSessionKeyInternal(item) != normalizedSessionKey, - ), - ]; - if (archived) { - next.add(normalizedSessionKey); - } - await AppControllerDesktopSettings(this).saveSettings( - settings.copyWith(assistantArchivedTaskKeys: next), - refreshAfterSave: false, - ); if (archived) { unawaited( enqueueThreadTurnInternal(normalizedSessionKey, () async { diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 37fa7150..0626f2f0 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -485,7 +485,11 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { ) { archivedTaskKeysInternal ..clear() - ..addAll(controller.settings.assistantArchivedTaskKeys); + ..addAll( + controller.assistantThreadRecordsInternal.values + .where((item) => item.archived) + .map((item) => item.sessionKey), + ); synchronizeTaskSeedsInternal(controller); final entries = taskSeedsInternal.values diff --git a/lib/runtime/file_store_support.dart b/lib/runtime/file_store_support.dart index 8fed8676..b8a02d02 100644 --- a/lib/runtime/file_store_support.dart +++ b/lib/runtime/file_store_support.dart @@ -121,14 +121,14 @@ class StoreLayout { class StoreLayoutResolver { StoreLayoutResolver({ - Future Function()? localRootPathResolver, + Future Function()? appDataRootPathResolver, Future Function()? secretRootPathResolver, Future Function()? supportRootPathResolver, - }) : _localRootPathResolver = localRootPathResolver, + }) : _appDataRootPathResolver = appDataRootPathResolver, _secretRootPathResolver = secretRootPathResolver, _supportRootPathResolver = supportRootPathResolver; - final Future Function()? _localRootPathResolver; + final Future Function()? _appDataRootPathResolver; final Future Function()? _secretRootPathResolver; final Future Function()? _supportRootPathResolver; @@ -145,13 +145,13 @@ class StoreLayoutResolver { if (supportRootPath == null) { throw StateError('Cannot resolve persistent storage root.'); } - final localRootPath = - await _resolvePath(_localRootPathResolver) ?? supportRootPath; + final appDataRootPath = + await _resolvePath(_appDataRootPathResolver) ?? supportRootPath; final secretRootPath = await _resolvePath(_secretRootPathResolver) ?? '$supportRootPath/secrets'; final rootDirectory = await ensureDirectory( - normalizeStoreDirectoryPath(localRootPath), + normalizeStoreDirectoryPath(appDataRootPath), ); final configDirectory = await ensureDirectory( '${rootDirectory.path}/config', diff --git a/lib/runtime/runtime_controllers_settings.dart b/lib/runtime/runtime_controllers_settings.dart index 99687037..34a7f93e 100644 --- a/lib/runtime/runtime_controllers_settings.dart +++ b/lib/runtime/runtime_controllers_settings.dart @@ -90,21 +90,10 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } - Future saveSnapshot( - SettingsSnapshot snapshot, { - bool recordAccountOverrides = true, - }) async { - final previousSnapshot = snapshotInternal; + Future saveSnapshot(SettingsSnapshot snapshot) async { snapshotInternal = snapshot; lastSnapshotJsonInternal = snapshotInternal.toJsonString(); await storeInternal.saveSettingsSnapshot(snapshot); - if (recordAccountOverrides) { - await recordAccountOverridesForSnapshotChangeSettingsInternal( - this, - previous: previousSnapshot, - current: snapshotInternal, - ); - } await refreshSettingsFileStampInternal(); await reloadDerivedStateInternal(); notifyListeners(); @@ -528,8 +517,8 @@ class SettingsController extends ChangeNotifier { await subscription.cancel(); } settingsWatchSubscriptionsInternal.clear(); - final files = await storeInternal.resolvedSettingsFiles(); - final directories = await storeInternal.resolvedSettingsWatchDirectories(); + final file = await storeInternal.resolvedSettingsFile(); + final directory = await storeInternal.resolvedSettingsWatchDirectory(); void scheduleReload() { settingsReloadDebounceInternal?.cancel(); settingsReloadDebounceInternal = Timer( @@ -538,7 +527,7 @@ class SettingsController extends ChangeNotifier { ); } - for (final file in files) { + if (file != null) { try { if (await file.exists()) { settingsWatchSubscriptionsInternal.add( @@ -551,7 +540,7 @@ class SettingsController extends ChangeNotifier { // Best effort only. Directory watch below remains as a fallback. } } - for (final directory in directories) { + if (directory != null) { try { if (!await directory.exists()) { await directory.create(recursive: true); @@ -628,9 +617,9 @@ class SettingsController extends ChangeNotifier { } Future computeSettingsFileStampInternal() async { - final files = await storeInternal.resolvedSettingsFiles(); final buffer = StringBuffer(); - for (final file in files) { + final file = await storeInternal.resolvedSettingsFile(); + if (file != null) { buffer.write(file.path); if (await file.exists()) { final stat = await file.stat(); diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 4c6fba62..35b561fb 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -103,12 +103,6 @@ extension SettingsControllerAccountExtension on SettingsController { Future cancelAccountMfaChallenge() => cancelAccountMfaChallengeSettingsInternal(this); - Future markAccountOverride(String fieldKey) => - markAccountOverrideSettingsInternal(this, fieldKey: fieldKey); - - Future clearAccountOverride(String fieldKey) => - clearAccountOverrideSettingsInternal(this, fieldKey: fieldKey); - List buildSecretReferences() { final entries = [ ...secureRefsInternal.entries.map( diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index b31f3031..07e371c3 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -292,7 +292,6 @@ Future syncAccountSettingsInternal( accountLocalMode: false, acpBridgeServerModeConfig: nextModeConfig, ), - recordAccountOverrides: false, ); } await applyAccountSyncedDefaultsSettingsInternal( @@ -361,13 +360,7 @@ Future applyAccountSyncedDefaultsSettingsInternal( final previous = controller.snapshotInternal; var next = previous; final defaults = state.syncedDefaults; - final overrideFlags = state.overrideFlags; - - if (_isOverrideDisabled( - overrideFlags, - kAccountOverrideGatewayRemoteEndpoint, - ) && - defaults.openclawUrl.trim().isNotEmpty) { + if (defaults.openclawUrl.trim().isNotEmpty) { final remoteProfile = previous.gatewayProfiles[kGatewayRemoteProfileIndex]; final normalized = normalizeGatewayManualEndpointInternal( host: defaults.openclawUrl, @@ -392,33 +385,25 @@ Future applyAccountSyncedDefaultsSettingsInternal( ); if (gatewayTokenLocator != null) { final remoteProfile = next.gatewayProfiles[kGatewayRemoteProfileIndex]; - final currentTokenRef = remoteProfile.tokenRef.trim(); - final defaultRemoteTokenRef = - GatewayConnectionProfile.defaultsRemote().tokenRef; - if (currentTokenRef.isEmpty || currentTokenRef == defaultRemoteTokenRef) { - next = next.copyWithGatewayProfileAt( - kGatewayRemoteProfileIndex, - remoteProfile.copyWith(tokenRef: gatewayTokenLocator.target), - ); - } + next = next.copyWithGatewayProfileAt( + kGatewayRemoteProfileIndex, + remoteProfile.copyWith(tokenRef: gatewayTokenLocator.target), + ); } - if (_isOverrideDisabled(overrideFlags, kAccountOverrideVaultAddress) && - defaults.vaultUrl.trim().isNotEmpty) { + if (defaults.vaultUrl.trim().isNotEmpty) { next = next.copyWith( vault: next.vault.copyWith(address: defaults.vaultUrl.trim()), ); } - if (_isOverrideDisabled(overrideFlags, kAccountOverrideVaultNamespace) && - defaults.vaultNamespace.trim().isNotEmpty) { + if (defaults.vaultNamespace.trim().isNotEmpty) { next = next.copyWith( vault: next.vault.copyWith(namespace: defaults.vaultNamespace.trim()), ); } - if (_isOverrideDisabled(overrideFlags, kAccountOverrideAiGatewayBaseUrl) && - defaults.apisixUrl.trim().isNotEmpty) { + if (defaults.apisixUrl.trim().isNotEmpty) { next = next.copyWith( aiGateway: next.aiGateway.copyWith(baseUrl: defaults.apisixUrl.trim()), ); @@ -427,8 +412,7 @@ Future applyAccountSyncedDefaultsSettingsInternal( final aiGatewayLocator = defaults.locatorForTarget( kAccountManagedSecretTargetAIGatewayAccessToken, ); - if (_isOverrideDisabled(overrideFlags, kAccountOverrideAiGatewayApiKeyRef) && - aiGatewayLocator != null) { + if (aiGatewayLocator != null) { next = next.copyWith( aiGateway: next.aiGateway.copyWith(apiKeyRef: aiGatewayLocator.target), ); @@ -437,11 +421,7 @@ Future applyAccountSyncedDefaultsSettingsInternal( final ollamaLocator = defaults.locatorForTarget( kAccountManagedSecretTargetOllamaCloudApiKey, ); - if (_isOverrideDisabled( - overrideFlags, - kAccountOverrideOllamaCloudApiKeyRef, - ) && - ollamaLocator != null) { + if (ollamaLocator != null) { next = next.copyWith( ollamaCloud: next.ollamaCloud.copyWith(apiKeyRef: ollamaLocator.target), ); @@ -471,7 +451,7 @@ Future applyAccountSyncedDefaultsSettingsInternal( ); if (next.toJsonString() != previous.toJsonString()) { - await controller.saveSnapshot(next, recordAccountOverrides: false); + await controller.saveSnapshot(next); } } @@ -511,7 +491,6 @@ Future logoutAccountSettingsInternal( acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig .copyWith(cloudSynced: clearedCloudSync), ), - recordAccountOverrides: false, ); } else { controller.snapshotInternal = currentSnapshot.copyWith( @@ -551,126 +530,6 @@ String normalizeAccountBaseUrlSettingsInternal( : candidate; } -Future markAccountOverrideSettingsInternal( - SettingsController controller, { - required String fieldKey, -}) async { - if (!kAccountOverrideFieldKeys.contains(fieldKey)) { - return; - } - final current = await controller.storeInternal.loadAccountSyncState(); - if (current == null) { - return; - } - if (current.overrideFlags[fieldKey] == true) { - return; - } - final nextFlags = Map.from(current.overrideFlags) - ..[fieldKey] = true; - await controller.storeInternal.saveAccountSyncState( - current.copyWith(overrideFlags: nextFlags), - ); - await controller.reloadDerivedStateInternal(); - controller.notifyListeners(); -} - -Future clearAccountOverrideSettingsInternal( - SettingsController controller, { - required String fieldKey, -}) async { - if (!kAccountOverrideFieldKeys.contains(fieldKey)) { - return; - } - final current = await controller.storeInternal.loadAccountSyncState(); - if (current == null || current.overrideFlags[fieldKey] != true) { - return; - } - final nextFlags = Map.from(current.overrideFlags) - ..remove(fieldKey); - await controller.storeInternal.saveAccountSyncState( - current.copyWith(overrideFlags: nextFlags), - ); - await controller.reloadDerivedStateInternal(); - controller.notifyListeners(); -} - -Future recordAccountOverridesForSnapshotChangeSettingsInternal( - SettingsController controller, { - required SettingsSnapshot previous, - required SettingsSnapshot current, -}) async { - final syncState = await controller.storeInternal.loadAccountSyncState(); - if (syncState == null) { - return; - } - - final nextFlags = Map.from(syncState.overrideFlags); - var changed = false; - - if (_remoteGatewayEndpointChanged(previous, current)) { - changed = - _markOverrideFlag(nextFlags, kAccountOverrideGatewayRemoteEndpoint) || - changed; - } - if (previous.vault.address != current.vault.address) { - changed = - _markOverrideFlag(nextFlags, kAccountOverrideVaultAddress) || changed; - } - if (previous.vault.namespace != current.vault.namespace) { - changed = - _markOverrideFlag(nextFlags, kAccountOverrideVaultNamespace) || changed; - } - if (previous.aiGateway.baseUrl != current.aiGateway.baseUrl) { - changed = - _markOverrideFlag(nextFlags, kAccountOverrideAiGatewayBaseUrl) || - changed; - } - if (previous.aiGateway.apiKeyRef != current.aiGateway.apiKeyRef) { - changed = - _markOverrideFlag(nextFlags, kAccountOverrideAiGatewayApiKeyRef) || - changed; - } - if (previous.ollamaCloud.apiKeyRef != current.ollamaCloud.apiKeyRef) { - changed = - _markOverrideFlag(nextFlags, kAccountOverrideOllamaCloudApiKeyRef) || - changed; - } - - if (!changed) { - return; - } - - await controller.storeInternal.saveAccountSyncState( - syncState.copyWith(overrideFlags: nextFlags), - ); -} - -bool _isOverrideDisabled(Map flags, String fieldKey) { - return flags[fieldKey] != true; -} - -bool _markOverrideFlag(Map flags, String fieldKey) { - if (flags[fieldKey] == true) { - return false; - } - flags[fieldKey] = true; - return true; -} - -bool _remoteGatewayEndpointChanged( - SettingsSnapshot previous, - SettingsSnapshot current, -) { - final previousProfile = previous.gatewayProfiles[kGatewayRemoteProfileIndex]; - final currentProfile = current.gatewayProfiles[kGatewayRemoteProfileIndex]; - return previousProfile.mode != currentProfile.mode || - previousProfile.useSetupCode != currentProfile.useSetupCode || - previousProfile.setupCode != currentProfile.setupCode || - previousProfile.host != currentProfile.host || - previousProfile.port != currentProfile.port || - previousProfile.tls != currentProfile.tls; -} - int _parseExpiresAtMs(Object? value) { if (value is int) { return value; diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index fb692dc4..07863e86 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -3,6 +3,7 @@ export 'runtime_models_profiles.dart'; export 'runtime_models_configs.dart'; export 'runtime_models_account.dart'; export 'runtime_models_settings_snapshot.dart'; +export 'runtime_models_ui_state.dart'; export 'runtime_models_runtime_payloads.dart'; export 'runtime_models_gateway_entities.dart'; export 'runtime_models_multi_agent.dart'; diff --git a/lib/runtime/runtime_models_account.dart b/lib/runtime/runtime_models_account.dart index 212f117a..9853c674 100644 --- a/lib/runtime/runtime_models_account.dart +++ b/lib/runtime/runtime_models_account.dart @@ -596,7 +596,6 @@ class AccountProfileResponse { class AccountSyncState { const AccountSyncState({ required this.syncedDefaults, - required this.overrideFlags, required this.syncState, required this.syncMessage, required this.lastSyncAtMs, @@ -607,7 +606,6 @@ class AccountSyncState { }); final AccountRemoteProfile syncedDefaults; - final Map overrideFlags; final String syncState; final String syncMessage; final int lastSyncAtMs; @@ -619,7 +617,6 @@ class AccountSyncState { factory AccountSyncState.defaults() { return AccountSyncState( syncedDefaults: AccountRemoteProfile.defaults(), - overrideFlags: const {}, syncState: 'idle', syncMessage: 'Remote config not synced yet', lastSyncAtMs: 0, @@ -632,7 +629,6 @@ class AccountSyncState { AccountSyncState copyWith({ AccountRemoteProfile? syncedDefaults, - Map? overrideFlags, String? syncState, String? syncMessage, int? lastSyncAtMs, @@ -643,7 +639,6 @@ class AccountSyncState { }) { return AccountSyncState( syncedDefaults: syncedDefaults ?? this.syncedDefaults, - overrideFlags: overrideFlags ?? this.overrideFlags, syncState: syncState ?? this.syncState, syncMessage: syncMessage ?? this.syncMessage, lastSyncAtMs: lastSyncAtMs ?? this.lastSyncAtMs, @@ -657,7 +652,6 @@ class AccountSyncState { Map toJson() { return { 'syncedDefaults': syncedDefaults.toJson(), - 'overrideFlags': overrideFlags, 'syncState': syncState, 'syncMessage': syncMessage, 'lastSyncAtMs': lastSyncAtMs, @@ -669,21 +663,11 @@ class AccountSyncState { } factory AccountSyncState.fromJson(Map json) { - Map decodeOverrideFlags(Object? value) { - if (value is! Map) { - return const {}; - } - return value.map((key, entry) { - return MapEntry(key.toString(), entry == true); - }); - } - return AccountSyncState( syncedDefaults: AccountRemoteProfile.fromJson( (json['syncedDefaults'] as Map?)?.cast() ?? const {}, ), - overrideFlags: decodeOverrideFlags(json['overrideFlags']), syncState: json['syncState'] as String? ?? 'idle', syncMessage: json['syncMessage'] as String? ?? 'Remote config not synced yet', @@ -721,19 +705,3 @@ const List kAccountManagedSecretTargets = [ bool isSupportedAccountManagedSecretTarget(String target) { return kAccountManagedSecretTargets.contains(target.trim()); } - -const String kAccountOverrideGatewayRemoteEndpoint = 'gateway.remote.endpoint'; -const String kAccountOverrideVaultAddress = 'vault.address'; -const String kAccountOverrideVaultNamespace = 'vault.namespace'; -const String kAccountOverrideAiGatewayBaseUrl = 'aiGateway.baseUrl'; -const String kAccountOverrideAiGatewayApiKeyRef = 'aiGateway.apiKeyRef'; -const String kAccountOverrideOllamaCloudApiKeyRef = 'ollamaCloud.apiKeyRef'; - -const List kAccountOverrideFieldKeys = [ - kAccountOverrideGatewayRemoteEndpoint, - kAccountOverrideVaultAddress, - kAccountOverrideVaultNamespace, - kAccountOverrideAiGatewayBaseUrl, - kAccountOverrideAiGatewayApiKeyRef, - kAccountOverrideOllamaCloudApiKeyRef, -]; diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index e0314409..f63321aa 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -347,7 +347,11 @@ List normalizeSingleAgentProviderList( } const List kPresetExternalAcpProviders = - [SingleAgentProvider.opencode]; + [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + SingleAgentProvider.gemini, + ]; const List kKnownSingleAgentProviders = [ @@ -357,4 +361,4 @@ const List kKnownSingleAgentProviders = SingleAgentProvider.gemini, ]; -const Set kLegacyExternalAcpProviderIds = {'claude', 'gemini'}; +const Set kLegacyExternalAcpProviderIds = {'claude'}; diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart index 731a9363..952da557 100644 --- a/lib/runtime/runtime_models_settings_snapshot.dart +++ b/lib/runtime/runtime_models_settings_snapshot.dart @@ -11,8 +11,11 @@ import 'runtime_models_runtime_payloads.dart'; import 'runtime_models_gateway_entities.dart'; import 'runtime_models_multi_agent.dart'; +const int settingsSnapshotSchemaVersion = 1; + class SettingsSnapshot { const SettingsSnapshot({ + required this.schemaVersion, required this.appLanguage, required this.appActive, required this.launchAtLogin, @@ -25,7 +28,7 @@ class SettingsSnapshot { required this.defaultModel, required this.defaultProvider, required this.gatewayProfiles, - required this.externalAcpEndpoints, + required this.providerSyncDefinitions, required this.authorizedSkillDirectories, required this.ollamaLocal, required this.ollamaCloud, @@ -45,13 +48,9 @@ class SettingsSnapshot { required this.linuxDesktop, required this.assistantExecutionTarget, required this.assistantPermissionLevel, - required this.assistantNavigationDestinations, - required this.assistantCustomTaskTitles, - required this.assistantArchivedTaskKeys, - required this.savedGatewayTargets, - required this.assistantLastSessionKey, }); + final int schemaVersion; final AppLanguage appLanguage; final bool appActive; final bool launchAtLogin; @@ -64,7 +63,7 @@ class SettingsSnapshot { final String defaultModel; final String defaultProvider; final List gatewayProfiles; - final List externalAcpEndpoints; + final List providerSyncDefinitions; final List authorizedSkillDirectories; final OllamaLocalConfig ollamaLocal; final OllamaCloudConfig ollamaCloud; @@ -84,14 +83,10 @@ class SettingsSnapshot { final LinuxDesktopConfig linuxDesktop; final AssistantExecutionTarget assistantExecutionTarget; final AssistantPermissionLevel assistantPermissionLevel; - final List assistantNavigationDestinations; - final Map assistantCustomTaskTitles; - final List assistantArchivedTaskKeys; - final List savedGatewayTargets; - final String assistantLastSessionKey; factory SettingsSnapshot.defaults() { return SettingsSnapshot( + schemaVersion: settingsSnapshotSchemaVersion, appLanguage: AppLanguage.zh, appActive: true, launchAtLogin: false, @@ -104,7 +99,7 @@ class SettingsSnapshot { defaultModel: '', defaultProvider: 'gateway', gatewayProfiles: normalizeGatewayProfiles(), - externalAcpEndpoints: normalizeExternalAcpEndpoints(), + providerSyncDefinitions: normalizeExternalAcpEndpoints(), authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(), ollamaLocal: OllamaLocalConfig.defaults(), ollamaCloud: OllamaCloudConfig.defaults(), @@ -124,15 +119,11 @@ class SettingsSnapshot { linuxDesktop: LinuxDesktopConfig.defaults(), assistantExecutionTarget: AssistantExecutionTarget.singleAgent, assistantPermissionLevel: AssistantPermissionLevel.defaultAccess, - assistantNavigationDestinations: kAssistantNavigationDestinationDefaults, - assistantCustomTaskTitles: const {}, - assistantArchivedTaskKeys: const [], - savedGatewayTargets: const [], - assistantLastSessionKey: '', ); } SettingsSnapshot copyWith({ + int? schemaVersion, AppLanguage? appLanguage, bool? appActive, bool? launchAtLogin, @@ -145,7 +136,7 @@ class SettingsSnapshot { String? defaultModel, String? defaultProvider, List? gatewayProfiles, - List? externalAcpEndpoints, + List? providerSyncDefinitions, List? authorizedSkillDirectories, OllamaLocalConfig? ollamaLocal, OllamaCloudConfig? ollamaCloud, @@ -165,18 +156,13 @@ class SettingsSnapshot { LinuxDesktopConfig? linuxDesktop, AssistantExecutionTarget? assistantExecutionTarget, AssistantPermissionLevel? assistantPermissionLevel, - List? assistantNavigationDestinations, - Map? assistantCustomTaskTitles, - List? assistantArchivedTaskKeys, - List? savedGatewayTargets, - String? assistantLastSessionKey, }) { final resolvedGatewayProfiles = gatewayProfiles != null ? normalizeGatewayProfiles(profiles: gatewayProfiles) : this.gatewayProfiles; - final resolvedExternalAcpEndpoints = externalAcpEndpoints != null - ? normalizeExternalAcpEndpoints(profiles: externalAcpEndpoints) - : this.externalAcpEndpoints; + final resolvedProviderSyncDefinitions = providerSyncDefinitions != null + ? normalizeExternalAcpEndpoints(profiles: providerSyncDefinitions) + : this.providerSyncDefinitions; final resolvedAuthorizedSkillDirectories = authorizedSkillDirectories != null ? normalizeAuthorizedSkillDirectories( @@ -184,6 +170,7 @@ class SettingsSnapshot { ) : this.authorizedSkillDirectories; return SettingsSnapshot( + schemaVersion: schemaVersion ?? this.schemaVersion, appLanguage: appLanguage ?? this.appLanguage, appActive: appActive ?? this.appActive, launchAtLogin: launchAtLogin ?? this.launchAtLogin, @@ -196,7 +183,7 @@ class SettingsSnapshot { defaultModel: defaultModel ?? this.defaultModel, defaultProvider: defaultProvider ?? this.defaultProvider, gatewayProfiles: resolvedGatewayProfiles, - externalAcpEndpoints: resolvedExternalAcpEndpoints, + providerSyncDefinitions: resolvedProviderSyncDefinitions, authorizedSkillDirectories: resolvedAuthorizedSkillDirectories, ollamaLocal: ollamaLocal ?? this.ollamaLocal, ollamaCloud: ollamaCloud ?? this.ollamaCloud, @@ -221,23 +208,12 @@ class SettingsSnapshot { assistantExecutionTarget ?? this.assistantExecutionTarget, assistantPermissionLevel: assistantPermissionLevel ?? this.assistantPermissionLevel, - assistantNavigationDestinations: - assistantNavigationDestinations ?? - this.assistantNavigationDestinations, - assistantCustomTaskTitles: - assistantCustomTaskTitles ?? this.assistantCustomTaskTitles, - assistantArchivedTaskKeys: - assistantArchivedTaskKeys ?? this.assistantArchivedTaskKeys, - savedGatewayTargets: normalizeSavedGatewayTargets( - savedGatewayTargets ?? this.savedGatewayTargets, - ), - assistantLastSessionKey: - assistantLastSessionKey ?? this.assistantLastSessionKey, ); } Map toJson() { return { + 'schemaVersion': schemaVersion, 'appLanguage': appLanguage.name, 'appActive': appActive, 'launchAtLogin': launchAtLogin, @@ -252,7 +228,7 @@ class SettingsSnapshot { 'gatewayProfiles': gatewayProfiles .map((item) => item.toJson()) .toList(growable: false), - 'externalAcpEndpoints': externalAcpEndpoints + 'providerSyncDefinitions': providerSyncDefinitions .map((item) => item.toJson()) .toList(growable: false), 'authorizedSkillDirectories': authorizedSkillDirectories @@ -276,71 +252,16 @@ class SettingsSnapshot { 'linuxDesktop': linuxDesktop.toJson(), 'assistantExecutionTarget': assistantExecutionTarget.name, 'assistantPermissionLevel': assistantPermissionLevel.name, - 'assistantNavigationDestinations': assistantNavigationDestinations - .map((item) => item.name) - .toList(growable: false), - 'assistantCustomTaskTitles': assistantCustomTaskTitles, - 'assistantArchivedTaskKeys': assistantArchivedTaskKeys, - 'savedGatewayTargets': savedGatewayTargets, - 'assistantLastSessionKey': assistantLastSessionKey, }; } factory SettingsSnapshot.fromJson(Map json) { - Map normalizeTaskTitles(Object? value) { - if (value is! Map) { - return const {}; - } - final normalized = {}; - value.forEach((key, title) { - final normalizedKey = key.toString().trim(); - final normalizedTitle = title.toString().trim(); - if (normalizedKey.isEmpty || normalizedTitle.isEmpty) { - return; - } - normalized[normalizedKey] = normalizedTitle; - }); - return normalized; - } - - List normalizeTaskKeys(Object? value) { - if (value is! List) { - return const []; - } - final normalized = []; - final seen = {}; - for (final item in value) { - final normalizedKey = item?.toString().trim() ?? ''; - if (normalizedKey.isEmpty || !seen.add(normalizedKey)) { - continue; - } - normalized.add(normalizedKey); - } - return normalized; - } - - List normalizeSavedGatewayTargetsFromJson(Object? value) { - if (value is! List) { - return const []; - } - return normalizeSavedGatewayTargets( - value.map((item) => item?.toString() ?? ''), + final parsedSchemaVersion = (json['schemaVersion'] as num?)?.toInt() ?? -1; + if (parsedSchemaVersion != settingsSnapshotSchemaVersion) { + throw const FormatException( + 'Unsupported settings snapshot schema version.', ); } - - final rawAssistantNavigationDestinations = - json['assistantNavigationDestinations']; - final assistantNavigationDestinations = - rawAssistantNavigationDestinations is List - ? normalizeAssistantNavigationDestinations( - rawAssistantNavigationDestinations - .map( - (item) => - AssistantFocusEntryCopy.fromJsonValue(item?.toString()), - ) - .whereType(), - ) - : kAssistantNavigationDestinationDefaults; final gatewayProfiles = normalizeGatewayProfiles( profiles: ((json['gatewayProfiles'] as List?) ?? const []) .whereType() @@ -349,8 +270,8 @@ class SettingsSnapshot { GatewayConnectionProfile.fromJson(item.cast()), ), ); - final externalAcpEndpoints = normalizeExternalAcpEndpoints( - profiles: ((json['externalAcpEndpoints'] as List?) ?? const []) + final providerSyncDefinitions = normalizeExternalAcpEndpoints( + profiles: ((json['providerSyncDefinitions'] as List?) ?? const []) .whereType() .map( (item) => ExternalAcpEndpointProfile.fromJson( @@ -369,6 +290,7 @@ class SettingsSnapshot { ), ); return SettingsSnapshot( + schemaVersion: parsedSchemaVersion, appLanguage: AppLanguageCopy.fromJsonValue( json['appLanguage'] as String?, ), @@ -396,7 +318,7 @@ class SettingsSnapshot { json['defaultProvider'] as String? ?? SettingsSnapshot.defaults().defaultProvider, gatewayProfiles: gatewayProfiles, - externalAcpEndpoints: externalAcpEndpoints, + providerSyncDefinitions: providerSyncDefinitions, authorizedSkillDirectories: authorizedSkillDirectories, ollamaLocal: OllamaLocalConfig.fromJson( (json['ollamaLocal'] as Map?)?.cast() ?? const {}, @@ -445,17 +367,6 @@ class SettingsSnapshot { assistantPermissionLevel: AssistantPermissionLevelCopy.fromJsonValue( json['assistantPermissionLevel'] as String?, ), - assistantNavigationDestinations: assistantNavigationDestinations, - assistantCustomTaskTitles: normalizeTaskTitles( - json['assistantCustomTaskTitles'], - ), - assistantArchivedTaskKeys: normalizeTaskKeys( - json['assistantArchivedTaskKeys'], - ), - savedGatewayTargets: normalizeSavedGatewayTargetsFromJson( - json['savedGatewayTargets'], - ), - assistantLastSessionKey: json['assistantLastSessionKey'] as String? ?? '', ); } @@ -513,21 +424,21 @@ class SettingsSnapshot { return copyWithGatewayProfileAt(index, profile); } - ExternalAcpEndpointProfile externalAcpEndpointForProvider( + ExternalAcpEndpointProfile providerSyncDefinitionForProvider( SingleAgentProvider provider, ) { - return externalAcpEndpointForProviderId(provider.providerId) ?? + return providerSyncDefinitionForProviderId(provider.providerId) ?? ExternalAcpEndpointProfile.defaultsForProvider(provider); } - ExternalAcpEndpointProfile? externalAcpEndpointForProviderId( + ExternalAcpEndpointProfile? providerSyncDefinitionForProviderId( String providerId, ) { final normalized = normalizeSingleAgentProviderId(providerId); if (normalized.isEmpty) { return null; } - for (final item in externalAcpEndpoints) { + for (final item in providerSyncDefinitions) { if (item.providerKey == normalized) { return item; } @@ -539,7 +450,7 @@ class SettingsSnapshot { if (provider.isAuto) { return SingleAgentProvider.auto; } - final profile = externalAcpEndpointForProviderId(provider.providerId); + final profile = providerSyncDefinitionForProviderId(provider.providerId); if (profile != null) { return profile.toProvider(); } @@ -552,7 +463,7 @@ class SettingsSnapshot { return SingleAgentProvider.auto; } final normalizedSelection = SingleAgentProvider.fromJsonValue(resolved); - final profile = externalAcpEndpointForProviderId( + final profile = providerSyncDefinitionForProviderId( normalizedSelection.providerId, ); if (profile != null) { @@ -576,57 +487,13 @@ class SettingsSnapshot { return resolved; } - bool isGatewayTargetSaved(AssistantExecutionTarget target) { - final targetKey = switch (target) { - AssistantExecutionTarget.local => 'local', - AssistantExecutionTarget.remote => 'remote', - _ => '', - }; - return targetKey.isNotEmpty && savedGatewayTargets.contains(targetKey); - } - - SettingsSnapshot markGatewayTargetSaved(AssistantExecutionTarget target) { - final targetKey = switch (target) { - AssistantExecutionTarget.local => 'local', - AssistantExecutionTarget.remote => 'remote', - _ => '', - }; - if (targetKey.isEmpty || savedGatewayTargets.contains(targetKey)) { - return this; - } - return copyWith( - savedGatewayTargets: [...savedGatewayTargets, targetKey], - ); - } - - List visibleAssistantExecutionTargets({ - required Iterable supportedTargets, - required Iterable availableSingleAgentProviders, - }) { - final supported = supportedTargets.toSet(); - final visible = []; - if (supported.contains(AssistantExecutionTarget.singleAgent) && - availableSingleAgentProviders.isNotEmpty) { - visible.add(AssistantExecutionTarget.singleAgent); - } - if (supported.contains(AssistantExecutionTarget.local) && - isGatewayTargetSaved(AssistantExecutionTarget.local)) { - visible.add(AssistantExecutionTarget.local); - } - if (supported.contains(AssistantExecutionTarget.remote) && - isGatewayTargetSaved(AssistantExecutionTarget.remote)) { - visible.add(AssistantExecutionTarget.remote); - } - return List.unmodifiable(visible); - } - - SettingsSnapshot copyWithExternalAcpEndpointForProvider( + SettingsSnapshot copyWithProviderSyncDefinitionForProvider( SingleAgentProvider provider, ExternalAcpEndpointProfile profile, ) { return copyWith( - externalAcpEndpoints: replaceExternalAcpEndpointForProvider( - externalAcpEndpoints, + providerSyncDefinitions: replaceExternalAcpEndpointForProvider( + providerSyncDefinitions, provider, profile, ), @@ -640,24 +507,10 @@ class SettingsSnapshot { gatewayProfiles: gatewayProfiles, vault: vault, aiGateway: aiGateway, - acpBridgeServerProfiles: externalAcpEndpoints, + acpBridgeServerProfiles: providerSyncDefinitions, authorizedSkillDirectories: authorizedSkillDirectories, ), ), ); } } - -List normalizeSavedGatewayTargets(Iterable rawTargets) { - final normalized = []; - final seen = {}; - for (final item in rawTargets) { - final normalizedTarget = item.trim().toLowerCase(); - if ((normalizedTarget != 'local' && normalizedTarget != 'remote') || - !seen.add(normalizedTarget)) { - continue; - } - normalized.add(normalizedTarget); - } - return List.unmodifiable(normalized); -} diff --git a/lib/runtime/runtime_models_ui_state.dart b/lib/runtime/runtime_models_ui_state.dart new file mode 100644 index 00000000..91cc41fd --- /dev/null +++ b/lib/runtime/runtime_models_ui_state.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import '../models/app_models.dart'; +import 'runtime_models_connection.dart'; + +const int appUiStateSchemaVersion = 1; + +class AppUiState { + const AppUiState({ + required this.schemaVersion, + required this.assistantLastSessionKey, + required this.assistantNavigationDestinations, + required this.savedGatewayTargets, + }); + + final int schemaVersion; + final String assistantLastSessionKey; + final List assistantNavigationDestinations; + final List savedGatewayTargets; + + factory AppUiState.defaults() { + return const AppUiState( + schemaVersion: appUiStateSchemaVersion, + assistantLastSessionKey: '', + assistantNavigationDestinations: kAssistantNavigationDestinationDefaults, + savedGatewayTargets: [], + ); + } + + AppUiState copyWith({ + int? schemaVersion, + String? assistantLastSessionKey, + List? assistantNavigationDestinations, + List? savedGatewayTargets, + }) { + return AppUiState( + schemaVersion: schemaVersion ?? this.schemaVersion, + assistantLastSessionKey: + assistantLastSessionKey ?? this.assistantLastSessionKey, + assistantNavigationDestinations: + assistantNavigationDestinations ?? + this.assistantNavigationDestinations, + savedGatewayTargets: normalizeSavedGatewayTargets( + savedGatewayTargets ?? this.savedGatewayTargets, + ), + ); + } + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'assistantLastSessionKey': assistantLastSessionKey, + 'assistantNavigationDestinations': assistantNavigationDestinations + .map((item) => item.name) + .toList(growable: false), + 'savedGatewayTargets': savedGatewayTargets, + }; + } + + factory AppUiState.fromJson(Map json) { + final parsedSchemaVersion = (json['schemaVersion'] as num?)?.toInt() ?? -1; + if (parsedSchemaVersion != appUiStateSchemaVersion) { + throw const FormatException('Unsupported app ui state schema version.'); + } + final rawAssistantNavigationDestinations = + json['assistantNavigationDestinations']; + final assistantNavigationDestinations = + rawAssistantNavigationDestinations is List + ? normalizeAssistantNavigationDestinations( + rawAssistantNavigationDestinations + .map( + (item) => + AssistantFocusEntryCopy.fromJsonValue(item?.toString()), + ) + .whereType(), + ) + : kAssistantNavigationDestinationDefaults; + return AppUiState( + schemaVersion: parsedSchemaVersion, + assistantLastSessionKey: json['assistantLastSessionKey'] as String? ?? '', + assistantNavigationDestinations: assistantNavigationDestinations, + savedGatewayTargets: normalizeSavedGatewayTargets( + (json['savedGatewayTargets'] as List? ?? const []).map( + (item) => item?.toString() ?? '', + ), + ), + ); + } + + static AppUiState fromJsonString(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return AppUiState.defaults(); + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return AppUiState.defaults(); + } + return AppUiState.fromJson(decoded); + } catch (_) { + return AppUiState.defaults(); + } + } + + String toJsonString() => jsonEncode(toJson()); + + bool isGatewayTargetSaved(AssistantExecutionTarget target) { + final targetKey = switch (target) { + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + _ => '', + }; + return targetKey.isNotEmpty && savedGatewayTargets.contains(targetKey); + } + + AppUiState markGatewayTargetSaved(AssistantExecutionTarget target) { + final targetKey = switch (target) { + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + _ => '', + }; + if (targetKey.isEmpty || savedGatewayTargets.contains(targetKey)) { + return this; + } + return copyWith( + savedGatewayTargets: [...savedGatewayTargets, targetKey], + ); + } +} + +List normalizeSavedGatewayTargets(Iterable rawTargets) { + final normalized = []; + final seen = {}; + for (final item in rawTargets) { + final normalizedTarget = item.trim().toLowerCase(); + if ((normalizedTarget != 'local' && normalizedTarget != 'remote') || + !seen.add(normalizedTarget)) { + continue; + } + normalized.add(normalizedTarget); + } + return List.unmodifiable(normalized); +} diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart index 0ceff941..5155e031 100644 --- a/lib/runtime/secret_store.dart +++ b/lib/runtime/secret_store.dart @@ -59,18 +59,18 @@ class FileSecureStorageClient implements SecureStorageClient { class SecretStore { SecretStore({ - Future Function()? fallbackDirectoryPathResolver, - Future Function()? databasePathResolver, - Future Function()? defaultSupportDirectoryPathResolver, + Future Function()? secretRootPathResolver, + Future Function()? appDataRootPathResolver, + Future Function()? supportRootPathResolver, SecureStorageClient? secureStorage, bool enableSecureStorage = true, StoreLayoutResolver? layoutResolver, }) : _layoutResolver = layoutResolver ?? StoreLayoutResolver( - localRootPathResolver: databasePathResolver, - secretRootPathResolver: fallbackDirectoryPathResolver, - supportRootPathResolver: defaultSupportDirectoryPathResolver, + appDataRootPathResolver: appDataRootPathResolver, + secretRootPathResolver: secretRootPathResolver, + supportRootPathResolver: supportRootPathResolver, ), _secureStorageOverride = secureStorage; diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 8d7b755c..f37c9825 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -12,32 +12,29 @@ import 'settings_store.dart'; class SecureConfigStore { SecureConfigStore({ - Future Function()? fallbackDirectoryPathResolver, - Future Function()? databasePathResolver, - Future Function()? defaultSupportDirectoryPathResolver, - SecureConfigDatabaseOpener? databaseOpener, + Future Function()? secretRootPathResolver, + Future Function()? appDataRootPathResolver, + Future Function()? supportRootPathResolver, SecureStorageClient? secureStorage, bool enableSecureStorage = true, }) { final layoutResolver = StoreLayoutResolver( - localRootPathResolver: databasePathResolver, - secretRootPathResolver: fallbackDirectoryPathResolver, - supportRootPathResolver: defaultSupportDirectoryPathResolver, + appDataRootPathResolver: appDataRootPathResolver, + secretRootPathResolver: secretRootPathResolver, + supportRootPathResolver: supportRootPathResolver, ); _layoutResolver = layoutResolver; _secretStore = SecretStore( - fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, - databasePathResolver: databasePathResolver, - defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver, + secretRootPathResolver: secretRootPathResolver, + appDataRootPathResolver: appDataRootPathResolver, + supportRootPathResolver: supportRootPathResolver, secureStorage: secureStorage, enableSecureStorage: enableSecureStorage, layoutResolver: layoutResolver, ); _settingsStore = SettingsStore( - fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, - databasePathResolver: databasePathResolver, - defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver, - databaseOpener: databaseOpener, + appDataRootPathResolver: appDataRootPathResolver, + supportRootPathResolver: supportRootPathResolver, layoutResolver: layoutResolver, ); } @@ -67,12 +64,12 @@ class SecureConfigStore { return _settingsStore.saveSettingsSnapshot(snapshot); } - Future> resolvedSettingsFiles() { - return _settingsStore.resolvedSettingsFiles(); + Future resolvedSettingsFile() { + return _settingsStore.resolvedSettingsFile(); } - Future> resolvedSettingsWatchDirectories() { - return _settingsStore.resolvedSettingsWatchDirectories(); + Future resolvedSettingsWatchDirectory() { + return _settingsStore.resolvedSettingsWatchDirectory(); } Future> loadTaskThreads() { @@ -243,6 +240,29 @@ class SecureConfigStore { } } + Future 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 saveAppUiState(AppUiState value) => + saveSupportJson('ui/state.json', value.toJson()); + + Future clearAppUiState() async { + final file = await supportFile('ui/state.json'); + if (file == null) { + return; + } + await deleteIfExists(file); + } + Future loadAccountManagedSecret({required String target}) => _secretStore.loadAccountManagedSecret(target: target); diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index 6f0c685b..62c45bdf 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -5,9 +5,6 @@ import 'dart:io'; import 'file_store_support.dart'; import 'runtime_models.dart'; -typedef SecureConfigDatabaseOpener = - FutureOr Function(String resolvedPath); - enum SettingsSnapshotReloadStatus { applied, invalid } class SettingsSnapshotReloadResult { @@ -37,40 +34,27 @@ class SkippedTaskThreadRecord { class SettingsStore { SettingsStore({ - Future Function()? fallbackDirectoryPathResolver, - Future Function()? databasePathResolver, - Future Function()? defaultSupportDirectoryPathResolver, - SecureConfigDatabaseOpener? databaseOpener, + Future Function()? appDataRootPathResolver, + Future Function()? supportRootPathResolver, 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'; + appDataRootPathResolver: appDataRootPathResolver, + supportRootPathResolver: supportRootPathResolver, + ); final StoreLayoutResolver _layoutResolver; - final bool _enableUserSettingsMirror; bool _initialized = false; StoreLayout? _layout; - List _settingsFiles = const []; - List _settingsWatchDirectories = const []; + File? _settingsFile; + Directory? _settingsWatchDirectory; SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults(); List _threadRecords = const []; List _auditTrail = const []; PersistentWriteFailure? _settingsWriteFailure; PersistentWriteFailure? _tasksWriteFailure; PersistentWriteFailure? _auditWriteFailure; - bool _taskThreadStateResetRequired = false; List _lastSkippedInvalidTaskThreadRecords = const []; @@ -94,44 +78,16 @@ class SettingsStore { _initialized = true; try { _layout = await _layoutResolver.resolve(); - _settingsFiles = _resolveSettingsFiles(_layout!); - _settingsWatchDirectories = _resolveSettingsWatchDirectories( - _settingsFiles, - ); + _settingsFile = _layout!.settingsFile; + _settingsWatchDirectory = _settingsFile!.parent; } catch (_) { _layout = null; - _settingsFiles = const []; - _settingsWatchDirectories = const []; + _settingsFile = null; + _settingsWatchDirectory = null; return; } _settingsSnapshot = await _readSettingsSnapshot(); _threadRecords = await _readTaskThreads(); - if (_taskThreadStateResetRequired) { - _settingsSnapshot = _settingsSnapshot.copyWith( - assistantCustomTaskTitles: const {}, - assistantArchivedTaskKeys: const [], - assistantLastSessionKey: '', - ); - final layout = _layout; - if (layout != null) { - try { - final contents = encodeYamlDocument(_settingsSnapshot.toJson()); - for (final file - in _settingsFiles.isEmpty - ? [layout.settingsFile] - : _settingsFiles) { - await atomicWriteString(file, contents); - } - _settingsWriteFailure = null; - } catch (error) { - _settingsWriteFailure = _buildWriteFailure( - PersistentStoreScope.settings, - 'resetTaskThreadState', - error, - ); - } - } - } _auditTrail = await _readAuditTrail(); } @@ -161,14 +117,14 @@ class SettingsStore { ); } - Future> resolvedSettingsFiles() async { + Future resolvedSettingsFile() async { await initialize(); - return List.from(_settingsFiles); + return _settingsFile; } - Future> resolvedSettingsWatchDirectories() async { + Future resolvedSettingsWatchDirectory() async { await initialize(); - return List.from(_settingsWatchDirectories); + return _settingsWatchDirectory; } Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { @@ -185,12 +141,7 @@ class SettingsStore { } try { final contents = encodeYamlDocument(snapshot.toJson()); - for (final file - in _settingsFiles.isEmpty - ? [layout.settingsFile] - : _settingsFiles) { - await atomicWriteString(file, contents); - } + await atomicWriteString(layout.settingsFile, contents); _settingsWriteFailure = null; } catch (error) { _settingsWriteFailure = _buildWriteFailure( @@ -263,22 +214,9 @@ class SettingsStore { Future clearAssistantLocalState() async { await initialize(); - final nextSnapshot = _settingsSnapshot.copyWith( - assistantCustomTaskTitles: const {}, - assistantArchivedTaskKeys: const [], - assistantLastSessionKey: '', - ); - _settingsSnapshot = nextSnapshot; _threadRecords = const []; 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', @@ -286,24 +224,6 @@ class SettingsStore { ); return; } - try { - final settingsFiles = _settingsFiles.isEmpty - ? [layout.settingsFile] - : _settingsFiles; - for (final file in settingsFiles) { - await atomicWriteString( - file, - encodeYamlDocument(nextSnapshot.toJson()), - ); - } - _settingsWriteFailure = null; - } catch (error) { - _settingsWriteFailure = _buildWriteFailure( - PersistentStoreScope.settings, - 'clearAssistantLocalState', - error, - ); - } try { await deleteIfExists(layout.taskIndexFile); await for (final entity in layout.tasksDirectory.list()) { @@ -367,78 +287,46 @@ class SettingsStore { } Future _readSettingsSnapshotResult() async { - if (_settingsFiles.isEmpty) { + final settingsFile = _settingsFile; + if (settingsFile == null) { return SettingsSnapshotReloadResult( snapshot: SettingsSnapshot.defaults(), status: SettingsSnapshotReloadStatus.applied, ); } - var sawExistingFile = false; - var sawInvalidFile = false; - for (final file in _settingsFiles) { - if (!await file.exists()) { - continue; + 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, + ); } - sawExistingFile = true; - try { - final raw = await file.readAsString(); - final decoded = decodeYamlDocument(raw); - if (decoded is Map) { - return SettingsSnapshotReloadResult( - snapshot: SettingsSnapshot.fromJson( - decoded.cast(), - ), - status: SettingsSnapshotReloadStatus.applied, - ); - } - sawInvalidFile = true; - } catch (_) { - sawInvalidFile = true; + 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: sawExistingFile && sawInvalidFile - ? SettingsSnapshotReloadStatus.invalid - : SettingsSnapshotReloadStatus.applied, + status: SettingsSnapshotReloadStatus.invalid, ); } - List _resolveSettingsFiles(StoreLayout layout) { - final resolved = []; - final seen = {}; - - 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.unmodifiable(resolved); - } - - List _resolveSettingsWatchDirectories(List files) { - final directories = []; - final seen = {}; - for (final file in files) { - final path = file.parent.path.trim(); - if (path.isEmpty || !seen.add(path)) { - continue; - } - directories.add(Directory(path)); - } - return List.unmodifiable(directories); - } - Future> _readTaskThreads() async { final layout = _layout; if (layout == null) { @@ -449,10 +337,8 @@ class SettingsStore { final index = await _readThreadIndex(layout); if (index.resetRequired) { await _resetTaskThreadState(layout); - _taskThreadStateResetRequired = true; return const []; } - _taskThreadStateResetRequired = false; final orderedKeys = index.sessions; final recordsByKey = {}; final skippedRecords = []; @@ -486,7 +372,6 @@ class SettingsStore { if (schemaVersion is! int || schemaVersion != taskThreadSchemaVersion) { await _resetTaskThreadState(layout); - _taskThreadStateResetRequired = true; return const []; } final record = TaskThread.fromJson(decoded); diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index 37f18e76..09191f12 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -356,9 +356,9 @@ class _FakeGatewayRuntimeDeps { ); final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${root.path}/settings.sqlite3', - fallbackDirectoryPathResolver: () async => root.path, - defaultSupportDirectoryPathResolver: () async => root.path, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, ); return _FakeGatewayRuntimeDeps._(root, store, DeviceIdentityStore(store)); } diff --git a/test/assistant_execution_target_picker_widget_test.dart b/test/assistant_execution_target_picker_widget_test.dart index 295e5d61..eab9cfb8 100644 --- a/test/assistant_execution_target_picker_widget_test.dart +++ b/test/assistant_execution_target_picker_widget_test.dart @@ -24,9 +24,9 @@ void main() { ); final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${root.path}/settings.sqlite3', - fallbackDirectoryPathResolver: () async => root.path, - defaultSupportDirectoryPathResolver: () async => root.path, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, ); final controller = AppController( store: store, @@ -51,8 +51,9 @@ void main() { } }); - controller.settingsController.snapshotInternal = controller.settings - .copyWith(savedGatewayTargets: const ['local', 'remote']); + controller.appUiStateInternal = controller.appUiState.copyWith( + savedGatewayTargets: const ['local', 'remote'], + ); controller.lastObservedSettingsSnapshotInternal = controller.settingsController.snapshotInternal; diff --git a/test/runtime/account_sync_overwrite_test.dart b/test/runtime/account_sync_overwrite_test.dart new file mode 100644 index 00000000..00fb7c6e --- /dev/null +++ b/test/runtime/account_sync_overwrite_test.dart @@ -0,0 +1,181 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('syncAccountSettings overwrite policy', () { + test( + 'always overwrites sync-owned fields and stores metadata only', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-account-sync-overwrite-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => _FakeAccountRuntimeClient(), + ); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + await controller.initialize(); + await store.saveAccountSessionToken('session-token'); + + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + accountLocalMode: true, + gatewayProfiles: + controller.snapshot.gatewayProfiles.toList(growable: false) + ..[kGatewayRemoteProfileIndex] = controller + .snapshot + .gatewayProfiles[kGatewayRemoteProfileIndex] + .copyWith( + host: 'local.example.com', + port: 7443, + tokenRef: 'local_ref', + ), + vault: controller.snapshot.vault.copyWith( + address: 'https://local-vault.example.com', + namespace: 'local', + ), + aiGateway: controller.snapshot.aiGateway.copyWith( + baseUrl: 'https://local-apisix.example.com', + apiKeyRef: 'local_ai_ref', + ), + ollamaCloud: controller.snapshot.ollamaCloud.copyWith( + apiKeyRef: 'local_ollama_ref', + ), + ), + ); + + final first = await controller.syncAccountSettings( + baseUrl: 'https://accounts.svc.plus', + ); + expect(first.state, 'ready'); + expect( + controller.snapshot.gatewayProfiles[kGatewayRemoteProfileIndex].host, + 'remote.gateway.svc.plus', + ); + expect( + controller + .snapshot + .gatewayProfiles[kGatewayRemoteProfileIndex] + .tokenRef, + kAccountManagedSecretTargetOpenclawGatewayToken, + ); + expect(controller.snapshot.vault.address, 'https://vault.svc.plus'); + expect(controller.snapshot.vault.namespace, 'prod'); + expect( + controller.snapshot.aiGateway.baseUrl, + 'https://apisix.svc.plus', + ); + expect( + controller.snapshot.aiGateway.apiKeyRef, + kAccountManagedSecretTargetAIGatewayAccessToken, + ); + expect( + controller.snapshot.ollamaCloud.apiKeyRef, + kAccountManagedSecretTargetOllamaCloudApiKey, + ); + expect(controller.snapshot.accountLocalMode, isFalse); + + await controller.saveSnapshot( + controller.snapshot.copyWith( + vault: controller.snapshot.vault.copyWith( + address: 'https://edited.example.com', + ), + aiGateway: controller.snapshot.aiGateway.copyWith( + baseUrl: 'https://edited-apisix.example.com', + ), + ), + ); + + final second = await controller.syncAccountSettings( + baseUrl: 'https://accounts.svc.plus', + ); + expect(second.state, 'ready'); + expect(controller.snapshot.vault.address, 'https://vault.svc.plus'); + expect( + controller.snapshot.aiGateway.baseUrl, + 'https://apisix.svc.plus', + ); + + final rawSyncState = await store.loadSupportJson( + 'account/sync_state.json', + ); + expect(rawSyncState, isNotNull); + expect(rawSyncState!.containsKey('overrideFlags'), isFalse); + expect(rawSyncState['syncState'], 'ready'); + expect(rawSyncState['lastSyncError'], isEmpty); + }, + ); + }); +} + +class _FakeAccountRuntimeClient extends AccountRuntimeClient { + _FakeAccountRuntimeClient() : super(baseUrl: 'https://accounts.svc.plus'); + + @override + Future loadProfile({required String token}) async { + expect(token, 'session-token'); + return AccountProfileResponse( + profile: AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'wss://remote.gateway.svc.plus', + vaultUrl: 'https://vault.svc.plus', + vaultNamespace: 'prod', + apisixUrl: 'https://apisix.svc.plus', + secretLocators: const [ + AccountSecretLocator( + id: 'gateway', + provider: 'vault', + secretPath: 'kv/xworkmate', + secretKey: 'gateway_token', + target: kAccountManagedSecretTargetOpenclawGatewayToken, + required: true, + ), + AccountSecretLocator( + id: 'ai', + provider: 'vault', + secretPath: 'kv/xworkmate', + secretKey: 'ai_gateway_token', + target: kAccountManagedSecretTargetAIGatewayAccessToken, + required: true, + ), + AccountSecretLocator( + id: 'ollama', + provider: 'vault', + secretPath: 'kv/xworkmate', + secretKey: 'ollama_key', + target: kAccountManagedSecretTargetOllamaCloudApiKey, + required: true, + ), + ], + ), + profileScope: 'workspace', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: true, + apisix: true, + ), + ); + } +} diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index bd288b8e..b0773c13 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -9,114 +9,123 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('SettingsController account logout', () { - test('clears synced account state, managed secrets, and cloud summary', () async { - final root = await Directory.systemTemp.createTemp( - 'xworkmate-settings-account-test-', - ); - final store = SecureConfigStore( - enableSecureStorage: false, - databasePathResolver: () async => '${root.path}/settings.sqlite3', - fallbackDirectoryPathResolver: () async => root.path, - defaultSupportDirectoryPathResolver: () async => root.path, - ); - final controller = SettingsController(store); - addTearDown(() async { - controller.dispose(); - store.dispose(); - if (await root.exists()) { - await root.delete(recursive: true); - } - }); + test( + 'clears synced account state, managed secrets, and cloud summary', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-settings-account-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = SettingsController(store); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); - await store.initialize(); - await controller.initialize(); + await store.initialize(); + await controller.initialize(); - await store.saveAccountSessionToken('session-token'); - await store.saveAccountSessionSummary( - const AccountSessionSummary( - userId: 'u-1', - email: 'review@svc.plus', - name: 'Review', - role: 'member', - mfaEnabled: false, - ), - ); - await store.saveAccountSessionIdentifier('review@svc.plus'); - await store.saveAccountManagedSecret( - target: kAccountManagedSecretTargetAIGatewayAccessToken, - value: 'managed-secret', - ); - await store.saveAccountSyncState( - AccountSyncState.defaults().copyWith( - syncState: 'ready', - syncMessage: 'Remote defaults synced', - lastSyncAtMs: 123456789, - lastSyncSource: 'https://accounts.svc.plus', - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - openclawUrl: 'wss://gateway.svc.plus', - apisixUrl: 'https://apisix.svc.plus', + await store.saveAccountSessionToken('session-token'); + await store.saveAccountSessionSummary( + const AccountSessionSummary( + userId: 'u-1', + email: 'review@svc.plus', + name: 'Review', + role: 'member', + mfaEnabled: false, ), - ), - ); - await controller.saveSnapshot( - controller.snapshot.copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - accountUsername: 'review@svc.plus', - accountLocalMode: false, - acpBridgeServerModeConfig: controller.snapshot.acpBridgeServerModeConfig - .copyWith( - cloudSynced: controller - .snapshot - .acpBridgeServerModeConfig - .cloudSynced - .copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - accountIdentifier: 'review@svc.plus', - lastSyncAt: 123456789, - remoteServerSummary: const AcpBridgeServerRemoteServerSummary( - endpoint: 'wss://gateway.svc.plus', - hasAdvancedOverrides: false, - ), - ), - ), - ), - recordAccountOverrides: false, - ); - - await controller.logoutAccount(); - - expect(await store.loadAccountSessionToken(), isNull); - expect(await store.loadAccountSessionSummary(), isNull); - expect(await store.loadAccountSessionIdentifier(), isNull); - expect( - await store.loadAccountManagedSecret( + ); + await store.saveAccountSessionIdentifier('review@svc.plus'); + await store.saveAccountManagedSecret( target: kAccountManagedSecretTargetAIGatewayAccessToken, - ), - isNull, - ); - expect(await store.loadAccountSyncState(), isNull); + value: 'managed-secret', + ); + await store.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Remote defaults synced', + lastSyncAtMs: 123456789, + lastSyncSource: 'https://accounts.svc.plus', + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'wss://gateway.svc.plus', + apisixUrl: 'https://apisix.svc.plus', + ), + ), + ); + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + accountLocalMode: false, + acpBridgeServerModeConfig: controller + .snapshot + .acpBridgeServerModeConfig + .copyWith( + cloudSynced: controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountIdentifier: 'review@svc.plus', + lastSyncAt: 123456789, + remoteServerSummary: + const AcpBridgeServerRemoteServerSummary( + endpoint: 'wss://gateway.svc.plus', + hasAdvancedOverrides: false, + ), + ), + ), + ), + ); - expect(controller.accountSignedIn, isFalse); - expect(controller.accountStatus, 'Signed out'); - expect(controller.accountSyncState, isNull); - expect(controller.snapshot.accountLocalMode, isTrue); - expect( - controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountIdentifier, - isEmpty, - ); - expect( - controller.snapshot.acpBridgeServerModeConfig.cloudSynced.lastSyncAt, - 0, - ); - expect( - controller - .snapshot - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint, - isEmpty, - ); - }); + await controller.logoutAccount(); + + expect(await store.loadAccountSessionToken(), isNull); + expect(await store.loadAccountSessionSummary(), isNull); + expect(await store.loadAccountSessionIdentifier(), isNull); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetAIGatewayAccessToken, + ), + isNull, + ); + expect(await store.loadAccountSyncState(), isNull); + + expect(controller.accountSignedIn, isFalse); + expect(controller.accountStatus, 'Signed out'); + expect(controller.accountSyncState, isNull); + expect(controller.snapshot.accountLocalMode, isTrue); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountIdentifier, + isEmpty, + ); + expect( + controller.snapshot.acpBridgeServerModeConfig.cloudSynced.lastSyncAt, + 0, + ); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .endpoint, + isEmpty, + ); + }, + ); }); } diff --git a/test/runtime/secure_config_store_ui_state_test.dart b/test/runtime/secure_config_store_ui_state_test.dart new file mode 100644 index 00000000..508a701d --- /dev/null +++ b/test/runtime/secure_config_store_ui_state_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SecureConfigStore app ui state', () { + test('persists ui/state.json separately from settings.yaml', () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-ui-state-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + addTearDown(() async { + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + await store.saveAppUiState( + AppUiState.defaults().copyWith( + assistantLastSessionKey: 'draft:1', + assistantNavigationDestinations: const [ + AssistantFocusEntry.language, + ], + savedGatewayTargets: const ['remote'], + ), + ); + + final loaded = await store.loadAppUiState(); + final uiStateFile = await store.supportFile('ui/state.json'); + final settingsFile = await store.resolvedSettingsFile(); + + expect(loaded.assistantLastSessionKey, 'draft:1'); + expect( + loaded.assistantNavigationDestinations, + const [AssistantFocusEntry.language], + ); + expect(loaded.savedGatewayTargets, const ['remote']); + expect(await uiStateFile?.exists(), isTrue); + expect(await settingsFile?.exists(), isFalse); + }); + + test( + 'clearAssistantLocalState companion clear removes ui/state.json', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-ui-state-clear-test-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + addTearDown(() async { + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + await store.saveAppUiState( + AppUiState.defaults().copyWith(assistantLastSessionKey: 'draft:2'), + ); + + await store.clearAppUiState(); + + expect((await store.loadAppUiState()).assistantLastSessionKey, isEmpty); + expect( + await (await store.supportFile('ui/state.json'))?.exists(), + isFalse, + ); + }, + ); + }); +} diff --git a/test/runtime/settings_snapshot_provider_sync_definitions_test.dart b/test/runtime/settings_snapshot_provider_sync_definitions_test.dart new file mode 100644 index 00000000..3bb53c4e --- /dev/null +++ b/test/runtime/settings_snapshot_provider_sync_definitions_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + group('SettingsSnapshot schema v1', () { + test('defaults include provider sync presets', () { + final providerKeys = SettingsSnapshot.defaults().providerSyncDefinitions + .map((item) => item.providerKey) + .toList(growable: false); + + expect(providerKeys, ['codex', 'opencode', 'gemini']); + }); + + test('round trips providerSyncDefinitions and schemaVersion', () { + final snapshot = SettingsSnapshot.defaults().copyWith( + providerSyncDefinitions: [ + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.codex, + ).copyWith(endpoint: 'https://codex.example.com'), + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.opencode, + ), + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.gemini, + ), + ], + ); + + final decoded = SettingsSnapshot.fromJson(snapshot.toJson()); + + expect(decoded.schemaVersion, settingsSnapshotSchemaVersion); + expect( + decoded.providerSyncDefinitions.first.endpoint, + 'https://codex.example.com', + ); + }); + + test('missing schemaVersion is rejected', () { + expect( + () => SettingsSnapshot.fromJson({ + 'assistantExecutionTarget': 'singleAgent', + 'gatewayProfiles': >[], + }), + throwsFormatException, + ); + }); + + test('removed ui restore fields are not serialized', () { + final json = SettingsSnapshot.defaults().toJson(); + + expect(json.containsKey('assistantLastSessionKey'), isFalse); + expect(json.containsKey('assistantNavigationDestinations'), isFalse); + expect(json.containsKey('assistantCustomTaskTitles'), isFalse); + expect(json.containsKey('assistantArchivedTaskKeys'), isFalse); + expect(json.containsKey('savedGatewayTargets'), isFalse); + expect(json.containsKey('externalAcpEndpoints'), isFalse); + expect(json.containsKey('providerSyncDefinitions'), isTrue); + }); + }); + + group('AcpBridgeServerModeConfig advanced overrides', () { + test('advanced override ACP profiles are normalized to full presets', () { + final config = AcpBridgeServerModeConfig.fromJson({ + 'advancedOverrides': { + 'acpBridgeServerProfiles': >[ + { + 'providerKey': 'opencode', + 'label': 'OpenCode', + 'badge': 'O', + 'endpoint': '', + 'authRef': '', + 'enabled': true, + }, + ], + }, + }); + + final providerKeys = config.advancedOverrides.acpBridgeServerProfiles + .map((item) => item.providerKey) + .toList(growable: false); + + expect(providerKeys, ['codex', 'opencode', 'gemini']); + }); + }); +} diff --git a/test/runtime/settings_store_v1_test.dart b/test/runtime/settings_store_v1_test.dart new file mode 100644 index 00000000..07918c5d --- /dev/null +++ b/test/runtime/settings_store_v1_test.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/settings_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SettingsStore v1', () { + test('resolves a single settings file and watch directory', () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-settings-store-v1-', + ); + final store = SettingsStore( + appDataRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + + final file = await store.resolvedSettingsFile(); + final watchDirectory = await store.resolvedSettingsWatchDirectory(); + + expect(file?.path, '${root.path}/config/settings.yaml'); + expect(watchDirectory?.path, '${root.path}/config'); + }); + + test('old schema resets to defaults and reports invalid reload', () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-settings-store-v1-invalid-', + ); + final store = SettingsStore( + appDataRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + final file = await store.resolvedSettingsFile(); + expect(file, isNotNull); + + await file!.create(recursive: true); + await file.writeAsString( + 'appLanguage: zh\nassistantExecutionTarget: singleAgent\n', + ); + + final reload = await store.reloadSettingsSnapshotResult(); + final loaded = await store.loadSettingsSnapshot(); + + expect(reload.status, SettingsSnapshotReloadStatus.invalid); + expect(reload.snapshot.toJsonString(), loaded.toJsonString()); + expect(loaded.schemaVersion, settingsSnapshotSchemaVersion); + }); + }); +}