personal memory agent
0
fork

Configure Feed

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

fix(setup): real per-step resumption + exception/env/mkdir discipline + tighter tests

- Per-step resumption: skip steps whose prior status is `ok` and whose recorded paths exist; service step probes health and restarts when wedged before reinstalling.
- Exception discipline: wrap each step in try/except; record `failed` row, flush manifest, return exit 1 for ordinary exceptions. KeyboardInterrupt/SystemExit propagate.
- `SOLSTONE_JOURNAL` is honored in `resolve_journal_path`, mirroring `get_journal_info` resolver order.
- `step_journal` mkdirs the journal directory before writing config; `--journal <regular-file>` is rejected at `resolve_context` with a dead-end.
- Tests: replace substring `command_contains` with positional `assert_command`; add resumption / wedged-service / env-precedence / dead-end / exception / port-propagation cases.

+618 -64
+451 -36
tests/test_setup.py
··· 88 88 monkeypatch.setattr(health_cli, "health_check", lambda: 0) 89 89 90 90 91 - def command_contains(calls: list[list[str]], *parts: str) -> bool: 92 - return any(all(part in command for part in parts) for command in calls) 91 + STEP_NAMES = [ 92 + "doctor", 93 + "journal", 94 + "install_models", 95 + "skills", 96 + "wrapper", 97 + "service", 98 + ] 99 + 100 + 101 + def expected_doctor_command(port: int = 5015) -> list[str]: 102 + return [ 103 + sys.executable, 104 + "-m", 105 + "think.sol_cli", 106 + "doctor", 107 + "--json", 108 + "--port", 109 + str(port), 110 + ] 111 + 112 + 113 + def expected_install_models_command() -> list[str]: 114 + return [ 115 + sys.executable, 116 + "-m", 117 + "think.sol_cli", 118 + "install-models", 119 + "--variant", 120 + "auto", 121 + ] 122 + 123 + 124 + def expected_skills_command() -> list[str]: 125 + return [ 126 + sys.executable, 127 + "-m", 128 + "think.sol_cli", 129 + "skills", 130 + "install", 131 + "--agent", 132 + "claude", 133 + ] 134 + 135 + 136 + def expected_wrapper_command() -> list[str]: 137 + return [sys.executable, "-m", "think.install_guard", "install"] 138 + 139 + 140 + def expected_service_install_command(port: int = 5015) -> list[str]: 141 + return [ 142 + sys.executable, 143 + "-m", 144 + "think.sol_cli", 145 + "service", 146 + "install", 147 + "--port", 148 + str(port), 149 + ] 150 + 151 + 152 + def expected_service_restart_command() -> list[str]: 153 + return [sys.executable, "-m", "think.sol_cli", "service", "restart"] 154 + 155 + 156 + def assert_command( 157 + calls: list[list[str]], position: int, expected_argv: list[str] 158 + ) -> None: 159 + assert position < len(calls), ( 160 + f"expected {position + 1}+ subprocess calls, got {len(calls)}" 161 + ) 162 + assert calls[position] == expected_argv, ( 163 + f"call[{position}] mismatch:\n want: {expected_argv}\n got: {calls[position]}" 164 + ) 165 + 166 + 167 + def assert_step_names_and_statuses( 168 + manifest: dict[str, Any], statuses: list[str] 169 + ) -> None: 170 + assert [step["name"] for step in manifest["steps"]] == STEP_NAMES 171 + assert [step["status"] for step in manifest["steps"]] == statuses 93 172 94 173 95 174 def read_manifest(journal: Path) -> dict[str, Any]: 96 175 return json.loads((journal / ".setup-state.json").read_text(encoding="utf-8")) 97 176 98 177 178 + def touch_file(path: Path) -> None: 179 + path.parent.mkdir(parents=True, exist_ok=True) 180 + path.touch() 181 + 182 + 183 + def prior_artifact_paths(journal: Path) -> dict[str, list[Path]]: 184 + service_path = setup.service_artifact_path() 185 + return { 186 + "doctor": [], 187 + "journal": [setup.config_path(), journal], 188 + "install_models": setup.model_paths(), 189 + "skills": [Path.home() / ".claude" / "skills" / "solstone" / "SKILL.md"], 190 + "wrapper": [Path.home() / ".local" / "bin" / "sol"], 191 + "service": [service_path] if service_path is not None else [], 192 + } 193 + 194 + 195 + def write_clean_prior_manifest(journal: Path) -> dict[str, list[Path]]: 196 + journal.mkdir(parents=True, exist_ok=True) 197 + paths_by_name = prior_artifact_paths(journal) 198 + for paths in paths_by_name.values(): 199 + for path in paths: 200 + if path == journal: 201 + path.mkdir(parents=True, exist_ok=True) 202 + else: 203 + touch_file(path) 204 + started_at = "2026-05-02T21:29:42Z" 205 + completed_at = "2026-05-02T21:30:42Z" 206 + steps = [ 207 + { 208 + "name": name, 209 + "status": "ok", 210 + "paths": [str(path.expanduser().resolve()) for path in paths_by_name[name]], 211 + "started_at": started_at, 212 + "finished_at": completed_at, 213 + "error": None, 214 + } 215 + for name in STEP_NAMES 216 + ] 217 + (journal / ".setup-state.json").write_text( 218 + json.dumps( 219 + { 220 + "schema_version": 1, 221 + "started_at": started_at, 222 + "completed_at": completed_at, 223 + "mode": "non_interactive", 224 + "args_resolved": {}, 225 + "steps": steps, 226 + } 227 + ), 228 + encoding="utf-8", 229 + ) 230 + return paths_by_name 231 + 232 + 99 233 def test_interactive_happy_path_default_journal( 100 234 tmp_path: Path, 101 235 monkeypatch: pytest.MonkeyPatch, ··· 117 251 encoding="utf-8" 118 252 ) == f'journal = "{journal}"\n' 119 253 manifest = read_manifest(journal) 120 - assert [step["name"] for step in manifest["steps"]] == [ 121 - "doctor", 122 - "journal", 123 - "install_models", 124 - "skills", 125 - "wrapper", 126 - "service", 127 - ] 254 + assert_step_names_and_statuses(manifest, ["ok", "ok", "ok", "ok", "ok", "ok"]) 128 255 assert "solstone is running at http://localhost:5015" in capsys.readouterr().out 129 - assert command_contains(calls, "install-models") 130 - assert command_contains(calls, "skills", "claude") 131 - assert command_contains(calls, "think.install_guard", "install") 256 + assert_command(calls, 0, expected_doctor_command()) 257 + assert_command(calls, 1, expected_install_models_command()) 258 + assert_command(calls, 2, expected_skills_command()) 259 + assert_command(calls, 3, expected_wrapper_command()) 260 + assert_command(calls, 4, expected_service_install_command()) 261 + assert len(calls) == 5 132 262 133 263 134 264 def test_interactive_happy_path_journal_override( ··· 151 281 encoding="utf-8" 152 282 ) == f'journal = "{journal}"\n' 153 283 assert read_manifest(journal)["args_resolved"]["journal"]["source"] == "cli" 154 - assert command_contains(calls, "think.install_guard", "install") 284 + assert_command(calls, 3, expected_wrapper_command()) 285 + assert len(calls) == 5 155 286 156 287 157 288 def test_non_interactive_happy_path( ··· 171 302 assert rc == 0 172 303 manifest = read_manifest(journal) 173 304 assert manifest["completed_at"] is not None 174 - assert len(manifest["steps"]) == 6 175 - assert command_contains(calls, "service", "install") 305 + assert_step_names_and_statuses(manifest, ["ok", "ok", "ok", "ok", "ok", "ok"]) 306 + assert_command(calls, 4, expected_service_install_command()) 307 + assert len(calls) == 5 176 308 177 309 178 310 @pytest.mark.parametrize("use_journal_flag", [False, True]) ··· 201 333 err = capsys.readouterr().err 202 334 assert "already contains journal data" in err 203 335 assert "--accept-existing-journal" in err 204 - assert not command_contains(calls, "install-models") 205 - assert not command_contains(calls, "skills") 206 - assert not command_contains(calls, "think.install_guard") 207 - assert not command_contains(calls, "service", "install") 336 + assert_command(calls, 0, expected_doctor_command()) 337 + assert len(calls) == 1 208 338 209 339 210 340 def test_dry_run_side_effect_free( ··· 432 562 assert ( 433 563 f"sol setup last run on {started_at} left these steps incomplete:\n" 434 564 " - install_models (failed)\n" 435 - "Re-running will pick up where the previous run left off." 565 + "Re-running will verify state and re-run incomplete steps." 436 566 ) in capsys.readouterr().out 437 567 438 568 ··· 511 641 assert "Use --force to re-run all steps unconditionally." not in out 512 642 513 643 514 - def test_partial_completion_resumption( 644 + def test_partial_completion_runs_remaining_steps( 515 645 tmp_path: Path, 516 646 monkeypatch: pytest.MonkeyPatch, 517 647 ) -> None: ··· 539 669 rc = setup.main(["--yes", "--journal", str(journal)]) 540 670 541 671 assert rc == 0 542 - assert [step["name"] for step in read_manifest(journal)["steps"]] == [ 543 - "doctor", 544 - "journal", 545 - "install_models", 546 - "skills", 547 - "wrapper", 548 - "service", 549 - ] 550 - assert command_contains(calls, "service", "install") 672 + manifest = read_manifest(journal) 673 + assert_step_names_and_statuses(manifest, ["skipped", "ok", "ok", "ok", "ok", "ok"]) 674 + assert manifest["steps"][0]["reason"] == "prior_run_ok" 675 + assert_command(calls, 0, expected_install_models_command()) 676 + assert_command(calls, 1, expected_skills_command()) 677 + assert_command(calls, 2, expected_wrapper_command()) 678 + assert_command(calls, 3, expected_service_install_command()) 679 + assert len(calls) == 4 551 680 552 681 553 682 def test_port_in_use_default_non_interactive_dead_end( ··· 578 707 579 708 assert rc == 2 580 709 assert "port 5015 is already in use" in capsys.readouterr().err 581 - assert command_contains(calls, "doctor") 582 - assert not command_contains(calls, "install-models") 710 + assert_command(calls, 0, expected_doctor_command()) 711 + assert len(calls) == 1 583 712 584 713 585 714 def test_packaged_install_skips_service( ··· 601 730 assert rc == 0 602 731 out = capsys.readouterr().out 603 732 assert "packaged-install service support is not implemented in v1" in out 604 - assert not command_contains(calls, "think.install_guard") 605 - assert not command_contains(calls, "service", "install") 733 + assert_command(calls, 0, expected_doctor_command()) 734 + assert len(calls) == 1 606 735 assert [step["status"] for step in read_manifest(journal)["steps"]][-2:] == [ 607 736 "skipped", 608 737 "skipped", ··· 625 754 626 755 assert rc == 0 627 756 assert "Claude Code config not found" in capsys.readouterr().out 628 - assert not command_contains(calls, "skills", "install") 757 + assert_command(calls, 0, expected_doctor_command()) 758 + assert_command(calls, 1, expected_wrapper_command()) 759 + assert_command(calls, 2, expected_service_install_command()) 760 + assert len(calls) == 3 629 761 skill_step = next( 630 762 step for step in read_manifest(journal)["steps"] if step["name"] == "skills" 631 763 ) 632 764 assert skill_step["status"] == "skipped" 765 + 766 + 767 + def test_resumption_skips_completed_steps( 768 + tmp_path: Path, 769 + monkeypatch: pytest.MonkeyPatch, 770 + ) -> None: 771 + patch_home(monkeypatch, tmp_path) 772 + patch_source_checkout(monkeypatch, tmp_path) 773 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 774 + journal = tmp_path / "journal" 775 + write_clean_prior_manifest(journal) 776 + calls = patch_subprocess(monkeypatch) 777 + patch_service_health(monkeypatch) 778 + 779 + rc = setup.main(["--yes", "--journal", str(journal)]) 780 + 781 + assert rc == 0 782 + assert calls == [] 783 + manifest = read_manifest(journal) 784 + assert_step_names_and_statuses(manifest, ["skipped"] * 6) 785 + assert {step["reason"] for step in manifest["steps"]} == {"prior_run_ok"} 786 + 787 + 788 + def test_resumption_runs_step_when_artifact_missing( 789 + tmp_path: Path, 790 + monkeypatch: pytest.MonkeyPatch, 791 + ) -> None: 792 + patch_home(monkeypatch, tmp_path) 793 + patch_source_checkout(monkeypatch, tmp_path) 794 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 795 + journal = tmp_path / "journal" 796 + paths_by_name = write_clean_prior_manifest(journal) 797 + paths_by_name["wrapper"][0].unlink() 798 + calls = patch_subprocess(monkeypatch) 799 + patch_service_health(monkeypatch) 800 + 801 + rc = setup.main(["--yes", "--journal", str(journal)]) 802 + 803 + assert rc == 0 804 + assert_command(calls, 0, expected_wrapper_command()) 805 + assert len(calls) == 1 806 + manifest = read_manifest(journal) 807 + assert_step_names_and_statuses( 808 + manifest, ["skipped", "skipped", "skipped", "skipped", "ok", "skipped"] 809 + ) 810 + 811 + 812 + def test_resumption_wedged_service_restarts( 813 + tmp_path: Path, 814 + monkeypatch: pytest.MonkeyPatch, 815 + ) -> None: 816 + patch_home(monkeypatch, tmp_path) 817 + patch_source_checkout(monkeypatch, tmp_path) 818 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 819 + journal = tmp_path / "journal" 820 + write_clean_prior_manifest(journal) 821 + calls = patch_subprocess(monkeypatch) 822 + health_results = iter([1, 0]) 823 + monkeypatch.setattr(health_cli, "health_check", lambda: next(health_results, 0)) 824 + 825 + rc = setup.main(["--yes", "--journal", str(journal)]) 826 + 827 + assert rc == 0 828 + assert_command(calls, 0, expected_service_restart_command()) 829 + assert len(calls) == 1 830 + service_step = read_manifest(journal)["steps"][-1] 831 + assert service_step["status"] == "ok" 832 + assert service_step["reason"] == "resumed_after_restart" 833 + 834 + 835 + def test_resumption_wedged_service_falls_through_when_restart_fails( 836 + tmp_path: Path, 837 + monkeypatch: pytest.MonkeyPatch, 838 + ) -> None: 839 + patch_home(monkeypatch, tmp_path) 840 + patch_source_checkout(monkeypatch, tmp_path) 841 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 842 + monkeypatch.setattr(setup, "HEALTH_ATTEMPTS", 1) 843 + monkeypatch.setattr(setup, "HEALTH_SLEEP_SECONDS", 0) 844 + monkeypatch.setattr(service, "_up", lambda port=5015: 0) 845 + journal = tmp_path / "journal" 846 + write_clean_prior_manifest(journal) 847 + calls = patch_subprocess(monkeypatch) 848 + monkeypatch.setattr(health_cli, "health_check", lambda: 1) 849 + 850 + rc = setup.main(["--yes", "--journal", str(journal)]) 851 + 852 + assert rc == 1 853 + assert_command(calls, 0, expected_service_restart_command()) 854 + assert_command(calls, 1, expected_service_install_command()) 855 + assert len(calls) == 2 856 + 857 + 858 + def test_force_skips_resumption( 859 + tmp_path: Path, 860 + monkeypatch: pytest.MonkeyPatch, 861 + ) -> None: 862 + patch_home(monkeypatch, tmp_path) 863 + patch_source_checkout(monkeypatch, tmp_path) 864 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 865 + journal = tmp_path / "journal" 866 + write_clean_prior_manifest(journal) 867 + calls = patch_subprocess(monkeypatch) 868 + patch_service_health(monkeypatch) 869 + 870 + rc = setup.main(["--yes", "--force", "--journal", str(journal)]) 871 + 872 + assert rc == 0 873 + assert_command(calls, 0, expected_doctor_command()) 874 + assert_command(calls, 1, expected_install_models_command()) 875 + assert_command(calls, 2, expected_skills_command()) 876 + assert_command(calls, 3, expected_wrapper_command()) 877 + assert_command(calls, 4, expected_service_install_command()) 878 + assert len(calls) == 5 879 + manifest = read_manifest(journal) 880 + assert_step_names_and_statuses(manifest, ["ok", "ok", "ok", "ok", "ok", "ok"]) 881 + assert all(step["reason"] is None for step in manifest["steps"]) 882 + 883 + 884 + def test_step_exception_records_failed_row( 885 + tmp_path: Path, 886 + monkeypatch: pytest.MonkeyPatch, 887 + ) -> None: 888 + patch_home(monkeypatch, tmp_path) 889 + patch_source_checkout(monkeypatch, tmp_path) 890 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 891 + journal = tmp_path / "journal" 892 + 893 + def boom(ctx: setup.SetupContext, step_index: int) -> setup.StepResult: 894 + raise RuntimeError("boom") 895 + 896 + monkeypatch.setattr(setup, "_STEPS", (boom,)) 897 + monkeypatch.setattr(setup, "_STEP_NAME", {boom: "doctor"}) 898 + 899 + rc = setup.main(["--yes", "--journal", str(journal)]) 900 + 901 + assert rc == 1 902 + manifest = read_manifest(journal) 903 + assert len(manifest["steps"]) == 1 904 + step = manifest["steps"][0] 905 + assert step["name"] == "doctor" 906 + assert step["status"] == "failed" 907 + assert step["error"]["message"] == "boom" 908 + 909 + 910 + @pytest.mark.parametrize("exc", [KeyboardInterrupt(), SystemExit(7)]) 911 + def test_base_exceptions_propagate( 912 + tmp_path: Path, 913 + monkeypatch: pytest.MonkeyPatch, 914 + exc: BaseException, 915 + ) -> None: 916 + patch_home(monkeypatch, tmp_path) 917 + patch_source_checkout(monkeypatch, tmp_path) 918 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 919 + journal = tmp_path / "journal" 920 + 921 + def boom(ctx: setup.SetupContext, step_index: int) -> setup.StepResult: 922 + raise exc 923 + 924 + monkeypatch.setattr(setup, "_STEPS", (boom,)) 925 + monkeypatch.setattr(setup, "_STEP_NAME", {boom: "doctor"}) 926 + 927 + with pytest.raises(type(exc)) as raised: 928 + setup.main(["--yes", "--journal", str(journal)]) 929 + 930 + if isinstance(exc, SystemExit): 931 + assert raised.value.code == 7 932 + assert not (journal / ".setup-state.json").exists() 933 + 934 + 935 + def test_env_journal_overrides_config( 936 + tmp_path: Path, 937 + monkeypatch: pytest.MonkeyPatch, 938 + ) -> None: 939 + home = patch_home(monkeypatch, tmp_path) 940 + patch_source_checkout(monkeypatch, tmp_path) 941 + config_journal = tmp_path / "from_config" 942 + env_journal = tmp_path / "from_env" 943 + write_user_config(journal=str(config_journal)) 944 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(env_journal)) 945 + calls = patch_subprocess(monkeypatch) 946 + patch_service_health(monkeypatch) 947 + 948 + rc = setup.main(["--yes"]) 949 + 950 + assert rc == 0 951 + assert (home / ".config" / "solstone" / "config.toml").read_text( 952 + encoding="utf-8" 953 + ) == f'journal = "{env_journal}"\n' 954 + manifest = read_manifest(env_journal) 955 + assert manifest["args_resolved"]["journal"]["source"] == "env" 956 + assert env_journal.is_dir() 957 + assert not config_journal.exists() 958 + assert_command(calls, 0, expected_doctor_command()) 959 + 960 + 961 + def test_journal_is_regular_file_dead_ends( 962 + tmp_path: Path, 963 + monkeypatch: pytest.MonkeyPatch, 964 + capsys: pytest.CaptureFixture[str], 965 + ) -> None: 966 + patch_home(monkeypatch, tmp_path) 967 + patch_source_checkout(monkeypatch, tmp_path) 968 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 969 + journal_file = tmp_path / "journal-file" 970 + journal_file.write_text("not a directory", encoding="utf-8") 971 + calls = patch_subprocess(monkeypatch) 972 + 973 + rc = setup.main(["--yes", "--journal", str(journal_file)]) 974 + 975 + assert rc == 2 976 + assert calls == [] 977 + assert "directory" in capsys.readouterr().err 978 + assert not (journal_file / ".setup-state.json").exists() 979 + 980 + 981 + def test_doctor_parse_failure_records_failed( 982 + tmp_path: Path, 983 + monkeypatch: pytest.MonkeyPatch, 984 + ) -> None: 985 + patch_home(monkeypatch, tmp_path) 986 + patch_source_checkout(monkeypatch, tmp_path) 987 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 988 + journal = tmp_path / "journal" 989 + patch_subprocess(monkeypatch, doctor_stdout="not json") 990 + 991 + rc = setup.main(["--yes", "--journal", str(journal)]) 992 + 993 + assert rc == 1 994 + manifest = read_manifest(journal) 995 + assert len(manifest["steps"]) == 1 996 + step = manifest["steps"][0] 997 + assert step["name"] == "doctor" 998 + assert step["status"] == "failed" 999 + assert "doctor JSON parse failed" in step["error"]["message"] 1000 + 1001 + 1002 + def test_invalid_manifest_treated_as_no_prior( 1003 + tmp_path: Path, 1004 + monkeypatch: pytest.MonkeyPatch, 1005 + capsys: pytest.CaptureFixture[str], 1006 + ) -> None: 1007 + home = patch_home(monkeypatch, tmp_path) 1008 + patch_source_checkout(monkeypatch, tmp_path) 1009 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1010 + (home / ".claude").mkdir() 1011 + journal = tmp_path / "journal" 1012 + journal.mkdir() 1013 + (journal / ".setup-state.json").write_text("{", encoding="utf-8") 1014 + calls = patch_subprocess(monkeypatch) 1015 + patch_service_health(monkeypatch) 1016 + 1017 + rc = setup.main(["--yes", "--journal", str(journal)]) 1018 + 1019 + assert rc == 0 1020 + out = capsys.readouterr().out 1021 + assert "last ran cleanly" not in out and "left these steps incomplete" not in out 1022 + assert_command(calls, 0, expected_doctor_command()) 1023 + assert_command(calls, 1, expected_install_models_command()) 1024 + assert_command(calls, 2, expected_skills_command()) 1025 + assert_command(calls, 3, expected_wrapper_command()) 1026 + assert_command(calls, 4, expected_service_install_command()) 1027 + assert len(calls) == 5 1028 + 1029 + 1030 + def test_port_propagates_to_subprocess_argv( 1031 + tmp_path: Path, 1032 + monkeypatch: pytest.MonkeyPatch, 1033 + ) -> None: 1034 + home = patch_home(monkeypatch, tmp_path) 1035 + patch_source_checkout(monkeypatch, tmp_path) 1036 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1037 + (home / ".claude").mkdir() 1038 + journal = tmp_path / "journal" 1039 + calls = patch_subprocess(monkeypatch) 1040 + patch_service_health(monkeypatch) 1041 + 1042 + rc = setup.main(["--yes", "--journal", str(journal), "--port", "8080"]) 1043 + 1044 + assert rc == 0 1045 + assert_command(calls, 0, expected_doctor_command(port=8080)) 1046 + assert_command(calls, 4, expected_service_install_command(port=8080)) 1047 + assert len(calls) == 5
+167 -28
think/setup.py
··· 17 17 from datetime import datetime, timezone 18 18 from enum import Enum 19 19 from pathlib import Path 20 - from typing import Any, Literal 20 + from typing import Any, Callable, Literal 21 21 22 22 from think.user_config import ( 23 23 config_path, ··· 77 77 started_at: str 78 78 finished_at: str 79 79 error: dict[str, object] | None 80 + reason: str | None = None 80 81 81 82 82 83 class SetupDeadEnd(Exception): ··· 152 153 parser.add_argument( 153 154 "--force", 154 155 action="store_true", 155 - help="re-run all steps unconditionally (today: only changes the preface wording)", 156 + help="re-run all steps unconditionally", 156 157 ) 157 158 return parser 158 159 ··· 236 237 }, 237 238 } 238 239 239 - return SetupContext( 240 + ctx = SetupContext( 240 241 mode=mode, 241 242 project_root=project_root, 242 243 is_source_checkout=is_source_checkout, ··· 260 261 args_resolved=args_resolved, 261 262 doctor_advisories=[], 262 263 ) 264 + if ctx.journal_path.exists() and not ctx.journal_path.is_dir(): 265 + dead_end_journal_is_file(ctx) 266 + return ctx 263 267 264 268 265 269 def resolve_journal_path(args: argparse.Namespace) -> tuple[Path, str]: 266 270 if args.journal is not None: 267 271 return expand_path(args.journal), "cli" 268 272 273 + env_path = os.environ.get("SOLSTONE_JOURNAL", "").strip() 274 + if env_path: 275 + return expand_path(env_path), "env" 276 + 269 277 configured = read_user_config().get("journal", "").strip() 270 278 if configured: 271 279 return expand_path(configured), "config" ··· 334 342 return PriorRunStatus("partial", manifest.get("started_at"), failed) 335 343 336 344 345 + def prior_step_lookup(manifest: dict[str, Any]) -> dict[str, dict]: 346 + lookup = {} 347 + for step in manifest.get("steps", []): 348 + lookup[step["name"]] = step 349 + return lookup 350 + 351 + 352 + def can_skip(prior_step: dict | None) -> bool: 353 + if prior_step is None or prior_step.get("status") != "ok": 354 + return False 355 + return all(Path(path).exists() for path in prior_step.get("paths", [])) 356 + 357 + 337 358 def write_manifest(ctx: SetupContext, manifest: dict[str, Any]) -> None: 338 359 try: 339 360 ctx.manifest_path.parent.mkdir(parents=True, exist_ok=True) ··· 380 401 paths: list[Path | str], 381 402 started_at: str, 382 403 error: dict[str, object] | None = None, 404 + reason: str | None = None, 383 405 ) -> StepResult: 384 406 return StepResult( 385 407 name=name, ··· 388 410 started_at=started_at, 389 411 finished_at=utc_now(), 390 412 error=error, 413 + reason=reason, 391 414 ) 392 415 393 416 ··· 552 575 raise SetupDeadEnd("setup aborted by user", 2) 553 576 554 577 if not persisted_matches: 578 + ctx.journal_path.mkdir(parents=True, exist_ok=True) 555 579 write_user_config(journal=str(ctx.journal_path)) 556 580 print(f"[step {step_index}/{TOTAL_STEPS}] wrote {ctx.config_path}") 557 581 else: 558 582 print(f"[step {step_index}/{TOTAL_STEPS}] journal config already current") 559 - ctx.journal_path.mkdir(parents=True, exist_ok=True) 583 + ctx.journal_path.mkdir(parents=True, exist_ok=True) 560 584 return step_result( 561 585 "journal", 562 586 "ok", ··· 598 622 started_at = utc_now() 599 623 if ctx.skip_models: 600 624 print_step_skipped(step_index, "install_models", "--skip-models") 601 - return step_result("install_models", "skipped", [], started_at) 625 + return step_result( 626 + "install_models", "skipped", [], started_at, reason="--skip-models" 627 + ) 602 628 command = install_models_command(ctx) 603 629 print_step_header(step_index, "install-models", command) 604 630 rc = run_inherited(command) ··· 619 645 skill_path = claude_dir / "skills" / "solstone" / "SKILL.md" 620 646 if ctx.skip_skills: 621 647 print_step_skipped(step_index, "skills", "--skip-skills") 622 - return step_result("skills", "skipped", [], started_at) 648 + return step_result("skills", "skipped", [], started_at, reason="--skip-skills") 623 649 if not claude_dir.exists(): 624 650 reason = f"Claude Code config not found at {claude_dir}" 625 651 print_step_skipped(step_index, "skills", reason) 626 - return step_result("skills", "skipped", [], started_at) 652 + return step_result( 653 + "skills", "skipped", [], started_at, reason="claude_config_missing" 654 + ) 627 655 command = skills_command() 628 656 print_step_header(step_index, "skills", command) 629 657 rc = run_inherited(command) ··· 643 671 wrapper_path = Path.home() / ".local" / "bin" / "sol" 644 672 if not ctx.is_source_checkout: 645 673 print_step_skipped(step_index, "wrapper", "packaged install") 646 - return step_result("wrapper", "skipped", [], started_at) 674 + return step_result( 675 + "wrapper", "skipped", [], started_at, reason="packaged_install" 676 + ) 647 677 command = wrapper_command() 648 678 print_step_header(step_index, "wrapper", command) 649 679 rc = run_inherited(command) ··· 672 702 paths = [artifact] if artifact is not None else [] 673 703 if ctx.skip_service: 674 704 print_step_skipped(step_index, "service", "--skip-service") 675 - return step_result("service", "skipped", [], started_at) 705 + return step_result( 706 + "service", "skipped", [], started_at, reason="--skip-service" 707 + ) 676 708 if not ctx.is_source_checkout: 677 709 ctx.service_skipped_packaged = True 678 710 reason = ( ··· 681 713 "again from a source checkout to install the background service." 682 714 ) 683 715 print_step_skipped(step_index, "service", reason) 684 - return step_result("service", "skipped", [], started_at) 716 + return step_result( 717 + "service", 718 + "skipped", 719 + [], 720 + started_at, 721 + reason="packaged_service_unsupported", 722 + ) 685 723 686 724 command = service_install_command(ctx) 687 725 print_step_header(step_index, "service install", command) ··· 747 785 raise SetupDeadEnd(message, 2) 748 786 749 787 788 + def dead_end_journal_is_file(ctx: SetupContext) -> None: 789 + message = ( 790 + f"expected a directory at {ctx.journal_path}; got a regular file. " 791 + "Re-run with --journal <other-path>." 792 + ) 793 + raise SetupDeadEnd(message, 2) 794 + 795 + 750 796 def dead_end_port_in_use(ctx: SetupContext) -> None: 751 797 message = "\n".join( 752 798 [ ··· 778 824 print(f" variant: {ctx.variant} ({ctx.variant_source})") 779 825 print(f" source checkout: {ctx.is_source_checkout}") 780 826 print() 781 - print("[step 1/6] doctor") 827 + print(f"[step 1/6] {_STEP_NAME[step_doctor]}") 782 828 print(f" would run: {format_command(doctor_command(ctx))}") 783 - print("[step 2/6] journal") 829 + print(f"[step 2/6] {_STEP_NAME[step_journal]}") 784 830 print(f" would write: {ctx.config_path}") 785 831 print(f" would use journal: {ctx.journal_path}") 786 - print("[step 3/6] install_models") 832 + print(f"[step 3/6] {_STEP_NAME[step_install_models]}") 787 833 if ctx.skip_models: 788 834 print(" skipped: --skip-models") 789 835 else: 790 836 print(f" would run: {format_command(install_models_command(ctx))}") 791 - print("[step 4/6] skills") 837 + print(f"[step 4/6] {_STEP_NAME[step_skills]}") 792 838 if ctx.skip_skills: 793 839 print(" skipped: --skip-skills") 794 840 else: 795 841 print(f" would run: {format_command(skills_command())}") 796 - print("[step 5/6] wrapper") 842 + print(f"[step 5/6] {_STEP_NAME[step_wrapper]}") 797 843 if not ctx.is_source_checkout: 798 844 print(" skipped: packaged install") 799 845 else: 800 846 print(f" would run: {format_command(wrapper_command())}") 801 - print("[step 6/6] service") 847 + print(f"[step 6/6] {_STEP_NAME[step_service]}") 802 848 if ctx.skip_service: 803 849 print(" skipped: --skip-service") 804 850 elif not ctx.is_source_checkout: ··· 820 866 def print_success_summary(ctx: SetupContext, manifest: dict[str, Any]) -> None: 821 867 print() 822 868 print("solstone is set up.") 869 + print() 870 + steps = manifest.get("steps", []) 871 + n_skipped_prior = sum(1 for step in steps if step.get("reason") == "prior_run_ok") 872 + n_skipped_other = sum( 873 + 1 874 + for step in steps 875 + if step.get("status") == "skipped" and step.get("reason") != "prior_run_ok" 876 + ) 877 + n_ran = TOTAL_STEPS - n_skipped_prior - n_skipped_other 878 + print(f"{n_skipped_prior} of {TOTAL_STEPS} steps already done; ran {n_ran}") 823 879 print() 824 880 print("artifacts:") 825 881 paths = artifact_paths(ctx, manifest) ··· 885 941 print(f"sol setup last run on {status.timestamp} left these steps incomplete:") 886 942 for name in status.failed_steps: 887 943 print(f" - {name} (failed)") 888 - print("Re-running will pick up where the previous run left off.") 944 + print("Re-running will verify state and re-run incomplete steps.") 945 + 946 + 947 + def _resume_service( 948 + ctx: SetupContext, step_index: int, prior_step: dict 949 + ) -> StepResult | None: 950 + started_at = utc_now() 951 + from think.service import service_is_installed 952 + 953 + if not service_is_installed(): 954 + return None 955 + 956 + from think.health_cli import health_check 957 + 958 + paths = prior_step.get("paths", []) 959 + if health_check() == 0: 960 + return step_result( 961 + "service", "skipped", paths, started_at, reason="prior_run_ok" 962 + ) 963 + 964 + print( 965 + f"[step {step_index}/{TOTAL_STEPS}] service installed but unhealthy; restarting..." 966 + ) 967 + run_inherited([sys.executable, "-m", "think.sol_cli", "service", "restart"]) 968 + for attempt in range(1, HEALTH_ATTEMPTS + 1): 969 + if health_check() == 0: 970 + return step_result( 971 + "service", 972 + "ok", 973 + paths, 974 + started_at, 975 + reason="resumed_after_restart", 976 + ) 977 + if attempt < HEALTH_ATTEMPTS: 978 + time.sleep(HEALTH_SLEEP_SECONDS) 979 + return None 980 + 981 + 982 + _STEP_NAME: dict[Callable[[SetupContext, int], StepResult], str] = { 983 + step_doctor: "doctor", 984 + step_journal: "journal", 985 + step_install_models: "install_models", 986 + step_skills: "skills", 987 + step_wrapper: "wrapper", 988 + step_service: "service", 989 + } 990 + 991 + _STEPS: tuple[Callable[[SetupContext, int], StepResult], ...] = ( 992 + step_doctor, 993 + step_journal, 994 + step_install_models, 995 + step_skills, 996 + step_wrapper, 997 + step_service, 998 + ) 889 999 890 1000 891 1001 def run_setup(ctx: SetupContext) -> int: ··· 897 1007 return 0 898 1008 899 1009 print_prior_run_preface(ctx) 1010 + prior_manifest = read_manifest(ctx) or {} 1011 + prior = {} if ctx.force else prior_step_lookup(prior_manifest) 900 1012 manifest = initial_manifest(ctx) 901 - steps = [ 902 - step_doctor, 903 - step_journal, 904 - step_install_models, 905 - step_skills, 906 - step_wrapper, 907 - step_service, 908 - ] 909 - for index, step in enumerate(steps, start=1): 910 - result = step(ctx, index) 1013 + for index, step in enumerate(_STEPS, start=1): 1014 + step_name = _STEP_NAME[step] 1015 + prior_step = prior.get(step_name) 1016 + started_at = utc_now() 1017 + try: 1018 + if can_skip(prior_step): 1019 + if step is step_service: 1020 + result = _resume_service(ctx, index, prior_step) 1021 + if result is None: 1022 + result = step(ctx, index) 1023 + else: 1024 + result = step_result( 1025 + step_name, 1026 + "skipped", 1027 + prior_step.get("paths", []), 1028 + started_at, 1029 + reason="prior_run_ok", 1030 + ) 1031 + else: 1032 + result = step(ctx, index) 1033 + except SetupDeadEnd: 1034 + raise 1035 + except Exception as exc: 1036 + result = step_result( 1037 + step_name, 1038 + "failed", 1039 + [], 1040 + started_at, 1041 + { 1042 + "message": str(exc) or exc.__class__.__name__, 1043 + "exit_code": 1, 1044 + }, 1045 + ) 1046 + append_step(manifest, result) 1047 + write_manifest(ctx, manifest) 1048 + print_failure(result) 1049 + return 1 911 1050 append_step(manifest, result) 912 1051 write_manifest(ctx, manifest) 913 1052 if result.status == "failed": ··· 925 1064 raw_argv = list(argv) if argv is not None else sys.argv[1:] 926 1065 parser = build_parser() 927 1066 args = parser.parse_args(raw_argv) 928 - ctx = resolve_context(args, raw_argv) 929 1067 try: 1068 + ctx = resolve_context(args, raw_argv) 930 1069 return run_setup(ctx) 931 1070 except SetupDeadEnd as exc: 932 1071 print(exc.message, file=sys.stderr)