mlx-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. mlx_stack/__init__.py +5 -0
  2. mlx_stack/_version.py +24 -0
  3. mlx_stack/cli/__init__.py +5 -0
  4. mlx_stack/cli/bench.py +221 -0
  5. mlx_stack/cli/config.py +166 -0
  6. mlx_stack/cli/down.py +109 -0
  7. mlx_stack/cli/init.py +180 -0
  8. mlx_stack/cli/install.py +165 -0
  9. mlx_stack/cli/logs.py +234 -0
  10. mlx_stack/cli/main.py +187 -0
  11. mlx_stack/cli/models.py +304 -0
  12. mlx_stack/cli/profile.py +65 -0
  13. mlx_stack/cli/pull.py +134 -0
  14. mlx_stack/cli/recommend.py +397 -0
  15. mlx_stack/cli/status.py +111 -0
  16. mlx_stack/cli/up.py +163 -0
  17. mlx_stack/cli/watch.py +252 -0
  18. mlx_stack/core/__init__.py +1 -0
  19. mlx_stack/core/benchmark.py +1182 -0
  20. mlx_stack/core/catalog.py +560 -0
  21. mlx_stack/core/config.py +471 -0
  22. mlx_stack/core/deps.py +323 -0
  23. mlx_stack/core/hardware.py +304 -0
  24. mlx_stack/core/launchd.py +531 -0
  25. mlx_stack/core/litellm_gen.py +188 -0
  26. mlx_stack/core/log_rotation.py +231 -0
  27. mlx_stack/core/log_viewer.py +386 -0
  28. mlx_stack/core/models.py +639 -0
  29. mlx_stack/core/paths.py +79 -0
  30. mlx_stack/core/process.py +887 -0
  31. mlx_stack/core/pull.py +815 -0
  32. mlx_stack/core/scoring.py +611 -0
  33. mlx_stack/core/stack_down.py +317 -0
  34. mlx_stack/core/stack_init.py +524 -0
  35. mlx_stack/core/stack_status.py +229 -0
  36. mlx_stack/core/stack_up.py +856 -0
  37. mlx_stack/core/watchdog.py +744 -0
  38. mlx_stack/data/__init__.py +1 -0
  39. mlx_stack/data/catalog/__init__.py +1 -0
  40. mlx_stack/data/catalog/deepseek-r1-32b.yaml +46 -0
  41. mlx_stack/data/catalog/deepseek-r1-8b.yaml +45 -0
  42. mlx_stack/data/catalog/gemma3-12b.yaml +45 -0
  43. mlx_stack/data/catalog/gemma3-27b.yaml +45 -0
  44. mlx_stack/data/catalog/gemma3-4b.yaml +45 -0
  45. mlx_stack/data/catalog/llama3.3-8b.yaml +44 -0
  46. mlx_stack/data/catalog/nemotron-49b.yaml +41 -0
  47. mlx_stack/data/catalog/nemotron-8b.yaml +44 -0
  48. mlx_stack/data/catalog/qwen3-8b.yaml +45 -0
  49. mlx_stack/data/catalog/qwen3.5-0.8b.yaml +45 -0
  50. mlx_stack/data/catalog/qwen3.5-14b.yaml +46 -0
  51. mlx_stack/data/catalog/qwen3.5-32b.yaml +45 -0
  52. mlx_stack/data/catalog/qwen3.5-3b.yaml +44 -0
  53. mlx_stack/data/catalog/qwen3.5-72b.yaml +42 -0
  54. mlx_stack/data/catalog/qwen3.5-8b.yaml +45 -0
  55. mlx_stack/py.typed +1 -0
  56. mlx_stack/utils/__init__.py +1 -0
  57. mlx_stack-0.1.0.dist-info/METADATA +397 -0
  58. mlx_stack-0.1.0.dist-info/RECORD +61 -0
  59. mlx_stack-0.1.0.dist-info/WHEEL +4 -0
  60. mlx_stack-0.1.0.dist-info/entry_points.txt +2 -0
  61. mlx_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,231 @@
