@tduncan / Cortana / commits / 5efd361

fix: add robust server type resolution and entity healing logic for ObjectBox servers

abdulmominsakib committed Sep 25, 2026 at 08:09 UTC 5efd36189512686ce585a2509fd0555e1f61f0b9
15 files changed +619 -45
lib/bootstrap/bootstrap_host.dart
+3 -1
@@ -92,7 +92,9 @@ class _BootstrapHostState extends State<BootstrapHost> {
92
93 _updateStage(BootstrapStage.configuringServer, 'Configuring server...');
94 final servers = await container.read(serversProvider.future);
95 - final hasOnDevice = servers.any((s) => s.type == ServerType.onDevice);
95 + final hasOnDevice = servers.any(
96 + (s) => s.type == ServerType.onDevice || s.id == 'on-device',
97 + );
98 if (!hasOnDevice) {
99 final server = Server(
100 id: 'on-device',
lib/core/storage/entities.dart
+62 -3
@@ -75,13 +75,72 @@ class ServerEntity {
75 );
76 }
77
78 + static ServerType resolveServerType({
79 + required int typeIndex,
80 + required String id,
81 + required String name,
82 + required String host,
83 + required int port,
84 + String? apiKey,
85 + }) {
86 + final trimmedHost = host.trim();
87 + final lowerId = id.trim().toLowerCase();
88 + final lowerName = name.trim().toLowerCase();
89 +
90 + // 1. On-Device server detection:
91 + // On-device servers always have id == 'on-device' or name == 'On-Device',
92 + // or host is empty with port 0 without API key.
93 + // Prior to Ollama Cloud being added (commit 51996bb), onDevice had typeIndex 4.
94 + // In the updated enum, index 4 is openRouter and index 5 is onDevice.
95 + if (lowerId == 'on-device' ||
96 + (lowerName == 'on-device' && trimmedHost.isEmpty && port <= 0) ||
97 + (trimmedHost.isEmpty &&
98 + port <= 0 &&
99 + (apiKey == null || apiKey.trim().isEmpty))) {
100 + return ServerType.onDevice;
101 + }
102 +
103 + // 2. OpenRouter server detection / drift resolution:
104 + // Prior to Ollama Cloud being added, openRouter had typeIndex 3.
105 + // In the updated enum, index 3 is ollamaCloud and index 4 is openRouter.
106 + final isProbablyOpenRouter =
107 + trimmedHost.contains('openrouter.ai') ||
108 + lowerName.contains('openrouter') ||
109 + (apiKey != null && apiKey.trim().startsWith('sk-or'));
110 + if (isProbablyOpenRouter) {
111 + return ServerType.openRouter;
112 + }
113 +
114 + // 3. Ollama Cloud detection:
115 + final isProbablyOllamaCloud =
116 + trimmedHost.contains('ollama.com') ||
117 + lowerName.contains('ollama cloud');
118 + if (isProbablyOllamaCloud) {
119 + return ServerType.ollamaCloud;
120 + }
121 +
122 + // 4. Default bounds-checked enum resolution:
123 + if (typeIndex >= 0 && typeIndex < ServerType.values.length) {
124 + return ServerType.values[typeIndex];
125 + }
126 +
127 + return ServerType.values.first;
128 + }
129 +
130 Server toDomain() {
131 + final resolvedType = resolveServerType(
132 + typeIndex: typeIndex,
133 + id: id,
134 + name: name,
135 + host: host,
136 + port: port,
137 + apiKey: apiKey,
138 + );
139 +
140 return Server(
141 id: id,
142 name: name,
82 - type: typeIndex >= 0 && typeIndex < ServerType.values.length
83 - ? ServerType.values[typeIndex]
84 - : ServerType.values.first,
143 + type: resolvedType,
144 host: host,
145 port: port,
146 apiKey: apiKey,
lib/core/storage/objectbox_store.dart
+2 -2
@@ -17,7 +17,7 @@ class ObjectBoxStore {
17 late final Box<SavedMessageEntity> savedMessageBox;
18 late final Box<SavedMessageFolderEntity> savedMessageFolderBox;
19
20 - ObjectBoxStore._create(this.store) {
20 + ObjectBoxStore.fromStore(this.store) {
21 serverBox = Box<ServerEntity>(store);
22 personaBox = Box<PersonaEntity>(store);
23 conversationBox = Box<ConversationEntity>(store);
@@ -37,6 +37,6 @@ class ObjectBoxStore {
37 }
38
39 final store = await openStore(directory: storeDir.path);
40 - return ObjectBoxStore._create(store);
40 + return ObjectBoxStore.fromStore(store);
41 }
42 }
lib/features/chat/providers/chat_service_providers.dart
+2 -2
@@ -60,7 +60,7 @@ ChatService createChatServiceForServer({
60 bool imageCompressionEnabled = true,
61 ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
62 }) {
63 - if (server.type == ServerType.onDevice) {
63 + if (server.isOnDevice) {
64 if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
65 return OnDeviceLlamaChatService(onDeviceLlamaService);
66 }
@@ -70,7 +70,7 @@ ChatService createChatServiceForServer({
70 }
71
72 return ChatService.forServer(
73 - server.type,
73 + ServerType.onDevice,
74 dio,
75 onDeviceGemma: onDeviceGemmaService,
76 imageCompressionEnabled: imageCompressionEnabled,
lib/features/chat/providers/model_selection_providers.dart
+9 -7
@@ -52,7 +52,7 @@ final activeChatTargetProvider = Provider<ActiveChatTarget>((ref) {
52
53 final selected = ref.watch(selectedModelProvider);
54 final selectedForServer = selected?.serverId == server.id ? selected : null;
55 - if (server.type == ServerType.onDevice) {
55 + if (server.isOnDevice) {
56 final engine = ref.watch(onDeviceEngineProvider);
57 if (selectedForServer != null &&
58 engine.status == OnDeviceEngineStatus.loaded &&
@@ -200,6 +200,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
200 // default there's nothing to auto-select for them (avoids an extra model
201 // list fetch for users who never set one).
202 if (!hasDefaultForServer &&
203 + !activeServer.isOnDevice &&
204 (activeServer.type == ServerType.openRouter ||
205 activeServer.type == ServerType.requesty ||
206 activeServer.type == ServerType.openAICompatible)) {
@@ -208,7 +209,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
209
210 try {
211 final Set<String> loadedModels;
211 - if (activeServer.type == ServerType.onDevice) {
212 + if (activeServer.isOnDevice) {
213 final engine = ref.watch(onDeviceEngineProvider);
214 loadedModels = engine.loadedModelId != null
215 ? {engine.loadedModelId!}
@@ -232,7 +233,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
233 .where((m) => m.id == defaultModelId)
234 .firstOrNull;
235 if (defaultModel != null) {
235 - if (activeServer.type == ServerType.onDevice) {
236 + if (activeServer.isOnDevice) {
237 // On-device inference runs one engine instance at a time, so only
238 // auto-select the default when it's the model the engine already
239 // has loaded; otherwise fall through to the loaded-model logic.
@@ -260,14 +261,15 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
261
262 // Cloud providers fall back to no selection when the configured default
263 // isn't among the available models.
263 - if (activeServer.type == ServerType.openRouter ||
264 - activeServer.type == ServerType.requesty ||
265 - activeServer.type == ServerType.openAICompatible) {
264 + if (!activeServer.isOnDevice &&
265 + (activeServer.type == ServerType.openRouter ||
266 + activeServer.type == ServerType.requesty ||
267 + activeServer.type == ServerType.openAICompatible)) {
268 return;
269 }
270
271 if (loadedModels.isEmpty) {
270 - if (activeServer.type == ServerType.onDevice) {
272 + if (activeServer.isOnDevice) {
273 final downloadedAsync = await ref.read(downloadedModelsProvider.future);
274 if (!ref.mounted) return;
275 if (hasDefaultForServer) {
lib/features/models/views/model_picker_sheet.dart
+10 -2
@@ -82,7 +82,14 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
82 .where((s) => s.id == activeServer?.id)
83 .firstOrNull;
84 final isOnDevice =
85 - currentServer != null && currentServer.type == ServerType.onDevice;
85 + (activeServer != null &&
86 + (activeServer.type == ServerType.onDevice ||
87 + activeServer.id == 'on-device' ||
88 + activeServer.name.trim().toLowerCase() == 'on-device')) ||
89 + (currentServer != null &&
90 + (currentServer.type == ServerType.onDevice ||
91 + currentServer.id == 'on-device' ||
92 + currentServer.name.trim().toLowerCase() == 'on-device'));
93 final isLmStudio =
94 currentServer != null && currentServer.type == ServerType.lmStudio;
95
@@ -192,7 +199,8 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
199 final l10n = AppLocalizations.of(context)!;
200
201 try {
195 - if (activeServer.type == ServerType.onDevice) {
202 + if (activeServer.type == ServerType.onDevice ||
203 + activeServer.id == 'on-device') {
204 await ref.read(onDeviceEngineProvider.notifier).unloadModel();
205 } else {
206 final loadedInstances = await ref.read(
lib/features/on_device/providers/on_device_providers.dart
+2 -4
@@ -497,12 +497,10 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
497 );
498
499 final activeServer = ref.read(activeServerProvider);
500 - if (activeServer?.type != ServerType.onDevice) {
500 + if (activeServer?.isOnDevice != true) {
501 final servers = await ref.read(serversProvider.future);
502 if (!ref.mounted) return;
503 - final onDeviceServer = servers
504 - .where((s) => s.type == ServerType.onDevice)
505 - .firstOrNull;
503 + final onDeviceServer = servers.where((s) => s.isOnDevice).firstOrNull;
504 if (onDeviceServer != null && ref.mounted) {
505 Future.microtask(() {
506 if (ref.mounted) {
lib/features/servers/data/models/server.dart
+1 -1
@@ -302,7 +302,7 @@ class Server {
302 }
303 }
304
305 - bool get isOnDevice => type == ServerType.onDevice;
305 + bool get isOnDevice => type == ServerType.onDevice || id == 'on-device';
306 bool get isOllamaCloud => type == ServerType.ollamaCloud;
307 bool get isOllamaFamily =>
308 type == ServerType.ollama || type == ServerType.ollamaCloud;
lib/features/servers/data/server_api_service.dart
+8 -5
@@ -12,7 +12,7 @@ class ServerApiService {
12 ServerApiService(this._dio);
13
14 Future<bool> testConnection(Server server) async {
15 - if (server.type == ServerType.onDevice) {
15 + if (server.isOnDevice) {
16 return true;
17 }
18
@@ -67,6 +67,9 @@ class ServerApiService {
67 }
68
69 Future<List<ModelInfo>> fetchModels(Server server) async {
70 + if (server.isOnDevice) {
71 + return [];
72 + }
73 try {
74 Response response;
75 try {
@@ -122,7 +125,7 @@ class ServerApiService {
125 if (server.type == ServerType.openRouter ||
126 server.type == ServerType.requesty ||
127 server.type == ServerType.openAICompatible ||
125 - server.type == ServerType.onDevice) {
128 + server.isOnDevice) {
129 return {};
130 }
131
@@ -144,7 +147,7 @@ class ServerApiService {
147 Future<void> loadModel(Server server, String modelId) async {
148 if (server.type == ServerType.openRouter ||
149 server.type == ServerType.requesty ||
147 - server.type == ServerType.onDevice) {
150 + server.isOnDevice) {
151 return;
152 }
153
@@ -182,7 +185,7 @@ class ServerApiService {
185 }) async {
186 if (server.type == ServerType.openRouter ||
187 server.type == ServerType.requesty ||
185 - server.type == ServerType.onDevice) {
188 + server.isOnDevice) {
189 return null;
190 }
191
@@ -265,7 +268,7 @@ class ServerApiService {
268 }) async {
269 if (server.type == ServerType.openRouter ||
270 server.type == ServerType.requesty ||
268 - server.type == ServerType.onDevice) {
271 + server.isOnDevice) {
272 return;
273 }
274
lib/features/servers/providers/server_providers.dart
+61 -6
@@ -114,7 +114,7 @@ final loadedModelsProvider = FutureProvider.family<Set<String>, Server>((
114 ) async {
115 ref.watch(loadedModelsRefreshProvider);
116
117 - if (server.type == ServerType.onDevice) {
117 + if (server.type == ServerType.onDevice || server.id == 'on-device') {
118 final engineState = ref.watch(onDeviceEngineProvider);
119 return engineState.loadedModelId != null
120 ? {engineState.loadedModelId!}
@@ -147,7 +147,7 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
147 );
148 final apiService = ref.watch(serverApiServiceProvider);
149
150 - if (server.type == ServerType.onDevice) {
150 + if (server.type == ServerType.onDevice || server.id == 'on-device') {
151 final models = ref
152 .watch(onDeviceModelsProvider)
153 .map(
@@ -207,13 +207,67 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
207 if (!ref.mounted) return [];
208 final db = ref.read(databaseProvider);
209 final entities = db.serverBox.getAll();
210 - return entities.map((e) => e.toDomain()).toList();
210 +
211 + // Group entities by id to detect and remove duplicate records.
212 + final entitiesById = <String, List<ServerEntity>>{};
213 + for (final entity in entities) {
214 + entitiesById.putIfAbsent(entity.id, () => []).add(entity);
215 + }
216 +
217 + final sanitizedEntities = <ServerEntity>[];
218 + for (final entry in entitiesById.entries) {
219 + final list = entry.value;
220 + if (list.length == 1) {
221 + sanitizedEntities.add(list.first);
222 + } else {
223 + // Prefer isDefault, then entity whose typeIndex matches the resolved domain type,
224 + // then lowest internalId.
225 + list.sort((a, b) {
226 + if (a.isDefault != b.isDefault) {
227 + return a.isDefault ? -1 : 1;
228 + }
229 + final aResolved = a.toDomain().type.index == a.typeIndex;
230 + final bResolved = b.toDomain().type.index == b.typeIndex;
231 + if (aResolved != bResolved) {
232 + return aResolved ? -1 : 1;
233 + }
234 + return a.internalId.compareTo(b.internalId);
235 + });
236 +
237 + final keep = list.first;
238 + sanitizedEntities.add(keep);
239 +
240 + for (var i = 1; i < list.length; i++) {
241 + db.serverBox.remove(list[i].internalId);
242 + }
243 + }
244 + }
245 +
246 + // Persist any healed typeIndex back to ObjectBox.
247 + for (final entity in sanitizedEntities) {
248 + final domain = entity.toDomain();
249 + if (entity.typeIndex != domain.type.index) {
250 + entity.typeIndex = domain.type.index;
251 + db.serverBox.put(entity);
252 + }
253 + }
254 +
255 + return sanitizedEntities.map((e) => e.toDomain()).toList();
256 }
257
258 Future<void> addServer(Server server) async {
259 if (!ref.mounted) return;
260 final db = ref.read(databaseProvider);
216 - db.serverBox.put(ServerEntity.fromDomain(server));
261 + final existing = db.serverBox
262 + .getAll()
263 + .where((e) => e.id == server.id)
264 + .firstOrNull;
265 +
266 + final entity = ServerEntity.fromDomain(server);
267 + if (existing != null) {
268 + entity.internalId = existing.internalId;
269 + }
270 + db.serverBox.put(entity);
271 invalidateAvailableModelsCache(server.id);
272 final data = await _loadAll();
273 if (ref.mounted) {
@@ -327,7 +381,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
381 if (index == -1) return ConnectionStatus.disconnected;
382 final server = servers[index];
383
330 - if (server.type == ServerType.onDevice) {
384 + if (server.type == ServerType.onDevice || server.id == 'on-device') {
385 await updateServerStatus(serverId, ConnectionStatus.connected);
386 return ConnectionStatus.connected;
387 }
@@ -367,7 +421,8 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
421 return ConnectionStatus.disconnected;
422 }
423
370 - if (activeServer.type == ServerType.onDevice) {
424 + if (activeServer.type == ServerType.onDevice ||
425 + activeServer.id == 'on-device') {
426 return ConnectionStatus.connected;
427 }
428
lib/features/servers/views/components/server_card.dart
+1 -1
@@ -65,7 +65,7 @@ class ServerCard extends StatelessWidget {
65 if (server.type == ServerType.ollamaCloud) {
66 return l10n.server_address_ollama_cloud;
67 }
68 - if (server.type == ServerType.onDevice) {
68 + if (server.isOnDevice) {
69 return l10n.server_address_on_device;
70 }
71 return server.displayAddress;
lib/features/servers/views/server_list_screen.dart
+1 -3
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:go_router/go_router.dart';
5 import 'package:localmind/l10n/app_localizations.dart';
6 -import '../../../core/models/enums.dart';
6 import '../../../core/providers/service_providers.dart';
7 import '../../../core/routes/app_routes.dart';
8 import '../../../core/utils/system_insets.dart';
@@ -101,8 +100,7 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
100 itemCount: serverList.length,
101 itemBuilder: (context, index) {
102 final server = serverList[index];
104 - final isOnDevice =
105 - server.type == ServerType.onDevice;
103 + final isOnDevice = server.isOnDevice;
104 return Padding(
105 padding: const EdgeInsets.only(bottom: 12),
106 child: ServerCard(
test/features/chat/providers/active_chat_target_provider_test.dart
+10 -8
@@ -49,14 +49,16 @@ void main() {
49 );
50 addTearDown(container.dispose);
51
52 - container.read(selectedModelProvider.notifier).setModel(
53 - ModelInfo(
54 - id: 'apple-foundation-models',
55 - name: 'Apple Foundation Models',
56 - serverType: ServerType.onDevice,
57 - serverId: 'on-device',
58 - ),
59 - );
52 + container
53 + .read(selectedModelProvider.notifier)
54 + .setModel(
55 + ModelInfo(
56 + id: 'apple-foundation-models',
57 + name: 'Apple Foundation Models',
58 + serverType: ServerType.onDevice,
59 + serverId: 'on-device',
60 + ),
61 + );
62
63 final target = container.read(activeChatTargetProvider);
64
test/features/servers/server_entity_healing_test.dart new
+256
@@ -0,0 +1,256 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/core/models/enums.dart';
3 +import 'package:localmind/core/storage/entities.dart';
4 +import 'package:localmind/features/servers/data/models/server.dart';
5 +
6 +void main() {
7 + group('ServerEntity.resolveServerType', () {
8 + test(
9 + 'resolves on-device server when typeIndex was 4 before Ollama Cloud was inserted',
10 + () {
11 + // Prior to Ollama Cloud (commit 51996bb), onDevice had index 4.
12 + // After inserting Ollama Cloud at index 3, index 4 became openRouter.
13 + // Existing on-device records in ObjectBox have typeIndex: 4.
14 + final type = ServerEntity.resolveServerType(
15 + typeIndex: 4,
16 + id: 'on-device',
17 + name: 'On-Device',
18 + host: '',
19 + port: 0,
20 + );
21 +
22 + expect(type, ServerType.onDevice);
23 + },
24 + );
25 +
26 + test('resolves on-device server by id even if typeIndex is unexpected', () {
27 + final type = ServerEntity.resolveServerType(
28 + typeIndex: 99,
29 + id: 'on-device',
30 + name: 'Custom On Device',
31 + host: '',
32 + port: 0,
33 + );
34 +
35 + expect(type, ServerType.onDevice);
36 + });
37 +
38 + test(
39 + 'resolves on-device server created with timestamp id from onboarding',
40 + () {
41 + final type = ServerEntity.resolveServerType(
42 + typeIndex: 4,
43 + id: '1720000000000',
44 + name: 'On-Device',
45 + host: '',
46 + port: 0,
47 + );
48 +
49 + expect(type, ServerType.onDevice);
50 + },
51 + );
52 +
53 + test(
54 + 'resolves on-device server with empty host and port 0 without API key',
55 + () {
56 + final type = ServerEntity.resolveServerType(
57 + typeIndex: 4,
58 + id: 'custom-local',
59 + name: 'Local inference',
60 + host: '',
61 + port: 0,
62 + );
63 +
64 + expect(type, ServerType.onDevice);
65 + },
66 + );
67 +
68 + test(
69 + 'resolves openRouter server when typeIndex was 3 before Ollama Cloud was inserted',
70 + () {
71 + // Prior to Ollama Cloud, openRouter had index 3.
72 + // When Ollama Cloud was inserted at index 3, existing OpenRouter records
73 + // were deserialized as ollamaCloud.
74 + final type = ServerEntity.resolveServerType(
75 + typeIndex: 3,
76 + id: 'openrouter-1',
77 + name: 'OpenRouter',
78 + host: 'https://openrouter.ai/api/v1',
79 + port: 443,
80 + apiKey: 'sk-or-v1-testkey',
81 + );
82 +
83 + expect(type, ServerType.openRouter);
84 + },
85 + );
86 +
87 + test(
88 + 'resolves openRouter server by sk-or apiKey even with generic host',
89 + () {
90 + final type = ServerEntity.resolveServerType(
91 + typeIndex: 3,
92 + id: 'openrouter-2',
93 + name: 'Cloud AI',
94 + host: 'https://openrouter.ai',
95 + port: 443,
96 + apiKey: 'sk-or-testkey',
97 + );
98 +
99 + expect(type, ServerType.openRouter);
100 + },
101 + );
102 +
103 + test('resolves ollamaCloud correctly when host points to ollama.com', () {
104 + final type = ServerEntity.resolveServerType(
105 + typeIndex: 3,
106 + id: 'ollama-cloud-1',
107 + name: 'Ollama Cloud',
108 + host: 'https://ollama.com',
109 + port: 443,
110 + );
111 +
112 + expect(type, ServerType.ollamaCloud);
113 + });
114 +
115 + test('preserves standard server types by valid typeIndex', () {
116 + expect(
117 + ServerEntity.resolveServerType(
118 + typeIndex: 0,
119 + id: 'lm-studio',
120 + name: 'LM Studio',
121 + host: 'localhost',
122 + port: 1234,
123 + ),
124 + ServerType.lmStudio,
125 + );
126 +
127 + expect(
128 + ServerEntity.resolveServerType(
129 + typeIndex: 1,
130 + id: 'oai-compat',
131 + name: 'Custom OpenAI',
132 + host: 'http://localhost:8000',
133 + port: 8000,
134 + ),
135 + ServerType.openAICompatible,
136 + );
137 +
138 + expect(
139 + ServerEntity.resolveServerType(
140 + typeIndex: 2,
141 + id: 'ollama-local',
142 + name: 'Ollama',
143 + host: 'http://localhost:11434',
144 + port: 11434,
145 + ),
146 + ServerType.ollama,
147 + );
148 +
149 + expect(
150 + ServerEntity.resolveServerType(
151 + typeIndex: 5,
152 + id: 'on-device-new',
153 + name: 'On-Device',
154 + host: '',
155 + port: 0,
156 + ),
157 + ServerType.onDevice,
158 + );
159 +
160 + expect(
161 + ServerEntity.resolveServerType(
162 + typeIndex: 6,
163 + id: 'requesty-1',
164 + name: 'Requesty',
165 + host: 'router.requesty.ai',
166 + port: 443,
167 + ),
168 + ServerType.requesty,
169 + );
170 + });
171 + });
172 +
173 + group('ServerEntity.toDomain healing', () {
174 + test('heals corrupted on-device entity to ServerType.onDevice', () {
175 + final now = DateTime(2026, 1, 1);
176 + final entity = ServerEntity(
177 + id: 'on-device',
178 + name: 'On-Device',
179 + typeIndex: 4, // Was onDevice, shifted to openRouter
180 + host: '',
181 + port: 0,
182 + createdAt: now,
183 + lastConnectedAt: now,
184 + statusIndex: 1,
185 + );
186 +
187 + final domain = entity.toDomain();
188 + expect(domain.id, 'on-device');
189 + expect(domain.type, ServerType.onDevice);
190 + expect(domain.isOnDevice, isTrue);
191 + });
192 +
193 + test('heals shifted OpenRouter entity to ServerType.openRouter', () {
194 + final now = DateTime(2026, 1, 1);
195 + final entity = ServerEntity(
196 + id: 'or-server',
197 + name: 'OpenRouter Cloud',
198 + typeIndex: 3, // Was openRouter, shifted to ollamaCloud
199 + host: 'https://openrouter.ai/api/v1',
200 + port: 443,
201 + apiKey: 'sk-or-v1-abcdef',
202 + createdAt: now,
203 + lastConnectedAt: now,
204 + statusIndex: 1,
205 + );
206 +
207 + final domain = entity.toDomain();
208 + expect(domain.type, ServerType.openRouter);
209 + expect(domain.isOnDevice, isFalse);
210 + });
211 + });
212 +
213 + group('Server.isOnDevice', () {
214 + test('returns true for ServerType.onDevice', () {
215 + final server = Server(
216 + id: 'custom-id',
217 + name: 'On-Device',
218 + type: ServerType.onDevice,
219 + host: '',
220 + port: 0,
221 + createdAt: DateTime(2026, 1, 1),
222 + lastConnectedAt: DateTime(2026, 1, 1),
223 + );
224 +
225 + expect(server.isOnDevice, isTrue);
226 + });
227 +
228 + test('returns true for id on-device even if type was corrupted', () {
229 + final server = Server(
230 + id: 'on-device',
231 + name: 'On-Device',
232 + type: ServerType.openRouter,
233 + host: '',
234 + port: 0,
235 + createdAt: DateTime(2026, 1, 1),
236 + lastConnectedAt: DateTime(2026, 1, 1),
237 + );
238 +
239 + expect(server.isOnDevice, isTrue);
240 + });
241 +
242 + test('returns false for remote servers', () {
243 + final server = Server(
244 + id: 'cloud-server',
245 + name: 'OpenRouter',
246 + type: ServerType.openRouter,
247 + host: 'https://openrouter.ai',
248 + port: 443,
249 + createdAt: DateTime(2026, 1, 1),
250 + lastConnectedAt: DateTime(2026, 1, 1),
251 + );
252 +
253 + expect(server.isOnDevice, isFalse);
254 + });
255 + });
256 +}
test/features/servers/server_providers_healing_test.dart new
+191
@@ -0,0 +1,191 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/core/providers/storage_providers.dart';
5 +import 'package:localmind/core/storage/entities.dart';
6 +import 'package:localmind/core/storage/objectbox_store.dart';
7 +import 'package:localmind/features/servers/data/models/server.dart';
8 +import 'package:localmind/features/servers/providers/server_providers.dart';
9 +import 'package:localmind/objectbox.g.dart';
10 +
11 +void main() {
12 + late _FakeServerBox fakeServerBox;
13 + late _FakeObjectBoxStore fakeStore;
14 +
15 + setUp(() {
16 + fakeServerBox = _FakeServerBox();
17 + fakeStore = _FakeObjectBoxStore(fakeServerBox);
18 + });
19 +
20 + test(
21 + 'ServersNotifier._loadAll deduplicates servers with duplicate id and heals typeIndex in ObjectBox',
22 + () async {
23 + final now = DateTime(2026, 1, 1);
24 +
25 + // Seed duplicate on-device entities:
26 + // Entity 1: corrupted typeIndex 4 (shifted from onDevice to openRouter)
27 + final corruptedEntity = ServerEntity(
28 + internalId: 1,
29 + id: 'on-device',
30 + name: 'On-Device',
31 + typeIndex: 4,
32 + host: '',
33 + port: 0,
34 + createdAt: now,
35 + lastConnectedAt: now,
36 + statusIndex: 1,
37 + );
38 + // Entity 2: duplicate on-device inserted by bootstrap with typeIndex 5
39 + final duplicateEntity = ServerEntity(
40 + internalId: 2,
41 + id: 'on-device',
42 + name: 'On-Device',
43 + typeIndex: 5,
44 + host: '',
45 + port: 0,
46 + createdAt: now,
47 + lastConnectedAt: now,
48 + statusIndex: 1,
49 + );
50 +
51 + fakeServerBox.put(corruptedEntity);
52 + fakeServerBox.put(duplicateEntity);
53 + expect(fakeServerBox.count(), 2);
54 +
55 + final container = ProviderContainer(
56 + overrides: [databaseProvider.overrideWithValue(fakeStore)],
57 + );
58 + addTearDown(container.dispose);
59 +
60 + final servers = await container.read(serversProvider.future);
61 +
62 + // Exactly one on-device server should be returned
63 + expect(servers.length, 1);
64 + final onDevice = servers.first;
65 + expect(onDevice.id, 'on-device');
66 + expect(onDevice.type, ServerType.onDevice);
67 + expect(onDevice.isOnDevice, isTrue);
68 +
69 + // The database itself should now only have 1 entity, with healed typeIndex 5
70 + expect(fakeServerBox.count(), 1);
71 + final persisted = fakeServerBox.getAll().first;
72 + expect(persisted.id, 'on-device');
73 + expect(persisted.typeIndex, ServerType.onDevice.index);
74 + },
75 + );
76 +
77 + test(
78 + 'ServersNotifier._loadAll heals shifted typeIndex 4 when single on-device record exists',
79 + () async {
80 + final now = DateTime(2026, 1, 1);
81 + final corruptedEntity = ServerEntity(
82 + internalId: 1,
83 + id: 'on-device',
84 + name: 'On-Device',
85 + typeIndex: 4, // Was onDevice, shifted to openRouter
86 + host: '',
87 + port: 0,
88 + createdAt: now,
89 + lastConnectedAt: now,
90 + statusIndex: 1,
91 + );
92 +
93 + fakeServerBox.put(corruptedEntity);
94 +
95 + final container = ProviderContainer(
96 + overrides: [databaseProvider.overrideWithValue(fakeStore)],
97 + );
98 + addTearDown(container.dispose);
99 +
100 + final servers = await container.read(serversProvider.future);
101 +
102 + expect(servers.length, 1);
103 + expect(servers.first.type, ServerType.onDevice);
104 + expect(fakeServerBox.getAll().first.typeIndex, ServerType.onDevice.index);
105 + },
106 + );
107 +
108 + test(
109 + 'ServersNotifier.addServer is idempotent and does not create duplicates for existing id',
110 + () async {
111 + final now = DateTime(2026, 1, 1);
112 + final server = Server(
113 + id: 'on-device',
114 + name: 'On-Device',
115 + type: ServerType.onDevice,
116 + host: '',
117 + port: 0,
118 + createdAt: now,
119 + lastConnectedAt: now,
120 + status: ConnectionStatus.connected,
121 + );
122 +
123 + final container = ProviderContainer(
124 + overrides: [databaseProvider.overrideWithValue(fakeStore)],
125 + );
126 + addTearDown(container.dispose);
127 +
128 + // Call addServer twice
129 + await container.read(serversProvider.notifier).addServer(server);
130 + await container.read(serversProvider.notifier).addServer(server);
131 +
132 + final servers = await container.read(serversProvider.future);
133 + expect(servers.length, 1);
134 + expect(fakeServerBox.count(), 1);
135 + },
136 + );
137 +}
138 +
139 +class _FakeServerBox extends Fake implements Box<ServerEntity> {
140 + final List<ServerEntity> _entities;
141 + int _nextId = 1;
142 +
143 + _FakeServerBox([List<ServerEntity>? initial])
144 + : _entities = List<ServerEntity>.from(initial ?? []) {
145 + for (final e in _entities) {
146 + if (e.internalId == 0) {
147 + e.internalId = _nextId++;
148 + } else if (e.internalId >= _nextId) {
149 + _nextId = e.internalId + 1;
150 + }
151 + }
152 + }
153 +
154 + @override
155 + List<ServerEntity> getAll() => List<ServerEntity>.from(_entities);
156 +
157 + @override
158 + int put(ServerEntity entity, {PutMode mode = PutMode.put}) {
159 + if (entity.internalId == 0) {
160 + entity.internalId = _nextId++;
161 + _entities.add(entity);
162 + } else {
163 + final idx = _entities.indexWhere(
164 + (e) => e.internalId == entity.internalId,
165 + );
166 + if (idx >= 0) {
167 + _entities[idx] = entity;
168 + } else {
169 + _entities.add(entity);
170 + }
171 + }
172 + return entity.internalId;
173 + }
174 +
175 + @override
176 + bool remove(int id) {
177 + final before = _entities.length;
178 + _entities.removeWhere((e) => e.internalId == id);
179 + return _entities.length < before;
180 + }
181 +
182 + @override
183 + int count({int limit = 0}) => _entities.length;
184 +}
185 +
186 +class _FakeObjectBoxStore extends Fake implements ObjectBoxStore {
187 + _FakeObjectBoxStore(this.serverBox);
188 +
189 + @override
190 + final Box<ServerEntity> serverBox;
191 +}