From 77001c2add81d5adf7ab0c3ebbecd9df961a2b8d Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 20:39:25 -0400 Subject: [PATCH 1/7] fix: preserve cloud sync concurrency invariants --- .../collab/active_page_live_sync_models.dart | 6 +- .../active_page_live_sync_provider.dart | 154 ++++++++----- .../collab/strategy_op_queue_provider.dart | 15 +- test/strategy_op_queue_provider_test.dart | 212 ++++++++++++++++++ test/strategy_page_session_provider_test.dart | 150 ++++++++++++- 5 files changed, 474 insertions(+), 63 deletions(-) diff --git a/lib/providers/collab/active_page_live_sync_models.dart b/lib/providers/collab/active_page_live_sync_models.dart index c885b1bc..5dd552d9 100644 --- a/lib/providers/collab/active_page_live_sync_models.dart +++ b/lib/providers/collab/active_page_live_sync_models.dart @@ -137,6 +137,7 @@ class ActivePageOverlayEntry { required this.desiredSortIndex, required this.deletion, required this.baseRevision, + required this.baseDeleted, required this.dirtyAt, }); @@ -145,7 +146,8 @@ class ActivePageOverlayEntry { final Object? desiredPayload; final int? desiredSortIndex; final bool deletion; - final int baseRevision; + final int? baseRevision; + final bool baseDeleted; final DateTime dirtyAt; ActivePageOverlayEntry copyWith({ @@ -153,6 +155,7 @@ class ActivePageOverlayEntry { int? desiredSortIndex, bool? deletion, int? baseRevision, + bool? baseDeleted, DateTime? dirtyAt, }) { return ActivePageOverlayEntry( @@ -162,6 +165,7 @@ class ActivePageOverlayEntry { desiredSortIndex: desiredSortIndex ?? this.desiredSortIndex, deletion: deletion ?? this.deletion, baseRevision: baseRevision ?? this.baseRevision, + baseDeleted: baseDeleted ?? this.baseDeleted, dirtyAt: dirtyAt ?? this.dirtyAt, ); } diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 40fe2e98..61187eaf 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -70,12 +70,17 @@ final activePageLiveSyncProvider = ); 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 = {}; + @override ActivePageLiveSyncState build() { return const ActivePageLiveSyncState(); } void reset() { + _hydratedBaseByEntityKey.clear(); state = const ActivePageLiveSyncState(); } @@ -87,8 +92,12 @@ class ActivePageLiveSyncNotifier extends Notifier { required String? strategyPublicId, required String? activePageId, }) { + final strategyChanged = strategyPublicId != state.strategyPublicId; final contextChanged = strategyPublicId != state.strategyPublicId || activePageId != state.activePageId; + if (strategyChanged) { + _hydratedBaseByEntityKey.clear(); + } state = state.copyWith( strategyPublicId: strategyPublicId, activePageId: activePageId, @@ -121,9 +130,24 @@ class ActivePageLiveSyncNotifier extends Notifier { required String pageId, }) { setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final remoteEntities = snapshot == null || + snapshot.header.publicId != strategyPublicId || + snapshot.activePage?.page.publicId != pageId + ? const {} + : _normalizedRemoteEntities(snapshot, pageId); + _hydratedBaseByEntityKey.removeWhere((key, _) => key.pageId == pageId); + _hydratedBaseByEntityKey.addAll(remoteEntities); + final remoteRevisions = Map.from( + state.remoteBaseRevisionByEntity, + )..removeWhere((key, _) => key.pageId == pageId); + for (final entry in remoteEntities.entries) { + remoteRevisions[entry.key] = entry.value.revision; + } state = state.copyWith( hydratedPageId: pageId, hydratedEntityKeys: _normalizedLocalEntities(pageId).keys.toSet(), + remoteBaseRevisionByEntity: remoteRevisions, ); } @@ -159,17 +183,11 @@ class ActivePageLiveSyncNotifier extends Notifier { final queueState = ref.read(strategyOpQueueProvider); final remoteEntities = _normalizedRemoteEntities(snapshot, pageId); final localEntities = _normalizedLocalEntities(pageId); - final remoteRevisions = Map.from( - state.remoteBaseRevisionByEntity, - ); - - for (final entry in remoteEntities.entries) { - remoteRevisions[entry.key] = entry.value.revision; - } final pageKeys = { ...remoteEntities.keys, ...localEntities.keys, + ..._hydratedBaseByEntityKey.keys.where((key) => key.pageId == pageId), ...state.overlayByEntityKey.keys.where((key) => key.pageId == pageId), ...queueState.queuedByEntityKey.keys.where((key) => key.pageId == pageId), ...queueState.inFlightByEntityKey.keys @@ -185,14 +203,24 @@ class ActivePageLiveSyncNotifier extends Notifier { for (final key in pageKeys) { final remote = remoteEntities[key]; final local = localEntities[key]; + final hydratedBase = _hydratedBaseByEntityKey[key]; final hasQueued = queueState.queuedByEntityKey.containsKey(key); final hasInFlight = queueState.inFlightByEntityKey.containsKey(key); + final hasSuccessor = queueState.successorByEntityKey.containsKey(key); final existingOverlay = state.overlayByEntityKey[key]; - final shouldPreserveTouched = hasQueued || hasInFlight; + final shouldPreserveTouched = hasQueued || hasInFlight || hasSuccessor; final matchesRemote = _entitiesEquivalent(local, remote); + final matchesHydratedBase = _entitiesEquivalent(local, hydratedBase); + + if (matchesHydratedBase && !shouldPreserveTouched) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=unchanged_since_hydration'); + } + continue; + } - if (matchesRemote && !hasQueued && !hasInFlight) { + if (matchesRemote && !shouldPreserveTouched) { if (nextOverlay.remove(key) != null) { _debugLog('overlay.remove $key reason=matched_remote'); } @@ -210,7 +238,8 @@ class ActivePageLiveSyncNotifier extends Notifier { final overlay = _overlayFromDesiredEntity( key: key, desired: local, - baseRevision: remote?.revision ?? existingOverlay?.baseRevision ?? 0, + hydratedBase: hydratedBase, + existingOverlay: existingOverlay, ); nextOverlay[key] = overlay; _debugLog('overlay.keep $key reason=pending_reconciliation'); @@ -223,23 +252,27 @@ class ActivePageLiveSyncNotifier extends Notifier { continue; } - if (local == null && remote != null) { - final wasHydratedLocally = state.hydratedEntityKeys.contains(key); - if (!wasHydratedLocally && + if (local == null) { + if (hydratedBase == null && existingOverlay == null && !shouldPreserveTouched) { _debugLog( - 'overlay.skip $key reason=remote_not_yet_hydrated_locally', + 'overlay.skip $key reason=not_in_hydrated_base', ); continue; } final overlay = ActivePageOverlayEntry( entityKey: key, - entityType: remote.overlayEntityType, + entityType: existingOverlay?.entityType ?? + hydratedBase?.overlayEntityType ?? + remote!.overlayEntityType, desiredPayload: null, desiredSortIndex: null, deletion: true, - baseRevision: remote.revision, + baseRevision: + existingOverlay?.baseRevision ?? hydratedBase?.revision, + baseDeleted: + existingOverlay?.baseDeleted ?? hydratedBase?.deleted ?? false, dirtyAt: DateTime.now(), ); nextOverlay[key] = overlay; @@ -247,17 +280,16 @@ class ActivePageLiveSyncNotifier extends Notifier { continue; } - if (local != null) { - final overlay = _overlayFromDesiredEntity( - key: key, - desired: local, - baseRevision: remote?.revision ?? existingOverlay?.baseRevision ?? 0, - ); - nextOverlay[key] = overlay; - _debugLog( - 'overlay.upsert $key deletion=false baseRevision=${overlay.baseRevision}', - ); - } + final overlay = _overlayFromDesiredEntity( + key: key, + desired: local, + hydratedBase: hydratedBase, + existingOverlay: existingOverlay, + ); + nextOverlay[key] = overlay; + _debugLog( + 'overlay.upsert $key deletion=false baseRevision=${overlay.baseRevision}', + ); } final desiredOpsByEntityKey = {}; @@ -269,15 +301,15 @@ class ActivePageLiveSyncNotifier extends Notifier { final remote = remoteEntities[key]; final overlay = entry.value; if (_overlayMatchesRemote(overlay, remote) && - !_needsPageDescriptorSuccessor( + !_needsSuccessor( + pageId: pageId, key: key, overlay: overlay, queueState: queueState, )) { continue; } - final op = _strategyOpFromOverlay( - pageId: pageId, overlay: overlay, remote: remote); + final op = _strategyOpFromOverlay(pageId: pageId, overlay: overlay); if (op != null) { desiredOpsByEntityKey[key] = op; } @@ -286,7 +318,6 @@ class ActivePageLiveSyncNotifier extends Notifier { state = state.copyWith( strategyPublicId: strategyPublicId, activePageId: pageId, - remoteBaseRevisionByEntity: remoteRevisions, overlayByEntityKey: nextOverlay, ); @@ -639,7 +670,8 @@ class ActivePageLiveSyncNotifier extends Notifier { ActivePageOverlayEntry _overlayFromDesiredEntity({ required EntitySyncKey key, required _NormalizedEntity desired, - required int baseRevision, + required _NormalizedEntity? hydratedBase, + required ActivePageOverlayEntry? existingOverlay, }) { return ActivePageOverlayEntry( entityKey: key, @@ -647,7 +679,9 @@ class ActivePageLiveSyncNotifier extends Notifier { desiredPayload: desired.payload, desiredSortIndex: desired.sortIndex, deletion: desired.deleted, - baseRevision: baseRevision, + baseRevision: existingOverlay?.baseRevision ?? hydratedBase?.revision, + baseDeleted: + existingOverlay?.baseDeleted ?? hydratedBase?.deleted ?? false, dirtyAt: DateTime.now(), ); } @@ -655,18 +689,21 @@ class ActivePageLiveSyncNotifier extends Notifier { StrategyOp? _strategyOpFromOverlay({ required String pageId, required ActivePageOverlayEntry overlay, - required _NormalizedEntity? remote, }) { final entityId = overlay.entityKey.entityId; switch (overlay.entityType) { case ActivePageOverlayEntityType.pageDescriptor: + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; return PagePatchOp( opId: const Uuid().v4(), pagePublicId: pageId, payload: Map.from(overlay.desiredPayload as Map), - expectedPageRevision: remote?.revision ?? overlay.baseRevision, + expectedPageRevision: baseRevision, ); case ActivePageOverlayEntityType.pageContent: + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; final payload = Map.from( overlay.desiredPayload as Map, ); @@ -674,30 +711,32 @@ class ActivePageLiveSyncNotifier extends Notifier { opId: const Uuid().v4(), pagePublicId: pageId, settings: Map.from(payload['settings'] as Map), - expectedPageContentRevision: remote?.revision ?? overlay.baseRevision, + expectedPageContentRevision: baseRevision, ); case ActivePageOverlayEntityType.element: if (entityId == null) { return null; } if (overlay.deletion) { + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; return ElementDeleteOp( opId: const Uuid().v4(), elementPublicId: entityId, pagePublicId: pageId, - expectedElementRevision: remote?.revision ?? overlay.baseRevision, + expectedElementRevision: baseRevision, ); } final payload = Map.from(overlay.desiredPayload as Map); - return remote == null || remote.deleted + return overlay.baseRevision == null || overlay.baseDeleted ? ElementAddOp( opId: const Uuid().v4(), elementPublicId: entityId, pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex ?? 0, - expectedElementRevision: remote?.revision, + expectedElementRevision: overlay.baseRevision, ) : ElementPatchOp( opId: const Uuid().v4(), @@ -705,30 +744,32 @@ class ActivePageLiveSyncNotifier extends Notifier { pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex, - expectedElementRevision: remote.revision, + expectedElementRevision: overlay.baseRevision!, ); case ActivePageOverlayEntityType.lineup: if (entityId == null) { return null; } if (overlay.deletion) { + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; return LineupDeleteOp( opId: const Uuid().v4(), lineupPublicId: entityId, pagePublicId: pageId, - expectedLineupRevision: remote?.revision ?? overlay.baseRevision, + expectedLineupRevision: baseRevision, ); } final payload = Map.from(overlay.desiredPayload as Map); - return remote == null || remote.deleted + return overlay.baseRevision == null || overlay.baseDeleted ? LineupAddOp( opId: const Uuid().v4(), lineupPublicId: entityId, pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex ?? 0, - expectedLineupRevision: remote?.revision, + expectedLineupRevision: overlay.baseRevision, ) : LineupPatchOp( opId: const Uuid().v4(), @@ -736,7 +777,7 @@ class ActivePageLiveSyncNotifier extends Notifier { pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex, - expectedLineupRevision: remote.revision, + expectedLineupRevision: overlay.baseRevision!, ); } } @@ -755,24 +796,31 @@ class ActivePageLiveSyncNotifier extends Notifier { overlay.desiredSortIndex == remote.sortIndex; } - bool _needsPageDescriptorSuccessor({ + bool _needsSuccessor({ + required String pageId, required EntitySyncKey key, required ActivePageOverlayEntry overlay, required StrategyOpQueueState queueState, }) { - if (key.kind != EntitySyncKeyKind.pageDescriptor || - queueState.successorByEntityKey.containsKey(key)) { + if (queueState.successorByEntityKey.containsKey(key)) { return false; } - final desiredPayload = overlay.desiredPayload; - if (desiredPayload is! Map) return false; - final desiredSide = desiredPayload['isAttack']; - if (desiredSide is! bool) return false; final predecessor = queueState.inFlightByEntityKey[key]?.pending.op ?? queueState.queuedByEntityKey[key]?.pending.op; - if (predecessor is! PagePatchOp) return false; - return predecessor.payload['isAttack'] != desiredSide; + if (predecessor == null) return false; + final desired = _strategyOpFromOverlay(pageId: pageId, overlay: overlay); + return desired != null && !_opsEquivalent(predecessor, desired); + } + + bool _opsEquivalent(StrategyOp left, StrategyOp right) { + return left.kind == right.kind && + left.entityType == right.entityType && + left.entityPublicId == right.entityPublicId && + left.pagePublicId == right.pagePublicId && + cloudJsonEquivalent(left.payload, right.payload) && + left.sortIndex == right.sortIndex && + left.expectedRevision == right.expectedRevision; } bool _entitiesEquivalent( diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 271f6b30..de721cc0 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -360,8 +360,7 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - if (inFlightIntent != null && - key.kind == EntitySyncKeyKind.pageDescriptor) { + if (inFlightIntent != null) { if (successorIntent != null && _sameIntent(successorIntent.pending.op, desired)) { continue; @@ -387,9 +386,7 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - if (existing != null && - successorIntent != null && - key.kind == EntitySyncKeyKind.pageDescriptor) { + if (existing != null && successorIntent != null) { if (_sameIntent(existing.pending.op, desired)) { await _putRecord(_recordFor( key: key, @@ -746,8 +743,10 @@ class StrategyOpQueueNotifier extends Notifier { final current = _recordForActiveKey(sent.entityKey); if (current?.pending.op.opId != ack.opId) continue; final successor = current!.successorPending; - final successorRevision = ack.appliedRevision ?? ack.latestRevision; - if (successor != null && successorRevision != null) { + // Only an accepted predecessor establishes a revision for automatic + // promotion. A rejected predecessor leaves both intents in attention. + final successorRevision = ack.appliedRevision; + if (successor != null && ack.isAck && successorRevision != null) { final promoted = PendingOp( op: _rebaseRejectedOp( successor.op, @@ -775,7 +774,7 @@ class StrategyOpQueueNotifier extends Notifier { status: DurableOutboxStatus.attention, updatedAt: DateTime.now(), lastError: ack.reason ?? - 'The final Page change is waiting for a server revision.', + 'The final change is waiting for conflict resolution.', latestServerRevision: ack.latestRevision, ); await _putRecord(retained); diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 53ceaf05..49bd8fc9 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -770,6 +770,204 @@ void main() { await replayRepository.secondCompleted.future; }); }); + + group('same-entity final intent', () { + test('keeps an element successor behind its in-flight predecessor', + () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-edit', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second-edit', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + final duringFirst = container.read(strategyOpQueueProvider); + expect( + duringFirst.inFlightByEntityKey[key]!.pending.op.opId, + 'first-edit', + ); + expect( + duringFirst.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durableDuringFirst = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableDuringFirst.pending.op.opId, 'first-edit'); + expect( + durableDuringFirst.successorPending!.op.payload, + {'value': 'second'}, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'first-edit', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final promoted = repository.calls[1].single as ElementPatchOp; + expect(promoted.payload, {'value': 'second'}); + expect(promoted.expectedElementRevision, 2); + expect(promoted.opId, isNot('second-edit')); + + repository.completeSecond(AppliedOpAck( + opId: promoted.opId, + revision: 3, + )); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('restart replays an element predecessor before its successor', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final firstRepository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: firstRepository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-before-restart', + value: 'first', + expectedRevision: 4, + )); + unawaited(notifier.flushNow()); + await firstRepository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second-after-restart', + value: 'second', + expectedRevision: 4, + ), + }, + ); + container.dispose(); + + final replayRepository = _SequencedAckRepository(); + container = _cloudQueueContainer( + store: store, + repository: replayRepository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await replayRepository.firstStarted.future; + + final replayed = replayRepository.calls.first.single as ElementPatchOp; + expect(replayed.opId, 'first-before-restart'); + expect(replayed.payload, {'value': 'first'}); + expect( + container + .read(strategyOpQueueProvider) + .successorByEntityKey[key]! + .pending + .op + .payload, + {'value': 'second'}, + ); + + replayRepository.completeFirst(const AppliedOpAck( + opId: 'first-before-restart', + revision: 5, + )); + await replayRepository.secondStarted.future; + final finalEdit = replayRepository.calls[1].single as ElementPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedElementRevision, 5); + replayRepository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 6, + )); + await replayRepository.secondCompleted.future; + }); + + test('rejected predecessor leaves its element successor in attention', + () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'conflicting-first', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'retained-second', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + repository.completeFirst(const RejectedOpAck( + opId: 'conflicting-first', + rejectionReason: OpRejectionReason.revisionMismatch, + current: ElementCurrentSnapshot(revision: 2, value: {'value': 'peer'}), + )); + await firstFlush; + await Future.delayed(Duration.zero); + + final conflicted = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(conflicted.attentionByEntityKey, contains(key)); + expect( + conflicted.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'conflicting-first'); + expect(durable.successorPending!.op.payload, {'value': 'second'}); + expect(durable.latestServerRevision, 2); + }); + }); } ProviderContainer _cloudQueueContainer({ @@ -807,6 +1005,20 @@ PagePatchOp _pageSideOp({ ); } +ElementPatchOp _elementPatch({ + required String opId, + required String value, + required int expectedRevision, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': value}, + expectedElementRevision: expectedRevision, + ); +} + void _expectBatchRestored( ProviderContainer container, MemoryDurableStrategyOutboxStore store, diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 65f817dd..129b7fad 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -210,9 +210,12 @@ RemoteElement _textElement( String id, String value, { int revision = 1, + int sortIndex = 0, + bool worldSized = false, bool deleted = false, }) { final text = PlacedText(id: id, position: const Offset(10, 20))..text = value; + if (worldSized) text.markSizeAsWorld(); final payload = Map.from(text.toJson()) ..['elementType'] = 'text'; return RemoteElement( @@ -221,7 +224,7 @@ RemoteElement _textElement( pagePublicId: pageId, elementType: 'text', payload: cloudElementPayload(kind: 'text', data: payload), - sortIndex: 0, + sortIndex: sortIndex, revision: revision, deleted: deleted, ); @@ -892,6 +895,59 @@ void main() { ); }); + test('final text is emitted while an earlier edit is in flight', () async { + final page = _page('page-1', 0); + const textId = 'text-page-1'; + final remoteText = _textElement( + page.publicId, + textId, + 'before', + worldSized: true, + ); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [remoteText]), + )), + queue: queue, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + const key = EntitySyncKey.element('page-1', textId); + queue.holdInFlight( + key, + ElementPatchOp( + opId: 'first-edit-in-flight', + elementPublicId: textId, + pagePublicId: page.publicId, + payload: _textElement( + page.publicId, + textId, + 'first-edit', + worldSized: true, + ).payload, + sortIndex: 0, + expectedElementRevision: 1, + ), + ); + + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final finalEdit = desired![key] as ElementPatchOp; + expect(finalEdit.payload.toString(), contains('before')); + expect(finalEdit.expectedElementRevision, 1); + }); + test('side switch authors exactly one Page descriptor operation', () async { final page = _page('page-1', 0, revision: 11); final container = await _syncContainer( @@ -1100,6 +1156,98 @@ void main() { expect(container.read(strategyOpQueueProvider).pending, isNotEmpty); }); + test( + 'collaborator edits stay based on the page this client actually hydrated', + () async { + final page = _page('page-1', 0); + const localTextId = 'local-text'; + const collaboratorTextId = 'collaborator-text'; + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement( + page.publicId, + localTextId, + 'shared-before', + worldSized: true, + ), + _textElement( + page.publicId, + collaboratorTextId, + 'collaborator-before', + sortIndex: 1, + worldSized: true, + ), + ], + ), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).commitText( + localTextId, + 'this-client-edit', + ); + await _settle(); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement( + page.publicId, + localTextId, + 'collaborator-winner', + revision: 2, + worldSized: true, + ), + _textElement( + page.publicId, + collaboratorTextId, + 'collaborator-after', + revision: 2, + sortIndex: 1, + worldSized: true, + ), + ], + ), + )); + await _settle(); + + expect( + container + .read(textProvider) + .firstWhere((text) => text.id == collaboratorTextId) + .text, + 'collaborator-before', + ); + + await session.flushCurrentPage(); + + final elementOps = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .where((entry) => entry.key.kind == EntitySyncKeyKind.element) + .toList(growable: false); + expect(elementOps, hasLength(1)); + expect(elementOps.single.key.entityId, localTextId); + expect(elementOps.single.value.pending.op.expectedRevision, 1); + expect( + elementOps.single.value.pending.op.payload.toString(), + contains('this-client-edit'), + ); + }); + test('local mode page switching keeps its shipped Hive shape', () async { final box = await _openStrategyBox(); final now = DateTime.utc(2026); From c6fa786913e0db9d1f14c6b8f1792919e27299a9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 20:59:50 -0400 Subject: [PATCH 2/7] fix: retain delete successors after local adds --- .../active_page_live_sync_provider.dart | 9 +- .../collab/strategy_op_queue_provider.dart | 6 +- test/strategy_op_queue_provider_test.dart | 136 ++++++++++++++++++ test/strategy_page_session_provider_test.dart | 40 ++++++ 4 files changed, 185 insertions(+), 6 deletions(-) diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 61187eaf..9d047bc9 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -718,8 +718,10 @@ class ActivePageLiveSyncNotifier extends Notifier { return null; } if (overlay.deletion) { - final baseRevision = overlay.baseRevision; - if (baseRevision == null) return null; + // A delete after a local add has no revision until that add lands. + // Zero cannot land early; the outbox rebases the successor from the + // accepted add acknowledgment. + final baseRevision = overlay.baseRevision ?? 0; return ElementDeleteOp( opId: const Uuid().v4(), elementPublicId: entityId, @@ -751,8 +753,7 @@ class ActivePageLiveSyncNotifier extends Notifier { return null; } if (overlay.deletion) { - final baseRevision = overlay.baseRevision; - if (baseRevision == null) return null; + final baseRevision = overlay.baseRevision ?? 0; return LineupDeleteOp( opId: const Uuid().v4(), lineupPublicId: entityId, diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index de721cc0..95acc3be 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -368,7 +368,8 @@ class StrategyOpQueueNotifier extends Notifier { final pending = PendingOp( op: successorIntent == null ? desired - : _mergeQueuedIntent(successorIntent.pending.op, desired)!, + : _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, clientId: successorIntent?.pending.clientId ?? state.clientId!, ); await _putRecord(_recordFor( @@ -402,7 +403,8 @@ class StrategyOpQueueNotifier extends Notifier { continue; } final pending = PendingOp( - op: _mergeQueuedIntent(successorIntent.pending.op, desired)!, + op: _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, clientId: successorIntent.pending.clientId, ); await _putRecord(_recordFor( diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 49bd8fc9..7fbcd2ac 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -967,6 +967,142 @@ void main() { expect(durable.successorPending!.op.payload, {'value': 'second'}); expect(durable.latestServerRevision, 2); }); + + test('keeps a final element delete behind an in-flight add', () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementAddOp( + opId: 'element-add-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-add-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementDeleteOp( + opId: 'element-delete-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + expectedElementRevision: 0, + ), + }, + ); + + final durableBeforeAck = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableBeforeAck.pending.op.opId, 'element-add-in-flight'); + expect(durableBeforeAck.successorPending!.op, isA()); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalDelete = repository.calls[1].single as ElementDeleteOp; + expect(finalDelete.expectedElementRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalDelete.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('keeps a final lineup delete behind an in-flight add', () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.lineup('page-1', 'lineup-1'); + + await notifier.enqueue(const LineupAddOp( + opId: 'lineup-add-in-flight', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupAddOp( + opId: 'lineup-add-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupDeleteOp( + opId: 'lineup-delete-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + expectedLineupRevision: 0, + ), + }, + ); + + final durableBeforeAck = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableBeforeAck.pending.op.opId, 'lineup-add-in-flight'); + expect(durableBeforeAck.successorPending!.op, isA()); + + repository.completeFirst(const AppliedOpAck( + opId: 'lineup-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalDelete = repository.calls[1].single as LineupDeleteOp; + expect(finalDelete.expectedLineupRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalDelete.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); }); } diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 129b7fad..b35a14c3 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -948,6 +948,46 @@ void main() { expect(finalEdit.expectedElementRevision, 1); }); + test('a final delete is emitted behind an in-flight local add', () async { + final page = _page('page-1', 0); + const textId = 'new-local-text'; + const key = EntitySyncKey.element('page-1', textId); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )), + queue: queue, + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: textId, position: const Offset(10, 20)) + ..text = 'first' + ..markSizeAsWorld(), + ]); + + final firstDesired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + final add = firstDesired![key] as ElementAddOp; + queue.holdInFlight(key, add); + container.read(textProvider.notifier).removeText(textId); + + final finalDesired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final delete = finalDesired![key] as ElementDeleteOp; + expect(delete.expectedElementRevision, 0); + }); + test('side switch authors exactly one Page descriptor operation', () async { final page = _page('page-1', 0, revision: 11); final container = await _syncContainer( From c5d780258add231aa8b2b02cbafca77f0ce2c198 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 21:11:03 -0400 Subject: [PATCH 3/7] fix: promote accepted add successors as patches --- .../collab/strategy_op_queue_provider.dart | 4 +- test/strategy_op_queue_provider_test.dart | 153 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 95acc3be..6be6c84c 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -749,11 +749,13 @@ class StrategyOpQueueNotifier extends Notifier { // promotion. A rejected predecessor leaves both intents in attention. final successorRevision = ack.appliedRevision; if (successor != null && ack.isAck && successorRevision != null) { + final predecessorCreatedEntity = sent.pending.op is ElementAddOp || + sent.pending.op is LineupAddOp; final promoted = PendingOp( op: _rebaseRejectedOp( successor.op, successorRevision, - preserveAdd: true, + preserveAdd: !predecessorCreatedEntity, ), clientId: successor.clientId, ); diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 7fbcd2ac..370a162d 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -968,6 +968,159 @@ void main() { expect(durable.latestServerRevision, 2); }); + test('promotes an edit after an element add as a patch', () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementAddOp( + opId: 'element-add-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-add-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalEdit = repository.calls[1].single as ElementPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedElementRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('promotes an edit after a lineup add as a patch', () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.lineup('page-1', 'lineup-1'); + + await notifier.enqueue(const LineupAddOp( + opId: 'lineup-add-in-flight', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupAddOp( + opId: 'lineup-add-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'lineup-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalEdit = repository.calls[1].single as LineupPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedLineupRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('keeps a restore add after an accepted element delete', () async { + final store = MemoryDurableStrategyOutboxStore(); + 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 key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementDeleteOp( + opId: 'element-delete-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + expectedElementRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-restore-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'restored'}, + sortIndex: 0, + expectedElementRevision: 1, + ), + }, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-delete-in-flight', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final restore = repository.calls[1].single as ElementAddOp; + expect(restore.payload, {'value': 'restored'}); + expect(restore.expectedElementRevision, 2); + repository.completeSecond(AppliedOpAck( + opId: restore.opId, + revision: 3, + )); + await repository.secondCompleted.future; + }); + test('keeps a final element delete behind an in-flight add', () async { final store = MemoryDurableStrategyOutboxStore(); final repository = _SequencedAckRepository(); From eb573b85b1e8c25baa760d8da8f74ebee5eacc8e Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 21:27:22 -0400 Subject: [PATCH 4/7] fix: keep rejected successors in attention --- .../collab/strategy_op_queue_provider.dart | 62 ++++++++-- test/strategy_op_queue_provider_test.dart | 109 ++++++++++++++++-- 2 files changed, 151 insertions(+), 20 deletions(-) diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 6be6c84c..d715c6d6 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -331,11 +331,56 @@ class StrategyOpQueueNotifier extends Notifier { final pausedIntent = paused[key]; final attentionIntent = attention[key]; + // A rejected op remains the durable authority until the user + // explicitly retries it. Reconciliation may update its successor, but + // it must never make rejected work eligible for an automatic flush. + if (attentionIntent != null) { + if (desired == null) continue; + final current = _recordForActiveKey(key); + if (current == null) { + throw StateError('Durable attention record is missing for $key.'); + } + if (_sameIntent(attentionIntent.pending.op, desired)) { + if (successorIntent != null) { + await _putRecord(current.copyWith( + clearSuccessorPending: true, + updatedAt: DateTime.now(), + )); + successors.remove(key); + changed = true; + } + continue; + } + if (successorIntent != null && + _sameIntent(successorIntent.pending.op, desired)) { + continue; + } + final pending = PendingOp( + op: successorIntent == null + ? desired + : _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, + clientId: successorIntent?.pending.clientId ?? + attentionIntent.pending.clientId, + ); + await _putRecord(current.copyWith( + status: DurableOutboxStatus.attention, + successorPending: pending, + updatedAt: DateTime.now(), + )); + successors[key] = QueuedEntityIntent( + entityKey: key, + pending: pending, + ); + changed = true; + continue; + } + if (desired == null) { if (inFlight != null || successorIntent != null) { continue; } - final current = existing ?? pausedIntent ?? attentionIntent; + final current = existing ?? pausedIntent; if (current != null) { await _removeRecordIfCurrent(key, current.pending.op.opId); queued.remove(key); @@ -425,14 +470,10 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - // A rejected opId is an immutable server event. Reconciliation must - // replace it with the newly based op instead of replaying the reject. - final base = attentionIntent ?? pausedIntent ?? existing; - final merged = attentionIntent != null + final base = pausedIntent ?? existing; + final merged = base == null ? desired - : (base == null - ? desired - : _mergeQueuedIntent(base.pending.op, desired)); + : _mergeQueuedIntent(base.pending.op, desired); if (merged == null) { if (base != null) { await _removeRecordIfCurrent(key, base.pending.op.opId); @@ -447,9 +488,8 @@ class StrategyOpQueueNotifier extends Notifier { final pending = PendingOp( op: merged, clientId: base?.pending.clientId ?? state.clientId!, - attempts: attentionIntent != null ? 0 : (base?.pending.attempts ?? 0), - lastAttemptAt: - attentionIntent != null ? null : base?.pending.lastAttemptAt, + attempts: base?.pending.attempts ?? 0, + lastAttemptAt: base?.pending.lastAttemptAt, ); final record = _recordFor( key: key, diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 370a162d..95051908 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -250,11 +250,14 @@ void main() { ); }); - test('reconciliation replaces rejected immutable opId before removal', + test('reconciliation retains rejected work and updates its successor', () async { - final saved = record(status: DurableOutboxStatus.attention); + final saved = record(status: DurableOutboxStatus.attention).copyWith( + latestServerRevision: 7, + lastError: 'revision_mismatch', + ); await store.put(saved); - final notifier = start(); + var notifier = start(); await notifier.syncDesiredOpsForPage( pageId: 'page-1', desiredOpsByEntityKey: { @@ -263,14 +266,58 @@ void main() { }, flushImmediately: false, ); - final current = container!.read(strategyOpQueueProvider); - expect(current.attentionByEntityKey, isEmpty); - expect(current.queuedByEntityKey.values.single.pending.op.opId, - 'replacement'); + var current = container!.read(strategyOpQueueProvider); + expect( + current.attentionByEntityKey.values.single.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey, isEmpty); + expect( + current.successorByEntityKey.values.single.pending.op.payload, + {'value': 'new'}, + ); + + notifier = start(); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(current.successorByEntityKey, hasLength(1)); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + const EntitySyncKey.element('page-1', 'element-1'): + elementOp(opId: 'newer-replacement', value: 'newest'), + }, + flushImmediately: false, + ); + + current = container!.read(strategyOpQueueProvider); expect( - (store.values.values.single as Map)['opId'], - 'replacement', + current.attentionByEntityKey.values.single.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey, isEmpty); + expect( + current.successorByEntityKey.values.single.pending.op.payload, + {'value': 'newest'}, + ); + var durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'op-1'); + expect(durable.successorPending!.op.payload, {'value': 'newest'}); + expect(durable.latestServerRevision, 7); + expect(durable.lastError, 'revision_mismatch'); + + await notifier.retryRejected(flushImmediately: false); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.successorByEntityKey, isEmpty); + final retry = current.queuedByEntityKey.values.single.pending.op; + expect(retry.opId, isNot(anyOf('op-1', 'newer-replacement'))); + expect(retry.payload, {'value': 'newest'}); + expect(retry.expectedRevision, 7); + durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), ); + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.successorPending, isNull); }); test('ordinary reconciliation does not discard attention work', () async { @@ -966,6 +1013,50 @@ void main() { expect(durable.pending.op.opId, 'conflicting-first'); expect(durable.successorPending!.op.payload, {'value': 'second'}); expect(durable.latestServerRevision, 2); + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'retained-second-again', + value: 'second', + expectedRevision: 1, + ), + }, + flushImmediately: true, + ); + await Future.delayed(Duration.zero); + + final reconciled = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(reconciled.attentionByEntityKey, contains(key)); + expect(reconciled.queuedByEntityKey, isEmpty); + expect( + reconciled.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durableAfterReconcile = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableAfterReconcile.status, DurableOutboxStatus.attention); + expect(durableAfterReconcile.pending.op.opId, 'conflicting-first'); + expect( + durableAfterReconcile.successorPending!.op.payload, + {'value': 'second'}, + ); + expect(durableAfterReconcile.latestServerRevision, 2); + + await notifier.retryRejected(flushImmediately: true); + await repository.secondStarted.future; + final retried = repository.calls[1].single as ElementPatchOp; + expect(retried.opId, isNot('retained-second')); + expect(retried.payload, {'value': 'second'}); + expect(retried.expectedElementRevision, 2); + repository.completeSecond(AppliedOpAck( + opId: retried.opId, + revision: 3, + )); + await repository.secondCompleted.future; }); test('promotes an edit after an element add as a patch', () async { From 972c4c35e5b98f6947cdc2c036209603703551e8 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 21:48:54 -0400 Subject: [PATCH 5/7] fix: retain queued adds across page restart --- .../active_page_live_sync_provider.dart | 35 ++++++++- test/strategy_page_session_provider_test.dart | 74 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 9d047bc9..bed84bee 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -199,6 +199,7 @@ class ActivePageLiveSyncNotifier extends Notifier { final nextOverlay = Map.from( state.overlayByEntityKey, ); + final retainedDesiredOps = {}; for (final key in pageKeys) { final remote = remoteEntities[key]; @@ -208,10 +209,28 @@ class ActivePageLiveSyncNotifier extends Notifier { final hasInFlight = queueState.inFlightByEntityKey.containsKey(key); final hasSuccessor = queueState.successorByEntityKey.containsKey(key); final existingOverlay = state.overlayByEntityKey[key]; + final retainedOp = queueState.successorByEntityKey[key]?.pending.op ?? + queueState.inFlightByEntityKey[key]?.pending.op ?? + queueState.queuedByEntityKey[key]?.pending.op; final shouldPreserveTouched = hasQueued || hasInFlight || hasSuccessor; final matchesRemote = _entitiesEquivalent(local, remote); final matchesHydratedBase = _entitiesEquivalent(local, hydratedBase); + final shouldUseRetainedIntent = hasQueued || + (!hasInFlight && hasSuccessor) || + (local == null && hydratedBase == null); + + // A restored queue entry has no in-memory overlay. If the canvas still + // matches its hydrated base, the durable op is the only local intent and + // must remain desired until it lands or the user changes that entity. + if (existingOverlay == null && + retainedOp != null && + shouldUseRetainedIntent && + matchesHydratedBase) { + retainedDesiredOps[key] = retainedOp; + _debugLog('overlay.keep $key reason=durable_queue_only'); + continue; + } if (matchesHydratedBase && !shouldPreserveTouched) { if (nextOverlay.remove(key) != null) { @@ -261,11 +280,17 @@ class ActivePageLiveSyncNotifier extends Notifier { ); continue; } + final entityType = existingOverlay?.entityType ?? + hydratedBase?.overlayEntityType ?? + remote?.overlayEntityType ?? + key.overlayType; + if (entityType == null) { + _debugLog('overlay.skip $key reason=unsupported_entity_key'); + continue; + } final overlay = ActivePageOverlayEntry( entityKey: key, - entityType: existingOverlay?.entityType ?? - hydratedBase?.overlayEntityType ?? - remote!.overlayEntityType, + entityType: entityType, desiredPayload: null, desiredSortIndex: null, deletion: true, @@ -292,7 +317,9 @@ class ActivePageLiveSyncNotifier extends Notifier { ); } - final desiredOpsByEntityKey = {}; + final desiredOpsByEntityKey = { + ...retainedDesiredOps, + }; for (final entry in nextOverlay.entries) { final key = entry.key; if (key.pageId != pageId) { diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index b35a14c3..08e26b83 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/line_provider.dart'; @@ -15,6 +16,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; @@ -988,6 +990,78 @@ void main() { expect(delete.expectedElementRevision, 0); }); + test('restart retains a queued add missing from canvas and remote', () async { + final page = _page('page-1', 0); + const textId = 'queued-before-restart'; + const key = EntitySyncKey.element('page-1', textId); + final add = ElementAddOp( + opId: 'add-before-restart', + elementPublicId: textId, + pagePublicId: page.publicId, + payload: _textElement(page.publicId, textId, 'unsent').payload, + sortIndex: 0, + ); + final store = MemoryDurableStrategyOutboxStore(); + final firstContainer = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + ]); + firstContainer + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final firstQueue = firstContainer.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await firstQueue.enqueue(add, flushImmediately: false); + expect(store.load().records.single.pending.op.opId, 'add-before-restart'); + firstContainer.dispose(); + + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )); + final restarted = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + remoteEditorSnapshotProvider.overrideWith(() => remote), + ]); + addTearDown(restarted.dispose); + restarted + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final restartedQueue = restarted.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await restarted.read(remoteEditorSnapshotProvider.future); + final sync = restarted.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final retained = desired![key] as ElementAddOp; + expect(retained.opId, 'add-before-restart'); + expect(retained.payload, add.payload); + await restartedQueue.syncDesiredOpsForPage( + pageId: page.publicId, + desiredOpsByEntityKey: desired, + flushImmediately: false, + ); + expect( + restarted + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op, + isA(), + ); + final durable = store.load().records.singleWhere( + (record) => record.entityKey == key, + ); + expect(durable.pending.op.opId, 'add-before-restart'); + }); + test('side switch authors exactly one Page descriptor operation', () async { final page = _page('page-1', 0, revision: 11); final container = await _syncContainer( From e4b388b6f5966957676490301eb59c663bc400ca Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 22:54:14 -0400 Subject: [PATCH 6/7] fix: add explicit cloud conflict resolution --- .../active_page_live_sync_provider.dart | 26 ++ .../collab/strategy_op_queue_provider.dart | 88 ++++++- .../strategy_page_session_provider.dart | 78 +++++- lib/providers/strategy_provider.dart | 4 + lib/widgets/cloud_sync_status_chip.dart | 120 +++++++-- test/strategy_op_queue_provider_test.dart | 225 +++++++++++++++++ test/strategy_page_session_provider_test.dart | 210 +++++++++++++++- test/widgets/cloud_sync_status_chip_test.dart | 230 ++++++++++++++++++ 8 files changed, 955 insertions(+), 26 deletions(-) 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), + ); + }); } From 5bf2fc590884d4d958c38df33db0896fe8f85d8b Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 00:21:24 -0400 Subject: [PATCH 7/7] fix: surface and drain account cloud outboxes --- lib/main.dart | 10 +- lib/providers/auth_provider.dart | 5 +- .../collab/cloud_sync_status_provider.dart | 9 +- .../collab/remote_library_provider.dart | 8 + .../collab/strategy_op_queue_provider.dart | 697 ++++++++++++++---- lib/services/cloud_sign_out_coordinator.dart | 250 +++++++ lib/services/guarded_sign_out.dart | 34 + lib/widgets/cloud_outbox_summary_banner.dart | 184 +++++ lib/widgets/cloud_sync_status_chip.dart | 55 +- lib/widgets/folder_navigator.dart | 50 +- lib/widgets/settings_tab.dart | 12 +- test/cloud_sign_out_coordinator_test.dart | 431 +++++++++++ test/global_strategy_outbox_test.dart | 474 ++++++++++++ test/providers/auth_provider_test.dart | 46 ++ test/strategy_op_queue_provider_test.dart | 21 + test/strategy_page_session_provider_test.dart | 14 + .../cloud_beta_automation_semantics_test.dart | 47 ++ .../cloud_outbox_summary_banner_test.dart | 288 ++++++++ test/widgets/cloud_sync_status_chip_test.dart | 132 +++- test/widgets/settings_sign_out_test.dart | 49 ++ 20 files changed, 2636 insertions(+), 180 deletions(-) create mode 100644 lib/services/cloud_sign_out_coordinator.dart create mode 100644 lib/services/guarded_sign_out.dart create mode 100644 lib/widgets/cloud_outbox_summary_banner.dart create mode 100644 test/cloud_sign_out_coordinator_test.dart create mode 100644 test/global_strategy_outbox_test.dart create mode 100644 test/widgets/cloud_outbox_summary_banner_test.dart create mode 100644 test/widgets/settings_sign_out_test.dart diff --git a/lib/main.dart b/lib/main.dart index 51ca1f00..81d9e728 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,7 @@ import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -39,7 +40,9 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/share/share_link_format.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/cloud_sign_out_coordinator.dart'; import 'package:icarus/services/discord_presence_service.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -113,7 +116,11 @@ Future main(List args) async { () async { WidgetsFlutterBinding.ensureInitialized(); - appProviderContainer = ProviderContainer(); + appProviderContainer = ProviderContainer(overrides: [ + guardedSignOutRequestProvider.overrideWith( + (ref) => ref.watch(cloudSignOutRequestProvider), + ), + ]); await _initializePersistedDebugLog(); _installGlobalErrorHandlers(); @@ -390,6 +397,7 @@ class _MyAppState extends ConsumerState { void initState() { super.initState(); ref.read(authProvider); + ref.read(strategyOpQueueProvider); ref.read(cloudMediaUploadQueueProvider); ref.read(cloudMediaCacheProvider); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index d298d9fc..8d25dbdb 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -1251,7 +1252,9 @@ class AuthProvider extends Notifier { await reinitializeConvexAuth(source: 'incident_prompt_retry'); break; case _AuthIncidentAction.signOut: - await signOut(); + if (navCtx.mounted) { + await ref.read(guardedSignOutRequestProvider)(navCtx); + } break; case _AuthIncidentAction.dismiss: case null: diff --git a/lib/providers/collab/cloud_sync_status_provider.dart b/lib/providers/collab/cloud_sync_status_provider.dart index bedc1f6c..c244db3a 100644 --- a/lib/providers/collab/cloud_sync_status_provider.dart +++ b/lib/providers/collab/cloud_sync_status_provider.dart @@ -20,8 +20,8 @@ final cloudSyncStatusProvider = Provider((ref) { final unknownOwnerMediaJobs = mediaQueueState.unknownOwnerJobsForStrategy( strategy.source == StrategySource.cloud ? strategy.strategyId : null, ); - final activeMediaErrorCount = - activeMediaJobs.where((job) => job.isFailed).length; + final accountMediaErrorCount = + mediaQueueState.jobs.where((job) => job.isFailed).length; final hasTextDrafts = ref.watch( textDraftProvider.select((drafts) => drafts.isNotEmpty), ); @@ -35,8 +35,9 @@ final cloudSyncStatusProvider = Provider((ref) { return CloudSyncStatus.attention; } if (opQueueState.needsAttention || + opQueueState.accountOutbox.needsAttention || saveState.mediaSyncErrorCount > 0 || - activeMediaErrorCount > 0 || + accountMediaErrorCount > 0 || unknownOwnerMediaJobs.isNotEmpty) { return CloudSyncStatus.attention; } @@ -53,6 +54,8 @@ final cloudSyncStatusProvider = Provider((ref) { saveState.hasPendingCloudSync || saveState.hasPendingMediaSync || activeMediaJobs.isNotEmpty || + opQueueState.accountOutbox.hasWork || + mediaQueueState.jobs.isNotEmpty || !opQueueState.durableLoaded || !mediaQueueState.durableLoaded) { return CloudSyncStatus.syncing; diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart index 1ef5f6ac..20a41239 100644 --- a/lib/providers/collab/remote_library_provider.dart +++ b/lib/providers/collab/remote_library_provider.dart @@ -119,6 +119,14 @@ final cloudStrategiesProvider = } }); +final cloudStrategyNamesProvider = Provider>((ref) { + final strategies = ref.watch(cloudStrategiesProvider).valueOrNull; + if (strategies == null) return const {}; + return { + for (final entry in strategies) entry.strategy.id: entry.strategy.name, + }; +}); + bool _isInvalidFolderError(Object error) { final message = error.toString().toLowerCase(); return message.contains('folder not found') || message.contains('forbidden'); diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 068b41ec..e5531dbf 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -13,6 +13,77 @@ import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:uuid/uuid.dart'; +class StrategyOutboxSession { + const StrategyOutboxSession({ + required this.accountId, + required this.isReady, + required this.hasAuthIncident, + }); + + final String? accountId; + final bool isReady; + final bool hasAuthIncident; +} + +final strategyOutboxSessionProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return StrategyOutboxSession( + accountId: auth.user?.id, + isReady: auth.isAuthenticated && auth.isConvexUserReady, + hasAuthIncident: auth.hasActiveAuthIncident, + ); +}); + +class StrategyOutboxSummary { + const StrategyOutboxSummary({ + required this.strategyPublicId, + required this.queuedCount, + required this.inFlightCount, + required this.pausedCount, + required this.attentionCount, + required this.successorCount, + this.reason, + }); + + final String strategyPublicId; + final int queuedCount; + final int inFlightCount; + final int pausedCount; + final int attentionCount; + final int successorCount; + final String? reason; + + int get workCount => + queuedCount + + inFlightCount + + pausedCount + + attentionCount + + successorCount; + bool get hasRunnableWork => queuedCount > 0 || inFlightCount > 0; + bool get needsAttention => pausedCount > 0 || attentionCount > 0; +} + +class AccountStrategyOutboxSummary { + const AccountStrategyOutboxSummary({ + this.accountId, + this.strategies = const {}, + }); + + final String? accountId; + final Map strategies; + + int get workCount => strategies.values.fold( + 0, + (total, strategy) => total + strategy.workCount, + ); + int get strategyCount => strategies.length; + bool get hasWork => workCount > 0; + bool get hasRunnableWork => + strategies.values.any((strategy) => strategy.hasRunnableWork); + bool get needsAttention => + strategies.values.any((strategy) => strategy.needsAttention); +} + class StrategyOpQueueState { const StrategyOpQueueState({ this.accountId, @@ -31,6 +102,7 @@ class StrategyOpQueueState { this.lastFlushAt, this.lastAcks = const [], this.lastAckBatch = const [], + this.accountOutbox = const AccountStrategyOutboxSummary(), }); final String? accountId; @@ -49,6 +121,7 @@ class StrategyOpQueueState { final DateTime? lastFlushAt; final List lastAcks; final List lastAckBatch; + final AccountStrategyOutboxSummary accountOutbox; bool get needsAttention => loadIssues.isNotEmpty || @@ -79,6 +152,7 @@ class StrategyOpQueueState { DateTime? lastFlushAt, List? lastAcks, List? lastAckBatch, + AccountStrategyOutboxSummary? accountOutbox, }) { return StrategyOpQueueState( accountId: accountId, @@ -98,6 +172,7 @@ class StrategyOpQueueState { lastFlushAt: lastFlushAt ?? this.lastFlushAt, lastAcks: lastAcks ?? this.lastAcks, lastAckBatch: lastAckBatch ?? this.lastAckBatch, + accountOutbox: accountOutbox ?? this.accountOutbox, ); } } @@ -117,7 +192,10 @@ class StrategyOpQueueNotifier extends Notifier { static const Duration _debounceDelay = Duration(milliseconds: 180); Timer? _debounceTimer; Timer? _retryTimer; + Timer? _backgroundRetryTimer; int _offlineRetryCount = 0; + bool _networkBusy = false; + ({String accountId, String strategyPublicId})? _drainingStrategy; late DurableStrategyOutboxStore _store; late Map _recordsByStorageKey; final Set _awaitingRemoteAdoption = {}; @@ -136,8 +214,36 @@ class StrategyOpQueueNotifier extends Notifier { ref.onDispose(() { _retryTimer?.cancel(); _debounceTimer?.cancel(); + _backgroundRetryTimer?.cancel(); + }); + ref.listen(strategyOutboxSessionProvider, + (previous, next) { + if (previous?.accountId != next.accountId) { + setCurrentAccount(next.accountId); + } + final becameReady = !(previous?.isReady ?? false) && next.isReady; + final recovered = (previous?.hasAuthIncident ?? false) && + !next.hasAuthIncident; + if (becameReady || recovered) { + retryCurrentAccount(); + } }); + ref.listen>(convexConnectionProvider, (previous, next) { + if (previous?.valueOrNull != true && next.valueOrNull == true) { + _scheduleBackgroundDrain(ignoreBackoff: true); + } + }); + final session = ref.read(strategyOutboxSessionProvider); + if (session.accountId != null && + loaded.records + .any((record) => record.accountId == session.accountId)) { + _backgroundRetryTimer = Timer( + Duration.zero, + () => retryCurrentAccount(), + ); + } return StrategyOpQueueState( + accountId: session.accountId, clientId: const Uuid().v4(), loadIssues: loaded.issues, durableLoaded: true, @@ -145,15 +251,29 @@ class StrategyOpQueueNotifier extends Notifier { lastError: loaded.issues.isEmpty ? null : 'The cloud outbox contains unreadable saved work.', + accountOutbox: _accountSummary(session.accountId), ); } + void setCurrentAccount(String? accountId) { + if (state.accountId == accountId) { + _publishAccountSummary(); + _scheduleBackgroundDrain(ignoreBackoff: true); + return; + } + setActiveStrategy(null, accountId: accountId); + } + void setActiveStrategy( String? strategyPublicId, { required String? accountId, }) { if (state.strategyPublicId == strategyPublicId && - state.accountId == accountId) return; + state.accountId == accountId) { + _publishAccountSummary(); + _scheduleBackgroundDrain(ignoreBackoff: true); + return; + } _debounceTimer?.cancel(); _retryTimer?.cancel(); @@ -167,6 +287,7 @@ class StrategyOpQueueNotifier extends Notifier { record.strategyPublicId == strategyPublicId) .toList(growable: false); final queued = {}; + final inFlight = {}; final successors = {}; final paused = {}; final attention = {}; @@ -184,9 +305,20 @@ class StrategyOpQueueNotifier extends Notifier { } switch (record.status) { case DurableOutboxStatus.queued: - case DurableOutboxStatus.inFlight: - // An interrupted request is replayed with its original op/client id. queued[record.entityKey] = intent; + case DurableOutboxStatus.inFlight: + if (_drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId)) { + inFlight[record.entityKey] = InFlightEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + sentAt: record.updatedAt, + ); + } else { + // An interrupted request is replayed with its original op/client + // id after restart. + queued[record.entityKey] = intent; + } case DurableOutboxStatus.paused: paused[record.entityKey] = intent; case DurableOutboxStatus.attention: @@ -200,6 +332,7 @@ class StrategyOpQueueNotifier extends Notifier { strategyPublicId: strategyPublicId, clientId: clientId, queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, successorByEntityKey: successors, pausedByEntityKey: paused, attentionByEntityKey: attention, @@ -212,8 +345,12 @@ class StrategyOpQueueNotifier extends Notifier { paused: paused, attention: attention, ), + accountOutbox: _accountSummary(accountId), ); - if (queued.isNotEmpty) _scheduleFlush(flushImmediately: true); + if (queued.isNotEmpty && inFlight.isEmpty) { + _scheduleFlush(flushImmediately: true); + } + _scheduleBackgroundDrain(ignoreBackoff: true); } Future enqueue( @@ -737,23 +874,47 @@ class StrategyOpQueueNotifier extends Notifier { Future flushNow() async { await _writeTail; - if (state.isFlushing) return; + if (_networkBusy) return; + final accountId = state.accountId; final strategyPublicId = state.strategyPublicId; - if (strategyPublicId == null || state.queuedByEntityKey.isEmpty) return; + if (accountId == null || + strategyPublicId == null || + state.queuedByEntityKey.isEmpty) { + return; + } + + await _flushStrategy( + accountId: accountId, + strategyPublicId: strategyPublicId, + isBackground: false, + ); + } + + Future _flushStrategy({ + required String accountId, + required String strategyPublicId, + required bool isBackground, + bool ignoreBackoff = false, + }) async { + if (_networkBusy) return; final mode = ref.read(cloudCollabModeProvider); if (!mode.featureFlagEnabled || mode.forceLocalFallback) return; final auth = ref.read(authProvider); if (auth.hasActiveAuthIncident) { - state = state.copyWith( - lastError: 'Cloud auth incident active. Saved work is paused.', - ); + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith( + lastError: 'Cloud auth incident active. Saved work is paused.', + ); + } return; } - if (auth.user?.id != state.accountId) { - state = state.copyWith( - lastError: 'Cloud outbox belongs to a different account.', - ); + if (auth.user?.id != accountId) { + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith( + lastError: 'Cloud outbox belongs to a different account.', + ); + } return; } if (!auth.isAuthenticated || @@ -764,50 +925,42 @@ class StrategyOpQueueNotifier extends Notifier { : (!auth.isConvexUserReady ? 'Cloud user setup is not ready.' : 'Cloud connection is offline.'); - _scheduleRetry( - state.queuedByEntityKey.values.map((item) => item.pending).toList(), - delay: _offlineRetryDelay(), - ); - state = state.copyWith(lastError: message); + if (!isBackground && _isActive(accountId, strategyPublicId)) { + _scheduleRetry( + state.queuedByEntityKey.values.map((item) => item.pending).toList(), + delay: _offlineRetryDelay(), + ); + state = state.copyWith(lastError: message); + } return; } - final candidates = state.queuedByEntityKey.values.toList(growable: false); - if (candidates.isEmpty) return; - final batchClientId = candidates.first.pending.clientId; - final batch = candidates - .where((intent) => intent.pending.clientId == batchClientId) - .take(_maxBatchSize) - .toList(growable: false); - final queued = Map.from( - state.queuedByEntityKey, - ); - final inFlight = Map.from( - state.inFlightByEntityKey, + _networkBusy = true; + _drainingStrategy = ( + accountId: accountId, + strategyPublicId: strategyPublicId, ); - final sentAt = DateTime.now(); + List batch; + var batchSucceeded = false; try { - for (final intent in batch) { - await _putRecord(_recordFor( - key: intent.entityKey, - pending: intent.pending, - status: DurableOutboxStatus.inFlight, - )); - queued.remove(intent.entityKey); - inFlight[intent.entityKey] = InFlightEntityIntent( - entityKey: intent.entityKey, - pending: intent.pending, - sentAt: sentAt, - ); - } + batch = await _claimBatch( + accountId: accountId, + strategyPublicId: strategyPublicId, + ignoreBackoff: ignoreBackoff || !isBackground, + ); } catch (error, stackTrace) { _recordPersistenceFailure(error, stackTrace); + _finishNetworkLane(); return; } - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: inFlight, - isFlushing: true, + if (batch.isEmpty) { + _finishNetworkLane(); + _scheduleBackgroundDrain(); + return; + } + final batchClientId = batch.first.pending.clientId; + _refreshActiveQueueView( + isFlushing: _isActive(accountId, strategyPublicId), clearError: true, ); @@ -818,10 +971,10 @@ class StrategyOpQueueNotifier extends Notifier { final acks = await _repo.applyBatch( strategyPublicId: strategyPublicId, clientId: batchClientId, - ops: batch.map((intent) => intent.pending.op).toList(growable: false), + ops: batch.map((record) => record.pending.op).toList(growable: false), ); - await _applyAcks(batch, acks); - if (state.queuedByEntityKey.isNotEmpty) unawaited(flushNow()); + await _applyAcksForRecords(batch, acks); + batchSucceeded = true; } catch (error, stackTrace) { if (isConvexUnauthenticatedError(error)) { unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( @@ -833,44 +986,90 @@ class StrategyOpQueueNotifier extends Notifier { log('Failed flushing op queue: $error', error: error, stackTrace: stackTrace); } - await _restoreBatchAfterFailure(batch, lastError: '$error'); + await _restoreRecordsAfterFailure(batch, lastError: '$error'); + } finally { + _finishNetworkLane(); } + + if (state.queuedByEntityKey.isNotEmpty && + (isBackground || batchSucceeded)) { + unawaited(flushNow()); + } else { + _scheduleBackgroundDrain(); + } + } + + Future> _claimBatch({ + required String accountId, + required String strategyPublicId, + required bool ignoreBackoff, + }) { + return _serializeWrite(() async { + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + (ignoreBackoff || !_nextAttemptAt(record).isAfter(now))) + .toList(growable: false); + if (candidates.isEmpty) return const []; + final batchClientId = candidates.first.pending.clientId; + final selected = candidates + .where((record) => record.pending.clientId == batchClientId) + .take(_maxBatchSize) + .toList(growable: false); + final claimed = []; + for (final record in selected) { + final current = _recordsByStorageKey[record.storageKey]; + if (current == null || + current.pending.op.opId != record.pending.op.opId || + (current.status != DurableOutboxStatus.queued && + current.status != DurableOutboxStatus.inFlight)) { + continue; + } + final inFlight = current.copyWith( + status: DurableOutboxStatus.inFlight, + updatedAt: now, + clearError: true, + ); + await _putRecord(inFlight); + claimed.add(inFlight); + } + return claimed; + }); + } + + Future _applyAcksForRecords( + List batch, + List acks, + ) { + return _serializeWrite(() => _applyAcksForRecordsLocked(batch, acks)); } - Future _applyAcks( - List batch, + Future _applyAcksForRecordsLocked( + List batch, List acks, ) async { final byOpId = {for (final item in batch) item.pending.op.opId: item}; final ackByOpId = {for (final ack in acks) ack.opId: ack}; - if (ackByOpId.length != batch.length) { + if (ackByOpId.length != batch.length || + !ackByOpId.keys.toSet().containsAll(byOpId.keys)) { throw StateError( 'Server returned an incomplete operation result batch.', ); } - final inFlight = Map.from( - state.inFlightByEntityKey, - ); - final queued = Map.from( - state.queuedByEntityKey, - ); - final successors = Map.from( - state.successorByEntityKey, - ); - final attention = Map.from( - state.attentionByEntityKey, - ); final acked = []; for (final ack in acks) { final sent = byOpId[ack.opId]; if (sent == null) continue; - inFlight.remove(sent.entityKey); acked.add(AckedEntityIntent( entityKey: sent.entityKey, op: sent.pending.op, ack: ack, )); - final current = _recordForActiveKey(sent.entityKey); + final current = _recordsByStorageKey[sent.storageKey]; if (current?.pending.op.opId != ack.opId) continue; final successor = current!.successorPending; // Only an accepted predecessor establishes a revision for automatic @@ -895,12 +1094,6 @@ class StrategyOpQueueNotifier extends Notifier { clearError: true, clearLatestServerRevision: true, )); - queued[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: promoted, - ); - successors.remove(sent.entityKey); - attention.remove(sent.entityKey); } else if (successor != null) { final retained = current.copyWith( status: DurableOutboxStatus.attention, @@ -910,13 +1103,8 @@ class StrategyOpQueueNotifier extends Notifier { latestServerRevision: ack.latestRevision, ); await _putRecord(retained); - attention[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: sent.pending, - ); } else if (ack.isAck) { - await _removeRecordIfCurrent(sent.entityKey, ack.opId); - successors.remove(sent.entityKey); + await _removeRecordByStorageKeyIfCurrent(sent.storageKey, ack.opId); } else { final rejected = current.copyWith( status: DurableOutboxStatus.attention, @@ -925,84 +1113,70 @@ class StrategyOpQueueNotifier extends Notifier { latestServerRevision: ack.latestRevision, ); await _putRecord(rejected); - attention[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: sent.pending, - ); } } - final attentionMessage = _loadedAttentionMessage( - loadIssues: state.loadIssues, - paused: state.pausedByEntityKey, - attention: attention, - ); - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: inFlight, - successorByEntityKey: successors, - attentionByEntityKey: attention, - isFlushing: false, - lastFlushAt: DateTime.now(), - lastAcks: acks, - lastAckBatch: acked, - lastError: attentionMessage, - clearError: attentionMessage == null, + final first = batch.first; + if (_isActive(first.accountId, first.strategyPublicId)) { + _refreshActiveQueueView( + isFlushing: false, + lastAcks: acks, + lastAckBatch: acked, + lastFlushAt: DateTime.now(), + ); + } else { + _refreshActiveQueueView(); + } + } + + Future _restoreRecordsAfterFailure( + List batch, { + required String lastError, + }) { + return _serializeWrite( + () => _restoreRecordsAfterFailureLocked(batch, lastError: lastError), ); } - Future _restoreBatchAfterFailure( - List batch, { + Future _restoreRecordsAfterFailureLocked( + List batch, { required String lastError, }) async { - final queued = Map.from( - state.queuedByEntityKey, - ); - final inFlight = Map.from( - state.inFlightByEntityKey, - ); - final paused = Map.from( - state.pausedByEntityKey, - ); final retrying = []; try { for (final sent in batch) { - inFlight.remove(sent.entityKey); - if (queued.containsKey(sent.entityKey)) continue; - final pending = sent.pending.incrementAttempt(); + final current = _recordsByStorageKey[sent.storageKey]; + if (current == null || + current.pending.op.opId != sent.pending.op.opId) { + continue; + } + final pending = current.pending.incrementAttempt(); final isPaused = pending.attempts >= _maxAttempts; - await _putRecord(_recordFor( - key: sent.entityKey, + await _putRecord(current.copyWith( pending: pending, status: isPaused ? DurableOutboxStatus.paused : DurableOutboxStatus.queued, + updatedAt: DateTime.now(), lastError: lastError, )); - final intent = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: pending, - ); - if (isPaused) { - paused[sent.entityKey] = intent; - } else { - queued[sent.entityKey] = intent; - retrying.add(pending); - } + if (!isPaused) retrying.add(pending); } } catch (error, stackTrace) { _recordPersistenceFailure(error, stackTrace); return; } - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: inFlight, - pausedByEntityKey: paused, - isFlushing: false, - lastError: paused.isEmpty - ? lastError - : '$lastError (retry paused after $_maxAttempts attempts)', - ); - _scheduleRetry(retrying); + final draining = _drainingStrategy; + if (draining != null && + _isActive(draining.accountId, draining.strategyPublicId)) { + _refreshActiveQueueView( + isFlushing: false, + lastError: lastError, + useProvidedError: true, + ); + _scheduleRetry(retrying); + } else { + _refreshActiveQueueView(); + } } DurableOutboxRecord _recordFor({ @@ -1044,6 +1218,7 @@ class StrategyOpQueueNotifier extends Notifier { Future _putRecord(DurableOutboxRecord record) async { await _store.put(record); _recordsByStorageKey[record.storageKey] = record; + _publishAccountSummary(); } Future _removeRecordIfCurrent( @@ -1054,6 +1229,178 @@ class StrategyOpQueueNotifier extends Notifier { if (record == null || record.pending.op.opId != opId) return; await _store.remove(record.storageKey); _recordsByStorageKey.remove(record.storageKey); + _publishAccountSummary(); + } + + Future _removeRecordByStorageKeyIfCurrent( + String storageKey, + String opId, + ) async { + final record = _recordsByStorageKey[storageKey]; + if (record == null || record.pending.op.opId != opId) return; + await _store.remove(storageKey); + _recordsByStorageKey.remove(storageKey); + _publishAccountSummary(); + } + + bool _isActive(String accountId, String strategyPublicId) { + return state.accountId == accountId && + state.strategyPublicId == strategyPublicId; + } + + void _refreshActiveQueueView({ + bool? isFlushing, + String? lastError, + bool useProvidedError = false, + DateTime? lastFlushAt, + List? lastAcks, + List? lastAckBatch, + bool clearError = false, + }) { + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + final queued = {}; + final inFlight = {}; + final successors = {}; + final paused = {}; + final attention = {}; + if (accountId != null && strategyPublicId != null) { + final isActivelyDraining = _drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId); + for (final record in _recordsByStorageKey.values) { + if (record.accountId != accountId || + record.strategyPublicId != strategyPublicId) { + continue; + } + final intent = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + switch (record.status) { + case DurableOutboxStatus.queued: + queued[record.entityKey] = intent; + case DurableOutboxStatus.inFlight: + if (isActivelyDraining) { + inFlight[record.entityKey] = InFlightEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + sentAt: record.updatedAt, + ); + } else { + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.paused: + paused[record.entityKey] = intent; + case DurableOutboxStatus.attention: + attention[record.entityKey] = intent; + } + } + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ); + final effectiveError = attentionMessage ?? + (useProvidedError ? lastError : (clearError ? null : state.lastError)); + state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + successorByEntityKey: successors, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + accountOutbox: _accountSummary(accountId), + isFlushing: isFlushing, + lastError: effectiveError, + clearError: effectiveError == null, + lastFlushAt: lastFlushAt, + lastAcks: lastAcks, + lastAckBatch: lastAckBatch, + ); + } + + void _finishNetworkLane() { + _networkBusy = false; + _drainingStrategy = null; + if (state.isFlushing) { + _refreshActiveQueueView(isFlushing: false); + } + } + + AccountStrategyOutboxSummary _accountSummary(String? accountId) { + if (accountId == null) return const AccountStrategyOutboxSummary(); + final recordsByStrategy = >{}; + for (final record in _recordsByStorageKey.values) { + if (record.accountId != accountId) continue; + (recordsByStrategy[record.strategyPublicId] ??= []) + .add(record); + } + return AccountStrategyOutboxSummary( + accountId: accountId, + strategies: { + for (final entry in recordsByStrategy.entries) + entry.key: StrategyOutboxSummary( + strategyPublicId: entry.key, + queuedCount: entry.value + .where((record) => record.status == DurableOutboxStatus.queued) + .length, + inFlightCount: entry.value + .where( + (record) => record.status == DurableOutboxStatus.inFlight) + .length, + pausedCount: entry.value + .where((record) => record.status == DurableOutboxStatus.paused) + .length, + attentionCount: entry.value + .where( + (record) => record.status == DurableOutboxStatus.attention) + .length, + successorCount: entry.value + .where((record) => record.successorPending != null) + .length, + reason: entry.value + .where((record) => record.lastError?.isNotEmpty ?? false) + .map((record) => record.lastError) + .firstOrNull, + ), + }, + ); + } + + void _publishAccountSummary() { + final summary = _accountSummary(state.accountId); + if (_sameAccountSummary(state.accountOutbox, summary)) return; + state = state.copyWith(accountOutbox: summary); + } + + bool _sameAccountSummary( + AccountStrategyOutboxSummary left, + AccountStrategyOutboxSummary right, + ) { + if (left.accountId != right.accountId || + left.strategies.length != right.strategies.length) { + return false; + } + for (final entry in left.strategies.entries) { + final other = right.strategies[entry.key]; + if (other == null || + other.queuedCount != entry.value.queuedCount || + other.inFlightCount != entry.value.inFlightCount || + other.pausedCount != entry.value.pausedCount || + other.attentionCount != entry.value.attentionCount || + other.successorCount != entry.value.successorCount || + other.reason != entry.value.reason) { + return false; + } + } + return true; } Future _serializeWrite(Future Function() action) { @@ -1086,6 +1433,82 @@ class StrategyOpQueueNotifier extends Notifier { _debounceTimer = Timer(_debounceDelay, () => unawaited(flushNow())); } + void retryCurrentAccount() { + _scheduleBackgroundDrain(ignoreBackoff: true); + if (state.queuedByEntityKey.isNotEmpty) { + unawaited(flushNow()); + } + } + + void _scheduleBackgroundDrain({bool ignoreBackoff = false}) { + _backgroundRetryTimer?.cancel(); + final accountId = state.accountId; + if (accountId == null) return; + final activeStrategyId = state.strategyPublicId; + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId != activeStrategyId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight)) + .toList(growable: false); + if (candidates.isEmpty) return; + final nextAttempt = candidates + .map(_nextAttemptAt) + .reduce((left, right) => left.isBefore(right) ? left : right); + final delay = ignoreBackoff || !nextAttempt.isAfter(now) + ? Duration.zero + : nextAttempt.difference(now); + _backgroundRetryTimer = Timer( + delay, + () => unawaited(_drainNextBackgroundStrategy( + ignoreBackoff: ignoreBackoff, + )), + ); + } + + Future _drainNextBackgroundStrategy({ + required bool ignoreBackoff, + }) async { + if (_networkBusy) return; + await _writeTail; + final accountId = state.accountId; + if (accountId == null) return; + final activeStrategyId = state.strategyPublicId; + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId != activeStrategyId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + (ignoreBackoff || !_nextAttemptAt(record).isAfter(now))) + .toList(growable: false) + ..sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); + if (candidates.isEmpty) { + _scheduleBackgroundDrain(); + return; + } + await _flushStrategy( + accountId: accountId, + strategyPublicId: candidates.first.strategyPublicId, + isBackground: true, + ignoreBackoff: ignoreBackoff, + ); + } + + DateTime _nextAttemptAt(DurableOutboxRecord record) { + final lastAttemptAt = record.pending.lastAttemptAt; + if (lastAttemptAt == null || record.status == DurableOutboxStatus.inFlight) { + return DateTime.fromMillisecondsSinceEpoch(0); + } + final exponent = record.pending.attempts.clamp(0, 6); + return lastAttemptAt.add( + Duration(milliseconds: 300 * (1 << exponent)), + ); + } + void _scheduleRetry(List pending, {Duration? delay}) { if (pending.isEmpty) return; final maxAttempt = pending.fold( diff --git a/lib/services/cloud_sign_out_coordinator.dart b/lib/services/cloud_sign_out_coordinator.dart new file mode 100644 index 00000000..fb41be34 --- /dev/null +++ b/lib/services/cloud_sign_out_coordinator.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_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'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +typedef CloudSignOutPreparation = Future Function(); +typedef CloudEditorClose = Future Function(); +typedef RawSignOut = Future Function(); + +final cloudSignOutPreparationProvider = Provider( + (ref) => () async { + final strategy = ref.read(strategyProvider); + if (strategy.source != StrategySource.cloud || + strategy.strategyId == null) { + return; + } + ref.read(textDraftProvider.notifier).commitAllDrafts(); + await ref + .read(strategyProvider.notifier) + .forceSaveNow(strategy.strategyId!); + // Reconciliation promotes staged media only after the durable strategy op + // proves its exact reference. Network processing continues independently. + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + }, +); + +final cloudEditorCloseProvider = Provider( + (ref) => () async { + if (ref.read(strategyProvider).source == StrategySource.cloud) { + await ref.read(strategyProvider.notifier).clearCurrentStrategy(); + } + }, +); + +final rawSignOutProvider = Provider( + (ref) => () async { + await ref.read(authProvider.notifier).signOut(); + return !ref.read(authProvider).isAuthenticated; + }, +); + +final cloudSignOutRequestProvider = Provider( + (ref) { + var requestInProgress = false; + return (context) async { + if (requestInProgress) return false; + requestInProgress = true; + try { + return await _requestCloudSafeSignOut(context, ref); + } finally { + requestInProgress = false; + } + }; + }, +); + +Future _requestCloudSafeSignOut( + BuildContext context, + Ref ref, +) async { + final accountId = ref.read(authProvider).user?.id; + if (accountId == null) return false; + + try { + await ref.read(cloudSignOutPreparationProvider)(); + } catch (_) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + + final strategy = ref.read(strategyProvider); + final saveState = ref.read(strategySaveStateProvider); + final opQueue = ref.read(strategyOpQueueProvider); + final mediaQueue = ref.read(cloudMediaUploadQueueProvider); + final currentStrategyId = + strategy.source == StrategySource.cloud ? strategy.strategyId : null; + final stagedMedia = mediaQueue.jobs + .where((job) => + job.accountId == accountId && + currentStrategyId == job.strategyPublicId && + !job.referenceDurable) + .toList(growable: false); + final hasUnstagedActiveWork = ref.read(textDraftProvider).isNotEmpty || + stagedMedia.isNotEmpty || + saveState.isSaving || + (saveState.isDirty && + opQueue.pending.isEmpty && + mediaQueue.jobsForStrategy(currentStrategyId).isEmpty); + if (!opQueue.outboxIsReliable || + !mediaQueue.outboxIsReliable || + hasUnstagedActiveWork) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + + final strategyIds = { + ...opQueue.accountOutbox.strategies.keys, + for (final job in mediaQueue.jobs) + if (job.accountId == accountId) job.strategyPublicId, + }; + final activeUnknownOwnerJobs = mediaQueue.unknownOwnerJobsForStrategy( + currentStrategyId, + ); + if (activeUnknownOwnerJobs.isNotEmpty && currentStrategyId != null) { + strategyIds.add(currentStrategyId); + } + final workCount = opQueue.accountOutbox.workCount + + mediaQueue.jobs.where((job) => job.accountId == accountId).length + + activeUnknownOwnerJobs.length; + if (!context.mounted) return false; + final confirmed = await _showSignOutConfirmation( + context, + workCount: workCount, + strategyIds: strategyIds, + currentStrategyId: currentStrategyId, + currentStrategyName: strategy.strategyName, + strategyNames: ref.read(cloudStrategyNamesProvider), + hasLegacyMedia: activeUnknownOwnerJobs.isNotEmpty, + ); + if (!confirmed) return false; + + try { + await ref.read(cloudEditorCloseProvider)(); + } catch (_) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + final signedOut = await ref.read(rawSignOutProvider)(); + if (!signedOut) { + if (context.mounted) { + await _showSignOutFailed( + context, + ref.read(authProvider).errorMessage, + ); + } + return false; + } + if (context.mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + return true; +} + +Future _showPersistenceBlocked(BuildContext context) { + return showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => ShadDialog.alert( + title: const Text("Can't sign out yet"), + description: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Icarus could not confirm that all pending cloud work is saved on ' + 'this device. Stay signed in and try again.', + ), + ), + actions: [ + ShadButton( + key: const ValueKey('sign-out-persistence-blocked'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay Signed In'), + ), + ], + ), + ); +} + +Future _showSignOutConfirmation( + BuildContext context, { + required int workCount, + required Set strategyIds, + required String? currentStrategyId, + required String? currentStrategyName, + required Map strategyNames, + required bool hasLegacyMedia, +}) async { + final hasPendingWork = workCount > 0; + final strategyLabels = strategyIds + .map((id) => id == currentStrategyId && + currentStrategyName?.trim().isNotEmpty == true + ? currentStrategyName!.trim() + : (strategyNames[id]?.trim().isNotEmpty == true + ? strategyNames[id]!.trim() + : 'a cloud strategy')) + .toSet() + .join(', '); + final result = await showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => ShadDialog.alert( + title: Text(hasPendingWork ? 'Cloud work is still waiting' : 'Sign out?'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text( + hasPendingWork + ? '$workCount saved ${workCount == 1 ? 'change' : 'changes'} ' + 'across ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy is' : 'strategies are'} ' + 'still waiting: $strategyLabels. The work remains on this ' + 'device and resumes only when this same account signs in ' + 'again.${hasLegacyMedia ? ' Older preserved media will not upload automatically.' : ''}' + : 'Cloud strategies stay online. Pending work on this device ' + 'will remain tied to this account.', + ), + ), + actions: [ + ShadButton.secondary( + key: const ValueKey('sign-out-cancel'), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ShadButton.destructive( + key: const ValueKey('sign-out-confirm'), + onPressed: () => Navigator.of(context).pop(true), + child: Text(hasPendingWork ? 'Sign Out Anyway' : 'Sign Out'), + ), + ], + ), + ); + return result ?? false; +} + +Future _showSignOutFailed(BuildContext context, String? message) { + return showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text('Sign out failed'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text(message ?? 'Icarus could not sign out. Please try again.'), + ), + actions: [ + ShadButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); +} diff --git a/lib/services/guarded_sign_out.dart b/lib/services/guarded_sign_out.dart new file mode 100644 index 00000000..3d586495 --- /dev/null +++ b/lib/services/guarded_sign_out.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +typedef GuardedSignOutRequest = Future Function(BuildContext context); + +/// The app shell replaces this with the cloud-aware implementation. +/// +/// Keeping the default fail-closed lets auth UI depend on one contract without +/// introducing a provider import cycle through the editor state. +final guardedSignOutRequestProvider = Provider( + (ref) => (context) async { + await showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text("Can't sign out yet"), + description: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Icarus could not verify pending cloud work. Stay signed in and ' + 'try again.', + ), + ), + actions: [ + ShadButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay Signed In'), + ), + ], + ), + ); + return false; + }, +); diff --git a/lib/widgets/cloud_outbox_summary_banner.dart b/lib/widgets/cloud_outbox_summary_banner.dart new file mode 100644 index 00000000..4adb371f --- /dev/null +++ b/lib/widgets/cloud_outbox_summary_banner.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +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/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/strategy_view.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class CloudOutboxSummaryBanner extends ConsumerWidget { + const CloudOutboxSummaryBanner({super.key, this.onOpenStrategy}); + + final ValueChanged? onOpenStrategy; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(libraryWorkspaceProvider) != LibraryWorkspace.cloud) { + return const SizedBox.shrink(); + } + final opQueue = ref.watch(strategyOpQueueProvider); + final mediaQueue = ref.watch(cloudMediaUploadQueueProvider); + final auth = ref.watch(authProvider); + final strategyNames = ref.watch(cloudStrategyNamesProvider); + final connected = ref.watch(convexConnectionProvider).valueOrNull ?? true; + final strategyIds = { + ...opQueue.accountOutbox.strategies.keys, + for (final job in mediaQueue.jobs) job.strategyPublicId, + }; + final workCount = opQueue.accountOutbox.workCount + mediaQueue.jobs.length; + final failedMediaByStrategy = {}; + for (final job in mediaQueue.jobs.where((job) => job.isFailed)) { + failedMediaByStrategy.update( + job.strategyPublicId, + (count) => count + 1, + ifAbsent: () => 1, + ); + } + final attentionIds = { + for (final summary in opQueue.accountOutbox.strategies.values) + if (summary.needsAttention) summary.strategyPublicId, + ...failedMediaByStrategy.keys, + }; + final hasDurabilityProblem = !opQueue.outboxIsReliable || + !mediaQueue.outboxIsReliable || + opQueue.loadIssues.isNotEmpty || + mediaQueue.loadIssues.isNotEmpty; + final authBlocked = workCount > 0 && + (auth.hasActiveAuthIncident || !auth.isConvexUserReady); + if (workCount == 0 && !hasDurabilityProblem) { + return const SizedBox.shrink(); + } + + final needsAttention = + hasDurabilityProblem || authBlocked || attentionIds.isNotEmpty; + final title = needsAttention + ? 'Cloud work needs attention' + : connected + ? 'Syncing cloud work' + : 'Working offline'; + final detail = hasDurabilityProblem + ? 'Icarus could not verify part of the durable cloud outbox. Stay ' + 'signed in and review the affected work.' + : authBlocked + ? 'Cloud authentication is paused. Reconnect this account to ' + 'resume its saved work.' + : needsAttention + ? '$workCount saved ${workCount == 1 ? 'change needs' : 'changes need'} ' + 'review across ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + : connected + ? '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' + 'being sent from ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + : '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' + 'waiting on this device and will resume when the ' + 'connection returns.'; + final theme = ShadTheme.of(context); + return Container( + key: const ValueKey('cloud-outbox-summary'), + margin: const EdgeInsets.fromLTRB(24, 16, 24, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + border: Border.all(color: Settings.tacticalVioletTheme.border), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + needsAttention + ? LucideIcons.circleAlert + : connected + ? LucideIcons.cloudUpload + : LucideIcons.cloudOff, + size: 18, + color: needsAttention + ? theme.colorScheme.destructive + : theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + detail, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + if (attentionIds.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final strategyId in attentionIds) + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: () => _openStrategy(context, strategyId), + child: Text(_attentionLabel( + strategyId, + strategyNames[strategyId], + opQueue + .accountOutbox.strategies[strategyId]?.reason, + failedMediaByStrategy[strategyId] ?? 0, + )), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } + + void _openStrategy(BuildContext context, String strategyId) { + final callback = onOpenStrategy; + if (callback != null) { + callback(strategyId); + return; + } + Navigator.of(context).push( + StrategyView.route( + initialStrategyId: strategyId, + initialStrategySource: StrategySource.cloud, + ), + ); + } + + String _attentionLabel( + String id, + String? strategyName, + String? reason, + int failedMediaCount, + ) { + final label = strategyName?.trim().isNotEmpty == true + ? strategyName!.trim() + : 'A cloud strategy'; + if (reason != null && reason.isNotEmpty) { + return '$label: review sync'; + } + if (failedMediaCount > 0) { + return '$label: $failedMediaCount ' + '${failedMediaCount == 1 ? 'image failed' : 'images failed'}'; + } + return '$label: review'; + } +} diff --git a/lib/widgets/cloud_sync_status_chip.dart b/lib/widgets/cloud_sync_status_chip.dart index 264e7c52..df0f3da6 100644 --- a/lib/widgets/cloud_sync_status_chip.dart +++ b/lib/widgets/cloud_sync_status_chip.dart @@ -153,6 +153,33 @@ class _CloudSyncStatusChipState extends ConsumerState { final saveState = ref.watch(strategySaveStateProvider); final opQueueState = ref.watch(strategyOpQueueProvider); + final mediaQueueState = ref.watch(cloudMediaUploadQueueProvider); + final activeStrategyId = ref.watch( + strategyProvider.select((state) => state.strategyId), + ); + final hasOtherStrategyWork = opQueueState.accountOutbox.strategies.values + .any((summary) => summary.strategyPublicId != activeStrategyId) || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId != activeStrategyId, + ); + final hasOtherStrategyAttention = opQueueState + .accountOutbox.strategies.values + .any((summary) => + summary.strategyPublicId != activeStrategyId && + summary.needsAttention) || + mediaQueueState.jobs.any( + (job) => + job.strategyPublicId != activeStrategyId && job.isFailed, + ); + final hasActiveStrategyAttention = opQueueState.needsAttention || + saveState.cloudSyncError != null || + saveState.mediaSyncErrorCount > 0 || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId == activeStrategyId && job.isFailed, + ) || + mediaQueueState + .unknownOwnerJobsForStrategy(activeStrategyId) + .isNotEmpty; final status = switch (ref.watch(cloudSyncStatusProvider)) { CloudSyncStatus.synced => _SyncStatus.synced, CloudSyncStatus.editing => _SyncStatus.editing, @@ -173,6 +200,9 @@ class _CloudSyncStatusChipState extends ConsumerState { status: status, saveState: saveState, rejectedCount: opQueueState.attentionByEntityKey.length, + hasOtherStrategyWork: hasOtherStrategyWork, + hasOtherStrategyAttention: hasOtherStrategyAttention, + hasActiveStrategyAttention: hasActiveStrategyAttention, isResolving: _isResolving, resolutionError: _resolutionError, onRetry: _retry, @@ -317,6 +347,9 @@ class _SyncStatusPopover extends StatelessWidget { required this.status, required this.saveState, required this.rejectedCount, + required this.hasOtherStrategyWork, + required this.hasOtherStrategyAttention, + required this.hasActiveStrategyAttention, required this.isResolving, required this.resolutionError, required this.onRetry, @@ -326,6 +359,9 @@ class _SyncStatusPopover extends StatelessWidget { final _SyncStatus status; final StrategySaveState saveState; final int rejectedCount; + final bool hasOtherStrategyWork; + final bool hasOtherStrategyAttention; + final bool hasActiveStrategyAttention; final bool isResolving; final String? resolutionError; final Future Function() onRetry; @@ -378,7 +414,8 @@ class _SyncStatusPopover extends StatelessWidget { ), ), ], - if (status == _SyncStatus.attention) ...[ + if (status == _SyncStatus.attention && + (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ const SizedBox(height: 12), if (hasRejectedWork) ...[ ShadButton.secondary( @@ -426,13 +463,23 @@ class _SyncStatusPopover extends StatelessWidget { return 'Finish editing or switch pages to send this change to the ' 'cloud.'; case _SyncStatus.syncing: - return 'Your edits are being sent to the cloud. You can keep ' - 'working — this happens in the background.'; + return hasOtherStrategyWork + ? 'Saved changes from your cloud library are being sent in the ' + 'background.' + : 'Your edits are being sent to the cloud. You can keep ' + 'working — this happens in the background.'; case _SyncStatus.offline: return 'Changes are kept on this device and will sync automatically ' 'when your connection returns.'; case _SyncStatus.attention: - return _attentionExplanation; + const otherStrategyExplanation = + 'Saved work in another strategy also needs attention. Open it ' + 'from the Cloud library to review the reason.'; + if (!hasOtherStrategyAttention) return _attentionExplanation; + if (hasActiveStrategyAttention) { + return '$_attentionExplanation $otherStrategyExplanation'; + } + return otherStrategyExplanation.replaceFirst(' also', ''); } } diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 5fbf91dc..00665472 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -20,17 +20,18 @@ import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/current_path_bar.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/demo_tag.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/create_strategy_dialog.dart'; import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; import 'package:icarus/widgets/account_avatar.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; import 'package:icarus/widgets/folder_content.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; @@ -464,17 +465,24 @@ class _FolderNavigatorState extends ConsumerState { child: const Text('Create Strategy'), ), ], - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: KeyedSubtree( - key: ValueKey('$workspace/$cloudSection'), - child: FolderContent( - folder: currentFolder, - onCreateStrategy: showCreateDialog, + child: Column( + children: [ + if (isCloudWorkspace) const CloudOutboxSummaryBanner(), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: KeyedSubtree( + key: ValueKey('$workspace/$cloudSection'), + child: FolderContent( + folder: currentFolder, + onCreateStrategy: showCreateDialog, + ), + ), + ), ), - ), + ], ), ), ), @@ -687,23 +695,9 @@ class _LibraryNavigationRailState extends ConsumerState { ? null : () async { if (authState.isAuthenticated) { - // One accidental click on the avatar used - // to sign out instantly. - final confirmed = - await ConfirmAlertDialog.show( - context: context, - title: 'Sign out?', - content: - 'Cloud strategies stay online; your ' - 'local strategies stay on this ' - 'device.', - confirmText: 'Sign Out', - ); - if (!confirmed || !context.mounted) { - return; - } - unawaited( - ref.read(authProvider.notifier).signOut(), + await ref + .read(guardedSignOutRequestProvider)( + context, ); } else { showDialog( diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index d436a4fe..45c57a5d 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -15,6 +15,7 @@ import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/account_avatar.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/map_theme_settings_section.dart'; @@ -306,7 +307,7 @@ class _GlobalSettingsSections extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AccountSettingsSection( + AccountSettingsSection( key: sectionKeys[_SettingsSection.globalAccount], ), const SizedBox(height: 20), @@ -1026,8 +1027,8 @@ class _ShortcutEmptySearch extends StatelessWidget { } } -class _AccountSettingsSection extends ConsumerWidget { - const _AccountSettingsSection({super.key}); +class AccountSettingsSection extends ConsumerWidget { + const AccountSettingsSection({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1191,11 +1192,12 @@ class _SignedInAccountRow extends ConsumerWidget { const SizedBox(width: 8), ], ShadButton.outline( + key: const ValueKey('settings-sign-out'), size: ShadButtonSize.sm, onPressed: authState.isLoading ? null - : () { - ref.read(authProvider.notifier).signOut(); + : () async { + await ref.read(guardedSignOutRequestProvider)(context); }, child: const Text('Sign Out'), ), diff --git a/test/cloud_sign_out_coordinator_test.dart b/test/cloud_sign_out_coordinator_test.dart new file mode 100644 index 00000000..601ad3e9 --- /dev/null +++ b/test/cloud_sign_out_coordinator_test.dart @@ -0,0 +1,431 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_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'; +import 'package:icarus/services/cloud_sign_out_coordinator.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('cancel keeps durable inactive strategy and media work signed in', + (tester) async { + var rawSignOuts = 0; + var editorCloses = 0; + final container = _container( + opQueue: _pendingQueue(), + mediaQueue: _pendingMedia(), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Cloud work is still waiting'), findsOneWidget); + expect( + find.textContaining('3 saved changes across 3 strategies'), + findsOneWidget, + ); + expect(find.textContaining('remains on this device'), findsOneWidget); + expect(find.textContaining('same account signs in again'), findsOneWidget); + expect(find.textContaining('Bind retake'), findsOneWidget); + expect(find.textContaining('Ascent execute'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(rawSignOuts, 0); + expect(editorCloses, 0); + }); + + testWidgets( + 'confirmed sign out closes the cloud editor without clearing work', + (tester) async { + var rawSignOuts = 0; + var editorCloses = 0; + final opQueue = _pendingQueue(); + final mediaQueue = _pendingMedia(); + final container = _container( + opQueue: opQueue, + mediaQueue: mediaQueue, + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out Anyway')); + await tester.pumpAndSettle(); + + expect(editorCloses, 1); + expect(rawSignOuts, 1); + expect(opQueue.accountOutbox.workCount, 2); + expect(mediaQueue.jobs, hasLength(1)); + }); + + testWidgets('failed local persistence blocks sign out on screen', + (tester) async { + var rawSignOuts = 0; + final container = _container( + preparation: () async => throw StateError('disk full'), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text("Can't sign out yet"), findsOneWidget); + expect( + find.textContaining('could not confirm that all pending cloud work'), + findsOneWidget, + ); + expect(find.text('Sign Out Anyway'), findsNothing); + expect(rawSignOuts, 0); + }); + + testWidgets('preparation commits drafts and asks both outboxes to persist', + (tester) async { + final strategy = _PreparingCloudStrategy(); + final media = _PreparingMediaQueue(); + final container = ProviderContainer(overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + strategyProvider.overrideWith(() => strategy), + strategySaveStateProvider.overrideWith(_CleanSaveState.new), + strategyOpQueueProvider.overrideWith( + () => _FixedOpQueue(const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'active-strategy', + durableLoaded: true, + )), + ), + cloudMediaUploadQueueProvider.overrideWith(() => media), + cloudEditorCloseProvider.overrideWithValue(() async {}), + rawSignOutProvider.overrideWithValue(() async => true), + cloudStrategyNamesProvider.overrideWithValue(const {}), + ]); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('draft-no-longer-mounted', 'last thought'); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + + expect(strategy.forceSaveCount, 1); + expect(media.retryCount, 1); + expect(container.read(textDraftProvider), isEmpty); + expect(find.text('Sign out?'), findsOneWidget); + }); + + testWidgets('an unreliable durable outbox blocks sign out', (tester) async { + var rawSignOuts = 0; + final container = _container( + opQueue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + hasDurabilityFailure: true, + ), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text("Can't sign out yet"), findsOneWidget); + expect(rawSignOuts, 0); + }); + + testWidgets('normal sign out still asks for explicit confirmation', + (tester) async { + var rawSignOuts = 0; + final container = _container( + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out?'), findsOneWidget); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + expect(rawSignOuts, 1); + }); + + testWidgets('another account media is not shown or relabeled', + (tester) async { + final otherAccountMedia = CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'account-b-media', + accountId: 'account-b', + strategyPublicId: 'account-b-strategy', + assetPublicId: 'account-b-media', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ); + final container = _container(mediaQueue: otherAccountMedia); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out?'), findsOneWidget); + expect(find.text('Cloud work is still waiting'), findsNothing); + expect(find.textContaining('account-b'), findsNothing); + }); + + testWidgets('raw sign-out failure does not report success or navigate', + (tester) async { + var editorCloses = 0; + final container = _container( + rawSignOut: () async => false, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + + expect(editorCloses, 1); + expect(find.text('Sign out failed'), findsOneWidget); + expect(find.text('Request sign out'), findsOneWidget); + }); +} + +StrategyOpQueueState _pendingQueue() { + return const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'active-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'active-strategy': StrategyOutboxSummary( + strategyPublicId: 'active-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ); +} + +CloudMediaUploadQueueState _pendingMedia() { + return CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'media-one', + accountId: 'account-a', + strategyPublicId: 'media-strategy', + assetPublicId: 'media-one', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ); +} + +ProviderContainer _container({ + StrategyOpQueueState opQueue = const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + ), + CloudMediaUploadQueueState mediaQueue = const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ), + CloudSignOutPreparation? preparation, + RawSignOut? rawSignOut, + CloudEditorClose? closeEditor, +}) { + return ProviderContainer(overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + strategyProvider.overrideWith(_ActiveCloudStrategy.new), + strategySaveStateProvider.overrideWith(_CleanSaveState.new), + strategyOpQueueProvider.overrideWith(() => _FixedOpQueue(opQueue)), + cloudMediaUploadQueueProvider.overrideWith( + () => _FixedMediaQueue(mediaQueue), + ), + cloudSignOutPreparationProvider.overrideWithValue( + preparation ?? () async {}, + ), + rawSignOutProvider.overrideWithValue(rawSignOut ?? () async => true), + cloudEditorCloseProvider.overrideWithValue(closeEditor ?? () async {}), + cloudStrategyNamesProvider.overrideWithValue(const { + 'active-strategy': 'Active strategy', + 'closed-strategy': 'Bind retake', + 'media-strategy': 'Ascent execute', + }), + ]); +} + +Future _pumpHarness( + WidgetTester tester, + ProviderContainer container, +) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp(home: _SignOutHarness()), + ), + ); +} + +class _SignOutHarness extends ConsumerWidget { + const _SignOutHarness(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: ShadButton( + onPressed: () => unawaited( + ref.read(cloudSignOutRequestProvider)(context), + ), + child: const Text('Request sign out'), + ), + ), + ); + } +} + +class _SignedInAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _ActiveCloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'active-strategy', + strategyName: 'Active strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); +} + +class _PreparingCloudStrategy extends _ActiveCloudStrategy { + int forceSaveCount = 0; + + @override + Future forceSaveNow(String id) async { + forceSaveCount += 1; + } +} + +class _CleanSaveState extends StrategySaveStateNotifier { + @override + StrategySaveState build() => const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ); +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _FixedMediaQueue extends CloudMediaUploadQueueNotifier { + _FixedMediaQueue(this.initialState); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +class _PreparingMediaQueue extends CloudMediaUploadQueueNotifier { + int retryCount = 0; + + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + @override + Future retryNow({bool ignoreBackoff = false}) async { + retryCount += 1; + } +} diff --git a/test/global_strategy_outbox_test.dart b/test/global_strategy_outbox_test.dart new file mode 100644 index 00000000..596abd31 --- /dev/null +++ b/test/global_strategy_outbox_test.dart @@ -0,0 +1,474 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_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:supabase_flutter/supabase_flutter.dart'; + +void main() { + test('restart drains current-account work across closed strategies', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-one', opId: 'one')); + await store.put(_record( + strategyId: 'strategy-two', + opId: 'two', + elementId: 'element-two', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.map((call) => call.strategyId).toSet(), { + 'strategy-one', + 'strategy-two', + }); + expect(store.values, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isFalse, + ); + }); + + test('background drain never submits another account work', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-a', opId: 'a')); + await store.put(_record( + accountId: 'account-b', + strategyId: 'strategy-b', + opId: 'b', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await _waitUntil(() => repository.calls.length == 1); + + expect(repository.calls.single.strategyId, 'strategy-a'); + expect(store.values, hasLength(1)); + expect( + container.read(strategyOpQueueProvider).accountOutbox.accountId, + 'account-a', + ); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isFalse, + ); + + notifier.setCurrentAccount('account-b'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, hasLength(1)); + + final accountBContainer = _container( + store: store, + repository: repository, + authAccountId: 'account-b', + ); + addTearDown(accountBContainer.dispose); + accountBContainer + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-b'); + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.last.strategyId, 'strategy-b'); + expect(store.values, isEmpty); + }); + + test('active work waits for a background request then runs next', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'closed-strategy', opId: 'closed')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active-strategy', accountId: 'account-a'); + await repository.firstStarted.future; + + await notifier.enqueue( + _op(opId: 'active', elementId: 'active-element'), + flushImmediately: false, + ); + await Future.delayed(const Duration(milliseconds: 220)); + expect(repository.calls, hasLength(1)); + + repository.releaseFirst(); + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls[0].strategyId, 'closed-strategy'); + expect(repository.calls[1].strategyId, 'active-strategy'); + expect(repository.calls[1].ops.single.opId, 'active'); + }); + + test('opening a draining strategy preserves a concurrent final intent', + () async { + final store = _BlockingSuccessorStore(); + await store.put(_record(strategyId: 'opening', opId: 'predecessor')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await repository.firstStarted.future; + + notifier.setActiveStrategy('opening', accountId: 'account-a'); + final enqueue = notifier.enqueue( + _op( + opId: 'final-intent', + elementId: 'element-one', + value: 'final', + ), + ); + await store.successorWriteStarted.future; + repository.releaseFirst(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(repository.calls, hasLength(1)); + + store.allowSuccessorWrite.complete(); + await enqueue; + await _waitUntil(() => repository.calls.length == 2); + final finalOp = repository.calls.last.ops.single as ElementPatchOp; + expect(finalOp.payload, {'value': 'final'}); + expect(finalOp.expectedElementRevision, 2); + }); + + test('one failed closed strategy does not block another', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'fails', opId: 'fails')); + await store.put(_record( + strategyId: 'lands', + opId: 'lands', + elementId: 'element-lands', + )); + final repository = _FailFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length >= 2); + expect(repository.calls.take(2).map((call) => call.strategyId), [ + 'fails', + 'lands', + ]); + expect( + store.values.values + .map((value) => DurableOutboxRecord.fromJson( + Map.from(value as Map), + )) + .where((record) => record.strategyPublicId == 'fails'), + isNotEmpty, + ); + }); + + test('paused and rejected records remain visible and never auto-run', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record( + strategyId: 'paused-strategy', + opId: 'paused', + status: DurableOutboxStatus.paused, + lastError: 'Retry limit reached', + )); + await store.put(_record( + strategyId: 'rejected-strategy', + opId: 'rejected', + elementId: 'rejected-element', + status: DurableOutboxStatus.attention, + lastError: 'Revision conflict', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + final summary = container.read(strategyOpQueueProvider).accountOutbox; + expect(repository.calls, isEmpty); + expect(summary.strategyCount, 2); + expect(summary.needsAttention, isTrue); + expect( + summary.strategies['paused-strategy']!.reason, 'Retry limit reached'); + expect( + summary.strategies['rejected-strategy']!.reason, 'Revision conflict'); + }); + + test('reconnection resumes eligible closed-strategy work', () async { + final connectionChanges = StreamController(); + addTearDown(connectionChanges.close); + var connected = false; + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'offline-strategy', opId: 'offline')); + final repository = _RecordingRepository(); + final container = _container( + store: store, + repository: repository, + connected: () => connected, + connectionChanges: connectionChanges.stream, + ); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isTrue, + ); + + connected = true; + container.invalidate(convexConnectionSnapshotProvider); + connectionChanges.add(true); + await _waitUntil(() => repository.calls.length == 1); + expect(store.values, isEmpty); + }); + + test('auth readiness recovery resumes eligible closed-strategy work', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'auth-waiting', opId: 'auth-op')); + final repository = _RecordingRepository(); + final auth = _MutableAuthProvider(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => auth), + convexConnectionSnapshotProvider.overrideWithValue(true), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ]); + addTearDown(container.dispose); + + container.read(strategyOpQueueProvider); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, isEmpty); + + auth.markReady(); + await _waitUntil(() => repository.calls.length == 1); + expect(repository.calls.single.strategyId, 'auth-waiting'); + }); +} + +ProviderContainer _container({ + required DurableStrategyOutboxStore store, + required ConvexStrategyRepository repository, + bool Function()? connected, + Stream? connectionChanges, + String authAccountId = 'account-a', +}) { + return ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => _ReadyAuthProvider(authAccountId)), + convexConnectionSnapshotProvider.overrideWith( + (ref) => connected?.call() ?? true, + ), + convexConnectionProvider.overrideWith( + (ref) => connectionChanges ?? Stream.value(true), + ), + ]); +} + +DurableOutboxRecord _record({ + String accountId = 'account-a', + required String strategyId, + required String opId, + String elementId = 'element-one', + DurableOutboxStatus status = DurableOutboxStatus.queued, + String? lastError, +}) { + final now = DateTime(2026); + final op = _op(opId: opId, elementId: elementId); + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.element('page-one', elementId), + pending: PendingOp(op: op, clientId: 'client-$strategyId'), + status: status, + createdAt: now, + updatedAt: now, + lastError: lastError, + ); +} + +ElementPatchOp _op({ + required String opId, + required String elementId, + String value = 'safe', +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-one', + payload: {'value': value}, + expectedElementRevision: 1, + ); +} + +Future _waitUntil(bool Function() condition) async { + for (var i = 0; i < 100; i += 1) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Condition was not reached before timeout.'); +} + +class _ReadyAuthProvider extends AuthProvider { + _ReadyAuthProvider(this.accountId); + + final String accountId; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: accountId, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _MutableAuthProvider extends AuthProvider { + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + user: _user('account-a'), + ); + + void markReady() { + state = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: _user('account-a'), + ); + } +} + +User _user(String accountId) => User( + id: accountId, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ); + +typedef _Call = ({String strategyId, List ops}); + +class _RecordingRepository extends ConvexStrategyRepository { + _RecordingRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final List<_Call> calls = []; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _HeldFirstRepository extends _RecordingRepository { + final firstStarted = Completer(); + final _release = Completer(); + + void releaseFirst() => _release.complete(); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + if (calls.length == 1) { + firstStarted.complete(); + await _release.future; + } + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _FailFirstRepository extends _RecordingRepository { + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + if (calls.length == 1) throw StateError('temporary failure'); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _BlockingSuccessorStore extends MemoryDurableStrategyOutboxStore { + final successorWriteStarted = Completer(); + final allowSuccessorWrite = Completer(); + var _blocked = false; + + @override + Future put(DurableOutboxRecord record) async { + if (!_blocked && record.successorPending != null) { + _blocked = true; + successorWriteStarted.complete(); + await allowSuccessorWrite.future; + } + await super.put(record); + } +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 3a2dd076..710952d2 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -1,12 +1,16 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; void main() { @@ -332,6 +336,48 @@ void main() { ); }); + testWidgets('auth incident Sign Out uses the guarded flow', (tester) async { + supabaseApi.currentSession = fakeSession(); + var guardedRequests = 0; + final container = ProviderContainer(overrides: [ + guardedSignOutRequestProvider.overrideWithValue((context) async { + guardedRequests += 1; + return false; + }), + ]); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + navigatorKey: appNavigatorKey, + home: const Scaffold(body: SizedBox.shrink()), + ), + ), + ); + final notifier = container.read(authProvider.notifier); + await tester.pump(); + await tester.pump(); + + await notifier.reportConvexUnauthenticated( + source: 'test:incident-route', + error: const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('Cloud connection lost'), findsOneWidget); + + await tester.tap(find.text('Sign Out')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(guardedRequests, 1); + expect(supabaseApi.currentSession, isNotNull); + }); + test('auth readiness timeout surfaces as setup incident, not unauthenticated', () async { supabaseApi.currentSession = fakeSession(); diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 8cbab99b..89bc4fdb 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -42,6 +42,13 @@ void main() { container?.dispose(); container = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); container! .read(cloudCollabModeProvider.notifier) @@ -742,6 +749,13 @@ void main() { final store = _BlockingStore(); final container = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); addTearDown(container.dispose); final notifier = container.read(strategyOpQueueProvider.notifier) @@ -768,6 +782,13 @@ void main() { final store = _BlockingReplacementStore(); final container = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); addTearDown(container.dispose); final notifier = container.read(strategyOpQueueProvider.notifier) diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index d53698aa..765cfb2b 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -1212,6 +1212,13 @@ void main() { final store = MemoryDurableStrategyOutboxStore(); final firstContainer = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); firstContainer .read(cloudCollabModeProvider.notifier) @@ -1228,6 +1235,13 @@ void main() { )); final restarted = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), remoteEditorSnapshotProvider.overrideWith(() => remote), ]); addTearDown(restarted.dispose); diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index 74276ec4..c82daaa8 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -5,10 +5,12 @@ import 'package:flutter/semantics.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; void main() { testWidgets('shared text fields expose live editable semantics', @@ -158,6 +160,34 @@ void main() { expect(find.byType(AuthDialog), findsOneWidget); }); + + testWidgets('library account action uses guarded sign out', (tester) async { + var requests = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedInAuthProvider.new), + guardedSignOutRequestProvider.overrideWithValue((context) async { + requests += 1; + return true; + }), + ], + child: const ShadApp( + home: Scaffold( + body: SizedBox( + width: 220, + height: 800, + child: LibraryNavigationRail(), + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('library-account-action'))); + await tester.pump(); + expect(requests, 1); + }); } Semantics _semantics(String label) { @@ -212,3 +242,20 @@ class _SignedOutAuthProvider extends AuthProvider { user: null, ); } + +class _SignedInAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {'full_name': 'Coach'}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} diff --git a/test/widgets/cloud_outbox_summary_banner_test.dart b/test/widgets/cloud_outbox_summary_banner_test.dart new file mode 100644 index 00000000..c79e48ee --- /dev/null +++ b/test/widgets/cloud_outbox_summary_banner_test.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +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/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('closed-strategy attention is visible with no editor open', + (tester) async { + String? openedStrategy; + final container = _container( + queue: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: null, + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'strategy-needs-review': StrategyOutboxSummary( + strategyPublicId: 'strategy-needs-review', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + reason: 'ClientException: socket failed, bearer=secret', + ), + }, + ), + ), + ); + addTearDown(container.dispose); + + await _pump( + tester, + container, + CloudOutboxSummaryBanner( + onOpenStrategy: (id) => openedStrategy = id, + ), + ); + + expect(find.byKey(const ValueKey('cloud-outbox-summary')), findsOneWidget); + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect(find.textContaining('Haven retake'), findsOneWidget); + expect(find.text('Haven retake: review sync'), findsOneWidget); + expect(find.textContaining('ClientException'), findsNothing); + expect(find.textContaining('bearer=secret'), findsNothing); + await tester.tap(find.textContaining('Haven retake')); + expect(openedStrategy, 'strategy-needs-review'); + }); + + testWidgets('queued closed-strategy work is visible while offline', + (tester) async { + final container = _container( + connected: false, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 2, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + await tester.pump(); + + expect(find.text('Working offline'), findsOneWidget); + expect(find.textContaining('2 saved changes'), findsOneWidget); + expect(find.textContaining('waiting on this device'), findsOneWidget); + }); + + testWidgets('pending shared-strategy work is visible in Shared With Me', + (tester) async { + final container = _container( + section: CloudLibrarySection.sharedWithMe, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'shared-strategy': StrategyOutboxSummary( + strategyPublicId: 'shared-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.byKey(const ValueKey('cloud-outbox-summary')), findsOneWidget); + expect(find.text('Syncing cloud work'), findsOneWidget); + expect(find.textContaining('1 saved change'), findsOneWidget); + }); + + testWidgets('another account work is absent from this account library', + (tester) async { + final container = _container( + queue: const StrategyOpQueueState( + accountId: 'account-b', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary(accountId: 'account-b'), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect( + find.byKey(const ValueKey('cloud-outbox-summary')), + findsNothing, + ); + }); + + testWidgets('outbox banner stays absent from the local workspace', + (tester) async { + final container = _container( + workspace: LibraryWorkspace.local, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect( + find.byKey(const ValueKey('cloud-outbox-summary')), + findsNothing, + ); + }); + + testWidgets('auth-paused work shows the reason instead of syncing', + (tester) async { + final container = _container( + authReady: false, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect(find.textContaining('authentication is paused'), findsOneWidget); + expect(find.text('Syncing cloud work'), findsNothing); + }); +} + +ProviderContainer _container({ + required StrategyOpQueueState queue, + LibraryWorkspace workspace = LibraryWorkspace.cloud, + CloudLibrarySection section = CloudLibrarySection.home, + bool connected = true, + bool authReady = true, +}) { + return ProviderContainer(overrides: [ + authProvider.overrideWith(() => _ReadyAuth(authReady)), + libraryWorkspaceProvider.overrideWith(() => _Workspace(workspace)), + cloudLibrarySectionProvider.overrideWith(() => _CloudSection(section)), + strategyOpQueueProvider.overrideWith(() => _Queue(queue)), + cloudMediaUploadQueueProvider.overrideWith(_MediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(connected)), + cloudStrategyNamesProvider.overrideWithValue(const { + 'strategy-needs-review': 'Haven retake', + }), + ]); +} + +Future _pump( + WidgetTester tester, + ProviderContainer container, + Widget banner, +) { + return tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp(home: Scaffold(body: banner)), + ), + ); +} + +class _Workspace extends LibraryWorkspaceNotifier { + _Workspace(this.workspace); + + final LibraryWorkspace workspace; + + @override + LibraryWorkspace build() => workspace; +} + +class _CloudSection extends CloudLibrarySectionNotifier { + _CloudSection(this.section); + + final CloudLibrarySection section; + + @override + CloudLibrarySection build() => section; +} + +class _ReadyAuth extends AuthProvider { + _ReadyAuth(this.ready); + + final bool ready; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: ready, + convexAuthStatus: + ready ? ConvexAuthStatus.ready : ConvexAuthStatus.incident, + user: const User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _Queue extends StrategyOpQueueNotifier { + _Queue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _MediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); +} diff --git a/test/widgets/cloud_sync_status_chip_test.dart b/test/widgets/cloud_sync_status_chip_test.dart index 21d9eeae..30e04381 100644 --- a/test/widgets/cloud_sync_status_chip_test.dart +++ b/test/widgets/cloud_sync_status_chip_test.dart @@ -74,9 +74,13 @@ class _FixedSaveState extends StrategySaveStateNotifier { } class _AttentionOpQueue extends StrategyOpQueueNotifier { - _AttentionOpQueue(this.rejectedCount); + _AttentionOpQueue( + this.rejectedCount, { + this.hasOtherStrategyAttention = false, + }); final int rejectedCount; + final bool hasOtherStrategyAttention; int retryRejectedCount = 0; int flushNowCount = 0; @@ -104,6 +108,21 @@ class _AttentionOpQueue extends StrategyOpQueueNotifier { ), ), }, + accountOutbox: hasOtherStrategyAttention + ? const AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + ), + }, + ) + : const AccountStrategyOutboxSummary(), lastError: 'Some saved work needs attention.', ); @@ -357,6 +376,81 @@ void main() { expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); }); + test('queued work in another strategy prevents a synced status', () { + final container = _createContainer( + opQueueState: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.syncing); + }); + + testWidgets('inactive attention directs the user to the cloud library', + (tester) async { + final container = _createContainer( + opQueueState: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + reason: 'Retry limit reached', + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + + expect(find.text('Needs attention'), findsOneWidget); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + expect( + find.text( + 'Saved work in another strategy needs attention. Open it from the ' + 'Cloud library to review the reason.', + ), + findsOneWidget, + ); + expect(find.text('Retry sync'), findsNothing); + }); + test('media errors remain visible while offline', () async { final container = _createContainer( connected: false, @@ -444,6 +538,42 @@ void main() { expect(queue.flushNowCount, 0); }); + testWidgets( + 'active conflict controls remain when another strategy needs attention', + (tester) async { + final queue = _AttentionOpQueue( + 1, + hasOtherStrategyAttention: true, + ); + 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('Choose which version to keep'), findsOneWidget); + expect( + find.textContaining('another strategy also needs attention'), + findsOneWidget, + ); + expect(find.textContaining('Cloud library'), findsOneWidget); + }); + testWidgets('keep mine remains an explicit rejected retry', (tester) async { final queue = _AttentionOpQueue(1); final session = _ConflictSession(); diff --git a/test/widgets/settings_sign_out_test.dart b/test/widgets/settings_sign_out_test.dart new file mode 100644 index 00000000..9d18ddad --- /dev/null +++ b/test/widgets/settings_sign_out_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/settings_tab.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('Settings account action uses guarded sign out', (tester) async { + var requests = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + guardedSignOutRequestProvider.overrideWithValue((context) async { + requests += 1; + return true; + }), + ], + child: const ShadApp( + home: Scaffold(body: AccountSettingsSection()), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('settings-sign-out'))); + await tester.pump(); + expect(requests, 1); + }); +} + +class _SignedInAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {'full_name': 'Coach'}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +}