diff --git a/features/entity_sync.feature b/features/entity_sync.feature index 283b833ea..fe406daa8 100644 --- a/features/entity_sync.feature +++ b/features/entity_sync.feature @@ -416,3 +416,37 @@ Feature: Entity sync via _cleveragents/sync/* And a local actor snapshot for offline queuing When I enqueue an offline pull from namespace "team" Then the offline queue should have 1 entry + + Scenario: Vector clock happens_before rejects non-VectorClock + Given an empty vector clock + When I try to call happens_before with a non-VectorClock value + Then a sync TypeError should be raised + + Scenario: Vector clock is_concurrent rejects non-VectorClock + Given an empty vector clock + When I try to call is_concurrent with a non-VectorClock value + Then a sync TypeError should be raised + + Scenario: Process offline queue processes pull operation + Given a sync service with node_id "test-client" + And server entities in namespace "team" with 1 actors + And a local actor snapshot for offline queuing + When I enqueue an offline pull from namespace "team" + And I process the offline queue + Then the processed entries should include the offline actor + And the offline queue should be empty + + Scenario: Resolve conflict with last_writer_wins picks server when newer + Given a sync service with node_id "test-client" + And an unresolved conflict where server is newer + When I resolve the conflict with last_writer_wins strategy + Then the conflict should be marked as resolved + And the conflict winner should be "server" + + Scenario: Push with client_wins resolution accepts local entity + Given a sync service with node_id "test-client" + And a local actor "conflict-actor" in namespace "team" with clock {"client": 2, "server": 1} + And a server actor "conflict-actor" in namespace "team" with clock {"client": 1, "server": 2} + When I push the local actor to namespace "team" with client-wins resolution + Then the push response should have 1 accepted + And the push conflict winner should be "local" diff --git a/features/steps/entity_sync_steps.py b/features/steps/entity_sync_steps.py index b55af2063..af3e79f9b 100644 --- a/features/steps/entity_sync_steps.py +++ b/features/steps/entity_sync_steps.py @@ -119,6 +119,24 @@ def step_merge_non_clock(context: Any) -> None: context.caught_exception = exc +@when("I try to call happens_before with a non-VectorClock value") +def step_happens_before_bad_type(context: Any) -> None: + context.caught_exception = None + try: + context.clock1.happens_before("not a clock") # type: ignore[arg-type] + except TypeError as exc: + context.caught_exception = exc + + +@when("I try to call is_concurrent with a non-VectorClock value") +def step_is_concurrent_bad_type(context: Any) -> None: + context.caught_exception = None + try: + context.clock1.is_concurrent("not a clock") # type: ignore[arg-type] + except TypeError as exc: + context.caught_exception = exc + + # ------------------------------------------------------------------ # Model validation steps # ------------------------------------------------------------------ @@ -445,6 +463,28 @@ def step_setup_unresolved_conflict(context: Any) -> None: context.conflict_id = svc.conflicts[0].conflict_id +@given("an unresolved conflict where server is newer") +def step_setup_conflict_server_newer(context: Any) -> None: + svc: SyncService = context.sync_service + local_entity = _make_actor_snapshot( + entity_id="ts-conflict-srv", + namespace="team", + clock_entries={"client": 2, "server": 1}, + updated_at="2025-01-01T00:00:00+00:00", + ) + server_entity = _make_actor_snapshot( + entity_id="ts-conflict-srv", + namespace="team", + clock_entries={"client": 1, "server": 2}, + updated_at="2025-07-01T00:00:00+00:00", + ) + svc.register_local_entity(local_entity) + svc.register_server_entity(server_entity) + pull_req = SyncPullRequest(namespace="team") + svc.pull(pull_req) + context.conflict_id = svc.conflicts[0].conflict_id + + @given("an unresolved conflict where local is newer") def step_setup_conflict_local_newer(context: Any) -> None: svc: SyncService = context.sync_service @@ -581,6 +621,16 @@ def step_push_server_wins(context: Any, namespace: str) -> None: context.push_response = context.sync_service.push(request) +@when('I push the local actor to namespace "{namespace}" with client-wins resolution') +def step_push_client_wins(context: Any, namespace: str) -> None: + request = SyncPushRequest( + namespace=namespace, + entities=[context.local_entity], + resolution=ConflictResolution.CLIENT_WINS, + ) + context.push_response = context.sync_service.push(request) + + @when('I push the local actor to namespace "{namespace}" with manual resolution') def step_push_manual(context: Any, namespace: str) -> None: request = SyncPushRequest( diff --git a/src/cleveragents/a2a/sync_models.py b/src/cleveragents/a2a/sync_models.py index f930a0e1b..e2a8e0569 100644 --- a/src/cleveragents/a2a/sync_models.py +++ b/src/cleveragents/a2a/sync_models.py @@ -9,7 +9,6 @@ The ``local/`` namespace is **never** synced — it exists only on the client. from __future__ import annotations -from datetime import UTC, datetime from enum import StrEnum from typing import Any @@ -379,11 +378,6 @@ class SyncQueueEntry(BaseModel): return value -def _iso_now() -> str: - """Return the current UTC time as an ISO-8601 string.""" - return datetime.now(tz=UTC).isoformat() - - __all__ = [ "ConflictResolution", "SyncConflict",