diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index bed84bee..06570b56 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -73,6 +73,7 @@ class ActivePageLiveSyncNotifier extends Notifier { // Live reads can advance while local work blocks rehydration. Outbound diffs // must stay based on the server state that was actually loaded into canvas. final Map _hydratedBaseByEntityKey = {}; + final Set _remoteAdoptionPending = {}; @override ActivePageLiveSyncState build() { @@ -81,6 +82,7 @@ class ActivePageLiveSyncNotifier extends Notifier { void reset() { _hydratedBaseByEntityKey.clear(); + _remoteAdoptionPending.clear(); state = const ActivePageLiveSyncState(); } @@ -97,6 +99,7 @@ class ActivePageLiveSyncNotifier extends Notifier { activePageId != state.activePageId; if (strategyChanged) { _hydratedBaseByEntityKey.clear(); + _remoteAdoptionPending.clear(); } state = state.copyWith( strategyPublicId: strategyPublicId, @@ -138,6 +141,7 @@ class ActivePageLiveSyncNotifier extends Notifier { : _normalizedRemoteEntities(snapshot, pageId); _hydratedBaseByEntityKey.removeWhere((key, _) => key.pageId == pageId); _hydratedBaseByEntityKey.addAll(remoteEntities); + _remoteAdoptionPending.removeWhere((key) => key.pageId == pageId); final remoteRevisions = Map.from( state.remoteBaseRevisionByEntity, )..removeWhere((key, _) => key.pageId == pageId); @@ -159,6 +163,22 @@ class ActivePageLiveSyncNotifier extends Notifier { state = state.copyWith(lastAckBatch: intents); } + /// Stops local projection and reconciliation for explicitly discarded work + /// until the affected page has loaded the authoritative remote snapshot. + void adoptRemoteForEntities(Set entityKeys) { + if (entityKeys.isEmpty) return; + final overlays = Map.from( + state.overlayByEntityKey, + ); + for (final key in entityKeys) { + overlays.remove(key); + if (key.pageId != null) { + _remoteAdoptionPending.add(key); + } + } + state = state.copyWith(overlayByEntityKey: overlays); + } + Map? syncLocalPage({ required String strategyPublicId, required String pageId, @@ -194,6 +214,7 @@ class ActivePageLiveSyncNotifier extends Notifier { .where((key) => key.pageId == pageId), ...queueState.successorByEntityKey.keys .where((key) => key.pageId == pageId), + ..._remoteAdoptionPending.where((key) => key.pageId == pageId), }; final nextOverlay = Map.from( @@ -202,6 +223,11 @@ class ActivePageLiveSyncNotifier extends Notifier { final retainedDesiredOps = {}; for (final key in pageKeys) { + if (_remoteAdoptionPending.contains(key)) { + nextOverlay.remove(key); + _debugLog('overlay.remove $key reason=adopting_remote'); + continue; + } final remote = remoteEntities[key]; final local = localEntities[key]; final hydratedBase = _hydratedBaseByEntityKey[key]; diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index d715c6d6..5a92319b 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -112,6 +112,7 @@ class StrategyOpQueueNotifier extends Notifier { int _offlineRetryCount = 0; late DurableStrategyOutboxStore _store; late Map _recordsByStorageKey; + final Set _awaitingRemoteAdoption = {}; Future _writeTail = Future.value(); ConvexStrategyRepository get _repo => @@ -147,6 +148,7 @@ class StrategyOpQueueNotifier extends Notifier { _debounceTimer?.cancel(); _retryTimer?.cancel(); + _awaitingRemoteAdoption.clear(); _offlineRetryCount = 0; final matching = accountId == null || strategyPublicId == null ? const [] @@ -323,6 +325,7 @@ class StrategyOpQueueNotifier extends Notifier { var changed = false; try { for (final key in keys) { + if (_awaitingRemoteAdoption.contains(key)) continue; final desired = desiredOps[key]; final existing = queued[key]; final inFlightIntent = state.inFlightByEntityKey[key]; @@ -647,6 +650,79 @@ class StrategyOpQueueNotifier extends Notifier { }); } + /// Discards selected server-rejected intents after an explicit user choice. + /// + /// Each durable record contains both the rejected predecessor and any newer + /// successor for that entity. Removing the record discards both, without + /// changing unrelated queued, in-flight, paused, or rejected work. + Future> discardRejected( + Set entityKeys, + ) { + return _serializeWrite(() async { + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + final discarded = {}; + Object? persistenceError; + StackTrace? persistenceStackTrace; + + for (final key in entityKeys) { + final rejected = attention[key]; + final record = _recordForActiveKey(key); + if (rejected == null || + record == null || + record.status != DurableOutboxStatus.attention || + record.pending.op.opId != rejected.pending.op.opId) { + continue; + } + try { + await _store.remove(record.storageKey); + _recordsByStorageKey.remove(record.storageKey); + attention.remove(key); + successors.remove(key); + _awaitingRemoteAdoption.add(key); + discarded.add(key); + } catch (error, stackTrace) { + persistenceError = error; + persistenceStackTrace = stackTrace; + break; + } + } + + if (persistenceError != null) { + log( + 'Durable outbox persistence failed: $persistenceError', + name: 'strategy_outbox', + error: persistenceError, + stackTrace: persistenceStackTrace, + ); + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + final errorMessage = persistenceError == null + ? attentionMessage + : 'Cloud work could not be removed from the durable outbox: ' + '$persistenceError'; + state = state.copyWith( + attentionByEntityKey: attention, + successorByEntityKey: successors, + lastError: errorMessage, + clearError: errorMessage == null, + ); + return Set.unmodifiable(discarded); + }); + } + + void completeRemoteAdoption(Set entityKeys) { + _awaitingRemoteAdoption.removeAll(entityKeys); + } + Future flushNow() async { await _writeTail; if (state.isFlushing) return; @@ -968,12 +1044,14 @@ class StrategyOpQueueNotifier extends Notifier { _recordsByStorageKey.remove(record.storageKey); } - Future _serializeWrite(Future Function() action) { + Future _serializeWrite(Future Function() action) { final next = _writeTail.then((_) => action()); - _writeTail = next.catchError((Object error, StackTrace stackTrace) { - log('Outbox write failed: $error', - name: 'strategy_outbox', error: error, stackTrace: stackTrace); - }); + _writeTail = next.then((_) {}).catchError( + (Object error, StackTrace stackTrace) { + log('Outbox write failed: $error', + name: 'strategy_outbox', error: error, stackTrace: stackTrace); + }, + ); return next; } diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index f483af5b..31db01ef 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -110,6 +110,7 @@ final strategyPageSessionProvider = class StrategyPageSessionNotifier extends Notifier { _RemotePageHydrationKey? _lastHydratedRemotePageKey; bool _pendingRemoteReapply = false; + bool _isResolvingConflicts = false; @override StrategyPageSessionState build() { @@ -421,6 +422,70 @@ class StrategyPageSessionNotifier extends Notifier { } } + /// Adopts the cloud version for every current conflict in this strategy. + /// + /// The snapshot refresh happens before any local intent is discarded. Each + /// entity is removed from local projection only after its durable outbox + /// record has been deleted. + Future useCloudVersionsForRejected() async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + if (strategyState.source != StrategySource.cloud || strategyId == null) { + return false; + } + + _isResolvingConflicts = true; + try { + await _resolvePageSource(strategyId, StrategySource.cloud) + .flushCurrentPage(); + final strategyNotifier = ref.read(strategyProvider.notifier); + strategyNotifier.consumeScheduledCloudPageSync(); + strategyNotifier.consumeScheduledCloudStrategySync(); + final rejected = Map.from( + ref.read(strategyOpQueueProvider).attentionByEntityKey, + ); + if (rejected.isEmpty) return false; + + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.header.publicId != strategyId) { + return false; + } + + final discarded = await ref + .read(strategyOpQueueProvider.notifier) + .discardRejected(rejected.keys.toSet()); + if (discarded.isEmpty) return false; + + ref + .read(activePageLiveSyncProvider.notifier) + .adoptRemoteForEntities(discarded); + for (final entry in rejected.entries) { + if (discarded.contains(entry.key)) { + ref + .read(strategyConflictProvider.notifier) + .clear(entry.value.pending.op.opId); + } + } + + final targetPageId = _resolveHydrationTargetPage(snapshot); + if (targetPageId != null) { + await _rehydrateActivePageFromSource( + targetPageId, + hydrationKey: _buildRemotePageHydrationKey(snapshot, targetPageId), + preserveTextDrafts: true, + ); + ref + .read(strategyOpQueueProvider.notifier) + .completeRemoteAdoption(discarded); + } + _pendingRemoteReapply = false; + return true; + } finally { + _isResolvingConflicts = false; + } + } + bool get isApplyingPage => state.isApplyingPage; void setStateForTest(StrategyPageSessionState newState) { @@ -436,6 +501,7 @@ class StrategyPageSessionNotifier extends Notifier { ); _lastHydratedRemotePageKey = null; _pendingRemoteReapply = false; + _isResolvingConflicts = false; ref.read(activePageLiveSyncProvider.notifier).reset(); } @@ -486,6 +552,7 @@ class StrategyPageSessionNotifier extends Notifier { Future _rehydrateActivePageFromSource( String pageId, { _RemotePageHydrationKey? hydrationKey, + bool preserveTextDrafts = false, }) async { final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; @@ -511,6 +578,7 @@ class StrategyPageSessionNotifier extends Notifier { strategyId: strategyId, source: source, hydrationKey: hydrationKey, + preserveTextDrafts: preserveTextDrafts, ); } @@ -519,6 +587,7 @@ class StrategyPageSessionNotifier extends Notifier { required String strategyId, required StrategySource source, _RemotePageHydrationKey? hydrationKey, + bool preserveTextDrafts = false, }) async { final preserveHistory = source == StrategySource.cloud && _lastHydratedRemotePageKey?.strategyPublicId == strategyId && @@ -534,6 +603,9 @@ class StrategyPageSessionNotifier extends Notifier { await _resolvePageSource(strategyId, source).listPageIds(), ); + final retainedTextDrafts = preserveTextDrafts + ? Map.from(ref.read(textDraftProvider)) + : const {}; try { await applyStrategyEditorPageData( ref, @@ -542,6 +614,9 @@ class StrategyPageSessionNotifier extends Notifier { themeOverridePalette: themeOverridePalette, preserveHistory: preserveHistory, ); + for (final entry in retainedTextDrafts.entries) { + ref.read(textDraftProvider.notifier).setDraft(entry.key, entry.value); + } if (source == StrategySource.cloud) { ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: strategyId, @@ -622,7 +697,8 @@ class StrategyPageSessionNotifier extends Notifier { bool _canSafelyReapplyRemotePage() { final saveState = ref.read(strategySaveStateProvider); - return !state.isApplyingPage && + return !_isResolvingConflicts && + !state.isApplyingPage && state.transitionState == PageTransitionState.idle && ref.read(textDraftProvider).isEmpty && !saveState.isDirty && diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 4723c9c7..494eb36c 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -413,6 +413,10 @@ class StrategyProvider extends Notifier { _cloudMutationSyncScheduled = false; } + void consumeScheduledCloudStrategySync() { + _cloudStrategyMutationSyncScheduled = false; + } + void _scheduleCloudStrategySync() { if (_cloudStrategyMutationSyncScheduled) { return; diff --git a/lib/widgets/cloud_sync_status_chip.dart b/lib/widgets/cloud_sync_status_chip.dart index 375cd723..ccec9352 100644 --- a/lib/widgets/cloud_sync_status_chip.dart +++ b/lib/widgets/cloud_sync_status_chip.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,6 +9,7 @@ import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; @@ -37,6 +39,8 @@ class _CloudSyncStatusChipState extends ConsumerState { final ShadPopoverController _popoverController = ShadPopoverController(); DateTime? _lastConflictToast; Timer? _pendingConflictToast; + bool _isResolving = false; + String? _resolutionError; @override void dispose() { @@ -81,15 +85,60 @@ class _CloudSyncStatusChipState extends ConsumerState { } Future _retry() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); _popoverController.hide(); - await ref - .read(cloudMediaUploadQueueProvider.notifier) - .retryNow(ignoreBackoff: true); - final opQueue = ref.read(strategyOpQueueProvider.notifier); - await opQueue.retryPaused(flushImmediately: false); - await opQueue.retryRejected(flushImmediately: false); - await opQueue.flushNow(); - opQueue.clearStaleError(); + try { + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + final opQueue = ref.read(strategyOpQueueProvider.notifier); + await opQueue.retryPaused(flushImmediately: false); + await opQueue.retryRejected(flushImmediately: false); + await opQueue.flushNow(); + opQueue.clearStaleError(); + } finally { + if (mounted) setState(() => _isResolving = false); + } + } + + Future _useCloudVersions() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); + String? resolutionError; + try { + final resolved = await ref + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + if (resolved) { + _popoverController.hide(); + } else { + resolutionError = 'Could not load the cloud version. ' + 'Your saved version was not changed.'; + } + } catch (error, stackTrace) { + log( + 'Failed to use the cloud version: $error', + name: 'cloud_conflict_resolution', + error: error, + stackTrace: stackTrace, + ); + resolutionError = 'Could not load the cloud version. ' + 'Your saved version was not changed.'; + } finally { + if (mounted) { + setState(() { + _isResolving = false; + _resolutionError = resolutionError; + }); + } + } } @override @@ -139,8 +188,11 @@ class _CloudSyncStatusChipState extends ConsumerState { popover: (context) => _SyncStatusPopover( status: status, saveState: saveState, - hasRejectedWork: opQueueState.attentionByEntityKey.isNotEmpty, + rejectedCount: opQueueState.attentionByEntityKey.length, + isResolving: _isResolving, + resolutionError: _resolutionError, onRetry: _retry, + onUseCloudVersions: _useCloudVersions, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -280,14 +332,22 @@ class _SyncStatusPopover extends StatelessWidget { const _SyncStatusPopover({ required this.status, required this.saveState, - required this.hasRejectedWork, + required this.rejectedCount, + required this.isResolving, + required this.resolutionError, required this.onRetry, + required this.onUseCloudVersions, }); final _SyncStatus status; final StrategySaveState saveState; - final bool hasRejectedWork; + final int rejectedCount; + final bool isResolving; + final String? resolutionError; final Future Function() onRetry; + final Future Function() onUseCloudVersions; + + bool get hasRejectedWork => rejectedCount > 0; @override Widget build(BuildContext context) { @@ -314,6 +374,16 @@ class _SyncStatusPopover extends StatelessWidget { height: 1.35, ), ), + if (resolutionError != null) ...[ + const SizedBox(height: 8), + Text( + resolutionError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + height: 1.35, + ), + ), + ], if (lastSynced != null) ...[ const SizedBox(height: 8), Text( @@ -326,11 +396,22 @@ class _SyncStatusPopover extends StatelessWidget { ], if (status == _SyncStatus.attention) ...[ const SizedBox(height: 12), + if (hasRejectedWork) ...[ + ShadButton.secondary( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onUseCloudVersions, + child: const Text('Use cloud'), + ), + const SizedBox(height: 8), + ], ShadButton( size: ShadButtonSize.sm, - onPressed: onRetry, - leading: const Icon(LucideIcons.refreshCw, size: 14), - child: Text(hasRejectedWork ? 'Keep my version' : 'Retry sync'), + expands: false, + onPressed: isResolving ? null : onRetry, + child: Text( + hasRejectedWork ? 'Keep mine' : 'Retry sync', + ), ), ], ], @@ -379,6 +460,11 @@ class _SyncStatusPopover extends StatelessWidget { 'Another edit reached the cloud first. Your version remains saved ' 'on this device.', ); + parts.add( + rejectedCount == 1 + ? 'Choose which version to keep for this conflicting change.' + : 'Your choice applies to all $rejectedCount conflicting changes.', + ); } final error = saveState.cloudSyncError; final retryUnavailable = @@ -397,11 +483,7 @@ class _SyncStatusPopover extends StatelessWidget { if (parts.isEmpty) { parts.add("Some changes haven't reached the cloud yet."); } - parts.add( - hasRejectedWork - ? 'Choose Keep my version to send your retained edit again.' - : 'Retry to send them now.', - ); + if (!hasRejectedWork) parts.add('Retry to send them now.'); return parts.join(' '); } diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 95051908..8cbab99b 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -334,6 +334,165 @@ void main() { expect(store.values, hasLength(1)); }); + test( + 'cloud adoption discards only selected rejected work and survives restart', + () async { + const selectedKey = EntitySyncKey.element('page-1', 'element-1'); + const otherAttentionKey = + EntitySyncKey.element('page-1', 'element-2'); + const queuedKey = EntitySyncKey.element('page-1', 'element-3'); + final selected = record(status: DurableOutboxStatus.attention).copyWith( + successorPending: PendingOp( + op: elementOp( + opId: 'selected-successor', + value: 'newer local intent', + ), + clientId: 'stable-client', + ), + latestServerRevision: 7, + ); + final otherAttention = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: otherAttentionKey, + pending: PendingOp( + op: elementOp(opId: 'other-rejected', elementId: 'element-2'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + latestServerRevision: 4, + ); + final queued = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: queuedKey, + pending: PendingOp( + op: elementOp(opId: 'unrelated-queued', elementId: 'element-3'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + final otherStrategy = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-2', + entityKey: selectedKey, + pending: PendingOp( + op: elementOp(opId: 'other-strategy'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(selected); + await store.put(otherAttention); + await store.put(queued); + await store.put(otherStrategy); + var notifier = start(); + + final discarded = await notifier.discardRejected({selectedKey}); + + expect(discarded, {selectedKey}); + var current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(otherAttentionKey)); + expect(current.attentionByEntityKey, isNot(contains(selectedKey))); + expect(current.successorByEntityKey, isEmpty); + expect(current.queuedByEntityKey, contains(queuedKey)); + expect( + store.load().records.map((record) => record.pending.op.opId), + containsAll([ + 'other-rejected', + 'unrelated-queued', + 'other-strategy', + ]), + ); + expect( + store.load().records.map((record) => record.pending.op.opId), + isNot(contains('op-1')), + ); + expect( + store.load().records + .expand((record) => [ + record.pending.op.opId, + if (record.successorPending != null) + record.successorPending!.op.opId, + ]), + isNot(contains('selected-successor')), + ); + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + selectedKey: elementOp(opId: 'stale-reconciliation'), + }, + clearMissing: false, + flushImmediately: false, + ); + expect( + container!.read(strategyOpQueueProvider).queuedByEntityKey, + isNot(contains(selectedKey)), + ); + + notifier = start(); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(otherAttentionKey)); + expect(current.attentionByEntityKey, isNot(contains(selectedKey))); + expect(current.queuedByEntityKey, contains(queuedKey)); + expect(current.pending.map((pending) => pending.op.opId), + isNot(contains('selected-successor'))); + + notifier.setActiveStrategy('strategy-2', accountId: 'account-a'); + expect( + container! + .read(strategyOpQueueProvider) + .attentionByEntityKey[selectedKey]! + .pending + .op + .opId, + 'other-strategy', + ); + }); + + test('partial cloud adoption leaves a failed durable delete in attention', + () async { + const firstKey = EntitySyncKey.element('page-1', 'element-1'); + const secondKey = EntitySyncKey.element('page-1', 'element-2'); + final failingStore = _FailingSelectedRemovalStore(); + store = failingStore; + await store.put(record(status: DurableOutboxStatus.attention)); + final second = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: secondKey, + pending: PendingOp( + op: elementOp(opId: 'second', elementId: 'element-2'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(second); + failingStore.failStorageKey = second.storageKey; + final notifier = start(); + + final discarded = await notifier.discardRejected({firstKey, secondKey}); + + expect(discarded, {firstKey}); + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(secondKey)); + expect(current.attentionByEntityKey, isNot(contains(firstKey))); + expect(current.lastError, contains('could not be removed')); + expect( + store.load().records.map((record) => record.entityKey), + unorderedEquals([secondKey]), + ); + }); + test('explicit rejected retry uses durable latest server revision', () async { final saved = record(status: DurableOutboxStatus.attention).copyWith( @@ -645,6 +804,60 @@ void main() { expect(afterWrite.op.payload, {'value': 'b'}); }); + test('cloud adoption leaves unrelated in-flight work untouched', () async { + const rejectedKey = EntitySyncKey.element('page-1', 'element-1'); + final store = MemoryDurableStrategyOutboxStore(); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: rejectedKey, + pending: const PendingOp( + op: ElementPatchOp( + opId: 'rejected', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'mine'}, + expectedElementRevision: 1, + ), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const inFlightKey = EntitySyncKey.element('page-1', 'element-2'); + await notifier.enqueue(const ElementPatchOp( + opId: 'unrelated-in-flight', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'other'}, + expectedElementRevision: 1, + )); + final flush = notifier.flushNow(); + await repository.firstStarted.future; + + final discarded = await notifier.discardRejected({rejectedKey}); + + expect(discarded, {rejectedKey}); + expect( + container.read(strategyOpQueueProvider).inFlightByEntityKey, + contains(inFlightKey), + ); + repository.completeFirst(const AppliedOpAck( + opId: 'unrelated-in-flight', + revision: 2, + )); + await flush; + }); + group('acknowledgement persistence recovery', () { test('accepted ack remove failure restores the batch for retry', () async { final store = _OneShotAckFailureStore(failRemove: true); @@ -1517,6 +1730,18 @@ class _OneShotAckFailureStore extends MemoryDurableStrategyOutboxStore { } } +class _FailingSelectedRemovalStore extends MemoryDurableStrategyOutboxStore { + String? failStorageKey; + + @override + Future remove(String storageKey) async { + if (storageKey == failStorageKey) { + throw StateError('selected removal failed'); + } + await super.remove(storageKey); + } +} + class _UnusedTransport implements ConvexTransport { @override Future action(String name, ConvexObject args) => diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 08e26b83..d53698aa 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -30,6 +30,7 @@ import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/transition_provider.dart' hide PageTransitionState; +import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -113,6 +114,18 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { final queued = Map.from( state.queuedByEntityKey, ); + final successors = Map.from( + state.successorByEntityKey, + ); + if (desiredOp != null && + state.attentionByEntityKey.containsKey(entityKey)) { + successors[entityKey] = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp(op: desiredOp, clientId: 'test-client'), + ); + state = state.copyWith(successorByEntityKey: successors); + return; + } if (desiredOp == null) { queued.remove(entityKey); } else { @@ -134,17 +147,30 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { final queued = Map.from( state.queuedByEntityKey, ); + final successors = Map.from( + state.successorByEntityKey, + ); if (clearMissing) { queued.removeWhere((key, _) => key.pageId == pageId && !desiredOpsByEntityKey.containsKey(key)); } for (final entry in desiredOpsByEntityKey.entries) { + if (state.attentionByEntityKey.containsKey(entry.key)) { + successors[entry.key] = QueuedEntityIntent( + entityKey: entry.key, + pending: PendingOp(op: entry.value, clientId: 'test-client'), + ); + continue; + } queued[entry.key] = QueuedEntityIntent( entityKey: entry.key, pending: PendingOp(op: entry.value, clientId: 'test-client'), ); } - state = state.copyWith(queuedByEntityKey: queued); + state = state.copyWith( + queuedByEntityKey: queued, + successorByEntityKey: successors, + ); } @override @@ -153,6 +179,29 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { if (blockFlush) await Completer().future; } + @override + Future> discardRejected( + Set entityKeys, + ) async { + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + final discarded = attention.keys.toSet().intersection(entityKeys); + for (final key in discarded) { + attention.remove(key); + successors.remove(key); + } + state = state.copyWith( + attentionByEntityKey: attention, + successorByEntityKey: successors, + clearError: attention.isEmpty, + ); + return discarded; + } + void reject(StrategyOp op) { final key = EntitySyncKey.forStrategyOp(op)!; final pending = PendingOp(op: op, clientId: 'test-client'); @@ -795,6 +844,165 @@ void main() { expect(container.read(strategyConflictProvider).single.opId, op.opId); }); + test( + 'using cloud after a conflict replaces the canvas without resubmitting it', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, 'text-page-1', 'server-before', + worldSized: true), + ], + ), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + const textId = 'text-page-1'; + const key = EntitySyncKey.element('page-1', textId); + container.read(textProvider.notifier).commitText( + textId, + 'local-losing-intent', + ); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == textId); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, textId, 'server-winner', + worldSized: true), + ], + contentRevision: 2, + ), + )); + queue.reject(rejectedOp); + await _settle(); + container + .read(textDraftProvider.notifier) + .setDraft(textId, 'draft-in-progress'); + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + + expect(resolved, isTrue); + expect(container.read(textProvider).single.text, 'server-winner'); + expect(container.read(textDraftProvider), { + textId: 'draft-in-progress', + }); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + isNot(contains(key)), + ); + expect( + container.read(activePageLiveSyncProvider).overlayByEntityKey, + isNot(contains(key)), + ); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityPublicId), + isNot(contains(textId)), + ); + + container.read(textDraftProvider.notifier).clearDraft(textId); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect(desired, isNotNull); + expect(desired, isNot(contains(key))); + await queue.syncDesiredOpsForPage( + pageId: page.publicId, + desiredOpsByEntityKey: desired!, + flushImmediately: false, + ); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityPublicId), + isNot(contains(textId)), + ); + }); + + test('using cloud for a strategy conflict restores remote map and theme', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 3, + mapData: Maps.mapNames[MapValue.haven], + themeProfileId: 'remote-theme', + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(mapProvider.notifier).updateMap(MapValue.ascent); + container + .read(strategyThemeProvider.notifier) + .setProfile('local-theme'); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityType == StrategyOpEntityType.strategy); + queue.reject(rejectedOp); + await _settle(); + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + + expect(resolved, isTrue); + expect(container.read(mapProvider).currentMap, MapValue.haven); + expect(container.read(strategyThemeProvider).profileId, 'remote-theme'); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + isNot(contains(const EntitySyncKey.strategy())), + ); + + await container + .read(strategyProvider.notifier) + .notifyCloudStrategyMutation(flushImmediately: false); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityType), + isNot(contains(StrategyOpEntityType.strategy)), + ); + }); + test('inactive page shell update does not rehydrate the active canvas', () async { final pageOne = _page('page-1', 0); diff --git a/test/widgets/cloud_sync_status_chip_test.dart b/test/widgets/cloud_sync_status_chip_test.dart index fa2e6b23..c6831c6e 100644 --- a/test/widgets/cloud_sync_status_chip_test.dart +++ b/test/widgets/cloud_sync_status_chip_test.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -40,6 +43,77 @@ class _EmptyMediaQueue extends CloudMediaUploadQueueNotifier { ); } +class _AttentionOpQueue extends StrategyOpQueueNotifier { + _AttentionOpQueue(this.rejectedCount); + + final int rejectedCount; + int retryRejectedCount = 0; + int flushNowCount = 0; + + @override + StrategyOpQueueState build() => StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + attentionByEntityKey: { + for (var index = 0; index < rejectedCount; index++) + EntitySyncKey.element('page-1', 'element-$index'): + QueuedEntityIntent( + entityKey: + EntitySyncKey.element('page-1', 'element-$index'), + pending: PendingOp( + op: ElementPatchOp( + opId: 'rejected-$index', + elementPublicId: 'element-$index', + pagePublicId: 'page-1', + payload: const {'value': 'mine'}, + expectedElementRevision: 1, + ), + clientId: 'client-a', + ), + ), + }, + lastError: 'Some saved work needs attention.', + ); + + @override + Future retryPaused({bool flushImmediately = true}) async {} + + @override + Future retryRejected({bool flushImmediately = true}) async { + retryRejectedCount += 1; + } + + @override + Future flushNow() async { + flushNowCount += 1; + } +} + +class _ConflictSession extends StrategyPageSessionNotifier { + _ConflictSession({this.result = true, this.failure}); + + final bool result; + final Object? failure; + int useCloudCount = 0; + + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + @override + Future useCloudVersionsForRejected() async { + useCloudCount += 1; + if (failure != null) throw failure!; + return result; + } +} + ProviderContainer _createContainer({bool connected = true}) { return ProviderContainer( overrides: [ @@ -51,6 +125,21 @@ ProviderContainer _createContainer({bool connected = true}) { ); } +ProviderContainer _createConflictContainer({ + required _AttentionOpQueue queue, + required _ConflictSession session, +}) { + return ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + strategyOpQueueProvider.overrideWith(() => queue), + strategyPageSessionProvider.overrideWith(() => session), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ], + ); +} + void main() { testWidgets('an active text draft can never appear synced', (tester) async { final container = _createContainer(); @@ -111,4 +200,145 @@ void main() { expect(find.text('Editing…'), findsNothing); expect(find.text('Synced'), findsNothing); }); + + testWidgets('conflict popover offers an explicit cloud choice', + (tester) async { + final queue = _AttentionOpQueue(2); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + expect( + find.textContaining('applies to all 2 conflicting changes'), + findsOneWidget, + ); + + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect(session.useCloudCount, 1); + expect(queue.retryRejectedCount, 0); + expect(queue.flushNowCount, 0); + }); + + testWidgets('keep mine remains an explicit rejected retry', (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + + await tester.tap(find.text('Keep mine')); + await tester.pumpAndSettle(); + + expect(queue.retryRejectedCount, 1); + expect(queue.flushNowCount, 1); + expect(session.useCloudCount, 0); + }); + + testWidgets('failed cloud load keeps attention and explains the failure', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(result: false); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not load the cloud version. Your saved version was not changed.', + ), + findsOneWidget, + ); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + }); + + testWidgets('thrown cloud load keeps attention and explains the failure', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(failure: StateError('refresh failed')); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not load the cloud version. Your saved version was not changed.', + ), + findsOneWidget, + ); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + }); }