From 25ae509d5260761edb0d07aafc40351062c92346 Mon Sep 17 00:00:00 2001 From: Dmitrii Krosh Date: Wed, 24 Jun 2026 04:11:31 +0300 Subject: [PATCH] add info about aviable commands in heartbeat --- src/nexus_sync/client/execute.py | 4 ++++ src/nexus_sync/client/runtime.py | 22 +++++++++++++++++++++- src/nexus_sync/common/__init__.py | 2 ++ src/nexus_sync/common/models.py | 7 +++++++ src/nexus_sync/server/store.py | 1 + tests/test_client_runtime.py | 19 +++++++++++++++++++ tests/test_models.py | 10 ++++++++++ tests/test_server.py | 22 ++++++++++++++++++++++ 8 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/nexus_sync/client/execute.py b/src/nexus_sync/client/execute.py index 12eccb9..3b766a8 100644 --- a/src/nexus_sync/client/execute.py +++ b/src/nexus_sync/client/execute.py @@ -10,6 +10,10 @@ from nexus_sync.common import Command, CommandKind, CommandResult, CommandResult PresetBuilder = Callable[[Mapping[str, Any]], Sequence[str]] DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024 +DEFAULT_PRESET_DESCRIPTIONS = { + "hostname": "Return system hostname", + "network_interfaces": "Return network interface information", +} @dataclass(frozen=True) diff --git a/src/nexus_sync/client/runtime.py b/src/nexus_sync/client/runtime.py index b240e08..c2f0a62 100644 --- a/src/nexus_sync/client/runtime.py +++ b/src/nexus_sync/client/runtime.py @@ -13,11 +13,17 @@ from typing import Callable, Mapping, Protocol, Self from pydantic import ValidationError from nexus_sync.client.config import load_command_access_policy -from nexus_sync.client.execute import CommandAccessPolicy, execute_command +from nexus_sync.client.execute import ( + DEFAULT_PRESET_DESCRIPTIONS, + DEFAULT_PRESETS, + CommandAccessPolicy, + execute_command, +) from nexus_sync.common import ( ClientInfo, ClientPlatform, ClientState, + ClientCommandCapability, CommandResult, HeartbeatRequest, HeartbeatResponse, @@ -93,10 +99,24 @@ def build_heartbeat_request( local_time=datetime.now().astimezone(), uptime_seconds=None, ), + available_commands=list_available_commands(config.command_access_policy), last_command_result=last_command_result, ) +def list_available_commands( + access_policy: CommandAccessPolicy, +) -> list[ClientCommandCapability]: + return [ + ClientCommandCapability( + name=name, + description=DEFAULT_PRESET_DESCRIPTIONS.get(name, ""), + ) + for name in sorted(DEFAULT_PRESETS) + if access_policy.allows(name) + ] + + def send_heartbeat( config: ClientConfig, heartbeat: HeartbeatRequest, diff --git a/src/nexus_sync/common/__init__.py b/src/nexus_sync/common/__init__.py index 960b955..d38ce7a 100644 --- a/src/nexus_sync/common/__init__.py +++ b/src/nexus_sync/common/__init__.py @@ -4,6 +4,7 @@ from nexus_sync.common.models import ( ClientPlatform, ClientRecord, ClientState, + ClientCommandCapability, Command, CommandKind, CommandRecord, @@ -21,6 +22,7 @@ __all__ = [ "ClientPlatform", "ClientRecord", "ClientState", + "ClientCommandCapability", "Command", "CommandKind", "CommandRecord", diff --git a/src/nexus_sync/common/models.py b/src/nexus_sync/common/models.py index c76c5e0..410cd1d 100644 --- a/src/nexus_sync/common/models.py +++ b/src/nexus_sync/common/models.py @@ -47,6 +47,11 @@ class ClientState(StrictBaseModel): uptime_seconds: int | None = Field(default=None, ge=0) +class ClientCommandCapability(StrictBaseModel): + name: str + description: str + + class CommandResult(StrictBaseModel): command_id: str status: CommandResultStatus @@ -62,6 +67,7 @@ class HeartbeatRequest(StrictBaseModel): observed_at: datetime client: ClientInfo state: ClientState + available_commands: list[ClientCommandCapability] = Field(default_factory=list) last_command_result: CommandResult | None = None @@ -89,6 +95,7 @@ class ClientRecord(StrictBaseModel): last_seen_at: datetime is_active: bool = True token_hash: str | None = None + available_commands: list[ClientCommandCapability] = Field(default_factory=list) class CommandRecord(StrictBaseModel): diff --git a/src/nexus_sync/server/store.py b/src/nexus_sync/server/store.py index 50ada3d..d4238bb 100644 --- a/src/nexus_sync/server/store.py +++ b/src/nexus_sync/server/store.py @@ -45,6 +45,7 @@ class InMemoryStore: last_seen_at=now, is_active=True, token_hash=token_hash, + available_commands=heartbeat.available_commands, ) self.clients[heartbeat.client_id] = record return record diff --git a/tests/test_client_runtime.py b/tests/test_client_runtime.py index e910131..e39b187 100644 --- a/tests/test_client_runtime.py +++ b/tests/test_client_runtime.py @@ -14,6 +14,7 @@ from nexus_sync.client.runtime import ( ClientConfigError, HeartbeatError, build_heartbeat_request, + list_available_commands, load_client_config, main, run_once, @@ -84,6 +85,24 @@ def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None: assert serialized["state"]["uptime_seconds"] is None +def test_list_available_commands_returns_allowed_command_names_and_descriptions() -> None: + commands = list_available_commands(CommandAccessPolicy.allow(["hostname"])) + + assert [command.model_dump() for command in commands] == [ + {"name": "hostname", "description": "Return system hostname"} + ] + + +def test_build_heartbeat_request_includes_available_commands(monkeypatch) -> None: + monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local") + monkeypatch.setattr("platform.system", lambda: "Darwin") + + heartbeat = build_heartbeat_request(_config(CommandAccessPolicy.allow(["hostname"]))) + + assert [command.name for command in heartbeat.available_commands] == ["hostname"] + assert heartbeat.available_commands[0].description == "Return system hostname" + + def test_build_heartbeat_request_includes_last_command_result(monkeypatch) -> None: monkeypatch.setattr("socket.gethostname", lambda: "macbook-pro.local") monkeypatch.setattr("platform.system", lambda: "Darwin") diff --git a/tests/test_models.py b/tests/test_models.py index a8ca9db..d84dec9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,6 +6,7 @@ from nexus_sync.common import ( ClientInfo, ClientPlatform, ClientState, + ClientCommandCapability, Command, CommandKind, CommandResult, @@ -28,6 +29,12 @@ def test_heartbeat_request_accepts_payload() -> None: local_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), uptime_seconds=1200, ), + available_commands=[ + ClientCommandCapability( + name="hostname", + description="Return system hostname", + ) + ], last_command_result=None, ) @@ -35,6 +42,9 @@ def test_heartbeat_request_accepts_payload() -> None: assert serialized["client_id"] == "macbook-pro-01" assert serialized["client"]["platform"] == "darwin" + assert serialized["available_commands"] == [ + {"name": "hostname", "description": "Return system hostname"} + ] assert serialized["last_command_result"] is None diff --git a/tests/test_server.py b/tests/test_server.py index c5304b7..b95ec93 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -87,6 +87,28 @@ def test_heartbeat_accepts_state_without_command() -> None: assert store.clients["macbook-pro-01"].hostname == "macbook-pro.local" +def test_heartbeat_stores_available_commands() -> None: + store = InMemoryStore() + client = _client(store) + payload = _heartbeat_payload() + payload["available_commands"] = [ + {"name": "hostname", "description": "Return system hostname"}, + {"name": "network_interfaces", "description": "Return network interface information"}, + ] + + response = client.post( + "/api/v1/client/heartbeat", + json=payload, + headers={"Authorization": "Bearer client-token"}, + ) + + assert response.status_code == 200 + assert [command.name for command in store.clients["macbook-pro-01"].available_commands] == [ + "hostname", + "network_interfaces", + ] + + def test_heartbeat_delivers_pending_command_and_marks_it_delivered() -> None: store = InMemoryStore() store.enqueue_command(