personal memory agent
at main 519 lines 16 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Cross-platform background service management for solstone. 5 6Usage: 7 sol service install [--port PORT] Install solstone as a background service 8 sol service uninstall Remove the background service 9 sol service start Start the background service 10 sol service stop Stop the background service 11 sol service restart [--if-installed] Restart the background service 12 sol service status Show service installation and runtime status 13 sol service logs View service logs 14 sol service logs -f Follow service logs 15 16 sol up Install (if needed), start, and show status 17 sol down Stop the background service 18 19Default convey port for installed services is 5015. 20""" 21 22from __future__ import annotations 23 24import os 25import plistlib 26import subprocess 27import sys 28from pathlib import Path 29 30from think.utils import get_journal, get_journal_info 31 32SERVICE_LABEL = "org.solpbc.solstone" 33SYSTEMD_UNIT = "solstone" 34DEFAULT_SERVICE_PORT = 5015 35 36 37def _platform() -> str: 38 """Return 'darwin', 'linux', or raise on unsupported.""" 39 if sys.platform == "darwin": 40 return "darwin" 41 elif sys.platform.startswith("linux"): 42 return "linux" 43 else: 44 print(f"Error: unsupported platform '{sys.platform}'", file=sys.stderr) 45 sys.exit(1) 46 47 48def _plist_path() -> Path: 49 return Path.home() / "Library" / "LaunchAgents" / f"{SERVICE_LABEL}.plist" 50 51 52def _unit_path() -> Path: 53 return Path.home() / ".config" / "systemd" / "user" / f"{SYSTEMD_UNIT}.service" 54 55 56def _sol_bin() -> str: 57 """Return absolute path to the sol binary in the current venv.""" 58 return str(Path(sys.executable).parent / "sol") 59 60 61def _collect_env() -> dict[str, str]: 62 """Collect environment variables for the service file. 63 64 Captures HOME and PATH (with venv bin prepended). The real PATH is read 65 from os.environ so installed services inherit the shell's tool visibility. 66 Falls back to /usr/local/bin:/usr/bin:/bin if PATH is unset. API keys are 67 NOT written into service files — the supervisor reads them from journal.json 68 at process startup via setup_cli(). Never propagate _SOLSTONE_JOURNAL_OVERRIDE 69 into service files — installed services should use default path resolution. 70 """ 71 venv_bin = str(Path(sys.executable).parent) 72 base_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin") 73 path = ":".join(dict.fromkeys([venv_bin] + base_path.split(":"))) 74 75 return { 76 "HOME": str(Path.home()), 77 "PATH": path, 78 } 79 80 81def _generate_plist(env: dict[str, str], port: int = DEFAULT_SERVICE_PORT) -> bytes: 82 """Generate a launchd plist for the solstone supervisor.""" 83 journal_path = str(Path(get_journal()).resolve()) 84 sol = _sol_bin() 85 86 plist = { 87 "Label": SERVICE_LABEL, 88 "ProgramArguments": [sol, "supervisor", str(port)], 89 "EnvironmentVariables": env, 90 "RunAtLoad": True, 91 "KeepAlive": True, 92 "StandardOutPath": f"{journal_path}/health/launchd-stdout.log", 93 "StandardErrorPath": f"{journal_path}/health/launchd-stderr.log", 94 } 95 return plistlib.dumps(plist) 96 97 98def _generate_systemd_unit( 99 env: dict[str, str], port: int = DEFAULT_SERVICE_PORT 100) -> str: 101 """Generate a systemd user unit for the solstone supervisor.""" 102 sol = _sol_bin() 103 env_lines = "\n".join(f"Environment={k}={v}" for k, v in sorted(env.items())) 104 105 return ( 106 f"[Unit]\n" 107 f"Description=Solstone Supervisor\n" 108 f"After=default.target\n" 109 f"\n" 110 f"[Service]\n" 111 f"Type=simple\n" 112 f"ExecStart={sol} supervisor {port}\n" 113 f"Restart=on-failure\n" 114 f"RestartSec=5\n" 115 f"{env_lines}\n" 116 f"\n" 117 f"[Install]\n" 118 f"WantedBy=default.target\n" 119 ) 120 121 122def _check_linger() -> None: 123 """Warn if systemd linger is not enabled for the current user.""" 124 try: 125 result = subprocess.run( 126 ["loginctl", "show-user", os.environ.get("USER", ""), "--property=Linger"], 127 capture_output=True, 128 text=True, 129 timeout=5, 130 ) 131 if result.returncode == 0 and "Linger=no" in result.stdout: 132 print( 133 "Warning: systemd linger is not enabled. " 134 "The service will stop when you log out.\n" 135 "Enable it with: sudo loginctl enable-linger $USER" 136 ) 137 except (subprocess.TimeoutExpired, FileNotFoundError): 138 pass 139 140 141def _install(port: int = DEFAULT_SERVICE_PORT) -> int: 142 platform = _platform() 143 env = _collect_env() 144 145 journal_path, _source = get_journal_info() 146 Path(journal_path, "health").mkdir(parents=True, exist_ok=True) 147 148 if platform == "darwin": 149 plist_data = _generate_plist(env, port=port) 150 path = _plist_path() 151 path.parent.mkdir(parents=True, exist_ok=True) 152 153 uid = os.getuid() 154 subprocess.run( 155 ["launchctl", "bootout", f"gui/{uid}", str(path)], 156 capture_output=True, 157 ) 158 159 path.write_bytes(plist_data) 160 print(f"Wrote {path}") 161 162 result = subprocess.run( 163 ["launchctl", "bootstrap", f"gui/{uid}", str(path)], 164 capture_output=True, 165 text=True, 166 ) 167 if result.returncode != 0: 168 print(f"Error loading service: {result.stderr.strip()}", file=sys.stderr) 169 return 1 170 print("Service loaded into launchd") 171 172 else: 173 unit_content = _generate_systemd_unit(env, port=port) 174 path = _unit_path() 175 path.parent.mkdir(parents=True, exist_ok=True) 176 path.write_text(unit_content) 177 print(f"Wrote {path}") 178 179 subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) 180 subprocess.run(["systemctl", "--user", "enable", SYSTEMD_UNIT], check=True) 181 print("Service enabled") 182 183 _check_linger() 184 185 return 0 186 187 188def _uninstall() -> int: 189 platform = _platform() 190 191 if platform == "darwin": 192 path = _plist_path() 193 uid = os.getuid() 194 subprocess.run( 195 ["launchctl", "bootout", f"gui/{uid}", str(path)], 196 capture_output=True, 197 ) 198 if path.exists(): 199 path.unlink() 200 print(f"Removed {path}") 201 else: 202 print("Service was not installed") 203 204 else: 205 path = _unit_path() 206 subprocess.run( 207 ["systemctl", "--user", "stop", SYSTEMD_UNIT], 208 capture_output=True, 209 ) 210 subprocess.run( 211 ["systemctl", "--user", "disable", SYSTEMD_UNIT], 212 capture_output=True, 213 ) 214 if path.exists(): 215 path.unlink() 216 subprocess.run( 217 ["systemctl", "--user", "daemon-reload"], 218 capture_output=True, 219 ) 220 print(f"Removed {path}") 221 else: 222 print("Service was not installed") 223 224 return 0 225 226 227def _start() -> int: 228 platform = _platform() 229 if platform == "darwin": 230 uid = os.getuid() 231 path = _plist_path() 232 if not path.exists(): 233 print( 234 "Error: service not installed. Run 'sol service install' first.", 235 file=sys.stderr, 236 ) 237 return 1 238 result = subprocess.run( 239 ["launchctl", "kickstart", f"gui/{uid}/{SERVICE_LABEL}"], 240 capture_output=True, 241 text=True, 242 ) 243 if result.returncode != 0: 244 print(f"Error starting service: {result.stderr.strip()}", file=sys.stderr) 245 return 1 246 else: 247 if not _unit_path().exists(): 248 print( 249 "Error: service not installed. Run 'sol service install' first.", 250 file=sys.stderr, 251 ) 252 return 1 253 result = subprocess.run( 254 ["systemctl", "--user", "start", SYSTEMD_UNIT], 255 capture_output=True, 256 text=True, 257 ) 258 if result.returncode != 0: 259 print(f"Error starting service: {result.stderr.strip()}", file=sys.stderr) 260 return 1 261 262 print("Service started") 263 return 0 264 265 266def _stop() -> int: 267 platform = _platform() 268 if platform == "darwin": 269 uid = os.getuid() 270 result = subprocess.run( 271 ["launchctl", "kill", "SIGTERM", f"gui/{uid}/{SERVICE_LABEL}"], 272 capture_output=True, 273 text=True, 274 ) 275 if result.returncode != 0: 276 print(f"Error stopping service: {result.stderr.strip()}", file=sys.stderr) 277 return 1 278 else: 279 result = subprocess.run( 280 ["systemctl", "--user", "stop", SYSTEMD_UNIT], 281 capture_output=True, 282 text=True, 283 ) 284 if result.returncode != 0: 285 print(f"Error stopping service: {result.stderr.strip()}", file=sys.stderr) 286 return 1 287 288 print("Service stopped") 289 return 0 290 291 292def _restart(if_installed: bool = False) -> int: 293 platform = _platform() 294 if platform == "darwin": 295 installed = _plist_path().exists() 296 else: 297 installed = _unit_path().exists() 298 299 if not installed: 300 if if_installed: 301 return 0 302 print( 303 "Error: service not installed. Run 'sol service install' first.", 304 file=sys.stderr, 305 ) 306 return 1 307 308 if platform == "darwin": 309 uid = os.getuid() 310 subprocess.run( 311 ["launchctl", "kill", "SIGTERM", f"gui/{uid}/{SERVICE_LABEL}"], 312 capture_output=True, 313 ) 314 result = subprocess.run( 315 ["launchctl", "kickstart", f"gui/{uid}/{SERVICE_LABEL}"], 316 capture_output=True, 317 text=True, 318 ) 319 if result.returncode != 0: 320 print(f"Error restarting service: {result.stderr.strip()}", file=sys.stderr) 321 return 1 322 else: 323 result = subprocess.run( 324 ["systemctl", "--user", "restart", SYSTEMD_UNIT], 325 capture_output=True, 326 text=True, 327 ) 328 if result.returncode != 0: 329 print(f"Error restarting service: {result.stderr.strip()}", file=sys.stderr) 330 return 1 331 332 print("Service restarted") 333 return 0 334 335 336def _status() -> int: 337 platform = _platform() 338 339 if platform == "darwin": 340 installed = _plist_path().exists() 341 else: 342 installed = _unit_path().exists() 343 344 if not installed: 345 print("Service: not installed") 346 print("Run 'sol service install' to install, or 'sol up' to install and start.") 347 return 1 348 349 print("Service: installed") 350 351 if platform == "darwin": 352 uid = os.getuid() 353 result = subprocess.run( 354 ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"], 355 capture_output=True, 356 text=True, 357 ) 358 if result.returncode == 0: 359 print("State: running (launchd)") 360 else: 361 print("State: stopped") 362 return 0 363 else: 364 result = subprocess.run( 365 ["systemctl", "--user", "is-active", SYSTEMD_UNIT], 366 capture_output=True, 367 text=True, 368 ) 369 state = result.stdout.strip() 370 if state == "active": 371 print("State: running (systemd)") 372 else: 373 print(f"State: {state}") 374 return 0 375 376 print() 377 from think.health_cli import health_check 378 379 return health_check() 380 381 382def _logs(follow: bool = False) -> int: 383 platform = _platform() 384 385 if platform == "linux": 386 cmd = ["journalctl", "--user", "-u", SYSTEMD_UNIT, "--no-pager", "-n", "100"] 387 if follow: 388 cmd.append("--follow") 389 result = subprocess.run(cmd) 390 return result.returncode 391 else: 392 journal_path = Path(get_journal()) 393 stdout_log = journal_path / "health" / "launchd-stdout.log" 394 stderr_log = journal_path / "health" / "launchd-stderr.log" 395 396 if follow: 397 logs_to_follow = [str(p) for p in [stdout_log, stderr_log] if p.exists()] 398 if not logs_to_follow: 399 print("No service log files found", file=sys.stderr) 400 return 1 401 result = subprocess.run(["/usr/bin/tail", "-f"] + logs_to_follow) 402 return result.returncode 403 else: 404 for log_path in [stdout_log, stderr_log]: 405 if log_path.exists(): 406 print(f"=== {log_path.name} ===") 407 print(log_path.read_text(errors="replace")[-10000:]) 408 else: 409 print(f"=== {log_path.name} === (not found)") 410 return 0 411 412 413def _up(port: int = DEFAULT_SERVICE_PORT) -> int: 414 """Install if needed, start if not running, show status.""" 415 platform = _platform() 416 417 if platform == "darwin": 418 installed = _plist_path().exists() 419 else: 420 installed = _unit_path().exists() 421 422 if not installed: 423 print("Installing service...") 424 rc = _install(port=port) 425 if rc != 0: 426 return rc 427 428 if platform == "darwin": 429 uid = os.getuid() 430 result = subprocess.run( 431 ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"], 432 capture_output=True, 433 text=True, 434 ) 435 running = result.returncode == 0 436 else: 437 result = subprocess.run( 438 ["systemctl", "--user", "is-active", SYSTEMD_UNIT], 439 capture_output=True, 440 text=True, 441 ) 442 running = result.stdout.strip() == "active" 443 444 if not running: 445 print("Starting service...") 446 rc = _start() 447 if rc != 0: 448 return rc 449 450 return _status() 451 452 453def _down() -> int: 454 """Stop the service.""" 455 return _stop() 456 457 458_SUBCOMMANDS = { 459 "uninstall": _uninstall, 460 "start": _start, 461 "stop": _stop, 462 "status": _status, 463 "down": lambda **_kw: _down(), 464} 465 466 467def _parse_port(args: list[str]) -> int: 468 """Extract --port PORT from args, return DEFAULT_SERVICE_PORT if absent.""" 469 for i, arg in enumerate(args): 470 if arg == "--port" and i + 1 < len(args): 471 try: 472 return int(args[i + 1]) 473 except ValueError: 474 print(f"Error: invalid port '{args[i + 1]}'", file=sys.stderr) 475 sys.exit(1) 476 if arg.startswith("--port="): 477 try: 478 return int(arg.split("=", 1)[1]) 479 except ValueError: 480 print(f"Error: invalid port '{arg}'", file=sys.stderr) 481 sys.exit(1) 482 return DEFAULT_SERVICE_PORT 483 484 485def main() -> None: 486 """Entry point for ``sol service``.""" 487 args = sys.argv[1:] 488 489 if args and args[0] == "logs": 490 follow = "-f" in args[1:] or "--follow" in args[1:] 491 sys.exit(_logs(follow=follow)) 492 493 if not args: 494 print("Usage: sol service <install|uninstall|start|stop|restart|status|logs>") 495 print(" sol service install [--port PORT] (default: 5015)") 496 print( 497 " sol service restart [--if-installed] " 498 "(restart; --if-installed noops if not installed)" 499 ) 500 print(" sol up [--port PORT] (install + start + status)") 501 print(" sol down (stop)") 502 sys.exit(1) 503 504 subcmd = args[0] 505 rest = args[1:] 506 507 if subcmd == "install": 508 sys.exit(_install(port=_parse_port(rest))) 509 elif subcmd == "up": 510 sys.exit(_up(port=_parse_port(rest))) 511 elif subcmd == "restart": 512 if_installed = "--if-installed" in rest 513 sys.exit(_restart(if_installed=if_installed)) 514 elif subcmd in _SUBCOMMANDS: 515 sys.exit(_SUBCOMMANDS[subcmd]()) 516 else: 517 print(f"Unknown subcommand: {subcmd}", file=sys.stderr) 518 print("Available: install, uninstall, start, stop, restart, status, logs") 519 sys.exit(1)