From 2af4e111aad49b76c547fe5bcf54c5061df32568 Mon Sep 17 00:00:00 2001 From: Chan Date: Thu, 6 Aug 2026 13:09:42 +0000 Subject: [PATCH] fix(cloud): converge legacy revision-zero members --- src/langbot/pkg/cloud/directory_projection.py | 20 +++- .../cloud/test_directory_projection.py | 95 ++++++++++++++++--- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/langbot/pkg/cloud/directory_projection.py b/src/langbot/pkg/cloud/directory_projection.py index 46d06f153..a2b477906 100644 --- a/src/langbot/pkg/cloud/directory_projection.py +++ b/src/langbot/pkg/cloud/directory_projection.py @@ -865,6 +865,12 @@ class DirectoryProjectionService: ) ).all() } + existing_account_sources: dict[str, str] = {} + if existing: + existing_account_sources = { + account.uuid: account.source + for account in (await session.scalars(sqlalchemy.select(User).where(User.uuid.in_(existing)))).all() + } included_accounts: set[str] = set() for member in candidate.members: included_accounts.add(member.account_uuid) @@ -891,10 +897,13 @@ class DirectoryProjectionService: ) ) continue - if membership.projection_revision == 0: - # Revision zero is Core-owned collaboration state. Directory - # projection seeds memberships, but must not overwrite later - # invitation, role, or removal decisions made by Core. + if ( + membership.projection_revision == 0 + and existing_account_sources.get(member.account_uuid) != AccountSource.CLOUD_PROJECTION.value + ): + # Keep genuinely local collaboration state. Historical Cloud + # memberships also used revision zero; once Space includes the + # account in its authoritative directory, adopt that row. continue if membership.uuid != member.membership_uuid: raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account') @@ -910,7 +919,8 @@ class DirectoryProjectionService: membership.projection_revision = member.projection_revision for account_uuid, membership in existing.items(): - if account_uuid not in included_accounts and membership.projection_revision != 0: + space_owned = existing_account_sources.get(account_uuid) == AccountSource.CLOUD_PROJECTION.value + if account_uuid not in included_accounts and (membership.projection_revision != 0 or space_owned): membership.status = MembershipStatus.REMOVED.value membership.projection_revision = max( int(membership.projection_revision), diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 0256ae595..6946a4087 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -1056,7 +1056,7 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context): await service.initialize() -async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context): +async def test_revision_zero_membership_in_authoritative_directory_is_adopted(projection_context): application, session_factory = projection_context service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) await service.initialize() @@ -1067,11 +1067,45 @@ async def test_core_owned_membership_survives_directory_updates_and_omission(pro membership.role = 'viewer' membership.status = 'active' membership.projection_revision = 0 + + projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'}) + projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)}) + await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace])) + + async with session_factory() as session: + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + assert membership.role == 'owner' + assert membership.status == 'removed' + assert membership.projection_revision == 2 + + +async def test_revision_zero_membership_absent_from_authoritative_directory_is_removed(projection_context): + application, session_factory = projection_context + service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) + await service.initialize() + + historical_account_uuid = '20000000-0000-0000-0000-000000000099' + async with session_factory() as session: + async with session.begin(): + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + session.add( + User( + uuid=historical_account_uuid, + user='Historical Space Member', + normalized_email='historical@example.com', + password='', + status='active', + source='cloud_projection', + projection_revision=1, + account_type='space', + space_account_uuid=historical_account_uuid, + ) + ) session.add( WorkspaceMembership( uuid=SECOND_MEMBERSHIP_UUID, workspace_uuid=WORKSPACE_UUID, - account_uuid='20000000-0000-0000-0000-000000000099', + account_uuid=historical_account_uuid, role='viewer', status='active', joined_at=membership.joined_at, @@ -1079,17 +1113,50 @@ async def test_core_owned_membership_survives_directory_updates_and_omission(pro ) ) - projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'}) - projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)}) - await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace])) + await service.apply_snapshot(_snapshot(2)) async with session_factory() as session: - memberships = { - membership.uuid: membership - for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all() - } - assert memberships[MEMBERSHIP_UUID].role == 'viewer' - assert memberships[MEMBERSHIP_UUID].status == 'active' - assert memberships[MEMBERSHIP_UUID].projection_revision == 0 - assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active' - assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0 + historical = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) + assert historical.status == 'removed' + assert historical.projection_revision == 2 + + +async def test_revision_zero_local_collaboration_survives_directory_omission(projection_context): + application, session_factory = projection_context + service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) + await service.initialize() + + local_account_uuid = '20000000-0000-0000-0000-000000000098' + async with session_factory() as session: + async with session.begin(): + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + session.add( + User( + uuid=local_account_uuid, + user='Local Collaborator', + normalized_email='local@example.com', + password='', + status='active', + source='local', + projection_revision=0, + account_type='local', + ) + ) + session.add( + WorkspaceMembership( + uuid=SECOND_MEMBERSHIP_UUID, + workspace_uuid=WORKSPACE_UUID, + account_uuid=local_account_uuid, + role='viewer', + status='active', + joined_at=membership.joined_at, + projection_revision=0, + ) + ) + + await service.apply_snapshot(_snapshot(2)) + + async with session_factory() as session: + local = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) + assert local.status == 'active' + assert local.projection_revision == 0