personal memory agent
0

Configure Feed

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

solstone / tests / test_setup.py
103 kB 3023 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import hashlib 7import json 8import subprocess 9import sys 10import time 11from pathlib import Path 12from types import SimpleNamespace 13from typing import Any 14from unittest.mock import Mock 15 16import pytest 17 18from solstone.think import health_cli, install_guard, service, setup, setup_events 19from solstone.think.user_config import write_user_config 20 21 22def fake_check_report(overall: str = "ok") -> SimpleNamespace: 23 return SimpleNamespace(report=SimpleNamespace(overall=overall)) 24 25 26@pytest.fixture(autouse=True) 27def no_user_path_mutation(monkeypatch: pytest.MonkeyPatch) -> None: 28 monkeypatch.setattr( 29 install_guard, 30 "_ensure_user_bin_on_path", 31 lambda _path: ["path: ~/.local/bin already on PATH"], 32 ) 33 34 35@pytest.fixture(autouse=True) 36def fast_brain_check(monkeypatch: pytest.MonkeyPatch) -> None: 37 from solstone.think import check 38 39 monkeypatch.setattr(check, "build_check_report", lambda: fake_check_report()) 40 41 42def patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 43 home = tmp_path / "home" 44 home.mkdir() 45 monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) 46 monkeypatch.setenv("HOME", str(home)) 47 return home 48 49 50def patch_source_checkout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 51 repo = tmp_path / "repo" 52 repo.mkdir() 53 (repo / "pyproject.toml").write_text("[project]\nname = 'solstone'\n") 54 (repo / ".git").mkdir() 55 monkeypatch.setattr(setup, "get_project_root", lambda: str(repo)) 56 monkeypatch.setattr(setup, "source_checkout", lambda: True) 57 return repo 58 59 60def patch_packaged_install(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 61 root = tmp_path / "site-packages" 62 root.mkdir() 63 monkeypatch.setattr(setup, "get_project_root", lambda: str(root)) 64 monkeypatch.setattr(setup, "source_checkout", lambda: False) 65 return root 66 67 68def patch_tty(monkeypatch: pytest.MonkeyPatch) -> None: 69 monkeypatch.setattr(sys.stdin, "isatty", lambda: True) 70 monkeypatch.setattr(sys.stdout, "isatty", lambda: True) 71 72 73def doctor_payload(checks: list[dict[str, Any]] | None = None) -> str: 74 return json.dumps( 75 { 76 "checks": checks or [], 77 "summary": { 78 "total": len(checks or []), 79 "failed": 0, 80 "warnings": 0, 81 "skipped": 0, 82 }, 83 } 84 ) 85 86 87def patch_subprocess( 88 monkeypatch: pytest.MonkeyPatch, 89 *, 90 doctor_stdout: str | None = None, 91 doctor_returncode: int = 0, 92 command_returncode: int = 0, 93 command_stdout: str = "", 94 command_stderr: str = "", 95 doctor_timeout: bool = False, 96 popen_timeout_command: list[str] | None = None, 97 doctor_jsonl_lines: list[str | dict[str, Any]] | None = None, 98 doctor_jsonl_returncode: int = 0, 99 doctor_jsonl_stderr: str = "", 100) -> list[list[str]]: 101 calls: list[list[str]] = [] 102 103 def fake_run( 104 command: list[str], **kwargs: object 105 ) -> subprocess.CompletedProcess[str]: 106 calls.append(command) 107 if "doctor" in command: 108 if doctor_timeout: 109 raise subprocess.TimeoutExpired(command, setup.DOCTOR_TIMEOUT_SECONDS) 110 return subprocess.CompletedProcess( 111 command, 112 doctor_returncode, 113 stdout=doctor_stdout if doctor_stdout is not None else doctor_payload(), 114 stderr="doctor failed\n" if doctor_returncode else "", 115 ) 116 return subprocess.CompletedProcess( 117 command, 118 command_returncode, 119 stdout=command_stdout, 120 stderr=command_stderr, 121 ) 122 123 class FakePopen: 124 def __init__(self, command: list[str], **kwargs: object) -> None: 125 del kwargs 126 self.command = command 127 self.terminated = False 128 self.returncode: int | None = None 129 self._returncode = command_returncode 130 self.stdout = None 131 self.stderr = None 132 if ( 133 doctor_jsonl_lines is not None 134 and "doctor" in command 135 and "--jsonl" in command 136 ): 137 serialized = [ 138 item if isinstance(item, str) else json.dumps(item) 139 for item in doctor_jsonl_lines 140 ] 141 self.stdout = iter( 142 line if line.endswith("\n") else f"{line}\n" for line in serialized 143 ) 144 self.stderr = iter( 145 line if line.endswith("\n") else f"{line}\n" 146 for line in doctor_jsonl_stderr.splitlines() 147 ) 148 self._returncode = doctor_jsonl_returncode 149 calls.append(command) 150 151 def wait(self, timeout: float | None = None) -> int: 152 if self.command == popen_timeout_command and not self.terminated: 153 raise subprocess.TimeoutExpired(self.command, timeout) 154 self.returncode = self._returncode 155 return self._returncode 156 157 def terminate(self) -> None: 158 self.terminated = True 159 self.returncode = -15 160 161 def kill(self) -> None: 162 self.terminated = True 163 self.returncode = -9 164 165 monkeypatch.setattr(setup.subprocess, "run", fake_run) 166 monkeypatch.setattr(setup.subprocess, "Popen", FakePopen) 167 return calls 168 169 170def patch_service_health(monkeypatch: pytest.MonkeyPatch) -> None: 171 monkeypatch.setattr(service, "_up", lambda: 0) 172 monkeypatch.setattr(health_cli, "health_check", lambda: 0) 173 174 175def patch_journal_os_defaults(monkeypatch: pytest.MonkeyPatch) -> None: 176 monkeypatch.setattr( 177 "solstone.think.journal_config._resolve_os_identity", 178 lambda: ("Setup User", "setup"), 179 ) 180 monkeypatch.setattr( 181 "solstone.think.journal_config._resolve_os_timezone", 182 lambda: "America/Denver", 183 ) 184 185 186STEP_NAMES = [ 187 "doctor", 188 "journal", 189 "install_models", 190 "skills_user", 191 "skills_journal", 192 "wrapper", 193 "service", 194 "brain", 195] 196 197 198def expected_doctor_command(port: int = 5015) -> list[str]: 199 return [ 200 str(Path(sys.executable).parent / "journal"), 201 "doctor", 202 "--readiness", 203 "--json", 204 "--port", 205 str(port), 206 ] 207 208 209def expected_install_models_command() -> list[str]: 210 return [ 211 str(Path(sys.executable).parent / "journal"), 212 "install-models", 213 "--variant", 214 "auto", 215 ] 216 217 218def expected_skills_user_command() -> list[str]: 219 return [ 220 *setup.sol_console_command(), 221 "skills", 222 "install", 223 "--agent", 224 "all", 225 ] 226 227 228def expected_skills_journal_command(journal: Path) -> list[str]: 229 return [ 230 *setup.sol_console_command(), 231 "skills", 232 "install", 233 "--project", 234 str(journal), 235 "--agent", 236 "all", 237 ] 238 239 240def expected_service_install_command(port: int = 5015) -> list[str]: 241 return [ 242 str(Path(sys.executable).parent / "journal"), 243 "service", 244 "install", 245 "--port", 246 str(port), 247 ] 248 249 250def expected_service_restart_command() -> list[str]: 251 return [str(Path(sys.executable).parent / "journal"), "service", "restart"] 252 253 254def expected_brain_bootstrap_command() -> list[str]: 255 return [ 256 *setup.sol_console_command(), 257 "call", 258 "thinking", 259 "local", 260 "bootstrap", 261 ] 262 263 264def assert_command( 265 calls: list[list[str]], position: int, expected_argv: list[str] 266) -> None: 267 assert position < len(calls), ( 268 f"expected {position + 1}+ subprocess calls, got {len(calls)}" 269 ) 270 assert calls[position] == expected_argv, ( 271 f"call[{position}] mismatch:\n want: {expected_argv}\n got: {calls[position]}" 272 ) 273 274 275def assert_step_names_and_statuses( 276 manifest: dict[str, Any], statuses: list[str] 277) -> None: 278 assert [step["name"] for step in manifest["steps"]] == STEP_NAMES 279 assert [step["status"] for step in manifest["steps"]] == statuses 280 281 282def read_manifest(journal: Path) -> dict[str, Any]: 283 return json.loads( 284 (journal / "health" / "setup-state.json").read_text(encoding="utf-8") 285 ) 286 287 288def read_journal_config_file(journal: Path) -> dict[str, Any]: 289 return json.loads((journal / "config" / "journal.json").read_text(encoding="utf-8")) 290 291 292def active_provider(journal: Path) -> str | None: 293 config = read_journal_config_file(journal) 294 providers = config.get("providers", {}) 295 return providers.get("active", {}).get("provider") 296 297 298def step_by_name(manifest: dict[str, Any], name: str) -> dict[str, Any]: 299 return next(step for step in manifest["steps"] if step["name"] == name) 300 301 302def touch_file(path: Path) -> None: 303 path.parent.mkdir(parents=True, exist_ok=True) 304 path.touch() 305 306 307def write_executable_script(path: Path, content: str = "#!/bin/sh\n") -> Path: 308 path.parent.mkdir(parents=True, exist_ok=True) 309 path.write_text(content, encoding="utf-8") 310 path.chmod(0o755) 311 return path 312 313 314def patch_runtime_bin(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 315 bin_dir = tmp_path / "runtime" / "bin" 316 for binary in ("python", "sol", "journal"): 317 write_executable_script(bin_dir / binary) 318 monkeypatch.setattr(sys, "executable", str(bin_dir / "python")) 319 return bin_dir 320 321 322def assert_setup_wrapper( 323 alias: Path, binary: str, journal: Path, bin_dir: Path 324) -> None: 325 parsed = install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) 326 assert parsed == { 327 "journal": str(journal), 328 "sol_bin": str(bin_dir / binary), 329 "version": 7, 330 } 331 332 333def test_doctor_command_uses_journal_console( 334 tmp_path: Path, 335 monkeypatch: pytest.MonkeyPatch, 336) -> None: 337 patch_home(monkeypatch, tmp_path) 338 patch_source_checkout(monkeypatch, tmp_path) 339 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 340 journal = tmp_path / "journal" 341 argv = ["--yes", "--journal", str(journal)] 342 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 343 344 command = setup.doctor_command(ctx) 345 346 assert command == expected_doctor_command() 347 assert command[:1] == setup.journal_console_command() 348 assert command[:3] != [sys.executable, "-m", "solstone.think.sol_cli"] 349 350 351def write_owned_wrapper(home: Path, repo: Path, journal: Path) -> Path: 352 first_wrapper: Path | None = None 353 for binary, wrapper in install_guard.alias_paths().items(): 354 if first_wrapper is None: 355 first_wrapper = wrapper 356 wrapper.parent.mkdir(parents=True, exist_ok=True) 357 target = install_guard.expected_target(repo, binary) 358 target.parent.mkdir(parents=True, exist_ok=True) 359 target.write_text("", encoding="utf-8") 360 wrapper.write_text( 361 install_guard.render_wrapper(str(journal), str(target), binary), 362 encoding="utf-8", 363 ) 364 wrapper.chmod(0o755) 365 assert first_wrapper is not None 366 return first_wrapper 367 368 369def seed_clean_uninstall_artifacts( 370 home: Path, repo: Path, journal: Path 371) -> dict[str, Path]: 372 service_path = setup._service_path_for_uninstall() 373 touch_file(service_path) 374 wrapper_path = write_owned_wrapper(home, repo, journal) 375 journal_wrapper_path = home / ".local" / "bin" / "journal" 376 write_user_config(journal=str(journal)) 377 manifest_path = journal / "health" / "setup-state.json" 378 manifest_path.parent.mkdir(parents=True, exist_ok=True) 379 manifest_path.write_text("{}", encoding="utf-8") 380 return { 381 "service": service_path, 382 "wrapper": wrapper_path, 383 "wrapper_journal": journal_wrapper_path, 384 "config": setup.config_path(), 385 "manifest": manifest_path, 386 } 387 388 389def digest_journal_tree(journal: Path, exclude: set[Path] | None = None) -> str: 390 excluded = {path.resolve() for path in (exclude or set())} 391 digest = hashlib.sha256() 392 for path in sorted(p for p in journal.rglob("*") if p.is_file()): 393 if path.resolve() in excluded: 394 continue 395 digest.update(path.relative_to(journal).as_posix().encode("utf-8")) 396 digest.update(b"\0") 397 digest.update(path.read_bytes()) 398 digest.update(b"\0") 399 return digest.hexdigest() 400 401 402def prior_artifact_paths(journal: Path) -> dict[str, list[Path]]: 403 service_path = setup.service_artifact_path() 404 return { 405 "doctor": [], 406 "journal": [setup.config_path(), journal], 407 "install_models": setup.model_paths(journal), 408 "skills_user": [ 409 Path.home() / ".claude" / "skills" / "sol" / "SKILL.md", 410 Path.home() / ".codex" / "skills" / "sol" / "SKILL.md", 411 Path.home() / ".gemini" / "skills" / "sol" / "SKILL.md", 412 ], 413 "skills_journal": [ 414 journal / ".claude" / "skills", 415 journal / ".agents" / "skills", 416 ], 417 "wrapper": list(install_guard.alias_paths().values()), 418 "service": [service_path] if service_path is not None else [], 419 "brain": [], 420 } 421 422 423def write_clean_prior_manifest(journal: Path) -> dict[str, list[Path]]: 424 journal.mkdir(parents=True, exist_ok=True) 425 paths_by_name = prior_artifact_paths(journal) 426 for paths in paths_by_name.values(): 427 for path in paths: 428 if path == journal: 429 path.mkdir(parents=True, exist_ok=True) 430 else: 431 touch_file(path) 432 started_at = "2026-05-02T21:29:42Z" 433 completed_at = "2026-05-02T21:30:42Z" 434 steps = [ 435 { 436 "name": name, 437 "status": "ok", 438 "paths": [str(path.expanduser().resolve()) for path in paths_by_name[name]], 439 "started_at": started_at, 440 "finished_at": completed_at, 441 "error": None, 442 } 443 for name in STEP_NAMES 444 ] 445 (journal / "health").mkdir(parents=True, exist_ok=True) 446 (journal / "health" / "setup-state.json").write_text( 447 json.dumps( 448 { 449 "schema_version": 1, 450 "started_at": started_at, 451 "completed_at": completed_at, 452 "mode": "non_interactive", 453 "args_resolved": {}, 454 "steps": steps, 455 } 456 ), 457 encoding="utf-8", 458 ) 459 return paths_by_name 460 461 462def test_interactive_happy_path_default_journal( 463 tmp_path: Path, 464 monkeypatch: pytest.MonkeyPatch, 465 capsys: pytest.CaptureFixture[str], 466) -> None: 467 home = patch_home(monkeypatch, tmp_path) 468 patch_source_checkout(monkeypatch, tmp_path) 469 patch_tty(monkeypatch) 470 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 471 (home / ".claude").mkdir() 472 calls = patch_subprocess(monkeypatch) 473 patch_service_health(monkeypatch) 474 475 rc = setup.main([]) 476 477 assert rc == 0 478 journal = home / "journal" 479 assert (home / ".config" / "solstone" / "config.toml").read_text( 480 encoding="utf-8" 481 ) == f'journal = "{journal}"\n' 482 manifest = read_manifest(journal) 483 assert_step_names_and_statuses( 484 manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] 485 ) 486 assert "solstone is running at http://localhost:5015" in capsys.readouterr().out 487 assert_command(calls, 0, expected_doctor_command()) 488 assert_command(calls, 1, expected_install_models_command()) 489 assert_command(calls, 2, expected_skills_user_command()) 490 assert_command(calls, 3, expected_skills_journal_command(journal)) 491 assert_command(calls, 4, expected_service_install_command()) 492 assert_command(calls, 5, expected_brain_bootstrap_command()) 493 assert len(calls) == 6 494 495 496def test_resolve_journal_path_precedence_chain( 497 tmp_path: Path, 498 monkeypatch: pytest.MonkeyPatch, 499) -> None: 500 home = patch_home(monkeypatch, tmp_path) 501 patch_source_checkout(monkeypatch, tmp_path) 502 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 503 parser = setup.build_parser() 504 505 cli_journal = tmp_path / "cli-journal" 506 path, source = setup.resolve_journal_path( 507 parser.parse_args(["--journal", str(cli_journal)]) 508 ) 509 assert path == cli_journal 510 assert source == "cli" 511 512 env_journal = tmp_path / "env-journal" 513 monkeypatch.setenv("SOLSTONE_JOURNAL", str(env_journal)) 514 path, source = setup.resolve_journal_path(parser.parse_args([])) 515 assert path == env_journal 516 assert source == "env" 517 518 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 519 config_journal = tmp_path / "config-journal" 520 write_user_config(journal=str(config_journal)) 521 path, source = setup.resolve_journal_path(parser.parse_args([])) 522 assert path == config_journal 523 assert source == "config" 524 525 write_user_config(journal="") 526 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 527 path, source = setup.resolve_journal_path(parser.parse_args([])) 528 assert path == home / "journal" 529 assert source == "default" 530 531 532def test_interactive_happy_path_journal_override( 533 tmp_path: Path, 534 monkeypatch: pytest.MonkeyPatch, 535) -> None: 536 home = patch_home(monkeypatch, tmp_path) 537 patch_source_checkout(monkeypatch, tmp_path) 538 patch_tty(monkeypatch) 539 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 540 (home / ".claude").mkdir() 541 calls = patch_subprocess(monkeypatch) 542 patch_service_health(monkeypatch) 543 journal = tmp_path / "custom-journal" 544 545 rc = setup.main(["--journal", str(journal)]) 546 547 assert rc == 0 548 assert (home / ".config" / "solstone" / "config.toml").read_text( 549 encoding="utf-8" 550 ) == f'journal = "{journal}"\n' 551 assert read_manifest(journal)["args_resolved"]["journal"]["source"] == "cli" 552 assert_command(calls, 4, expected_service_install_command()) 553 assert_command(calls, 5, expected_brain_bootstrap_command()) 554 assert len(calls) == 6 555 556 557def test_non_interactive_happy_path( 558 tmp_path: Path, 559 monkeypatch: pytest.MonkeyPatch, 560) -> None: 561 home = patch_home(monkeypatch, tmp_path) 562 patch_source_checkout(monkeypatch, tmp_path) 563 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 564 (home / ".claude").mkdir() 565 calls = patch_subprocess(monkeypatch) 566 patch_service_health(monkeypatch) 567 journal = tmp_path / "journal" 568 569 rc = setup.main(["--yes", "--journal", str(journal)]) 570 571 assert rc == 0 572 manifest = read_manifest(journal) 573 assert manifest["completed_at"] is not None 574 assert_step_names_and_statuses( 575 manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] 576 ) 577 assert_command(calls, 4, expected_service_install_command()) 578 assert_command(calls, 5, expected_brain_bootstrap_command()) 579 assert len(calls) == 6 580 581 582def test_step_wrapper_skip_flag_does_not_discover_or_provision( 583 tmp_path: Path, 584 monkeypatch: pytest.MonkeyPatch, 585) -> None: 586 patch_home(monkeypatch, tmp_path) 587 patch_source_checkout(monkeypatch, tmp_path) 588 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 589 monkeypatch.setattr( 590 install_guard, 591 "alias_paths", 592 lambda: pytest.fail("alias_paths should not run"), 593 ) 594 monkeypatch.setattr( 595 install_guard, 596 "provision_wrappers", 597 lambda _root, _journal: pytest.fail("provision_wrappers should not run"), 598 ) 599 journal = tmp_path / "journal" 600 argv = ["--yes", "--journal", str(journal), "--skip-wrapper"] 601 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 602 603 result = setup.step_wrapper(ctx, 6) 604 605 assert result.status == "skipped" 606 assert result.paths == [] 607 assert result.reason == "--skip-wrapper" 608 609 610def test_step_wrapper_default_provisions( 611 tmp_path: Path, 612 monkeypatch: pytest.MonkeyPatch, 613) -> None: 614 patch_home(monkeypatch, tmp_path) 615 repo = patch_source_checkout(monkeypatch, tmp_path) 616 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 617 calls: list[tuple[Path, str]] = [] 618 619 def fake_provision_wrappers(root: Path, journal_path: str) -> None: 620 calls.append((root, journal_path)) 621 622 monkeypatch.setattr(install_guard, "provision_wrappers", fake_provision_wrappers) 623 journal = tmp_path / "journal" 624 argv = ["--yes", "--journal", str(journal)] 625 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 626 627 result = setup.step_wrapper(ctx, 6) 628 629 assert result.status == "ok" 630 assert result.reason is None 631 assert calls == [(repo, str(journal))] 632 assert result.paths == [ 633 str(path.resolve()) for path in install_guard.alias_paths().values() 634 ] 635 636 637def test_skip_wrapper_and_skip_service_are_independent( 638 tmp_path: Path, 639 monkeypatch: pytest.MonkeyPatch, 640) -> None: 641 home = patch_home(monkeypatch, tmp_path) 642 patch_source_checkout(monkeypatch, tmp_path) 643 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 644 (home / ".claude").mkdir() 645 calls = patch_subprocess(monkeypatch) 646 patch_service_health(monkeypatch) 647 648 skip_wrapper_argv = [ 649 "--yes", 650 "--journal", 651 str(tmp_path / "skip-wrapper-journal"), 652 "--skip-wrapper", 653 ] 654 skip_wrapper_ctx = setup.resolve_context( 655 setup.build_parser().parse_args(skip_wrapper_argv), 656 skip_wrapper_argv, 657 ) 658 assert skip_wrapper_ctx.skip_wrapper is True 659 assert skip_wrapper_ctx.skip_service is False 660 661 rc = setup.main(skip_wrapper_argv) 662 663 assert rc == 0 664 skip_wrapper_manifest = read_manifest(tmp_path / "skip-wrapper-journal") 665 assert step_by_name(skip_wrapper_manifest, "wrapper")["status"] == "skipped" 666 assert step_by_name(skip_wrapper_manifest, "wrapper")["reason"] == "--skip-wrapper" 667 assert step_by_name(skip_wrapper_manifest, "service")["status"] == "ok" 668 assert expected_service_install_command() in calls 669 670 calls.clear() 671 skip_service_argv = [ 672 "--yes", 673 "--journal", 674 str(tmp_path / "skip-service-journal"), 675 "--skip-service", 676 ] 677 skip_service_ctx = setup.resolve_context( 678 setup.build_parser().parse_args(skip_service_argv), 679 skip_service_argv, 680 ) 681 assert skip_service_ctx.skip_service is True 682 assert skip_service_ctx.skip_wrapper is False 683 684 rc = setup.main(skip_service_argv) 685 686 assert rc == 0 687 skip_service_manifest = read_manifest(tmp_path / "skip-service-journal") 688 assert step_by_name(skip_service_manifest, "wrapper")["status"] == "ok" 689 assert step_by_name(skip_service_manifest, "service")["status"] == "skipped" 690 assert expected_service_install_command() not in calls 691 for binary, alias in install_guard.alias_paths().items(): 692 assert_setup_wrapper( 693 alias, 694 binary, 695 tmp_path / "skip-service-journal", 696 bin_dir=Path(sys.executable).parent, 697 ) 698 699 700def test_skip_wrapper_args_resolved_provenance( 701 tmp_path: Path, 702 monkeypatch: pytest.MonkeyPatch, 703) -> None: 704 home = patch_home(monkeypatch, tmp_path) 705 patch_source_checkout(monkeypatch, tmp_path) 706 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 707 (home / ".claude").mkdir() 708 patch_subprocess(monkeypatch) 709 patch_service_health(monkeypatch) 710 default_journal = tmp_path / "default-wrapper-journal" 711 skipped_journal = tmp_path / "skipped-wrapper-journal" 712 713 default_rc = setup.main(["--yes", "--journal", str(default_journal)]) 714 skipped_rc = setup.main( 715 ["--yes", "--journal", str(skipped_journal), "--skip-wrapper"] 716 ) 717 718 assert default_rc == 0 719 assert skipped_rc == 0 720 assert read_manifest(default_journal)["args_resolved"]["skip_wrapper"] == { 721 "value": False, 722 "source": "default", 723 } 724 assert read_manifest(skipped_journal)["args_resolved"]["skip_wrapper"] == { 725 "value": True, 726 "source": "cli", 727 } 728 729 730def test_skip_wrapper_preserves_foreign_aliases_until_default_run_repairs_them( 731 tmp_path: Path, 732 monkeypatch: pytest.MonkeyPatch, 733) -> None: 734 patch_home(monkeypatch, tmp_path) 735 patch_source_checkout(monkeypatch, tmp_path) 736 bin_dir = patch_runtime_bin(monkeypatch, tmp_path) 737 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 738 backup_dir = tmp_path / "legacy-backups" 739 backup_dir.mkdir() 740 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir) 741 patch_subprocess(monkeypatch) 742 journal = tmp_path / "journal" 743 aliases = install_guard.alias_paths() 744 original_bytes: dict[str, bytes] = {} 745 original_modes: dict[str, int] = {} 746 for binary, alias in aliases.items(): 747 alias.parent.mkdir(parents=True, exist_ok=True) 748 original_bytes[binary] = ( 749 f'#!/bin/sh\nexec /opt/signed-journal/{binary} "$@"\n' 750 ).encode("utf-8") 751 alias.write_bytes(original_bytes[binary]) 752 alias.chmod(0o744 if binary == "sol" else 0o755) 753 original_modes[binary] = alias.stat().st_mode & 0o777 754 755 rc = setup.main( 756 [ 757 "--yes", 758 "--journal", 759 str(journal), 760 "--skip-wrapper", 761 "--skip-service", 762 "--skip-models", 763 "--skip-skills", 764 ] 765 ) 766 767 assert rc == 0 768 for binary, alias in aliases.items(): 769 assert alias.read_bytes() == original_bytes[binary] 770 assert alias.stat().st_mode & 0o777 == original_modes[binary] 771 assert not list(backup_dir.glob(f"{binary}.old-symlink-*")) 772 skip_manifest = read_manifest(journal) 773 assert step_by_name(skip_manifest, "wrapper")["status"] == "skipped" 774 assert step_by_name(skip_manifest, "wrapper")["reason"] == "--skip-wrapper" 775 776 rc = setup.main( 777 [ 778 "--yes", 779 "--journal", 780 str(journal), 781 "--skip-service", 782 "--skip-models", 783 "--skip-skills", 784 ] 785 ) 786 787 assert rc == 0 788 for binary, alias in aliases.items(): 789 assert alias.read_bytes() != original_bytes[binary] 790 assert_setup_wrapper(alias, binary, journal, bin_dir) 791 assert list(backup_dir.glob(f"{binary}.old-symlink-*")) 792 repair_manifest = read_manifest(journal) 793 assert step_by_name(repair_manifest, "wrapper")["status"] == "ok" 794 795 796def test_brain_capable_sets_lane_and_triggers_bootstrap( 797 tmp_path: Path, 798 monkeypatch: pytest.MonkeyPatch, 799) -> None: 800 home = patch_home(monkeypatch, tmp_path) 801 patch_source_checkout(monkeypatch, tmp_path) 802 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 803 (home / ".claude").mkdir() 804 calls = patch_subprocess(monkeypatch) 805 patch_service_health(monkeypatch) 806 journal = tmp_path / "journal" 807 808 started = time.monotonic() 809 rc = setup.main(["--yes", "--journal", str(journal)]) 810 elapsed = time.monotonic() - started 811 812 assert rc == 0 813 assert elapsed < 1 814 assert active_provider(journal) == "local" 815 manifest = read_manifest(journal) 816 assert step_by_name(manifest, "brain")["status"] == "ok" 817 assert_command(calls, 5, expected_brain_bootstrap_command()) 818 819 820def test_brain_skip_brain_sets_lane_and_skips( 821 tmp_path: Path, 822 monkeypatch: pytest.MonkeyPatch, 823) -> None: 824 from solstone.think import check 825 826 home = patch_home(monkeypatch, tmp_path) 827 patch_source_checkout(monkeypatch, tmp_path) 828 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 829 (home / ".claude").mkdir() 830 monkeypatch.setattr( 831 check, 832 "build_check_report", 833 lambda: pytest.fail("build_check_report should not run"), 834 ) 835 calls = patch_subprocess(monkeypatch) 836 patch_service_health(monkeypatch) 837 journal = tmp_path / "journal" 838 839 rc = setup.main(["--yes", "--journal", str(journal), "--skip-brain"]) 840 841 assert rc == 0 842 assert active_provider(journal) == "local" 843 brain_step = step_by_name(read_manifest(journal), "brain") 844 assert brain_step["status"] == "skipped" 845 assert brain_step["reason"] == "--skip-brain" 846 assert expected_brain_bootstrap_command() not in calls 847 848 849def test_brain_skip_models_derives_skip_brain( 850 tmp_path: Path, 851 monkeypatch: pytest.MonkeyPatch, 852) -> None: 853 from solstone.think import check 854 855 home = patch_home(monkeypatch, tmp_path) 856 patch_source_checkout(monkeypatch, tmp_path) 857 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 858 (home / ".claude").mkdir() 859 monkeypatch.setattr( 860 check, 861 "build_check_report", 862 lambda: pytest.fail("build_check_report should not run"), 863 ) 864 calls = patch_subprocess(monkeypatch) 865 patch_service_health(monkeypatch) 866 journal = tmp_path / "journal" 867 868 rc = setup.main(["--yes", "--journal", str(journal), "--skip-models"]) 869 870 assert rc == 0 871 manifest = read_manifest(journal) 872 assert manifest["args_resolved"]["skip_brain"] == { 873 "value": True, 874 "source": "cli:--skip-models", 875 } 876 brain_step = step_by_name(manifest, "brain") 877 assert brain_step["status"] == "skipped" 878 assert brain_step["reason"] == "--skip-models implies --skip-brain" 879 assert active_provider(journal) == "local" 880 assert expected_brain_bootstrap_command() not in calls 881 882 883def test_brain_skip_service_sets_lane_and_skips( 884 tmp_path: Path, 885 monkeypatch: pytest.MonkeyPatch, 886) -> None: 887 from solstone.think import check 888 889 home = patch_home(monkeypatch, tmp_path) 890 patch_source_checkout(monkeypatch, tmp_path) 891 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 892 (home / ".claude").mkdir() 893 monkeypatch.setattr( 894 check, 895 "build_check_report", 896 lambda: pytest.fail("build_check_report should not run"), 897 ) 898 calls = patch_subprocess(monkeypatch) 899 journal = tmp_path / "journal" 900 901 rc = setup.main(["--yes", "--journal", str(journal), "--skip-service"]) 902 903 assert rc == 0 904 assert active_provider(journal) == "local" 905 brain_step = step_by_name(read_manifest(journal), "brain") 906 assert brain_step["status"] == "skipped" 907 assert brain_step["reason"] == "--skip-service" 908 assert expected_service_install_command() not in calls 909 assert expected_brain_bootstrap_command() not in calls 910 911 912def test_brain_blocked_verdict_skips_without_trigger( 913 tmp_path: Path, 914 monkeypatch: pytest.MonkeyPatch, 915) -> None: 916 from solstone.think import check 917 918 home = patch_home(monkeypatch, tmp_path) 919 patch_source_checkout(monkeypatch, tmp_path) 920 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 921 (home / ".claude").mkdir() 922 monkeypatch.setattr( 923 check, "build_check_report", lambda: fake_check_report("blocked") 924 ) 925 calls = patch_subprocess(monkeypatch) 926 patch_service_health(monkeypatch) 927 journal = tmp_path / "journal" 928 929 rc = setup.main(["--yes", "--journal", str(journal)]) 930 931 assert rc == 0 932 manifest = read_manifest(journal) 933 assert manifest["completed_at"] is not None 934 assert active_provider(journal) == "local" 935 brain_step = step_by_name(manifest, "brain") 936 assert brain_step["status"] == "skipped" 937 assert brain_step["reason"] == "local provider unavailable on this host" 938 assert expected_brain_bootstrap_command() not in calls 939 940 941def test_brain_trigger_nonzero_warns_but_setup_succeeds( 942 tmp_path: Path, 943 monkeypatch: pytest.MonkeyPatch, 944) -> None: 945 home = patch_home(monkeypatch, tmp_path) 946 patch_source_checkout(monkeypatch, tmp_path) 947 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 948 (home / ".claude").mkdir() 949 patch_subprocess(monkeypatch) 950 patch_service_health(monkeypatch) 951 journal = tmp_path / "journal" 952 commands: list[list[str]] = [] 953 954 def fake_run_step_subprocess( 955 ctx: setup.SetupContext, 956 command: list[str], 957 *, 958 timeout: float | None = None, 959 ) -> setup.StepProcessResult: 960 del ctx, timeout 961 commands.append(command) 962 if command == expected_brain_bootstrap_command(): 963 return setup.StepProcessResult(9, "", "bootstrap unavailable\n", False) 964 return setup.StepProcessResult(0, "", "", False) 965 966 monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) 967 968 rc = setup.main(["--yes", "--journal", str(journal)]) 969 970 assert rc == 0 971 manifest = read_manifest(journal) 972 assert manifest["completed_at"] is not None 973 brain_step = step_by_name(manifest, "brain") 974 assert brain_step["status"] == "warning" 975 assert brain_step["reason"] == "local bootstrap did not start" 976 assert brain_step["error"]["message"] == "bootstrap unavailable" 977 assert brain_step["error"]["fix_hint"] == "journal install-provider local" 978 assert expected_brain_bootstrap_command() in commands 979 980 981def test_brain_preserves_existing_provider_lane( 982 tmp_path: Path, 983 monkeypatch: pytest.MonkeyPatch, 984) -> None: 985 home = patch_home(monkeypatch, tmp_path) 986 patch_source_checkout(monkeypatch, tmp_path) 987 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 988 (home / ".claude").mkdir() 989 journal = tmp_path / "journal" 990 config_path = journal / "config" / "journal.json" 991 config_path.parent.mkdir(parents=True) 992 original_config = { 993 "providers": {"active": {"provider": "anthropic", "model": "claude-sonnet-5"}} 994 } 995 config_path.write_text(json.dumps(original_config), encoding="utf-8") 996 calls = patch_subprocess(monkeypatch) 997 patch_service_health(monkeypatch) 998 999 rc = setup.main(["--yes", "--journal", str(journal), "--accept-existing-journal"]) 1000 1001 assert rc == 0 1002 assert read_journal_config_file(journal) == original_config 1003 brain_step = step_by_name(read_manifest(journal), "brain") 1004 assert brain_step["status"] == "skipped" 1005 assert brain_step["reason"] == "a provider is already configured" 1006 assert expected_brain_bootstrap_command() not in calls 1007 1008 1009@pytest.mark.parametrize( 1010 "original_config", 1011 [ 1012 {"providers": "anthropic"}, 1013 {"providers": {"active": "anthropic"}}, 1014 {"providers": {"active": ["local"]}}, 1015 ], 1016) 1017def test_brain_skips_on_malformed_provider_config( 1018 tmp_path: Path, 1019 monkeypatch: pytest.MonkeyPatch, 1020 original_config: dict[str, Any], 1021) -> None: 1022 home = patch_home(monkeypatch, tmp_path) 1023 patch_source_checkout(monkeypatch, tmp_path) 1024 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1025 (home / ".claude").mkdir() 1026 journal = tmp_path / "journal" 1027 config_path = journal / "config" / "journal.json" 1028 config_path.parent.mkdir(parents=True) 1029 original_text = json.dumps(original_config, sort_keys=True) 1030 config_path.write_text(original_text, encoding="utf-8") 1031 calls = patch_subprocess(monkeypatch) 1032 patch_service_health(monkeypatch) 1033 1034 rc = setup.main(["--yes", "--journal", str(journal), "--accept-existing-journal"]) 1035 1036 assert rc == 0 1037 assert config_path.read_text(encoding="utf-8") == original_text 1038 brain_step = step_by_name(read_manifest(journal), "brain") 1039 assert brain_step["status"] == "skipped" 1040 assert brain_step["reason"] == "provider config is not in the expected shape" 1041 assert expected_brain_bootstrap_command() not in calls 1042 1043 1044def test_brain_dry_run_writes_nothing( 1045 tmp_path: Path, 1046 monkeypatch: pytest.MonkeyPatch, 1047) -> None: 1048 patch_home(monkeypatch, tmp_path) 1049 patch_source_checkout(monkeypatch, tmp_path) 1050 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1051 journal = tmp_path / "journal" 1052 1053 rc = setup.main(["--dry-run", "--journal", str(journal)]) 1054 1055 assert rc == 0 1056 assert not (journal / "config" / "journal.json").exists() 1057 1058 1059def test_brain_never_calls_direct_installers( 1060 tmp_path: Path, 1061 monkeypatch: pytest.MonkeyPatch, 1062) -> None: 1063 from solstone.think import install_provider 1064 from solstone.think.providers import local_install 1065 1066 home = patch_home(monkeypatch, tmp_path) 1067 patch_source_checkout(monkeypatch, tmp_path) 1068 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1069 (home / ".claude").mkdir() 1070 monkeypatch.setattr( 1071 local_install, 1072 "install_local", 1073 lambda *args, **kwargs: pytest.fail("install_local should not run"), 1074 ) 1075 monkeypatch.setattr( 1076 install_provider, 1077 "main", 1078 lambda *args, **kwargs: pytest.fail("install_provider.main should not run"), 1079 ) 1080 calls = patch_subprocess(monkeypatch) 1081 patch_service_health(monkeypatch) 1082 journal = tmp_path / "journal" 1083 1084 rc = setup.main(["--yes", "--journal", str(journal)]) 1085 1086 assert rc == 0 1087 assert expected_brain_bootstrap_command() in calls 1088 1089 1090def test_brain_rerun_after_defer_triggers_bootstrap( 1091 tmp_path: Path, 1092 monkeypatch: pytest.MonkeyPatch, 1093) -> None: 1094 home = patch_home(monkeypatch, tmp_path) 1095 patch_source_checkout(monkeypatch, tmp_path) 1096 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1097 (home / ".claude").mkdir() 1098 first_calls = patch_subprocess(monkeypatch) 1099 patch_service_health(monkeypatch) 1100 journal = tmp_path / "journal" 1101 1102 first_rc = setup.main(["--yes", "--journal", str(journal), "--skip-brain"]) 1103 1104 assert first_rc == 0 1105 first_manifest = read_manifest(journal) 1106 first_brain = step_by_name(first_manifest, "brain") 1107 assert first_brain["status"] == "skipped" 1108 assert first_brain["reason"] == "--skip-brain" 1109 assert active_provider(journal) == "local" 1110 assert expected_brain_bootstrap_command() not in first_calls 1111 1112 second_calls = patch_subprocess(monkeypatch) 1113 second_rc = setup.main(["--yes", "--journal", str(journal)]) 1114 1115 assert second_rc == 0 1116 assert active_provider(journal) == "local" 1117 second_brain = step_by_name(read_manifest(journal), "brain") 1118 assert second_brain["status"] == "ok" 1119 assert expected_brain_bootstrap_command() in second_calls 1120 1121 1122def test_brain_rerun_after_warning_retries_bootstrap( 1123 tmp_path: Path, 1124 monkeypatch: pytest.MonkeyPatch, 1125) -> None: 1126 home = patch_home(monkeypatch, tmp_path) 1127 patch_source_checkout(monkeypatch, tmp_path) 1128 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1129 (home / ".claude").mkdir() 1130 patch_subprocess(monkeypatch) 1131 patch_service_health(monkeypatch) 1132 journal = tmp_path / "journal" 1133 bootstrap_results = [ 1134 setup.StepProcessResult(9, "", "bootstrap unavailable\n", False), 1135 setup.StepProcessResult(0, "", "", False), 1136 ] 1137 commands: list[list[str]] = [] 1138 1139 def fake_run_step_subprocess( 1140 ctx: setup.SetupContext, 1141 command: list[str], 1142 *, 1143 timeout: float | None = None, 1144 ) -> setup.StepProcessResult: 1145 del ctx, timeout 1146 commands.append(command) 1147 if command == expected_brain_bootstrap_command(): 1148 return bootstrap_results.pop(0) 1149 return setup.StepProcessResult(0, "", "", False) 1150 1151 monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) 1152 1153 first_rc = setup.main(["--yes", "--journal", str(journal)]) 1154 first_brain = step_by_name(read_manifest(journal), "brain") 1155 1156 assert first_rc == 0 1157 assert first_brain["status"] == "warning" 1158 assert first_brain["reason"] == "local bootstrap did not start" 1159 1160 second_rc = setup.main(["--yes", "--journal", str(journal)]) 1161 second_brain = step_by_name(read_manifest(journal), "brain") 1162 1163 assert second_rc == 0 1164 assert second_brain["status"] == "ok" 1165 assert second_brain["reason"] is None 1166 assert commands.count(expected_brain_bootstrap_command()) == 2 1167 assert bootstrap_results == [] 1168 1169 1170@pytest.mark.parametrize("use_journal_flag", [False, True]) 1171def test_non_interactive_dead_end_on_existing_journal( 1172 tmp_path: Path, 1173 monkeypatch: pytest.MonkeyPatch, 1174 capsys: pytest.CaptureFixture[str], 1175 use_journal_flag: bool, 1176) -> None: 1177 home = patch_home(monkeypatch, tmp_path) 1178 patch_source_checkout(monkeypatch, tmp_path) 1179 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1180 calls = patch_subprocess(monkeypatch) 1181 patch_service_health(monkeypatch) 1182 journal = tmp_path / "journal" if use_journal_flag else home / "journal" 1183 (journal / "config").mkdir(parents=True) 1184 argv = ["--yes"] 1185 if use_journal_flag: 1186 argv.extend(["--journal", str(journal)]) 1187 1188 rc = setup.main(argv) 1189 1190 assert rc == 2 1191 err = capsys.readouterr().err 1192 assert "already contains journal data" in err 1193 assert "--accept-existing-journal" in err 1194 assert_command(calls, 0, expected_doctor_command()) 1195 assert len(calls) == 1 1196 1197 1198def test_dry_run_side_effect_free( 1199 tmp_path: Path, 1200 monkeypatch: pytest.MonkeyPatch, 1201 capsys: pytest.CaptureFixture[str], 1202) -> None: 1203 home = patch_home(monkeypatch, tmp_path) 1204 patch_source_checkout(monkeypatch, tmp_path) 1205 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1206 calls = patch_subprocess(monkeypatch) 1207 journal = tmp_path / "journal" 1208 1209 rc = setup.main(["--dry-run", "--journal", str(journal)]) 1210 1211 assert rc == 0 1212 assert calls == [] 1213 assert not (home / ".config" / "solstone" / "config.toml").exists() 1214 assert not (journal / "health" / "setup-state.json").exists() 1215 assert "setup dry-run:" in capsys.readouterr().out 1216 1217 skip_wrapper_journal = tmp_path / "skip-wrapper-journal" 1218 rc = setup.main( 1219 ["--dry-run", "--journal", str(skip_wrapper_journal), "--skip-wrapper"] 1220 ) 1221 1222 assert rc == 0 1223 assert calls == [] 1224 assert not (home / ".config" / "solstone" / "config.toml").exists() 1225 assert not (skip_wrapper_journal / "health" / "setup-state.json").exists() 1226 assert "skipped: --skip-wrapper" in capsys.readouterr().out 1227 1228 1229def test_step_journal_materializes_journal_config( 1230 tmp_path: Path, 1231 monkeypatch: pytest.MonkeyPatch, 1232) -> None: 1233 patch_home(monkeypatch, tmp_path) 1234 patch_source_checkout(monkeypatch, tmp_path) 1235 patch_journal_os_defaults(monkeypatch) 1236 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1237 journal = tmp_path / "journal" 1238 argv = ["--yes", "--journal", str(journal)] 1239 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 1240 1241 result = setup.step_journal(ctx, 2) 1242 1243 config_path = journal / "config" / "journal.json" 1244 config = json.loads(config_path.read_text(encoding="utf-8")) 1245 assert result.status == "ok" 1246 assert config["identity"]["name"] == "Setup User" 1247 assert config["identity"]["preferred"] == "setup" 1248 assert config["identity"]["timezone"] == "America/Denver" 1249 assert "convey" not in config 1250 assert str(config_path.resolve()) in result.paths 1251 1252 1253def test_step_journal_dry_run_does_not_materialize_journal_config( 1254 tmp_path: Path, 1255 monkeypatch: pytest.MonkeyPatch, 1256) -> None: 1257 patch_home(monkeypatch, tmp_path) 1258 patch_source_checkout(monkeypatch, tmp_path) 1259 patch_journal_os_defaults(monkeypatch) 1260 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1261 journal = tmp_path / "journal" 1262 argv = ["--dry-run", "--journal", str(journal)] 1263 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 1264 1265 result = setup.step_journal(ctx, 2) 1266 1267 assert result.status == "ok" 1268 assert not (journal / "config" / "journal.json").exists() 1269 1270 1271def test_step_journal_is_idempotent( 1272 tmp_path: Path, 1273 monkeypatch: pytest.MonkeyPatch, 1274) -> None: 1275 patch_home(monkeypatch, tmp_path) 1276 patch_source_checkout(monkeypatch, tmp_path) 1277 patch_journal_os_defaults(monkeypatch) 1278 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1279 journal = tmp_path / "journal" 1280 argv = ["--yes", "--journal", str(journal)] 1281 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 1282 1283 setup.step_journal(ctx, 2) 1284 config_path = journal / "config" / "journal.json" 1285 first = config_path.stat() 1286 setup.step_journal(ctx, 2) 1287 second = config_path.stat() 1288 1289 assert second.st_ino == first.st_ino 1290 assert second.st_size == first.st_size 1291 1292 1293def test_explain_early_exit( 1294 tmp_path: Path, 1295 monkeypatch: pytest.MonkeyPatch, 1296 capsys: pytest.CaptureFixture[str], 1297) -> None: 1298 patch_home(monkeypatch, tmp_path) 1299 patch_source_checkout(monkeypatch, tmp_path) 1300 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1301 calls = patch_subprocess(monkeypatch) 1302 1303 rc = setup.main(["--explain"]) 1304 1305 assert rc == 0 1306 assert calls == [] 1307 assert "setup plan:" in capsys.readouterr().out 1308 1309 1310def test_print_plan_step_count_matches_steps( 1311 tmp_path: Path, 1312 monkeypatch: pytest.MonkeyPatch, 1313 capsys: pytest.CaptureFixture[str], 1314) -> None: 1315 patch_home(monkeypatch, tmp_path) 1316 patch_source_checkout(monkeypatch, tmp_path) 1317 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1318 1319 rc = setup.main(["--explain"]) 1320 1321 assert rc == 0 1322 assert len(setup._STEPS) == setup.TOTAL_STEPS 1323 assert set(setup._STEP_NAME) == set(setup._STEPS) 1324 out = capsys.readouterr().out 1325 headers = [line for line in out.splitlines() if line.startswith("[step ")] 1326 assert len(headers) == setup.TOTAL_STEPS 1327 assert headers == [ 1328 f"[step {index}/{setup.TOTAL_STEPS}] {setup._STEP_NAME[step]}" 1329 + setup._plan_step_suffix( 1330 setup.resolve_context(setup.build_parser().parse_args(["--explain"]), []), 1331 step, 1332 ) 1333 for index, step in enumerate(setup._STEPS, start=1) 1334 ] 1335 1336 1337def test_step_registries_agree() -> None: 1338 assert tuple(setup._STEP_NAME[step] for step in setup._STEPS) == ( 1339 setup_events.STEP_NAMES 1340 ) 1341 assert set(setup._PLAN_BODY) == set(setup._STEPS) 1342 assert set(setup._STEP_NAME) == set(setup._STEPS) 1343 assert setup.TOTAL_STEPS == len(setup._STEPS) 1344 1345 1346def test_manifest_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 1347 home = patch_home(monkeypatch, tmp_path) 1348 patch_source_checkout(monkeypatch, tmp_path) 1349 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1350 (home / ".claude").mkdir() 1351 patch_subprocess(monkeypatch) 1352 patch_service_health(monkeypatch) 1353 journal = tmp_path / "journal" 1354 1355 rc = setup.main(["--yes", "--journal", str(journal)]) 1356 1357 assert rc == 0 1358 manifest = read_manifest(journal) 1359 assert manifest["schema_version"] == 1 1360 assert manifest["mode"] == "non_interactive" 1361 assert all( 1362 Path(path).is_absolute() for step in manifest["steps"] for path in step["paths"] 1363 ) 1364 assert {step["status"] for step in manifest["steps"]} <= { 1365 "ok", 1366 "skipped", 1367 "failed", 1368 "warning", 1369 } 1370 1371 1372def test_persisted_journal_skips_existing_journal_check_non_interactive( 1373 tmp_path: Path, 1374 monkeypatch: pytest.MonkeyPatch, 1375 capsys: pytest.CaptureFixture[str], 1376) -> None: 1377 patch_home(monkeypatch, tmp_path) 1378 patch_source_checkout(monkeypatch, tmp_path) 1379 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1380 journal = tmp_path / "journal" 1381 (journal / "config").mkdir(parents=True) 1382 write_user_config(journal=str(journal)) 1383 patch_subprocess(monkeypatch) 1384 patch_service_health(monkeypatch) 1385 1386 rc = setup.main(["--yes"]) 1387 1388 assert rc == 0 1389 assert "already contains journal data" not in capsys.readouterr().err 1390 journal_step = next( 1391 step for step in read_manifest(journal)["steps"] if step["name"] == "journal" 1392 ) 1393 assert journal_step["status"] == "ok" 1394 1395 1396def test_persisted_journal_skips_existing_journal_check_interactive( 1397 tmp_path: Path, 1398 monkeypatch: pytest.MonkeyPatch, 1399 capsys: pytest.CaptureFixture[str], 1400) -> None: 1401 patch_home(monkeypatch, tmp_path) 1402 patch_source_checkout(monkeypatch, tmp_path) 1403 patch_tty(monkeypatch) 1404 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1405 journal = tmp_path / "journal" 1406 (journal / "config").mkdir(parents=True) 1407 write_user_config(journal=str(journal)) 1408 patch_subprocess(monkeypatch) 1409 patch_service_health(monkeypatch) 1410 1411 def fail_on_prompt(path: Path) -> bool: 1412 raise AssertionError(f"unexpected existing-journal prompt for {path}") 1413 1414 monkeypatch.setattr(setup, "prompt_accept_existing_journal", fail_on_prompt) 1415 1416 rc = setup.main([]) 1417 1418 assert rc == 0 1419 assert "Use existing journal" not in capsys.readouterr().out 1420 1421 1422def test_existing_journal_dead_end_still_fires_when_path_not_persisted( 1423 tmp_path: Path, 1424 monkeypatch: pytest.MonkeyPatch, 1425 capsys: pytest.CaptureFixture[str], 1426) -> None: 1427 patch_home(monkeypatch, tmp_path) 1428 patch_source_checkout(monkeypatch, tmp_path) 1429 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1430 patch_subprocess(monkeypatch) 1431 patch_service_health(monkeypatch) 1432 journal = tmp_path / "journal" 1433 (journal / "config").mkdir(parents=True) 1434 1435 rc = setup.main(["--yes", "--journal", str(journal)]) 1436 1437 assert rc == 2 1438 assert "already contains journal data" in capsys.readouterr().err 1439 1440 1441def test_clean_rerun_preface_when_manifest_complete( 1442 tmp_path: Path, 1443 monkeypatch: pytest.MonkeyPatch, 1444 capsys: pytest.CaptureFixture[str], 1445) -> None: 1446 patch_home(monkeypatch, tmp_path) 1447 patch_source_checkout(monkeypatch, tmp_path) 1448 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1449 patch_subprocess(monkeypatch) 1450 patch_service_health(monkeypatch) 1451 journal = tmp_path / "journal" 1452 journal.mkdir() 1453 completed_at = "2026-05-02T21:30:42Z" 1454 started_at = "2026-05-02T21:29:42Z" 1455 steps = [ 1456 { 1457 "name": name, 1458 "status": "ok", 1459 "paths": [], 1460 "started_at": started_at, 1461 "finished_at": completed_at, 1462 "error": None, 1463 } 1464 for name in ( 1465 "doctor", 1466 "journal", 1467 "install_models", 1468 "skills_user", 1469 "skills_journal", 1470 "wrapper", 1471 "service", 1472 ) 1473 ] 1474 (journal / "health").mkdir(parents=True, exist_ok=True) 1475 (journal / "health" / "setup-state.json").write_text( 1476 json.dumps( 1477 { 1478 "schema_version": 1, 1479 "started_at": started_at, 1480 "completed_at": completed_at, 1481 "mode": "non_interactive", 1482 "args_resolved": {}, 1483 "steps": steps, 1484 } 1485 ), 1486 encoding="utf-8", 1487 ) 1488 1489 rc = setup.main(["--yes", "--journal", str(journal)]) 1490 1491 assert rc == 0 1492 lines = capsys.readouterr().out.splitlines() 1493 assert lines[0] == ( 1494 f"journal setup last ran cleanly on {completed_at}; verifying current state." 1495 ) 1496 assert lines[1] == "Use --force to re-run all steps unconditionally." 1497 1498 1499def test_partial_rerun_preface_when_steps_failed( 1500 tmp_path: Path, 1501 monkeypatch: pytest.MonkeyPatch, 1502 capsys: pytest.CaptureFixture[str], 1503) -> None: 1504 patch_home(monkeypatch, tmp_path) 1505 patch_source_checkout(monkeypatch, tmp_path) 1506 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1507 patch_subprocess(monkeypatch) 1508 patch_service_health(monkeypatch) 1509 journal = tmp_path / "journal" 1510 journal.mkdir() 1511 started_at = "2026-05-02T21:29:42Z" 1512 (journal / "health").mkdir(parents=True, exist_ok=True) 1513 (journal / "health" / "setup-state.json").write_text( 1514 json.dumps( 1515 { 1516 "schema_version": 1, 1517 "started_at": started_at, 1518 "completed_at": None, 1519 "mode": "non_interactive", 1520 "args_resolved": {}, 1521 "steps": [ 1522 { 1523 "name": "install_models", 1524 "status": "failed", 1525 "paths": [], 1526 "started_at": started_at, 1527 "finished_at": "2026-05-02T21:30:42Z", 1528 "error": {"message": "install failed", "exit_code": 1}, 1529 } 1530 ], 1531 } 1532 ), 1533 encoding="utf-8", 1534 ) 1535 1536 rc = setup.main(["--yes", "--journal", str(journal)]) 1537 1538 assert rc == 0 1539 assert ( 1540 f"journal setup last run on {started_at} left these steps incomplete:\n" 1541 " - install_models (failed)\n" 1542 "Re-running will verify state and re-run incomplete steps." 1543 ) in capsys.readouterr().out 1544 1545 1546def test_no_preface_without_manifest( 1547 tmp_path: Path, 1548 monkeypatch: pytest.MonkeyPatch, 1549 capsys: pytest.CaptureFixture[str], 1550) -> None: 1551 patch_home(monkeypatch, tmp_path) 1552 patch_source_checkout(monkeypatch, tmp_path) 1553 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1554 patch_subprocess(monkeypatch) 1555 patch_service_health(monkeypatch) 1556 journal = tmp_path / "journal" 1557 1558 rc = setup.main(["--yes", "--journal", str(journal)]) 1559 1560 assert rc == 0 1561 out = capsys.readouterr().out 1562 assert "last ran cleanly" not in out and "left these steps incomplete" not in out 1563 1564 1565def test_force_flag_changes_preface_text( 1566 tmp_path: Path, 1567 monkeypatch: pytest.MonkeyPatch, 1568 capsys: pytest.CaptureFixture[str], 1569) -> None: 1570 patch_home(monkeypatch, tmp_path) 1571 patch_source_checkout(monkeypatch, tmp_path) 1572 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1573 patch_subprocess(monkeypatch) 1574 patch_service_health(monkeypatch) 1575 journal = tmp_path / "journal" 1576 journal.mkdir() 1577 completed_at = "2026-05-02T21:30:42Z" 1578 started_at = "2026-05-02T21:29:42Z" 1579 steps = [ 1580 { 1581 "name": name, 1582 "status": "ok", 1583 "paths": [], 1584 "started_at": started_at, 1585 "finished_at": completed_at, 1586 "error": None, 1587 } 1588 for name in ( 1589 "doctor", 1590 "journal", 1591 "install_models", 1592 "skills_user", 1593 "skills_journal", 1594 "wrapper", 1595 "service", 1596 ) 1597 ] 1598 (journal / "health").mkdir(parents=True, exist_ok=True) 1599 (journal / "health" / "setup-state.json").write_text( 1600 json.dumps( 1601 { 1602 "schema_version": 1, 1603 "started_at": started_at, 1604 "completed_at": completed_at, 1605 "mode": "non_interactive", 1606 "args_resolved": {}, 1607 "steps": steps, 1608 } 1609 ), 1610 encoding="utf-8", 1611 ) 1612 1613 rc = setup.main(["--yes", "--force", "--journal", str(journal)]) 1614 1615 assert rc == 0 1616 out = capsys.readouterr().out 1617 assert out.startswith( 1618 f"journal setup last ran cleanly on {completed_at}; re-running all steps (--force)." 1619 ) 1620 assert "Use --force to re-run all steps unconditionally." not in out 1621 1622 1623def test_partial_completion_runs_remaining_steps( 1624 tmp_path: Path, 1625 monkeypatch: pytest.MonkeyPatch, 1626) -> None: 1627 home = patch_home(monkeypatch, tmp_path) 1628 patch_source_checkout(monkeypatch, tmp_path) 1629 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1630 (home / ".claude").mkdir() 1631 journal = tmp_path / "journal" 1632 journal.mkdir() 1633 (journal / "health").mkdir(parents=True, exist_ok=True) 1634 (journal / "health" / "setup-state.json").write_text( 1635 json.dumps( 1636 { 1637 "schema_version": 1, 1638 "steps": [ 1639 {"name": "doctor", "status": "ok"}, 1640 {"name": "service", "status": "failed"}, 1641 ], 1642 } 1643 ), 1644 encoding="utf-8", 1645 ) 1646 calls = patch_subprocess(monkeypatch) 1647 patch_service_health(monkeypatch) 1648 1649 rc = setup.main(["--yes", "--journal", str(journal)]) 1650 1651 assert rc == 0 1652 manifest = read_manifest(journal) 1653 assert_step_names_and_statuses( 1654 manifest, ["skipped", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] 1655 ) 1656 assert manifest["steps"][0]["reason"] == "prior_run_ok" 1657 assert_command(calls, 0, expected_install_models_command()) 1658 assert_command(calls, 1, expected_skills_user_command()) 1659 assert_command(calls, 2, expected_skills_journal_command(journal)) 1660 assert_command(calls, 3, expected_service_install_command()) 1661 assert_command(calls, 4, expected_brain_bootstrap_command()) 1662 assert len(calls) == 5 1663 1664 1665def test_non_interactive_setup_has_no_port_preflight_dead_end( 1666 tmp_path: Path, 1667 monkeypatch: pytest.MonkeyPatch, 1668 capsys: pytest.CaptureFixture[str], 1669) -> None: 1670 """Post-removal there is no port pre-flight. 1671 1672 On a real upgrade port 5015 is held by the running solstone service; setup no 1673 longer probes it, so non-interactive setup completes with no dead-end or 1674 prompt. The doctor subprocess is faked by the harness; the assertion is the 1675 absence of the former port dead-end. 1676 """ 1677 home = patch_home(monkeypatch, tmp_path) 1678 patch_source_checkout(monkeypatch, tmp_path) 1679 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1680 (home / ".claude").mkdir() 1681 input_mock = Mock() 1682 monkeypatch.setattr("builtins.input", input_mock) 1683 calls = patch_subprocess(monkeypatch, doctor_stdout=doctor_payload()) 1684 patch_service_health(monkeypatch) 1685 journal = tmp_path / "journal" 1686 1687 rc = setup.main(["--yes", "--journal", str(journal)]) 1688 1689 assert rc == 0 1690 input_mock.assert_not_called() 1691 captured = capsys.readouterr() 1692 assert "port 5015 is already in use" not in captured.err 1693 assert "port_in_use_non_interactive" not in captured.err 1694 assert "port 5015 is already in use" not in captured.out 1695 assert "port_in_use_non_interactive" not in captured.out 1696 assert_command(calls, 0, expected_doctor_command()) 1697 assert_command(calls, 4, expected_service_install_command()) 1698 assert_command(calls, 5, expected_brain_bootstrap_command()) 1699 assert len(calls) == 6 1700 1701 1702def test_doctor_timeout_records_failure( 1703 tmp_path: Path, 1704 monkeypatch: pytest.MonkeyPatch, 1705) -> None: 1706 patch_home(monkeypatch, tmp_path) 1707 patch_source_checkout(monkeypatch, tmp_path) 1708 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1709 journal = tmp_path / "journal" 1710 patch_subprocess(monkeypatch, doctor_timeout=True) 1711 1712 rc = setup.main(["--yes", "--journal", str(journal)]) 1713 1714 assert rc == 1 1715 step = read_manifest(journal)["steps"][-1] 1716 assert step["name"] == "doctor" 1717 assert step["status"] == "failed" 1718 assert "timed out after 30s" in step["error"]["message"] 1719 assert step["error"]["exit_code"] == 1 1720 1721 1722def test_step_doctor_missing_journal_binary_records_failure( 1723 tmp_path: Path, 1724 monkeypatch: pytest.MonkeyPatch, 1725) -> None: 1726 patch_home(monkeypatch, tmp_path) 1727 patch_source_checkout(monkeypatch, tmp_path) 1728 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1729 journal = tmp_path / "journal" 1730 argv = ["--yes", "--journal", str(journal)] 1731 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 1732 1733 def fail_run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: 1734 raise FileNotFoundError("journal binary missing") 1735 1736 monkeypatch.setattr(setup.subprocess, "run", fail_run) 1737 1738 result = setup.step_doctor(ctx, 1) 1739 1740 assert result.name == "doctor" 1741 assert result.status == "failed" 1742 assert result.error is not None 1743 assert result.error["code"] == "doctor_failed" 1744 assert "doctor failed to start" in str(result.error["message"]) 1745 assert "journal binary missing" in str(result.error["message"]) 1746 assert result.error["exit_code"] == 1 1747 1748 1749def test_install_models_timeout_records_failure( 1750 tmp_path: Path, 1751 monkeypatch: pytest.MonkeyPatch, 1752) -> None: 1753 patch_home(monkeypatch, tmp_path) 1754 patch_source_checkout(monkeypatch, tmp_path) 1755 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1756 journal = tmp_path / "journal" 1757 patch_subprocess( 1758 monkeypatch, 1759 popen_timeout_command=expected_install_models_command(), 1760 ) 1761 1762 rc = setup.main(["--yes", "--journal", str(journal), "--step-timeout-seconds", "1"]) 1763 1764 assert rc == 1 1765 step = read_manifest(journal)["steps"][-1] 1766 assert step["name"] == "install_models" 1767 assert step["status"] == "failed" 1768 assert "timed out after 1s" in step["error"]["message"] 1769 assert step["error"]["exit_code"] == 1 1770 1771 1772def test_step_timeout_seconds_passes_through_to_help_and_explain( 1773 tmp_path: Path, 1774 monkeypatch: pytest.MonkeyPatch, 1775 capsys: pytest.CaptureFixture[str], 1776) -> None: 1777 with pytest.raises(SystemExit) as raised: 1778 setup.main(["--help"]) 1779 assert raised.value.code == 0 1780 assert "--step-timeout-seconds" in capsys.readouterr().out 1781 1782 patch_home(monkeypatch, tmp_path) 1783 patch_source_checkout(monkeypatch, tmp_path) 1784 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1785 1786 rc = setup.main(["--explain", "--yes"]) 1787 1788 assert rc == 0 1789 assert "step_timeout_seconds: 1800" in capsys.readouterr().out 1790 1791 1792def test_skip_wrapper_passes_through_to_help_and_parser( 1793 capsys: pytest.CaptureFixture[str], 1794) -> None: 1795 with pytest.raises(SystemExit) as raised: 1796 setup.main(["--help"]) 1797 assert raised.value.code == 0 1798 assert "--skip-wrapper" in capsys.readouterr().out 1799 1800 parser = setup.build_parser() 1801 assert parser.parse_args([]).skip_wrapper is False 1802 assert parser.parse_args(["--skip-wrapper"]).skip_wrapper is True 1803 1804 1805def test_empty_journal_arg_rejected_at_parse_time( 1806 capsys: pytest.CaptureFixture[str], 1807) -> None: 1808 with pytest.raises(SystemExit) as raised: 1809 setup.main(["--journal", ""]) 1810 1811 assert raised.value.code == 2 1812 assert "--journal must not be empty" in capsys.readouterr().err 1813 1814 1815@pytest.mark.parametrize("port", ["0", "99999"]) 1816def test_port_out_of_range_rejected_at_parse_time( 1817 capsys: pytest.CaptureFixture[str], 1818 port: str, 1819) -> None: 1820 with pytest.raises(SystemExit) as raised: 1821 setup.main(["--port", port]) 1822 1823 assert raised.value.code == 2 1824 assert "--port must be in 1024-65535" in capsys.readouterr().err 1825 1826 1827def test_clean_uninstall_appears_in_help( 1828 capsys: pytest.CaptureFixture[str], 1829) -> None: 1830 with pytest.raises(SystemExit): 1831 setup.main(["--help"]) 1832 1833 assert "--clean-uninstall" in capsys.readouterr().out 1834 1835 1836def test_clean_uninstall_rejects_jsonl_with_specific_message( 1837 capsys: pytest.CaptureFixture[str], 1838) -> None: 1839 with pytest.raises(SystemExit) as raised: 1840 setup.main(["--clean-uninstall", "--jsonl"]) 1841 1842 assert raised.value.code == 2 1843 assert ( 1844 "JSONL output is not supported for --clean-uninstall in this version." 1845 in capsys.readouterr().err 1846 ) 1847 1848 1849@pytest.mark.parametrize( 1850 "flag_argv", 1851 [ 1852 ["--journal", "journal"], 1853 ["--port", "5015"], 1854 ["--port=5015"], 1855 ["--variant", "auto"], 1856 ["--variant=auto"], 1857 ["--step-timeout-seconds", "1800"], 1858 ["--step-timeout-seconds=1800"], 1859 ["--dry-run"], 1860 ["--explain"], 1861 ["--skip-models"], 1862 ["--skip-brain"], 1863 ["--skip-skills"], 1864 ["--skip-service"], 1865 ["--skip-wrapper"], 1866 ["--accept-existing-journal"], 1867 ["--force"], 1868 ], 1869) 1870def test_clean_uninstall_rejects_other_setup_flags( 1871 flag_argv: list[str], 1872 capsys: pytest.CaptureFixture[str], 1873) -> None: 1874 with pytest.raises(SystemExit) as raised: 1875 setup.main(["--clean-uninstall", *flag_argv]) 1876 1877 assert raised.value.code == 2 1878 assert "--clean-uninstall cannot be combined with" in capsys.readouterr().err 1879 1880 1881@pytest.mark.parametrize("answer", ["", "n", "no"]) 1882def test_clean_uninstall_prompt_cancels_without_mutation( 1883 answer: str, 1884 tmp_path: Path, 1885 monkeypatch: pytest.MonkeyPatch, 1886 capsys: pytest.CaptureFixture[str], 1887) -> None: 1888 home = patch_home(monkeypatch, tmp_path) 1889 repo = patch_source_checkout(monkeypatch, tmp_path) 1890 monkeypatch.chdir(repo) 1891 patch_tty(monkeypatch) 1892 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1893 journal = home / "journal" 1894 paths = seed_clean_uninstall_artifacts(home, repo, journal) 1895 monkeypatch.setattr("builtins.input", lambda _prompt: answer) 1896 1897 rc = setup.main(["--clean-uninstall"]) 1898 1899 assert rc == 0 1900 assert all(path.exists() for path in paths.values()) 1901 out = capsys.readouterr().out 1902 assert "journal setup --clean-uninstall will remove these runtime artifacts:" in out 1903 assert "[present] service:" in out 1904 assert "[present] wrapper:" in out 1905 assert "[present] config:" in out 1906 assert "[present] manifest:" in out 1907 assert "will not remove:" in out 1908 assert "journal directory:" in out 1909 assert "/Applications/solstone.app" in out 1910 assert "~/Library/Application Support/solstone/" in out 1911 assert "macOS microphone or screen recording permissions" in out 1912 assert "the python package" in out 1913 assert "cancelled" in out 1914 1915 1916def test_clean_uninstall_eof_cancels_without_mutation( 1917 tmp_path: Path, 1918 monkeypatch: pytest.MonkeyPatch, 1919 capsys: pytest.CaptureFixture[str], 1920) -> None: 1921 home = patch_home(monkeypatch, tmp_path) 1922 repo = patch_source_checkout(monkeypatch, tmp_path) 1923 monkeypatch.chdir(repo) 1924 patch_tty(monkeypatch) 1925 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1926 journal = home / "journal" 1927 paths = seed_clean_uninstall_artifacts(home, repo, journal) 1928 1929 def raise_eof(_prompt: str) -> str: 1930 raise EOFError 1931 1932 monkeypatch.setattr("builtins.input", raise_eof) 1933 1934 rc = setup.main(["--clean-uninstall"]) 1935 1936 assert rc == 0 1937 assert all(path.exists() for path in paths.values()) 1938 assert "cancelled" in capsys.readouterr().out 1939 1940 1941def test_clean_uninstall_non_tty_cancels_without_mutation( 1942 tmp_path: Path, 1943 monkeypatch: pytest.MonkeyPatch, 1944 capsys: pytest.CaptureFixture[str], 1945) -> None: 1946 home = patch_home(monkeypatch, tmp_path) 1947 repo = patch_source_checkout(monkeypatch, tmp_path) 1948 monkeypatch.chdir(repo) 1949 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1950 journal = home / "journal" 1951 paths = seed_clean_uninstall_artifacts(home, repo, journal) 1952 1953 rc = setup.main(["--clean-uninstall"]) 1954 1955 assert rc == 0 1956 assert all(path.exists() for path in paths.values()) 1957 assert ( 1958 "not a tty; rerun with --yes to proceed non-interactively (cancelled)" 1959 in capsys.readouterr().out 1960 ) 1961 1962 1963def test_clean_uninstall_yes_removes_all_seeded_artifacts( 1964 tmp_path: Path, 1965 monkeypatch: pytest.MonkeyPatch, 1966 capsys: pytest.CaptureFixture[str], 1967) -> None: 1968 home = patch_home(monkeypatch, tmp_path) 1969 repo = patch_source_checkout(monkeypatch, tmp_path) 1970 monkeypatch.chdir(repo) 1971 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 1972 journal = home / "journal" 1973 paths = seed_clean_uninstall_artifacts(home, repo, journal) 1974 monkeypatch.setattr(setup, "_run_service_uninstall", lambda: 0) 1975 1976 rc = setup.main(["--clean-uninstall", "--yes"]) 1977 1978 assert rc == 0 1979 assert not any(path.exists() or path.is_symlink() for path in paths.values()) 1980 out = capsys.readouterr().out 1981 assert out.index("[step 1/4] running service uninstall...") < out.index( 1982 "[step 2/4] running wrapper uninstall..." 1983 ) 1984 assert out.index("[step 2/4] running wrapper uninstall...") < out.index( 1985 "[step 3/4] running config uninstall..." 1986 ) 1987 assert out.index("[step 3/4] running config uninstall...") < out.index( 1988 "[step 4/4] running manifest uninstall..." 1989 ) 1990 assert "[step 1/4] removed service:" in out 1991 assert "[step 2/4] removed wrapper:" in out 1992 assert "[step 3/4] removed config:" in out 1993 assert "[step 4/4] removed manifest:" in out 1994 assert ( 1995 "clean uninstall complete: 4 removed, 0 already-absent, 0 skipped, 0 failed" 1996 in out 1997 ) 1998 1999 2000def test_clean_uninstall_noop_when_all_paths_absent( 2001 tmp_path: Path, 2002 monkeypatch: pytest.MonkeyPatch, 2003 capsys: pytest.CaptureFixture[str], 2004) -> None: 2005 patch_home(monkeypatch, tmp_path) 2006 repo = patch_source_checkout(monkeypatch, tmp_path) 2007 monkeypatch.chdir(repo) 2008 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2009 service_calls: list[str] = [] 2010 monkeypatch.setattr( 2011 setup, "_run_service_uninstall", lambda: service_calls.append("service") or 0 2012 ) 2013 2014 rc = setup.main(["--clean-uninstall", "--yes"]) 2015 2016 assert rc == 0 2017 assert service_calls == [] 2018 assert capsys.readouterr().out == "nothing to remove (all paths already absent)\n" 2019 2020 2021def test_clean_uninstall_partial_wrapper_state_removes_remaining_wrapper( 2022 tmp_path: Path, 2023 monkeypatch: pytest.MonkeyPatch, 2024 capsys: pytest.CaptureFixture[str], 2025) -> None: 2026 home = patch_home(monkeypatch, tmp_path) 2027 repo = patch_source_checkout(monkeypatch, tmp_path) 2028 monkeypatch.chdir(repo) 2029 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2030 journal = home / "journal" 2031 journal_wrapper = home / ".local" / "bin" / "journal" 2032 target = install_guard.expected_target(repo, "journal") 2033 target.parent.mkdir(parents=True, exist_ok=True) 2034 target.write_text("", encoding="utf-8") 2035 journal_wrapper.parent.mkdir(parents=True, exist_ok=True) 2036 journal_wrapper.write_text( 2037 install_guard.render_wrapper(str(journal), str(target), "journal"), 2038 encoding="utf-8", 2039 ) 2040 journal_wrapper.chmod(0o755) 2041 monkeypatch.setattr(setup, "_run_service_uninstall", lambda: 0) 2042 2043 rc = setup.main(["--clean-uninstall", "--yes"]) 2044 2045 assert rc == 0 2046 assert not journal_wrapper.exists() 2047 assert not (home / ".local" / "bin" / "sol").exists() 2048 out = capsys.readouterr().out 2049 assert "[step 2/4] removed wrapper:" in out 2050 assert ( 2051 "clean uninstall complete: 1 removed, 3 already-absent, 0 skipped, 0 failed" 2052 in out 2053 ) 2054 2055 2056def test_clean_uninstall_service_failure_is_best_effort( 2057 tmp_path: Path, 2058 monkeypatch: pytest.MonkeyPatch, 2059 capsys: pytest.CaptureFixture[str], 2060) -> None: 2061 home = patch_home(monkeypatch, tmp_path) 2062 repo = patch_source_checkout(monkeypatch, tmp_path) 2063 monkeypatch.chdir(repo) 2064 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2065 journal = home / "journal" 2066 paths = seed_clean_uninstall_artifacts(home, repo, journal) 2067 2068 def fail_service() -> int: 2069 raise RuntimeError("service exploded") 2070 2071 monkeypatch.setattr(setup, "_run_service_uninstall", fail_service) 2072 2073 rc = setup.main(["--clean-uninstall", "--yes"]) 2074 2075 assert rc == 1 2076 assert paths["service"].exists() 2077 assert not paths["wrapper"].exists() 2078 assert not paths["config"].exists() 2079 assert not paths["manifest"].exists() 2080 out = capsys.readouterr().out 2081 assert "[step 1/4] failed service: RuntimeError: service exploded" in out 2082 assert "[step 2/4] removed wrapper:" in out 2083 assert "[step 3/4] removed config:" in out 2084 assert "[step 4/4] removed manifest:" in out 2085 assert ( 2086 "clean uninstall complete: 3 removed, 0 already-absent, 0 skipped, 1 failed" 2087 in out 2088 ) 2089 2090 2091def test_clean_uninstall_wrapper_cross_repo_is_skipped( 2092 tmp_path: Path, 2093 monkeypatch: pytest.MonkeyPatch, 2094 capsys: pytest.CaptureFixture[str], 2095) -> None: 2096 home = patch_home(monkeypatch, tmp_path) 2097 repo = patch_source_checkout(monkeypatch, tmp_path) 2098 monkeypatch.chdir(repo) 2099 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2100 other = tmp_path / "other" / ".venv" / "bin" / "sol" 2101 other.parent.mkdir(parents=True) 2102 other.write_text("", encoding="utf-8") 2103 wrapper = home / ".local" / "bin" / "sol" 2104 wrapper.parent.mkdir(parents=True) 2105 wrapper.symlink_to(other) 2106 monkeypatch.setattr(setup, "_run_service_uninstall", lambda: 0) 2107 2108 rc = setup.main(["--clean-uninstall", "--yes"]) 2109 2110 assert rc == 0 2111 assert wrapper.is_symlink() 2112 assert wrapper.resolve() == other 2113 out = capsys.readouterr().out 2114 assert f"[step 2/4] skipped wrapper: alias points at {other}, not removing" in out 2115 assert ( 2116 "clean uninstall complete: 0 removed, 3 already-absent, 1 skipped, 0 failed" 2117 in out 2118 ) 2119 2120 2121def test_clean_uninstall_wrapper_worktree_is_skipped( 2122 tmp_path: Path, 2123 monkeypatch: pytest.MonkeyPatch, 2124 capsys: pytest.CaptureFixture[str], 2125) -> None: 2126 home = patch_home(monkeypatch, tmp_path) 2127 repo = tmp_path / "repo" 2128 repo.mkdir() 2129 (repo / ".git").write_text("gitdir: /tmp/worktree\n", encoding="utf-8") 2130 monkeypatch.chdir(repo) 2131 monkeypatch.setattr(setup, "get_project_root", lambda: str(repo)) 2132 monkeypatch.setattr(setup, "source_checkout", lambda: True) 2133 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2134 wrapper = home / ".local" / "bin" / "sol" 2135 wrapper.parent.mkdir(parents=True) 2136 wrapper.write_text("foreign", encoding="utf-8") 2137 monkeypatch.setattr(setup, "_run_service_uninstall", lambda: 0) 2138 2139 rc = setup.main(["--clean-uninstall", "--yes"]) 2140 2141 assert rc == 0 2142 assert wrapper.read_text(encoding="utf-8") == "foreign" 2143 out = capsys.readouterr().out 2144 assert "[step 2/4] skipped wrapper: refusing to act from a git worktree" in out 2145 assert ( 2146 "clean uninstall complete: 0 removed, 3 already-absent, 1 skipped, 0 failed" 2147 in out 2148 ) 2149 2150 2151def test_clean_uninstall_uses_env_config_default_without_source_fallback( 2152 tmp_path: Path, 2153 monkeypatch: pytest.MonkeyPatch, 2154) -> None: 2155 home = patch_home(monkeypatch, tmp_path) 2156 patch_source_checkout(monkeypatch, tmp_path) 2157 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2158 parser = setup.build_parser() 2159 args = parser.parse_args(["--clean-uninstall", "--yes"]) 2160 2161 assert setup.resolve_clean_uninstall_context(args).journal_path == home / "journal" 2162 2163 config_journal = tmp_path / "config-journal" 2164 write_user_config(journal=str(config_journal)) 2165 assert setup.resolve_clean_uninstall_context(args).journal_path == config_journal 2166 2167 env_journal = tmp_path / "env-journal" 2168 monkeypatch.setenv("SOLSTONE_JOURNAL", str(env_journal)) 2169 assert setup.resolve_clean_uninstall_context(args).journal_path == env_journal 2170 2171 2172@pytest.mark.parametrize( 2173 ("platform", "expected_rel"), 2174 [ 2175 ("darwin", "Library/LaunchAgents/org.solpbc.solstone.plist"), 2176 ("linux", ".config/systemd/user/solstone.service"), 2177 ], 2178) 2179def test_clean_uninstall_service_path_uses_platform_constants( 2180 platform: str, 2181 expected_rel: str, 2182 tmp_path: Path, 2183 monkeypatch: pytest.MonkeyPatch, 2184) -> None: 2185 home = patch_home(monkeypatch, tmp_path) 2186 monkeypatch.setattr(setup.sys, "platform", platform) 2187 2188 assert setup._service_path_for_uninstall() == home / expected_rel 2189 2190 2191def test_clean_uninstall_preserves_journal_contents_except_manifest( 2192 tmp_path: Path, 2193 monkeypatch: pytest.MonkeyPatch, 2194) -> None: 2195 home = patch_home(monkeypatch, tmp_path) 2196 repo = patch_source_checkout(monkeypatch, tmp_path) 2197 monkeypatch.chdir(repo) 2198 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2199 journal = home / "journal" 2200 paths = seed_clean_uninstall_artifacts(home, repo, journal) 2201 seeded_files = { 2202 "chronicle/20260520/_default/090000_60/transcript.jsonl": "{}\n", 2203 "config/journal.json": "{}\n", 2204 "entities/alice/entity.json": '{"name":"Alice"}\n', 2205 "facets/work/activities/20260520.jsonl": "{}\n", 2206 "health/other-health.json": "{}\n", 2207 } 2208 for rel, content in seeded_files.items(): 2209 path = journal / rel 2210 path.parent.mkdir(parents=True, exist_ok=True) 2211 path.write_text(content, encoding="utf-8") 2212 before = digest_journal_tree(journal, exclude={paths["manifest"]}) 2213 monkeypatch.setattr(setup, "_run_service_uninstall", lambda: 0) 2214 2215 rc = setup.main(["--clean-uninstall", "--yes"]) 2216 2217 assert rc == 0 2218 assert digest_journal_tree(journal, exclude={paths["manifest"]}) == before 2219 assert journal.exists() 2220 assert not paths["manifest"].exists() 2221 for rel in seeded_files: 2222 assert (journal / rel).exists() 2223 2224 2225def test_packaged_install_runs_service_step( 2226 tmp_path: Path, 2227 monkeypatch: pytest.MonkeyPatch, 2228 capsys: pytest.CaptureFixture[str], 2229) -> None: 2230 home = patch_home(monkeypatch, tmp_path) 2231 patch_packaged_install(monkeypatch, tmp_path) 2232 bin_dir = patch_runtime_bin(monkeypatch, tmp_path) 2233 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2234 calls = patch_subprocess(monkeypatch) 2235 patch_service_health(monkeypatch) 2236 journal = tmp_path / "journal" 2237 2238 rc = setup.main( 2239 ["--yes", "--journal", str(journal), "--skip-models", "--skip-skills"] 2240 ) 2241 2242 assert rc == 0 2243 out = capsys.readouterr().out 2244 unsupported_message = " ".join( 2245 ["packaged-install service support", "is not implemented"] 2246 ) 2247 assert unsupported_message not in out 2248 assert "solstone is running at http://localhost:5015" in out 2249 assert_command(calls, 0, expected_doctor_command()) 2250 assert_command(calls, 1, expected_service_install_command()) 2251 assert len(calls) == 2 2252 steps = read_manifest(journal)["steps"] 2253 service_step = next(step for step in steps if step["name"] == "service") 2254 assert service_step["status"] == "ok" 2255 for binary, alias in install_guard.alias_paths().items(): 2256 assert alias == home / ".local" / "bin" / binary 2257 assert_setup_wrapper(alias, binary, journal, bin_dir) 2258 brain_step = next(step for step in steps if step["name"] == "brain") 2259 assert brain_step["status"] == "skipped" 2260 assert brain_step["reason"] == "--skip-models implies --skip-brain" 2261 2262 2263def test_packaged_install_absent_provisions_wrappers( 2264 tmp_path: Path, 2265 monkeypatch: pytest.MonkeyPatch, 2266) -> None: 2267 patch_home(monkeypatch, tmp_path) 2268 patch_packaged_install(monkeypatch, tmp_path) 2269 bin_dir = patch_runtime_bin(monkeypatch, tmp_path) 2270 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2271 patch_subprocess(monkeypatch) 2272 journal = tmp_path / "journal" 2273 2274 rc = setup.main( 2275 [ 2276 "--yes", 2277 "--journal", 2278 str(journal), 2279 "--skip-models", 2280 "--skip-skills", 2281 "--skip-service", 2282 ] 2283 ) 2284 2285 assert rc == 0 2286 for binary, alias in install_guard.alias_paths().items(): 2287 assert_setup_wrapper(alias, binary, journal, bin_dir) 2288 2289 2290@pytest.mark.parametrize("state", ["foreign", "cross_repo", "dangling"]) 2291def test_packaged_install_non_owned_alias_is_backed_up_and_repaired( 2292 state: str, 2293 tmp_path: Path, 2294 monkeypatch: pytest.MonkeyPatch, 2295) -> None: 2296 patch_home(monkeypatch, tmp_path) 2297 patch_packaged_install(monkeypatch, tmp_path) 2298 bin_dir = patch_runtime_bin(monkeypatch, tmp_path) 2299 backup_dir = tmp_path / "legacy-backups" 2300 backup_dir.mkdir() 2301 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir) 2302 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2303 aliases = install_guard.alias_paths() 2304 aliases["sol"].parent.mkdir(parents=True, exist_ok=True) 2305 if state == "foreign": 2306 aliases["sol"].write_text("foreign", encoding="utf-8") 2307 elif state == "cross_repo": 2308 target = tmp_path / "other" / ".venv" / "bin" / "sol" 2309 target.parent.mkdir(parents=True, exist_ok=True) 2310 target.write_text("", encoding="utf-8") 2311 aliases["sol"].symlink_to(target) 2312 else: 2313 target = tmp_path / "missing" / ".venv" / "bin" / "sol" 2314 target.parent.mkdir(parents=True, exist_ok=True) 2315 aliases["sol"].symlink_to(target) 2316 journal_target = tmp_path / "missing" / ".venv" / "bin" / "journal" 2317 journal_target.parent.mkdir(parents=True, exist_ok=True) 2318 aliases["journal"].symlink_to(journal_target) 2319 patch_subprocess(monkeypatch) 2320 journal = tmp_path / "journal" 2321 2322 rc = setup.main( 2323 [ 2324 "--yes", 2325 "--journal", 2326 str(journal), 2327 "--skip-models", 2328 "--skip-skills", 2329 "--skip-service", 2330 ] 2331 ) 2332 2333 assert rc == 0 2334 assert len(list(backup_dir.glob("sol.old-symlink-*"))) == 1 2335 assert len(list(backup_dir.glob("journal.old-symlink-*"))) == 1 2336 for binary, alias in aliases.items(): 2337 assert not alias.is_symlink() 2338 assert_setup_wrapper(alias, binary, journal, bin_dir) 2339 2340 2341def test_wrapper_provisioning_failure_is_non_fatal_warning( 2342 tmp_path: Path, 2343 monkeypatch: pytest.MonkeyPatch, 2344 capsys: pytest.CaptureFixture[str], 2345) -> None: 2346 patch_home(monkeypatch, tmp_path) 2347 patch_packaged_install(monkeypatch, tmp_path) 2348 patch_runtime_bin(monkeypatch, tmp_path) 2349 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2350 patch_subprocess(monkeypatch) 2351 journal = tmp_path / "journal" 2352 2353 def fail_write(_contents: dict[Path, str]) -> None: 2354 raise OSError("permission denied: ~/.local/bin/sol") 2355 2356 monkeypatch.setattr(install_guard, "write_wrappers_atomically", fail_write) 2357 2358 rc = setup.main( 2359 [ 2360 "--yes", 2361 "--journal", 2362 str(journal), 2363 "--skip-models", 2364 "--skip-skills", 2365 "--skip-service", 2366 ] 2367 ) 2368 2369 captured = capsys.readouterr() 2370 manifest = read_manifest(journal) 2371 wrapper_step = step_by_name(manifest, "wrapper") 2372 assert rc == 0 2373 assert wrapper_step["status"] == "warning" 2374 assert "~/.local/bin" in wrapper_step["error"]["fix_hint"] 2375 assert "could not provision the sol/journal wrappers at" in captured.out 2376 assert "fix permissions on ~/.local/bin" in captured.out 2377 2378 2379def test_owned_current_wrapper_setup_creates_no_backup( 2380 tmp_path: Path, 2381 monkeypatch: pytest.MonkeyPatch, 2382) -> None: 2383 home = patch_home(monkeypatch, tmp_path) 2384 patch_packaged_install(monkeypatch, tmp_path) 2385 bin_dir = patch_runtime_bin(monkeypatch, tmp_path) 2386 backup_dir = tmp_path / "legacy-backups" 2387 backup_dir.mkdir() 2388 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir) 2389 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2390 journal = tmp_path / "journal" 2391 for binary in install_guard.alias_paths(): 2392 alias = home / ".local" / "bin" / binary 2393 alias.parent.mkdir(parents=True, exist_ok=True) 2394 alias.write_text( 2395 install_guard.render_wrapper(str(journal), str(bin_dir / binary), binary), 2396 encoding="utf-8", 2397 ) 2398 alias.chmod(0o755) 2399 patch_subprocess(monkeypatch) 2400 2401 rc = setup.main( 2402 [ 2403 "--yes", 2404 "--journal", 2405 str(journal), 2406 "--skip-models", 2407 "--skip-skills", 2408 "--skip-service", 2409 ] 2410 ) 2411 2412 assert rc == 0 2413 assert list(backup_dir.glob("*.old-symlink-*")) == [] 2414 2415 2416def test_setup_wrapper_round_trip_closure( 2417 tmp_path: Path, 2418 monkeypatch: pytest.MonkeyPatch, 2419) -> None: 2420 patch_home(monkeypatch, tmp_path) 2421 curdir = patch_packaged_install(monkeypatch, tmp_path) 2422 patch_runtime_bin(monkeypatch, tmp_path) 2423 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2424 patch_subprocess(monkeypatch) 2425 journal = tmp_path / "journal" 2426 2427 rc = setup.main( 2428 [ 2429 "--yes", 2430 "--journal", 2431 str(journal), 2432 "--skip-models", 2433 "--skip-skills", 2434 "--skip-service", 2435 ] 2436 ) 2437 2438 assert rc == 0 2439 for binary, alias in install_guard.alias_paths().items(): 2440 parsed = install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) 2441 assert parsed is not None 2442 assert Path(parsed["sol_bin"]).exists() 2443 state, _other = install_guard.check_alias(curdir, binary) 2444 assert state is install_guard.AliasState.OWNED 2445 2446 2447def test_step_skills_user_installs_sol_skill_for_all_agents( 2448 tmp_path: Path, 2449 monkeypatch: pytest.MonkeyPatch, 2450) -> None: 2451 patch_home(monkeypatch, tmp_path) 2452 patch_source_checkout(monkeypatch, tmp_path) 2453 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2454 journal = tmp_path / "journal" 2455 argv = ["--yes", "--journal", str(journal)] 2456 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 2457 commands: list[list[str]] = [] 2458 2459 def fake_run_step_subprocess( 2460 ctx: setup.SetupContext, 2461 command: list[str], 2462 *, 2463 timeout: float | None = None, 2464 ) -> setup.StepProcessResult: 2465 del ctx, timeout 2466 commands.append(command) 2467 return setup.StepProcessResult(0, "", "", False) 2468 2469 monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) 2470 2471 result = setup.step_skills_user(ctx, 4) 2472 2473 assert result.status == "ok" 2474 assert commands == [expected_skills_user_command()] 2475 assert "--agent" in commands[0] 2476 assert commands[0][-1] == "all" 2477 assert "claude" not in commands[0] 2478 2479 2480def test_step_skills_journal_installs_into_journal_path( 2481 tmp_path: Path, 2482 monkeypatch: pytest.MonkeyPatch, 2483) -> None: 2484 patch_home(monkeypatch, tmp_path) 2485 patch_source_checkout(monkeypatch, tmp_path) 2486 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2487 journal = tmp_path / "journal" 2488 argv = ["--yes", "--journal", str(journal)] 2489 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 2490 commands: list[list[str]] = [] 2491 2492 def fake_run_step_subprocess( 2493 ctx: setup.SetupContext, 2494 command: list[str], 2495 *, 2496 timeout: float | None = None, 2497 ) -> setup.StepProcessResult: 2498 del ctx, timeout 2499 commands.append(command) 2500 return setup.StepProcessResult(0, "", "", False) 2501 2502 monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) 2503 2504 result = setup.step_skills_journal(ctx, 5) 2505 2506 assert result.status == "ok" 2507 assert commands == [expected_skills_journal_command(journal)] 2508 assert commands[0][-3:] == [str(journal), "--agent", "all"] 2509 2510 2511def test_step_skills_user_failure_does_not_block_skills_journal( 2512 tmp_path: Path, 2513 monkeypatch: pytest.MonkeyPatch, 2514) -> None: 2515 patch_home(monkeypatch, tmp_path) 2516 patch_source_checkout(monkeypatch, tmp_path) 2517 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2518 patch_subprocess(monkeypatch) 2519 patch_service_health(monkeypatch) 2520 journal = tmp_path / "journal" 2521 commands: list[list[str]] = [] 2522 2523 def fake_run_step_subprocess( 2524 ctx: setup.SetupContext, 2525 command: list[str], 2526 *, 2527 timeout: float | None = None, 2528 ) -> setup.StepProcessResult: 2529 del ctx, timeout 2530 commands.append(command) 2531 if command == expected_skills_user_command(): 2532 return setup.StepProcessResult(3, "", "user skills failed\n", False) 2533 return setup.StepProcessResult(0, "", "", False) 2534 2535 monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) 2536 2537 rc = setup.main(["--yes", "--journal", str(journal), "--skip-models"]) 2538 2539 assert rc == 3 2540 assert expected_skills_user_command() in commands 2541 assert expected_skills_journal_command(journal) in commands 2542 manifest = read_manifest(journal) 2543 assert_step_names_and_statuses( 2544 manifest, ["ok", "ok", "skipped", "failed", "ok", "ok", "ok", "skipped"] 2545 ) 2546 2547 2548def test_step_skills_journal_failure_does_not_block_subsequent_steps( 2549 tmp_path: Path, 2550 monkeypatch: pytest.MonkeyPatch, 2551) -> None: 2552 patch_home(monkeypatch, tmp_path) 2553 patch_source_checkout(monkeypatch, tmp_path) 2554 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2555 patch_subprocess(monkeypatch) 2556 patch_service_health(monkeypatch) 2557 journal = tmp_path / "journal" 2558 commands: list[list[str]] = [] 2559 2560 def fake_run_step_subprocess( 2561 ctx: setup.SetupContext, 2562 command: list[str], 2563 *, 2564 timeout: float | None = None, 2565 ) -> setup.StepProcessResult: 2566 del ctx, timeout 2567 commands.append(command) 2568 if command == expected_skills_journal_command(journal): 2569 return setup.StepProcessResult(4, "", "journal skills failed\n", False) 2570 return setup.StepProcessResult(0, "", "", False) 2571 2572 monkeypatch.setattr(setup, "run_step_subprocess", fake_run_step_subprocess) 2573 2574 rc = setup.main(["--yes", "--journal", str(journal), "--skip-models"]) 2575 2576 assert rc == 4 2577 assert commands[-1] == expected_service_install_command() 2578 manifest = read_manifest(journal) 2579 assert_step_names_and_statuses( 2580 manifest, ["ok", "ok", "skipped", "ok", "failed", "ok", "ok", "skipped"] 2581 ) 2582 2583 2584def test_skip_skills_flag_skips_both_skill_steps( 2585 tmp_path: Path, 2586 monkeypatch: pytest.MonkeyPatch, 2587) -> None: 2588 patch_home(monkeypatch, tmp_path) 2589 patch_source_checkout(monkeypatch, tmp_path) 2590 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2591 patch_subprocess(monkeypatch) 2592 patch_service_health(monkeypatch) 2593 journal = tmp_path / "journal" 2594 2595 rc = setup.main( 2596 ["--yes", "--journal", str(journal), "--skip-models", "--skip-skills"] 2597 ) 2598 2599 assert rc == 0 2600 steps = {step["name"]: step for step in read_manifest(journal)["steps"]} 2601 assert steps["skills_user"]["status"] == "skipped" 2602 assert steps["skills_user"]["reason"] == "--skip-skills" 2603 assert steps["skills_journal"]["status"] == "skipped" 2604 assert steps["skills_journal"]["reason"] == "--skip-skills" 2605 2606 2607def test_doctor_failure_still_early_exits( 2608 tmp_path: Path, 2609 monkeypatch: pytest.MonkeyPatch, 2610) -> None: 2611 patch_home(monkeypatch, tmp_path) 2612 patch_source_checkout(monkeypatch, tmp_path) 2613 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2614 journal = tmp_path / "journal" 2615 calls = patch_subprocess(monkeypatch, doctor_returncode=1) 2616 2617 rc = setup.main(["--yes", "--journal", str(journal)]) 2618 2619 assert rc == 1 2620 assert calls == [expected_doctor_command()] 2621 manifest = read_manifest(journal) 2622 assert [step["name"] for step in manifest["steps"]] == ["doctor"] 2623 assert manifest["steps"][0]["status"] == "failed" 2624 2625 2626def test_resumption_skips_completed_steps( 2627 tmp_path: Path, 2628 monkeypatch: pytest.MonkeyPatch, 2629) -> None: 2630 patch_home(monkeypatch, tmp_path) 2631 patch_source_checkout(monkeypatch, tmp_path) 2632 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2633 journal = tmp_path / "journal" 2634 write_clean_prior_manifest(journal) 2635 calls = patch_subprocess(monkeypatch) 2636 patch_service_health(monkeypatch) 2637 2638 rc = setup.main(["--yes", "--journal", str(journal)]) 2639 2640 assert rc == 0 2641 assert calls == [] 2642 manifest = read_manifest(journal) 2643 assert_step_names_and_statuses(manifest, ["skipped"] * 8) 2644 assert {step["reason"] for step in manifest["steps"]} == {"prior_run_ok"} 2645 2646 2647def test_skip_wrapper_keeps_prior_run_ok_reason_when_wrapper_already_done( 2648 tmp_path: Path, 2649 monkeypatch: pytest.MonkeyPatch, 2650) -> None: 2651 patch_home(monkeypatch, tmp_path) 2652 patch_source_checkout(monkeypatch, tmp_path) 2653 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2654 journal = tmp_path / "journal" 2655 write_clean_prior_manifest(journal) 2656 monkeypatch.setattr( 2657 install_guard, 2658 "provision_wrappers", 2659 lambda _root, _journal: pytest.fail("provision_wrappers should not run"), 2660 ) 2661 calls = patch_subprocess(monkeypatch) 2662 patch_service_health(monkeypatch) 2663 2664 rc = setup.main(["--yes", "--journal", str(journal), "--skip-wrapper"]) 2665 2666 assert rc == 0 2667 assert calls == [] 2668 wrapper = step_by_name(read_manifest(journal), "wrapper") 2669 assert wrapper["status"] == "skipped" 2670 assert wrapper["reason"] == "prior_run_ok" 2671 2672 2673def test_resumption_runs_step_when_artifact_missing( 2674 tmp_path: Path, 2675 monkeypatch: pytest.MonkeyPatch, 2676) -> None: 2677 patch_home(monkeypatch, tmp_path) 2678 patch_source_checkout(monkeypatch, tmp_path) 2679 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2680 journal = tmp_path / "journal" 2681 paths_by_name = write_clean_prior_manifest(journal) 2682 paths_by_name["wrapper"][0].unlink() 2683 backup_dir = tmp_path / "legacy-backups" 2684 backup_dir.mkdir() 2685 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir) 2686 calls = patch_subprocess(monkeypatch) 2687 patch_service_health(monkeypatch) 2688 2689 rc = setup.main(["--yes", "--journal", str(journal)]) 2690 2691 assert rc == 0 2692 assert len(calls) == 0 2693 for alias in install_guard.alias_paths().values(): 2694 assert ( 2695 install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) is not None 2696 ) 2697 manifest = read_manifest(journal) 2698 assert_step_names_and_statuses( 2699 manifest, 2700 [ 2701 "skipped", 2702 "skipped", 2703 "skipped", 2704 "skipped", 2705 "skipped", 2706 "ok", 2707 "skipped", 2708 "skipped", 2709 ], 2710 ) 2711 2712 2713def test_resumption_wedged_service_restarts( 2714 tmp_path: Path, 2715 monkeypatch: pytest.MonkeyPatch, 2716) -> None: 2717 patch_home(monkeypatch, tmp_path) 2718 patch_source_checkout(monkeypatch, tmp_path) 2719 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2720 journal = tmp_path / "journal" 2721 write_clean_prior_manifest(journal) 2722 calls = patch_subprocess(monkeypatch) 2723 health_check = Mock(side_effect=[1, 0]) 2724 monkeypatch.setattr(health_cli, "health_check", health_check) 2725 2726 rc = setup.main(["--yes", "--journal", str(journal)]) 2727 2728 assert rc == 0 2729 assert health_check.call_count == 2 2730 assert_command(calls, 0, expected_service_restart_command()) 2731 assert len(calls) == 1 2732 service_step = step_by_name(read_manifest(journal), "service") 2733 assert service_step["status"] == "ok" 2734 assert service_step["reason"] == "resumed_after_restart" 2735 2736 2737def test_resumption_wedged_service_falls_through_when_service_up_fails( 2738 tmp_path: Path, 2739 monkeypatch: pytest.MonkeyPatch, 2740) -> None: 2741 patch_home(monkeypatch, tmp_path) 2742 patch_source_checkout(monkeypatch, tmp_path) 2743 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2744 monkeypatch.setattr(service, "_up", lambda: 7) 2745 journal = tmp_path / "journal" 2746 write_clean_prior_manifest(journal) 2747 calls = patch_subprocess(monkeypatch) 2748 monkeypatch.setattr(health_cli, "health_check", lambda: 1) 2749 2750 rc = setup.main(["--yes", "--journal", str(journal)]) 2751 2752 assert rc == 1 2753 assert_command(calls, 0, expected_service_restart_command()) 2754 assert_command(calls, 1, expected_service_install_command()) 2755 assert len(calls) == 2 2756 2757 2758def test_step_service_failure_message( 2759 tmp_path: Path, 2760 monkeypatch: pytest.MonkeyPatch, 2761) -> None: 2762 patch_home(monkeypatch, tmp_path) 2763 patch_source_checkout(monkeypatch, tmp_path) 2764 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2765 argv = ["--yes", "--journal", str(tmp_path / "journal")] 2766 ctx = setup.resolve_context(setup.build_parser().parse_args(argv), argv) 2767 monkeypatch.setattr(setup, "run_inherited", lambda command: 0) 2768 artifact = setup.service_artifact_path() 2769 expected_paths = [setup.absolute_string(artifact)] if artifact is not None else [] 2770 2771 monkeypatch.setattr(service, "_up", lambda: 7) 2772 result = setup.step_service(ctx, 6) 2773 2774 assert result.status == "failed" 2775 assert result.paths == expected_paths 2776 assert result.error == { 2777 "code": "service_up_failed", 2778 "message": "service up failed (exit 7)", 2779 "details": "", 2780 "exit_code": 1, 2781 } 2782 2783 2784def test_force_skips_resumption( 2785 tmp_path: Path, 2786 monkeypatch: pytest.MonkeyPatch, 2787) -> None: 2788 patch_home(monkeypatch, tmp_path) 2789 patch_source_checkout(monkeypatch, tmp_path) 2790 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2791 journal = tmp_path / "journal" 2792 write_clean_prior_manifest(journal) 2793 backup_dir = tmp_path / "legacy-backups" 2794 backup_dir.mkdir() 2795 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir) 2796 calls = patch_subprocess(monkeypatch) 2797 patch_service_health(monkeypatch) 2798 2799 rc = setup.main(["--yes", "--force", "--journal", str(journal)]) 2800 2801 assert rc == 0 2802 assert_command(calls, 0, expected_doctor_command()) 2803 assert_command(calls, 1, expected_install_models_command()) 2804 assert_command(calls, 2, expected_skills_user_command()) 2805 assert_command(calls, 3, expected_skills_journal_command(journal)) 2806 assert_command(calls, 4, expected_service_install_command()) 2807 assert_command(calls, 5, expected_brain_bootstrap_command()) 2808 assert len(calls) == 6 2809 manifest = read_manifest(journal) 2810 assert_step_names_and_statuses( 2811 manifest, ["ok", "ok", "ok", "ok", "ok", "ok", "ok", "ok"] 2812 ) 2813 assert all(step["reason"] is None for step in manifest["steps"]) 2814 2815 2816def test_step_exception_records_failed_row( 2817 tmp_path: Path, 2818 monkeypatch: pytest.MonkeyPatch, 2819) -> None: 2820 patch_home(monkeypatch, tmp_path) 2821 patch_source_checkout(monkeypatch, tmp_path) 2822 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2823 journal = tmp_path / "journal" 2824 2825 def boom(ctx: setup.SetupContext, step_index: int) -> setup.StepResult: 2826 raise RuntimeError("boom") 2827 2828 monkeypatch.setattr(setup, "_STEPS", (boom,)) 2829 monkeypatch.setattr(setup, "_STEP_NAME", {boom: "doctor"}) 2830 2831 rc = setup.main(["--yes", "--journal", str(journal)]) 2832 2833 assert rc == 1 2834 manifest = read_manifest(journal) 2835 assert len(manifest["steps"]) == 1 2836 step = manifest["steps"][0] 2837 assert step["name"] == "doctor" 2838 assert step["status"] == "failed" 2839 assert step["error"]["message"] == "boom" 2840 2841 2842@pytest.mark.parametrize("exc", [KeyboardInterrupt(), SystemExit(7)]) 2843def test_base_exceptions_propagate( 2844 tmp_path: Path, 2845 monkeypatch: pytest.MonkeyPatch, 2846 exc: BaseException, 2847) -> None: 2848 patch_home(monkeypatch, tmp_path) 2849 patch_source_checkout(monkeypatch, tmp_path) 2850 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2851 journal = tmp_path / "journal" 2852 2853 def boom(ctx: setup.SetupContext, step_index: int) -> setup.StepResult: 2854 raise exc 2855 2856 monkeypatch.setattr(setup, "_STEPS", (boom,)) 2857 monkeypatch.setattr(setup, "_STEP_NAME", {boom: "doctor"}) 2858 2859 with pytest.raises(type(exc)) as raised: 2860 setup.main(["--yes", "--journal", str(journal)]) 2861 2862 if isinstance(exc, SystemExit): 2863 assert raised.value.code == 7 2864 assert not (journal / "health" / "setup-state.json").exists() 2865 2866 2867def test_env_journal_overrides_config( 2868 tmp_path: Path, 2869 monkeypatch: pytest.MonkeyPatch, 2870) -> None: 2871 home = patch_home(monkeypatch, tmp_path) 2872 patch_source_checkout(monkeypatch, tmp_path) 2873 config_journal = tmp_path / "from_config" 2874 env_journal = tmp_path / "from_env" 2875 write_user_config(journal=str(config_journal)) 2876 monkeypatch.setenv("SOLSTONE_JOURNAL", str(env_journal)) 2877 calls = patch_subprocess(monkeypatch) 2878 patch_service_health(monkeypatch) 2879 2880 rc = setup.main(["--yes"]) 2881 2882 assert rc == 0 2883 assert (home / ".config" / "solstone" / "config.toml").read_text( 2884 encoding="utf-8" 2885 ) == f'journal = "{env_journal}"\n' 2886 manifest = read_manifest(env_journal) 2887 assert manifest["args_resolved"]["journal"]["source"] == "env" 2888 assert env_journal.is_dir() 2889 assert not config_journal.exists() 2890 assert_command(calls, 0, expected_doctor_command()) 2891 2892 2893def test_journal_is_regular_file_dead_ends( 2894 tmp_path: Path, 2895 monkeypatch: pytest.MonkeyPatch, 2896 capsys: pytest.CaptureFixture[str], 2897) -> None: 2898 patch_home(monkeypatch, tmp_path) 2899 patch_source_checkout(monkeypatch, tmp_path) 2900 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2901 journal_file = tmp_path / "journal-file" 2902 journal_file.write_text("not a directory", encoding="utf-8") 2903 calls = patch_subprocess(monkeypatch) 2904 2905 rc = setup.main(["--yes", "--journal", str(journal_file)]) 2906 2907 assert rc == 2 2908 assert calls == [] 2909 assert "directory" in capsys.readouterr().err 2910 assert not (journal_file / "health" / "setup-state.json").exists() 2911 2912 2913def test_doctor_parse_failure_records_failed( 2914 tmp_path: Path, 2915 monkeypatch: pytest.MonkeyPatch, 2916) -> None: 2917 patch_home(monkeypatch, tmp_path) 2918 patch_source_checkout(monkeypatch, tmp_path) 2919 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2920 journal = tmp_path / "journal" 2921 patch_subprocess(monkeypatch, doctor_stdout="not json") 2922 2923 rc = setup.main(["--yes", "--journal", str(journal)]) 2924 2925 assert rc == 1 2926 manifest = read_manifest(journal) 2927 assert len(manifest["steps"]) == 1 2928 step = manifest["steps"][0] 2929 assert step["name"] == "doctor" 2930 assert step["status"] == "failed" 2931 assert "doctor JSON parse failed" in step["error"]["message"] 2932 2933 2934def test_invalid_manifest_treated_as_no_prior( 2935 tmp_path: Path, 2936 monkeypatch: pytest.MonkeyPatch, 2937 capsys: pytest.CaptureFixture[str], 2938) -> None: 2939 home = patch_home(monkeypatch, tmp_path) 2940 patch_source_checkout(monkeypatch, tmp_path) 2941 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2942 (home / ".claude").mkdir() 2943 journal = tmp_path / "journal" 2944 journal.mkdir() 2945 (journal / "health").mkdir(parents=True, exist_ok=True) 2946 (journal / "health" / "setup-state.json").write_text("{", encoding="utf-8") 2947 calls = patch_subprocess(monkeypatch) 2948 patch_service_health(monkeypatch) 2949 2950 rc = setup.main(["--yes", "--journal", str(journal)]) 2951 2952 assert rc == 0 2953 out = capsys.readouterr().out 2954 assert "last ran cleanly" not in out and "left these steps incomplete" not in out 2955 assert_command(calls, 0, expected_doctor_command()) 2956 assert_command(calls, 1, expected_install_models_command()) 2957 assert_command(calls, 2, expected_skills_user_command()) 2958 assert_command(calls, 3, expected_skills_journal_command(journal)) 2959 assert_command(calls, 4, expected_service_install_command()) 2960 assert_command(calls, 5, expected_brain_bootstrap_command()) 2961 assert len(calls) == 6 2962 2963 2964def test_port_propagates_to_subprocess_argv( 2965 tmp_path: Path, 2966 monkeypatch: pytest.MonkeyPatch, 2967) -> None: 2968 home = patch_home(monkeypatch, tmp_path) 2969 patch_source_checkout(monkeypatch, tmp_path) 2970 monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 2971 (home / ".claude").mkdir() 2972 journal = tmp_path / "journal" 2973 calls = patch_subprocess(monkeypatch) 2974 patch_service_health(monkeypatch) 2975 2976 rc = setup.main(["--yes", "--journal", str(journal), "--port", "8080"]) 2977 2978 assert rc == 0 2979 assert_command(calls, 0, expected_doctor_command(port=8080)) 2980 assert_command(calls, 4, expected_service_install_command(port=8080)) 2981 assert_command(calls, 5, expected_brain_bootstrap_command()) 2982 assert len(calls) == 6 2983 2984 2985def test_setup_never_dispatches_access_commands_through_the_journal_surface() -> None: 2986 """Setup must invoke journal-access commands through `sol`, never the module entry. 2987 2988 `python -m solstone.think.sol_cli` is `journal_main` — always the *journal* 2989 surface — and since the dispatcher gained its access-command rejection it exits 2 2990 for every command in the generated inventory. Setup built three of its own steps 2991 that way, so `journal setup` failed `skills_user` and `skills_journal`, exited 2992 non-zero, and the macOS app's first-run onboarding waited forever on a setup that 2993 never reported completion. 2994 2995 Anchored to the generated inventory rather than a literal list, so adding a new 2996 access command cannot silently reintroduce this. 2997 """ 2998 from solstone.think.generated.access_rejections import JOURNAL_ACCESS_ONLY_COMMANDS 2999 3000 ctx = SimpleNamespace(journal_path=Path("/tmp/journal-under-test")) 3001 commands = [ 3002 setup.skills_user_command(), 3003 setup.skills_journal_command(ctx), 3004 setup.brain_bootstrap_command(), 3005 ] 3006 sol_console = str(Path(sys.executable).parent / "sol") 3007 3008 checked = 0 3009 for command in commands: 3010 subcommand = next((arg for arg in command[1:] if not arg.startswith("-")), None) 3011 if subcommand not in JOURNAL_ACCESS_ONLY_COMMANDS: 3012 continue 3013 checked += 1 3014 assert command[0] == sol_console, ( 3015 f"{subcommand!r} is a journal-access command and must be invoked via the " 3016 f"sol console script, got {command[0]!r}" 3017 ) 3018 assert "solstone.think.sol_cli" not in command, ( 3019 f"{subcommand!r} is dispatched through the journal surface, which rejects it" 3020 ) 3021 3022 # Non-vacuous: all three builders carry an access command today. 3023 assert checked == 3