mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-08-29 13:31:44 +00:00
* feat: add direct provider connections * fix: harden direct connection workflows * fix: stabilize direct connection state * fix: ignore failed direct image attachments * fix: serialize direct profile reloads * fix: recheck queued media upload routes * fix: prune stale direct models during refresh * feat: polish adaptive backend onboarding * fix: restore explicit backend onboarding back paths Hermes and Direct onboarding are entered with replacement routes, so they cannot rely on an implicit navigator pop. Give both flows explicit destinations and align their setup screens with the adaptive auth shell. * fix: harden direct onboarding state Re-read profiles after asynchronous confirmation, guard discovery writes after disposal, and keep disabled segments unselected. Consolidate the shared onboarding shell and header-security resets to prevent drift. * fix: keep local backends independent after logout Logout intentionally retains OpenWebUI server state, so routing and model selection now key off resolved backend usability, terminal auth, trusted Direct bindings, and auth-session identity across loading, error, retained-value, cold-start, and backend-switch races. * fix: preserve chat storage boundaries on recovery Treat OpenWebUI cache reads as best-effort only when ownership is explicit, retain ambiguity and Direct-local failures instead of crossing providers, and contain asynchronous default-model cache write errors. Regression tests cover all three paths. * fix: retain legacy OpenWebUI chat ownership When the merged chat list is still loading, an explicitly OpenWebUI-scoped active summary now restores ownership for legacy raw IDs without trusting Direct-local or unannotated rows. This prevents same-ID local chats from making a valid OpenWebUI conversation ambiguous. * feat: migrate direct providers to typed SDKs * fix: isolate direct transport and reject blank streams * fix: hide stale direct model bindings * fix: apply CodeRabbit auto-fixes * feat: support Hermes attachments via Responses API * fix: address direct and Hermes review findings * fix: avoid OpenWebUI settings load in direct composer * fix: preserve images during direct regeneration * fix: preserve repeated direct images across turns * fix: settle direct reasoning and Android chrome * fix: harden multi-backend streaming ownership * fix: harden multi-backend recovery lifecycles * fix: close adversarial multi-backend edge cases * fix: separate Responses reasoning items * fix: preserve streamed reasoning boundaries * fix: address final multi-backend review findings * fix: close remaining security review findings * fix: address PR review findings
148 lines
4.9 KiB
Dart
148 lines
4.9 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:checks/checks.dart';
|
|
import 'package:conduit/core/database/app_database.dart';
|
|
import 'package:conduit/core/database/database_manager.dart';
|
|
import 'package:conduit/core/database/database_provider.dart';
|
|
import 'package:conduit/core/models/server_config.dart';
|
|
import 'package:conduit/core/providers/app_providers.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../../support/gated_close_database.dart';
|
|
|
|
const _alpha = ServerConfig(
|
|
id: 'alpha',
|
|
name: 'Alpha',
|
|
url: 'https://alpha.example',
|
|
);
|
|
const _beta = ServerConfig(
|
|
id: 'beta',
|
|
name: 'Beta',
|
|
url: 'https://beta.example',
|
|
);
|
|
|
|
final _serverSelectionProvider =
|
|
NotifierProvider<_ServerSelection, ServerConfig>(_ServerSelection.new);
|
|
|
|
class _ServerSelection extends Notifier<ServerConfig> {
|
|
@override
|
|
ServerConfig build() => _alpha;
|
|
|
|
void set(ServerConfig server) => state = server;
|
|
}
|
|
|
|
void main() {
|
|
test(
|
|
'app database becomes temporarily unavailable during rapid switch-back',
|
|
() async {
|
|
final opened = <String, List<GatedCloseDatabase>>{};
|
|
final manager = DatabaseManager(
|
|
openDatabase: (fileName) {
|
|
final database = GatedCloseDatabase.memory(failClose: false);
|
|
opened.putIfAbsent(fileName, () => []).add(database);
|
|
return database;
|
|
},
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
reviewerModeProvider.overrideWithValue(false),
|
|
activeServerProvider.overrideWith(
|
|
(ref) async => ref.watch(_serverSelectionProvider),
|
|
),
|
|
databaseManagerProvider.overrideWithValue(manager),
|
|
],
|
|
);
|
|
final subscription = container.listen<AppDatabase?>(
|
|
appDatabaseProvider,
|
|
(_, _) {},
|
|
fireImmediately: true,
|
|
);
|
|
final alphaFile = DatabaseManager.fileNameFor(_alpha.id);
|
|
final alphaCloseGate = Completer<void>();
|
|
|
|
addTearDown(() async {
|
|
if (!alphaCloseGate.isCompleted) alphaCloseGate.complete();
|
|
subscription.close();
|
|
container.dispose();
|
|
await manager.closeActive();
|
|
});
|
|
|
|
container.read(openWebUiDatabaseAccessProvider.notifier).open();
|
|
container
|
|
.read(openWebUiCertifiedDatabaseServerProvider.notifier)
|
|
.set(_alpha.id);
|
|
final originalAlpha = await _waitForDatabase(container, _alpha.id);
|
|
await originalAlpha.customSelect('SELECT 1').get();
|
|
opened[alphaFile]!.single.closeGate = alphaCloseGate;
|
|
|
|
container
|
|
.read(openWebUiCertifiedDatabaseServerProvider.notifier)
|
|
.set(_beta.id);
|
|
container.read(_serverSelectionProvider.notifier).set(_beta);
|
|
await _waitForDatabase(container, _beta.id);
|
|
await _waitForCloseCall(opened[alphaFile]!.single);
|
|
|
|
container
|
|
.read(openWebUiCertifiedDatabaseServerProvider.notifier)
|
|
.set(_alpha.id);
|
|
container.read(_serverSelectionProvider.notifier).set(_alpha);
|
|
await _waitForActiveServer(container, _alpha.id);
|
|
|
|
// The old alpha executor still owns the SQLite path. The provider must
|
|
// represent that as temporary unavailability, not throw from its build
|
|
// or open a second executor concurrently.
|
|
check(container.read(appDatabaseProvider)).isNull();
|
|
check(opened[alphaFile]!.length).equals(1);
|
|
|
|
alphaCloseGate.complete();
|
|
final reopenedAlpha = await _waitForDatabase(container, _alpha.id);
|
|
|
|
check(identical(reopenedAlpha, originalAlpha)).isFalse();
|
|
check(opened[alphaFile]!.length).equals(2);
|
|
check((await reopenedAlpha.customSelect('SELECT 1').get())).isNotEmpty();
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _waitForCloseCall(GatedCloseDatabase database) async {
|
|
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
|
while (database.closeAttempts == 0) {
|
|
if (DateTime.now().isAfter(deadline)) {
|
|
throw TimeoutException('database close never started');
|
|
}
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
}
|
|
|
|
Future<void> _waitForActiveServer(
|
|
ProviderContainer container,
|
|
String serverId,
|
|
) async {
|
|
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
|
while (container.read(activeServerProvider).asData?.value?.id != serverId) {
|
|
if (DateTime.now().isAfter(deadline)) {
|
|
throw TimeoutException('active server never became $serverId');
|
|
}
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
}
|
|
|
|
Future<AppDatabase> _waitForDatabase(
|
|
ProviderContainer container,
|
|
String serverId,
|
|
) async {
|
|
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
|
while (true) {
|
|
final database = container.read(appDatabaseProvider);
|
|
if (database != null &&
|
|
container.read(databaseManagerProvider).serverIdForDatabase(database) ==
|
|
serverId) {
|
|
return database;
|
|
}
|
|
if (DateTime.now().isAfter(deadline)) {
|
|
throw TimeoutException('database for $serverId never became available');
|
|
}
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
}
|