From 7afaecd3069aac04036f13db7918a1ac7232e296 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 28 May 2026 18:43:09 -0600 Subject: [PATCH] feat(doctor): split into universal `sol doctor` + journal-host `journal doctor` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe diagnostics so each surface owns the right question: - `sol doctor` (surface "universal", valid from both `sol` and `journal`) runs only universal CLI-usability checks — python sanity, sol_importable, local_bin_sol_reachable, and the `sol`-wrapper stale-alias check — and is clean on a box with no local journal. - new `journal doctor` runs journal-host health: disk, config/journal-dir writability, service identity + running/crash-loop state, journal_sync, the `journal`-wrapper stale-alias check, launchd plist, and feature advisories. Folder/service checks skip cleanly (never false-fail) off a journal host; severities per design (config-invalid/identity-mismatch/ crash-loop/systemd-failed/sync-conflict = fail, stopped/socket-absent = warn, features = advisory). Consolidate the probe infra that `preflight.py` had duplicated into a new stdlib-only `solstone/think/probe.py` (Check/CheckResult/ProbeOutput/ run_probe/make_result + helpers + the shared check bodies); preflight and doctor now compose from it. preflight behavior is byte-identical. Repoint `journal setup` step-1 at a readiness battery via `doctor --readiness` (universal + disk + journal-dir-writable + feature advisories, no runtime service/sync checks). STEP_NAMES and the doctor JSONL event/ status/summary contract are unchanged for the macOS installer parser. Split the per-binary stale-alias check + legacy uv-tool/pipx auto-migration so each surface migrates only its own wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- INSTALL.md | 4 +- docs/DOCTOR.md | 57 +++ docs/SOLCLI.md | 54 ++- scripts/doctor.py | 8 +- solstone/think/doctor.py | 770 +++++++++++++--------------------- solstone/think/preflight.py | 468 ++------------------- solstone/think/probe.py | 446 ++++++++++++++++++++ solstone/think/service.py | 36 +- solstone/think/setup.py | 1 + solstone/think/sol_cli.py | 10 +- tests/test_doctor.py | 290 +++---------- tests/test_doctor_features.py | 12 +- tests/test_journal_doctor.py | 315 ++++++++++++++ tests/test_preflight.py | 70 ++-- tests/test_setup.py | 1 + tests/test_sol.py | 33 +- tests/test_sol_cli_help.py | 2 +- 18 files changed, 1360 insertions(+), 1219 deletions(-) create mode 100644 solstone/think/probe.py create mode 100644 tests/test_journal_doctor.py diff --git a/AGENTS.md b/AGENTS.md index 9a76eaa9c..72361f1c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Top-level dirs intentionally not in the table: `.venv/`, `scratch/`, `logs/`, `t Two surfaces: - **`sol `** — access commands registered in `solstone/think/sol_cli.py`'s `COMMANDS` dict (e.g., `sol import`, `sol indexer`, `sol top`, `sol health`). -- **`journal `** — host/service commands from the same registry (e.g., `journal think`, `journal supervisor`, `journal heartbeat`). `ALIASES` provides shorthand compound commands (`journal start` → `journal supervisor`, `journal up/down` → `journal service up/down`). +- **`journal `** — host/service commands from the same registry (e.g., `journal think`, `journal supervisor`, `journal heartbeat`). `ALIASES` provides shorthand compound commands (`journal start` → `journal supervisor`, `journal up/down` → `journal service up/down`). `doctor` is universal: `sol doctor` checks CLI usability; `journal doctor` checks journal-host health. - **`sol call `** — routes to `solstone/think/call.py`, which discovers each `solstone/apps/*/call.py` Typer sub-app and mounts it as a subcommand. Example: `sol call entities list`, `sol call activities create`, `sol call journal search`. **Adding a top-level command:** add an entry to `COMMANDS` in `solstone/think/sol_cli.py`; ensure the module has a `main()` function. diff --git a/INSTALL.md b/INSTALL.md index f651cde60..6feb88cbe 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -39,11 +39,11 @@ uv tool install solstone journal setup ``` -this runs doctor diagnostics, confirms the journal directory at `~/journal`, installs the local transcription model (~2.5 GB on linux), installs the solstone skill for claude code, codex, and gemini, installs all journal-side talent skills into the configured journal so cogitate sub-agents can discover them, and starts a background service (systemd on linux, launchd on macOS) listening on http://localhost:5015. +this runs the setup readiness doctor battery, confirms the journal directory at `~/journal`, installs the local transcription model (~2.5 GB on linux), installs the solstone skill for claude code, codex, and gemini, installs all journal-side talent skills into the configured journal so cogitate sub-agents can discover them, and starts a background service (systemd on linux, launchd on macOS) listening on http://localhost:5015. let your human know: **open http://localhost:5015 in a browser**. the first-run wizard walks them through setting their identity and connecting a gemini API key. network access, and the password it requires, can be configured later in settings → security. -if a step has missing system libraries or python extras, `sol doctor` will tell you the exact install command to run for your platform. extras (`pdf`, `whisper`) can be added at any time with `uv tool upgrade solstone --extra pdf` or `pip install 'solstone[pdf]'`. on linux, local parakeet transcription needs `solstone[parakeet-onnx-cpu]` (or `[parakeet-onnx-cuda]` for NVIDIA GPUs); install or upgrade the same way as other extras. +if the readiness doctor step (`sol doctor --readiness`) finds missing system libraries or python extras, it will tell you the exact install command to run for your platform. extras (`pdf`, `whisper`) can be added at any time with `uv tool upgrade solstone --extra pdf` or `pip install 'solstone[pdf]'`. on linux, local parakeet transcription needs `solstone[parakeet-onnx-cpu]` (or `[parakeet-onnx-cuda]` for NVIDIA GPUs); install or upgrade the same way as other extras. if the service fails to start, check `journal service logs`. diff --git a/docs/DOCTOR.md b/docs/DOCTOR.md index 2ab9131d1..31668d59f 100644 --- a/docs/DOCTOR.md +++ b/docs/DOCTOR.md @@ -23,6 +23,63 @@ ls journal/talents/*/*_active.jsonl 2>/dev/null --- +## Diagnostic Commands + +Use the diagnostic command that matches the question: + +- `sol doctor` — is the CLI usable on this machine? This universal battery is + safe on a journal-less or repo-less box. +- `journal doctor` — is this journal host healthy, and what should be fixed? + This is the health diagnosis view. +- `make preflight` — can a fresh source checkout get ready before `.venv` or + `uv` exist? +- `sol health` — what live supervisor status is being reported right now? + +`sol doctor` runs four checks: + +| Check | Severity | Notes | +|-------|----------|-------| +| `python_version` | blocker | Light package-metadata Requires-Python floor; does not require `pyproject.toml`. | +| `sol_importable` | blocker | Verifies the installed/source CLI import path. | +| `local_bin_sol_reachable` | advisory | Checks the expected `~/.local/bin/sol` PATH shape. | +| `stale_alias_symlink` | blocker | Checks only the `sol` wrapper and can migrate recognized legacy aliases. | + +`journal doctor` runs the journal-host battery: + +| Check | Severity | Notes | +|-------|----------|-------| +| `disk_space` | advisory | Free-space warning. | +| `config_dir_readable` | blocker | Home and service config directory permissions. | +| `journal_dir_writable` | blocker | Journal directory writability when the local journal exists. | +| `service_identity` | blocker | Installed service points at this install. | +| `service_running` | blocker | Service installed/running/crash-loop diagnosis. | +| `journal_sync` | blocker | Concurrent-writer conflict check. | +| `stale_alias_symlink` | blocker | Checks only the `journal` wrapper. | +| `launchd_stale_plist` | advisory | macOS only; Linux skips it. | +| `feature:pdf`, `feature:whisper` | advisory | Optional extras with exact install commands. | + +`journal doctor` is role-aware. If there is no local journal directory or no +installed service, folder and service checks emit `skip` (`no local journal` or +`no local journal service`) rather than failing. Invalid service config, service +identity mismatch, crash loops, systemd failed state, and journal-sync conflicts +are blocker failures. An installed service with no supervisor socket is a +warning when the OS unit is not failed. Feature checks are advisory. + +`journal setup` step 1 runs `sol doctor --readiness`: the four universal checks +plus `disk_space`, `journal_dir_writable`, `feature:pdf`, and `feature:whisper`. +It does not run runtime service, sync, config-dir, or launchd checks. A blocker +failure still stops setup early; feature advisories stay advisory and include +the exact extra-install command. + +`make preflight` runs `scripts/preflight.py`, the stdlib-only source-checkout +readiness battery that is valid before `.venv`/`uv` exist: +`python_version`, `uv_installed`, `venv_consistent`, +`local_bin_sol_reachable`, `disk_space`, and `config_dir_readable`. It shares +probe primitives with doctor through `solstone/think/probe.py`, but its +behavior is unchanged. + +--- + ## Service Architecture The supervisor (`journal supervisor`) manages these services: diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 24eb36cca..038309fc1 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -248,9 +248,45 @@ This is for audit trail — it records that the agent confirmed user consent bef Use lowercase, single-word names. Hyphenated names for multi-word (`list-nudges-due`, `set-name`). -## Structured output: `journal setup --jsonl` and `sol doctor --jsonl` +## Doctor Commands -Use `--jsonl` when another process needs progress events as they happen. The contract is one JSON object per stdout line, flushed immediately; `sol doctor --jsonl` is mutually exclusive with `sol doctor --json`, and the existing `sol doctor --json` payload keeps its short statuses (`ok`, `warn`, `fail`, `skip`). +`doctor` is a universal command surface: both `sol doctor` and `journal doctor` +dispatch to `solstone.think.doctor`, with the battery selected by the active +binary. + +`sol doctor` checks universal CLI usability and is designed to run cleanly on a +journal-less or repo-less machine. Its default battery has four checks: + +- `python_version` — blocker; light package-metadata Requires-Python floor, no + `pyproject.toml` required. +- `sol_importable` — blocker. +- `local_bin_sol_reachable` — advisory. +- `stale_alias_symlink` — blocker; checks only the `sol` wrapper. + +`journal doctor` diagnoses journal-host health. It is role-aware: on a machine +without a local journal directory or installed journal service, folder and +service checks emit `skip` (`no local journal` / `no local journal service`) +instead of false failures. Its battery is: + +- `disk_space` — advisory. +- `config_dir_readable`, `journal_dir_writable`, `service_identity`, + `service_running`, `journal_sync`, `stale_alias_symlink` — blockers. +- `launchd_stale_plist` — advisory on macOS; skipped on Linux. +- `feature:pdf`, `feature:whisper` — advisories with the exact extra-install + command when missing. + +Journal-host blocker failures include invalid service config, service identity +mismatch, crash loops, systemd failed state, and journal-sync conflicts. An +installed service with no supervisor socket is a warning when the OS unit is not +failed. `--feature ` runs a single feature advisory on either surface. + +Use `sol doctor` for “can this CLI run?”, `journal doctor` for “why is this +journal host unhealthy?”, `make preflight` for the stdlib-only fresh-clone check +before `.venv`/`uv` exist, and `sol health` for the live supervisor status view. + +## Structured output: `journal setup --jsonl` and doctor `--jsonl` + +Use `--jsonl` when another process needs progress events as they happen. The contract is one JSON object per stdout line, flushed immediately; doctor `--jsonl` is mutually exclusive with doctor `--json`, and the existing doctor `--json` payload keeps its short statuses (`ok`, `warn`, `fail`, `skip`). | Event | Emitted by | When | |-------|------------|------| @@ -260,9 +296,9 @@ Use `--jsonl` when another process needs progress events as they happen. The con | `step.completed` | `journal setup --jsonl` | A setup step finishes with `outcome: "ok"` or `outcome: "skipped"`. | | `step.failed` | `journal setup --jsonl` | A setup step fails or reaches a dead end. | | `step.warning` | `journal setup --jsonl` | Setup translates advisory diagnostics or dropped doctor lines. | -| `doctor.started` | `sol doctor --jsonl` | Doctor diagnostics begin. | -| `check.completed` | `sol doctor --jsonl` | One diagnostic check finishes. Status is long form: `ok`, `warning`, `failed`, or `skipped`. | -| `doctor.completed` | `sol doctor --jsonl` | Doctor diagnostics finish with `status: "ok"`, `"warning"`, or `"failed"`. | +| `doctor.started` | doctor `--jsonl` | Doctor diagnostics begin. | +| `check.completed` | doctor `--jsonl` | One diagnostic check finishes. Status is long form: `ok`, `warning`, `failed`, or `skipped`. | +| `doctor.completed` | doctor `--jsonl` | Doctor diagnostics finish with `status: "ok"`, `"warning"`, or `"failed"`. | | Code | When | |------|------| @@ -282,16 +318,16 @@ Skipped or resumed reasons are fixed: `--skip-models`, `--skip-skills`, `--skip- ### Doctor pass-through -`journal setup --jsonl` runs `sol doctor --jsonl` for the doctor step and forwards `doctor.started`, `check.completed`, and `doctor.completed` lines verbatim. Advisory doctor checks are also translated into setup-level `step.warning` events so consumers can handle setup warnings uniformly. +`journal setup --jsonl` runs `sol doctor --readiness --jsonl` for the doctor step and forwards `doctor.started`, `check.completed`, and `doctor.completed` lines verbatim. The readiness battery is the four universal checks plus `disk_space`, `journal_dir_writable`, `feature:pdf`, and `feature:whisper`; it does not run runtime service, sync, config-dir, or launchd checks. Advisory doctor checks are also translated into setup-level `step.warning` events so consumers can handle setup warnings uniformly. -Example stream excerpt: +Example stream excerpt for setup readiness: ```jsonl {"event":"setup.started","ts":"2026-05-11T20:00:00Z","version":"0.0.0+source","mode":"non_interactive"} {"event":"step.started","ts":"2026-05-11T20:00:00Z","step":"doctor","index":1,"total":7} -{"event":"doctor.started","ts":"2026-05-11T20:00:00Z","version":"0.0.0+source"} +{"event":"doctor.started","ts":"2026-05-11T20:00:00Z","version":"0.0.0+source","port":5015,"feature":""} {"event":"check.completed","ts":"2026-05-11T20:00:01Z","name":"python_version","severity":"blocker","status":"ok","detail":"Python version ok","fix":""} -{"event":"doctor.completed","ts":"2026-05-11T20:00:01Z","status":"ok","duration_ms":120,"summary":{"total":17,"failed":0,"warnings":0,"skipped":0}} +{"event":"doctor.completed","ts":"2026-05-11T20:00:01Z","status":"ok","duration_ms":120,"summary":{"total":8,"failed":0,"warnings":0,"skipped":0}} {"event":"step.completed","ts":"2026-05-11T20:00:01Z","step":"doctor","outcome":"ok","duration_ms":121} {"event":"step.completed","ts":"2026-05-11T20:00:04Z","step":"service","outcome":"ok","duration_ms":900} {"event":"setup.completed","ts":"2026-05-11T20:00:04Z","status":"ok","duration_ms":4000} diff --git a/scripts/doctor.py b/scripts/doctor.py index 3170ee5bd..80c31dac4 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Stdlib-only bootstrap shim for `sol doctor`. +"""Entry shim for `sol doctor`. -Used as a pre-install entry point on machines that do not yet have `.venv` -populated. Delegates to `think.doctor.main`, which holds the canonical -diagnostic logic. +Delegates to `solstone.think.doctor.main`, the canonical diagnostic. This +requires the installed package; for the stdlib-only pre-`.venv` readiness +battery use `scripts/preflight.py` or `make preflight`. """ from __future__ import annotations diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index bd82b60fa..807a78754 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -1,22 +1,21 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Pre-install diagnostics for solstone. +"""Diagnostics for solstone CLI and journal hosts. -Runs a fixed battery of blocker and advisory checks using only the Python -standard library so a fresh clone can be diagnosed before `uv sync`. Exit code -`0` means no blocker failed; exit code `1` means at least one blocker-severity -check failed. +`sol doctor` runs universal CLI-usability checks that must be meaningful on a +journal-less machine. `journal doctor` runs journal-host service, folder, and +processing-health checks. `--readiness` runs the setup step-1 battery. + +Exit code `0` means no blocker failed; exit code `1` means at least one +blocker-severity check failed. Decision log: -- uv floor: 0.7.12 — `uv.lock` revision=3 requires >= 0.7.12 per - astral-sh/uv#15220. +- Universal python check reads installed package metadata (with a static + fallback), not pyproject.toml, so packaged installs and repo-less hosts can + be diagnosed. - disk threshold: 10 GiB — measured `.venv`=7.88 GiB + uv-cache first-install growth ~1 GiB + buffer. -- Makefile UV-guard strategy: MAKECMDGOALS filter; prep verified the - doctor-only matrix on GNU make. -- Ramon triage docs are absent in this worktree; the battery follows the task - spec directly. - Feature-extras checks (pdf, whisper) are dynamically registered from `solstone.think.features.FEATURES`, severity advisory, never affect exit code. Filter via `--feature `. @@ -30,35 +29,46 @@ import json import os import plistlib import re -import shutil -import subprocess import sys import time from dataclasses import dataclass +from functools import partial from importlib.metadata import PackageNotFoundError, distribution from importlib.metadata import version as _pkg_version from pathlib import Path -from typing import IO, Callable, Literal, Sequence +from typing import IO, Callable, Sequence from solstone.think import features as _features +from solstone.think.health_cli import fetch_supervisor_status +from solstone.think.probe import ( + CONFIG_DIR_READABLE_CHECK, + DEFAULT_REQUIRES_PYTHON, + DISK_SPACE_CHECK, + LOCAL_BIN_SOL_REACHABLE_CHECK, + PYTHON_VERSION_CHECK, + PYTHON_VERSION_FIX, + ROOT, + Check, + CheckResult, + Status, + compare_versions, + config_dir_readable_check, + disk_space_check, + local_bin_sol_reachable_check, + make_result, + platform_tag, + run_probe, + truncate, + version_text, +) +from solstone.think.service import ( + check_service_target_identity, + service_is_failed, + service_is_installed, +) from solstone.think.setup_events import STATUS_TRANSLATION, JsonlEmitter, utc_now_iso from solstone.think.sync_check import check_journal_sync, format_doctor_report -from solstone.think.utils import is_packaged_install - -ROOT = Path(__file__).resolve().parents[2] -MIN_UV = (0, 7, 12) -MIN_FREE_GIB = 10.0 -DEFAULT_REQUIRES_PYTHON = ">=3.11" -PYTHON_VERSION_FIX = "install Python >=3.11, then retry" -LOCAL_BIN_SOL_FIX = ( - "Install via `uv tool install solstone` or `pipx install solstone` for the " - "canonical layout, or run `ln -s $(command -v sol) ~/.local/bin/sol` to keep " - "your custom layout." -) - -Severity = Literal["blocker", "advisory"] -Status = Literal["ok", "fail", "warn", "skip"] -Platform = Literal["linux", "darwin"] +from solstone.think.utils import get_journal_info, is_packaged_install @dataclass(frozen=True) @@ -68,209 +78,44 @@ class Args: jsonl: bool port: int feature: str | None = None + readiness: bool = False -@dataclass(frozen=True) -class Check: - name: str - severity: Severity - platforms: tuple[Platform, ...] - - -@dataclass(frozen=True) -class CheckResult: - name: str - severity: Severity - status: Status - detail: str - fix: str | None - platform: str | None = None - - -@dataclass(frozen=True) -class ProbeOutput: - stdout: str - stderr: str - returncode: int - - -def platform_tag() -> Platform: - if sys.platform == "darwin": - return "darwin" - return "linux" - - -def make_result( - check: Check, - status: Status, - detail: str, - fix: str | None = None, - *, - platform: str | None = None, -) -> CheckResult: - return CheckResult( - name=check.name, - severity=check.severity, - status=status, - detail=detail, - fix=fix, - platform=platform, - ) - - -def truncate(text: str, limit: int) -> str: - text = " ".join(text.split()) - if len(text) <= limit: - return text - return text[: limit - 3] + "..." - - -def version_text(version: tuple[int, int, int]) -> str: - return ".".join(str(part) for part in version) - - -def parse_version(text: str) -> tuple[int, int, int] | None: - match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) - if not match: - return None - return tuple(int(part) for part in match.groups()) - - -def compare_versions(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: - if left < right: - return -1 - if left > right: - return 1 - return 0 - - -def unexpected_output_result( - check: Check, - output: str, - *, - fix: str | None = None, -) -> CheckResult: - snippet = truncate(output or "", 80) - return make_result( - check, - "fail", - f"probe returned unexpected output: {snippet}", - fix, - ) - - -def command_text(cmd: Sequence[str]) -> str: - return " ".join(cmd) - - -def run_probe( - check: Check, - cmd: Sequence[str], - *, - timeout: float, - cwd: Path | None = None, - env: dict[str, str] | None = None, - ok_returncodes: tuple[int, ...] = (0,), - allow_nonzero: bool = False, - allow_empty_stdout: bool = False, - fix: str | None = None, -) -> ProbeOutput | CheckResult: - merged_env = os.environ.copy() - if env: - merged_env.update(env) - try: - completed = subprocess.run( - list(cmd), - capture_output=True, - text=True, - timeout=timeout, - cwd=str(cwd) if cwd else None, - env=merged_env, - check=False, - ) - except FileNotFoundError: - return make_result(check, "fail", f"probe command not found: {cmd[0]}", fix) - except subprocess.TimeoutExpired: - return make_result( - check, - "fail", - f"probe timed out after {timeout:g}s: {command_text(cmd)}", - fix, - ) - except OSError as exc: - return make_result( - check, - "fail", - f"probe failed: {type(exc).__name__}: {exc}", - fix, - ) - - if completed.returncode not in ok_returncodes and not allow_nonzero: - detail = completed.stderr.strip() or completed.stdout.strip() or "" - return make_result( - check, - "fail", - f"probe exited {completed.returncode}: {truncate(detail, 80)}", - fix, - ) - - if not allow_empty_stdout and not completed.stdout.strip(): - return unexpected_output_result( - check, - completed.stderr.strip() or completed.stdout.strip(), - fix=fix, - ) +Runner = Callable[[Args], CheckResult] - return ProbeOutput( - stdout=completed.stdout, - stderr=completed.stderr, - returncode=completed.returncode, - ) +SOL_IMPORTABLE_CHECK = Check("sol_importable", "blocker", ("linux", "darwin")) +STALE_ALIAS_CHECK = Check("stale_alias_symlink", "blocker", ("linux", "darwin")) +JOURNAL_DIR_WRITABLE_CHECK = Check( + "journal_dir_writable", "blocker", ("linux", "darwin") +) +SERVICE_IDENTITY_CHECK = Check("service_identity", "blocker", ("linux", "darwin")) +SERVICE_RUNNING_CHECK = Check("service_running", "blocker", ("linux", "darwin")) +LAUNCHD_STALE_PLIST_CHECK = Check("launchd_stale_plist", "advisory", ("darwin",)) +JOURNAL_SYNC_CHECK = Check("journal_sync", "blocker", ("linux", "darwin")) -def python_version_check(args: Args) -> CheckResult: +def python_sanity_check(args: Args) -> CheckResult: del args - check = CHECK_MAP["python_version"] - pyproject = ROOT / "pyproject.toml" - spec_from_metadata = False + check = PYTHON_VERSION_CHECK try: - text = pyproject.read_text(encoding="utf-8") - match = re.search(r'^requires-python\s*=\s*"([^"]+)"', text, re.MULTILINE) - if not match: - return make_result( - check, - "fail", - "could not parse requires-python from pyproject.toml", - PYTHON_VERSION_FIX, - ) - spec = match.group(1) - except FileNotFoundError: - spec_from_metadata = True - try: - spec = distribution("solstone").metadata.get("Requires-Python") - except PackageNotFoundError: - spec = None - if not spec: - spec = DEFAULT_REQUIRES_PYTHON - except OSError as exc: + spec = distribution("solstone").metadata.get("Requires-Python") + except PackageNotFoundError: + spec = None + if not spec: + spec = DEFAULT_REQUIRES_PYTHON + + min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) + if not min_match: + spec = DEFAULT_REQUIRES_PYTHON + min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) + if not min_match: return make_result( check, "fail", - f"could not read {pyproject.name}: {type(exc).__name__}: {exc}", + f"unsupported requires-python specifier: {spec}", PYTHON_VERSION_FIX, ) - min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) - if not min_match: - if spec_from_metadata: - spec = DEFAULT_REQUIRES_PYTHON - min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) - if not min_match: - return make_result( - check, - "fail", - f"unsupported requires-python specifier: {spec}", - PYTHON_VERSION_FIX, - ) + minimum = ( int(min_match.group(1)), int(min_match.group(2)), @@ -282,7 +127,7 @@ def python_version_check(args: Args) -> CheckResult: check, "fail", f"python {version_text(current)} does not satisfy {spec}", - "install Python >=3.11, then `rm -rf .venv .installed && make install`", + PYTHON_VERSION_FIX, ) return make_result( check, @@ -291,82 +136,9 @@ def python_version_check(args: Args) -> CheckResult: ) -def uv_installed_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["uv_installed"] - if is_packaged_install(): - return make_result( - check, - "skip", - "uv is only required for source-checkout development", - ) - fix = "curl -LsSf https://astral.sh/uv/install.sh | sh" - probe = run_probe(check, ["uv", "--version"], timeout=0.5, fix=fix) - if isinstance(probe, CheckResult): - return probe - version = parse_version(probe.stdout) - if version is None: - return unexpected_output_result(check, probe.stdout, fix=fix) - if compare_versions(version, MIN_UV) < 0: - return make_result( - check, - "fail", - f"uv {version_text(version)} is older than required {version_text(MIN_UV)}", - fix, - ) - return make_result( - check, - "ok", - f"uv {version_text(version)} >= {version_text(MIN_UV)}", - ) - - -def venv_consistent_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["venv_consistent"] - if is_packaged_install(): - return make_result( - check, - "skip", - "packaged install: env managed by uv tool / pipx", - ) - python_bin = ROOT / ".venv" / "bin" / "python" - expected = (ROOT / ".venv").resolve() - if not python_bin.exists(): - return make_result( - check, - "skip", - ".venv absent; run make install", - ) - probe = run_probe( - check, - [str(python_bin), "-c", "import sys; print(sys.prefix)"], - timeout=0.5, - fix="rm -rf .venv .installed && make install", - ) - if isinstance(probe, CheckResult): - return probe - prefix_text = probe.stdout.strip() - if not prefix_text: - return unexpected_output_result( - check, - probe.stdout, - fix="rm -rf .venv .installed && make install", - ) - actual = Path(prefix_text).resolve() - if actual != expected: - return make_result( - check, - "fail", - f".venv points at {actual}, expected {expected}", - "rm -rf .venv .installed && make install", - ) - return make_result(check, "ok", f".venv points at this repo ({expected})") - - def sol_importable_check(args: Args) -> CheckResult: del args - check = CHECK_MAP["sol_importable"] + check = SOL_IMPORTABLE_CHECK if is_packaged_install(): try: import solstone # noqa: F401 @@ -415,104 +187,119 @@ def sol_importable_check(args: Args) -> CheckResult: return make_result(check, "fail", detail, fix) -def local_bin_sol_reachable_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["local_bin_sol_reachable"] - local = Path.home() / ".local" / "bin" / "sol" - which = shutil.which("sol") - if local.exists() and local.is_file() and which is not None: - which_path = Path(which) - local_resolved = local.resolve() - which_resolved = which_path.resolve() - if ( - which_path != local - and local.is_symlink() - and local_resolved == which_resolved - ): - return make_result( - check, - "ok", - f"~/.local/bin/sol symlinks to PATH sol at {which}", - ) - if which_resolved == local_resolved: - return make_result( - check, - "ok", - f"~/.local/bin/sol is on PATH at {local}", - ) +def _nearest_existing_ancestor(path: Path) -> Path: + current = path + while not current.exists() and current.parent != current: + current = current.parent + return current - failures: list[str] = [] - if not local.exists(): - failures.append(f"{local} is missing") - elif not local.is_file(): - failures.append(f"{local} is not a file") - if which is None: - failures.append("sol is not on PATH") - else: - try: - failures.append( - f"PATH sol resolves to {Path(which).resolve()}, expected {local.resolve()}" - ) - except OSError: - failures.append(f"PATH sol is {which}, but it could not be resolved") - return make_result(check, "warn", "; ".join(failures), LOCAL_BIN_SOL_FIX) +def _journal_writability_result(check: Check) -> CheckResult: + path_text, _source = get_journal_info() + path = Path(path_text) + if path.is_dir(): + if os.access(path, os.W_OK): + return make_result(check, "ok", f"journal dir writable: {path}") + return make_result( + check, + "fail", + f"journal dir not writable: {path}", + f"fix ownership/permissions of {path}", + ) + if path.exists(): + return make_result( + check, + "fail", + f"journal path exists but is not a directory: {path}", + f"move or remove {path}, then re-run", + ) -def disk_space_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["disk_space"] - usage = shutil.disk_usage(ROOT) - free_gib = usage.free / (1024**3) - if free_gib < MIN_FREE_GIB: + ancestor = _nearest_existing_ancestor(path) + if ancestor.is_dir() and os.access(ancestor, os.W_OK): return make_result( check, - "warn", - f"only {free_gib:.1f} GiB free on the repo filesystem (<{MIN_FREE_GIB:.0f} GiB)", - "free disk on the repo filesystem before `make install`", + "ok", + f"journal dir absent; parent {ancestor} is writable", ) return make_result( check, - "ok", - f"{free_gib:.1f} GiB free (>= {MIN_FREE_GIB:.0f} GiB)", + "fail", + f"journal dir absent; nearest existing ancestor is not writable: {ancestor}", + f"fix ownership/permissions of {ancestor}", ) -def config_dir_readable_check(args: Args) -> CheckResult: +def journal_dir_writable_readiness(args: Args) -> CheckResult: del args - check = CHECK_MAP["config_dir_readable"] - home = Path.home() - if not home.exists(): + return _journal_writability_result(JOURNAL_DIR_WRITABLE_CHECK) + + +def journal_dir_writable_journal(args: Args) -> CheckResult: + del args + path_text, _source = get_journal_info() + if not Path(path_text).exists(): + return make_result(JOURNAL_DIR_WRITABLE_CHECK, "skip", "no local journal") + return _journal_writability_result(JOURNAL_DIR_WRITABLE_CHECK) + + +def service_identity_check(args: Args) -> CheckResult: + del args + identity = check_service_target_identity() + if not identity.installed: + return make_result(SERVICE_IDENTITY_CHECK, "skip", "no local journal service") + if identity.target == "": return make_result( - check, + SERVICE_IDENTITY_CHECK, "fail", - f"home directory does not exist: {home}", - f"fix ownership/permissions of {home}", + identity.detail, + "run journal setup to reinstall the service", ) - required_access = os.R_OK | os.W_OK | os.X_OK - if not os.access(home, required_access): + if not identity.matches_current_install: return make_result( - check, + SERVICE_IDENTITY_CHECK, "fail", - f"home directory is not readable and writable: {home}", - f"fix ownership/permissions of {home}", + identity.detail, + "run journal setup --force from this install to refresh the service", ) - current_platform = platform_tag() - if current_platform == "darwin": - config_dir = home / "Library" / "LaunchAgents" - else: - config_dir = home / ".config" - if config_dir.exists() and not os.access(config_dir, required_access): + return make_result(SERVICE_IDENTITY_CHECK, "ok", identity.detail) + + +def service_running_check(args: Args) -> CheckResult: + del args + if not service_is_installed(): + return make_result(SERVICE_RUNNING_CHECK, "skip", "no local journal service") + + status = fetch_supervisor_status() + if status is None: + if service_is_failed(): + return make_result( + SERVICE_RUNNING_CHECK, + "fail", + "journal service unit is failed", + "run journal service restart; if it persists, run journal service logs", + ) return make_result( - check, + SERVICE_RUNNING_CHECK, + "warn", + "service installed but not running", + "run journal service start", + ) + + crashed = status.get("crashed") or [] + if crashed: + crashed_details = [] + for item in crashed: + name = item.get("name", "?") + attempts = item.get("restart_attempts", 0) + crashed_details.append(f"{name} ({attempts} restart attempts)") + return make_result( + SERVICE_RUNNING_CHECK, "fail", - f"service config directory is not writable: {config_dir}", - f"fix ownership/permissions of {config_dir}", + f"crash-loop: {', '.join(crashed_details)}", + "run journal service logs", ) - if config_dir.exists(): - detail = f"home and service config dir are writable ({config_dir})" - else: - detail = f"home is writable; install will create {config_dir}" - return make_result(check, "ok", detail) + + return make_result(SERVICE_RUNNING_CHECK, "ok", "journal service is running") def import_install_guard() -> tuple[object, object]: @@ -587,61 +374,65 @@ def _legacy_target_from_symlink(alias: Path) -> tuple[Path, str] | None: return resolved, tag -def _auto_migrate_legacy_aliases(check: Check, install_guard: object) -> CheckResult: +def _auto_migrate_legacy_aliases( + check: Check, + install_guard: object, + binary: str, +) -> CheckResult: try: journal = install_guard._current_journal_for_alias() except Exception as exc: return make_result( check, "fail", - f"legacy alias detected but journal resolution failed: {type(exc).__name__}: {exc} — run from venv to auto-migrate", - "run `journal setup` from the repo that owns the wrapper, or remove `~/.local/bin/sol` manually if the repo is gone", + f"legacy {binary} alias detected but journal resolution failed: {type(exc).__name__}: {exc} — run from venv to auto-migrate", + f"run `journal setup` from the repo that owns the wrapper, or remove `~/.local/bin/{binary}` manually if the repo is gone", ) - migrated: list[tuple[str, str, Path]] = [] - aliases = install_guard.alias_paths() + alias = install_guard.alias_paths()[binary] + legacy = _legacy_target_from_symlink(alias) + if legacy is None: + return make_result( + check, + "fail", + f"legacy {binary} alias auto-migration failed: alias is no longer a recognized legacy symlink", + ) + _target, tag = legacy + try: - for binary, alias in aliases.items(): - legacy = _legacy_target_from_symlink(alias) - if legacy is None: - continue - _target, tag = legacy - backup = _legacy_backup_path(binary) - backup.parent.mkdir(parents=True, exist_ok=True) - alias.replace(backup) - migrated.append((binary, tag, backup)) - - sol_bin_dir = Path(sys.executable).parent - sol_bins = {binary: str(sol_bin_dir / binary) for binary in aliases} - install_guard.install_wrappers(str(journal), sol_bins) + backup = _legacy_backup_path(binary) + backup.parent.mkdir(parents=True, exist_ok=True) + alias.replace(backup) + + sol_bin = Path(sys.executable).parent / binary + install_guard.install_wrappers( + str(journal), + {binary: str(sol_bin)}, + paths={binary: alias}, + ) except Exception as exc: return make_result( check, "fail", - f"legacy alias auto-migration failed: {type(exc).__name__}: {exc}", + f"legacy {binary} alias auto-migration failed: {type(exc).__name__}: {exc}", ) - tags = {tag for _binary, tag, _backup in migrated} - tag = next(iter(tags)) if len(tags) == 1 else "mixed" if tag == "uv-tool": migration_phrase = "migrated legacy uv-tool symlink" elif tag.startswith("pipx"): migration_phrase = "migrated legacy pipx symlink" else: - migration_phrase = "migrated legacy mixed symlink" - backup_detail = " and ".join( - f"{binary} → {backup}" for binary, _tag, backup in migrated - ) + migration_phrase = "migrated legacy symlink" return make_result( check, "ok", - f"auto-migrated legacy {tag} install ({migration_phrase}): backed up {backup_detail}; installed managed wrappers at ~/.local/bin/{{sol,journal}}", + f"auto-migrated legacy {tag} install for {binary} ({migration_phrase}): backed up {binary} → {backup}; installed managed wrapper at ~/.local/bin/{binary}", ) -def stale_alias_symlink_check(args: Args) -> CheckResult: +def stale_alias_symlink_check(args: Args, binary: str) -> CheckResult: del args - check = CHECK_MAP["stale_alias_symlink"] + check = STALE_ALIAS_CHECK try: alias_state_cls, check_alias = import_install_guard() except Exception as exc: @@ -651,67 +442,62 @@ def stale_alias_symlink_check(args: Args) -> CheckResult: f"could not import solstone.think.install_guard: {type(exc).__name__}: {exc}", ) install_guard = importlib.import_module(check_alias.__module__) + alias = install_guard.alias_paths()[binary] + if not alias.exists() and not alias.is_symlink(): + backup = _latest_legacy_backup(binary) + if backup is not None: + return make_result( + check, + "fail", + _partial_migration_detail(binary, backup), + "restore the backup or re-run from a fresh shell", + ) + worktree = alias_state_cls.WORKTREE absent = alias_state_cls.ABSENT owned = alias_state_cls.OWNED cross_repo = alias_state_cls.CROSS_REPO dangling = alias_state_cls.DANGLING foreign = alias_state_cls.FOREIGN - fail_detail: str | None = None - - for binary, alias in install_guard.alias_paths().items(): - if not alias.exists() and not alias.is_symlink(): - backup = _latest_legacy_backup(binary) - if backup is not None: - return make_result( - check, - "fail", - _partial_migration_detail(binary, backup), - "restore the backup or re-run from a fresh shell", - ) - state, other = check_alias(ROOT, binary) - if state is worktree: - return make_result( - check, - "skip", - "git worktree; run doctor from the primary clone", - ) - if state in {absent, owned}: - continue - - if state in {cross_repo, dangling, foreign} and other is not None: - tag = _recognized_legacy_target(other) - if tag is not None: - return _auto_migrate_legacy_aliases(check, install_guard) - - if state is cross_repo: - fail_detail = f"~/.local/bin/{binary} points at another repo ({other})" - elif state is dangling: - fail_detail = f"~/.local/bin/{binary} is dangling ({other})" - elif state is foreign: - fail_detail = f"~/.local/bin/{binary} exists but is not a symlink" - else: - fail_detail = f"unexpected alias state for {binary}: {state}" - break - - if fail_detail is None: + state, other = check_alias(ROOT, binary) + if state is worktree: + return make_result( + check, + "skip", + "git worktree; run doctor from the primary clone", + ) + if state in {absent, owned}: return make_result( check, "ok", - "sol and journal aliases absent or owned by this repo", + f"{binary} alias absent or owned by this repo", ) + + if state in {cross_repo, dangling, foreign} and other is not None: + tag = _recognized_legacy_target(other) + if tag is not None: + return _auto_migrate_legacy_aliases(check, install_guard, binary) + + if state is cross_repo: + fail_detail = f"~/.local/bin/{binary} points at another repo ({other})" + elif state is dangling: + fail_detail = f"~/.local/bin/{binary} is dangling ({other})" + elif state is foreign: + fail_detail = f"~/.local/bin/{binary} exists but is not a symlink" + else: + fail_detail = f"unexpected alias state for {binary}: {state}" return make_result( check, "fail", fail_detail, - "run `journal setup` from the repo that owns the wrapper, or remove `~/.local/bin/sol` manually if the repo is gone", + f"run `journal setup` from the repo that owns the wrapper, or remove `~/.local/bin/{binary}` manually if the repo is gone", ) def launchd_stale_plist_check(args: Args) -> CheckResult: del args - check = CHECK_MAP["launchd_stale_plist"] + check = LAUNCHD_STALE_PLIST_CHECK if platform_tag() != "darwin": return make_result(check, "skip", "not supported on linux", platform="linux") plist_path = Path.home() / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" @@ -748,7 +534,10 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: def journal_sync_check(args: Args) -> CheckResult: del args - check = CHECK_MAP["journal_sync"] + check = JOURNAL_SYNC_CHECK + path_text, _source = get_journal_info() + if not Path(path_text).is_dir(): + return make_result(check, "skip", "no local journal") try: result = check_journal_sync() except Exception as exc: @@ -760,7 +549,7 @@ def journal_sync_check(args: Args) -> CheckResult: def _make_feature_check( feat_name: str, -) -> tuple[Check, Callable[[Args], CheckResult]]: +) -> tuple[Check, Runner]: feat = _features.FEATURES[feat_name] check = Check(f"feature:{feat_name}", "advisory", ("linux", "darwin")) @@ -778,40 +567,48 @@ def _make_feature_check( return check, _run -CHECKS: list[tuple[Check, Callable[[Args], CheckResult]]] = [ - (Check("python_version", "blocker", ("linux", "darwin")), python_version_check), - (Check("uv_installed", "blocker", ("linux", "darwin")), uv_installed_check), - (Check("venv_consistent", "blocker", ("linux", "darwin")), venv_consistent_check), - (Check("sol_importable", "blocker", ("linux", "darwin")), sol_importable_check), - ( - Check("local_bin_sol_reachable", "advisory", ("linux", "darwin")), - local_bin_sol_reachable_check, - ), - (Check("disk_space", "advisory", ("linux", "darwin")), disk_space_check), - ( - Check("config_dir_readable", "blocker", ("linux", "darwin")), - config_dir_readable_check, - ), - ( - Check("stale_alias_symlink", "blocker", ("linux", "darwin")), - stale_alias_symlink_check, - ), - ( - Check("launchd_stale_plist", "advisory", ("darwin",)), - launchd_stale_plist_check, - ), - (Check("journal_sync", "blocker", ("linux", "darwin")), journal_sync_check), +FEATURE_CHECKS: dict[str, tuple[Check, Runner]] = { + name: _make_feature_check(name) for name in _features.FEATURES +} + +UNIVERSAL_CHECKS: list[tuple[Check, Runner]] = [ + (PYTHON_VERSION_CHECK, python_sanity_check), + (SOL_IMPORTABLE_CHECK, sol_importable_check), + (LOCAL_BIN_SOL_REACHABLE_CHECK, local_bin_sol_reachable_check), + (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="sol")), ] -for _feat_name in _features.FEATURES: - CHECKS.append(_make_feature_check(_feat_name)) +JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ + (DISK_SPACE_CHECK, disk_space_check), + (CONFIG_DIR_READABLE_CHECK, config_dir_readable_check), + (JOURNAL_DIR_WRITABLE_CHECK, journal_dir_writable_journal), + (SERVICE_IDENTITY_CHECK, service_identity_check), + (SERVICE_RUNNING_CHECK, service_running_check), + (JOURNAL_SYNC_CHECK, journal_sync_check), + (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="journal")), + (LAUNCHD_STALE_PLIST_CHECK, launchd_stale_plist_check), + *FEATURE_CHECKS.values(), +] -CHECK_MAP = {check.name: check for check, _func in CHECKS} +READINESS_CHECKS: list[tuple[Check, Runner]] = [ + (PYTHON_VERSION_CHECK, python_sanity_check), + (SOL_IMPORTABLE_CHECK, sol_importable_check), + (LOCAL_BIN_SOL_REACHABLE_CHECK, local_bin_sol_reachable_check), + (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="sol")), + (DISK_SPACE_CHECK, disk_space_check), + (JOURNAL_DIR_WRITABLE_CHECK, journal_dir_writable_readiness), + *FEATURE_CHECKS.values(), +] + +_ALL_CHECKS = UNIVERSAL_CHECKS + JOURNAL_CHECKS + READINESS_CHECKS +CHECK_MAP: dict[str, Check] = {} +for _check, _runner in _ALL_CHECKS: + CHECK_MAP.setdefault(_check.name, _check) def parse_args(argv: Sequence[str] | None = None) -> Args: parser = argparse.ArgumentParser( - description="Run pre-install diagnostics for solstone.", + description="Run solstone diagnostics.", epilog=( "If 'sol doctor' is unavailable (e.g. before 'make install' completes), " "run 'python3 scripts/doctor.py' from the repo root for the same diagnostic." @@ -834,6 +631,11 @@ def parse_args(argv: Sequence[str] | None = None) -> Args: default=None, help=f"Run only the named feature check ({', '.join(sorted(_features.FEATURES))})", ) + parser.add_argument( + "--readiness", + action="store_true", + help="run the setup readiness battery", + ) namespace = parser.parse_args(argv) if namespace.json and namespace.jsonl: parser.error("--json and --jsonl are mutually exclusive") @@ -846,14 +648,26 @@ def parse_args(argv: Sequence[str] | None = None) -> Args: jsonl=namespace.jsonl, port=namespace.port, feature=namespace.feature, + readiness=namespace.readiness, ) -def run_checks(args: Args) -> list[CheckResult]: +def select_battery(args: Args) -> list[tuple[Check, Runner]]: + if args.readiness: + return READINESS_CHECKS + if sys.argv[0] == "journal doctor": + return JOURNAL_CHECKS + return UNIVERSAL_CHECKS + + +def run_checks( + args: Args, + checks: list[tuple[Check, Runner]] | None = None, +) -> list[CheckResult]: current_platform = platform_tag() if args.feature is not None: - check_name = f"feature:{args.feature}" - check = CHECK_MAP[check_name] + check_name = args.feature + check, runner = FEATURE_CHECKS[check_name] if current_platform not in check.platforms: return [ make_result( @@ -863,13 +677,11 @@ def run_checks(args: Args) -> list[CheckResult]: platform=current_platform, ) ] - for candidate, func in CHECKS: - if candidate.name == check_name: - return [func(args)] - raise RuntimeError(f"missing check runner for {check_name}") + return [runner(args)] + selected_checks = select_battery(args) if checks is None else checks results: list[CheckResult] = [] - for check, func in CHECKS: + for check, func in selected_checks: if current_platform not in check.platforms: results.append( make_result( diff --git a/solstone/think/preflight.py b/solstone/think/preflight.py index a1d442a00..6420078f3 100644 --- a/solstone/think/preflight.py +++ b/solstone/think/preflight.py @@ -3,10 +3,8 @@ """Stdlib-only install-readiness checks for `make preflight`. -This battery can run before `.venv` or `uv` exist. It is an intentional, -temporary duplication of a subset of `solstone/think/doctor.py`; a future lode -will unify doctor and preflight onto shared stdlib code. See doctor.py's -decision-log for the source rationale. +This battery can run before `.venv` or `uv` exist. It composes the stdlib-only +checks from `solstone.think.probe`. Exit code `0` means no blocker-severity check failed; exit code `1` means at least one blocker-severity check failed. @@ -16,35 +14,28 @@ from __future__ import annotations import argparse import json -import os -import re -import shutil -import subprocess -import sys from dataclasses import dataclass -from importlib.metadata import PackageNotFoundError, distribution -from pathlib import Path -from typing import Callable, Literal, Sequence - -# These constants and the check bodies below are duplicated verbatim from -# `solstone/think/doctor.py` (see its decision-log for the MIN_UV=0.7.12 and -# MIN_FREE_GIB=10 rationale). Keep them in sync until the doctor/preflight unify -# lode reconciles them. -ROOT = Path(__file__).resolve().parents[2] -MIN_UV = (0, 7, 12) -MIN_FREE_GIB = 10.0 -DEFAULT_REQUIRES_PYTHON = ">=3.11" -PYTHON_VERSION_FIX = "install Python >=3.11, then retry" -LOCAL_BIN_SOL_FIX = ( - "Install via `uv tool install solstone` or `pipx install solstone` for the " - "canonical layout, or run `ln -s $(command -v sol) ~/.local/bin/sol` to keep " - "your custom layout." +from typing import Callable, Sequence + +from solstone.think.probe import ( + CONFIG_DIR_READABLE_CHECK, + DISK_SPACE_CHECK, + LOCAL_BIN_SOL_REACHABLE_CHECK, + PYTHON_VERSION_CHECK, + UV_INSTALLED_CHECK, + VENV_CONSISTENT_CHECK, + Check, + CheckResult, + config_dir_readable_check, + disk_space_check, + local_bin_sol_reachable_check, + make_result, + platform_tag, + python_version_check, + uv_installed_check, + venv_consistent_check, ) -Severity = Literal["blocker", "advisory"] -Status = Literal["ok", "fail", "warn", "skip"] -Platform = Literal["linux", "darwin"] - @dataclass(frozen=True) class Args: @@ -52,418 +43,13 @@ class Args: json: bool -@dataclass(frozen=True) -class Check: - name: str - severity: Severity - platforms: tuple[Platform, ...] - - -@dataclass(frozen=True) -class CheckResult: - name: str - severity: Severity - status: Status - detail: str - fix: str | None - platform: str | None = None - - -@dataclass(frozen=True) -class ProbeOutput: - stdout: str - stderr: str - returncode: int - - -def platform_tag() -> Platform: - if sys.platform == "darwin": - return "darwin" - return "linux" - - -def make_result( - check: Check, - status: Status, - detail: str, - fix: str | None = None, - *, - platform: str | None = None, -) -> CheckResult: - return CheckResult( - name=check.name, - severity=check.severity, - status=status, - detail=detail, - fix=fix, - platform=platform, - ) - - -def truncate(text: str, limit: int) -> str: - text = " ".join(text.split()) - if len(text) <= limit: - return text - return text[: limit - 3] + "..." - - -def version_text(version: tuple[int, int, int]) -> str: - return ".".join(str(part) for part in version) - - -def parse_version(text: str) -> tuple[int, int, int] | None: - match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) - if not match: - return None - return tuple(int(part) for part in match.groups()) - - -def compare_versions(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: - if left < right: - return -1 - if left > right: - return 1 - return 0 - - -def unexpected_output_result( - check: Check, - output: str, - *, - fix: str | None = None, -) -> CheckResult: - snippet = truncate(output or "", 80) - return make_result( - check, - "fail", - f"probe returned unexpected output: {snippet}", - fix, - ) - - -def command_text(cmd: Sequence[str]) -> str: - return " ".join(cmd) - - -def run_probe( - check: Check, - cmd: Sequence[str], - *, - timeout: float, - cwd: Path | None = None, - env: dict[str, str] | None = None, - ok_returncodes: tuple[int, ...] = (0,), - allow_nonzero: bool = False, - allow_empty_stdout: bool = False, - fix: str | None = None, -) -> ProbeOutput | CheckResult: - merged_env = os.environ.copy() - if env: - merged_env.update(env) - try: - completed = subprocess.run( - list(cmd), - capture_output=True, - text=True, - timeout=timeout, - cwd=str(cwd) if cwd else None, - env=merged_env, - check=False, - ) - except FileNotFoundError: - return make_result(check, "fail", f"probe command not found: {cmd[0]}", fix) - except subprocess.TimeoutExpired: - return make_result( - check, - "fail", - f"probe timed out after {timeout:g}s: {command_text(cmd)}", - fix, - ) - except OSError as exc: - return make_result( - check, - "fail", - f"probe failed: {type(exc).__name__}: {exc}", - fix, - ) - - if completed.returncode not in ok_returncodes and not allow_nonzero: - detail = completed.stderr.strip() or completed.stdout.strip() or "" - return make_result( - check, - "fail", - f"probe exited {completed.returncode}: {truncate(detail, 80)}", - fix, - ) - - if not allow_empty_stdout and not completed.stdout.strip(): - return unexpected_output_result( - check, - completed.stderr.strip() or completed.stdout.strip(), - fix=fix, - ) - - return ProbeOutput( - stdout=completed.stdout, - stderr=completed.stderr, - returncode=completed.returncode, - ) - - -def _is_source_checkout() -> bool: - # Inline solstone.think.utils.is_source_checkout; importing utils pulls timefhuman. - return (ROOT / "pyproject.toml").exists() and (ROOT / ".git").exists() - - -def python_version_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["python_version"] - pyproject = ROOT / "pyproject.toml" - spec_from_metadata = False - try: - text = pyproject.read_text(encoding="utf-8") - match = re.search(r'^requires-python\s*=\s*"([^"]+)"', text, re.MULTILINE) - if not match: - return make_result( - check, - "fail", - "could not parse requires-python from pyproject.toml", - PYTHON_VERSION_FIX, - ) - spec = match.group(1) - except FileNotFoundError: - spec_from_metadata = True - try: - spec = distribution("solstone").metadata.get("Requires-Python") - except PackageNotFoundError: - spec = None - if not spec: - spec = DEFAULT_REQUIRES_PYTHON - except OSError as exc: - return make_result( - check, - "fail", - f"could not read {pyproject.name}: {type(exc).__name__}: {exc}", - PYTHON_VERSION_FIX, - ) - min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) - if not min_match: - if spec_from_metadata: - spec = DEFAULT_REQUIRES_PYTHON - min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) - if not min_match: - return make_result( - check, - "fail", - f"unsupported requires-python specifier: {spec}", - PYTHON_VERSION_FIX, - ) - minimum = ( - int(min_match.group(1)), - int(min_match.group(2)), - int(min_match.group(3) or 0), - ) - current = sys.version_info[:3] - if compare_versions(current, minimum) < 0: - return make_result( - check, - "fail", - f"python {version_text(current)} does not satisfy {spec}", - "install Python >=3.11, then `rm -rf .venv .installed && make install`", - ) - return make_result( - check, - "ok", - f"python {version_text(current)} satisfies {spec}", - ) - - -def uv_installed_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["uv_installed"] - if not _is_source_checkout(): - return make_result( - check, - "skip", - "uv is only required for source-checkout development", - ) - fix = "curl -LsSf https://astral.sh/uv/install.sh | sh" - probe = run_probe(check, ["uv", "--version"], timeout=0.5, fix=fix) - if isinstance(probe, CheckResult): - return probe - version = parse_version(probe.stdout) - if version is None: - return unexpected_output_result(check, probe.stdout, fix=fix) - if compare_versions(version, MIN_UV) < 0: - return make_result( - check, - "fail", - f"uv {version_text(version)} is older than required {version_text(MIN_UV)}", - fix, - ) - return make_result( - check, - "ok", - f"uv {version_text(version)} >= {version_text(MIN_UV)}", - ) - - -def venv_consistent_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["venv_consistent"] - if not _is_source_checkout(): - return make_result( - check, - "skip", - "packaged install: env managed by uv tool / pipx", - ) - python_bin = ROOT / ".venv" / "bin" / "python" - expected = (ROOT / ".venv").resolve() - if not python_bin.exists(): - return make_result( - check, - "skip", - ".venv absent; run make install", - ) - probe = run_probe( - check, - [str(python_bin), "-c", "import sys; print(sys.prefix)"], - timeout=0.5, - fix="rm -rf .venv .installed && make install", - ) - if isinstance(probe, CheckResult): - return probe - prefix_text = probe.stdout.strip() - if not prefix_text: - return unexpected_output_result( - check, - probe.stdout, - fix="rm -rf .venv .installed && make install", - ) - actual = Path(prefix_text).resolve() - if actual != expected: - return make_result( - check, - "fail", - f".venv points at {actual}, expected {expected}", - "rm -rf .venv .installed && make install", - ) - return make_result(check, "ok", f".venv points at this repo ({expected})") - - -def local_bin_sol_reachable_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["local_bin_sol_reachable"] - local = Path.home() / ".local" / "bin" / "sol" - which = shutil.which("sol") - if local.exists() and local.is_file() and which is not None: - which_path = Path(which) - local_resolved = local.resolve() - which_resolved = which_path.resolve() - if ( - which_path != local - and local.is_symlink() - and local_resolved == which_resolved - ): - return make_result( - check, - "ok", - f"~/.local/bin/sol symlinks to PATH sol at {which}", - ) - if which_resolved == local_resolved: - return make_result( - check, - "ok", - f"~/.local/bin/sol is on PATH at {local}", - ) - - failures: list[str] = [] - if not local.exists(): - failures.append(f"{local} is missing") - elif not local.is_file(): - failures.append(f"{local} is not a file") - if which is None: - failures.append("sol is not on PATH") - else: - try: - failures.append( - f"PATH sol resolves to {Path(which).resolve()}, expected {local.resolve()}" - ) - except OSError: - failures.append(f"PATH sol is {which}, but it could not be resolved") - return make_result(check, "warn", "; ".join(failures), LOCAL_BIN_SOL_FIX) - - -def disk_space_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["disk_space"] - usage = shutil.disk_usage(ROOT) - free_gib = usage.free / (1024**3) - if free_gib < MIN_FREE_GIB: - return make_result( - check, - "warn", - f"only {free_gib:.1f} GiB free on the repo filesystem (<{MIN_FREE_GIB:.0f} GiB)", - "free disk on the repo filesystem before `make install`", - ) - return make_result( - check, - "ok", - f"{free_gib:.1f} GiB free (>= {MIN_FREE_GIB:.0f} GiB)", - ) - - -def config_dir_readable_check(args: Args) -> CheckResult: - del args - check = CHECK_MAP["config_dir_readable"] - home = Path.home() - if not home.exists(): - return make_result( - check, - "fail", - f"home directory does not exist: {home}", - f"fix ownership/permissions of {home}", - ) - required_access = os.R_OK | os.W_OK | os.X_OK - if not os.access(home, required_access): - return make_result( - check, - "fail", - f"home directory is not readable and writable: {home}", - f"fix ownership/permissions of {home}", - ) - current_platform = platform_tag() - if current_platform == "darwin": - config_dir = home / "Library" / "LaunchAgents" - else: - config_dir = home / ".config" - if config_dir.exists() and not os.access(config_dir, required_access): - return make_result( - check, - "fail", - f"service config directory is not writable: {config_dir}", - f"fix ownership/permissions of {config_dir}", - ) - if config_dir.exists(): - detail = f"home and service config dir are writable ({config_dir})" - else: - detail = f"home is writable; install will create {config_dir}" - return make_result(check, "ok", detail) - - CHECKS: list[tuple[Check, Callable[[Args], CheckResult]]] = [ - (Check("python_version", "blocker", ("linux", "darwin")), python_version_check), - (Check("uv_installed", "blocker", ("linux", "darwin")), uv_installed_check), - (Check("venv_consistent", "blocker", ("linux", "darwin")), venv_consistent_check), - ( - Check("local_bin_sol_reachable", "advisory", ("linux", "darwin")), - local_bin_sol_reachable_check, - ), - (Check("disk_space", "advisory", ("linux", "darwin")), disk_space_check), - ( - Check("config_dir_readable", "blocker", ("linux", "darwin")), - config_dir_readable_check, - ), + (PYTHON_VERSION_CHECK, python_version_check), + (UV_INSTALLED_CHECK, uv_installed_check), + (VENV_CONSISTENT_CHECK, venv_consistent_check), + (LOCAL_BIN_SOL_REACHABLE_CHECK, local_bin_sol_reachable_check), + (DISK_SPACE_CHECK, disk_space_check), + (CONFIG_DIR_READABLE_CHECK, config_dir_readable_check), ] CHECK_MAP = {check.name: check for check, _func in CHECKS} diff --git a/solstone/think/probe.py b/solstone/think/probe.py new file mode 100644 index 000000000..59e964210 --- /dev/null +++ b/solstone/think/probe.py @@ -0,0 +1,446 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Stdlib-only shared probe/check base for preflight.py and doctor.py. + +This module must import only the Python standard library so install-readiness +checks can run before `.venv` or `uv` exist. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path +from typing import Literal, Sequence + +# See doctor.py's decision-log for the MIN_UV=0.7.12 and MIN_FREE_GIB=10 +# rationale. +ROOT = Path(__file__).resolve().parents[2] +MIN_UV = (0, 7, 12) +MIN_FREE_GIB = 10.0 +DEFAULT_REQUIRES_PYTHON = ">=3.11" +PYTHON_VERSION_FIX = "install Python >=3.11, then retry" +LOCAL_BIN_SOL_FIX = ( + "Install via `uv tool install solstone` or `pipx install solstone` for the " + "canonical layout, or run `ln -s $(command -v sol) ~/.local/bin/sol` to keep " + "your custom layout." +) + +Severity = Literal["blocker", "advisory"] +Status = Literal["ok", "fail", "warn", "skip"] +Platform = Literal["linux", "darwin"] + + +@dataclass(frozen=True) +class Check: + name: str + severity: Severity + platforms: tuple[Platform, ...] + + +@dataclass(frozen=True) +class CheckResult: + name: str + severity: Severity + status: Status + detail: str + fix: str | None + platform: str | None = None + + +@dataclass(frozen=True) +class ProbeOutput: + stdout: str + stderr: str + returncode: int + + +PYTHON_VERSION_CHECK = Check("python_version", "blocker", ("linux", "darwin")) +UV_INSTALLED_CHECK = Check("uv_installed", "blocker", ("linux", "darwin")) +VENV_CONSISTENT_CHECK = Check("venv_consistent", "blocker", ("linux", "darwin")) +LOCAL_BIN_SOL_REACHABLE_CHECK = Check( + "local_bin_sol_reachable", "advisory", ("linux", "darwin") +) +DISK_SPACE_CHECK = Check("disk_space", "advisory", ("linux", "darwin")) +CONFIG_DIR_READABLE_CHECK = Check("config_dir_readable", "blocker", ("linux", "darwin")) + + +def platform_tag() -> Platform: + if sys.platform == "darwin": + return "darwin" + return "linux" + + +def make_result( + check: Check, + status: Status, + detail: str, + fix: str | None = None, + *, + platform: str | None = None, +) -> CheckResult: + return CheckResult( + name=check.name, + severity=check.severity, + status=status, + detail=detail, + fix=fix, + platform=platform, + ) + + +def truncate(text: str, limit: int) -> str: + text = " ".join(text.split()) + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +def version_text(version: tuple[int, int, int]) -> str: + return ".".join(str(part) for part in version) + + +def parse_version(text: str) -> tuple[int, int, int] | None: + match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) + if not match: + return None + return tuple(int(part) for part in match.groups()) + + +def compare_versions(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: + if left < right: + return -1 + if left > right: + return 1 + return 0 + + +def unexpected_output_result( + check: Check, + output: str, + *, + fix: str | None = None, +) -> CheckResult: + snippet = truncate(output or "", 80) + return make_result( + check, + "fail", + f"probe returned unexpected output: {snippet}", + fix, + ) + + +def command_text(cmd: Sequence[str]) -> str: + return " ".join(cmd) + + +def run_probe( + check: Check, + cmd: Sequence[str], + *, + timeout: float, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ok_returncodes: tuple[int, ...] = (0,), + allow_nonzero: bool = False, + allow_empty_stdout: bool = False, + fix: str | None = None, +) -> ProbeOutput | CheckResult: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + try: + completed = subprocess.run( + list(cmd), + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd) if cwd else None, + env=merged_env, + check=False, + ) + except FileNotFoundError: + return make_result(check, "fail", f"probe command not found: {cmd[0]}", fix) + except subprocess.TimeoutExpired: + return make_result( + check, + "fail", + f"probe timed out after {timeout:g}s: {command_text(cmd)}", + fix, + ) + except OSError as exc: + return make_result( + check, + "fail", + f"probe failed: {type(exc).__name__}: {exc}", + fix, + ) + + if completed.returncode not in ok_returncodes and not allow_nonzero: + detail = completed.stderr.strip() or completed.stdout.strip() or "" + return make_result( + check, + "fail", + f"probe exited {completed.returncode}: {truncate(detail, 80)}", + fix, + ) + + if not allow_empty_stdout and not completed.stdout.strip(): + return unexpected_output_result( + check, + completed.stderr.strip() or completed.stdout.strip(), + fix=fix, + ) + + return ProbeOutput( + stdout=completed.stdout, + stderr=completed.stderr, + returncode=completed.returncode, + ) + + +def _is_source_checkout() -> bool: + # Inline solstone.think.utils.is_source_checkout; importing utils pulls timefhuman. + return (ROOT / "pyproject.toml").exists() and (ROOT / ".git").exists() + + +def python_version_check(args: object) -> CheckResult: + del args + check = PYTHON_VERSION_CHECK + pyproject = ROOT / "pyproject.toml" + spec_from_metadata = False + try: + text = pyproject.read_text(encoding="utf-8") + match = re.search(r'^requires-python\s*=\s*"([^"]+)"', text, re.MULTILINE) + if not match: + return make_result( + check, + "fail", + "could not parse requires-python from pyproject.toml", + PYTHON_VERSION_FIX, + ) + spec = match.group(1) + except FileNotFoundError: + spec_from_metadata = True + try: + spec = distribution("solstone").metadata.get("Requires-Python") + except PackageNotFoundError: + spec = None + if not spec: + spec = DEFAULT_REQUIRES_PYTHON + except OSError as exc: + return make_result( + check, + "fail", + f"could not read {pyproject.name}: {type(exc).__name__}: {exc}", + PYTHON_VERSION_FIX, + ) + min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) + if not min_match: + if spec_from_metadata: + spec = DEFAULT_REQUIRES_PYTHON + min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) + if not min_match: + return make_result( + check, + "fail", + f"unsupported requires-python specifier: {spec}", + PYTHON_VERSION_FIX, + ) + minimum = ( + int(min_match.group(1)), + int(min_match.group(2)), + int(min_match.group(3) or 0), + ) + current = sys.version_info[:3] + if compare_versions(current, minimum) < 0: + return make_result( + check, + "fail", + f"python {version_text(current)} does not satisfy {spec}", + "install Python >=3.11, then `rm -rf .venv .installed && make install`", + ) + return make_result( + check, + "ok", + f"python {version_text(current)} satisfies {spec}", + ) + + +def uv_installed_check(args: object) -> CheckResult: + del args + check = UV_INSTALLED_CHECK + if not _is_source_checkout(): + return make_result( + check, + "skip", + "uv is only required for source-checkout development", + ) + fix = "curl -LsSf https://astral.sh/uv/install.sh | sh" + probe = run_probe(check, ["uv", "--version"], timeout=0.5, fix=fix) + if isinstance(probe, CheckResult): + return probe + version = parse_version(probe.stdout) + if version is None: + return unexpected_output_result(check, probe.stdout, fix=fix) + if compare_versions(version, MIN_UV) < 0: + return make_result( + check, + "fail", + f"uv {version_text(version)} is older than required {version_text(MIN_UV)}", + fix, + ) + return make_result( + check, + "ok", + f"uv {version_text(version)} >= {version_text(MIN_UV)}", + ) + + +def venv_consistent_check(args: object) -> CheckResult: + del args + check = VENV_CONSISTENT_CHECK + if not _is_source_checkout(): + return make_result( + check, + "skip", + "packaged install: env managed by uv tool / pipx", + ) + python_bin = ROOT / ".venv" / "bin" / "python" + expected = (ROOT / ".venv").resolve() + if not python_bin.exists(): + return make_result( + check, + "skip", + ".venv absent; run make install", + ) + probe = run_probe( + check, + [str(python_bin), "-c", "import sys; print(sys.prefix)"], + timeout=0.5, + fix="rm -rf .venv .installed && make install", + ) + if isinstance(probe, CheckResult): + return probe + prefix_text = probe.stdout.strip() + if not prefix_text: + return unexpected_output_result( + check, + probe.stdout, + fix="rm -rf .venv .installed && make install", + ) + actual = Path(prefix_text).resolve() + if actual != expected: + return make_result( + check, + "fail", + f".venv points at {actual}, expected {expected}", + "rm -rf .venv .installed && make install", + ) + return make_result(check, "ok", f".venv points at this repo ({expected})") + + +def local_bin_sol_reachable_check(args: object) -> CheckResult: + del args + check = LOCAL_BIN_SOL_REACHABLE_CHECK + local = Path.home() / ".local" / "bin" / "sol" + which = shutil.which("sol") + if local.exists() and local.is_file() and which is not None: + which_path = Path(which) + local_resolved = local.resolve() + which_resolved = which_path.resolve() + if ( + which_path != local + and local.is_symlink() + and local_resolved == which_resolved + ): + return make_result( + check, + "ok", + f"~/.local/bin/sol symlinks to PATH sol at {which}", + ) + if which_resolved == local_resolved: + return make_result( + check, + "ok", + f"~/.local/bin/sol is on PATH at {local}", + ) + + failures: list[str] = [] + if not local.exists(): + failures.append(f"{local} is missing") + elif not local.is_file(): + failures.append(f"{local} is not a file") + if which is None: + failures.append("sol is not on PATH") + else: + try: + failures.append( + f"PATH sol resolves to {Path(which).resolve()}, expected {local.resolve()}" + ) + except OSError: + failures.append(f"PATH sol is {which}, but it could not be resolved") + return make_result(check, "warn", "; ".join(failures), LOCAL_BIN_SOL_FIX) + + +def disk_space_check(args: object) -> CheckResult: + del args + check = DISK_SPACE_CHECK + usage = shutil.disk_usage(ROOT) + free_gib = usage.free / (1024**3) + if free_gib < MIN_FREE_GIB: + return make_result( + check, + "warn", + f"only {free_gib:.1f} GiB free on the repo filesystem (<{MIN_FREE_GIB:.0f} GiB)", + "free disk on the repo filesystem before `make install`", + ) + return make_result( + check, + "ok", + f"{free_gib:.1f} GiB free (>= {MIN_FREE_GIB:.0f} GiB)", + ) + + +def config_dir_readable_check(args: object) -> CheckResult: + del args + check = CONFIG_DIR_READABLE_CHECK + home = Path.home() + if not home.exists(): + return make_result( + check, + "fail", + f"home directory does not exist: {home}", + f"fix ownership/permissions of {home}", + ) + required_access = os.R_OK | os.W_OK | os.X_OK + if not os.access(home, required_access): + return make_result( + check, + "fail", + f"home directory is not readable and writable: {home}", + f"fix ownership/permissions of {home}", + ) + current_platform = platform_tag() + if current_platform == "darwin": + config_dir = home / "Library" / "LaunchAgents" + else: + config_dir = home / ".config" + if config_dir.exists() and not os.access(config_dir, required_access): + return make_result( + check, + "fail", + f"service config directory is not writable: {config_dir}", + f"fix ownership/permissions of {config_dir}", + ) + if config_dir.exists(): + detail = f"home and service config dir are writable ({config_dir})" + else: + detail = f"home is writable; install will create {config_dir}" + return make_result(check, "ok", detail) diff --git a/solstone/think/service.py b/solstone/think/service.py index 2a530a9e6..cec26ffb2 100644 --- a/solstone/think/service.py +++ b/solstone/think/service.py @@ -65,7 +65,7 @@ class ServiceTargetIdentity: def _ready_timeout_message() -> str: return ( f"Service did not become ready within {READY_TIMEOUT_SECONDS:g}s — " - "run 'journal service status' or 'sol doctor' for diagnostics" + "run 'journal service status' or 'journal doctor' for diagnostics" ) @@ -119,6 +119,40 @@ def service_is_running() -> bool: return result.stdout.strip() == "active" +def service_is_failed() -> bool: + """Return whether the installed user service is in a failed state.""" + if not service_is_installed(): + return False + if _platform() == "darwin": + try: + result = subprocess.run( + ["launchctl", "print", f"gui/{os.getuid()}/{SERVICE_LABEL}"], + capture_output=True, + text=True, + timeout=2.0, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + if result.returncode != 0: + return False + return ( + "\n\tstate = crashed\n" in result.stdout + or "state = crashed" in result.stdout + ) + try: + result = subprocess.run( + ["systemctl", "--user", "is-failed", SYSTEMD_UNIT], + capture_output=True, + text=True, + timeout=2.0, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.stdout.strip() == "failed" + + def _collect_env() -> dict[str, str]: """Collect environment variables for the service file. diff --git a/solstone/think/setup.py b/solstone/think/setup.py index 188077848..ddb65fd48 100644 --- a/solstone/think/setup.py +++ b/solstone/think/setup.py @@ -735,6 +735,7 @@ def doctor_command(ctx: SetupContext, *, jsonl: bool = False) -> list[str]: "-m", "solstone.think.sol_cli", "doctor", + "--readiness", "--jsonl" if jsonl else "--json", "--port", str(ctx.port), diff --git a/solstone/think/sol_cli.py b/solstone/think/sol_cli.py index e2fd89427..7564cd895 100644 --- a/solstone/think/sol_cli.py +++ b/solstone/think/sol_cli.py @@ -41,13 +41,13 @@ import setproctitle class Command(NamedTuple): module: str - surface: Literal["access", "service"] + surface: Literal["access", "service", "universal"] class Alias(NamedTuple): module: str preset_args: list[str] - surface: Literal["access", "service"] + surface: Literal["access", "service", "universal"] class HelpGroup(NamedTuple): @@ -84,7 +84,7 @@ COMMANDS: dict[str, Command] = { "top": Command("solstone.think.top", "access"), "health": Command("solstone.think.health_cli", "access"), "notify": Command("solstone.think.notify_cli", "access"), - "doctor": Command("solstone.think.doctor", "access"), + "doctor": Command("solstone.think.doctor", "universal"), "config": Command("solstone.think.config_cli", "service"), "install-models": Command("solstone.think.install_models", "service"), "skills": Command("solstone.think.skills_cli", "access"), @@ -244,7 +244,7 @@ def print_journal_help() -> None: print("Commands:") for name, command in sorted(COMMANDS.items()): - if command.surface == "service": + if command.surface in ("service", "universal"): print(f" {name:16} {command.module}") print() @@ -442,7 +442,7 @@ def main() -> None: def journal_main() -> None: """Main entry point for journal service CLI.""" - _dispatch("journal", allowed_surfaces=frozenset({"service"})) + _dispatch("journal", allowed_surfaces=frozenset({"service", "universal"})) if __name__ == "__main__": diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a8047179c..9879850d2 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -5,7 +5,6 @@ from __future__ import annotations import json import os -import plistlib import subprocess import sys from pathlib import Path @@ -14,6 +13,7 @@ from types import SimpleNamespace import pytest from solstone.think import install_guard +from solstone.think.probe import ProbeOutput ROOT = Path(__file__).resolve().parent.parent @@ -101,81 +101,16 @@ def test_install_guard_import_succeeds_when_frontmatter_is_shadowed(tmp_path): class TestPythonVersion: def test_ok(self, doctor): - result = doctor.python_version_check(args(doctor)) + result = doctor.python_sanity_check(args(doctor)) assert result.status == "ok" def test_fail_when_too_old(self, doctor, monkeypatch): monkeypatch.setattr(doctor.sys, "version_info", (3, 9, 18)) - result = doctor.python_version_check(args(doctor)) + result = doctor.python_sanity_check(args(doctor)) assert result.status == "fail" assert "does not satisfy" in result.detail -class TestUvInstalled: - def test_ok(self, doctor, monkeypatch): - monkeypatch.setattr( - doctor, - "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput("uv 0.10.0\n", "", 0), - ) - result = doctor.uv_installed_check(args(doctor)) - assert result.status == "ok" - - def test_missing(self, doctor, monkeypatch): - def raise_missing(*_args, **_kwargs): - raise FileNotFoundError - - monkeypatch.setattr(doctor.subprocess, "run", raise_missing) - result = doctor.uv_installed_check(args(doctor)) - assert result.status == "fail" - assert "probe command not found" in result.detail - - def test_fail_when_too_old(self, doctor, monkeypatch): - monkeypatch.setattr( - doctor, - "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput("uv 0.7.0\n", "", 0), - ) - result = doctor.uv_installed_check(args(doctor)) - assert result.status == "fail" - assert "older than required" in result.detail - - -class TestVenvConsistent: - def test_skip_when_absent(self, doctor, monkeypatch, tmp_path): - monkeypatch.setattr(doctor, "ROOT", tmp_path) - result = doctor.venv_consistent_check(args(doctor)) - assert result.status == "skip" - - def test_ok_when_consistent(self, doctor, monkeypatch, tmp_path): - monkeypatch.setattr(doctor, "ROOT", tmp_path) - python_bin = tmp_path / ".venv" / "bin" / "python" - python_bin.parent.mkdir(parents=True) - python_bin.write_text("", encoding="utf-8") - monkeypatch.setattr( - doctor, - "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput( - f"{tmp_path / '.venv'}\n", "", 0 - ), - ) - result = doctor.venv_consistent_check(args(doctor)) - assert result.status == "ok" - - def test_fail_when_inconsistent(self, doctor, monkeypatch, tmp_path): - monkeypatch.setattr(doctor, "ROOT", tmp_path) - python_bin = tmp_path / ".venv" / "bin" / "python" - python_bin.parent.mkdir(parents=True) - python_bin.write_text("", encoding="utf-8") - monkeypatch.setattr( - doctor, - "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput("/tmp/elsewhere\n", "", 0), - ) - result = doctor.venv_consistent_check(args(doctor)) - assert result.status == "fail" - - class TestSolImportable: def test_skip_when_absent(self, doctor, monkeypatch, tmp_path): monkeypatch.setattr(doctor, "ROOT", tmp_path) @@ -190,7 +125,7 @@ class TestSolImportable: monkeypatch.setattr( doctor, "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput("", "", 0), + lambda *_args, **_kwargs: ProbeOutput("", "", 0), ) result = doctor.sol_importable_check(args(doctor)) assert result.status == "ok" @@ -207,7 +142,7 @@ class TestSolImportable: monkeypatch.setattr( doctor, "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput( + lambda *_args, **_kwargs: ProbeOutput( "", "Traceback (most recent call last):\nModuleNotFoundError: No module named 'solstone'\n", 1, @@ -225,7 +160,7 @@ class TestSolImportable: monkeypatch.setattr( doctor, "run_probe", - lambda *_args, **_kwargs: doctor.ProbeOutput( + lambda *_args, **_kwargs: ProbeOutput( "", "SyntaxError: broken import\n", 1 ), ) @@ -249,27 +184,11 @@ class TestPackagedInstall: lambda name: SimpleNamespace(metadata={"Requires-Python": ">=3.11"}), ) - result = doctor.python_version_check(args(doctor)) + result = doctor.python_sanity_check(args(doctor)) assert result.status == "ok" assert ">=3.11" in result.detail - def test_uv_installed_skips(self, doctor, monkeypatch, tmp_path): - self.setup_packaged(doctor, monkeypatch, tmp_path) - - result = doctor.uv_installed_check(args(doctor)) - - assert result.status == "skip" - assert result.detail == "uv is only required for source-checkout development" - - def test_venv_consistent_skips(self, doctor, monkeypatch, tmp_path): - self.setup_packaged(doctor, monkeypatch, tmp_path) - - result = doctor.venv_consistent_check(args(doctor)) - - assert result.status == "skip" - assert result.detail == "packaged install: env managed by uv tool / pipx" - def test_sol_importable_uses_in_process_import(self, doctor, monkeypatch, tmp_path): self.setup_packaged(doctor, monkeypatch, tmp_path) @@ -278,89 +197,22 @@ class TestPackagedInstall: assert result.status == "ok" assert result.detail == "import solstone succeeded in packaged install" - def test_local_bin_sol_reachable_canonical_pass( - self, doctor, monkeypatch, home_root - ): - local = home_root / ".local" / "bin" / "sol" - local.parent.mkdir(parents=True) - local.write_text("#!/bin/sh\n", encoding="utf-8") - monkeypatch.setattr(doctor.shutil, "which", lambda name: str(local)) - - result = doctor.local_bin_sol_reachable_check(args(doctor)) - - assert result.status == "ok" - assert "~/.local/bin/sol is on PATH" in result.detail - - def test_local_bin_sol_reachable_symlink_pass( - self, doctor, monkeypatch, home_root, tmp_path - ): - target = tmp_path / "usr" / "local" / "bin" / "sol" - target.parent.mkdir(parents=True) - target.write_text("#!/bin/sh\n", encoding="utf-8") - local = home_root / ".local" / "bin" / "sol" - local.parent.mkdir(parents=True) - local.symlink_to(target) - monkeypatch.setattr(doctor.shutil, "which", lambda name: str(target)) - - result = doctor.local_bin_sol_reachable_check(args(doctor)) - - assert result.status == "ok" - assert "symlinks to PATH sol" in result.detail - - def test_local_bin_sol_reachable_warns_when_missing( - self, doctor, monkeypatch, home_root - ): - monkeypatch.setattr(doctor.shutil, "which", lambda name: None) - - result = doctor.local_bin_sol_reachable_check(args(doctor)) - - assert result.status == "warn" - assert ".local/bin/sol" in result.detail - assert "uv tool install solstone" in (result.fix or "") - class TestPortCheckRemoved: def test_port_check_is_not_registered(self, doctor): assert "port_5015_free" not in doctor.CHECK_MAP - assert "port_5015_free" not in {check.name for check, _runner in doctor.CHECKS} - - -class TestDiskSpace: - def test_warn_when_low(self, doctor, monkeypatch): - monkeypatch.setattr( - doctor.shutil, - "disk_usage", - lambda _root: SimpleNamespace(total=100, used=95, free=5 * 1024**3), - ) - result = doctor.disk_space_check(args(doctor)) - assert result.status == "warn" - - def test_ok_when_sufficient(self, doctor, monkeypatch): - monkeypatch.setattr( - doctor.shutil, - "disk_usage", - lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), - ) - result = doctor.disk_space_check(args(doctor)) - assert result.status == "ok" - - -class TestConfigDirReadable: - def test_ok(self, doctor, monkeypatch, home_root): - config_dir = home_root / ".config" - config_dir.mkdir() - result = doctor.config_dir_readable_check(args(doctor)) - assert result.status == "ok" + assert "port_5015_free" not in { + check.name for check, _runner in doctor.UNIVERSAL_CHECKS + } - def test_fail_when_home_unwritable(self, doctor, monkeypatch, home_root): - def fake_access(path, mode): - if Path(path) == home_root: - return False - return True - monkeypatch.setattr(doctor.os, "access", fake_access) - result = doctor.config_dir_readable_check(args(doctor)) - assert result.status == "fail" +def test_default_universal_battery_check_names(doctor): + assert {check.name for check, _runner in doctor.UNIVERSAL_CHECKS} == { + "python_version", + "sol_importable", + "local_bin_sol_reachable", + "stale_alias_symlink", + } class TestStaleAliasSymlink: @@ -409,7 +261,7 @@ class TestStaleAliasSymlink: self.setup_import(doctor, monkeypatch) repo = make_repo(tmp_path) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" def test_owned_ok(self, doctor, monkeypatch, home_root, tmp_path): @@ -417,7 +269,7 @@ class TestStaleAliasSymlink: repo = make_repo(tmp_path) make_alias(home_root, ensure_expected_target(repo)) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" def test_cross_repo_fail(self, doctor, monkeypatch, home_root, tmp_path): @@ -425,7 +277,7 @@ class TestStaleAliasSymlink: repo = make_repo(tmp_path) make_alias(home_root, other_target(tmp_path)) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "fail" def test_dangling_fail(self, doctor, monkeypatch, home_root, tmp_path): @@ -434,7 +286,7 @@ class TestStaleAliasSymlink: missing = tmp_path / "missing" / ".venv" / "bin" / "sol" make_alias(home_root, missing) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "fail" def test_not_symlink_fail(self, doctor, monkeypatch, home_root, tmp_path): @@ -444,14 +296,14 @@ class TestStaleAliasSymlink: alias.parent.mkdir(parents=True, exist_ok=True) alias.write_text("not a symlink", encoding="utf-8") monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "fail" def test_worktree_skip(self, doctor, monkeypatch, home_root, tmp_path): self.setup_import(doctor, monkeypatch) repo = make_repo(tmp_path, worktree=True) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "skip" def test_import_failure_skips(self, doctor, monkeypatch): @@ -460,7 +312,7 @@ class TestStaleAliasSymlink: "import_install_guard", lambda: (_ for _ in ()).throw(ImportError("boom")), ) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "skip" assert "could not import solstone.think.install_guard" in result.detail @@ -473,13 +325,13 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" assert "auto-migrated" in result.detail assert "uv-tool" in result.detail self.assert_managed_wrapper(home_root, "sol", journal, fake_bin) - self.assert_managed_wrapper(home_root, "journal", journal, fake_bin) + assert not (home_root / ".local" / "bin" / "journal").exists() backups = list(self.backup_dir.glob("sol.old-symlink-*")) assert len(backups) == 1 assert backups[0].exists() @@ -500,12 +352,12 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" assert "pipx-xdg" in result.detail self.assert_managed_wrapper(home_root, "sol", journal, fake_bin) - self.assert_managed_wrapper(home_root, "journal", journal, fake_bin) + assert not (home_root / ".local" / "bin" / "journal").exists() def test_pipx_legacy_auto_migrates(self, doctor, monkeypatch, home_root, tmp_path): fake_bin, journal = self.setup_auto_migration(doctor, monkeypatch, tmp_path) @@ -516,12 +368,12 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" assert "pipx-legacy" in result.detail self.assert_managed_wrapper(home_root, "sol", journal, fake_bin) - self.assert_managed_wrapper(home_root, "journal", journal, fake_bin) + assert not (home_root / ".local" / "bin" / "journal").exists() def test_uv_tool_dangling_auto_migrates( self, doctor, monkeypatch, home_root, tmp_path @@ -535,12 +387,12 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" assert "migrated legacy uv-tool symlink" in result.detail self.assert_managed_wrapper(home_root, "sol", journal, fake_bin) - self.assert_managed_wrapper(home_root, "journal", journal, fake_bin) + assert not (home_root / ".local" / "bin" / "journal").exists() backups = list(self.backup_dir.glob("sol.old-symlink-*")) assert len(backups) == 1 assert backups[0].is_symlink() @@ -564,12 +416,12 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "ok" assert "migrated legacy pipx symlink" in result.detail self.assert_managed_wrapper(home_root, "sol", journal, fake_bin) - self.assert_managed_wrapper(home_root, "journal", journal, fake_bin) + assert not (home_root / ".local" / "bin" / "journal").exists() backups = list(self.backup_dir.glob("sol.old-symlink-*")) assert len(backups) == 1 assert backups[0].is_symlink() @@ -585,7 +437,7 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "fail" assert list(self.backup_dir.glob("sol.old-symlink-*")) == [] @@ -599,9 +451,9 @@ class TestStaleAliasSymlink: make_alias(home_root, target) monkeypatch.setattr(doctor, "ROOT", repo) - first = doctor.stale_alias_symlink_check(args(doctor)) + first = doctor.stale_alias_symlink_check(args(doctor), binary="sol") backups_after_first = sorted(self.backup_dir.glob("*.old-symlink-*")) - second = doctor.stale_alias_symlink_check(args(doctor)) + second = doctor.stale_alias_symlink_check(args(doctor), binary="sol") backups_after_second = sorted(self.backup_dir.glob("*.old-symlink-*")) assert first.status == "ok" @@ -619,49 +471,13 @@ class TestStaleAliasSymlink: backup.write_text("", encoding="utf-8") monkeypatch.setattr(doctor, "ROOT", repo) - result = doctor.stale_alias_symlink_check(args(doctor)) + result = doctor.stale_alias_symlink_check(args(doctor), binary="sol") assert result.status == "fail" assert "partial migration detected" in result.detail assert str(backup) in result.detail -class TestLaunchdStalePlist: - def test_skip_on_linux(self, doctor, monkeypatch): - monkeypatch.setattr(doctor, "platform_tag", lambda: "linux") - result = doctor.launchd_stale_plist_check(args(doctor)) - assert result.status == "skip" - - def test_skip_when_absent(self, doctor, monkeypatch, home_root): - monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") - result = doctor.launchd_stale_plist_check(args(doctor)) - assert result.status == "skip" - - def test_fail_when_target_missing(self, doctor, monkeypatch, home_root): - monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") - plist_path = ( - home_root / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" - ) - plist_path.parent.mkdir(parents=True) - plist_path.write_bytes( - plistlib.dumps({"ProgramArguments": ["/tmp/missing-sol"]}) - ) - result = doctor.launchd_stale_plist_check(args(doctor)) - assert result.status == "fail" - - def test_ok_when_target_exists(self, doctor, monkeypatch, home_root, tmp_path): - monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") - exe = tmp_path / "sol" - exe.write_text("", encoding="utf-8") - plist_path = ( - home_root / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" - ) - plist_path.parent.mkdir(parents=True) - plist_path.write_bytes(plistlib.dumps({"ProgramArguments": [str(exe)]})) - result = doctor.launchd_stale_plist_check(args(doctor)) - assert result.status == "ok" - - class TestJsonAndExitCodes: def test_json_output(self, doctor, monkeypatch, capsys): monkeypatch.setattr( @@ -747,7 +563,7 @@ class TestJsonAndExitCodes: "run_checks", lambda _args: [ doctor.CheckResult(check.name, check.severity, "ok", "fine", None) - for check, _func in doctor.CHECKS + for check, _func in doctor.UNIVERSAL_CHECKS ], ) @@ -755,7 +571,7 @@ class TestJsonAndExitCodes: events = [json.loads(line) for line in capsys.readouterr().out.splitlines()] checks = [event for event in events if event["event"] == "check.completed"] - assert len(checks) == len(doctor.CHECKS) + assert len(checks) == len(doctor.UNIVERSAL_CHECKS) def test_doctor_jsonl_status_translates_short_to_long( self, doctor, monkeypatch, capsys @@ -849,7 +665,12 @@ def test_sol_doctor_subprocess_json_shape(): payload = json.loads(result.stdout) assert "checks" in payload and isinstance(payload["checks"], list) assert "summary" in payload and isinstance(payload["summary"], dict) - assert len(payload["checks"]) >= 1 + assert {check["name"] for check in payload["checks"]} == { + "python_version", + "sol_importable", + "local_bin_sol_reachable", + "stale_alias_symlink", + } class TestMakefileIntegration: @@ -889,9 +710,16 @@ def test_doctor_runs_with_minimal_path_env(tmp_path): f"stdout={result.stdout}\nstderr={result.stderr}" ) payload = json.loads(result.stdout) - sync = next((c for c in payload["checks"] if c["name"] == "journal_sync"), None) - assert sync is not None, "journal_sync check missing from output" - if sync["status"] == "fail": - assert "machine id" not in (sync.get("detail") or "").lower(), ( - f"journal_sync failed due to machine id: {sync}" - ) + names = {check["name"] for check in payload["checks"]} + assert names == { + "python_version", + "sol_importable", + "local_bin_sol_reachable", + "stale_alias_symlink", + } + assert not any( + name.startswith("service_") + or name == "journal_sync" + or name.startswith("feature:") + for name in names + ) diff --git a/tests/test_doctor_features.py b/tests/test_doctor_features.py index 69a942626..6ad02ff29 100644 --- a/tests/test_doctor_features.py +++ b/tests/test_doctor_features.py @@ -24,18 +24,12 @@ def args(doctor, *, port: int = 5015, feature: str | None = None): def run_check(doctor, name: str): - for check, runner in doctor.CHECKS: - if check.name == name: - return runner(args(doctor)) - raise AssertionError(f"missing check runner for {name}") + _check, runner = doctor.FEATURE_CHECKS[name.removeprefix("feature:")] + return runner(args(doctor)) def test_feature_checks_registered(doctor): - feature_checks = [ - check for check, _runner in doctor.CHECKS if check.name.startswith("feature:") - ] - - assert len(feature_checks) == len(features.FEATURES) + assert set(doctor.FEATURE_CHECKS) == set(features.FEATURES) def test_feature_checks_in_check_map(doctor): diff --git a/tests/test_journal_doctor.py b/tests/test_journal_doctor.py new file mode 100644 index 000000000..4fbb747b0 --- /dev/null +++ b/tests/test_journal_doctor.py @@ -0,0 +1,315 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import plistlib +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from solstone.think import install_guard + + +@pytest.fixture +def doctor(): + from solstone.think import doctor as doctor_module + + return doctor_module + + +@pytest.fixture +def home_root(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + return home + + +def args(doctor): + return doctor.Args(verbose=False, json=False, jsonl=False, port=5015) + + +def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + if worktree: + (repo / ".git").write_text("gitdir: /tmp/worktree\n", encoding="utf-8") + else: + (repo / ".git").mkdir() + return repo + + +def make_alias(home_root: Path, binary: str, target: Path | str) -> Path: + alias = home_root / ".local" / "bin" / binary + alias.parent.mkdir(parents=True, exist_ok=True) + alias.symlink_to(target) + return alias + + +def make_existing_target(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + return path + + +def assert_managed_wrapper(home_root: Path, binary: str, journal: Path, fake_bin: Path): + alias = home_root / ".local" / "bin" / binary + assert alias.exists() + parsed = install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) + assert parsed is not None + assert parsed["journal"] == str(journal) + assert parsed["sol_bin"] == str(fake_bin / binary) + + +def patch_alias_absent(doctor, monkeypatch): + monkeypatch.setattr( + doctor, + "import_install_guard", + lambda: (install_guard.AliasState, install_guard.check_alias), + ) + + +def test_service_running_ok(doctor, monkeypatch): + monkeypatch.setattr(doctor, "service_is_installed", lambda: True) + monkeypatch.setattr(doctor, "fetch_supervisor_status", lambda: {"crashed": []}) + + result = doctor.service_running_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "journal service is running" + + +def test_service_running_stopped_warns(doctor, monkeypatch): + monkeypatch.setattr(doctor, "service_is_installed", lambda: True) + monkeypatch.setattr(doctor, "fetch_supervisor_status", lambda: None) + monkeypatch.setattr(doctor, "service_is_failed", lambda: False) + + result = doctor.service_running_check(args(doctor)) + + assert result.status == "warn" + assert result.detail == "service installed but not running" + assert result.fix == "run journal service start" + + +def test_service_running_failed_unit_fails(doctor, monkeypatch): + monkeypatch.setattr(doctor, "service_is_installed", lambda: True) + monkeypatch.setattr(doctor, "fetch_supervisor_status", lambda: None) + monkeypatch.setattr(doctor, "service_is_failed", lambda: True) + + result = doctor.service_running_check(args(doctor)) + + assert result.status == "fail" + assert result.detail == "journal service unit is failed" + + +def test_service_running_crash_loop_fails(doctor, monkeypatch): + monkeypatch.setattr(doctor, "service_is_installed", lambda: True) + monkeypatch.setattr( + doctor, + "fetch_supervisor_status", + lambda: {"crashed": [{"name": "cortex", "restart_attempts": 3}]}, + ) + + result = doctor.service_running_check(args(doctor)) + + assert result.status == "fail" + assert result.detail == "crash-loop: cortex (3 restart attempts)" + assert result.fix == "run journal service logs" + + +def test_service_identity_not_installed_skips(doctor, monkeypatch): + monkeypatch.setattr( + doctor, + "check_service_target_identity", + lambda: SimpleNamespace( + installed=False, + target="", + matches_current_install=False, + detail="service not installed", + ), + ) + + result = doctor.service_identity_check(args(doctor)) + + assert result.status == "skip" + assert result.detail == "no local journal service" + + +def test_service_identity_malformed_fails(doctor, monkeypatch): + monkeypatch.setattr( + doctor, + "check_service_target_identity", + lambda: SimpleNamespace( + installed=True, + target="", + matches_current_install=False, + detail="service config invalid", + ), + ) + + result = doctor.service_identity_check(args(doctor)) + + assert result.status == "fail" + assert result.detail == "service config invalid" + assert result.fix == "run journal setup to reinstall the service" + + +def test_service_identity_mismatch_fails_with_force_fix(doctor, monkeypatch): + monkeypatch.setattr( + doctor, + "check_service_target_identity", + lambda: SimpleNamespace( + installed=True, + target="/tmp/old/journal", + matches_current_install=False, + detail="service target mismatch", + ), + ) + + result = doctor.service_identity_check(args(doctor)) + + assert result.status == "fail" + assert "journal setup --force" in (result.fix or "") + + +def test_service_identity_match_ok(doctor, monkeypatch): + monkeypatch.setattr( + doctor, + "check_service_target_identity", + lambda: SimpleNamespace( + installed=True, + target="/tmp/current/journal", + matches_current_install=True, + detail="service target matches current install", + ), + ) + + result = doctor.service_identity_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "service target matches current install" + + +def test_role_skip_without_local_journal(doctor, monkeypatch, tmp_path, home_root): + journal = tmp_path / "missing-journal" + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) + monkeypatch.setattr(doctor, "service_is_installed", lambda: False) + monkeypatch.setattr( + doctor, + "check_journal_sync", + lambda: pytest.fail("journal_sync should be role-skipped"), + ) + patch_alias_absent(doctor, monkeypatch) + monkeypatch.setattr(doctor, "ROOT", make_repo(tmp_path)) + + results = doctor.run_checks(args(doctor), checks=doctor.JOURNAL_CHECKS) + by_name = {result.name: result for result in results} + + assert by_name["journal_dir_writable"].status == "skip" + assert by_name["journal_sync"].status == "skip" + assert by_name["service_identity"].status == "skip" + assert by_name["service_running"].status == "skip" + assert by_name["disk_space"].status in {"ok", "warn"} + assert by_name["config_dir_readable"].status == "ok" + assert by_name["feature:pdf"].status in {"ok", "warn"} + assert by_name["feature:whisper"].status in {"ok", "warn"} + + +class TestJournalAlias: + @pytest.fixture(autouse=True) + def isolated_legacy_backups(self, doctor, monkeypatch, tmp_path): + backup_dir = tmp_path / "legacy-backups" + backup_dir.mkdir() + monkeypatch.setattr(doctor, "_legacy_backup_dir", lambda: backup_dir) + self.backup_dir = backup_dir + + def setup_auto_migration(self, doctor, monkeypatch, tmp_path): + patch_alias_absent(doctor, monkeypatch) + fake_bin = tmp_path / "fakevenv" / "bin" + fake_bin.mkdir(parents=True) + journal = tmp_path / "journal" + monkeypatch.setattr(sys, "executable", str(fake_bin / "python")) + monkeypatch.setattr( + "solstone.think.install_guard._current_journal_for_alias", + lambda: journal, + ) + return fake_bin, journal + + def test_journal_only_absent_ok_even_if_sol_is_foreign( + self, doctor, monkeypatch, home_root, tmp_path + ): + patch_alias_absent(doctor, monkeypatch) + repo = make_repo(tmp_path) + sol_target = make_existing_target(tmp_path / "other" / ".venv" / "bin" / "sol") + make_alias(home_root, "sol", sol_target) + monkeypatch.setattr(doctor, "ROOT", repo) + + result = doctor.stale_alias_symlink_check(args(doctor), binary="journal") + + assert result.status == "ok" + + def test_journal_uv_tool_auto_migrates_only_journal( + self, doctor, monkeypatch, home_root, tmp_path + ): + fake_bin, journal = self.setup_auto_migration(doctor, monkeypatch, tmp_path) + repo = make_repo(tmp_path) + target = make_existing_target( + home_root + / ".local" + / "share" + / "uv" + / "tools" + / "solstone" + / "bin" + / "journal" + ) + make_alias(home_root, "journal", target) + monkeypatch.setattr(doctor, "ROOT", repo) + + result = doctor.stale_alias_symlink_check(args(doctor), binary="journal") + + assert result.status == "ok" + assert "uv-tool" in result.detail + assert_managed_wrapper(home_root, "journal", journal, fake_bin) + assert not (home_root / ".local" / "bin" / "sol").exists() + backups = list(self.backup_dir.glob("journal.old-symlink-*")) + assert len(backups) == 1 + + +class TestLaunchdStalePlist: + def test_skip_on_linux(self, doctor, monkeypatch): + monkeypatch.setattr(doctor, "platform_tag", lambda: "linux") + result = doctor.launchd_stale_plist_check(args(doctor)) + assert result.status == "skip" + + def test_skip_when_absent(self, doctor, monkeypatch, home_root): + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") + result = doctor.launchd_stale_plist_check(args(doctor)) + assert result.status == "skip" + + def test_fail_when_target_missing(self, doctor, monkeypatch, home_root): + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") + plist_path = ( + home_root / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" + ) + plist_path.parent.mkdir(parents=True) + plist_path.write_bytes( + plistlib.dumps({"ProgramArguments": ["/tmp/missing-sol"]}) + ) + result = doctor.launchd_stale_plist_check(args(doctor)) + assert result.status == "fail" + + def test_ok_when_target_exists(self, doctor, monkeypatch, home_root, tmp_path): + monkeypatch.setattr(doctor, "platform_tag", lambda: "darwin") + exe = tmp_path / "sol" + exe.write_text("", encoding="utf-8") + plist_path = ( + home_root / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" + ) + plist_path.parent.mkdir(parents=True) + plist_path.write_bytes(plistlib.dumps({"ProgramArguments": [str(exe)]})) + result = doctor.launchd_stale_plist_check(args(doctor)) + assert result.status == "ok" diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 1019172ae..8467bc801 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -21,6 +21,13 @@ def preflight(): yield preflight_module +@pytest.fixture +def probe(): + from solstone.think import probe as probe_module + + yield probe_module + + @pytest.fixture def home_root(monkeypatch, tmp_path): home = tmp_path / "home" @@ -48,26 +55,26 @@ def make_repo(tmp_path: Path, *, with_venv: bool = False) -> Path: return repo -def fake_probe_dispatcher(preflight, repo: Path): +def fake_probe_dispatcher(probe, repo: Path): def run_probe(_check, cmd, **_kwargs): if list(cmd) == ["uv", "--version"]: - return preflight.ProbeOutput("uv 0.7.12\n", "", 0) + return probe.ProbeOutput("uv 0.7.12\n", "", 0) if len(cmd) >= 3 and cmd[1:] == ["-c", "import sys; print(sys.prefix)"]: - return preflight.ProbeOutput(f"{repo / '.venv'}\n", "", 0) + return probe.ProbeOutput(f"{repo / '.venv'}\n", "", 0) raise AssertionError(f"unexpected probe command: {cmd}") return run_probe -def patch_green_environment(preflight, monkeypatch, home_root, repo: Path) -> None: - monkeypatch.setattr(preflight, "ROOT", repo) +def patch_green_environment(probe, monkeypatch, home_root, repo: Path) -> None: + monkeypatch.setattr(probe, "ROOT", repo) monkeypatch.setattr( - preflight, + probe, "run_probe", - fake_probe_dispatcher(preflight, repo), + fake_probe_dispatcher(probe, repo), ) monkeypatch.setattr( - preflight.shutil, + probe.shutil, "disk_usage", lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), ) @@ -76,10 +83,10 @@ def patch_green_environment(preflight, monkeypatch, home_root, repo: Path) -> No def test_main_json_passes_when_blockers_pass( - preflight, monkeypatch, tmp_path, home_root, capsys + preflight, probe, monkeypatch, tmp_path, home_root, capsys ): repo = make_repo(tmp_path, with_venv=True) - patch_green_environment(preflight, monkeypatch, home_root, repo) + patch_green_environment(probe, monkeypatch, home_root, repo) rc = preflight.main(["--json"]) payload = json.loads(capsys.readouterr().out) @@ -90,21 +97,21 @@ def test_main_json_passes_when_blockers_pass( assert isinstance(payload["summary"], dict) -def test_python_version_ok(preflight, monkeypatch, tmp_path): +def test_python_version_ok(preflight, probe, monkeypatch, tmp_path): repo = make_repo(tmp_path) - monkeypatch.setattr(preflight, "ROOT", repo) + monkeypatch.setattr(probe, "ROOT", repo) result = preflight.python_version_check(args(preflight)) assert result.status == "ok" -def test_uv_installed_ok(preflight, monkeypatch): - monkeypatch.setattr(preflight, "_is_source_checkout", lambda: True) +def test_uv_installed_ok(preflight, probe, monkeypatch): + monkeypatch.setattr(probe, "_is_source_checkout", lambda: True) monkeypatch.setattr( - preflight, + probe, "run_probe", - lambda *_args, **_kwargs: preflight.ProbeOutput("uv 0.10.0\n", "", 0), + lambda *_args, **_kwargs: probe.ProbeOutput("uv 0.10.0\n", "", 0), ) result = preflight.uv_installed_check(args(preflight)) @@ -112,13 +119,13 @@ def test_uv_installed_ok(preflight, monkeypatch): assert result.status == "ok" -def test_venv_consistent_ok(preflight, monkeypatch, tmp_path): +def test_venv_consistent_ok(preflight, probe, monkeypatch, tmp_path): repo = make_repo(tmp_path, with_venv=True) - monkeypatch.setattr(preflight, "ROOT", repo) + monkeypatch.setattr(probe, "ROOT", repo) monkeypatch.setattr( - preflight, + probe, "run_probe", - lambda *_args, **_kwargs: preflight.ProbeOutput(f"{repo / '.venv'}\n", "", 0), + lambda *_args, **_kwargs: probe.ProbeOutput(f"{repo / '.venv'}\n", "", 0), ) result = preflight.venv_consistent_check(args(preflight)) @@ -126,9 +133,9 @@ def test_venv_consistent_ok(preflight, monkeypatch, tmp_path): assert result.status == "ok" -def test_disk_space_ok(preflight, monkeypatch): +def test_disk_space_ok(preflight, probe, monkeypatch): monkeypatch.setattr( - preflight.shutil, + probe.shutil, "disk_usage", lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), ) @@ -147,23 +154,23 @@ def test_config_dir_readable_ok(preflight, home_root): assert result.status == "ok" -def test_local_bin_sol_reachable_ok(preflight, monkeypatch, home_root): +def test_local_bin_sol_reachable_ok(preflight, probe, monkeypatch, home_root): local = home_root / ".local" / "bin" / "sol" local.parent.mkdir(parents=True) local.write_text("#!/bin/sh\n", encoding="utf-8") - monkeypatch.setattr(preflight.shutil, "which", lambda name: str(local)) + monkeypatch.setattr(probe.shutil, "which", lambda name: str(local)) result = preflight.local_bin_sol_reachable_check(args(preflight)) assert result.status == "ok" -def test_uv_missing_fails(preflight, monkeypatch): +def test_uv_missing_fails(preflight, probe, monkeypatch): def raise_missing(*_args, **_kwargs): raise FileNotFoundError - monkeypatch.setattr(preflight, "_is_source_checkout", lambda: True) - monkeypatch.setattr(preflight.subprocess, "run", raise_missing) + monkeypatch.setattr(probe, "_is_source_checkout", lambda: True) + monkeypatch.setattr(probe.subprocess, "run", raise_missing) result = preflight.uv_installed_check(args(preflight)) @@ -172,16 +179,16 @@ def test_uv_missing_fails(preflight, monkeypatch): def test_main_returns_one_when_uv_missing( - preflight, monkeypatch, tmp_path, home_root, capsys + preflight, probe, monkeypatch, tmp_path, home_root, capsys ): def raise_missing(*_args, **_kwargs): raise FileNotFoundError repo = make_repo(tmp_path) - monkeypatch.setattr(preflight, "ROOT", repo) - monkeypatch.setattr(preflight.subprocess, "run", raise_missing) + monkeypatch.setattr(probe, "ROOT", repo) + monkeypatch.setattr(probe.subprocess, "run", raise_missing) monkeypatch.setattr( - preflight.shutil, + probe.shutil, "disk_usage", lambda _root: SimpleNamespace(total=100, used=80, free=20 * 1024**3), ) @@ -216,6 +223,7 @@ def test_preflight_runs_under_stdlib_import_guard(): sys.executable, "-c", stdlib_guard_code( + "from solstone.think import probe\n" "from solstone.think.preflight import main\n" 'raise SystemExit(main(["--json"]))' ), diff --git a/tests/test_setup.py b/tests/test_setup.py index 6dd9532e8..54448ad59 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -175,6 +175,7 @@ def expected_doctor_command(port: int = 5015) -> list[str]: "-m", "solstone.think.sol_cli", "doctor", + "--readiness", "--json", "--port", str(port), diff --git a/tests/test_sol.py b/tests/test_sol.py index 0d335b7e1..7efee891f 100644 --- a/tests/test_sol.py +++ b/tests/test_sol.py @@ -30,6 +30,12 @@ def access_command_names() -> list[str]: ) +def universal_command_names() -> list[str]: + return sorted( + name for name, command in sol.COMMANDS.items() if command.surface == "universal" + ) + + def service_alias_names() -> list[str]: return [ name @@ -380,7 +386,7 @@ class TestCommandRegistry: def test_every_registry_entry_has_surface_tag(self): """All commands and aliases declare the CLI surface they belong to.""" - valid_surfaces = {"access", "service"} + valid_surfaces = {"access", "service", "universal"} for name, command in sol.COMMANDS.items(): assert command.surface in valid_surfaces, ( f"Command '{name}' has invalid surface '{command.surface}'" @@ -409,6 +415,21 @@ class TestCommandRegistry: == [f"journal {name}"] + command_alias.preset_args ) + @pytest.mark.parametrize("name", universal_command_names()) + def test_universal_entries_dispatch_through_sol_and_journal( + self, monkeypatch, name + ): + """Universal commands are available from both binaries.""" + command = sol.COMMANDS[name] + + sol_result = run_dispatch(monkeypatch, "sol", name) + journal_result = run_dispatch(monkeypatch, "journal", name) + + assert sol_result["module"] == command.module + assert sol_result["argv"] == [f"sol {name}"] + assert journal_result["module"] == command.module + assert journal_result["argv"] == [f"journal {name}"] + @pytest.mark.parametrize("name", access_command_names()) def test_journal_rejects_access_tagged_commands(self, monkeypatch, capsys, name): """The journal binary exposes only service-tagged registry entries.""" @@ -426,8 +447,8 @@ class TestCommandRegistry: assert exc_info.value.code == 2 assert JOURNAL_ACCESS_CMD_ERROR.format(cmd=name) in captured.err - def test_journal_help_lists_only_service_surface(self): - """journal --help renders a flat service-only command list.""" + def test_journal_help_lists_service_and_universal_surfaces(self): + """journal --help renders service and universal command lists.""" code = ( "from solstone.think.sol_cli import journal_main; " "import sys; " @@ -443,7 +464,7 @@ class TestCommandRegistry: ) assert result.returncode == 0, result.stderr - for name in service_command_names(): + for name in service_command_names() + universal_command_names(): assert name in result.stdout for name in access_command_names(): assert name not in result.stdout @@ -490,7 +511,9 @@ class TestCommandRegistry: if name in sol.ALIASES: rendered_aliases.add(name) - assert rendered_commands == set(access_command_names()) + assert rendered_commands == set( + access_command_names() + universal_command_names() + ) assert rendered_group_headers == expected_group_headers assert rendered_aliases == set() diff --git a/tests/test_sol_cli_help.py b/tests/test_sol_cli_help.py index 67b3ce027..37c34f050 100644 --- a/tests/test_sol_cli_help.py +++ b/tests/test_sol_cli_help.py @@ -27,7 +27,7 @@ def _access_command_names() -> list[str]: return sorted( name for name, command in sol_cli.COMMANDS.items() - if command.surface == "access" + if command.surface in {"access", "universal"} ) -- 2.51.2