personal memory agent
0
fork

Configure Feed

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

wrapper+supervisor: tee redirect, bash shebang, lifecycle hint

The managed wrapper needs bash for process substitution so service logs can tee to the journal while the supervisor remains in the foreground. Bump the wrapper to v4 and migrate older managed wrappers to the bash shebang plus tee redirect.

Users who type lifecycle verbs after sol supervisor now get the intended sol service command instead of an opaque argparse failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+169 -30
+1 -1
tests/test_config_cli.py
··· 63 63 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 64 64 "journal": journal, 65 65 "sol_bin": sol_bin, 66 - "version": 3, 66 + "version": 4, 67 67 } 68 68 69 69
+119 -22
tests/test_install_guard.py
··· 46 46 exec "$SOL_BIN" "$@" 47 47 """ 48 48 49 + V3_WRAPPER_TEMPLATE = """\ 50 + #!/bin/sh 51 + # sol — managed by 'sol config'. Edits will be overwritten. 52 + # managed-version: 3 53 + : "${{SOLSTONE_JOURNAL:={journal}}}" 54 + export SOLSTONE_JOURNAL 55 + SOL_BIN='{sol_bin}' 56 + if [ ! -x "$SOL_BIN" ]; then 57 + printf 'sol: venv binary missing or not executable: %s\\n' "$SOL_BIN" >&2 58 + exit 127 59 + fi 60 + if [ "$1" = "supervisor" ]; then 61 + mkdir -p "$SOLSTONE_JOURNAL/health" 62 + exec >>"$SOLSTONE_JOURNAL/health/service.log" 2>&1 63 + export PYTHONUNBUFFERED=1 64 + fi 65 + exec "$SOL_BIN" "$@" 66 + """ 67 + 49 68 50 69 @pytest.fixture 51 70 def home_root(monkeypatch, tmp_path): ··· 106 125 return V2_WRAPPER_TEMPLATE.format(journal=journal, sol_bin=escaped_sol_bin) 107 126 108 127 128 + def render_v3_wrapper(*, journal: str, sol_bin: str) -> str: 129 + escaped_sol_bin = sol_bin.replace("'", "'\\''") 130 + return V3_WRAPPER_TEMPLATE.format(journal=journal, sol_bin=escaped_sol_bin) 131 + 132 + 109 133 def make_v1_wrapper( 110 134 home_root: Path, 111 135 *, ··· 137 161 return alias 138 162 139 163 164 + def make_v3_wrapper( 165 + home_root: Path, 166 + *, 167 + journal: str, 168 + sol_bin: str, 169 + mode: int = 0o755, 170 + ) -> Path: 171 + alias = home_root / ".local" / "bin" / "sol" 172 + alias.parent.mkdir(parents=True, exist_ok=True) 173 + alias.write_text( 174 + render_v3_wrapper(journal=journal, sol_bin=sol_bin), 175 + encoding="utf-8", 176 + ) 177 + alias.chmod(mode) 178 + return alias 179 + 180 + 140 181 def other_target(tmp_path: Path) -> Path: 141 182 target = tmp_path / "other" / ".venv" / "bin" / "sol" 142 183 target.parent.mkdir(parents=True, exist_ok=True) ··· 198 239 assert install_guard.parse_wrapper(content) == { 199 240 "journal": journal, 200 241 "sol_bin": sol_bin, 201 - "version": 3, 242 + "version": 4, 202 243 } 203 244 204 245 def test_render_wrapper_round_trip_tricky_paths(self): ··· 210 251 assert install_guard.parse_wrapper(content) == { 211 252 "journal": journal, 212 253 "sol_bin": sol_bin, 213 - "version": 3, 254 + "version": 4, 214 255 } 215 256 216 257 def test_render_wrapper_matches_spec_template(self): ··· 220 261 content = install_guard.render_wrapper(journal, sol_bin) 221 262 222 263 assert ( 223 - content == "#!/bin/sh\n" 264 + content == "#!/bin/bash\n" 224 265 "# sol — managed by 'sol config'. Edits will be overwritten.\n" 225 - "# managed-version: 3\n" 266 + "# managed-version: 4\n" 226 267 ': "${SOLSTONE_JOURNAL:=/Users/jer/Documents/Solstone}"\n' 227 268 "export SOLSTONE_JOURNAL\n" 228 269 "SOL_BIN='/Users/jer/projects/solstone/.venv/bin/sol'\n" ··· 232 273 "fi\n" 233 274 'if [ "$1" = "supervisor" ]; then\n' 234 275 ' mkdir -p "$SOLSTONE_JOURNAL/health"\n' 235 - ' exec >>"$SOLSTONE_JOURNAL/health/service.log" 2>&1\n' 276 + ' exec > >(tee -a "$SOLSTONE_JOURNAL/health/service.log") 2>&1\n' 236 277 " export PYTHONUNBUFFERED=1\n" 237 278 "fi\n" 238 279 'exec "$SOL_BIN" "$@"\n' ··· 266 307 journal = "/tmp/solstone" 267 308 sol_bin = "/tmp/repo/.venv/bin/sol" 268 309 269 - content = install_guard.render_wrapper(journal, sol_bin) 310 + content = render_v3_wrapper(journal=journal, sol_bin=sol_bin) 270 311 271 312 assert install_guard.parse_wrapper(content) == { 272 313 "journal": journal, ··· 274 315 "version": 3, 275 316 } 276 317 277 - def test_parse_wrapper_rejects_v4(self): 278 - content = install_guard.render_wrapper( 279 - "/tmp/solstone", "/tmp/repo/.venv/bin/sol" 280 - ) 281 - content = content.replace("# managed-version: 3", "# managed-version: 4") 318 + def test_parse_wrapper_accepts_v4(self): 319 + journal = "/tmp/solstone" 320 + sol_bin = "/tmp/repo/.venv/bin/sol" 321 + 322 + content = install_guard.render_wrapper(journal, sol_bin) 282 323 283 - assert install_guard.parse_wrapper(content) is None 324 + assert install_guard.parse_wrapper(content) == { 325 + "journal": journal, 326 + "sol_bin": sol_bin, 327 + "version": 4, 328 + } 284 329 285 330 @pytest.mark.parametrize("char", ["$", "`", '"', "\\"]) 286 331 def test_validate_journal_path_for_wrapper_rejects_invalid_chars(self, char: str): ··· 392 437 assert captured.out == "fresh\n" 393 438 assert captured.err == "" 394 439 395 - def test_check_reports_current_for_v3_wrapper_with_matching_paths( 440 + def test_check_reports_current_for_v4_wrapper_with_matching_paths( 396 441 self, home_root, tmp_path, capsys 397 442 ): 398 443 repo = make_repo(tmp_path) ··· 426 471 assert state is install_guard.AliasState.OWNED 427 472 assert token == "upgrade" 428 473 474 + def test_check_reports_upgrade_for_v3_wrapper_with_matching_paths( 475 + self, home_root, tmp_path 476 + ): 477 + repo = make_repo(tmp_path) 478 + target = ensure_expected_target(repo) 479 + make_v3_wrapper( 480 + home_root, 481 + journal=install_guard._current_journal_for_alias(), 482 + sol_bin=str(target), 483 + ) 484 + 485 + state, token = install_guard.check_alias_detail(repo) 486 + 487 + assert state is install_guard.AliasState.OWNED 488 + assert token == "upgrade" 489 + 429 490 def test_check_reports_upgrade_for_v2_wrapper_with_matching_paths( 430 491 self, home_root, tmp_path 431 492 ): ··· 551 612 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 552 613 "journal": install_guard._current_journal_for_alias(), 553 614 "sol_bin": str(target), 554 - "version": 3, 615 + "version": 4, 555 616 } 556 617 557 618 def test_install_refuses_foreign_regular_file_without_force( ··· 590 651 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 591 652 "journal": install_guard._current_journal_for_alias(), 592 653 "sol_bin": str(target), 593 - "version": 3, 654 + "version": 4, 594 655 } 595 656 assert os.access(alias, os.X_OK) 596 657 ··· 614 675 assert install_guard.parse_wrapper(first_content) == { 615 676 "journal": install_guard._current_journal_for_alias(), 616 677 "sol_bin": str(target), 617 - "version": 3, 678 + "version": 4, 618 679 } 619 680 620 - def test_v1_wrapper_upgrades_to_v3_end_to_end( 681 + def test_v1_wrapper_upgrades_to_v4_end_to_end( 621 682 self, home_root, tmp_path, monkeypatch, capsys 622 683 ): 623 684 repo = make_repo(tmp_path) ··· 641 702 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 642 703 "journal": install_guard._current_journal_for_alias(), 643 704 "sol_bin": str(target), 644 - "version": 3, 705 + "version": 4, 645 706 } 646 707 647 708 assert install_guard.cmd_check(repo) == 0 ··· 649 710 assert captured.out == "current\n" 650 711 assert captured.err == "" 651 712 652 - def test_v2_wrapper_upgrades_to_v3_end_to_end( 713 + def test_v2_wrapper_upgrades_to_v4_end_to_end( 653 714 self, home_root, tmp_path, monkeypatch, capsys 654 715 ): 655 716 repo = make_repo(tmp_path) ··· 673 734 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 674 735 "journal": install_guard._current_journal_for_alias(), 675 736 "sol_bin": str(target), 676 - "version": 3, 737 + "version": 4, 738 + } 739 + 740 + assert install_guard.cmd_check(repo) == 0 741 + captured = capsys.readouterr() 742 + assert captured.out == "current\n" 743 + assert captured.err == "" 744 + 745 + def test_v3_wrapper_upgrades_to_v4_end_to_end( 746 + self, home_root, tmp_path, monkeypatch, capsys 747 + ): 748 + repo = make_repo(tmp_path) 749 + target = ensure_expected_target(repo) 750 + alias = make_v3_wrapper( 751 + home_root, 752 + journal=install_guard._current_journal_for_alias(), 753 + sol_bin=str(target), 754 + ) 755 + 756 + assert install_guard.cmd_check(repo) == 0 757 + captured = capsys.readouterr() 758 + assert captured.out == "upgrade\n" 759 + assert captured.err == "" 760 + 761 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 762 + 763 + content = alias.read_text(encoding="utf-8") 764 + assert rc == 0 765 + assert out == "installed\npath: ~/.local/bin already on PATH\n" 766 + assert err == "" 767 + assert content.startswith("#!/bin/bash\n") 768 + assert 'exec > >(tee -a "$SOLSTONE_JOURNAL/health/service.log") 2>&1' in content 769 + assert install_guard.parse_wrapper(content) == { 770 + "journal": install_guard._current_journal_for_alias(), 771 + "sol_bin": str(target), 772 + "version": 4, 677 773 } 678 774 679 775 assert install_guard.cmd_check(repo) == 0 ··· 720 816 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 721 817 "journal": install_guard._current_journal_for_alias(), 722 818 "sol_bin": str(target), 723 - "version": 3, 819 + "version": 4, 724 820 } 725 821 append_mock.assert_called_once_with( 726 822 str(alias.parent), ··· 840 936 ) 841 937 842 938 assert result.returncode == 0 843 - assert result.stdout == "" 939 + assert "OUT supervisor 5015" in result.stdout 940 + assert "ERR supervisor 5015" in result.stdout 844 941 assert result.stderr == "" 845 942 content = (journal / "health" / "service.log").read_text(encoding="utf-8") 846 943 assert "OUT supervisor 5015" in content
+17
tests/test_supervisor.py
··· 131 131 assert args.remote is None 132 132 133 133 134 + def test_parse_args_lifecycle_verb_hint(monkeypatch, capsys): 135 + mod = importlib.reload(importlib.import_module("think.supervisor")) 136 + monkeypatch.setattr(sys, "argv", ["sol", "supervisor", "stop"]) 137 + 138 + parser = mod.parse_args() 139 + with pytest.raises(SystemExit) as exc_info: 140 + parser.parse_args(["stop"]) 141 + 142 + captured = capsys.readouterr() 143 + assert exc_info.value.code == 2 144 + assert ( 145 + "sol supervisor is the server-launch command (takes a port). " 146 + "For lifecycle, use: sol service <verb>. " 147 + "Did you mean: sol service stop ?" 148 + ) in captured.err 149 + 150 + 134 151 def test_shutdown_stops_in_reverse_order(monkeypatch): 135 152 """Shutdown stops services in reverse order.""" 136 153 operations = []
+6 -6
think/install_guard.py
··· 22 22 23 23 24 24 WRAPPER_TEMPLATE = """\ 25 - #!/bin/sh 25 + #!/bin/bash 26 26 # sol — managed by 'sol config'. Edits will be overwritten. 27 - # managed-version: 3 27 + # managed-version: 4 28 28 : "${{SOLSTONE_JOURNAL:={journal}}}" 29 29 export SOLSTONE_JOURNAL 30 30 SOL_BIN='{sol_bin}' ··· 34 34 fi 35 35 if [ "$1" = "supervisor" ]; then 36 36 mkdir -p "$SOLSTONE_JOURNAL/health" 37 - exec >>"$SOLSTONE_JOURNAL/health/service.log" 2>&1 37 + exec > >(tee -a "$SOLSTONE_JOURNAL/health/service.log") 2>&1 38 38 export PYTHONUNBUFFERED=1 39 39 fi 40 40 exec "$SOL_BIN" "$@" 41 41 """ 42 42 43 - WRAPPER_MARKER = "# managed-version: 3" 44 - WRAPPER_VERSION = 3 43 + WRAPPER_MARKER = "# managed-version: 4" 44 + WRAPPER_VERSION = 4 45 45 46 - _RE_MARKER = re.compile(r"(?m)^# managed-version: (?P<version>[123])$") 46 + _RE_MARKER = re.compile(r"(?m)^# managed-version: (?P<version>[1234])$") 47 47 _RE_JOURNAL = re.compile(r'(?m)^: "\$\{SOLSTONE_JOURNAL:=(?P<journal>[^\n]*)\}"$') 48 48 _RE_SOL_BIN = re.compile(r"(?m)^SOL_BIN='(?P<sol_bin>(?:[^']|'\\'')*)'$") 49 49
+26 -1
think/supervisor.py
··· 51 51 "sol:think", 52 52 "sol:heartbeat", 53 53 } 54 + _SERVICE_LIFECYCLE_VERBS = { 55 + "start", 56 + "stop", 57 + "restart", 58 + "status", 59 + "install", 60 + "uninstall", 61 + "logs", 62 + } 54 63 55 64 # Global shutdown flag 56 65 shutdown_requested = False ··· 148 157 pass 149 158 finally: 150 159 self._emitting = False 160 + 161 + 162 + class SupervisorArgumentParser(argparse.ArgumentParser): 163 + def error(self, message: str) -> None: 164 + mistaken = next( 165 + (arg for arg in sys.argv[1:] if arg in _SERVICE_LIFECYCLE_VERBS), 166 + None, 167 + ) 168 + if mistaken: 169 + self.exit( 170 + 2, 171 + "sol supervisor is the server-launch command (takes a port). " 172 + "For lifecycle, use: sol service <verb>. " 173 + f"Did you mean: sol service {mistaken} ?\n", 174 + ) 175 + super().error(message) 151 176 152 177 153 178 # Desktop notification system ··· 1498 1523 1499 1524 1500 1525 def parse_args() -> argparse.ArgumentParser: 1501 - parser = argparse.ArgumentParser(description="Monitor journaling health") 1526 + parser = SupervisorArgumentParser(description="Monitor journaling health") 1502 1527 parser.add_argument( 1503 1528 "port", 1504 1529 nargs="?",