diff --git a/src/nexus_sync/client/runtime.py b/src/nexus_sync/client/runtime.py index 3f72833..8847e2a 100644 --- a/src/nexus_sync/client/runtime.py +++ b/src/nexus_sync/client/runtime.py @@ -57,7 +57,11 @@ def load_client_config(env: Mapping[str, str] = os.environ) -> ClientConfig: ) -def build_heartbeat_request(config: ClientConfig) -> HeartbeatRequest: +def build_heartbeat_request( + config: ClientConfig, + *, + last_command_result: CommandResult | None = None, +) -> HeartbeatRequest: now = datetime.now(UTC) return HeartbeatRequest( client_id=config.client_id, @@ -71,7 +75,7 @@ def build_heartbeat_request(config: ClientConfig) -> HeartbeatRequest: local_time=datetime.now().astimezone(), uptime_seconds=None, ), - last_command_result=None, + last_command_result=last_command_result, ) @@ -112,13 +116,17 @@ def send_heartbeat( def run_once( config: ClientConfig, *, + last_command_result: CommandResult | None = None, heartbeat_sender: Callable[ [ClientConfig, HeartbeatRequest], HeartbeatResponse, ] = send_heartbeat, executor: Callable[..., CommandResult] = execute_command, ) -> CommandResult | None: - response = heartbeat_sender(config, build_heartbeat_request(config)) + response = heartbeat_sender( + config, + build_heartbeat_request(config, last_command_result=last_command_result), + ) if response.command is None: return None diff --git a/tests/test_client_runtime.py b/tests/test_client_runtime.py index f6af3e8..da685e4 100644 --- a/tests/test_client_runtime.py +++ b/tests/test_client_runtime.py @@ -84,6 +84,29 @@ def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None: assert serialized["state"]["uptime_seconds"] is None +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") + command_result = CommandResult( + command_id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B", + status=CommandResultStatus.SUCCEEDED, + started_at=datetime(2026, 5, 24, 13, 20, 31, tzinfo=UTC), + finished_at=datetime(2026, 5, 24, 13, 20, 32, tzinfo=UTC), + return_code=0, + stdout="host\n", + stderr="", + ) + + heartbeat = build_heartbeat_request(_config(), last_command_result=command_result) + serialized = heartbeat.model_dump(mode="json") + + assert heartbeat.last_command_result == command_result + assert serialized["last_command_result"]["command_id"] == command_result.command_id + assert serialized["last_command_result"]["status"] == "succeeded" + assert serialized["last_command_result"]["return_code"] == 0 + assert serialized["last_command_result"]["stdout"] == "host\n" + + def test_send_heartbeat_posts_json_with_bearer_token() -> None: captured = {} @@ -128,6 +151,51 @@ def test_send_heartbeat_posts_json_with_bearer_token() -> None: assert b"macbook-pro-01" in captured["data"] +def test_send_heartbeat_serializes_last_command_result() -> None: + captured = {} + + def fake_opener(request: urllib.request.Request): + captured["data"] = request.data + return _Response( + HeartbeatResponse( + server_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), + next_poll_after_seconds=60, + command=None, + ) + .model_dump_json() + .encode() + ) + + heartbeat = HeartbeatRequest( + client_id="macbook-pro-01", + observed_at=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), + client={ + "hostname": "macbook-pro.local", + "platform": "darwin", + "version": "0.1.0", + }, + state={ + "local_time": datetime(2026, 5, 24, 16, 20, 30, tzinfo=UTC), + "uptime_seconds": None, + }, + last_command_result={ + "command_id": "cmd_01JY3H8V8W8P3FXDR3S2BM7M6B", + "status": "succeeded", + "started_at": "2026-05-24T13:20:31Z", + "finished_at": "2026-05-24T13:20:32Z", + "return_code": 0, + "stdout": "host\n", + "stderr": "", + }, + ) + + send_heartbeat(_config(), heartbeat, opener=fake_opener) + + assert b'"last_command_result":' in captured["data"] + assert b'"command_id":"cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"' in captured["data"] + assert b'"stdout":"host\\n"' in captured["data"] + + def test_send_heartbeat_maps_http_error_to_runtime_error() -> None: def fake_opener(_request: urllib.request.Request): raise urllib.error.HTTPError( @@ -198,6 +266,34 @@ def test_run_once_executes_command_with_configured_access_policy() -> None: assert seen["access_policy"] == policy +def test_run_once_sends_previous_command_result() -> None: + previous_result = CommandResult( + command_id="cmd_previous", + status=CommandResultStatus.FAILED, + return_code=1, + stdout="", + stderr="failed\n", + ) + seen = {} + + def fake_sender(_config: ClientConfig, heartbeat: HeartbeatRequest) -> HeartbeatResponse: + seen["last_command_result"] = heartbeat.last_command_result + return HeartbeatResponse( + server_time=datetime(2026, 5, 24, 13, 20, 30, tzinfo=UTC), + next_poll_after_seconds=60, + command=None, + ) + + result = run_once( + _config(), + last_command_result=previous_result, + heartbeat_sender=fake_sender, + ) + + assert result is None + assert seen["last_command_result"] == previous_result + + def test_main_returns_non_zero_for_missing_config(monkeypatch, capsys) -> None: monkeypatch.delenv(SERVER_URL_ENV, raising=False) monkeypatch.delenv(CLIENT_ID_ENV, raising=False)