refactor: move app settings to v1 single-file snapshot
This commit is contained in:
parent
bae412132d
commit
37cefdfec6
101
docs/plans/2026-04-11-settings-config-state-workflow-design.md
Normal file
101
docs/plans/2026-04-11-settings-config-state-workflow-design.md
Normal 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.
|
||||
@ -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;
|
||||
|
||||
@ -384,7 +384,7 @@ Uri? resolveSingleAgentEndpointRuntimeInternal(
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
final endpoint = controller.settings
|
||||
.externalAcpEndpointForProvider(provider)
|
||||
.providerSyncDefinitionForProvider(provider)
|
||||
.endpoint
|
||||
.trim();
|
||||
if (endpoint.isEmpty) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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'};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
143
lib/runtime/runtime_models_ui_state.dart
Normal file
143
lib/runtime/runtime_models_ui_state.dart
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
181
test/runtime/account_sync_overwrite_test.dart
Normal file
181
test/runtime/account_sync_overwrite_test.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
88
test/runtime/secure_config_store_ui_state_test.dart
Normal file
88
test/runtime/secure_config_store_ui_state_test.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
65
test/runtime/settings_store_v1_test.dart
Normal file
65
test/runtime/settings_store_v1_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user