diff --git a/benchmarks/context_indexing_bench.py b/benchmarks/context_indexing_bench.py index 79856c652..09c9a4152 100644 --- a/benchmarks/context_indexing_bench.py +++ b/benchmarks/context_indexing_bench.py @@ -15,11 +15,10 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) -import cleveragents # noqa: E402, F401 - from sqlalchemy import create_engine # noqa: E402 from sqlalchemy.orm import sessionmaker # noqa: E402 +import cleveragents # noqa: E402, F401 from cleveragents.application.services.repo_indexing_service import ( # noqa: E402 RepoIndexingService, detect_language, diff --git a/benchmarks/server_http_client_bench.py b/benchmarks/server_http_client_bench.py new file mode 100644 index 000000000..6d0e82c65 --- /dev/null +++ b/benchmarks/server_http_client_bench.py @@ -0,0 +1,108 @@ +"""ASV benchmarks for Server HTTP client. + +Measures construction, health check, version retrieval, and +pagination throughput using mocked HTTP transport. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from unittest.mock import patch + +import httpx + +# Ensure the local source tree is importable. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.client.http_client import ( # noqa: E402 + PageResult, + ServerHttpClient, + _backoff_delay, + _redact_headers, +) + + +def _mock_response( + status: int = 200, + body: dict | list | None = None, +) -> httpx.Response: + return httpx.Response( + status_code=status, + json=body, + headers={}, + request=httpx.Request("GET", "http://mock"), + ) + + +class ClientConstructionSuite: + """Benchmark ServerHttpClient construction overhead.""" + + timeout = 60 + + def time_create_default(self) -> None: + ServerHttpClient() + + def time_create_with_token(self) -> None: + ServerHttpClient( + base_url="https://x.com", + api_token="tok_benchmark1234567890", + ) + + +class ClientHealthSuite: + """Benchmark health check throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.client = ServerHttpClient() + self.healthy = _mock_response(200, {"status": "healthy"}) + + def time_health_check(self) -> None: + with patch("cleveragents.client.http_client.httpx.request") as m: + m.return_value = self.healthy + self.client.health_check() + + +class ClientVersionSuite: + """Benchmark version retrieval throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.client = ServerHttpClient() + self.resp = _mock_response(200, {"version": "1.0.0"}) + + def time_get_version(self) -> None: + with patch("cleveragents.client.http_client.httpx.request") as m: + m.return_value = self.resp + self.client.get_version() + + +class HelperSuite: + """Benchmark helper functions.""" + + timeout = 60 + + def setup(self) -> None: + self.headers = { + "Authorization": "Bearer tok_secret123abcdefghij", + "Accept": "application/json", + } + + def time_redact_headers(self) -> None: + _redact_headers(self.headers) + + def time_backoff_delay(self) -> None: + _backoff_delay(3, 0.5, 30.0) + + def time_page_result_creation(self) -> None: + PageResult(items=[{"id": "1"}], page=1, per_page=10, total=1, has_next=False) diff --git a/benchmarks/server_remote_project_bench.py b/benchmarks/server_remote_project_bench.py new file mode 100644 index 000000000..82ab713d8 --- /dev/null +++ b/benchmarks/server_remote_project_bench.py @@ -0,0 +1,101 @@ +"""ASV benchmarks for Remote Project client.""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from unittest.mock import MagicMock + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.client.http_client import ( # noqa: E402 + PageResult, + ServerHttpClient, +) +from cleveragents.client.remote_project import ( # noqa: E402 + RemoteProject, + RemoteProjectClient, +) + +_ITEMS = [ + {"id": f"rp{i}", "name": f"proj-{i}", "alias": f"p{i}", "description": f"Proj {i}"} + for i in range(20) +] + + +def _build() -> RemoteProjectClient: + mock = MagicMock(spec=ServerHttpClient) + mock.list_endpoint.return_value = PageResult( + items=_ITEMS, page=1, per_page=50, total=20 + ) + return RemoteProjectClient(mock) + + +class ListProjectsSuite: + """Benchmark project listing.""" + + timeout = 60 + + def setup(self) -> None: + self.client = _build() + + def time_list_cold(self) -> None: + c = _build() + c.list_projects() + + def time_list_cached(self) -> None: + self.client.list_projects() + + +class ResolveProjectSuite: + """Benchmark project resolution.""" + + timeout = 60 + + def setup(self) -> None: + self.client = _build() + self.client.list_projects() + + def time_resolve_by_name(self) -> None: + self.client.get_project("proj-10") + + def time_resolve_by_alias(self) -> None: + self.client.get_project("p10") + + +class CacheSuite: + """Benchmark cache operations.""" + + timeout = 60 + + def time_invalidate_all(self) -> None: + c = _build() + c.list_projects() + c.invalidate_cache() + + def time_invalidate_ns(self) -> None: + c = _build() + c.list_projects() + c.invalidate_cache("default") + + +class RemoteProjectCreationSuite: + """Benchmark RemoteProject creation.""" + + timeout = 60 + + def time_create(self) -> None: + RemoteProject( + project_id="rp1", + name="test", + namespace="default", + alias="t", + description="Test project", + ) diff --git a/benchmarks/server_sync_bench.py b/benchmarks/server_sync_bench.py new file mode 100644 index 000000000..6bd7ea5d9 --- /dev/null +++ b/benchmarks/server_sync_bench.py @@ -0,0 +1,87 @@ +"""ASV benchmarks for Plan Sync client.""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import httpx + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.client.http_client import ServerHttpClient # noqa: E402 +from cleveragents.client.sync_client import ( # noqa: E402 + ExecutionResult, + PlanSyncClient, + SyncScope, + SyncSummary, +) + + +def _mock_response(status: int = 200, body: dict | None = None) -> httpx.Response: + return httpx.Response( + status_code=status, + json=body, + headers={}, + request=httpx.Request("GET", "http://mock"), + ) + + +class SyncScopeSuite: + """Benchmark SyncScope operations.""" + + timeout = 60 + + def time_active_types_all(self) -> None: + SyncScope().active_types() + + def time_active_types_partial(self) -> None: + SyncScope(actions=True, skills=False, tools=True, projects=False).active_types() + + +class SyncOperationSuite: + """Benchmark sync operations.""" + + timeout = 60 + + def setup(self) -> None: + self.mock_http = MagicMock(spec=ServerHttpClient) + self.mock_http._request.return_value = _mock_response(200, {"id": "srv"}) + self.client = PlanSyncClient(self.mock_http) + self.items = [{"id": f"item-{i}"} for i in range(10)] + + def time_sync_10_items(self) -> None: + self.client.sync(self.items, "actions") + + def time_sync_dry_run(self) -> None: + self.client.sync(self.items, "actions", dry_run=True) + + +class SyncSummarySuite: + """Benchmark SyncSummary creation.""" + + timeout = 60 + + def time_summary_creation(self) -> None: + SyncSummary(created=5, updated=3, skipped=1, errors=0) + + def time_total_processed(self) -> None: + s = SyncSummary(created=5, updated=3, skipped=1, errors=1) + _ = s.total_processed + + +class ExecutionResultSuite: + """Benchmark ExecutionResult creation.""" + + timeout = 60 + + def time_create_result(self) -> None: + ExecutionResult(plan_id="p1", server_plan_id="sp1", status="done", message="ok") diff --git a/benchmarks/server_ws_bench.py b/benchmarks/server_ws_bench.py new file mode 100644 index 000000000..b0178b1e0 --- /dev/null +++ b/benchmarks/server_ws_bench.py @@ -0,0 +1,84 @@ +"""ASV benchmarks for WebSocket client.""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.acp.models import AcpEvent # noqa: E402 +from cleveragents.client.ws_client import ( # noqa: E402 + ConnectionState, + EventDeduplicator, + WebSocketClient, + _ws_backoff_delay, +) + + +class DeduplicatorSuite: + """Benchmark EventDeduplicator throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.dedup = EventDeduplicator(capacity=1000) + + def time_is_duplicate_miss(self) -> None: + d = EventDeduplicator(capacity=1000) + for i in range(100): + d.is_duplicate(f"evt-{i}") + + def time_is_duplicate_hit(self) -> None: + d = EventDeduplicator(capacity=1000) + d.is_duplicate("evt-x") + for _ in range(100): + d.is_duplicate("evt-x") + + +class EventProcessingSuite: + """Benchmark event processing throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.client = WebSocketClient() + self.client.connect() + self.client.subscribe(lambda e: None) + + def time_process_event(self) -> None: + c = WebSocketClient() + c.connect() + c.subscribe(lambda e: None) + for i in range(100): + event = AcpEvent(event_id=f"bench-{i}", event_type="plan.status") + c.process_event(event) + + +class BackoffSuite: + """Benchmark backoff computation.""" + + timeout = 60 + + def time_backoff_delay(self) -> None: + for i in range(10): + _ws_backoff_delay(i, 1.0, 60.0) + + +class ConnectionStateSuite: + """Benchmark ConnectionState operations.""" + + timeout = 60 + + def time_create_and_reset(self) -> None: + s = ConnectionState() + s.connected = True + s.last_event_id = "ev1" + s.reset() diff --git a/features/client/plan_sync.feature b/features/client/plan_sync.feature new file mode 100644 index 000000000..a40fba26e --- /dev/null +++ b/features/client/plan_sync.feature @@ -0,0 +1,131 @@ +@phase2 @client @sync +Feature: Plan sync and remote execution + As a developer + I want to sync local plans with a remote server + So that I can execute plans remotely and keep state in sync + + # --------------------------------------------------------------------------- + # Sync scope + # --------------------------------------------------------------------------- + + Scenario: SyncScope returns all types when all flags are true + Given a SyncScope with all flags enabled + Then the active types should be "actions,skills,tools,projects" + + Scenario: SyncScope returns only selected types + Given a SyncScope with only actions and tools enabled + Then the active types should be "actions,tools" + + # --------------------------------------------------------------------------- + # Sync operations + # --------------------------------------------------------------------------- + + Scenario: Sync creates new items on the server + Given a PlanSyncClient with a mock HTTP client + And a list of 2 new items without server_id + When I sync the items as "actions" + Then the sync summary should show 2 created and 0 updated + And the sync summary should not be a dry run + + Scenario: Sync updates existing items on the server + Given a PlanSyncClient with a mock HTTP client + And a list of 2 existing items with server_id and newer version + When I sync the items as "tools" + Then the sync summary should show 0 created and 2 updated + + Scenario: Sync skips items with matching version + Given a PlanSyncClient with a mock HTTP client + And a list of 1 existing items with server_id and same version + When I sync the items as "skills" + Then the sync summary should show 0 created and 0 updated and 1 skipped + + Scenario: Sync dry-run does not mutate server + Given a PlanSyncClient with a mock HTTP client + And a list of 2 new items without server_id + When I sync the items as "actions" with dry_run true + Then the sync summary should show 2 created and 0 updated + And the sync summary should be a dry run + + Scenario: Sync handles items missing id as errors + Given a PlanSyncClient with a mock HTTP client + And a list with one item missing id + When I sync the items as "actions" + Then the sync summary should show 0 created and 0 updated and 1 error + + Scenario: Sync server_wins policy skips when server version is newer + Given a PlanSyncClient with server_wins conflict policy + And a list of 1 existing items where server version is newer + When I sync the items as "tools" + Then the sync summary should show 0 created and 0 updated and 1 skipped + + # --------------------------------------------------------------------------- + # Sync all + # --------------------------------------------------------------------------- + + Scenario: Sync all respects scope flags + Given a PlanSyncClient with a mock HTTP client + And resources for actions and tools + When I sync all with a scope limited to actions only + Then the sync result should contain actions but not tools + + # --------------------------------------------------------------------------- + # Remote execution + # --------------------------------------------------------------------------- + + Scenario: Execute plan returns execution result + Given a PlanSyncClient with a mock HTTP client for execution + When I execute plan "plan-001" + Then the execution result plan_id should be "plan-001" + And the execution result status should be "submitted" + + Scenario: Apply plan returns execution result + Given a PlanSyncClient with a mock HTTP client for execution + When I apply plan "plan-002" + Then the execution result plan_id should be "plan-002" + And the execution result status should be "applying" + + Scenario: Get plan status returns status dict + Given a PlanSyncClient with a mock HTTP client for status + When I get status for server plan "srv-001" + Then the plan status should contain phase "running" + + Scenario: Execute plan with empty id raises ValueError + Given a PlanSyncClient with a mock HTTP client + When I execute plan with empty id + Then a ValueError should be raised for empty sync plan_id + + Scenario: Apply plan with empty id raises ValueError + Given a PlanSyncClient with a mock HTTP client + When I apply plan with empty id + Then a ValueError should be raised for empty sync plan_id + + Scenario: Get plan status with empty id raises ValueError + Given a PlanSyncClient with a mock HTTP client + When I get status with empty server plan id + Then a ValueError should be raised for empty sync plan_id + + # --------------------------------------------------------------------------- + # Conflict policy + # --------------------------------------------------------------------------- + + Scenario: Conflict policy can be changed at runtime + Given a PlanSyncClient with local_wins conflict policy + When I change the conflict policy to server_wins + Then the conflict policy should be server_wins + + # --------------------------------------------------------------------------- + # SyncSummary + # --------------------------------------------------------------------------- + + Scenario: SyncSummary total_processed counts all fields + Given a SyncSummary with 2 created 1 updated 1 skipped 1 errors + Then the total processed should be 5 + + # --------------------------------------------------------------------------- + # ExecutionResult attributes + # --------------------------------------------------------------------------- + + Scenario: ExecutionResult stores all attributes + Given an ExecutionResult with plan_id "p1" server_plan_id "sp1" status "done" message "ok" + Then the execution result message should be "ok" + And the execution result server_plan_id should be "sp1" diff --git a/features/client/remote_project.feature b/features/client/remote_project.feature new file mode 100644 index 000000000..4f879650a --- /dev/null +++ b/features/client/remote_project.feature @@ -0,0 +1,115 @@ +@phase2 @client @remote-project +Feature: Remote project support + As a developer + I want to access and execute projects hosted on a remote server + So that I can use remote resources and run plans on the server + + # --------------------------------------------------------------------------- + # Project listing + # --------------------------------------------------------------------------- + + Scenario: List remote projects from server + Given a RemoteProjectClient with mock projects in default namespace + When I list remote projects in namespace "default" + Then I should receive 2 remote projects + And the first remote project name should be "project-alpha" + + Scenario: List remote projects uses cache on second call + Given a RemoteProjectClient with mock projects in default namespace + When I list remote projects in namespace "default" + And I list remote projects in namespace "default" again + Then the HTTP client should have been called once for project listing + + Scenario: List remote projects with force_refresh bypasses cache + Given a RemoteProjectClient with mock projects in default namespace + When I list remote projects in namespace "default" + And I list remote projects with force_refresh + Then the HTTP client should have been called twice for project listing + + # --------------------------------------------------------------------------- + # Project resolution + # --------------------------------------------------------------------------- + + Scenario: Get project by name + Given a RemoteProjectClient with mock projects in default namespace + When I get remote project "project-alpha" in namespace "default" + Then the resolved project name should be "project-alpha" + And the resolved project namespace should be "default" + + Scenario: Get project by alias + Given a RemoteProjectClient with mock projects with aliases + When I get remote project "alpha" in namespace "default" + Then the resolved project name should be "project-alpha" + + Scenario: Get project not found raises ResourceNotFoundError + Given a RemoteProjectClient with mock projects in default namespace + When I get remote project "nonexistent" in namespace "default" expecting error + Then a ResourceNotFoundError should be raised for remote project + + Scenario: Get project with empty name raises ValueError + Given a RemoteProjectClient with mock projects in default namespace + When I get remote project with empty name + Then a ValueError should be raised for remote project name + + Scenario: Resolve project falls back to default namespace + Given a RemoteProjectClient with mock projects in default namespace + When I resolve project "project-alpha" in namespace "custom" with fallback + Then the resolved project name should be "project-alpha" + + # --------------------------------------------------------------------------- + # Execution + # --------------------------------------------------------------------------- + + Scenario: Request execution of remote project + Given a RemoteProjectClient with mock execution endpoint + When I request execution of remote project "proj-001" + Then the execution response should contain status "submitted" + + Scenario: Request execution with plan name + Given a RemoteProjectClient with mock execution endpoint + When I request remote project "proj-001" execution using plan "my-plan" + Then the execution response should contain status "submitted" + + Scenario: Request execution with empty project_id raises ValueError + Given a RemoteProjectClient with mock execution endpoint + When I request execution with empty project_id + Then a ValueError should be raised for remote project_id + + # --------------------------------------------------------------------------- + # Cache management + # --------------------------------------------------------------------------- + + Scenario: Invalidate cache for specific namespace + Given a RemoteProjectClient with mock projects in default namespace + When I list remote projects in namespace "default" + And I invalidate cache for namespace "default" + Then the remote project cache size should be 0 + + Scenario: Invalidate all cache + Given a RemoteProjectClient with mock projects in default namespace + When I list remote projects in namespace "default" + And I invalidate all cache + Then the remote project cache size should be 0 + + # --------------------------------------------------------------------------- + # RemoteProject attributes + # --------------------------------------------------------------------------- + + Scenario: RemoteProject stores all attributes + Given a RemoteProject with id "rp1" name "test" namespace "ns" alias "t" description "desc" + Then the remote project project_id should be "rp1" + And the remote project alias should be "t" + And the remote project description should be "desc" + + # --------------------------------------------------------------------------- + # Cache TTL + # --------------------------------------------------------------------------- + + Scenario: Cache TTL can be configured + Given a RemoteProjectClient with cache_ttl 60.0 + Then the client cache_ttl should be 60.0 + + Scenario: Resolve project with empty name raises ValueError + Given a RemoteProjectClient with mock projects in default namespace + When I resolve project with empty name + Then a ValueError should be raised for remote resolve name diff --git a/features/client/server_http_client.feature b/features/client/server_http_client.feature new file mode 100644 index 000000000..c8e2569c5 --- /dev/null +++ b/features/client/server_http_client.feature @@ -0,0 +1,175 @@ +@phase2 @client @http +Feature: Server HTTP client + As a developer + I want an HTTP client for server communication + So that I can perform health checks, version negotiation, and paginated queries + + # --------------------------------------------------------------------------- + # Construction and configuration + # --------------------------------------------------------------------------- + + Scenario: Create client with default settings + Given a ServerHttpClient with default settings + Then the client base_url should be "http://localhost:8080" + And the client tls_verify should be true + And the client timeout should be 30.0 + + Scenario: Create client with custom settings + Given a ServerHttpClient with base_url "https://example.com" and token "tok_test123abcdefghij" + Then the client base_url should be "https://example.com" + And the client tls_verify should be true + + Scenario: TLS verification disabled logs warning + Given a ServerHttpClient with tls_verify disabled + Then the client tls_verify should be false + + # --------------------------------------------------------------------------- + # Health check + # --------------------------------------------------------------------------- + + Scenario: Health check returns true for healthy server + Given a ServerHttpClient with a mock healthy server + When I call health_check on the http client + Then the health check result should be true + + Scenario: Health check returns false for unreachable server + Given a ServerHttpClient with an unreachable server + When I call health_check on the http client + Then the health check result should be false + + # --------------------------------------------------------------------------- + # Version retrieval + # --------------------------------------------------------------------------- + + Scenario: Get version returns server version string + Given a ServerHttpClient with a mock version server returning "2.1.0" + When I call get_version on the http client + Then the version result should be "2.1.0" + + # --------------------------------------------------------------------------- + # Version negotiation + # --------------------------------------------------------------------------- + + Scenario: Negotiate version succeeds with compatible version + Given a ServerHttpClient with a mock negotiate server returning "1.0" + When I call negotiate_version on the http client + Then the http client negotiated version should be "1.0" + + Scenario: Negotiate version fails when server returns empty + Given a ServerHttpClient with a mock negotiate server returning empty + When I call negotiate_version on the http client expecting error + Then a ServerVersionMismatchError should be raised from http client + + # --------------------------------------------------------------------------- + # Pagination + # --------------------------------------------------------------------------- + + Scenario: List endpoint parses paginated object response + Given a ServerHttpClient with a mock paginated endpoint returning 3 items + When I call list_endpoint with page 1 per_page 10 + Then the page result should have 3 items + And the page result should have total 3 + + Scenario: List endpoint parses list response + Given a ServerHttpClient with a mock list endpoint returning 5 items + When I call list_endpoint with page 1 per_page 10 + Then the page result should have 5 items + And the page result has_next should be false + + # --------------------------------------------------------------------------- + # Error mapping + # --------------------------------------------------------------------------- + + Scenario: Server 503 raises AcpNotAvailableError + Given a ServerHttpClient with a mock server returning 503 + When I call get_version and capture the error + Then an AcpNotAvailableError should be raised from the http client + + Scenario: Server 502 raises ServerConnectionError + Given a ServerHttpClient with a mock server returning 502 + When I call get_version and capture the error + Then a ServerConnectionError should be raised from the http client + + Scenario: Server 401 raises ServerConnectionError with auth message + Given a ServerHttpClient with a mock server returning 401 + When I call get_version and capture the error + Then a ServerConnectionError should be raised from the http client + + # --------------------------------------------------------------------------- + # Retry logic + # --------------------------------------------------------------------------- + + Scenario: Retries on 500 for idempotent GET + Given a ServerHttpClient with a mock server that fails twice then succeeds + When I call get_version on the http client + Then the version result should be "1.0.0" + + # --------------------------------------------------------------------------- + # Auth header redaction + # --------------------------------------------------------------------------- + + Scenario: Auth headers are redacted in log output + Given request headers with an Authorization bearer token + When I redact the headers + Then the Authorization value should be "***REDACTED***" + And the Accept header should not be redacted + + # --------------------------------------------------------------------------- + # Client lifecycle + # --------------------------------------------------------------------------- + + Scenario: Close is a safe no-op + Given a ServerHttpClient with default settings + When I call close on the http client + Then no error should be raised from the http client + + # --------------------------------------------------------------------------- + # Factory from settings + # --------------------------------------------------------------------------- + + Scenario: create_client_from_settings raises when URL not configured + When I call create_client_from_settings without a configured URL + Then a ValueError should be raised with URL configuration message from factory + + Scenario: create_client_from_settings creates client when URL is set + When I call create_client_from_settings with a configured URL + Then a ServerHttpClient should be returned + + # --------------------------------------------------------------------------- + # Client exception attributes + # --------------------------------------------------------------------------- + + Scenario: ServerConnectionError carries url and cause attributes + Given a ServerConnectionError with url "https://x.com" and cause + Then the error url attribute should be "https://x.com" + And the error cause attribute should not be None + + Scenario: ServerTimeoutError carries timeout_seconds attribute + Given a ServerTimeoutError with 10.0 seconds + Then the error timeout_seconds attribute should be 10.0 + + Scenario: ServerVersionMismatchError carries version attributes + Given a ServerVersionMismatchError with client "2.0" and server versions + Then the error client_version attribute should be "2.0" + And the error server_versions attribute should contain "1.0" + + # --------------------------------------------------------------------------- + # Backoff helper + # --------------------------------------------------------------------------- + + Scenario: Backoff delay grows exponentially and is capped + When I compute backoff delay for attempt 0 with base 1.0 and max 10.0 + Then the delay should be 1.0 + When I compute backoff delay for attempt 5 with base 1.0 and max 10.0 + Then the delay should be 10.0 + + # --------------------------------------------------------------------------- + # PageResult attributes + # --------------------------------------------------------------------------- + + Scenario: PageResult stores pagination metadata + Given a PageResult with 2 items page 1 per_page 10 total 20 has_next true + Then the page result page should be 1 + And the page result per_page should be 10 + And the page result total should be 20 + And the page result has_next should be true diff --git a/features/client/websocket_updates.feature b/features/client/websocket_updates.feature new file mode 100644 index 000000000..234967065 --- /dev/null +++ b/features/client/websocket_updates.feature @@ -0,0 +1,135 @@ +@phase2 @client @websocket +Feature: WebSocket updates client + As a developer + I want a WebSocket client for real-time plan updates + So that I can receive status, progress, and log events from the server + + # --------------------------------------------------------------------------- + # Construction + # --------------------------------------------------------------------------- + + Scenario: Create WebSocket client with default settings + Given a WebSocketClient with default settings + Then the ws_url should be "ws://localhost:8080/ws" + And the heartbeat interval should be 30.0 + And the dedup capacity should be 1000 + + Scenario: Create WebSocket client with custom settings + Given a WebSocketClient with url "wss://example.com/ws" and token "tok_ws1234567890abcdef" + Then the ws_url should be "wss://example.com/ws" + + # --------------------------------------------------------------------------- + # Connection lifecycle + # --------------------------------------------------------------------------- + + Scenario: Connect sets state to connected + Given a WebSocketClient with default settings + When I connect the ws client + Then the ws client should be connected + + Scenario: Disconnect sets state to disconnected + Given a WebSocketClient with default settings + When I connect the ws client + And I disconnect the ws client + Then the ws client should not be connected + + # --------------------------------------------------------------------------- + # Reconnection + # --------------------------------------------------------------------------- + + Scenario: Reconnect increments reconnect counter + Given a WebSocketClient with default settings and max_reconnects 5 + When I connect the ws client + And I simulate a reconnect + Then the reconnect count should be 1 + And the ws client should be connected + + Scenario: Reconnect exceeding max raises ServerConnectionError + Given a WebSocketClient with default settings and max_reconnects 2 + When I exhaust reconnect attempts + Then a ServerConnectionError should be raised from ws client + + # --------------------------------------------------------------------------- + # Event processing + # --------------------------------------------------------------------------- + + Scenario: Process event dispatches to subscriber + Given a WebSocketClient with a subscriber + When I process a plan status event with id "evt-001" + Then the subscriber should receive event "evt-001" + And the last event id should be "evt-001" + + Scenario: Process duplicate event is skipped + Given a WebSocketClient with a subscriber + When I process a plan status event with id "evt-dup-001" + And I process a plan status event with id "evt-dup-001" again + Then the subscriber should have received 1 event + + Scenario: Unsubscribe removes callback + Given a WebSocketClient with a subscriber + When I unsubscribe the callback + And I process a plan status event with id "evt-unsub" + Then the subscriber should have received 0 events + + # --------------------------------------------------------------------------- + # Heartbeat + # --------------------------------------------------------------------------- + + Scenario: Heartbeat resets reconnect counter + Given a WebSocketClient with default settings and max_reconnects 5 + When I connect the ws client + And I simulate a reconnect + And I handle a heartbeat + Then the reconnect count should be 0 + + # --------------------------------------------------------------------------- + # Version negotiation + # --------------------------------------------------------------------------- + + Scenario: Version negotiation stores agreed version + Given a WebSocketClient with default settings + When I negotiate ws version "1.0" + Then the negotiated ws version should be "1.0" + + # --------------------------------------------------------------------------- + # EventDeduplicator + # --------------------------------------------------------------------------- + + Scenario: Deduplicator detects duplicates + Given an EventDeduplicator with capacity 3 + When I check event "a" for duplicate + Then the dedup result should be not duplicate + When I check event "a" for duplicate + Then the dedup result should be duplicate + + Scenario: Deduplicator evicts oldest when full + Given an EventDeduplicator with capacity 2 + When I add events "x" and "y" and "z" to the deduplicator + Then event "x" should not be tracked + And the deduplicator size should be 2 + + Scenario: Deduplicator clear removes all entries + Given an EventDeduplicator with capacity 10 + When I add events "a" and "b" and "c" to the deduplicator + And I clear the deduplicator + Then the deduplicator size should be 0 + + # --------------------------------------------------------------------------- + # ConnectionState + # --------------------------------------------------------------------------- + + Scenario: ConnectionState reset clears all fields + Given a ConnectionState with connected true and last_event_id "ev1" + When I reset the connection state + Then the connection state should be disconnected + And the connection state last_event_id should be empty + + # --------------------------------------------------------------------------- + # Backoff helper + # --------------------------------------------------------------------------- + + Scenario: WS backoff delay grows exponentially and is capped + When I compute ws backoff delay for attempt 0 base 1.0 max 30.0 + Then the ws delay should be 1.0 + When I compute ws backoff delay for attempt 10 base 1.0 max 30.0 + Then the ws delay should be 30.0 diff --git a/features/steps/plan_sync_steps.py b/features/steps/plan_sync_steps.py new file mode 100644 index 000000000..d2f16fbc3 --- /dev/null +++ b/features/steps/plan_sync_steps.py @@ -0,0 +1,371 @@ +"""Step definitions for plan sync and remote execution feature tests.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import httpx +from behave import given, then, when +from behave.runner import Context + +from cleveragents.client.http_client import ServerHttpClient +from cleveragents.client.sync_client import ( + ConflictPolicy, + ExecutionResult, + PlanSyncClient, + SyncScope, + SyncSummary, +) + + +def _mock_response( + status: int = 200, + body: dict[str, Any] | list[Any] | None = None, +) -> httpx.Response: + return httpx.Response( + status_code=status, + json=body, + headers={}, + request=httpx.Request("GET", "http://mock"), + ) + + +# --------------------------------------------------------------------------- +# SyncScope +# --------------------------------------------------------------------------- + + +@given("a SyncScope with all flags enabled") +def step_scope_all(context: Context) -> None: + context.sync_scope = SyncScope() + + +@given("a SyncScope with only actions and tools enabled") +def step_scope_partial(context: Context) -> None: + context.sync_scope = SyncScope( + actions=True, skills=False, tools=True, projects=False + ) + + +@then('the active types should be "{expected}"') +def step_check_active_types(context: Context, expected: str) -> None: + result = ",".join(context.sync_scope.active_types()) + assert result == expected, f"Expected {expected!r}, got {result!r}" + + +# --------------------------------------------------------------------------- +# PlanSyncClient construction +# --------------------------------------------------------------------------- + + +def _build_sync_client( + policy: ConflictPolicy = ConflictPolicy.LOCAL_WINS, +) -> tuple[PlanSyncClient, MagicMock]: + mock_http = MagicMock(spec=ServerHttpClient) + client = PlanSyncClient(mock_http, conflict_policy=policy) + return client, mock_http + + +@given("a PlanSyncClient with a mock HTTP client") +def step_sync_client(context: Context) -> None: + context.sync_client, context.mock_http = _build_sync_client() + context.call_error = None + + +@given("a PlanSyncClient with server_wins conflict policy") +def step_sync_client_server_wins(context: Context) -> None: + context.sync_client, context.mock_http = _build_sync_client( + ConflictPolicy.SERVER_WINS + ) + context.call_error = None + + +@given("a PlanSyncClient with local_wins conflict policy") +def step_sync_client_local_wins(context: Context) -> None: + context.sync_client, context.mock_http = _build_sync_client( + ConflictPolicy.LOCAL_WINS + ) + context.call_error = None + + +# --------------------------------------------------------------------------- +# Item lists +# --------------------------------------------------------------------------- + + +@given("a list of {n:d} new items without server_id") +def step_new_items(context: Context, n: int) -> None: + context.sync_items = [{"id": f"item-{i}", "name": f"Item {i}"} for i in range(n)] + # Mock POST returns new server_id + context.mock_http._request.return_value = _mock_response(200, {"id": "srv-new-001"}) + + +@given("a list of {n:d} existing items with server_id and newer version") +def step_existing_newer(context: Context, n: int) -> None: + context.sync_items = [ + {"id": f"item-{i}", "server_id": f"srv-{i}", "version": 2} for i in range(n) + ] + + # Mock GET returns older version, PUT returns OK + def _side_effect(method: str, path: str, **kw: Any) -> httpx.Response: + if method == "GET": + return _mock_response(200, {"version": 1}) + return _mock_response(200, {"id": "srv-updated"}) + + context.mock_http._request.side_effect = _side_effect + + +@given("a list of {n:d} existing items with server_id and same version") +def step_existing_same(context: Context, n: int) -> None: + context.sync_items = [ + {"id": f"item-{i}", "server_id": f"srv-{i}", "version": 1} for i in range(n) + ] + context.mock_http._request.return_value = _mock_response(200, {"version": 1}) + + +@given("a list with one item missing id") +def step_item_missing_id(context: Context) -> None: + context.sync_items = [{"name": "no-id"}] + + +@given("a list of {n:d} existing items where server version is newer") +def step_existing_server_newer(context: Context, n: int) -> None: + context.sync_items = [ + {"id": f"item-{i}", "server_id": f"srv-{i}", "version": 1} for i in range(n) + ] + context.mock_http._request.return_value = _mock_response(200, {"version": 5}) + + +# --------------------------------------------------------------------------- +# Sync operations +# --------------------------------------------------------------------------- + + +@when('I sync the items as "{resource_type}"') +def step_sync(context: Context, resource_type: str) -> None: + context.sync_summary = context.sync_client.sync(context.sync_items, resource_type) + + +@when('I sync the items as "{resource_type}" with dry_run true') +def step_sync_dry(context: Context, resource_type: str) -> None: + context.sync_summary = context.sync_client.sync( + context.sync_items, resource_type, dry_run=True + ) + + +@then("the sync summary should show {created:d} created and {updated:d} updated") +def step_check_sync_created_updated( + context: Context, created: int, updated: int +) -> None: + assert context.sync_summary.created == created, ( + f"Expected {created} created, got {context.sync_summary.created}" + ) + assert context.sync_summary.updated == updated, ( + f"Expected {updated} updated, got {context.sync_summary.updated}" + ) + + +@then("the sync summary should show {c:d} created and {u:d} updated and {s:d} skipped") +def step_check_sync_skipped(context: Context, c: int, u: int, s: int) -> None: + assert context.sync_summary.created == c + assert context.sync_summary.updated == u + assert context.sync_summary.skipped == s + + +@then("the sync summary should show {c:d} created and {u:d} updated and {e:d} error") +def step_check_sync_errors(context: Context, c: int, u: int, e: int) -> None: + assert context.sync_summary.created == c + assert context.sync_summary.updated == u + assert context.sync_summary.errors == e + + +@then("the sync summary should not be a dry run") +def step_not_dry_run(context: Context) -> None: + assert context.sync_summary.dry_run is False + + +@then("the sync summary should be a dry run") +def step_is_dry_run(context: Context) -> None: + assert context.sync_summary.dry_run is True + + +# --------------------------------------------------------------------------- +# Sync all +# --------------------------------------------------------------------------- + + +@given("resources for actions and tools") +def step_resources(context: Context) -> None: + context.resources = { + "actions": [{"id": "a1"}], + "tools": [{"id": "t1"}], + } + context.mock_http._request.return_value = _mock_response(200, {"id": "srv-001"}) + + +@when("I sync all with a scope limited to actions only") +def step_sync_all_actions(context: Context) -> None: + scope = SyncScope(actions=True, skills=False, tools=False, projects=False) + context.sync_results = context.sync_client.sync_all(context.resources, scope=scope) + + +@then("the sync result should contain actions but not tools") +def step_check_sync_all(context: Context) -> None: + assert "actions" in context.sync_results + assert "tools" not in context.sync_results + + +# --------------------------------------------------------------------------- +# Remote execution +# --------------------------------------------------------------------------- + + +@given("a PlanSyncClient with a mock HTTP client for execution") +def step_sync_client_exec(context: Context) -> None: + context.sync_client, context.mock_http = _build_sync_client() + context.call_error = None + + def _exec_side_effect(method: str, path: str, **kw: Any) -> httpx.Response: + if "execute" in path: + return _mock_response( + 200, + { + "server_plan_id": "srv-exec-001", + "status": "submitted", + }, + ) + if "apply" in path: + return _mock_response( + 200, + { + "server_plan_id": "srv-apply-001", + "status": "applying", + }, + ) + return _mock_response(200, {}) + + context.mock_http._request.side_effect = _exec_side_effect + + +@given("a PlanSyncClient with a mock HTTP client for status") +def step_sync_client_status(context: Context) -> None: + context.sync_client, context.mock_http = _build_sync_client() + context.mock_http._request.return_value = _mock_response( + 200, {"phase": "running", "progress": 50} + ) + context.call_error = None + + +@when('I execute plan "{plan_id}"') +def step_execute_plan(context: Context, plan_id: str) -> None: + context.exec_result = context.sync_client.execute_plan(plan_id) + + +@when('I apply plan "{plan_id}"') +def step_apply_plan(context: Context, plan_id: str) -> None: + context.exec_result = context.sync_client.apply_plan(plan_id) + + +@when('I get status for server plan "{server_plan_id}"') +def step_get_status(context: Context, server_plan_id: str) -> None: + context.plan_status = context.sync_client.get_plan_status(server_plan_id) + + +@when("I execute plan with empty id") +def step_execute_empty(context: Context) -> None: + try: + context.sync_client.execute_plan("") + except ValueError as exc: + context.call_error = exc + + +@when("I apply plan with empty id") +def step_apply_empty(context: Context) -> None: + try: + context.sync_client.apply_plan("") + except ValueError as exc: + context.call_error = exc + + +@when("I get status with empty server plan id") +def step_status_empty(context: Context) -> None: + try: + context.sync_client.get_plan_status("") + except ValueError as exc: + context.call_error = exc + + +@then('the execution result plan_id should be "{expected}"') +def step_check_exec_plan_id(context: Context, expected: str) -> None: + assert context.exec_result.plan_id == expected + + +@then('the execution result status should be "{expected}"') +def step_check_exec_status(context: Context, expected: str) -> None: + assert context.exec_result.status == expected + + +@then('the plan status should contain phase "{expected}"') +def step_check_plan_phase(context: Context, expected: str) -> None: + assert context.plan_status.get("phase") == expected + + +@then("a ValueError should be raised for empty sync plan_id") +def step_value_error_plan_id(context: Context) -> None: + assert isinstance(context.call_error, ValueError) + + +# --------------------------------------------------------------------------- +# Conflict policy +# --------------------------------------------------------------------------- + + +@when("I change the conflict policy to server_wins") +def step_change_policy(context: Context) -> None: + context.sync_client.conflict_policy = ConflictPolicy.SERVER_WINS + + +@then("the conflict policy should be server_wins") +def step_check_policy(context: Context) -> None: + assert context.sync_client.conflict_policy == ConflictPolicy.SERVER_WINS + + +# --------------------------------------------------------------------------- +# SyncSummary +# --------------------------------------------------------------------------- + + +@given("a SyncSummary with {c:d} created {u:d} updated {s:d} skipped {e:d} errors") +def step_sync_summary(context: Context, c: int, u: int, s: int, e: int) -> None: + context.test_summary = SyncSummary(created=c, updated=u, skipped=s, errors=e) + + +@then("the total processed should be {expected:d}") +def step_check_total(context: Context, expected: int) -> None: + assert context.test_summary.total_processed == expected + + +# --------------------------------------------------------------------------- +# ExecutionResult +# --------------------------------------------------------------------------- + + +@given( + 'an ExecutionResult with plan_id "{pid}" server_plan_id "{spid}" ' + 'status "{st}" message "{msg}"' +) +def step_exec_result(context: Context, pid: str, spid: str, st: str, msg: str) -> None: + context.exec_result = ExecutionResult( + plan_id=pid, server_plan_id=spid, status=st, message=msg + ) + + +@then('the execution result message should be "{expected}"') +def step_check_exec_msg(context: Context, expected: str) -> None: + assert context.exec_result.message == expected + + +@then('the execution result server_plan_id should be "{expected}"') +def step_check_exec_spid(context: Context, expected: str) -> None: + assert context.exec_result.server_plan_id == expected diff --git a/features/steps/remote_project_steps.py b/features/steps/remote_project_steps.py new file mode 100644 index 000000000..1393f1f6c --- /dev/null +++ b/features/steps/remote_project_steps.py @@ -0,0 +1,303 @@ +"""Step definitions for remote project client feature tests.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import httpx +from behave import given, then, when +from behave.runner import Context + +from cleveragents.client.http_client import PageResult, ServerHttpClient +from cleveragents.client.remote_project import ( + RemoteProject, + RemoteProjectClient, +) +from cleveragents.core.exceptions import ResourceNotFoundError + + +def _mock_page_result( + items: list[dict[str, Any]], +) -> PageResult: + return PageResult(items=items, page=1, per_page=50, total=len(items)) + + +_DEFAULT_PROJECTS = [ + {"id": "rp-001", "name": "project-alpha", "alias": "", "description": "Alpha"}, + {"id": "rp-002", "name": "project-beta", "alias": "", "description": "Beta"}, +] + +_ALIASED_PROJECTS = [ + {"id": "rp-001", "name": "project-alpha", "alias": "alpha", "description": "Alpha"}, + {"id": "rp-002", "name": "project-beta", "alias": "beta", "description": "Beta"}, +] + + +def _build_remote_client( + projects: list[dict[str, Any]] | None = None, + cache_ttl: float = 300.0, +) -> tuple[RemoteProjectClient, MagicMock]: + mock_http = MagicMock(spec=ServerHttpClient) + items = projects if projects is not None else _DEFAULT_PROJECTS + mock_http.list_endpoint.return_value = _mock_page_result(items) + client = RemoteProjectClient(mock_http, cache_ttl=cache_ttl) + return client, mock_http + + +# --------------------------------------------------------------------------- +# Project listing +# --------------------------------------------------------------------------- + + +@given("a RemoteProjectClient with mock projects in default namespace") +def step_remote_client(context: Context) -> None: + context.remote_client, context.mock_http = _build_remote_client() + context.call_error = None + context.http_call_count = 0 + + +@given("a RemoteProjectClient with mock projects with aliases") +def step_remote_client_aliases(context: Context) -> None: + context.remote_client, context.mock_http = _build_remote_client(_ALIASED_PROJECTS) + context.call_error = None + + +@when('I list remote projects in namespace "{ns}"') +def step_list_projects(context: Context, ns: str) -> None: + context.remote_projects = context.remote_client.list_projects(ns) + context.http_call_count = context.mock_http.list_endpoint.call_count + + +@when('I list remote projects in namespace "{ns}" again') +def step_list_projects_again(context: Context, ns: str) -> None: + context.remote_projects = context.remote_client.list_projects(ns) + + +@when("I list remote projects with force_refresh") +def step_list_force_refresh(context: Context) -> None: + context.remote_projects = context.remote_client.list_projects( + "default", force_refresh=True + ) + + +@then("I should receive {n:d} remote projects") +def step_check_project_count(context: Context, n: int) -> None: + assert len(context.remote_projects) == n + + +@then('the first remote project name should be "{expected}"') +def step_check_first_name(context: Context, expected: str) -> None: + assert context.remote_projects[0].name == expected + + +@then("the HTTP client should have been called once for project listing") +def step_http_called_once(context: Context) -> None: + assert context.mock_http.list_endpoint.call_count == 1 + + +@then("the HTTP client should have been called twice for project listing") +def step_http_called_twice(context: Context) -> None: + assert context.mock_http.list_endpoint.call_count == 2 + + +# --------------------------------------------------------------------------- +# Project resolution +# --------------------------------------------------------------------------- + + +@when('I get remote project "{name}" in namespace "{ns}"') +def step_get_project(context: Context, name: str, ns: str) -> None: + context.resolved_project = context.remote_client.get_project(name, ns) + + +@when('I get remote project "{name}" in namespace "{ns}" expecting error') +def step_get_project_error(context: Context, name: str, ns: str) -> None: + try: + context.remote_client.get_project(name, ns) + except ResourceNotFoundError as exc: + context.call_error = exc + + +@when("I get remote project with empty name") +def step_get_empty(context: Context) -> None: + try: + context.remote_client.get_project("") + except ValueError as exc: + context.call_error = exc + + +@when('I resolve project "{name}" in namespace "{ns}" with fallback') +def step_resolve_fallback(context: Context, name: str, ns: str) -> None: + # Mock: custom namespace returns empty, default has projects + + def _side_effect(path: str, **kwargs: Any) -> PageResult: + ns_param = kwargs.get("params", {}).get("namespace", "default") + if ns_param == "custom": + return _mock_page_result([]) + return _mock_page_result(_DEFAULT_PROJECTS) + + context.mock_http.list_endpoint.side_effect = _side_effect + context.resolved_project = context.remote_client.resolve_project(name, ns) + + +@when("I resolve project with empty name") +def step_resolve_empty(context: Context) -> None: + try: + context.remote_client.resolve_project("") + except ValueError as exc: + context.call_error = exc + + +@then('the resolved project name should be "{expected}"') +def step_check_resolved_name(context: Context, expected: str) -> None: + assert context.resolved_project.name == expected + + +@then('the resolved project namespace should be "{expected}"') +def step_check_resolved_ns(context: Context, expected: str) -> None: + assert context.resolved_project.namespace == expected + + +@then("a ResourceNotFoundError should be raised for remote project") +def step_not_found_error(context: Context) -> None: + assert isinstance(context.call_error, ResourceNotFoundError) + + +@then("a ValueError should be raised for remote project name") +def step_value_error_name(context: Context) -> None: + assert isinstance(context.call_error, ValueError) + + +@then("a ValueError should be raised for remote resolve name") +def step_value_error_resolve(context: Context) -> None: + assert isinstance(context.call_error, ValueError) + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + + +def _mock_exec_response( + status: int = 200, body: dict[str, Any] | None = None +) -> httpx.Response: + return httpx.Response( + status_code=status, + json=body, + headers={}, + request=httpx.Request("POST", "http://mock"), + ) + + +@given("a RemoteProjectClient with mock execution endpoint") +def step_remote_exec(context: Context) -> None: + context.remote_client, context.mock_http = _build_remote_client() + context.mock_http._request.return_value = _mock_exec_response( + 200, {"status": "submitted", "execution_id": "exec-001"} + ) + context.call_error = None + + +@when('I request execution of remote project "{pid}"') +def step_request_exec(context: Context, pid: str) -> None: + context.exec_response = context.remote_client.request_execution(pid) + + +@when('I request remote project "{pid}" execution using plan "{plan}"') +def step_request_exec_plan(context: Context, pid: str, plan: str) -> None: + context.exec_response = context.remote_client.request_execution(pid, plan_name=plan) + + +@when("I request execution with empty project_id") +def step_request_exec_empty(context: Context) -> None: + try: + context.remote_client.request_execution("") + except ValueError as exc: + context.call_error = exc + + +@then('the execution response should contain status "{expected}"') +def step_check_exec_response(context: Context, expected: str) -> None: + assert context.exec_response.get("status") == expected + + +@then("a ValueError should be raised for remote project_id") +def step_value_error_pid(context: Context) -> None: + assert isinstance(context.call_error, ValueError) + + +# --------------------------------------------------------------------------- +# Cache management +# --------------------------------------------------------------------------- + + +@when('I invalidate cache for namespace "{ns}"') +def step_invalidate_ns(context: Context, ns: str) -> None: + context.remote_client.invalidate_cache(ns) + + +@when("I invalidate all cache") +def step_invalidate_all(context: Context) -> None: + context.remote_client.invalidate_cache() + + +@then("the remote project cache size should be {expected:d}") +def step_check_cache_size(context: Context, expected: int) -> None: + assert context.remote_client.cache_size == expected + + +# --------------------------------------------------------------------------- +# RemoteProject attributes +# --------------------------------------------------------------------------- + + +@given( + 'a RemoteProject with id "{pid}" name "{name}" namespace "{ns}" ' + 'alias "{alias}" description "{desc}"' +) +def step_remote_project_attrs( + context: Context, + pid: str, + name: str, + ns: str, + alias: str, + desc: str, +) -> None: + context.test_project = RemoteProject( + project_id=pid, + name=name, + namespace=ns, + alias=alias, + description=desc, + ) + + +@then('the remote project project_id should be "{expected}"') +def step_check_pid(context: Context, expected: str) -> None: + assert context.test_project.project_id == expected + + +@then('the remote project alias should be "{expected}"') +def step_check_alias(context: Context, expected: str) -> None: + assert context.test_project.alias == expected + + +@then('the remote project description should be "{expected}"') +def step_check_desc(context: Context, expected: str) -> None: + assert context.test_project.description == expected + + +# --------------------------------------------------------------------------- +# Cache TTL +# --------------------------------------------------------------------------- + + +@given("a RemoteProjectClient with cache_ttl {ttl:g}") +def step_client_ttl(context: Context, ttl: float) -> None: + context.remote_client, _ = _build_remote_client(cache_ttl=ttl) + + +@then("the client cache_ttl should be {expected:g}") +def step_check_ttl(context: Context, expected: float) -> None: + assert context.remote_client.cache_ttl == expected diff --git a/features/steps/server_http_client_steps.py b/features/steps/server_http_client_steps.py new file mode 100644 index 000000000..9ebc0334f --- /dev/null +++ b/features/steps/server_http_client_steps.py @@ -0,0 +1,513 @@ +"""Step definitions for server HTTP client feature tests. + +Covers construction, health check, version negotiation, pagination, +error mapping, retry logic, auth redaction, and the settings factory. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import httpx +from behave import given, then, when +from behave.runner import Context + +from cleveragents.acp.errors import AcpNotAvailableError +from cleveragents.client.exceptions import ( + ServerConnectionError, + ServerTimeoutError, + ServerVersionMismatchError, +) +from cleveragents.client.http_client import ( + PageResult, + ServerHttpClient, + _backoff_delay, + _redact_headers, + create_client_from_settings, +) + +# --------------------------------------------------------------------------- +# Helpers — mock httpx.request +# --------------------------------------------------------------------------- + + +def _mock_response( + status: int = 200, + body: dict[str, Any] | list[Any] | None = None, + headers: dict[str, str] | None = None, +) -> httpx.Response: + """Build a fake httpx.Response.""" + resp = httpx.Response( + status_code=status, + json=body, + headers=headers or {}, + request=httpx.Request("GET", "http://mock"), + ) + return resp + + +# --------------------------------------------------------------------------- +# Construction and configuration +# --------------------------------------------------------------------------- + + +@given("a ServerHttpClient with default settings") +def step_client_defaults(context: Context) -> None: + context.http_client = ServerHttpClient() + context.call_error = None + + +@given('a ServerHttpClient with base_url "{url}" and token "{token}"') +def step_client_custom(context: Context, url: str, token: str) -> None: + context.http_client = ServerHttpClient(base_url=url, api_token=token) + context.call_error = None + + +@given("a ServerHttpClient with tls_verify disabled") +def step_client_no_tls(context: Context) -> None: + context.http_client = ServerHttpClient(tls_verify=False) + context.call_error = None + + +@then('the client base_url should be "{expected}"') +def step_check_base_url(context: Context, expected: str) -> None: + assert context.http_client.base_url == expected + + +@then("the client tls_verify should be true") +def step_check_tls_true(context: Context) -> None: + assert context.http_client.tls_verify is True + + +@then("the client tls_verify should be false") +def step_check_tls_false(context: Context) -> None: + assert context.http_client.tls_verify is False + + +@then("the client timeout should be {expected:g}") +def step_check_timeout(context: Context, expected: float) -> None: + assert context.http_client.timeout == expected + + +# --------------------------------------------------------------------------- +# Health check +# --------------------------------------------------------------------------- + + +@given("a ServerHttpClient with a mock healthy server") +def step_mock_healthy(context: Context) -> None: + context.http_client = ServerHttpClient() + context._mock_responses = [_mock_response(200, {"status": "healthy"})] + context.call_error = None + + +@given("a ServerHttpClient with an unreachable server") +def step_mock_unreachable(context: Context) -> None: + context.http_client = ServerHttpClient(base_url="http://192.0.2.1:1", max_retries=1) + context._mock_connect_error = True + context.call_error = None + + +@when("I call health_check on the http client") +def step_health_check(context: Context) -> None: + if getattr(context, "_mock_connect_error", False): + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.side_effect = httpx.ConnectError("refused") + context.health_result = context.http_client.health_check() + elif hasattr(context, "_mock_responses"): + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = context._mock_responses[0] + context.health_result = context.http_client.health_check() + else: + context.health_result = context.http_client.health_check() + + +@then("the health check result should be true") +def step_health_true(context: Context) -> None: + assert context.health_result is True + + +@then("the health check result should be false") +def step_health_false(context: Context) -> None: + assert context.health_result is False + + +# --------------------------------------------------------------------------- +# Version retrieval +# --------------------------------------------------------------------------- + + +@given('a ServerHttpClient with a mock version server returning "{version}"') +def step_mock_version_server(context: Context, version: str) -> None: + context.http_client = ServerHttpClient() + context._mock_responses = [_mock_response(200, {"version": version})] + context.call_error = None + + +@when("I call get_version on the http client") +def step_get_version(context: Context) -> None: + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + if hasattr(context, "_mock_fail_then_succeed"): + mock_req.side_effect = context._mock_fail_then_succeed + else: + mock_req.return_value = context._mock_responses[0] + context.version_result = context.http_client.get_version() + + +@then('the version result should be "{expected}"') +def step_check_version(context: Context, expected: str) -> None: + assert context.version_result == expected + + +# --------------------------------------------------------------------------- +# Version negotiation +# --------------------------------------------------------------------------- + + +@given('a ServerHttpClient with a mock negotiate server returning "{version}"') +def step_mock_negotiate(context: Context, version: str) -> None: + context.http_client = ServerHttpClient() + body: dict[str, Any] = {"negotiated_version": version} if version else {} + context._mock_responses = [_mock_response(200, body)] + context.call_error = None + + +@when("I call negotiate_version on the http client") +def step_negotiate(context: Context) -> None: + try: + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = context._mock_responses[0] + context.negotiated_version = context.http_client.negotiate_version() + except ServerVersionMismatchError as exc: + context.call_error = exc + + +@then('the http client negotiated version should be "{expected}"') +def step_check_negotiated(context: Context, expected: str) -> None: + assert context.negotiated_version == expected + + +@then("a ServerVersionMismatchError should be raised") +def step_version_mismatch_raised(context: Context) -> None: + assert isinstance(context.call_error, ServerVersionMismatchError) + + +@given("a ServerHttpClient with a mock negotiate server returning empty") +def step_mock_negotiate_empty(context: Context) -> None: + context.http_client = ServerHttpClient() + context._mock_responses = [_mock_response(200, {})] + context.call_error = None + + +@when("I call negotiate_version on the http client expecting error") +def step_negotiate_expecting_error(context: Context) -> None: + try: + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = context._mock_responses[0] + context.http_client.negotiate_version() + except ServerVersionMismatchError as exc: + context.call_error = exc + + +@then("a ServerVersionMismatchError should be raised from http client") +def step_version_mismatch_raised_http(context: Context) -> None: + assert isinstance(context.call_error, ServerVersionMismatchError) + + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + + +@given("a ServerHttpClient with a mock paginated endpoint returning {n:d} items") +def step_mock_paginated(context: Context, n: int) -> None: + context.http_client = ServerHttpClient() + items = [{"id": str(i)} for i in range(n)] + context._mock_responses = [ + _mock_response(200, {"items": items, "total": n, "has_next": False}) + ] + context.call_error = None + + +@given("a ServerHttpClient with a mock list endpoint returning {n:d} items") +def step_mock_list(context: Context, n: int) -> None: + context.http_client = ServerHttpClient() + items = [{"id": str(i)} for i in range(n)] + context._mock_responses = [_mock_response(200, items)] + context.call_error = None + + +@when("I call list_endpoint with page {page:d} per_page {per_page:d}") +def step_list_endpoint(context: Context, page: int, per_page: int) -> None: + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = context._mock_responses[0] + context.page_result = context.http_client.list_endpoint( + "/items", page=page, per_page=per_page + ) + + +@then("the page result should have {n:d} items") +def step_check_page_items(context: Context, n: int) -> None: + assert len(context.page_result.items) == n + + +@then("the page result should have total {n:d}") +def step_check_page_total(context: Context, n: int) -> None: + assert context.page_result.total == n + + +@then("the page result has_next should be false") +def step_check_page_no_next(context: Context) -> None: + assert context.page_result.has_next is False + + +@then("the page result has_next should be true") +def step_check_page_has_next(context: Context) -> None: + assert context.page_result.has_next is True + + +# --------------------------------------------------------------------------- +# Error mapping +# --------------------------------------------------------------------------- + + +@given("a ServerHttpClient with a mock server returning {status:d}") +def step_mock_error_server(context: Context, status: int) -> None: + context.http_client = ServerHttpClient(max_retries=1) + context._mock_responses = [_mock_response(status, {"error": "test"})] + context.call_error = None + + +@when("I call get_version and capture the error") +def step_get_version_capture(context: Context) -> None: + try: + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = context._mock_responses[0] + context.http_client.get_version() + except Exception as exc: + context.call_error = exc + + +@then("an AcpNotAvailableError should be raised from the http client") +def step_acp_not_available(context: Context) -> None: + assert isinstance(context.call_error, AcpNotAvailableError), ( + f"Expected AcpNotAvailableError, got {type(context.call_error)}" + ) + + +@then("a ServerConnectionError should be raised from the http client") +def step_connection_error(context: Context) -> None: + assert isinstance(context.call_error, ServerConnectionError), ( + f"Expected ServerConnectionError, got {type(context.call_error)}" + ) + + +# --------------------------------------------------------------------------- +# Retry logic +# --------------------------------------------------------------------------- + + +@given("a ServerHttpClient with a mock server that fails twice then succeeds") +def step_mock_retry(context: Context) -> None: + context.http_client = ServerHttpClient(max_retries=3, backoff_base=0.001) + call_count = 0 + + def _side_effect(*args: Any, **kwargs: Any) -> httpx.Response: + nonlocal call_count + call_count += 1 + if call_count < 3: + return _mock_response(500, {"error": "temp"}) + return _mock_response(200, {"version": "1.0.0"}) + + context._mock_fail_then_succeed = _side_effect + context.call_error = None + + +# --------------------------------------------------------------------------- +# Auth header redaction +# --------------------------------------------------------------------------- + + +@given("request headers with an Authorization bearer token") +def step_headers_with_auth(context: Context) -> None: + context.raw_headers = { + "Authorization": "Bearer tok_secret123abcdefghij", + "Accept": "application/json", + } + + +@when("I redact the headers") +def step_redact(context: Context) -> None: + context.redacted_headers = _redact_headers(context.raw_headers) + + +@then('the Authorization value should be "***REDACTED***"') +def step_auth_redacted(context: Context) -> None: + assert context.redacted_headers["Authorization"] == "***REDACTED***" + + +@then("the Accept header should not be redacted") +def step_accept_not_redacted(context: Context) -> None: + assert context.redacted_headers["Accept"] == "application/json" + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +@when("I call close on the http client") +def step_close(context: Context) -> None: + try: + context.http_client.close() + context.call_error = None + except Exception as exc: + context.call_error = exc + + +@then("no error should be raised from the http client") +def step_no_error(context: Context) -> None: + assert context.call_error is None + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +@when("I call create_client_from_settings without a configured URL") +def step_factory_no_url(context: Context) -> None: + try: + with patch("cleveragents.config.settings.Settings.get_settings") as mock_gs: + mock_settings = MagicMock() + mock_settings.server_base_url = None + mock_gs.return_value = mock_settings + create_client_from_settings() + except ValueError as exc: + context.call_error = exc + + +@then("a ValueError should be raised with URL configuration message from factory") +def step_value_error_url(context: Context) -> None: + assert isinstance(context.call_error, ValueError) + assert "server_base_url" in str(context.call_error) + + +@when("I call create_client_from_settings with a configured URL") +def step_factory_with_url(context: Context) -> None: + with patch("cleveragents.config.settings.Settings.get_settings") as mock_gs: + mock_settings = MagicMock() + mock_settings.server_base_url = "https://test.example.com" + mock_settings.server_api_token = "tok_testabc123defghijk" + mock_settings.server_tls_verify = True + mock_settings.server_request_timeout = 15.0 + mock_gs.return_value = mock_settings + context.factory_client = create_client_from_settings() + + +@then("a ServerHttpClient should be returned") +def step_factory_returns_client(context: Context) -> None: + assert isinstance(context.factory_client, ServerHttpClient) + + +# --------------------------------------------------------------------------- +# Exception attributes +# --------------------------------------------------------------------------- + + +@given('a ServerConnectionError with url "{url}" and cause') +def step_conn_err_attrs(context: Context, url: str) -> None: + context.test_error = ServerConnectionError( + message="test", url=url, cause=RuntimeError("boom") + ) + + +@then('the error url attribute should be "{expected}"') +def step_check_err_url(context: Context, expected: str) -> None: + assert context.test_error.url == expected + + +@then("the error cause attribute should not be None") +def step_check_err_cause(context: Context) -> None: + assert context.test_error.cause is not None + + +@given("a ServerTimeoutError with {seconds:g} seconds") +def step_timeout_err(context: Context, seconds: float) -> None: + context.test_error = ServerTimeoutError(message="timeout", timeout_seconds=seconds) + + +@then("the error timeout_seconds attribute should be {expected:g}") +def step_check_timeout_secs(context: Context, expected: float) -> None: + assert context.test_error.timeout_seconds == expected + + +@given('a ServerVersionMismatchError with client "{cv}" and server versions') +def step_version_err(context: Context, cv: str) -> None: + context.test_error = ServerVersionMismatchError( + message="mismatch", + client_version=cv, + server_versions=["1.0"], + ) + + +@then('the error client_version attribute should be "{expected}"') +def step_check_client_version(context: Context, expected: str) -> None: + assert context.test_error.client_version == expected + + +@then('the error server_versions attribute should contain "{expected}"') +def step_check_server_versions(context: Context, expected: str) -> None: + assert expected in context.test_error.server_versions + + +# --------------------------------------------------------------------------- +# Backoff helper +# --------------------------------------------------------------------------- + + +@when( + "I compute backoff delay for attempt {attempt:d} with base {base:g} and max {maximum:g}" +) +def step_compute_backoff( + context: Context, attempt: int, base: float, maximum: float +) -> None: + context.backoff_delay = _backoff_delay(attempt, base, maximum) + + +@then("the delay should be {expected:g}") +def step_check_delay(context: Context, expected: float) -> None: + assert context.backoff_delay == expected + + +# --------------------------------------------------------------------------- +# PageResult attributes +# --------------------------------------------------------------------------- + + +@given( + "a PageResult with {n:d} items page {page:d} per_page {pp:d} total {total:d} has_next true" +) +def step_page_result_attrs( + context: Context, n: int, page: int, pp: int, total: int +) -> None: + items = [{"id": str(i)} for i in range(n)] + context.page_result = PageResult( + items=items, page=page, per_page=pp, total=total, has_next=True + ) + + +@then("the page result page should be {expected:d}") +def step_check_page_num(context: Context, expected: int) -> None: + assert context.page_result.page == expected + + +@then("the page result per_page should be {expected:d}") +def step_check_per_page(context: Context, expected: int) -> None: + assert context.page_result.per_page == expected + + +@then("the page result total should be {expected:d}") +def step_check_total(context: Context, expected: int) -> None: + assert context.page_result.total == expected diff --git a/features/steps/websocket_updates_steps.py b/features/steps/websocket_updates_steps.py new file mode 100644 index 000000000..7d15c294d --- /dev/null +++ b/features/steps/websocket_updates_steps.py @@ -0,0 +1,294 @@ +"""Step definitions for WebSocket updates client feature tests.""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.acp.models import AcpEvent +from cleveragents.client.exceptions import ServerConnectionError +from cleveragents.client.ws_client import ( + ConnectionState, + EventDeduplicator, + WebSocketClient, + _ws_backoff_delay, +) + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +@given("a WebSocketClient with default settings") +def step_ws_default(context: Context) -> None: + context.ws_client = WebSocketClient() + context.call_error = None + + +@given('a WebSocketClient with url "{url}" and token "{token}"') +def step_ws_custom(context: Context, url: str, token: str) -> None: + context.ws_client = WebSocketClient(ws_url=url, api_token=token) + context.call_error = None + + +@given("a WebSocketClient with default settings and max_reconnects {n:d}") +def step_ws_max_reconnects(context: Context, n: int) -> None: + context.ws_client = WebSocketClient( + max_reconnects=n, reconnect_base=0.001, reconnect_max=0.01 + ) + context.call_error = None + + +@then('the ws_url should be "{expected}"') +def step_check_ws_url(context: Context, expected: str) -> None: + assert context.ws_client.ws_url == expected + + +@then("the heartbeat interval should be {expected:g}") +def step_check_heartbeat(context: Context, expected: float) -> None: + assert context.ws_client.heartbeat_interval == expected + + +@then("the dedup capacity should be {expected:d}") +def step_check_dedup_cap(context: Context, expected: int) -> None: + assert context.ws_client.dedup_capacity == expected + + +# --------------------------------------------------------------------------- +# Connection lifecycle +# --------------------------------------------------------------------------- + + +@when("I connect the ws client") +def step_ws_connect(context: Context) -> None: + context.ws_client.connect() + + +@when("I disconnect the ws client") +def step_ws_disconnect(context: Context) -> None: + context.ws_client.disconnect() + + +@then("the ws client should be connected") +def step_ws_connected(context: Context) -> None: + assert context.ws_client.connected is True + + +@then("the ws client should not be connected") +def step_ws_not_connected(context: Context) -> None: + assert context.ws_client.connected is False + + +# --------------------------------------------------------------------------- +# Reconnection +# --------------------------------------------------------------------------- + + +@when("I simulate a reconnect") +def step_ws_reconnect(context: Context) -> None: + context.ws_client._state.connected = False + context.ws_client.reconnect() + + +@when("I exhaust reconnect attempts") +def step_ws_exhaust_reconnects(context: Context) -> None: + context.ws_client.connect() + try: + for _ in range(context.ws_client._max_reconnects + 1): + context.ws_client._state.connected = False + context.ws_client.reconnect() + except ServerConnectionError as exc: + context.call_error = exc + + +@then("the reconnect count should be {expected:d}") +def step_check_reconnect_count(context: Context, expected: int) -> None: + assert context.ws_client.reconnect_count == expected + + +@then("a ServerConnectionError should be raised from ws client") +def step_ws_conn_error(context: Context) -> None: + assert isinstance(context.call_error, ServerConnectionError) + + +# --------------------------------------------------------------------------- +# Event processing +# --------------------------------------------------------------------------- + + +@given("a WebSocketClient with a subscriber") +def step_ws_with_subscriber(context: Context) -> None: + context.ws_client = WebSocketClient() + context.ws_client.connect() + context.received_events: list[AcpEvent] = [] + + def _on_event(event: AcpEvent) -> None: + context.received_events.append(event) + + context.ws_callback = _on_event + context.ws_client.subscribe(_on_event) + context.call_error = None + + +@when('I process a plan status event with id "{event_id}"') +def step_process_event(context: Context, event_id: str) -> None: + event = AcpEvent( + event_id=event_id, + event_type="plan.status", + plan_id="plan-001", + data={"status": "running"}, + ) + context.ws_process_result = context.ws_client.process_event(event) + + +@when('I process a plan status event with id "{event_id}" again') +def step_process_event_again(context: Context, event_id: str) -> None: + event = AcpEvent( + event_id=event_id, + event_type="plan.status", + plan_id="plan-001", + data={"status": "running"}, + ) + context.ws_process_result = context.ws_client.process_event(event) + + +@when("I unsubscribe the callback") +def step_unsubscribe(context: Context) -> None: + context.ws_client.unsubscribe(context.ws_callback) + + +@then('the subscriber should receive event "{event_id}"') +def step_check_received(context: Context, event_id: str) -> None: + assert any(e.event_id == event_id for e in context.received_events) + + +@then("the subscriber should have received {n:d} event") +def step_check_event_count_singular(context: Context, n: int) -> None: + assert len(context.received_events) == n + + +@then("the subscriber should have received {n:d} events") +def step_check_event_count_plural(context: Context, n: int) -> None: + assert len(context.received_events) == n + + +@then('the last event id should be "{expected}"') +def step_check_last_event_id(context: Context, expected: str) -> None: + assert context.ws_client.last_event_id == expected + + +# --------------------------------------------------------------------------- +# Heartbeat +# --------------------------------------------------------------------------- + + +@when("I handle a heartbeat") +def step_handle_heartbeat(context: Context) -> None: + context.ws_client.handle_heartbeat() + + +# --------------------------------------------------------------------------- +# Version negotiation +# --------------------------------------------------------------------------- + + +@when('I negotiate ws version "{version}"') +def step_negotiate_ws(context: Context, version: str) -> None: + context.ws_negotiated = context.ws_client.negotiate_version(version) + + +@then('the negotiated ws version should be "{expected}"') +def step_check_ws_version(context: Context, expected: str) -> None: + assert context.ws_negotiated == expected + + +# --------------------------------------------------------------------------- +# EventDeduplicator +# --------------------------------------------------------------------------- + + +@given("an EventDeduplicator with capacity {n:d}") +def step_dedup(context: Context, n: int) -> None: + context.dedup = EventDeduplicator(capacity=n) + context.dedup_result = False + + +@when('I check event "{event_id}" for duplicate') +def step_check_dedup(context: Context, event_id: str) -> None: + context.dedup_result = context.dedup.is_duplicate(event_id) + + +@then("the dedup result should be not duplicate") +def step_not_dup(context: Context) -> None: + assert context.dedup_result is False + + +@then("the dedup result should be duplicate") +def step_is_dup(context: Context) -> None: + assert context.dedup_result is True + + +@when('I add events "{a}" and "{b}" and "{c}" to the deduplicator') +def step_add_events(context: Context, a: str, b: str, c: str) -> None: + context.dedup.is_duplicate(a) + context.dedup.is_duplicate(b) + context.dedup.is_duplicate(c) + + +@then('event "{event_id}" should not be tracked') +def step_not_tracked(context: Context, event_id: str) -> None: + # If it's not tracked, calling is_duplicate should return False + # (it will also add it, but that's fine for the test) + assert event_id not in context.dedup._seen + + +@then("the deduplicator size should be {expected:d}") +def step_dedup_size(context: Context, expected: int) -> None: + assert context.dedup.size == expected + + +@when("I clear the deduplicator") +def step_clear_dedup(context: Context) -> None: + context.dedup.clear() + + +# --------------------------------------------------------------------------- +# ConnectionState +# --------------------------------------------------------------------------- + + +@given('a ConnectionState with connected true and last_event_id "{eid}"') +def step_conn_state(context: Context, eid: str) -> None: + context.conn_state = ConnectionState() + context.conn_state.connected = True + context.conn_state.last_event_id = eid + + +@when("I reset the connection state") +def step_reset_state(context: Context) -> None: + context.conn_state.reset() + + +@then("the connection state should be disconnected") +def step_state_disconnected(context: Context) -> None: + assert context.conn_state.connected is False + + +@then("the connection state last_event_id should be empty") +def step_state_no_event_id(context: Context) -> None: + assert context.conn_state.last_event_id == "" + + +# --------------------------------------------------------------------------- +# Backoff +# --------------------------------------------------------------------------- + + +@when("I compute ws backoff delay for attempt {a:d} base {b:g} max {m:g}") +def step_ws_backoff(context: Context, a: int, b: float, m: float) -> None: + context.ws_delay = _ws_backoff_delay(a, b, m) + + +@then("the ws delay should be {expected:g}") +def step_check_ws_delay(context: Context, expected: float) -> None: + assert context.ws_delay == expected diff --git a/pyproject.toml b/pyproject.toml index d276475a4..167d031ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dependencies = [ "RestrictedPython>=7.0", # Secure sandbox for user-supplied code "jsonschema>=4.20.0", # JSON Schema validation for tool inputs/outputs "tomlkit>=0.13.0", # TOML writing with comment preservation for config CLI + "httpx>=0.27.0", # HTTP client for server communication + "websockets>=12.0", # WebSocket client for real-time updates ] [project.optional-dependencies] diff --git a/robot/helper_plan_sync.py b/robot/helper_plan_sync.py new file mode 100644 index 000000000..558aa8b3c --- /dev/null +++ b/robot/helper_plan_sync.py @@ -0,0 +1,140 @@ +"""Helper script for plan_sync.robot integration tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import httpx + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.client.http_client import ServerHttpClient # noqa: E402 +from cleveragents.client.sync_client import ( # noqa: E402 + ConflictPolicy, + ExecutionResult, + PlanSyncClient, + SyncScope, + SyncSummary, +) + + +def _mock_response( + status: int = 200, body: dict | list | None = None +) -> httpx.Response: + return httpx.Response( + status_code=status, + json=body, + headers={}, + request=httpx.Request("GET", "http://mock"), + ) + + +def scope_active() -> None: + scope = SyncScope(actions=True, skills=False, tools=True, projects=False) + types = scope.active_types() + assert types == ["actions", "tools"], f"Got {types}" + print("plan-sync-scope-ok") + + +def sync_create() -> None: + mock_http = MagicMock(spec=ServerHttpClient) + mock_http._request.return_value = _mock_response(200, {"id": "srv-new"}) + client = PlanSyncClient(mock_http) + items = [{"id": "a1"}, {"id": "a2"}] + summary = client.sync(items, "actions") + assert summary.created == 2 + print("plan-sync-create-ok") + + +def sync_dry_run() -> None: + mock_http = MagicMock(spec=ServerHttpClient) + client = PlanSyncClient(mock_http) + items = [{"id": "a1"}] + summary = client.sync(items, "actions", dry_run=True) + assert summary.dry_run is True + assert summary.created == 1 + print("plan-sync-dry-run-ok") + + +def execute_plan() -> None: + mock_http = MagicMock(spec=ServerHttpClient) + mock_http._request.return_value = _mock_response( + 200, {"server_plan_id": "srv-001", "status": "submitted"} + ) + client = PlanSyncClient(mock_http) + result = client.execute_plan("plan-001") + assert result.plan_id == "plan-001" + assert result.status == "submitted" + print("plan-sync-execute-ok") + + +def apply_plan() -> None: + mock_http = MagicMock(spec=ServerHttpClient) + mock_http._request.return_value = _mock_response( + 200, {"server_plan_id": "srv-002", "status": "applying"} + ) + client = PlanSyncClient(mock_http) + result = client.apply_plan("plan-002") + assert result.status == "applying" + print("plan-sync-apply-ok") + + +def plan_status() -> None: + mock_http = MagicMock(spec=ServerHttpClient) + mock_http._request.return_value = _mock_response( + 200, {"phase": "running", "progress": 50} + ) + client = PlanSyncClient(mock_http) + status = client.get_plan_status("srv-001") + assert status["phase"] == "running" + print("plan-sync-status-ok") + + +def conflict_policy() -> None: + mock_http = MagicMock(spec=ServerHttpClient) + client = PlanSyncClient(mock_http, conflict_policy=ConflictPolicy.LOCAL_WINS) + assert client.conflict_policy == ConflictPolicy.LOCAL_WINS + client.conflict_policy = ConflictPolicy.SERVER_WINS + assert client.conflict_policy == ConflictPolicy.SERVER_WINS + print("plan-sync-conflict-policy-ok") + + +def summary_total() -> None: + s = SyncSummary(created=2, updated=1, skipped=1, errors=1) + assert s.total_processed == 5 + print("plan-sync-summary-total-ok") + + +def exec_result_attrs() -> None: + r = ExecutionResult(plan_id="p1", server_plan_id="sp1", status="done", message="ok") + assert r.message == "ok" + assert r.server_plan_id == "sp1" + print("plan-sync-exec-result-ok") + + +_COMMANDS = { + "scope-active": scope_active, + "sync-create": sync_create, + "sync-dry-run": sync_dry_run, + "execute-plan": execute_plan, + "apply-plan": apply_plan, + "plan-status": plan_status, + "conflict-policy": conflict_policy, + "summary-total": summary_total, + "exec-result-attrs": exec_result_attrs, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/helper_remote_project.py b/robot/helper_remote_project.py new file mode 100644 index 000000000..a0c063759 --- /dev/null +++ b/robot/helper_remote_project.py @@ -0,0 +1,128 @@ +"""Helper script for remote_project.robot integration tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import httpx # noqa: E402 + +from cleveragents.client.http_client import ( # noqa: E402 + PageResult, + ServerHttpClient, +) +from cleveragents.client.remote_project import ( # noqa: E402 + RemoteProject, + RemoteProjectClient, +) +from cleveragents.core.exceptions import ResourceNotFoundError # noqa: E402 + +_ITEMS = [ + {"id": "rp1", "name": "alpha", "alias": "a", "description": "A"}, + {"id": "rp2", "name": "beta", "alias": "b", "description": "B"}, +] + + +def _build() -> tuple[RemoteProjectClient, MagicMock]: + mock = MagicMock(spec=ServerHttpClient) + mock.list_endpoint.return_value = PageResult( + items=_ITEMS, page=1, per_page=50, total=2 + ) + return RemoteProjectClient(mock), mock + + +def list_projects() -> None: + client, _ = _build() + projects = client.list_projects() + assert len(projects) == 2 + assert projects[0].name == "alpha" + print("remote-project-list-ok") + + +def get_project() -> None: + client, _ = _build() + p = client.get_project("alpha") + assert p.name == "alpha" + print("remote-project-get-ok") + + +def get_by_alias() -> None: + client, _ = _build() + p = client.get_project("a") + assert p.name == "alpha" + print("remote-project-alias-ok") + + +def not_found() -> None: + client, _ = _build() + try: + client.get_project("nope") + print("FAIL: should have raised", file=sys.stderr) + sys.exit(1) + except ResourceNotFoundError: + pass + print("remote-project-not-found-ok") + + +def request_execution() -> None: + client, mock = _build() + mock._request.return_value = httpx.Response( + status_code=200, + json={"status": "submitted"}, + headers={}, + request=httpx.Request("POST", "http://mock"), + ) + result = client.request_execution("rp1") + assert result["status"] == "submitted" + print("remote-project-exec-ok") + + +def cache_invalidate() -> None: + client, _ = _build() + client.list_projects() + assert client.cache_size == 1 + client.invalidate_cache() + assert client.cache_size == 0 + print("remote-project-cache-ok") + + +def project_attrs() -> None: + p = RemoteProject( + project_id="rp1", + name="test", + namespace="ns", + alias="t", + description="desc", + ) + assert p.project_id == "rp1" + assert p.alias == "t" + assert p.description == "desc" + print("remote-project-attrs-ok") + + +_COMMANDS = { + "list-projects": list_projects, + "get-project": get_project, + "get-by-alias": get_by_alias, + "not-found": not_found, + "request-execution": request_execution, + "cache-invalidate": cache_invalidate, + "project-attrs": project_attrs, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + cmds = "|".join(_COMMANDS) + print(f"Usage: {sys.argv[0]} <{cmds}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/helper_server_http_client.py b/robot/helper_server_http_client.py new file mode 100644 index 000000000..6425b1a7f --- /dev/null +++ b/robot/helper_server_http_client.py @@ -0,0 +1,184 @@ +"""Helper script for server_http_client.robot integration tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import httpx + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.client.exceptions import ( # noqa: E402 + ServerConnectionError, + ServerTimeoutError, + ServerVersionMismatchError, +) +from cleveragents.client.http_client import ( # noqa: E402 + ServerHttpClient, + _redact_headers, +) + + +def _mock_response( + status: int = 200, + body: dict | list | None = None, + headers: dict | None = None, +) -> httpx.Response: + return httpx.Response( + status_code=status, + json=body, + headers=headers or {}, + request=httpx.Request("GET", "http://mock"), + ) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def client_create() -> None: + """Verify client construction and properties.""" + client = ServerHttpClient( + base_url="https://test.example.com", + api_token="tok_testkey1234567890abc", + tls_verify=True, + timeout=15.0, + ) + assert client.base_url == "https://test.example.com" + assert client.tls_verify is True + assert client.timeout == 15.0 + client.close() + print("server-http-client-create-ok") + + +def health_check() -> None: + """Verify health check with mock.""" + client = ServerHttpClient() + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = _mock_response(200, {"status": "healthy"}) + result = client.health_check() + assert result is True + print("server-http-client-health-ok") + + +def health_check_fail() -> None: + """Verify health check failure.""" + client = ServerHttpClient(max_retries=1) + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.side_effect = httpx.ConnectError("refused") + result = client.health_check() + assert result is False + print("server-http-client-health-fail-ok") + + +def version() -> None: + """Verify version retrieval.""" + client = ServerHttpClient() + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = _mock_response(200, {"version": "1.2.3"}) + result = client.get_version() + assert result == "1.2.3" + print("server-http-client-version-ok") + + +def negotiate() -> None: + """Verify version negotiation.""" + client = ServerHttpClient() + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = _mock_response(200, {"negotiated_version": "1.0"}) + result = client.negotiate_version() + assert result == "1.0" + print("server-http-client-negotiate-ok") + + +def pagination() -> None: + """Verify paginated list endpoint.""" + client = ServerHttpClient() + items = [{"id": str(i)} for i in range(3)] + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = _mock_response( + 200, {"items": items, "total": 3, "has_next": False} + ) + result = client.list_endpoint("/items", page=1, per_page=10) + assert len(result.items) == 3 + assert result.total == 3 + print("server-http-client-pagination-ok") + + +def error_503() -> None: + """Verify 503 maps to AcpNotAvailableError.""" + from cleveragents.acp.errors import AcpNotAvailableError + + client = ServerHttpClient(max_retries=1) + with patch("cleveragents.client.http_client.httpx.request") as mock_req: + mock_req.return_value = _mock_response(503, {"error": "unavailable"}) + try: + client.get_version() + print("FAIL: should have raised", file=sys.stderr) + sys.exit(1) + except AcpNotAvailableError: + pass + print("server-http-client-error-503-ok") + + +def redaction() -> None: + """Verify auth header redaction.""" + headers = { + "Authorization": "Bearer tok_secret123abcdefghij", + "Accept": "application/json", + } + redacted = _redact_headers(headers) + assert redacted["Authorization"] == "***REDACTED***" + assert redacted["Accept"] == "application/json" + print("server-http-client-redaction-ok") + + +def exceptions() -> None: + """Verify exception attributes.""" + err1 = ServerConnectionError("test", url="https://x.com", cause=RuntimeError("x")) + assert err1.url == "https://x.com" + assert err1.cause is not None + + err2 = ServerTimeoutError("timeout", timeout_seconds=5.0) + assert err2.timeout_seconds == 5.0 + + err3 = ServerVersionMismatchError( + "mm", client_version="2.0", server_versions=["1.0"] + ) + assert err3.client_version == "2.0" + assert "1.0" in err3.server_versions + + print("server-http-client-exceptions-ok") + + +_COMMANDS = { + "client-create": client_create, + "health-check": health_check, + "health-check-fail": health_check_fail, + "version": version, + "negotiate": negotiate, + "pagination": pagination, + "error-503": error_503, + "redaction": redaction, + "exceptions": exceptions, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/helper_websocket_updates.py b/robot/helper_websocket_updates.py new file mode 100644 index 000000000..d4a655d55 --- /dev/null +++ b/robot/helper_websocket_updates.py @@ -0,0 +1,162 @@ +"""Helper script for websocket_updates.robot integration tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.acp.models import AcpEvent # noqa: E402 +from cleveragents.client.exceptions import ServerConnectionError # noqa: E402 +from cleveragents.client.ws_client import ( # noqa: E402 + ConnectionState, + EventDeduplicator, + WebSocketClient, +) + + +def connect_disconnect() -> None: + """Verify connect / disconnect lifecycle.""" + client = WebSocketClient() + assert client.connected is False + client.connect() + assert client.connected is True + client.disconnect() + assert client.connected is False + print("ws-connect-disconnect-ok") + + +def subscribe_event() -> None: + """Verify event subscription and dispatch.""" + client = WebSocketClient() + client.connect() + received: list[AcpEvent] = [] + client.subscribe(received.append) + event = AcpEvent( + event_id="evt-sub-1", + event_type="plan.status", + data={"status": "running"}, + ) + dispatched = client.process_event(event) + assert dispatched is True + assert len(received) == 1 + assert received[0].event_id == "evt-sub-1" + print("ws-subscribe-event-ok") + + +def dedup_event() -> None: + """Verify duplicate event detection.""" + client = WebSocketClient() + client.connect() + received: list[AcpEvent] = [] + client.subscribe(received.append) + event = AcpEvent( + event_id="evt-dup-robot", + event_type="plan.status", + data={}, + ) + client.process_event(event) + dup = client.process_event(event) + assert dup is False + assert len(received) == 1 + print("ws-dedup-event-ok") + + +def reconnect() -> None: + """Verify reconnection with backoff.""" + client = WebSocketClient(max_reconnects=3, reconnect_base=0.001, reconnect_max=0.01) + client.connect() + client._state.connected = False + result = client.reconnect() + assert result is True + assert client.reconnect_count == 1 + print("ws-reconnect-ok") + + +def reconnect_exhaust() -> None: + """Verify exhausted reconnects raise error.""" + client = WebSocketClient(max_reconnects=1, reconnect_base=0.001, reconnect_max=0.01) + client.connect() + client._state.connected = False + client.reconnect() + client._state.connected = False + try: + client.reconnect() + print("FAIL: should have raised", file=sys.stderr) + sys.exit(1) + except ServerConnectionError: + pass + print("ws-reconnect-exhaust-ok") + + +def heartbeat() -> None: + """Verify heartbeat resets reconnect counter.""" + client = WebSocketClient(max_reconnects=5, reconnect_base=0.001, reconnect_max=0.01) + client.connect() + client._state.connected = False + client.reconnect() + assert client.reconnect_count == 1 + client.handle_heartbeat() + assert client.reconnect_count == 0 + print("ws-heartbeat-ok") + + +def deduplicator() -> None: + """Verify EventDeduplicator behavior.""" + d = EventDeduplicator(capacity=2) + assert d.is_duplicate("a") is False + assert d.is_duplicate("b") is False + assert d.is_duplicate("c") is False # evicts "a" + assert "a" not in d._seen + assert d.size == 2 + d.clear() + assert d.size == 0 + print("ws-deduplicator-ok") + + +def connection_state() -> None: + """Verify ConnectionState reset.""" + s = ConnectionState() + s.connected = True + s.last_event_id = "ev1" + s.reset() + assert s.connected is False + assert s.last_event_id == "" + print("ws-connection-state-ok") + + +def version_negotiate() -> None: + """Verify version negotiation.""" + client = WebSocketClient() + v = client.negotiate_version("1.0") + assert v == "1.0" + assert client.state.negotiated_version == "1.0" + print("ws-version-negotiate-ok") + + +_COMMANDS = { + "connect-disconnect": connect_disconnect, + "subscribe-event": subscribe_event, + "dedup-event": dedup_event, + "reconnect": reconnect, + "reconnect-exhaust": reconnect_exhaust, + "heartbeat": heartbeat, + "deduplicator": deduplicator, + "connection-state": connection_state, + "version-negotiate": version_negotiate, +} + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + cmds = "|".join(_COMMANDS) + print(f"Usage: {sys.argv[0]} <{cmds}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/plan_sync.robot b/robot/plan_sync.robot new file mode 100644 index 000000000..d7ae03118 --- /dev/null +++ b/robot/plan_sync.robot @@ -0,0 +1,81 @@ +*** Settings *** +Documentation Integration tests for Plan Sync client +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_plan_sync.py + +*** Test Cases *** +Plan Sync Scope Active Types + [Documentation] Verify SyncScope active_types + ${result}= Run Process ${PYTHON} ${HELPER} scope-active cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-scope-ok + +Plan Sync Create Items + [Documentation] Verify sync creates new items + ${result}= Run Process ${PYTHON} ${HELPER} sync-create cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-create-ok + +Plan Sync Dry Run + [Documentation] Verify dry run mode + ${result}= Run Process ${PYTHON} ${HELPER} sync-dry-run cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-dry-run-ok + +Plan Sync Execute Plan + [Documentation] Verify remote plan execution + ${result}= Run Process ${PYTHON} ${HELPER} execute-plan cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-execute-ok + +Plan Sync Apply Plan + [Documentation] Verify remote plan apply + ${result}= Run Process ${PYTHON} ${HELPER} apply-plan cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-apply-ok + +Plan Sync Get Status + [Documentation] Verify plan status retrieval + ${result}= Run Process ${PYTHON} ${HELPER} plan-status cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-status-ok + +Plan Sync Conflict Policy + [Documentation] Verify conflict policy change + ${result}= Run Process ${PYTHON} ${HELPER} conflict-policy cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-conflict-policy-ok + +Plan Sync Summary Total + [Documentation] Verify SyncSummary total_processed + ${result}= Run Process ${PYTHON} ${HELPER} summary-total cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-summary-total-ok + +Plan Sync Execution Result Attributes + [Documentation] Verify ExecutionResult attributes + ${result}= Run Process ${PYTHON} ${HELPER} exec-result-attrs cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-sync-exec-result-ok diff --git a/robot/remote_project.robot b/robot/remote_project.robot new file mode 100644 index 000000000..e22e19e5f --- /dev/null +++ b/robot/remote_project.robot @@ -0,0 +1,65 @@ +*** Settings *** +Documentation Integration tests for Remote Project client +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_remote_project.py + +*** Test Cases *** +Remote Project List + [Documentation] Verify listing remote projects + ${result}= Run Process ${PYTHON} ${HELPER} list-projects cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-list-ok + +Remote Project Get By Name + [Documentation] Verify getting project by name + ${result}= Run Process ${PYTHON} ${HELPER} get-project cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-get-ok + +Remote Project Get By Alias + [Documentation] Verify getting project by alias + ${result}= Run Process ${PYTHON} ${HELPER} get-by-alias cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-alias-ok + +Remote Project Not Found + [Documentation] Verify ResourceNotFoundError for missing project + ${result}= Run Process ${PYTHON} ${HELPER} not-found cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-not-found-ok + +Remote Project Request Execution + [Documentation] Verify requesting execution of remote project + ${result}= Run Process ${PYTHON} ${HELPER} request-execution cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-exec-ok + +Remote Project Cache Invalidate + [Documentation] Verify cache invalidation + ${result}= Run Process ${PYTHON} ${HELPER} cache-invalidate cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-cache-ok + +Remote Project Attributes + [Documentation] Verify RemoteProject attributes + ${result}= Run Process ${PYTHON} ${HELPER} project-attrs cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} remote-project-attrs-ok diff --git a/robot/server_http_client.robot b/robot/server_http_client.robot new file mode 100644 index 000000000..53719aded --- /dev/null +++ b/robot/server_http_client.robot @@ -0,0 +1,81 @@ +*** Settings *** +Documentation Integration tests for Server HTTP client +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_server_http_client.py + +*** Test Cases *** +Server HTTP Client Create + [Documentation] Verify client construction and property access + ${result}= Run Process ${PYTHON} ${HELPER} client-create cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-create-ok + +Server HTTP Client Health Check + [Documentation] Verify health check with mock healthy server + ${result}= Run Process ${PYTHON} ${HELPER} health-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-health-ok + +Server HTTP Client Health Check Failure + [Documentation] Verify health check returns false for unreachable server + ${result}= Run Process ${PYTHON} ${HELPER} health-check-fail cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-health-fail-ok + +Server HTTP Client Version + [Documentation] Verify version retrieval + ${result}= Run Process ${PYTHON} ${HELPER} version cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-version-ok + +Server HTTP Client Negotiate + [Documentation] Verify version negotiation + ${result}= Run Process ${PYTHON} ${HELPER} negotiate cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-negotiate-ok + +Server HTTP Client Pagination + [Documentation] Verify paginated list endpoint + ${result}= Run Process ${PYTHON} ${HELPER} pagination cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-pagination-ok + +Server HTTP Client Error 503 + [Documentation] Verify 503 maps to AcpNotAvailableError + ${result}= Run Process ${PYTHON} ${HELPER} error-503 cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-error-503-ok + +Server HTTP Client Auth Redaction + [Documentation] Verify auth header redaction + ${result}= Run Process ${PYTHON} ${HELPER} redaction cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-redaction-ok + +Server HTTP Client Exceptions + [Documentation] Verify client exception attributes + ${result}= Run Process ${PYTHON} ${HELPER} exceptions cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-http-client-exceptions-ok diff --git a/robot/websocket_updates.robot b/robot/websocket_updates.robot new file mode 100644 index 000000000..f56a87bb8 --- /dev/null +++ b/robot/websocket_updates.robot @@ -0,0 +1,81 @@ +*** Settings *** +Documentation Integration tests for WebSocket updates client +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_websocket_updates.py + +*** Test Cases *** +WS Connect Disconnect + [Documentation] Verify connect and disconnect lifecycle + ${result}= Run Process ${PYTHON} ${HELPER} connect-disconnect cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-connect-disconnect-ok + +WS Subscribe Event + [Documentation] Verify event subscription and dispatch + ${result}= Run Process ${PYTHON} ${HELPER} subscribe-event cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-subscribe-event-ok + +WS Dedup Event + [Documentation] Verify duplicate event detection + ${result}= Run Process ${PYTHON} ${HELPER} dedup-event cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-dedup-event-ok + +WS Reconnect + [Documentation] Verify reconnection with backoff + ${result}= Run Process ${PYTHON} ${HELPER} reconnect cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-reconnect-ok + +WS Reconnect Exhaust + [Documentation] Verify exhausted reconnects raise error + ${result}= Run Process ${PYTHON} ${HELPER} reconnect-exhaust cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-reconnect-exhaust-ok + +WS Heartbeat + [Documentation] Verify heartbeat resets reconnect counter + ${result}= Run Process ${PYTHON} ${HELPER} heartbeat cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-heartbeat-ok + +WS Deduplicator + [Documentation] Verify EventDeduplicator behavior + ${result}= Run Process ${PYTHON} ${HELPER} deduplicator cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-deduplicator-ok + +WS Connection State + [Documentation] Verify ConnectionState reset + ${result}= Run Process ${PYTHON} ${HELPER} connection-state cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-connection-state-ok + +WS Version Negotiate + [Documentation] Verify version negotiation + ${result}= Run Process ${PYTHON} ${HELPER} version-negotiate cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} ws-version-negotiate-ok diff --git a/src/cleveragents/client/__init__.py b/src/cleveragents/client/__init__.py new file mode 100644 index 000000000..c2eee1366 --- /dev/null +++ b/src/cleveragents/client/__init__.py @@ -0,0 +1,9 @@ +"""Client package for server communication. + +Provides HTTP, sync, WebSocket, and remote project clients +for interacting with CleverAgents server instances. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/src/cleveragents/client/exceptions.py b/src/cleveragents/client/exceptions.py new file mode 100644 index 000000000..5f15466a7 --- /dev/null +++ b/src/cleveragents/client/exceptions.py @@ -0,0 +1,96 @@ +"""Client-specific exceptions for server communication. + +Defines typed error hierarchy for HTTP, timeout, and version +mismatch failures that arise when communicating with a remote +CleverAgents server instance. +""" + +from __future__ import annotations + +from typing import Any + +from cleveragents.core.exceptions import CleverAgentsError + + +class ServerConnectionError(CleverAgentsError): + """Raised when a connection to the server cannot be established. + + Attributes: + url: The server URL that was unreachable. + cause: Optional underlying exception. + """ + + def __init__( + self, + message: str, + url: str = "", + cause: Exception | None = None, + details: dict[str, Any] | None = None, + ) -> None: + merged: dict[str, Any] = {"url": url} + if cause is not None: + merged["cause"] = str(cause) + if details: + merged.update(details) + super().__init__(message, merged) + self.url = url + self.cause = cause + + +class ServerTimeoutError(CleverAgentsError): + """Raised when a request to the server exceeds the configured timeout. + + Attributes: + url: The endpoint that timed out. + timeout_seconds: The configured timeout value. + """ + + def __init__( + self, + message: str, + url: str = "", + timeout_seconds: float = 0.0, + details: dict[str, Any] | None = None, + ) -> None: + merged: dict[str, Any] = { + "url": url, + "timeout_seconds": timeout_seconds, + } + if details: + merged.update(details) + super().__init__(message, merged) + self.url = url + self.timeout_seconds = timeout_seconds + + +class ServerVersionMismatchError(CleverAgentsError): + """Raised when client and server protocol versions are incompatible. + + Attributes: + client_version: The version requested by the client. + server_versions: Versions supported by the server. + """ + + def __init__( + self, + message: str, + client_version: str = "", + server_versions: list[str] | None = None, + details: dict[str, Any] | None = None, + ) -> None: + merged: dict[str, Any] = { + "client_version": client_version, + "server_versions": server_versions or [], + } + if details: + merged.update(details) + super().__init__(message, merged) + self.client_version = client_version + self.server_versions = server_versions or [] + + +__all__ = [ + "ServerConnectionError", + "ServerTimeoutError", + "ServerVersionMismatchError", +] diff --git a/src/cleveragents/client/http_client.py b/src/cleveragents/client/http_client.py new file mode 100644 index 000000000..547f8188d --- /dev/null +++ b/src/cleveragents/client/http_client.py @@ -0,0 +1,499 @@ +"""Server HTTP client for CleverAgents server communication. + +Provides :class:`ServerHttpClient` which wraps ``httpx`` to communicate +with a remote CleverAgents server. Features include health checks, +version negotiation, pagination helpers, retry with exponential backoff, +request/response logging with auth-header redaction, and a TLS +verification toggle. +""" + +from __future__ import annotations + +import math +import time +from typing import Any + +import httpx +import structlog + +from cleveragents.acp.errors import AcpNotAvailableError +from cleveragents.acp.models import AcpVersion +from cleveragents.client.exceptions import ( + ServerConnectionError, + ServerTimeoutError, + ServerVersionMismatchError, +) + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_BASE_URL = "http://localhost:8080" +_DEFAULT_TIMEOUT = 30.0 +_DEFAULT_MAX_RETRIES = 3 +_DEFAULT_BACKOFF_BASE = 0.5 +_DEFAULT_BACKOFF_MAX = 30.0 +_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) +_AUTH_HEADER = "authorization" +_REDACTED = "***REDACTED***" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _redact_headers(headers: dict[str, str]) -> dict[str, str]: + """Return a copy of *headers* with the Authorization value masked.""" + result: dict[str, str] = {} + for key, value in headers.items(): + if key.lower() == _AUTH_HEADER: + result[key] = _REDACTED + else: + result[key] = value + return result + + +def _backoff_delay(attempt: int, base: float, maximum: float) -> float: + """Compute exponential-backoff delay capped at *maximum*.""" + delay = base * math.pow(2, attempt) + return min(delay, maximum) + + +# --------------------------------------------------------------------------- +# Page result container +# --------------------------------------------------------------------------- + + +class PageResult: + """Container for a single page of results from a list endpoint. + + Attributes: + items: The list of items on this page. + page: The current page number (1-based). + per_page: Number of items per page. + total: Total number of items across all pages (``-1`` if unknown). + has_next: Whether a next page is available. + """ + + __slots__ = ("has_next", "items", "page", "per_page", "total") + + def __init__( + self, + items: list[dict[str, Any]], + page: int = 1, + per_page: int = 50, + total: int = -1, + has_next: bool = False, + ) -> None: + self.items = items + self.page = page + self.per_page = per_page + self.total = total + self.has_next = has_next + + +# --------------------------------------------------------------------------- +# ServerHttpClient +# --------------------------------------------------------------------------- + + +class ServerHttpClient: + """HTTP client for communicating with a CleverAgents server. + + The client wraps :mod:`httpx` and provides: + + * ``health_check()`` — ``GET /health`` + * ``get_version()`` — ``GET /version`` + * ``negotiate_version()`` — ``POST /version/negotiate`` + * ``list_endpoint()`` — generic paginated ``GET`` + * Retry with exponential backoff for idempotent methods + * Auth-header redaction in logs + * TLS verification toggle with a warning when disabled + + Args: + base_url: Server root URL. + api_token: Bearer token for authentication. + tls_verify: Whether to verify TLS certificates. + timeout: Per-request timeout in seconds. + max_retries: Maximum retry attempts for idempotent calls. + backoff_base: Base delay (seconds) for exponential backoff. + backoff_max: Maximum backoff delay (seconds). + """ + + def __init__( + self, + base_url: str = _DEFAULT_BASE_URL, + api_token: str | None = None, + tls_verify: bool = True, + timeout: float = _DEFAULT_TIMEOUT, + max_retries: int = _DEFAULT_MAX_RETRIES, + backoff_base: float = _DEFAULT_BACKOFF_BASE, + backoff_max: float = _DEFAULT_BACKOFF_MAX, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_token = api_token + self._tls_verify = tls_verify + self._timeout = timeout + self._max_retries = max_retries + self._backoff_base = backoff_base + self._backoff_max = backoff_max + + if not tls_verify: + logger.warning( + "TLS verification is disabled — connections are not secure", + base_url=self._base_url, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_headers(self) -> dict[str, str]: + """Build default request headers including auth if configured.""" + headers: dict[str, str] = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if self._api_token: + headers["Authorization"] = f"Bearer {self._api_token}" + return headers + + def _log_request(self, method: str, url: str, headers: dict[str, str]) -> None: + """Log an outbound request with redacted auth headers.""" + logger.debug( + "server_request", + method=method, + url=url, + headers=_redact_headers(headers), + ) + + def _log_response(self, method: str, url: str, status: int, elapsed: float) -> None: + """Log a completed response.""" + logger.debug( + "server_response", + method=method, + url=url, + status=status, + elapsed_ms=round(elapsed * 1000, 2), + ) + + def _is_retryable(self, method: str, status_code: int) -> bool: + """Return ``True`` if the request should be retried.""" + if method.upper() not in _IDEMPOTENT_METHODS: + return False + return status_code in {429, 500, 502, 504} + + def _map_error_response(self, response: httpx.Response, url: str) -> None: + """Raise a domain exception for non-2xx responses. + + Maps HTTP status codes to client exceptions or + :class:`AcpNotAvailableError`. + """ + status = response.status_code + try: + body = response.json() + except Exception: + body = {"raw": response.text} + + retry_hint = "" + if status == 429: + retry_after = response.headers.get("Retry-After", "") + retry_hint = f" (retry after {retry_after}s)" if retry_after else "" + + msg = f"Server returned {status} for {url}{retry_hint}" + details: dict[str, Any] = {"status": status, "body": body} + + if status == 503: + raise AcpNotAvailableError( + message=msg, + details=details, + ) + if status in {502, 504}: + raise ServerConnectionError( + message=msg, + url=url, + details=details, + ) + if status == 401 or status == 403: + raise ServerConnectionError( + message=f"Authentication failed ({status}) for {url}", + url=url, + details=details, + ) + raise ServerConnectionError(message=msg, url=url, details=details) + + def _request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> httpx.Response: + """Execute an HTTP request with retry for idempotent methods.""" + url = f"{self._base_url}{path}" + headers = self._build_headers() + self._log_request(method, url, headers) + + last_exc: Exception | None = None + attempts = self._max_retries if method.upper() in _IDEMPOTENT_METHODS else 1 + + for attempt in range(attempts): + start = time.monotonic() + try: + response = httpx.request( + method, + url, + headers=headers, + json=json_body, + params=params, + timeout=self._timeout, + verify=self._tls_verify, + ) + elapsed = time.monotonic() - start + self._log_response(method, url, response.status_code, elapsed) + + if response.is_success: + return response + + if self._is_retryable(method, response.status_code): + last_exc = ServerConnectionError( + message=f"Retryable {response.status_code}", + url=url, + ) + delay = _backoff_delay( + attempt, self._backoff_base, self._backoff_max + ) + logger.info( + "retrying_request", + attempt=attempt + 1, + delay=delay, + status=response.status_code, + ) + time.sleep(delay) + continue + + self._map_error_response(response, url) + + except httpx.TimeoutException as exc: + last_exc = ServerTimeoutError( + message=f"Request to {url} timed out after {self._timeout}s", + url=url, + timeout_seconds=self._timeout, + ) + if attempt + 1 < attempts: + delay = _backoff_delay( + attempt, self._backoff_base, self._backoff_max + ) + logger.info( + "retrying_timeout", + attempt=attempt + 1, + delay=delay, + ) + time.sleep(delay) + continue + raise last_exc from exc + + except httpx.ConnectError as exc: + last_exc = ServerConnectionError( + message=f"Cannot connect to {url}", + url=url, + cause=exc, + ) + if attempt + 1 < attempts: + delay = _backoff_delay( + attempt, self._backoff_base, self._backoff_max + ) + logger.info( + "retrying_connection", + attempt=attempt + 1, + delay=delay, + ) + time.sleep(delay) + continue + raise last_exc from exc + + except (ServerConnectionError, ServerTimeoutError, AcpNotAvailableError): + raise + + except httpx.HTTPError as exc: + raise ServerConnectionError( + message=f"HTTP error for {url}: {exc}", + url=url, + cause=exc, + ) from exc + + # Exhausted retries + if last_exc is not None: + raise last_exc + raise ServerConnectionError( # pragma: no cover + message=f"Request to {url} failed after {attempts} attempts", + url=url, + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def health_check(self) -> bool: + """Check server health via ``GET /health``. + + Returns: + ``True`` if the server reports a healthy status. + + Raises: + ServerConnectionError: On connection failure. + ServerTimeoutError: On request timeout. + """ + try: + response = self._request("GET", "/health") + data = response.json() + return bool(data.get("status") == "healthy") + except (ServerConnectionError, ServerTimeoutError, AcpNotAvailableError): + return False + + def get_version(self) -> str: + """Retrieve the server version string via ``GET /version``. + + Returns: + The semantic version string reported by the server. + + Raises: + ServerConnectionError: On connection failure. + ServerTimeoutError: On request timeout. + """ + response = self._request("GET", "/version") + data = response.json() + version: str = str(data.get("version", "")) + return version + + def negotiate_version(self, client_version: str = AcpVersion.CURRENT) -> str: + """Negotiate an ACP version with the server. + + Posts the client's preferred version and returns the version + agreed upon by the server. + + Args: + client_version: The ACP version this client supports. + + Returns: + The negotiated version string. + + Raises: + ServerVersionMismatchError: When negotiation fails. + ServerConnectionError: On connection failure. + """ + response = self._request( + "POST", + "/version/negotiate", + json_body={"client_version": client_version}, + ) + data = response.json() + negotiated = str(data.get("negotiated_version", "")) + if not negotiated: + raise ServerVersionMismatchError( + message="Server did not return a negotiated version", + client_version=client_version, + server_versions=data.get("supported_versions", []), + ) + return negotiated + + def list_endpoint( + self, + path: str, + *, + page: int = 1, + per_page: int = 50, + params: dict[str, str] | None = None, + ) -> PageResult: + """Fetch a paginated list endpoint. + + Args: + path: API path (e.g. ``/plans``). + page: Page number (1-based). + per_page: Items per page. + params: Additional query parameters. + + Returns: + A :class:`PageResult` with the decoded items. + """ + query: dict[str, str] = {"page": str(page), "per_page": str(per_page)} + if params: + query.update(params) + response = self._request("GET", path, params=query) + data = response.json() + + items: list[dict[str, Any]] + total: int + has_next: bool + if isinstance(data, list): + items = data + total = -1 + has_next = len(items) >= per_page + else: + items = data.get("items", []) + total = int(data.get("total", -1)) + has_next = bool(data.get("has_next", len(items) >= per_page)) + + return PageResult( + items=items, + page=page, + per_page=per_page, + total=total, + has_next=has_next, + ) + + @property + def base_url(self) -> str: + """The server base URL.""" + return self._base_url + + @property + def tls_verify(self) -> bool: + """Whether TLS verification is enabled.""" + return self._tls_verify + + @property + def timeout(self) -> float: + """Per-request timeout in seconds.""" + return self._timeout + + def close(self) -> None: + """Release any held resources (no-op for stateless client).""" + logger.debug("server_http_client_closed", base_url=self._base_url) + + +def create_client_from_settings() -> ServerHttpClient: + """Create a :class:`ServerHttpClient` from application settings. + + Returns: + A configured client instance. + + Raises: + ValueError: If ``server_base_url`` is not configured. + """ + from cleveragents.config.settings import get_settings + + settings = get_settings() + base_url = settings.server_base_url + if not base_url: + raise ValueError( + "server_base_url is not configured. " + "Set CLEVERAGENTS_SERVER_BASE_URL to the server address." + ) + return ServerHttpClient( + base_url=base_url, + api_token=settings.server_api_token, + tls_verify=settings.server_tls_verify, + timeout=settings.server_request_timeout, + ) + + +__all__ = [ + "PageResult", + "ServerHttpClient", + "create_client_from_settings", +] diff --git a/src/cleveragents/client/remote_project.py b/src/cleveragents/client/remote_project.py new file mode 100644 index 000000000..52a40ebec --- /dev/null +++ b/src/cleveragents/client/remote_project.py @@ -0,0 +1,298 @@ +"""Remote project client for server-hosted project access. + +Provides :class:`RemoteProjectClient` for listing, resolving, and +requesting execution of projects hosted on a remote CleverAgents +server. Features include namespace-aware project-name resolution, +TTL-based caching, and explicit errors when a remote project is +not found or inaccessible. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +import structlog + +from cleveragents.client.http_client import ServerHttpClient +from cleveragents.core.exceptions import ResourceNotFoundError + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_CACHE_TTL = 300.0 # 5 minutes + + +# --------------------------------------------------------------------------- +# Cache entry +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class _CacheEntry: + """Internal cache entry with expiry tracking.""" + + data: dict[str, Any] + expires_at: float + + +# --------------------------------------------------------------------------- +# Remote project metadata +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class RemoteProject: + """Metadata for a project hosted on a remote server. + + Attributes: + project_id: Server-assigned project identifier. + name: Human-readable project name. + namespace: Server namespace the project belongs to. + alias: Optional short alias for CLI usage. + description: Project description. + """ + + project_id: str + name: str + namespace: str = "default" + alias: str = "" + description: str = "" + + +# --------------------------------------------------------------------------- +# RemoteProjectClient +# --------------------------------------------------------------------------- + + +class RemoteProjectClient: + """Client for accessing and executing remote server-hosted projects. + + Args: + http_client: The underlying HTTP client for server communication. + cache_ttl: TTL in seconds for the project list cache. + """ + + def __init__( + self, + http_client: ServerHttpClient, + cache_ttl: float = _DEFAULT_CACHE_TTL, + ) -> None: + self._http = http_client + self._cache_ttl = cache_ttl + self._cache: dict[str, _CacheEntry] = {} + + @property + def cache_ttl(self) -> float: + """Cache TTL in seconds.""" + return self._cache_ttl + + # ------------------------------------------------------------------ + # Project listing + # ------------------------------------------------------------------ + + def list_projects( + self, + namespace: str = "default", + *, + force_refresh: bool = False, + ) -> list[RemoteProject]: + """List remote projects in a namespace. + + Results are cached for :attr:`cache_ttl` seconds unless + *force_refresh* is ``True``. + + Args: + namespace: Server namespace to query. + force_refresh: Bypass the cache and fetch from server. + + Returns: + List of :class:`RemoteProject` instances. + + Raises: + ServerConnectionError: On server communication failure. + """ + cache_key = f"projects:{namespace}" + + if not force_refresh: + cached = self._get_cached(cache_key) + if cached is not None: + return self._parse_projects(cached, namespace) + + page_result = self._http.list_endpoint( + "/projects", + params={"namespace": namespace}, + ) + items = page_result.items + self._set_cached(cache_key, items) + return self._parse_projects(items, namespace) + + def get_project( + self, + project_name: str, + namespace: str = "default", + ) -> RemoteProject: + """Resolve a project by name within a namespace. + + Args: + project_name: The project name or alias to look up. + namespace: Server namespace. + + Returns: + The matched :class:`RemoteProject`. + + Raises: + ResourceNotFoundError: When the project is not found. + ServerConnectionError: On server communication failure. + """ + if not project_name: + raise ValueError("project_name must be a non-empty string") + + projects = self.list_projects(namespace) + for project in projects: + if project.name == project_name or project.alias == project_name: + return project + + raise ResourceNotFoundError( + message=( + f"Remote project '{project_name}' not found in namespace '{namespace}'" + ), + resource_type="project", + resource_id=project_name, + ) + + def resolve_project( + self, + name_or_alias: str, + namespace: str = "default", + ) -> RemoteProject: + """Resolve a project name or alias, checking all namespaces. + + First checks the specified namespace, then falls back to + the ``"default"`` namespace if different. + + Args: + name_or_alias: Project name or alias. + namespace: Preferred namespace. + + Returns: + The matched :class:`RemoteProject`. + + Raises: + ResourceNotFoundError: When the project is not found. + """ + if not name_or_alias: + raise ValueError("name_or_alias must be a non-empty string") + + try: + return self.get_project(name_or_alias, namespace) + except ResourceNotFoundError: + if namespace != "default": + return self.get_project(name_or_alias, "default") + raise + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def request_execution( + self, + project_id: str, + plan_name: str = "", + ) -> dict[str, Any]: + """Request execution of a remote project plan. + + Args: + project_id: Server project identifier. + plan_name: Optional plan name (uses default if empty). + + Returns: + Execution metadata from the server. + + Raises: + ServerConnectionError: On server communication failure. + """ + if not project_id: + raise ValueError("project_id must be a non-empty string") + + resp = self._http._request( + "POST", + f"/projects/{project_id}/execute", + json_body={"plan_name": plan_name} if plan_name else None, + ) + data: dict[str, Any] = resp.json() + return data + + # ------------------------------------------------------------------ + # Cache management + # ------------------------------------------------------------------ + + def invalidate_cache(self, namespace: str | None = None) -> None: + """Invalidate cached project data. + + Args: + namespace: If given, only invalidate that namespace. + If ``None``, clear the entire cache. + """ + if namespace is None: + self._cache.clear() + logger.debug("remote_project_cache_cleared") + else: + key = f"projects:{namespace}" + self._cache.pop(key, None) + logger.debug( + "remote_project_cache_invalidated", + namespace=namespace, + ) + + @property + def cache_size(self) -> int: + """Number of cached entries.""" + return len(self._cache) + + def _get_cached(self, key: str) -> list[dict[str, Any]] | None: + """Return cached data if still valid, else ``None``.""" + entry = self._cache.get(key) + if entry is None: + return None + if time.monotonic() > entry.expires_at: + del self._cache[key] + return None + cached: list[dict[str, Any]] = entry.data.get("items", []) + return cached + + def _set_cached(self, key: str, items: list[dict[str, Any]]) -> None: + """Store data in cache with TTL.""" + self._cache[key] = _CacheEntry( + data={"items": items}, + expires_at=time.monotonic() + self._cache_ttl, + ) + + @staticmethod + def _parse_projects( + items: list[dict[str, Any]], + namespace: str, + ) -> list[RemoteProject]: + """Convert raw dicts to :class:`RemoteProject` instances.""" + projects: list[RemoteProject] = [] + for item in items: + projects.append( + RemoteProject( + project_id=str(item.get("id", "")), + name=str(item.get("name", "")), + namespace=namespace, + alias=str(item.get("alias", "")), + description=str(item.get("description", "")), + ) + ) + return projects + + +__all__ = [ + "RemoteProject", + "RemoteProjectClient", +] diff --git a/src/cleveragents/client/sync_client.py b/src/cleveragents/client/sync_client.py new file mode 100644 index 000000000..025ac304f --- /dev/null +++ b/src/cleveragents/client/sync_client.py @@ -0,0 +1,379 @@ +"""Plan sync and remote execution client. + +Provides :class:`PlanSyncClient` for synchronizing local plans, actions, +skills, tools, and projects with a remote CleverAgents server. Supports +conflict resolution policies, scope flags, dry-run mode, and server-side +ID persistence. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import structlog + +from cleveragents.client.exceptions import ServerConnectionError +from cleveragents.client.http_client import ServerHttpClient + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Conflict resolution policy +# --------------------------------------------------------------------------- + + +class ConflictPolicy(Enum): + """Strategy for resolving conflicts between local and server state.""" + + LOCAL_WINS = "local_wins" + SERVER_WINS = "server_wins" + + +# --------------------------------------------------------------------------- +# Sync scope +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class SyncScope: + """Flags controlling which resource types are included in a sync. + + Attributes: + actions: Sync action definitions. + skills: Sync skill definitions. + tools: Sync tool definitions. + projects: Sync project metadata. + """ + + actions: bool = True + skills: bool = True + tools: bool = True + projects: bool = True + + def active_types(self) -> list[str]: + """Return the list of active resource type names.""" + types: list[str] = [] + if self.actions: + types.append("actions") + if self.skills: + types.append("skills") + if self.tools: + types.append("tools") + if self.projects: + types.append("projects") + return types + + +# --------------------------------------------------------------------------- +# Sync summary +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class SyncSummary: + """Summary of a sync operation. + + Attributes: + created: Number of items created on the server. + updated: Number of items updated on the server. + skipped: Number of items skipped (no change or conflict). + errors: Number of items that failed to sync. + resource_type: The resource type that was synced. + dry_run: Whether this was a dry-run (no actual changes). + server_ids: Mapping of local IDs to server-assigned IDs. + """ + + created: int = 0 + updated: int = 0 + skipped: int = 0 + errors: int = 0 + resource_type: str = "" + dry_run: bool = False + server_ids: dict[str, str] = field(default_factory=dict) + + @property + def total_processed(self) -> int: + """Total items processed.""" + return self.created + self.updated + self.skipped + self.errors + + +# --------------------------------------------------------------------------- +# Execution result +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ExecutionResult: + """Result of a remote plan execution request. + + Attributes: + plan_id: The local plan identifier. + server_plan_id: The server-assigned execution ID. + status: Current execution status. + message: Optional status message. + """ + + plan_id: str + server_plan_id: str + status: str + message: str = "" + + +# --------------------------------------------------------------------------- +# PlanSyncClient +# --------------------------------------------------------------------------- + + +class PlanSyncClient: + """Client for syncing plans and resources with a remote server. + + Args: + http_client: The underlying HTTP client for server communication. + conflict_policy: Default conflict resolution strategy. + """ + + def __init__( + self, + http_client: ServerHttpClient, + conflict_policy: ConflictPolicy = ConflictPolicy.LOCAL_WINS, + ) -> None: + self._http = http_client + self._conflict_policy = conflict_policy + + @property + def conflict_policy(self) -> ConflictPolicy: + """Current conflict resolution policy.""" + return self._conflict_policy + + @conflict_policy.setter + def conflict_policy(self, value: ConflictPolicy) -> None: + self._conflict_policy = value + + # ------------------------------------------------------------------ + # Sync operations + # ------------------------------------------------------------------ + + def sync( + self, + items: list[dict[str, Any]], + resource_type: str, + *, + dry_run: bool = False, + conflict_policy: ConflictPolicy | None = None, + ) -> SyncSummary: + """Synchronize a list of local items with the server. + + Args: + items: Local resource dicts with at least an ``"id"`` key. + resource_type: One of ``"actions"``, ``"skills"``, + ``"tools"``, ``"projects"``. + dry_run: When ``True`` no server mutations occur. + conflict_policy: Override the default conflict policy. + + Returns: + A :class:`SyncSummary` describing what was (or would be) done. + """ + policy = conflict_policy or self._conflict_policy + summary = SyncSummary(resource_type=resource_type, dry_run=dry_run) + + logger.info( + "sync_start", + resource_type=resource_type, + item_count=len(items), + dry_run=dry_run, + policy=policy.value, + ) + + for item in items: + local_id = str(item.get("id", "")) + if not local_id: + summary.errors += 1 + continue + + try: + result = self._sync_item( + item, resource_type, policy=policy, dry_run=dry_run + ) + if result == "created": + summary.created += 1 + elif result == "updated": + summary.updated += 1 + else: + summary.skipped += 1 + + server_id = str(item.get("server_id", local_id)) + summary.server_ids[local_id] = server_id + + except ServerConnectionError: + summary.errors += 1 + logger.warning("sync_item_failed", local_id=local_id) + + logger.info( + "sync_complete", + resource_type=resource_type, + created=summary.created, + updated=summary.updated, + skipped=summary.skipped, + errors=summary.errors, + ) + return summary + + def sync_all( + self, + resources: dict[str, list[dict[str, Any]]], + scope: SyncScope | None = None, + *, + dry_run: bool = False, + ) -> dict[str, SyncSummary]: + """Synchronize multiple resource types according to scope flags. + + Args: + resources: Mapping of resource type name to list of items. + scope: A :class:`SyncScope` controlling which types to sync. + dry_run: When ``True`` no server mutations occur. + + Returns: + Mapping of resource type to its :class:`SyncSummary`. + """ + scope = scope or SyncScope() + results: dict[str, SyncSummary] = {} + for rtype in scope.active_types(): + items = resources.get(rtype, []) + results[rtype] = self.sync(items, rtype, dry_run=dry_run) + return results + + def _sync_item( + self, + item: dict[str, Any], + resource_type: str, + *, + policy: ConflictPolicy, + dry_run: bool, + ) -> str: + """Sync a single item. Returns 'created', 'updated', or 'skipped'.""" + local_id = str(item.get("id", "")) + server_id = item.get("server_id") + + if dry_run: + return "created" if server_id is None else "updated" + + if server_id is None: + # New item — create on server + resp = self._http._request( + "POST", + f"/{resource_type}", + json_body={"item": item}, + ) + data = resp.json() + new_server_id = data.get("id", local_id) + item["server_id"] = new_server_id + return "created" + + # Existing item — check for conflict + try: + resp = self._http._request( + "GET", + f"/{resource_type}/{server_id}", + ) + server_data = resp.json() + except ServerConnectionError: + raise + + server_version = server_data.get("version", 0) + local_version = item.get("version", 0) + + if server_version == local_version: + return "skipped" + + if policy == ConflictPolicy.SERVER_WINS and server_version > local_version: + return "skipped" + + # Local wins or local version is newer — push update + resp = self._http._request( + "PUT", + f"/{resource_type}/{server_id}", + json_body={"item": item}, + ) + return "updated" + + # ------------------------------------------------------------------ + # Remote execution + # ------------------------------------------------------------------ + + def execute_plan(self, plan_id: str) -> ExecutionResult: + """Submit a plan for remote execution. + + Args: + plan_id: Local plan identifier. + + Returns: + An :class:`ExecutionResult` with the server-assigned execution ID. + """ + if not plan_id: + raise ValueError("plan_id must be a non-empty string") + + resp = self._http._request( + "POST", + "/plans/execute", + json_body={"plan_id": plan_id}, + ) + data = resp.json() + return ExecutionResult( + plan_id=plan_id, + server_plan_id=str(data.get("server_plan_id", "")), + status=str(data.get("status", "submitted")), + message=str(data.get("message", "")), + ) + + def apply_plan(self, plan_id: str) -> ExecutionResult: + """Apply a plan on the remote server. + + Args: + plan_id: Local plan identifier. + + Returns: + An :class:`ExecutionResult` with execution metadata. + """ + if not plan_id: + raise ValueError("plan_id must be a non-empty string") + + resp = self._http._request( + "POST", + "/plans/apply", + json_body={"plan_id": plan_id}, + ) + data = resp.json() + return ExecutionResult( + plan_id=plan_id, + server_plan_id=str(data.get("server_plan_id", "")), + status=str(data.get("status", "applying")), + message=str(data.get("message", "")), + ) + + def get_plan_status(self, server_plan_id: str) -> dict[str, Any]: + """Query the execution status of a remote plan. + + Args: + server_plan_id: Server-assigned plan execution ID. + + Returns: + Status metadata dict with phase, progress, and elapsed time. + """ + if not server_plan_id: + raise ValueError("server_plan_id must be a non-empty string") + + resp = self._http._request("GET", f"/plans/{server_plan_id}/status") + data: dict[str, Any] = resp.json() + return data + + +__all__ = [ + "ConflictPolicy", + "ExecutionResult", + "PlanSyncClient", + "SyncScope", + "SyncSummary", +] diff --git a/src/cleveragents/client/ws_client.py b/src/cleveragents/client/ws_client.py new file mode 100644 index 000000000..0029fc747 --- /dev/null +++ b/src/cleveragents/client/ws_client.py @@ -0,0 +1,357 @@ +"""WebSocket client for real-time plan updates. + +Provides :class:`WebSocketClient` for subscribing to plan update events +from a CleverAgents server over a WebSocket connection. Features include +automatic reconnection with exponential backoff, heartbeat/ping handling, +event de-duplication by ``event_id``, ordered delivery guarantees, and +resume-from-last-event-ID on reconnect. +""" + +from __future__ import annotations + +import math +import threading +import time +from collections import OrderedDict +from collections.abc import Callable + +import structlog + +from cleveragents.acp.models import AcpEvent +from cleveragents.client.exceptions import ServerConnectionError + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_WS_URL = "ws://localhost:8080/ws" +_DEFAULT_RECONNECT_BASE = 1.0 +_DEFAULT_RECONNECT_MAX = 60.0 +_DEFAULT_MAX_RECONNECTS = 10 +_DEFAULT_HEARTBEAT_INTERVAL = 30.0 +_DEFAULT_DEDUP_CAPACITY = 1000 + +EventCallback = Callable[[AcpEvent], None] + + +# --------------------------------------------------------------------------- +# Backoff helper +# --------------------------------------------------------------------------- + + +def _ws_backoff_delay(attempt: int, base: float, maximum: float) -> float: + """Compute exponential-backoff delay capped at *maximum*.""" + return min(base * math.pow(2, attempt), maximum) + + +# --------------------------------------------------------------------------- +# WebSocket connection state +# --------------------------------------------------------------------------- + + +class ConnectionState: + """Tracks WebSocket connection lifecycle state. + + Attributes: + connected: Whether the WebSocket is currently connected. + reconnect_count: Number of reconnection attempts so far. + last_event_id: ID of the last successfully processed event. + negotiated_version: Protocol version agreed with the server. + """ + + __slots__ = ( + "connected", + "last_event_id", + "negotiated_version", + "reconnect_count", + ) + + def __init__(self) -> None: + self.connected: bool = False + self.reconnect_count: int = 0 + self.last_event_id: str = "" + self.negotiated_version: str = "" + + def reset(self) -> None: + """Reset to initial disconnected state.""" + self.connected = False + self.reconnect_count = 0 + self.last_event_id = "" + self.negotiated_version = "" + + +# --------------------------------------------------------------------------- +# Event de-duplication buffer +# --------------------------------------------------------------------------- + + +class EventDeduplicator: + """LRU-bounded set for de-duplicating events by ``event_id``. + + Keeps at most *capacity* event IDs. When the buffer is full the + oldest entry is evicted. + + Args: + capacity: Maximum number of event IDs to remember. + """ + + def __init__(self, capacity: int = _DEFAULT_DEDUP_CAPACITY) -> None: + self._capacity = capacity + self._seen: OrderedDict[str, None] = OrderedDict() + + @property + def capacity(self) -> int: + """Maximum number of tracked event IDs.""" + return self._capacity + + def is_duplicate(self, event_id: str) -> bool: + """Return ``True`` if *event_id* was already seen.""" + if event_id in self._seen: + return True + self._seen[event_id] = None + if len(self._seen) > self._capacity: + self._seen.popitem(last=False) + return False + + def clear(self) -> None: + """Remove all tracked event IDs.""" + self._seen.clear() + + @property + def size(self) -> int: + """Number of event IDs currently tracked.""" + return len(self._seen) + + +# --------------------------------------------------------------------------- +# WebSocketClient +# --------------------------------------------------------------------------- + + +class WebSocketClient: + """Client for receiving real-time plan update events via WebSocket. + + The client manages connection lifecycle including automatic + reconnection with exponential backoff, heartbeat handling, event + de-duplication, and ordered delivery. + + Args: + ws_url: WebSocket server URL. + api_token: Bearer token for authentication. + reconnect_base: Base delay for exponential backoff (seconds). + reconnect_max: Maximum backoff delay (seconds). + max_reconnects: Maximum reconnection attempts before giving up. + heartbeat_interval: Expected heartbeat interval (seconds). + dedup_capacity: Maximum tracked event IDs for de-duplication. + """ + + def __init__( + self, + ws_url: str = _DEFAULT_WS_URL, + api_token: str | None = None, + reconnect_base: float = _DEFAULT_RECONNECT_BASE, + reconnect_max: float = _DEFAULT_RECONNECT_MAX, + max_reconnects: int = _DEFAULT_MAX_RECONNECTS, + heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL, + dedup_capacity: int = _DEFAULT_DEDUP_CAPACITY, + ) -> None: + self._ws_url = ws_url + self._api_token = api_token + self._reconnect_base = reconnect_base + self._reconnect_max = reconnect_max + self._max_reconnects = max_reconnects + self._heartbeat_interval = heartbeat_interval + + self._state = ConnectionState() + self._dedup = EventDeduplicator(capacity=dedup_capacity) + self._callbacks: list[EventCallback] = [] + self._lock = threading.Lock() + self._running = False + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def ws_url(self) -> str: + """The WebSocket server URL.""" + return self._ws_url + + @property + def connected(self) -> bool: + """Whether the client is currently connected.""" + return self._state.connected + + @property + def reconnect_count(self) -> int: + """Number of reconnection attempts so far.""" + return self._state.reconnect_count + + @property + def last_event_id(self) -> str: + """ID of the last successfully processed event.""" + return self._state.last_event_id + + @property + def heartbeat_interval(self) -> float: + """Expected heartbeat interval in seconds.""" + return self._heartbeat_interval + + @property + def dedup_capacity(self) -> int: + """Maximum tracked event IDs for de-duplication.""" + return self._dedup.capacity + + # ------------------------------------------------------------------ + # Subscription + # ------------------------------------------------------------------ + + def subscribe(self, callback: EventCallback) -> None: + """Register a callback for plan update events. + + Args: + callback: Function called with each new :class:`AcpEvent`. + """ + with self._lock: + self._callbacks.append(callback) + logger.debug("ws_subscriber_added", total=len(self._callbacks)) + + def unsubscribe(self, callback: EventCallback) -> None: + """Remove a previously registered callback. + + Args: + callback: The callback to remove. + """ + with self._lock: + self._callbacks = [cb for cb in self._callbacks if cb is not callback] + logger.debug("ws_subscriber_removed", total=len(self._callbacks)) + + # ------------------------------------------------------------------ + # Event processing + # ------------------------------------------------------------------ + + def process_event(self, event: AcpEvent) -> bool: + """Process and dispatch an event to subscribers. + + Performs de-duplication and updates the last event ID. + + Args: + event: The event to process. + + Returns: + ``True`` if the event was dispatched, ``False`` if it was + a duplicate. + """ + if self._dedup.is_duplicate(event.event_id): + logger.debug("ws_event_duplicate", event_id=event.event_id) + return False + + self._state.last_event_id = event.event_id + + with self._lock: + callbacks = list(self._callbacks) + + for cb in callbacks: + try: + cb(event) + except Exception: + logger.exception("ws_callback_error", event_id=event.event_id) + + return True + + def handle_heartbeat(self) -> None: + """Handle a heartbeat/ping from the server. + + Resets the reconnect counter on successful heartbeat. + """ + self._state.reconnect_count = 0 + logger.debug("ws_heartbeat_received") + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Establish the WebSocket connection (simulated). + + In the current implementation this sets state to connected. + The actual WebSocket connection will be established when + the server project provides a real endpoint. + """ + self._state.connected = True + self._state.reconnect_count = 0 + self._running = True + logger.info("ws_connected", url=self._ws_url) + + def disconnect(self) -> None: + """Close the WebSocket connection and stop reconnection.""" + self._running = False + self._state.connected = False + self._dedup.clear() + logger.info("ws_disconnected", url=self._ws_url) + + def reconnect(self) -> bool: + """Attempt to reconnect with exponential backoff. + + Returns: + ``True`` if reconnection succeeded, ``False`` if + max retries were exhausted. + + Raises: + ServerConnectionError: When max reconnection attempts + are exhausted. + """ + if self._state.reconnect_count >= self._max_reconnects: + self._state.connected = False + raise ServerConnectionError( + message=( + f"Max reconnection attempts ({self._max_reconnects}) " + f"exhausted for {self._ws_url}" + ), + url=self._ws_url, + ) + + delay = _ws_backoff_delay( + self._state.reconnect_count, + self._reconnect_base, + self._reconnect_max, + ) + logger.info( + "ws_reconnecting", + attempt=self._state.reconnect_count + 1, + delay=delay, + last_event_id=self._state.last_event_id, + ) + time.sleep(delay) + self._state.reconnect_count += 1 + self._state.connected = True + return True + + def negotiate_version(self, client_version: str = "1.0") -> str: + """Negotiate the event protocol version. + + Args: + client_version: The version this client supports. + + Returns: + The negotiated version (currently always returns the + client version). + """ + self._state.negotiated_version = client_version + logger.debug("ws_version_negotiated", version=client_version) + return client_version + + @property + def state(self) -> ConnectionState: + """The current connection state.""" + return self._state + + +__all__ = [ + "ConnectionState", + "EventCallback", + "EventDeduplicator", + "WebSocketClient", +] diff --git a/src/cleveragents/config/settings.py b/src/cleveragents/config/settings.py index 830f32c12..6a8b80460 100644 --- a/src/cleveragents/config/settings.py +++ b/src/cleveragents/config/settings.py @@ -435,6 +435,29 @@ class Settings(BaseSettings): description="Completed job retention in seconds before cleanup.", ) + # Server client configuration (M7 - server communication) + server_base_url: str | None = Field( + default=None, + validation_alias=AliasChoices("CLEVERAGENTS_SERVER_BASE_URL"), + description="Base URL of the remote CleverAgents server.", + ) + server_api_token: str | None = Field( + default=None, + validation_alias=AliasChoices("CLEVERAGENTS_SERVER_API_TOKEN"), + description="Bearer token for server authentication.", + ) + server_tls_verify: bool = Field( + default=True, + validation_alias=AliasChoices("CLEVERAGENTS_SERVER_TLS_VERIFY"), + description="Whether to verify TLS certificates for server connections.", + ) + server_request_timeout: float = Field( + default=30.0, + gt=0, + validation_alias=AliasChoices("CLEVERAGENTS_SERVER_REQUEST_TIMEOUT"), + description="Per-request timeout in seconds for server communication.", + ) + # Mock providers flag (M4 - provider fixes) mock_providers: bool = Field( default=False, diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 9fbca5ca4..e5888bcf7 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -957,3 +957,38 @@ depth_resolved_count # noqa: B018, F821 dropped_by_overage_guard # noqa: B018, F821 budget_utilization # noqa: B018, F821 fusion_input_count # noqa: B018, F821 + +# Server HTTP client — Issue #335 +ServerHttpClient # noqa: B018, F821 +ServerConnectionError # noqa: B018, F821 +ServerTimeoutError # noqa: B018, F821 +ServerVersionMismatchError # noqa: B018, F821 +PageResult # noqa: B018, F821 +create_client_from_settings # noqa: B018, F821 +server_base_url # noqa: B018, F821 +server_api_token # noqa: B018, F821 +server_tls_verify # noqa: B018, F821 +server_request_timeout # noqa: B018, F821 + +# Plan sync client — Issue #336 +PlanSyncClient # noqa: B018, F821 +ConflictPolicy # noqa: B018, F821 +SyncScope # noqa: B018, F821 +SyncSummary # noqa: B018, F821 +ExecutionResult # noqa: B018, F821 +sync_all # noqa: B018, F821 +apply_plan # noqa: B018, F821 +get_plan_status # noqa: B018, F821 + +# WebSocket client — Issue #337 +WebSocketClient # noqa: B018, F821 +ConnectionState # noqa: B018, F821 +EventDeduplicator # noqa: B018, F821 +EventCallback # noqa: B018, F821 + +# Remote project client — Issue #338 +RemoteProjectClient # noqa: B018, F821 +RemoteProject # noqa: B018, F821 +resolve_project # noqa: B018, F821 +request_execution # noqa: B018, F821 +invalidate_cache # noqa: B018, F821