personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 691 lines 21 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""sol config — show and rewrite the embedded journal path in the wrapper.""" 5 6from __future__ import annotations 7 8import argparse 9import os 10import subprocess 11import sys 12from dataclasses import dataclass 13from enum import Enum 14from pathlib import Path 15 16from think.install_guard import ( 17 alias_path, 18 parse_wrapper, 19 render_wrapper, 20 validate_journal_path_for_wrapper, 21 wrapper_lock, 22 write_wrapper_atomic, 23) 24from think.service import service_is_installed, service_is_running 25from think.utils import ( 26 SolstoneNotConfigured, 27 get_journal_info, 28 get_project_root, 29 journal_is_active, 30) 31 32MERGE_INSTRUCTIONS = "\n".join( 33 [ 34 "sol config: --merge is not handled here.", 35 "use 'sol call journal merge <source> --dry-run' to preview the merge.", 36 "use 'sol call journal merge <source>' to perform the merge.", 37 ] 38) 39 40 41class RequestedAction(Enum): 42 MOVE = "move" 43 SWITCH = "switch" 44 MERGE = "merge" 45 FORCE = "force" 46 47 48class Action(Enum): 49 PROCEED = "proceed" 50 MOVE = "move" 51 SWITCH = "switch" 52 MERGE = "merge" 53 NOOP = "noop" 54 REFUSE = "refuse" 55 56 57@dataclass(frozen=True) 58class JournalChange: 59 current_path: Path 60 target_path: Path 61 paths_equal: bool 62 current_active: bool 63 target_active: bool 64 current_exists: bool 65 target_exists: bool 66 target_parent_exists: bool 67 current_device: int | None 68 target_parent_device: int | None 69 same_filesystem: bool | None 70 service_installed: bool 71 service_running: bool 72 action: RequestedAction | None 73 yes: bool 74 dry_run: bool 75 sol_bin: str 76 alias: Path 77 78 79@dataclass(frozen=True) 80class Decision: 81 action: Action 82 exit_code: int 83 message: str | None = None 84 plan_only: bool = False 85 86 87def _read_wrapper_status() -> tuple[str, str | None]: 88 alias = alias_path() 89 if not alias.exists() and not alias.is_symlink(): 90 return "absent", None 91 if alias.is_symlink(): 92 return "legacy-symlink", None 93 94 try: 95 content = alias.read_text(encoding="utf-8") 96 except OSError: 97 return "foreign", None 98 99 parsed = parse_wrapper(content) 100 if parsed is None: 101 return "foreign", None 102 return "managed", parsed["journal"] 103 104 105def _wrapper_refusal(alias: Path) -> str: 106 return ( 107 "sol config: refused: " 108 f"{alias} is not a managed wrapper (run 'sol setup' from the solstone " 109 "source checkout to install the wrapper first)" 110 ) 111 112 113def _state_label(active: bool) -> str: 114 return "active" if active else "not active" 115 116 117def _valid_flags(change: JournalChange) -> str: 118 if change.current_active and not change.target_active: 119 return "--move, --switch" 120 return "--switch, --merge, --force" 121 122 123def _refusal_message(change: JournalChange) -> str: 124 return ( 125 "sol config: refused: " 126 f"current is {_state_label(change.current_active)} and target is " 127 f"{_state_label(change.target_active)}; valid flags: {_valid_flags(change)}" 128 ) 129 130 131def _move_target_exists_message(change: JournalChange) -> str: 132 return f"sol config: refused: move target already exists: {change.target_path}" 133 134 135def _move_missing_current_message(change: JournalChange) -> str: 136 return f"sol config: refused: move source does not exist: {change.current_path}" 137 138 139def _move_missing_parent_message(change: JournalChange) -> str: 140 return f"sol config: refused: move target parent does not exist: {change.target_path.parent}" 141 142 143def _move_cross_filesystem_message(change: JournalChange) -> str: 144 return ( 145 "sol config: refused: cannot move across filesystems " 146 f"(current device={change.current_device}, target parent device={change.target_parent_device}); " 147 "use 'sol call journal merge <source>' instead" 148 ) 149 150 151def _move_requires_inactive_target_message(change: JournalChange) -> str: 152 return ( 153 "sol config: refused: " 154 f"--move requires a not active target; current is {_state_label(change.current_active)} " 155 f"and target is {_state_label(change.target_active)}; valid flags: --switch, --merge, --force" 156 ) 157 158 159def _plan_closer(change: JournalChange) -> str: 160 if change.dry_run: 161 return "dry-run: yes; nothing will be changed" 162 return "re-run with --yes to proceed" 163 164 165def _service_summary(change: JournalChange, decision: Decision) -> str: 166 if decision.action is Action.MOVE: 167 if not change.service_installed: 168 return "service: not installed; will move and rewrite wrapper" 169 if not change.service_running: 170 return "service: installed but not running; will move and rewrite wrapper" 171 return ( 172 "service: installed and running; will stop, move, rewrite wrapper, restart" 173 ) 174 175 if not change.service_installed: 176 return "service: not installed; will rewrite wrapper" 177 if not change.service_running: 178 return "service: installed but not running; will rewrite wrapper" 179 return "service: installed and running; will rewrite wrapper, restart" 180 181 182def render_plan(change: JournalChange, decision: Decision) -> str: 183 lines = [ 184 "sol config journal - plan summary", 185 "", 186 f"current: {change.current_path} ({_state_label(change.current_active)})", 187 f"target: {change.target_path} ({_state_label(change.target_active)})", 188 f"action: {decision.action.value}", 189 _service_summary(change, decision), 190 ] 191 192 if decision.action is Action.MOVE: 193 filesystem = "same device" if change.same_filesystem else "different devices" 194 lines.append(f"filesystem: {filesystem}") 195 196 if decision.action is Action.SWITCH: 197 lines.extend( 198 [ 199 "", 200 "current journal is left intact. " 201 f"to re-adopt it later: sol config journal {change.current_path} --switch --yes", 202 ] 203 ) 204 205 lines.extend(["", _plan_closer(change)]) 206 return "\n".join(lines) 207 208 209def _rewrite_wrapper(change: JournalChange) -> str | None: 210 try: 211 current_content = change.alias.read_text(encoding="utf-8") 212 except OSError as exc: 213 print( 214 f"sol config: refused: cannot read {change.alias}: {exc}", 215 file=sys.stderr, 216 ) 217 return None 218 219 current = parse_wrapper(current_content) 220 if current is None: 221 print(_wrapper_refusal(change.alias), file=sys.stderr) 222 return None 223 224 target_str = str(change.target_path) 225 if current["journal"] == target_str: 226 return current["sol_bin"] 227 228 new_content = render_wrapper(target_str, current["sol_bin"]) 229 write_wrapper_atomic(change.alias, new_content) 230 return current["sol_bin"] 231 232 233def _service_command(sol_bin: str, subcommand: str) -> subprocess.CompletedProcess: 234 return subprocess.run( 235 [sol_bin, "service", subcommand], 236 check=False, 237 capture_output=True, 238 text=True, 239 ) 240 241 242def _maybe_restart_current_service(change: JournalChange) -> None: 243 if not change.service_running: 244 return 245 try: 246 _service_command(change.sol_bin, "start") 247 except FileNotFoundError as exc: 248 print( 249 f"sol config: rollback warning: could not restart service ({exc})", 250 file=sys.stderr, 251 ) 252 253 254def _run_switch(change: JournalChange) -> int: 255 try: 256 change.target_path.mkdir(parents=True, exist_ok=True) 257 except OSError as exc: 258 print( 259 f"sol config: refused: cannot create {change.target_path}: {exc}", 260 file=sys.stderr, 261 ) 262 return 1 263 264 with wrapper_lock(): 265 try: 266 restart_sol = _rewrite_wrapper(change) 267 except OSError as exc: 268 print( 269 f"sol config: refused: cannot rewrite {change.alias}: {exc}", 270 file=sys.stderr, 271 ) 272 return 1 273 274 if restart_sol is None: 275 return 1 276 277 if not change.service_installed: 278 print("service not installed; wrapper updated.") 279 return 0 280 281 if not change.service_running: 282 print("service installed but not running; wrapper updated.") 283 return 0 284 285 try: 286 result = subprocess.run( 287 [restart_sol, "service", "restart", "--if-installed"], 288 check=False, 289 ) 290 except FileNotFoundError as exc: 291 print( 292 f"sol config: wrapper rewritten to {change.target_path} but service restart could not run ({exc}); restart manually", 293 file=sys.stderr, 294 ) 295 return 2 296 297 if result.returncode != 0: 298 print( 299 "sol config: wrapper rewritten to " 300 f"{change.target_path} but 'sol service restart --if-installed' exited " 301 f"{result.returncode}; investigate and restart manually", 302 file=sys.stderr, 303 ) 304 return 2 305 306 print("wrapper updated; service restarted.") 307 return 0 308 309 310def _run_move(change: JournalChange) -> int: 311 current = change.current_path 312 target = change.target_path 313 314 if not change.target_parent_exists: 315 print(_move_missing_parent_message(change), file=sys.stderr) 316 return 1 317 if not current.exists(): 318 print(_move_missing_current_message(change), file=sys.stderr) 319 return 1 320 if target.exists() or target.is_symlink(): 321 print(_move_target_exists_message(change), file=sys.stderr) 322 return 1 323 if change.same_filesystem is False: 324 print(_move_cross_filesystem_message(change), file=sys.stderr) 325 return 1 326 327 if change.service_running: 328 try: 329 stop_result = _service_command(change.sol_bin, "stop") 330 except FileNotFoundError as exc: 331 print( 332 f"sol config: could not stop service before move ({exc})", 333 file=sys.stderr, 334 ) 335 return 2 336 if stop_result.returncode != 0: 337 print( 338 "sol config: could not stop service before move", 339 file=sys.stderr, 340 ) 341 return 2 342 343 try: 344 os.rename(current, target) 345 except OSError as exc: 346 _maybe_restart_current_service(change) 347 print(f"sol config: move failed: {exc}", file=sys.stderr) 348 return 1 349 350 with wrapper_lock(): 351 try: 352 restart_sol = _rewrite_wrapper(change) 353 except OSError as exc: 354 rollback_ok = True 355 try: 356 if target.exists(): 357 os.rename(target, current) 358 except OSError as rollback_exc: 359 rollback_ok = False 360 print( 361 f"sol config: rollback failed after wrapper write error: {rollback_exc}", 362 file=sys.stderr, 363 ) 364 _maybe_restart_current_service(change) 365 message = f"sol config: move failed during wrapper update: {exc}" 366 if rollback_ok: 367 message += "; restored original journal" 368 print(message, file=sys.stderr) 369 return 2 370 371 if restart_sol is None: 372 try: 373 os.rename(target, current) 374 except OSError as rollback_exc: 375 print( 376 f"sol config: rollback failed after wrapper validation error: {rollback_exc}", 377 file=sys.stderr, 378 ) 379 _maybe_restart_current_service(change) 380 return 1 381 382 if not change.service_installed: 383 print("service not installed; journal moved; wrapper updated.") 384 return 0 385 386 if not change.service_running: 387 print("service installed but not running; journal moved; wrapper updated.") 388 return 0 389 390 try: 391 start_result = _service_command(restart_sol, "start") 392 except FileNotFoundError: 393 print( 394 f"wrapper updated to {target} but service start failed; restart manually", 395 file=sys.stderr, 396 ) 397 return 2 398 399 if start_result.returncode != 0: 400 print( 401 f"wrapper updated to {target} but service start failed; restart manually", 402 file=sys.stderr, 403 ) 404 return 2 405 406 print("journal moved; wrapper updated; service restarted.") 407 return 0 408 409 410def _run_noop(change: JournalChange, _decision: Decision) -> int: 411 print(f"sol config: journal already set to {change.target_path}") 412 return 0 413 414 415def _refuse(decision: Decision) -> int: 416 if decision.message: 417 print(decision.message, file=sys.stderr) 418 return decision.exit_code 419 420 421def decide(change: JournalChange) -> Decision: 422 if change.action is RequestedAction.MERGE: 423 return Decision(Action.MERGE, 1, MERGE_INSTRUCTIONS) 424 425 if change.paths_equal: 426 return Decision(Action.NOOP, 0) 427 428 if change.action is None: 429 if not change.current_active and not change.target_active: 430 return Decision(Action.PROCEED, 0) 431 return Decision(Action.REFUSE, 1, _refusal_message(change)) 432 433 if change.action is RequestedAction.FORCE: 434 return Decision(Action.SWITCH, 0) 435 436 if change.action is RequestedAction.MOVE: 437 if not change.target_parent_exists: 438 return Decision(Action.REFUSE, 1, _move_missing_parent_message(change)) 439 if not change.current_exists: 440 return Decision(Action.REFUSE, 1, _move_missing_current_message(change)) 441 if change.target_exists: 442 return Decision(Action.REFUSE, 1, _move_target_exists_message(change)) 443 if change.target_active: 444 return Decision( 445 Action.REFUSE, 1, _move_requires_inactive_target_message(change) 446 ) 447 if change.same_filesystem is False: 448 return Decision(Action.REFUSE, 1, _move_cross_filesystem_message(change)) 449 if change.dry_run: 450 return Decision(Action.MOVE, 0, plan_only=True) 451 if not change.yes: 452 return Decision(Action.MOVE, 1, plan_only=True) 453 return Decision(Action.MOVE, 0) 454 455 if change.action is RequestedAction.SWITCH: 456 if change.dry_run: 457 return Decision(Action.SWITCH, 0, plan_only=True) 458 if not change.yes: 459 return Decision(Action.SWITCH, 1, plan_only=True) 460 return Decision(Action.SWITCH, 0) 461 462 return Decision(Action.REFUSE, 1, _refusal_message(change)) 463 464 465def execute(change: JournalChange, decision: Decision) -> int: 466 if change.action is RequestedAction.FORCE: 467 print( 468 "sol config: warning: --force bypasses confirmation and target activity checks", 469 file=sys.stderr, 470 ) 471 472 if decision.action is Action.MERGE: 473 print(decision.message or MERGE_INSTRUCTIONS) 474 return decision.exit_code 475 if decision.action is Action.REFUSE: 476 return _refuse(decision) 477 if decision.action is Action.NOOP: 478 return _run_noop(change, decision) 479 if decision.plan_only: 480 print(render_plan(change, decision)) 481 return decision.exit_code 482 if decision.action in {Action.PROCEED, Action.SWITCH}: 483 return _run_switch(change) 484 if decision.action is Action.MOVE: 485 return _run_move(change) 486 return 1 487 488 489def build_change( 490 args: argparse.Namespace, *, alias_path: Path, sol_bin: str, current_path: Path 491) -> JournalChange: 492 target_path = Path(args.path).expanduser().resolve() 493 current_path = current_path.expanduser().resolve() 494 current_exists = current_path.exists() 495 target_exists = target_path.exists() or target_path.is_symlink() 496 target_parent_exists = target_path.parent.exists() 497 current_device = None 498 target_parent_device = None 499 same_filesystem = None 500 if current_exists: 501 try: 502 current_device = os.stat(current_path).st_dev 503 except OSError: 504 current_device = None 505 if target_parent_exists: 506 try: 507 target_parent_device = os.stat(target_path.parent).st_dev 508 except OSError: 509 target_parent_device = None 510 if current_device is not None and target_parent_device is not None: 511 same_filesystem = current_device == target_parent_device 512 513 installed = service_is_installed() 514 running = service_is_running() if installed else False 515 516 return JournalChange( 517 current_path=current_path, 518 target_path=target_path, 519 paths_equal=current_path == target_path, 520 current_active=journal_is_active(current_path), 521 target_active=journal_is_active(target_path), 522 current_exists=current_exists, 523 target_exists=target_exists, 524 target_parent_exists=target_parent_exists, 525 current_device=current_device, 526 target_parent_device=target_parent_device, 527 same_filesystem=same_filesystem, 528 service_installed=installed, 529 service_running=running, 530 action=args.action, 531 yes=args.yes, 532 dry_run=args.dry_run, 533 sol_bin=sol_bin, 534 alias=alias_path, 535 ) 536 537 538def cmd_show() -> int: 539 wrapper_status, embedded_journal = _read_wrapper_status() 540 541 try: 542 path, info_source = get_journal_info() 543 except SolstoneNotConfigured as exc: 544 print(f"sol config: {exc}", file=sys.stderr) 545 return 1 546 547 if info_source == "env": 548 if ( 549 embedded_journal is not None 550 and os.environ.get("SOLSTONE_JOURNAL") == embedded_journal 551 ): 552 user_source = "wrapper-embedded" 553 else: 554 user_source = "caller-override" 555 elif info_source == "config": 556 user_source = "user config (~/.config/solstone/config.toml)" 557 elif info_source == "default": 558 user_source = "built-in default (~/Documents/journal)" 559 else: # "source" 560 user_source = "source-tree fallback" 561 562 print(f"path: {path}") 563 print(f"source: {user_source}") 564 print(f"wrapper-status: {wrapper_status}") 565 return 0 566 567 568def cmd_journal( 569 target_path: str, 570 *, 571 action: RequestedAction | None = None, 572 yes: bool = False, 573 dry_run: bool = False, 574) -> int: 575 target = Path(target_path).expanduser().resolve() 576 target_str = str(target) 577 578 try: 579 validate_journal_path_for_wrapper(target_str) 580 except ValueError as exc: 581 print(f"sol config: refused: {exc}", file=sys.stderr) 582 return 1 583 584 project_root = Path(get_project_root()) 585 is_source_checkout = (project_root / "pyproject.toml").exists() and ( 586 project_root / ".git" 587 ).exists() 588 source_tree_journal = (project_root / "journal").resolve() 589 if target == source_tree_journal and not is_source_checkout: 590 print( 591 "sol config: refused: " 592 f"{target_str} is the source-tree fallback path but this is not a " 593 "source checkout", 594 file=sys.stderr, 595 ) 596 return 1 597 598 if action is RequestedAction.MOVE and not target.parent.exists(): 599 print( 600 f"sol config: refused: move target parent does not exist: {target.parent}", 601 file=sys.stderr, 602 ) 603 return 1 604 605 alias = alias_path() 606 if not alias.exists() or alias.is_symlink(): 607 print(_wrapper_refusal(alias), file=sys.stderr) 608 return 1 609 610 try: 611 content = alias.read_text(encoding="utf-8") 612 except OSError as exc: 613 print(f"sol config: refused: cannot read {alias}: {exc}", file=sys.stderr) 614 return 1 615 616 parsed = parse_wrapper(content) 617 if parsed is None: 618 print(_wrapper_refusal(alias), file=sys.stderr) 619 return 1 620 621 args = argparse.Namespace( 622 path=target_str, 623 action=action, 624 yes=yes, 625 dry_run=dry_run, 626 ) 627 change = build_change( 628 args, 629 alias_path=alias, 630 sol_bin=parsed["sol_bin"], 631 current_path=Path(parsed["journal"]), 632 ) 633 decision = decide(change) 634 return execute(change, decision) 635 636 637def main() -> int: 638 parser = argparse.ArgumentParser(prog="sol config") 639 subparsers = parser.add_subparsers(dest="cmd", required=True) 640 subparsers.add_parser("show", help="show the configured journal path and source") 641 journal_parser = subparsers.add_parser( 642 "journal", 643 help="rewrite the wrapper's embedded journal path", 644 ) 645 journal_parser.add_argument( 646 "path", help="absolute path to the new journal directory" 647 ) 648 action_group = journal_parser.add_mutually_exclusive_group() 649 action_group.add_argument( 650 "--move", 651 dest="action", 652 action="store_const", 653 const=RequestedAction.MOVE, 654 ) 655 action_group.add_argument( 656 "--switch", 657 dest="action", 658 action="store_const", 659 const=RequestedAction.SWITCH, 660 ) 661 action_group.add_argument( 662 "--merge", 663 dest="action", 664 action="store_const", 665 const=RequestedAction.MERGE, 666 ) 667 action_group.add_argument( 668 "--force", 669 dest="action", 670 action="store_const", 671 const=RequestedAction.FORCE, 672 ) 673 confirm_group = journal_parser.add_mutually_exclusive_group() 674 confirm_group.add_argument("--yes", action="store_true") 675 confirm_group.add_argument("--dry-run", action="store_true") 676 args = parser.parse_args() 677 678 if args.cmd == "show": 679 return cmd_show() 680 if args.cmd == "journal": 681 return cmd_journal( 682 args.path, 683 action=args.action, 684 yes=args.yes, 685 dry_run=args.dry_run, 686 ) 687 return 1 688 689 690if __name__ == "__main__": 691 sys.exit(main())