1
+ """Log rotation infrastructure for mlx-stack.
2
+
3
+ Implements copytruncate-based rotation for service log files in
4
+ ~/.mlx-stack/logs/. The rotation strategy copies the log file to an
5
+ archive, compresses it with gzip, then truncates the original in-place
6
+ so that the writing process's file descriptor remains valid.
7
+
8
+ Archive naming uses sequential numbering:
9
+ <name>.log.1.gz = most recent rotation
10
+ <name>.log.2.gz = second most recent
11
+ ...
12
+ <name>.log.N.gz = oldest retained rotation
13
+
14
+ Archives beyond max_files are deleted.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import gzip
20
+ import logging
21
+ import shutil
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class LogRotationError(Exception):
28
+ """Raised when a log rotation operation fails."""
29
+
30
+
31
+ def rotate_log(
32
+ log_path: Path,
33
+ max_size_mb: int = 50,
34
+ max_files: int = 5,
35
+ ) -> bool:
36
+ """Rotate a log file using the copytruncate strategy.
37
+
38
+ If the log file exceeds ``max_size_mb``, this function:
39
+ 1. Shifts existing archives up by one number (.1.gz → .2.gz, etc.)
40
+ 2. Copies the current log to ``<name>.log.1.gz`` (gzip compressed)
41
+ 3. Truncates the original log file in-place (preserving the FD)
42
+ 4. Deletes archives numbered higher than ``max_files``
43
+
44
+ Args:
45
+ log_path: Path to the log file to rotate.
46
+ max_size_mb: Maximum log file size in megabytes before rotation.
47
+ max_files: Maximum number of rotated archive files to retain.
48
+
49
+ Returns:
50
+ True if rotation was performed, False if skipped (file missing,
51
+ empty, or below threshold).
52
+
53
+ Raises:
54
+ LogRotationError: If a permission error or I/O error prevents
55
+ rotation.
56
+ """
57
+ # Edge case: missing file — silently skip
58
+ if not log_path.exists():
59
+ return False
60
+
61
+ # Edge case: empty file — skip
62
+ try:
63
+ file_size = log_path.stat().st_size
64
+ except OSError:
65
+ return False
66
+
67
+ if file_size == 0:
68
+ return False
69
+
70
+ # Edge case: below threshold — skip
71
+ threshold_bytes = max_size_mb * 1024 * 1024
72
+ if file_size < threshold_bytes:
73
+ return False
74
+
75
+ try:
76
+ _perform_rotation(log_path, max_files)
77
+ except PermissionError as exc:
78
+ msg = f"Permission denied during log rotation of {log_path}: {exc}"
79
+ raise LogRotationError(msg) from None
80
+ except OSError as exc:
81
+ msg = f"I/O error during log rotation of {log_path}: {exc}"
82
+ raise LogRotationError(msg) from None
83
+
84
+ return True
85
+
86
+
87
+ def _perform_rotation(log_path: Path, max_files: int) -> None:
88
+ """Execute the actual rotation steps.
89
+
90
+ This is separated from ``rotate_log`` to keep the edge-case
91
+ handling clean.
92
+
93
+ Args:
94
+ log_path: Path to the log file.
95
+ max_files: Maximum number of archive files to keep.
96
+ """
97
+ base = log_path.parent
98
+ stem = log_path.name # e.g. "fast.log"
99
+
100
+ # Step 1: Delete archives beyond max_files (they'll shift up by 1)
101
+ # After shifting, the old .max_files.gz becomes .(max_files+1).gz
102
+ # so we need to delete .max_files.gz before shifting
103
+ _delete_excess_archives(base, stem, max_files)
104
+
105
+ # Step 2: Shift existing archives up by one number
106
+ _shift_archives(base, stem, max_files)
107
+
108
+ # Step 3: Copy current log to temporary file, compress to .1.gz
109
+ archive_path = base / f"{stem}.1.gz"
110
+ _copy_and_compress(log_path, archive_path)
111
+
112
+ # Step 4: Truncate the original log file in-place
113
+ _truncate_file(log_path)
114
+
115
+
116
+ def _archive_path_for(base: Path, stem: str, number: int) -> Path:
117
+ """Return the path for archive number N.
118
+
119
+ Args:
120
+ base: Parent directory.
121
+ stem: Log file name (e.g. "fast.log").
122
+ number: Archive number (1 = most recent).
123
+
124
+ Returns:
125
+ Path like ``base/fast.log.1.gz``.
126
+ """
127
+ return base / f"{stem}.{number}.gz"
128
+
129
+
130
+ def _delete_excess_archives(base: Path, stem: str, max_files: int) -> None:
131
+ """Delete archives numbered higher than max_files.
132
+
133
+ After the upcoming shift, what is currently archive N will become
134
+ archive N+1. So we need to remove the current max_files archive
135
+ (which would become max_files+1 after shifting) and any existing
136
+ archives beyond max_files.
137
+
138
+ Scans the logs directory for ALL matching archive files using a glob
139
+ pattern (``<stem>.*.gz``) instead of stopping at the first missing
140
+ sequential index. Archives are sorted by modification time; the
141
+ oldest are deleted until the count is at or below ``max_files - 1``
142
+ (leaving room for the new rotation).
143
+
144
+ Args:
145
+ base: Parent directory.
146
+ stem: Log file name.
147
+ max_files: Maximum archives to keep.
148
+ """
149
+ import re
150
+
151
+ pattern = re.compile(rf"^{re.escape(stem)}\.(\d+)\.gz$")
152
+ archives: list[Path] = []
153
+
154
+ for path in base.iterdir():
155
+ if pattern.match(path.name) and path.is_file():
156
+ archives.append(path)
157
+
158
+ # We need room for the new archive that will become .1.gz after
159
+ # shifting, so keep at most max_files - 1 existing archives.
160
+ if len(archives) < max_files:
161
+ return
162
+
163
+ # Sort by modification time, oldest first
164
+ archives.sort(key=lambda p: p.stat().st_mtime)
165
+
166
+ # Delete oldest until we have at most max_files - 1
167
+ excess = len(archives) - (max_files - 1)
168
+ for path in archives[:excess]:
169
+ path.unlink()
170
+
171
+
172
+ def _shift_archives(base: Path, stem: str, max_files: int) -> None:
173
+ """Shift existing archives up by one number.
174
+
175
+ Moves .N.gz → .(N+1).gz, starting from the highest existing
176
+ number down to 1, to avoid overwriting.
177
+
178
+ Args:
179
+ base: Parent directory.
180
+ stem: Log file name.
181
+ max_files: Maximum archives (used as upper bound for search).
182
+ """
183
+ # Find the highest existing archive number (up to max_files - 1,
184
+ # since max_files was already deleted)
185
+ highest = 0
186
+ for i in range(1, max_files):
187
+ if _archive_path_for(base, stem, i).exists():
188
+ highest = i
189
+
190
+ # Shift from highest down to 1
191
+ for i in range(highest, 0, -1):
192
+ src = _archive_path_for(base, stem, i)
193
+ dst = _archive_path_for(base, stem, i + 1)
194
+ if src.exists():
195
+ src.rename(dst)
196
+
197
+
198
+ def _copy_and_compress(src: Path, dst_gz: Path) -> None:
199
+ """Copy a file and compress it to a gzip archive.
200
+
201
+ Uses shutil.copy2 to copy to a temporary file, then compresses
202
+ the temporary file to the target gzip path.
203
+
204
+ Args:
205
+ src: Source log file path.
206
+ dst_gz: Destination gzip archive path.
207
+ """
208
+ # Create a temporary uncompressed copy
209
+ tmp_path = dst_gz.parent / f"{dst_gz.name}.tmp"
210
+ try:
211
+ shutil.copy2(str(src), str(tmp_path))
212
+
213
+ # Compress the copy
214
+ with open(tmp_path, "rb") as f_in:
215
+ with gzip.open(str(dst_gz), "wb") as f_out:
216
+ shutil.copyfileobj(f_in, f_out)
217
+ finally:
218
+ # Clean up temporary file
219
+ if tmp_path.exists():
220
+ tmp_path.unlink()
221
+
222
+
223
+ def _truncate_file(path: Path) -> None:
224
+ """Truncate a file in-place, preserving the path.
225
+
226
+ Uses ``open(path, "w").close()`` to truncate to zero bytes.
227
+
228
+ Args:
229
+ path: Path to the file to truncate.
230
+ """
231
+ open(path, "w").close() # noqa: SIM115
@@ -0,0 +1,386 @@
1
+ """Log viewing and management module for mlx-stack.
2
+
3
+ Provides core functionality for viewing, following, listing, and rotating
4
+ service log files in ~/.mlx-stack/logs/. Supports tail-style viewing,
5
+ real-time following with truncation detection, log listing with metadata,
6
+ on-demand rotation, and archived log viewing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import re
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from mlx_stack.core.config import get_value
20
+ from mlx_stack.core.log_rotation import LogRotationError, rotate_log
21
+ from mlx_stack.core.paths import get_logs_dir
22
+
23
+ # --------------------------------------------------------------------------- #
24
+ # Constants
25
+ # --------------------------------------------------------------------------- #
26
+
27
+ # Polling interval for --follow mode (seconds)
28
+ FOLLOW_POLL_INTERVAL = 0.5
29
+
30
+ # Default number of lines for --tail
31
+ DEFAULT_TAIL_LINES = 50
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # Data classes
35
+ # --------------------------------------------------------------------------- #
36
+
37
+
38
+ @dataclass
39
+ class LogFileInfo:
40
+ """Metadata about a log file."""
41
+
42
+ name: str
43
+ service: str
44
+ size_bytes: int
45
+ modified: datetime
46
+ is_archive: bool = False
47
+
48
+ @property
49
+ def size_display(self) -> str:
50
+ """Return human-readable file size."""
51
+ if self.size_bytes < 1024:
52
+ return f"{self.size_bytes} B"
53
+ elif self.size_bytes < 1024 * 1024:
54
+ return f"{self.size_bytes / 1024:.1f} KB"
55
+ elif self.size_bytes < 1024 * 1024 * 1024:
56
+ return f"{self.size_bytes / (1024 * 1024):.1f} MB"
57
+ else:
58
+ return f"{self.size_bytes / (1024 * 1024 * 1024):.1f} GB"
59
+
60
+ @property
61
+ def modified_display(self) -> str:
62
+ """Return human-readable modification time."""
63
+ return self.modified.strftime("%Y-%m-%d %H:%M:%S")
64
+
65
+
66
+ @dataclass
67
+ class RotationResult:
68
+ """Result of a rotation operation."""
69
+
70
+ service: str
71
+ rotated: bool
72
+ error: str | None = None
73
+
74
+
75
+ # --------------------------------------------------------------------------- #
76
+ # Log listing
77
+ # --------------------------------------------------------------------------- #
78
+
79
+
80
+ def list_log_files() -> list[LogFileInfo]:
81
+ """List all log files in the logs directory.
82
+
83
+ Returns current log files (*.log) sorted alphabetically. Does not
84
+ include archived .gz files.
85
+
86
+ Returns:
87
+ List of LogFileInfo objects for each log file found.
88
+ """
89
+ logs_dir = get_logs_dir()
90
+ if not logs_dir.exists():
91
+ return []
92
+
93
+ results: list[LogFileInfo] = []
94
+ for path in sorted(logs_dir.iterdir()):
95
+ if path.suffix == ".log" and path.is_file():
96
+ # Extract service name: "fast.log" -> "fast"
97
+ service = path.stem
98
+ try:
99
+ stat = path.stat()
100
+ info = LogFileInfo(
101
+ name=path.name,
102
+ service=service,
103
+ size_bytes=stat.st_size,
104
+ modified=datetime.fromtimestamp(
105
+ stat.st_mtime, tz=timezone.utc
106
+ ),
107
+ )
108
+ results.append(info)
109
+ except OSError:
110
+ continue
111
+
112
+ return results
113
+
114
+
115
+ def get_log_path(service: str) -> Path | None:
116
+ """Get the path to a service's log file.
117
+
118
+ Args:
119
+ service: The service name (e.g. "fast", "litellm").
120
+
121
+ Returns:
122
+ Path to the log file, or None if it doesn't exist.
123
+ """
124
+ logs_dir = get_logs_dir()
125
+ log_path = logs_dir / f"{service}.log"
126
+ if log_path.exists() and log_path.is_file():
127
+ return log_path
128
+ return None
129
+
130
+
131
+ def get_available_services() -> list[str]:
132
+ """Get list of services that have log files.
133
+
134
+ Returns:
135
+ Sorted list of service names with existing log files.
136
+ """
137
+ return [info.service for info in list_log_files()]
138
+
139
+
140
+ # --------------------------------------------------------------------------- #
141
+ # Log viewing
142
+ # --------------------------------------------------------------------------- #
143
+
144
+
145
+ def read_log_tail(log_path: Path, num_lines: int = DEFAULT_TAIL_LINES) -> str:
146
+ """Read the last N lines from a log file.
147
+
148
+ Args:
149
+ log_path: Path to the log file.
150
+ num_lines: Number of lines to return from the end.
151
+
152
+ Returns:
153
+ String containing the last N lines of the file.
154
+ """
155
+ try:
156
+ content = log_path.read_text(encoding="utf-8", errors="replace")
157
+ except OSError:
158
+ return ""
159
+
160
+ if not content:
161
+ return ""
162
+
163
+ lines = content.splitlines()
164
+ tail_lines = lines[-num_lines:] if len(lines) > num_lines else lines
165
+ return "\n".join(tail_lines)
166
+
167
+
168
+ def read_log_full(log_path: Path) -> str:
169
+ """Read the full content of a log file.
170
+
171
+ Args:
172
+ log_path: Path to the log file.
173
+
174
+ Returns:
175
+ Full file content as a string.
176
+ """
177
+ try:
178
+ return log_path.read_text(encoding="utf-8", errors="replace")
179
+ except OSError:
180
+ return ""
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # Log following
185
+ # --------------------------------------------------------------------------- #
186
+
187
+
188
+ def follow_log(
189
+ log_path: Path,
190
+ num_lines: int = DEFAULT_TAIL_LINES,
191
+ output_callback: Callable[[str], None] | None = None,
192
+ ) -> None:
193
+ """Follow a log file, printing new content as it appears.
194
+
195
+ Implements tail -f behavior using polling. Checks file size every
196
+ FOLLOW_POLL_INTERVAL seconds, reads new content when size increases.
197
+ Detects file truncation (size decrease) and resets read position.
198
+
199
+ This function blocks until interrupted. Ctrl-C is handled cleanly
200
+ (no traceback, exit 0).
201
+
202
+ Args:
203
+ log_path: Path to the log file to follow.
204
+ num_lines: Number of initial lines to show.
205
+ output_callback: Optional callable for writing output. If None,
206
+ uses print(). Signature: callback(text: str) -> None.
207
+ """
208
+ write = output_callback or print
209
+
210
+ # Show initial tail
211
+ initial = read_log_tail(log_path, num_lines)
212
+ if initial:
213
+ write(initial)
214
+
215
+ # Start following from current end of file
216
+ try:
217
+ position = log_path.stat().st_size
218
+ except OSError:
219
+ position = 0
220
+
221
+ try:
222
+ while True:
223
+ time.sleep(FOLLOW_POLL_INTERVAL)
224
+
225
+ try:
226
+ current_size = log_path.stat().st_size
227
+ except OSError:
228
+ # File may have been removed temporarily
229
+ continue
230
+
231
+ # Detect truncation (copytruncate rotation)
232
+ if current_size < position:
233
+ position = 0
234
+
235
+ if current_size > position:
236
+ try:
237
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
238
+ f.seek(position)
239
+ new_content = f.read()
240
+ if new_content:
241
+ # Strip trailing newline to avoid double-spacing
242
+ text = new_content.rstrip("\n")
243
+ if text:
244
+ write(text)
245
+ position = f.tell()
246
+ except OSError:
247
+ continue
248
+
249
+ except KeyboardInterrupt:
250
+ # Clean exit on Ctrl-C — no traceback
251
+ pass
252
+
253
+
254
+ # --------------------------------------------------------------------------- #
255
+ # Archived log viewing
256
+ # --------------------------------------------------------------------------- #
257
+
258
+
259
+ def _get_archives_for_service(service: str) -> list[Path]:
260
+ """Get archived log files for a service, sorted oldest first.
261
+
262
+ Archives are named like ``<service>.log.N.gz`` where N=1 is the most
263
+ recent. We return them sorted with the highest N first (oldest first)
264
+ for chronological display.
265
+
266
+ Args:
267
+ service: The service name.
268
+
269
+ Returns:
270
+ List of archive paths, sorted oldest first.
271
+ """
272
+ logs_dir = get_logs_dir()
273
+ if not logs_dir.exists():
274
+ return []
275
+
276
+ pattern = re.compile(rf"^{re.escape(service)}\.log\.(\d+)\.gz$")
277
+ archives: list[tuple[int, Path]] = []
278
+
279
+ for path in logs_dir.iterdir():
280
+ match = pattern.match(path.name)
281
+ if match and path.is_file():
282
+ num = int(match.group(1))
283
+ archives.append((num, path))
284
+
285
+ # Sort by number descending (highest number = oldest)
286
+ archives.sort(key=lambda x: x[0], reverse=True)
287
+ return [path for _, path in archives]
288
+
289
+
290
+ def read_archive(archive_path: Path) -> str:
291
+ """Read and decompress a gzip archive.
292
+
293
+ Args:
294
+ archive_path: Path to the .gz archive file.
295
+
296
+ Returns:
297
+ Decompressed content as a string.
298
+ """
299
+ try:
300
+ with gzip.open(str(archive_path), "rb") as f:
301
+ return f.read().decode("utf-8", errors="replace")
302
+ except (OSError, gzip.BadGzipFile):
303
+ return f"[Error reading archive: {archive_path.name}]\n"
304
+
305
+
306
+ def read_all_logs(service: str) -> str:
307
+ """Read all logs for a service: archives + current, chronologically.
308
+
309
+ Archives are shown oldest first, then the current log file.
310
+
311
+ Args:
312
+ service: The service name.
313
+
314
+ Returns:
315
+ Combined content from all archives and the current log.
316
+ """
317
+ parts: list[str] = []
318
+
319
+ # Read archives oldest first
320
+ archives = _get_archives_for_service(service)
321
+ for archive_path in archives:
322
+ content = read_archive(archive_path)
323
+ if content:
324
+ parts.append(f"--- Archive: {archive_path.name} ---")
325
+ parts.append(content.rstrip("\n"))
326
+
327
+ # Read current log
328
+ log_path = get_log_path(service)
329
+ if log_path is not None:
330
+ current = read_log_full(log_path)
331
+ if current:
332
+ parts.append(f"--- Current: {log_path.name} ---")
333
+ parts.append(current.rstrip("\n"))
334
+
335
+ return "\n".join(parts) if parts else ""
336
+
337
+
338
+ # --------------------------------------------------------------------------- #
339
+ # On-demand rotation
340
+ # --------------------------------------------------------------------------- #
341
+
342
+
343
+ def rotate_service_log(service: str) -> RotationResult:
344
+ """Rotate a single service's log file.
345
+
346
+ Uses configured max_size_mb and max_files from the config module.
347
+
348
+ Args:
349
+ service: The service name.
350
+
351
+ Returns:
352
+ RotationResult indicating whether rotation was performed.
353
+ """
354
+ log_path = get_log_path(service)
355
+ if log_path is None:
356
+ return RotationResult(
357
+ service=service,
358
+ rotated=False,
359
+ error=f"No log file found for service '{service}'",
360
+ )
361
+
362
+ max_size_mb = get_value("log-max-size-mb")
363
+ max_files = get_value("log-max-files")
364
+
365
+ try:
366
+ rotated = rotate_log(log_path, max_size_mb=max_size_mb, max_files=max_files)
367
+ return RotationResult(service=service, rotated=rotated)
368
+ except LogRotationError as exc:
369
+ return RotationResult(service=service, rotated=False, error=str(exc))
370
+
371
+
372
+ def rotate_all_logs() -> list[RotationResult]:
373
+ """Rotate all eligible service log files.
374
+
375
+ Returns:
376
+ List of RotationResult objects, one per service.
377
+ """
378
+ services = get_available_services()
379
+ if not services:
380
+ return []
381
+
382
+ results: list[RotationResult] = []
383
+ for service in services:
384
+ result = rotate_service_log(service)
385
+ results.append(result)
386
+ return results