@smilintux/skcapstone 0.4.5 → 0.4.7
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.
- package/.github/workflows/publish.yml +8 -1
- package/docs/CUSTOM_AGENT.md +184 -0
- package/docs/GETTING_STARTED.md +3 -0
- package/launchd/com.skcapstone.daemon.plist +52 -0
- package/launchd/com.skcapstone.memory-compress.plist +45 -0
- package/launchd/com.skcapstone.skcomm-heartbeat.plist +33 -0
- package/launchd/com.skcapstone.skcomm-queue-drain.plist +34 -0
- package/launchd/install-launchd.sh +156 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/archive-sessions.sh +88 -0
- package/scripts/install.sh +39 -8
- package/scripts/notion-api.py +259 -0
- package/scripts/nvidia-proxy.mjs +856 -0
- package/scripts/proxy-monitor.sh +89 -0
- package/scripts/skgateway.mjs +856 -0
- package/scripts/telegram-catchup-all.sh +136 -0
- package/src/skcapstone/__init__.py +1 -1
- package/src/skcapstone/blueprint_registry.py +78 -0
- package/src/skcapstone/blueprints/builtins/itil-operations.yaml +40 -0
- package/src/skcapstone/cli/__init__.py +2 -0
- package/src/skcapstone/cli/daemon.py +116 -41
- package/src/skcapstone/cli/itil.py +434 -0
- package/src/skcapstone/cli/skills_cmd.py +90 -26
- package/src/skcapstone/cli/soul.py +47 -24
- package/src/skcapstone/consciousness_config.py +27 -0
- package/src/skcapstone/coordination.py +1 -0
- package/src/skcapstone/daemon.py +47 -20
- package/src/skcapstone/dreaming.py +761 -0
- package/src/skcapstone/fuse_mount.py +21 -13
- package/src/skcapstone/heartbeat.py +33 -29
- package/src/skcapstone/itil.py +1104 -0
- package/src/skcapstone/launchd.py +426 -0
- package/src/skcapstone/mcp_server.py +258 -0
- package/src/skcapstone/mcp_tools/__init__.py +2 -0
- package/src/skcapstone/mcp_tools/gtd_tools.py +1 -1
- package/src/skcapstone/mcp_tools/itil_tools.py +657 -0
- package/src/skcapstone/mcp_tools/notification_tools.py +12 -11
- package/src/skcapstone/notifications.py +40 -27
- package/src/skcapstone/onboard.py +130 -10
- package/src/skcapstone/scheduled_tasks.py +107 -0
- package/src/skcapstone/service_health.py +81 -2
- package/src/skcapstone/soul.py +19 -0
- package/src/skcapstone/systemd.py +17 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""LaunchD service management for the SKCapstone daemon (macOS).
|
|
2
|
+
|
|
3
|
+
Installs, manages, and queries launchd user agents — the macOS
|
|
4
|
+
equivalent of systemd user services. No root required.
|
|
5
|
+
|
|
6
|
+
Generates plist files dynamically with the correct agent name,
|
|
7
|
+
paths, and environment. Copies them to ~/Library/LaunchAgents/
|
|
8
|
+
and loads via launchctl.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from skcapstone.launchd import install_service, service_status
|
|
12
|
+
install_service(agent_name="myagent")
|
|
13
|
+
status = service_status()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import plistlib
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("skcapstone.launchd")
|
|
29
|
+
|
|
30
|
+
LABEL_PREFIX = "com.skcapstone"
|
|
31
|
+
LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
32
|
+
|
|
33
|
+
# Plist definitions: (suffix, program_args, schedule, nice)
|
|
34
|
+
# program_args use {skenv} and {agent} as placeholders
|
|
35
|
+
_SERVICE_DEFS: list[dict] = [
|
|
36
|
+
{
|
|
37
|
+
"suffix": "daemon",
|
|
38
|
+
"args": ["{skenv}/skcapstone", "daemon", "start", "--foreground"],
|
|
39
|
+
"env": {
|
|
40
|
+
"PYTHONUNBUFFERED": "1",
|
|
41
|
+
"OLLAMA_KEEP_ALIVE": "5m",
|
|
42
|
+
"SKCAPSTONE_AGENT": "{agent}",
|
|
43
|
+
},
|
|
44
|
+
"keep_alive": True,
|
|
45
|
+
"throttle": 10,
|
|
46
|
+
"logs": "{logs}/daemon",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"suffix": "memory-compress",
|
|
50
|
+
"args": ["{skenv}/skcapstone", "memory", "compress"],
|
|
51
|
+
"env": {"PYTHONUNBUFFERED": "1"},
|
|
52
|
+
"calendar": {"Weekday": 0, "Hour": 0, "Minute": 0},
|
|
53
|
+
"nice": 15,
|
|
54
|
+
"logs": "{logs}/memory-compress",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"suffix": "skcomm-heartbeat",
|
|
58
|
+
"args": ["{skenv}/skcomm", "heartbeat"],
|
|
59
|
+
"env": {},
|
|
60
|
+
"interval": 60,
|
|
61
|
+
"nice": 19,
|
|
62
|
+
"logs": "{logs}/skcomm-heartbeat",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"suffix": "skcomm-queue-drain",
|
|
66
|
+
"args": ["{skenv}/skcomm", "queue", "drain"],
|
|
67
|
+
"env": {},
|
|
68
|
+
"interval": 120,
|
|
69
|
+
"nice": 19,
|
|
70
|
+
"logs": "{logs}/skcomm-queue-drain",
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Optional services from other repos
|
|
75
|
+
_OPTIONAL_DEFS: list[dict] = [
|
|
76
|
+
{
|
|
77
|
+
"suffix": "skchat-daemon",
|
|
78
|
+
"args": ["{skenv}/skchat", "daemon", "start", "--interval", "5",
|
|
79
|
+
"--log-file", "{home}/.skchat/daemon.log"],
|
|
80
|
+
"env": {"SKCHAT_IDENTITY": "capauth:{agent}@skworld.io"},
|
|
81
|
+
"keep_alive": True,
|
|
82
|
+
"throttle": 5,
|
|
83
|
+
"logs": "{skchat}/launchd",
|
|
84
|
+
"requires_bin": "skchat",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"suffix": "skcomm-api",
|
|
88
|
+
"args": ["{skenv}/python3", "-m", "uvicorn", "skcomm.api:app",
|
|
89
|
+
"--host", "127.0.0.1", "--port", "9384", "--log-level", "info"],
|
|
90
|
+
"env": {"SKCHAT_IDENTITY": "capauth:{agent}@skworld.io"},
|
|
91
|
+
"keep_alive": True,
|
|
92
|
+
"throttle": 5,
|
|
93
|
+
"logs": "{skcomm}/launchd",
|
|
94
|
+
"requires_bin": "skcomm",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"suffix": "skcomm-daemon",
|
|
98
|
+
"args": ["{skenv}/skcomm", "daemon", "--all-agents", "--interval", "5"],
|
|
99
|
+
"env": {},
|
|
100
|
+
"keep_alive": True,
|
|
101
|
+
"throttle": 5,
|
|
102
|
+
"logs": "{skcomm}/daemon",
|
|
103
|
+
"requires_bin": "skcomm",
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_macos() -> None:
|
|
109
|
+
"""Raise RuntimeError if not running on macOS."""
|
|
110
|
+
if platform.system() != "Darwin":
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
"launchd is only available on macOS. Use systemd on Linux."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _skenv_bin() -> str:
|
|
117
|
+
"""Return the skenv bin directory."""
|
|
118
|
+
return str(Path.home() / ".skenv" / "bin")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _expand(s: str, agent: str) -> str:
|
|
122
|
+
"""Expand placeholders in a string."""
|
|
123
|
+
home = str(Path.home())
|
|
124
|
+
return (
|
|
125
|
+
s.replace("{skenv}", _skenv_bin())
|
|
126
|
+
.replace("{agent}", agent)
|
|
127
|
+
.replace("{home}", home)
|
|
128
|
+
.replace("{logs}", f"{home}/.skcapstone/logs")
|
|
129
|
+
.replace("{skchat}", f"{home}/.skchat")
|
|
130
|
+
.replace("{skcomm}", f"{home}/.skcomm")
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _build_plist(defn: dict, agent: str) -> dict:
|
|
135
|
+
"""Build a plist dict from a service definition."""
|
|
136
|
+
label = f"{LABEL_PREFIX}.{defn['suffix']}"
|
|
137
|
+
skenv = _skenv_bin()
|
|
138
|
+
home = str(Path.home())
|
|
139
|
+
|
|
140
|
+
plist: dict = {
|
|
141
|
+
"Label": label,
|
|
142
|
+
"ProgramArguments": [_expand(a, agent) for a in defn["args"]],
|
|
143
|
+
"EnvironmentVariables": {
|
|
144
|
+
k: _expand(v, agent) for k, v in defn.get("env", {}).items()
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Ensure PATH includes skenv and Homebrew
|
|
149
|
+
plist["EnvironmentVariables"]["PATH"] = (
|
|
150
|
+
f"{skenv}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if defn.get("keep_alive"):
|
|
154
|
+
plist["RunAtLoad"] = True
|
|
155
|
+
plist["KeepAlive"] = {"SuccessfulExit": False}
|
|
156
|
+
|
|
157
|
+
if defn.get("throttle"):
|
|
158
|
+
plist["ThrottleInterval"] = defn["throttle"]
|
|
159
|
+
|
|
160
|
+
if defn.get("interval"):
|
|
161
|
+
plist["StartInterval"] = defn["interval"]
|
|
162
|
+
|
|
163
|
+
if defn.get("calendar"):
|
|
164
|
+
plist["StartCalendarInterval"] = defn["calendar"]
|
|
165
|
+
|
|
166
|
+
if defn.get("nice"):
|
|
167
|
+
plist["Nice"] = defn["nice"]
|
|
168
|
+
|
|
169
|
+
logs_base = _expand(defn.get("logs", f"{home}/.skcapstone/logs/misc"), agent)
|
|
170
|
+
plist["StandardOutPath"] = f"{logs_base}.stdout.log"
|
|
171
|
+
plist["StandardErrorPath"] = f"{logs_base}.stderr.log"
|
|
172
|
+
|
|
173
|
+
return plist
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _label(suffix: str) -> str:
|
|
177
|
+
return f"{LABEL_PREFIX}.{suffix}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _launchctl_boot(label: str, plist_path: Path, load: bool = True) -> bool:
|
|
181
|
+
"""Load or unload a plist via launchctl."""
|
|
182
|
+
uid = os.getuid()
|
|
183
|
+
domain = f"gui/{uid}"
|
|
184
|
+
|
|
185
|
+
if not load:
|
|
186
|
+
# Unload
|
|
187
|
+
r = subprocess.run(
|
|
188
|
+
["launchctl", "bootout", f"{domain}/{label}"],
|
|
189
|
+
capture_output=True, text=True, timeout=10,
|
|
190
|
+
)
|
|
191
|
+
return r.returncode == 0
|
|
192
|
+
|
|
193
|
+
# Load — try modern bootstrap first, fall back to legacy load
|
|
194
|
+
r = subprocess.run(
|
|
195
|
+
["launchctl", "bootstrap", domain, str(plist_path)],
|
|
196
|
+
capture_output=True, text=True, timeout=10,
|
|
197
|
+
)
|
|
198
|
+
if r.returncode == 0:
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
r = subprocess.run(
|
|
202
|
+
["launchctl", "load", str(plist_path)],
|
|
203
|
+
capture_output=True, text=True, timeout=10,
|
|
204
|
+
)
|
|
205
|
+
return r.returncode == 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class ServiceStatus:
|
|
210
|
+
"""Status of an SK launchd service."""
|
|
211
|
+
installed: bool = False
|
|
212
|
+
loaded: bool = False
|
|
213
|
+
running: bool = False
|
|
214
|
+
pid: int = 0
|
|
215
|
+
exit_code: int = 0
|
|
216
|
+
label: str = ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def launchd_available() -> bool:
|
|
220
|
+
"""Check if launchd is available (i.e., we're on macOS)."""
|
|
221
|
+
return platform.system() == "Darwin"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def list_available_services(agent: str = "sovereign") -> list[dict]:
|
|
225
|
+
"""Return all service definitions, marking which are available.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
agent: Agent name for path expansion.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of dicts with 'suffix', 'label', 'available', 'description'.
|
|
232
|
+
"""
|
|
233
|
+
skenv = _skenv_bin()
|
|
234
|
+
services = []
|
|
235
|
+
|
|
236
|
+
for defn in _SERVICE_DEFS:
|
|
237
|
+
services.append({
|
|
238
|
+
"suffix": defn["suffix"],
|
|
239
|
+
"label": _label(defn["suffix"]),
|
|
240
|
+
"available": True,
|
|
241
|
+
"description": defn["suffix"].replace("-", " ").title(),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
for defn in _OPTIONAL_DEFS:
|
|
245
|
+
req_bin = defn.get("requires_bin")
|
|
246
|
+
available = bool(shutil.which(req_bin, path=skenv)) if req_bin else True
|
|
247
|
+
services.append({
|
|
248
|
+
"suffix": defn["suffix"],
|
|
249
|
+
"label": _label(defn["suffix"]),
|
|
250
|
+
"available": available,
|
|
251
|
+
"description": defn["suffix"].replace("-", " ").title(),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
return services
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def install_service(
|
|
258
|
+
agent_name: str = "sovereign",
|
|
259
|
+
services: Optional[list[str]] = None,
|
|
260
|
+
enable: bool = True,
|
|
261
|
+
start: bool = False,
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""Install launchd user agents for skcapstone.
|
|
264
|
+
|
|
265
|
+
Generates plist files dynamically with the given agent name,
|
|
266
|
+
writes them to ~/Library/LaunchAgents/, and optionally loads them.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
agent_name: Agent name (used in SKCAPSTONE_AGENT env var and paths).
|
|
270
|
+
services: List of service suffixes to install. None = all core services.
|
|
271
|
+
enable: Write plists to LaunchAgents (always True for launchd).
|
|
272
|
+
start: Load/start services immediately after installing.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
dict with 'installed', 'loaded', 'services' list.
|
|
276
|
+
"""
|
|
277
|
+
_require_macos()
|
|
278
|
+
LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
# Ensure log directories exist
|
|
281
|
+
log_dirs = [
|
|
282
|
+
Path.home() / ".skcapstone" / "logs",
|
|
283
|
+
Path.home() / ".skchat",
|
|
284
|
+
Path.home() / ".skcomm",
|
|
285
|
+
]
|
|
286
|
+
for d in log_dirs:
|
|
287
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
|
|
289
|
+
all_defs = _SERVICE_DEFS + _OPTIONAL_DEFS
|
|
290
|
+
if services:
|
|
291
|
+
all_defs = [d for d in all_defs if d["suffix"] in services]
|
|
292
|
+
|
|
293
|
+
result = {"installed": False, "loaded": False, "services": []}
|
|
294
|
+
installed_count = 0
|
|
295
|
+
|
|
296
|
+
for defn in all_defs:
|
|
297
|
+
suffix = defn["suffix"]
|
|
298
|
+
label = _label(suffix)
|
|
299
|
+
|
|
300
|
+
# Skip optional services whose binary isn't installed
|
|
301
|
+
req_bin = defn.get("requires_bin")
|
|
302
|
+
if req_bin and not shutil.which(req_bin, path=_skenv_bin()):
|
|
303
|
+
logger.debug("Skipping %s — %s not found", suffix, req_bin)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
plist_data = _build_plist(defn, agent_name)
|
|
307
|
+
plist_path = LAUNCH_AGENTS_DIR / f"{label}.plist"
|
|
308
|
+
|
|
309
|
+
with open(plist_path, "wb") as f:
|
|
310
|
+
plistlib.dump(plist_data, f)
|
|
311
|
+
|
|
312
|
+
svc_result = {"suffix": suffix, "label": label, "installed": True, "loaded": False}
|
|
313
|
+
installed_count += 1
|
|
314
|
+
|
|
315
|
+
if start:
|
|
316
|
+
# Unload first if already loaded
|
|
317
|
+
_launchctl_boot(label, plist_path, load=False)
|
|
318
|
+
loaded = _launchctl_boot(label, plist_path, load=True)
|
|
319
|
+
svc_result["loaded"] = loaded
|
|
320
|
+
|
|
321
|
+
result["services"].append(svc_result)
|
|
322
|
+
|
|
323
|
+
result["installed"] = installed_count > 0
|
|
324
|
+
result["loaded"] = start and all(s["loaded"] for s in result["services"])
|
|
325
|
+
logger.info("Installed %d launchd plist(s) for agent '%s'", installed_count, agent_name)
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def uninstall_service() -> dict:
|
|
330
|
+
"""Uninstall all SK launchd user agents.
|
|
331
|
+
|
|
332
|
+
Unloads running services and removes plist files.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
dict with 'stopped', 'removed' bools and 'services' list.
|
|
336
|
+
"""
|
|
337
|
+
_require_macos()
|
|
338
|
+
result = {"stopped": False, "removed": False, "services": []}
|
|
339
|
+
removed = 0
|
|
340
|
+
|
|
341
|
+
for plist_path in sorted(LAUNCH_AGENTS_DIR.glob(f"{LABEL_PREFIX}.*.plist")):
|
|
342
|
+
label = plist_path.stem # e.g., com.skcapstone.daemon
|
|
343
|
+
_launchctl_boot(label, plist_path, load=False)
|
|
344
|
+
plist_path.unlink(missing_ok=True)
|
|
345
|
+
result["services"].append(label)
|
|
346
|
+
removed += 1
|
|
347
|
+
|
|
348
|
+
result["stopped"] = removed > 0
|
|
349
|
+
result["removed"] = removed > 0
|
|
350
|
+
logger.info("Uninstalled %d launchd plist(s)", removed)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def service_status(suffix: str = "daemon") -> ServiceStatus:
|
|
355
|
+
"""Query the status of a specific SK launchd service.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
suffix: Service suffix (e.g., 'daemon', 'skcomm-heartbeat').
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
ServiceStatus with current state.
|
|
362
|
+
"""
|
|
363
|
+
_require_macos()
|
|
364
|
+
label = _label(suffix)
|
|
365
|
+
status = ServiceStatus(label=label)
|
|
366
|
+
|
|
367
|
+
plist_path = LAUNCH_AGENTS_DIR / f"{label}.plist"
|
|
368
|
+
status.installed = plist_path.exists()
|
|
369
|
+
if not status.installed:
|
|
370
|
+
return status
|
|
371
|
+
|
|
372
|
+
r = subprocess.run(
|
|
373
|
+
["launchctl", "list"],
|
|
374
|
+
capture_output=True, text=True, timeout=10,
|
|
375
|
+
)
|
|
376
|
+
for line in r.stdout.splitlines():
|
|
377
|
+
parts = line.split("\t")
|
|
378
|
+
if len(parts) >= 3 and parts[2] == label:
|
|
379
|
+
status.loaded = True
|
|
380
|
+
try:
|
|
381
|
+
pid = int(parts[0])
|
|
382
|
+
if pid > 0:
|
|
383
|
+
status.running = True
|
|
384
|
+
status.pid = pid
|
|
385
|
+
except (ValueError, IndexError):
|
|
386
|
+
pass
|
|
387
|
+
try:
|
|
388
|
+
status.exit_code = int(parts[1])
|
|
389
|
+
except (ValueError, IndexError):
|
|
390
|
+
pass
|
|
391
|
+
break
|
|
392
|
+
|
|
393
|
+
return status
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def service_logs(suffix: str = "daemon", lines: int = 50) -> str:
|
|
397
|
+
"""Get recent logs for an SK launchd service.
|
|
398
|
+
|
|
399
|
+
Reads the stdout/stderr log files written by launchd.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
suffix: Service suffix.
|
|
403
|
+
lines: Number of tail lines.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Combined log output.
|
|
407
|
+
"""
|
|
408
|
+
_require_macos()
|
|
409
|
+
logs_dir = Path.home() / ".skcapstone" / "logs"
|
|
410
|
+
stdout_log = logs_dir / f"{suffix}.stdout.log"
|
|
411
|
+
stderr_log = logs_dir / f"{suffix}.stderr.log"
|
|
412
|
+
|
|
413
|
+
output_parts = []
|
|
414
|
+
for log_path in (stdout_log, stderr_log):
|
|
415
|
+
if log_path.exists():
|
|
416
|
+
try:
|
|
417
|
+
r = subprocess.run(
|
|
418
|
+
["tail", "-n", str(lines), str(log_path)],
|
|
419
|
+
capture_output=True, text=True, timeout=5,
|
|
420
|
+
)
|
|
421
|
+
if r.stdout.strip():
|
|
422
|
+
output_parts.append(f"--- {log_path.name} ---\n{r.stdout}")
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
return "\n".join(output_parts) if output_parts else f"No logs found in {logs_dir}"
|