refactor: move app settings to v1 single-file snapshot

This commit is contained in:
Haitao Pan 2026-04-11 12:02:32 +08:00
parent bae412132d
commit 37cefdfec6
29 changed files with 1046 additions and 804 deletions

View File

@ -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.

View File

@ -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<String, String> draftSecretValuesInternal = <String, String>{};
@ -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<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
) {
final visible = settings.visibleAssistantExecutionTargets(
supportedTargets: supportedTargets,
availableSingleAgentProviders: availableSingleAgentProviders,
);
final supported = supportedTargets.toSet();
final visible = <AssistantExecutionTarget>[];
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;

View File

@ -384,7 +384,7 @@ Uri? resolveSingleAgentEndpointRuntimeInternal(
SingleAgentProvider provider,
) {
final endpoint = controller.settings
.externalAcpEndpointForProvider(provider)
.providerSyncDefinitionForProvider(provider)
.endpoint
.trim();
if (endpoint.isEmpty) {

View File

@ -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<void> saveAppUiStateInternal(
AppUiState next, {
bool notify = false,
}) async {
appUiStateInternal = next;
await storeInternal.saveAppUiState(next);
if (notify) {
notifyIfActiveInternal();
}
}
Future<void> 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<List<ExternalCodeAgentAcpSyncedProvider>>
buildExternalAcpSyncedProvidersInternal() async {
final providers = <ExternalCodeAgentAcpSyncedProvider>[];
for (final profile in settings.externalAcpEndpoints) {
for (final profile in settings.providerSyncDefinitions) {
final provider = settings.singleAgentProviderForId(profile.providerKey);
if (provider == SingleAgentProvider.auto) {
continue;

View File

@ -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<void> 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<void> clearAssistantLocalState() async {
await flushAssistantThreadPersistenceInternal();
await storeInternal.clearAssistantLocalState();
await storeInternal.clearAppUiState();
await storeInternal.saveTaskThreads(const <TaskThread>[]);
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,

View File

@ -231,9 +231,9 @@ extension AppControllerDesktopSettingsRuntime on AppController {
final next = current.contains(destination)
? current.where((item) => item != destination).toList(growable: false)
: <AssistantFocusEntry>[...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;

View File

@ -499,7 +499,7 @@ extension AppControllerDesktopThreadSessions on AppController {
List<RuntimeLogEntry> get runtimeLogs => runtimeInternal.logs;
List<AssistantFocusEntry> 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<GatewaySessionSummary> assistantSessionsInternal() {
final archivedKeys = settings.assistantArchivedTaskKeys
.map(normalizedAssistantSessionKeyInternal)
.toSet();
final byKey = <String, GatewaySessionSummary>{};
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);
}

View File

@ -98,7 +98,7 @@ extension AppControllerDesktopThreadStorage on AppController {
Future<void> 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<GatewayChatMessage>.from(
assistantThreadMessagesInternal[key] ?? const <GatewayChatMessage>[],
)..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<GatewaySessionSummary> assistantSessionSummariesInternal() {
final archivedKeys = settings.assistantArchivedTaskKeys
.map(normalizedAssistantSessionKeyInternal)
.toSet();
final items = <GatewaySessionSummary>[];
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 <AssistantThreadSkillEntry>[];
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(

View File

@ -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<String, String>.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<void> saveAssistantTaskArchived(
@ -556,19 +538,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
if (normalizedSessionKey.isEmpty) {
return;
}
final next = <String>[
...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<void>(normalizedSessionKey, () async {

View File

@ -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

View File

@ -121,14 +121,14 @@ class StoreLayout {
class StoreLayoutResolver {
StoreLayoutResolver({
Future<String?> Function()? localRootPathResolver,
Future<String?> Function()? appDataRootPathResolver,
Future<String?> Function()? secretRootPathResolver,
Future<String?> Function()? supportRootPathResolver,
}) : _localRootPathResolver = localRootPathResolver,
}) : _appDataRootPathResolver = appDataRootPathResolver,
_secretRootPathResolver = secretRootPathResolver,
_supportRootPathResolver = supportRootPathResolver;
final Future<String?> Function()? _localRootPathResolver;
final Future<String?> Function()? _appDataRootPathResolver;
final Future<String?> Function()? _secretRootPathResolver;
final Future<String?> 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',

View File

@ -90,21 +90,10 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<void> saveSnapshot(
SettingsSnapshot snapshot, {
bool recordAccountOverrides = true,
}) async {
final previousSnapshot = snapshotInternal;
Future<void> 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<String> 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();

View File

@ -103,12 +103,6 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<void> cancelAccountMfaChallenge() =>
cancelAccountMfaChallengeSettingsInternal(this);
Future<void> markAccountOverride(String fieldKey) =>
markAccountOverrideSettingsInternal(this, fieldKey: fieldKey);
Future<void> clearAccountOverride(String fieldKey) =>
clearAccountOverrideSettingsInternal(this, fieldKey: fieldKey);
List<SecretReferenceEntry> buildSecretReferences() {
final entries = <SecretReferenceEntry>[
...secureRefsInternal.entries.map(

View File

@ -292,7 +292,6 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
accountLocalMode: false,
acpBridgeServerModeConfig: nextModeConfig,
),
recordAccountOverrides: false,
);
}
await applyAccountSyncedDefaultsSettingsInternal(
@ -361,13 +360,7 @@ Future<void> 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<void> 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<void> 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<void> 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<void> applyAccountSyncedDefaultsSettingsInternal(
);
if (next.toJsonString() != previous.toJsonString()) {
await controller.saveSnapshot(next, recordAccountOverrides: false);
await controller.saveSnapshot(next);
}
}
@ -511,7 +491,6 @@ Future<void> logoutAccountSettingsInternal(
acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig
.copyWith(cloudSynced: clearedCloudSync),
),
recordAccountOverrides: false,
);
} else {
controller.snapshotInternal = currentSnapshot.copyWith(
@ -551,126 +530,6 @@ String normalizeAccountBaseUrlSettingsInternal(
: candidate;
}
Future<void> 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<String, bool>.from(current.overrideFlags)
..[fieldKey] = true;
await controller.storeInternal.saveAccountSyncState(
current.copyWith(overrideFlags: nextFlags),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<void> 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<String, bool>.from(current.overrideFlags)
..remove(fieldKey);
await controller.storeInternal.saveAccountSyncState(
current.copyWith(overrideFlags: nextFlags),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<void> recordAccountOverridesForSnapshotChangeSettingsInternal(
SettingsController controller, {
required SettingsSnapshot previous,
required SettingsSnapshot current,
}) async {
final syncState = await controller.storeInternal.loadAccountSyncState();
if (syncState == null) {
return;
}
final nextFlags = Map<String, bool>.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<String, bool> flags, String fieldKey) {
return flags[fieldKey] != true;
}
bool _markOverrideFlag(Map<String, bool> 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;

View File

@ -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';

View File

@ -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<String, bool> 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 <String, bool>{},
syncState: 'idle',
syncMessage: 'Remote config not synced yet',
lastSyncAtMs: 0,
@ -632,7 +629,6 @@ class AccountSyncState {
AccountSyncState copyWith({
AccountRemoteProfile? syncedDefaults,
Map<String, bool>? 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<String, dynamic> toJson() {
return {
'syncedDefaults': syncedDefaults.toJson(),
'overrideFlags': overrideFlags,
'syncState': syncState,
'syncMessage': syncMessage,
'lastSyncAtMs': lastSyncAtMs,
@ -669,21 +663,11 @@ class AccountSyncState {
}
factory AccountSyncState.fromJson(Map<String, dynamic> json) {
Map<String, bool> decodeOverrideFlags(Object? value) {
if (value is! Map) {
return const <String, bool>{};
}
return value.map<String, bool>((key, entry) {
return MapEntry(key.toString(), entry == true);
});
}
return AccountSyncState(
syncedDefaults: AccountRemoteProfile.fromJson(
(json['syncedDefaults'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{},
),
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<String> kAccountManagedSecretTargets = <String>[
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<String> kAccountOverrideFieldKeys = <String>[
kAccountOverrideGatewayRemoteEndpoint,
kAccountOverrideVaultAddress,
kAccountOverrideVaultNamespace,
kAccountOverrideAiGatewayBaseUrl,
kAccountOverrideAiGatewayApiKeyRef,
kAccountOverrideOllamaCloudApiKeyRef,
];

View File

@ -347,7 +347,11 @@ List<SingleAgentProvider> normalizeSingleAgentProviderList(
}
const List<SingleAgentProvider> kPresetExternalAcpProviders =
<SingleAgentProvider>[SingleAgentProvider.opencode];
<SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
];
const List<SingleAgentProvider> kKnownSingleAgentProviders =
<SingleAgentProvider>[
@ -357,4 +361,4 @@ const List<SingleAgentProvider> kKnownSingleAgentProviders =
SingleAgentProvider.gemini,
];
const Set<String> kLegacyExternalAcpProviderIds = <String>{'claude', 'gemini'};
const Set<String> kLegacyExternalAcpProviderIds = <String>{'claude'};

View File

@ -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<GatewayConnectionProfile> gatewayProfiles;
final List<ExternalAcpEndpointProfile> externalAcpEndpoints;
final List<ExternalAcpEndpointProfile> providerSyncDefinitions;
final List<AuthorizedSkillDirectory> authorizedSkillDirectories;
final OllamaLocalConfig ollamaLocal;
final OllamaCloudConfig ollamaCloud;
@ -84,14 +83,10 @@ class SettingsSnapshot {
final LinuxDesktopConfig linuxDesktop;
final AssistantExecutionTarget assistantExecutionTarget;
final AssistantPermissionLevel assistantPermissionLevel;
final List<AssistantFocusEntry> assistantNavigationDestinations;
final Map<String, String> assistantCustomTaskTitles;
final List<String> assistantArchivedTaskKeys;
final List<String> 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 <String, String>{},
assistantArchivedTaskKeys: const <String>[],
savedGatewayTargets: const <String>[],
assistantLastSessionKey: '',
);
}
SettingsSnapshot copyWith({
int? schemaVersion,
AppLanguage? appLanguage,
bool? appActive,
bool? launchAtLogin,
@ -145,7 +136,7 @@ class SettingsSnapshot {
String? defaultModel,
String? defaultProvider,
List<GatewayConnectionProfile>? gatewayProfiles,
List<ExternalAcpEndpointProfile>? externalAcpEndpoints,
List<ExternalAcpEndpointProfile>? providerSyncDefinitions,
List<AuthorizedSkillDirectory>? authorizedSkillDirectories,
OllamaLocalConfig? ollamaLocal,
OllamaCloudConfig? ollamaCloud,
@ -165,18 +156,13 @@ class SettingsSnapshot {
LinuxDesktopConfig? linuxDesktop,
AssistantExecutionTarget? assistantExecutionTarget,
AssistantPermissionLevel? assistantPermissionLevel,
List<AssistantFocusEntry>? assistantNavigationDestinations,
Map<String, String>? assistantCustomTaskTitles,
List<String>? assistantArchivedTaskKeys,
List<String>? 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<String, dynamic> 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<String, dynamic> json) {
Map<String, String> normalizeTaskTitles(Object? value) {
if (value is! Map) {
return const <String, String>{};
}
final normalized = <String, String>{};
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<String> normalizeTaskKeys(Object? value) {
if (value is! List) {
return const <String>[];
}
final normalized = <String>[];
final seen = <String>{};
for (final item in value) {
final normalizedKey = item?.toString().trim() ?? '';
if (normalizedKey.isEmpty || !seen.add(normalizedKey)) {
continue;
}
normalized.add(normalizedKey);
}
return normalized;
}
List<String> normalizeSavedGatewayTargetsFromJson(Object? value) {
if (value is! List) {
return const <String>[];
}
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<AssistantFocusEntry>(),
)
: kAssistantNavigationDestinationDefaults;
final gatewayProfiles = normalizeGatewayProfiles(
profiles: ((json['gatewayProfiles'] as List?) ?? const <Object>[])
.whereType<Map>()
@ -349,8 +270,8 @@ class SettingsSnapshot {
GatewayConnectionProfile.fromJson(item.cast<String, dynamic>()),
),
);
final externalAcpEndpoints = normalizeExternalAcpEndpoints(
profiles: ((json['externalAcpEndpoints'] as List?) ?? const <Object>[])
final providerSyncDefinitions = normalizeExternalAcpEndpoints(
profiles: ((json['providerSyncDefinitions'] as List?) ?? const <Object>[])
.whereType<Map>()
.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<String, dynamic>() ?? 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: <String>[...savedGatewayTargets, targetKey],
);
}
List<AssistantExecutionTarget> visibleAssistantExecutionTargets({
required Iterable<AssistantExecutionTarget> supportedTargets,
required Iterable<SingleAgentProvider> availableSingleAgentProviders,
}) {
final supported = supportedTargets.toSet();
final visible = <AssistantExecutionTarget>[];
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<AssistantExecutionTarget>.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<String> normalizeSavedGatewayTargets(Iterable<String> rawTargets) {
final normalized = <String>[];
final seen = <String>{};
for (final item in rawTargets) {
final normalizedTarget = item.trim().toLowerCase();
if ((normalizedTarget != 'local' && normalizedTarget != 'remote') ||
!seen.add(normalizedTarget)) {
continue;
}
normalized.add(normalizedTarget);
}
return List<String>.unmodifiable(normalized);
}

View File

@ -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<AssistantFocusEntry> assistantNavigationDestinations;
final List<String> savedGatewayTargets;
factory AppUiState.defaults() {
return const AppUiState(
schemaVersion: appUiStateSchemaVersion,
assistantLastSessionKey: '',
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
savedGatewayTargets: <String>[],
);
}
AppUiState copyWith({
int? schemaVersion,
String? assistantLastSessionKey,
List<AssistantFocusEntry>? assistantNavigationDestinations,
List<String>? savedGatewayTargets,
}) {
return AppUiState(
schemaVersion: schemaVersion ?? this.schemaVersion,
assistantLastSessionKey:
assistantLastSessionKey ?? this.assistantLastSessionKey,
assistantNavigationDestinations:
assistantNavigationDestinations ??
this.assistantNavigationDestinations,
savedGatewayTargets: normalizeSavedGatewayTargets(
savedGatewayTargets ?? this.savedGatewayTargets,
),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'schemaVersion': schemaVersion,
'assistantLastSessionKey': assistantLastSessionKey,
'assistantNavigationDestinations': assistantNavigationDestinations
.map((item) => item.name)
.toList(growable: false),
'savedGatewayTargets': savedGatewayTargets,
};
}
factory AppUiState.fromJson(Map<String, dynamic> 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<AssistantFocusEntry>(),
)
: kAssistantNavigationDestinationDefaults;
return AppUiState(
schemaVersion: parsedSchemaVersion,
assistantLastSessionKey: json['assistantLastSessionKey'] as String? ?? '',
assistantNavigationDestinations: assistantNavigationDestinations,
savedGatewayTargets: normalizeSavedGatewayTargets(
(json['savedGatewayTargets'] as List? ?? const <Object>[]).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<String, dynamic>) {
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: <String>[...savedGatewayTargets, targetKey],
);
}
}
List<String> normalizeSavedGatewayTargets(Iterable<String> rawTargets) {
final normalized = <String>[];
final seen = <String>{};
for (final item in rawTargets) {
final normalizedTarget = item.trim().toLowerCase();
if ((normalizedTarget != 'local' && normalizedTarget != 'remote') ||
!seen.add(normalizedTarget)) {
continue;
}
normalized.add(normalizedTarget);
}
return List<String>.unmodifiable(normalized);
}

View File

@ -59,18 +59,18 @@ class FileSecureStorageClient implements SecureStorageClient {
class SecretStore {
SecretStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
Future<String?> Function()? secretRootPathResolver,
Future<String?> Function()? appDataRootPathResolver,
Future<String?> 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;

View File

@ -12,32 +12,29 @@ import 'settings_store.dart';
class SecureConfigStore {
SecureConfigStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
SecureConfigDatabaseOpener? databaseOpener,
Future<String?> Function()? secretRootPathResolver,
Future<String?> Function()? appDataRootPathResolver,
Future<String?> 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<List<File>> resolvedSettingsFiles() {
return _settingsStore.resolvedSettingsFiles();
Future<File?> resolvedSettingsFile() {
return _settingsStore.resolvedSettingsFile();
}
Future<List<Directory>> resolvedSettingsWatchDirectories() {
return _settingsStore.resolvedSettingsWatchDirectories();
Future<Directory?> resolvedSettingsWatchDirectory() {
return _settingsStore.resolvedSettingsWatchDirectory();
}
Future<List<TaskThread>> loadTaskThreads() {
@ -243,6 +240,29 @@ class SecureConfigStore {
}
}
Future<AppUiState> loadAppUiState() async {
final payload = await loadSupportJson('ui/state.json');
if (payload == null) {
return AppUiState.defaults();
}
try {
return AppUiState.fromJson(payload);
} catch (_) {
return AppUiState.defaults();
}
}
Future<void> saveAppUiState(AppUiState value) =>
saveSupportJson('ui/state.json', value.toJson());
Future<void> clearAppUiState() async {
final file = await supportFile('ui/state.json');
if (file == null) {
return;
}
await deleteIfExists(file);
}
Future<String?> loadAccountManagedSecret({required String target}) =>
_secretStore.loadAccountManagedSecret(target: target);

View File

@ -5,9 +5,6 @@ import 'dart:io';
import 'file_store_support.dart';
import 'runtime_models.dart';
typedef SecureConfigDatabaseOpener =
FutureOr<Object?> Function(String resolvedPath);
enum SettingsSnapshotReloadStatus { applied, invalid }
class SettingsSnapshotReloadResult {
@ -37,40 +34,27 @@ class SkippedTaskThreadRecord {
class SettingsStore {
SettingsStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
SecureConfigDatabaseOpener? databaseOpener,
Future<String?> Function()? appDataRootPathResolver,
Future<String?> 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<File> _settingsFiles = const <File>[];
List<Directory> _settingsWatchDirectories = const <Directory>[];
File? _settingsFile;
Directory? _settingsWatchDirectory;
SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults();
List<TaskThread> _threadRecords = const <TaskThread>[];
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
PersistentWriteFailure? _settingsWriteFailure;
PersistentWriteFailure? _tasksWriteFailure;
PersistentWriteFailure? _auditWriteFailure;
bool _taskThreadStateResetRequired = false;
List<SkippedTaskThreadRecord> _lastSkippedInvalidTaskThreadRecords =
const <SkippedTaskThreadRecord>[];
@ -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 <File>[];
_settingsWatchDirectories = const <Directory>[];
_settingsFile = null;
_settingsWatchDirectory = null;
return;
}
_settingsSnapshot = await _readSettingsSnapshot();
_threadRecords = await _readTaskThreads();
if (_taskThreadStateResetRequired) {
_settingsSnapshot = _settingsSnapshot.copyWith(
assistantCustomTaskTitles: const <String, String>{},
assistantArchivedTaskKeys: const <String>[],
assistantLastSessionKey: '',
);
final layout = _layout;
if (layout != null) {
try {
final contents = encodeYamlDocument(_settingsSnapshot.toJson());
for (final file
in _settingsFiles.isEmpty
? <File>[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<List<File>> resolvedSettingsFiles() async {
Future<File?> resolvedSettingsFile() async {
await initialize();
return List<File>.from(_settingsFiles);
return _settingsFile;
}
Future<List<Directory>> resolvedSettingsWatchDirectories() async {
Future<Directory?> resolvedSettingsWatchDirectory() async {
await initialize();
return List<Directory>.from(_settingsWatchDirectories);
return _settingsWatchDirectory;
}
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
@ -185,12 +141,7 @@ class SettingsStore {
}
try {
final contents = encodeYamlDocument(snapshot.toJson());
for (final file
in _settingsFiles.isEmpty
? <File>[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<void> clearAssistantLocalState() async {
await initialize();
final nextSnapshot = _settingsSnapshot.copyWith(
assistantCustomTaskTitles: const <String, String>{},
assistantArchivedTaskKeys: const <String>[],
assistantLastSessionKey: '',
);
_settingsSnapshot = nextSnapshot;
_threadRecords = const <TaskThread>[];
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
? <File>[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<SettingsSnapshotReloadResult> _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<String, dynamic>) {
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<String, dynamic>(),
),
status: SettingsSnapshotReloadStatus.applied,
);
}
sawInvalidFile = true;
} catch (_) {
sawInvalidFile = true;
if (decoded is Map) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.fromJson(decoded.cast<String, dynamic>()),
status: SettingsSnapshotReloadStatus.applied,
);
}
} catch (_) {
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.defaults(),
status: SettingsSnapshotReloadStatus.invalid,
);
}
return SettingsSnapshotReloadResult(
snapshot: SettingsSnapshot.defaults(),
status: sawExistingFile && sawInvalidFile
? SettingsSnapshotReloadStatus.invalid
: SettingsSnapshotReloadStatus.applied,
status: SettingsSnapshotReloadStatus.invalid,
);
}
List<File> _resolveSettingsFiles(StoreLayout layout) {
final resolved = <File>[];
final seen = <String>{};
void addPath(String path) {
final normalized = path.trim();
if (normalized.isEmpty || !seen.add(normalized)) {
return;
}
resolved.add(File(normalized));
}
final userPath = _enableUserSettingsMirror
? defaultUserSettingsFilePath()
: null;
if ((userPath ?? '').isNotEmpty) {
addPath(userPath!);
}
addPath(layout.settingsFile.path);
return List<File>.unmodifiable(resolved);
}
List<Directory> _resolveSettingsWatchDirectories(List<File> files) {
final directories = <Directory>[];
final seen = <String>{};
for (final file in files) {
final path = file.parent.path.trim();
if (path.isEmpty || !seen.add(path)) {
continue;
}
directories.add(Directory(path));
}
return List<Directory>.unmodifiable(directories);
}
Future<List<TaskThread>> _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 <TaskThread>[];
}
_taskThreadStateResetRequired = false;
final orderedKeys = index.sessions;
final recordsByKey = <String, TaskThread>{};
final skippedRecords = <SkippedTaskThreadRecord>[];
@ -486,7 +372,6 @@ class SettingsStore {
if (schemaVersion is! int ||
schemaVersion != taskThreadSchemaVersion) {
await _resetTaskThreadState(layout);
_taskThreadStateResetRequired = true;
return const <TaskThread>[];
}
final record = TaskThread.fromJson(decoded);

View File

@ -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));
}

View File

@ -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 <String>['local', 'remote']);
controller.appUiStateInternal = controller.appUiState.copyWith(
savedGatewayTargets: const <String>['local', 'remote'],
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;

View File

@ -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<AccountProfileResponse> 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>[
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,
),
);
}
}

View File

@ -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,
);
},
);
});
}

View File

@ -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>[
AssistantFocusEntry.language,
],
savedGatewayTargets: const <String>['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>[AssistantFocusEntry.language],
);
expect(loaded.savedGatewayTargets, const <String>['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,
);
},
);
});
}

View File

@ -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, <String>['codex', 'opencode', 'gemini']);
});
test('round trips providerSyncDefinitions and schemaVersion', () {
final snapshot = SettingsSnapshot.defaults().copyWith(
providerSyncDefinitions: <ExternalAcpEndpointProfile>[
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(<String, dynamic>{
'assistantExecutionTarget': 'singleAgent',
'gatewayProfiles': <Map<String, dynamic>>[],
}),
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(<String, dynamic>{
'advancedOverrides': <String, dynamic>{
'acpBridgeServerProfiles': <Map<String, dynamic>>[
<String, dynamic>{
'providerKey': 'opencode',
'label': 'OpenCode',
'badge': 'O',
'endpoint': '',
'authRef': '',
'enabled': true,
},
],
},
});
final providerKeys = config.advancedOverrides.acpBridgeServerProfiles
.map((item) => item.providerKey)
.toList(growable: false);
expect(providerKeys, <String>['codex', 'opencode', 'gemini']);
});
});
}

View File

@ -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);
});
});
}