From 26f09d754ff5bf4b44d76501b4a9593725cb377e Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Tue, 26 May 2026 16:30:47 -0600 Subject: [PATCH] feat(cli): 0.4.0 decisive sol/journal service-CLI cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote `journal start` to a first-class service command and remove service-cmd surface from `sol`. Service units self-migrate via `reconcile_installed_unit()` on next start; legacy `…/sol ` units get one-shot shim execution into `journal`. Human-typed `sol ` hard-errors with exit 2 and a clear redirect. - new: `solstone/think/start.py` — reconcile → version-marker → supervisor - new: `Reconciled` + `reconcile_installed_unit()` + `remove_stale_systemd_units()` in `solstone/think/service.py` - new unit templates write `journal start [port]` (systemd + launchd) - `sol --help` drops Journal-service and Aliases sections - audit sweep: SKILL.md, config_cli prefix, fixture jsonl, prog-fidelity, vpe/workspace docs - tests: new test_journal_start, test_service_unit_reconcile, test_sol_service_hard_error; deleted test_sol_service_backcompat - version bump to 0.4.0 + CHANGELOG entry Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 + pyproject.toml | 2 +- solstone/__init__.py | 7 + solstone/apps/health/talent/health/SKILL.md | 48 ++-- solstone/talent/solstone/SKILL.md | 4 +- solstone/think/config_cli.py | 54 ++--- solstone/think/service.py | 223 +++++++++++++++++- solstone/think/setup.py | 14 +- solstone/think/sol_cli.py | 38 +-- solstone/think/start.py | 72 ++++++ tests/fixtures/health_logs_classified.jsonl | 4 +- tests/test_cli_prog_fidelity.py | 3 - tests/test_config_cli.py | 4 +- tests/test_journal_cli_migration.py | 4 +- tests/test_journal_start.py | 131 ++++++++++ tests/test_service.py | 7 +- tests/test_service_unit_reconcile.py | 196 +++++++++++++++ tests/test_setup.py | 24 +- tests/test_setup_jsonl.py | 24 +- tests/test_sol.py | 37 ++- tests/test_sol_cli_help.py | 32 ++- tests/test_sol_service_backcompat.py | 52 ---- tests/test_sol_service_hard_error.py | 140 +++++++++++ uv.lock | 2 +- ...n-l7-services-scout-device-code-disable.md | 4 +- .../review-l5c-services-enable-scout.md | 10 +- 26 files changed, 928 insertions(+), 218 deletions(-) create mode 100644 solstone/think/start.py create mode 100644 tests/test_journal_start.py create mode 100644 tests/test_service_unit_reconcile.py delete mode 100644 tests/test_sol_service_backcompat.py create mode 100644 tests/test_sol_service_hard_error.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7388d6b99..ad2251e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ Format adapted from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), al - the built-in `sol observer install` command is gone. linux and tmux observers now install from their own published packages: `pipx install solstone-linux` (or `solstone-tmux`), `solstone-linux install-service` (or `solstone-tmux install-service`), then `sol observer create ` mints a key you give the observer. the macOS observer continues to come from the signed app bundle at solstone.app/observers. - the bundled per-provider install commands are gone — `sol call settings providers install` now accepts `local` only (cogitate runs out of the box for hosted providers with a key set), and `uninstall`/`disable`/`enable`/`validate-key` are removed entirely. local install continues to work via `sol call settings providers install local`. +## [0.4.0] — 2026-05-26 + +### changed +- **service commands moved fully to `journal`.** Service commands (supervisor, cortex, heartbeat, setup, transcribe, services, etc.) are no longer surfaced under `sol` — they live exclusively under `journal`. Your existing solstone service migrates itself automatically on the next service restart; no action needed. +- `journal start` is now the canonical run command (replaces `journal supervisor` as the service-unit entry point — old units self-migrate). +- the `sol` CLI continues to be your day-to-day surface (chat, call, top, import, search across the journal). + +### removed +- `sol ` paths typed by a human now redirect to `journal ` with a clear error and exit non-zero. Service units still pointing at the old paths self-migrate; nothing on disk breaks. + ## [0.3.10] — 2026-05-26 ### Added diff --git a/pyproject.toml b/pyproject.toml index ef1c00906..6d47521f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "solstone" -version = "0.3.10" +version = "0.4.0" description = "Navigate Life Intelligently" readme = "README.md" requires-python = ">=3.12" diff --git a/solstone/__init__.py b/solstone/__init__.py index dc88dc43c..6a8e21d57 100644 --- a/solstone/__init__.py +++ b/solstone/__init__.py @@ -4,6 +4,13 @@ """solstone namespace package.""" import logging +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + +try: + __version__ = _pkg_version("solstone") +except PackageNotFoundError: + __version__ = "0.0.0+source" # httpx logs the full request URL at INFO; the Gemini API authenticates via # `?key=AIzaSy...`, so INFO leaks live keys into describe.log / transcribe.log. diff --git a/solstone/apps/health/talent/health/SKILL.md b/solstone/apps/health/talent/health/SKILL.md index 7aafd221e..09140f2f6 100644 --- a/solstone/apps/health/talent/health/SKILL.md +++ b/solstone/apps/health/talent/health/SKILL.md @@ -3,18 +3,18 @@ name: health description: > Monitor solstone uptime, troubleshoot capture/processing failures, review agent run costs and errors, pipeline health. CLIs: sol health (service), - sol talent (agent runs), sol call health pipeline (per-day summary). + journal talent (agent runs), sol call health pipeline (per-day summary). TRIGGER: health, status, is it running, service down, errors, agent runs, - logs, pipeline, sol health, sol talent logs. + logs, pipeline, sol health, journal talent logs. --- # Health CLI Skill -Monitor solstone service uptime, troubleshoot failures, and inspect agent runs. Invoke via Bash: `sol health ...`, `sol talent ...`, or `sol call health `. +Monitor solstone service uptime, troubleshoot failures, and inspect agent runs. Invoke via Bash: `sol health ...`, `journal talent ...`, or `sol call health `. -**Scope note**: Three CLI surfaces live here: `sol health*` (supervisor/service level), `sol talent*` (agent run level), and `sol call health ` (app-level pipeline health). They're grouped together because health troubleshooting routinely crosses the three levels. +**Scope note**: Three CLI surfaces live here: `sol health*` (supervisor/service level), `journal talent*` (agent run level), and `sol call health ` (app-level pipeline health). They're grouped together because health troubleshooting routinely crosses the three levels. -**Typical workflow**: `sol health` → `sol health logs` → `sol talent logs` → `sol talent log ` for agent-run detail → `sol call health pipeline` for a day-level pipeline summary. +**Typical workflow**: `sol health` → `sol health logs` → `journal talent logs` → `journal talent log ` for agent-run detail → `sol call health pipeline` for a day-level pipeline summary. ## status @@ -65,7 +65,7 @@ sol health logs -f ## agent runs ```bash -sol talent logs [AGENT] [-c COUNT] [--day YYYYMMDD] [--daily] [--errors] [--summary] +journal talent logs [AGENT] [-c COUNT] [--day YYYYMMDD] [--daily] [--errors] [--summary] ``` List recent agent runs. @@ -84,23 +84,23 @@ Output columns: use_id, time, name, status, runtime, cost, events, tools, output Examples: ```bash -sol talent logs -sol talent logs activity -c 10 -sol talent logs --daily -sol talent logs --daily --summary -sol talent logs --day 20260228 -sol talent logs --daily --errors +journal talent logs +journal talent logs activity -c 10 +journal talent logs --daily +journal talent logs --daily --summary +journal talent logs --day 20260228 +journal talent logs --daily --errors ``` ## agent run detail ```bash -sol talent log [--json] [--full] +journal talent log [--json] [--full] ``` Show events for a single agent run. -- `ID`: agent run ID (from `sol talent logs` output). +- `ID`: agent run ID (from `journal talent logs` output). - `--json`: raw JSONL events. - `--full`: expanded event detail (no truncation). @@ -109,9 +109,9 @@ Without flags, shows a one-line-per-event timeline: timestamp, event type, detai Examples: ```bash -sol talent log 1700000000001 -sol talent log 1700000000001 --json -sol talent log 1700000000001 --full +journal talent log 1700000000001 +journal talent log 1700000000001 --json +journal talent log 1700000000001 --full ``` ## pipeline summary @@ -125,7 +125,7 @@ Summarize think-pipeline health for one day — anomalies, performance metrics, - `--day YYYYMMDD`: target day. Defaults to today. - `--yesterday`: shortcut for yesterday. Mutually exclusive with `--day`. -Use this when you want a day-level view after daily processing completes, rather than a per-run drilldown via `sol talent log`. +Use this when you want a day-level view after daily processing completes, rather than a per-run drilldown via `journal talent log`. Examples: @@ -188,10 +188,10 @@ Which services write where: ## Troubleshooting ### `sol health` returns "Connection refused" or times out -The supervisor is not running. Check if `sol supervisor` is active. The owner may need to start solstone with `sol start` or `make dev`. +The supervisor is not running. Check if `journal supervisor` is active. The owner may need to start solstone with `journal start` or `make dev`. -### Agent run shows "error" status in `sol talent logs` -Run `sol talent log --full` to see the complete event timeline including the error. Common causes: +### Agent run shows "error" status in `journal talent logs` +Run `journal talent log --full` to see the complete event timeline including the error. Common causes: - API key issues (rate limits, expired keys) - Prompt too large (context overflow) - Network connectivity @@ -202,10 +202,10 @@ Run `sol talent log --full` to see the complete event timeline including th 3. Check if the stream is active: `sol streams` ### High agent costs -Run `sol talent logs --summary` for aggregated cost view. Filter by agent: `sol talent logs --summary`. +Run `journal talent logs --summary` for aggregated cost view. Filter by agent: `journal talent logs --summary`. ## Gotchas -- **`sol health` times out at 10 seconds.** If the supervisor is slow or hung, you'll hit the timeout before seeing results. Confirm the supervisor process is alive (`ps` / `sol supervisor` status) before assuming the service is down. -- **Talent log IDs are millisecond timestamps.** `sol talent log 1700000000001` expects the full ID from `sol talent logs`, not a seconds-precision value. +- **`sol health` times out at 10 seconds.** If the supervisor is slow or hung, you'll hit the timeout before seeing results. Confirm the supervisor process is alive (`ps` / `journal supervisor` status) before assuming the service is down. +- **Talent log IDs are millisecond timestamps.** `journal talent log 1700000000001` expects the full ID from `journal talent logs`, not a seconds-precision value. - **`sol call health pipeline` needs today's processing to have run.** Running it at 6am before the daily pipeline has executed will return sparse results for today; use `--yesterday` instead. diff --git a/solstone/talent/solstone/SKILL.md b/solstone/talent/solstone/SKILL.md index 42ca40de6..85c123bb5 100644 --- a/solstone/talent/solstone/SKILL.md +++ b/solstone/talent/solstone/SKILL.md @@ -22,7 +22,7 @@ The `sol` CLI must be on PATH. Quick check: sol help ``` -If this fails, solstone is not installed. Install it from the solstone project: `sol setup`. +If this fails, solstone is not installed. Install it from the solstone project: `journal setup`. ## Capabilities @@ -155,7 +155,7 @@ If a task requires writing to the journal, it must be done from within the solst If `sol` is not found on PATH or returns an error: -- **"command not found: sol"** — solstone is not installed. The user needs to run `sol setup` in their solstone project. +- **"command not found: sol"** — solstone is not installed. The user needs to run `journal setup` in their solstone project. - **"journal not found"** or empty output — the journal directory doesn't exist or has no data yet. solstone may be installed but not yet initialized. - **Connection errors from `sol call support`** — `diagnose` is local-only and should always work. Other support commands (`search`, `article`) contact the support portal and may fail if offline. diff --git a/solstone/think/config_cli.py b/solstone/think/config_cli.py index 30f0c7440..bb5ca4d48 100644 --- a/solstone/think/config_cli.py +++ b/solstone/think/config_cli.py @@ -31,7 +31,7 @@ from solstone.think.utils import ( MERGE_INSTRUCTIONS = "\n".join( [ - "sol config: --merge is not handled here.", + "journal config: --merge is not handled here.", "use 'sol call journal merge --dry-run' to preview the merge.", "use 'sol call journal merge ' to perform the merge.", ] @@ -105,7 +105,7 @@ def _read_wrapper_status() -> tuple[str, str | None]: def _wrapper_refusal(alias: Path) -> str: return ( - "sol config: refused: " + "journal config: refused: " f"{alias} is not a managed wrapper (run 'journal setup' from the solstone " "source checkout to install the wrapper first)" ) @@ -123,27 +123,27 @@ def _valid_flags(change: JournalChange) -> str: def _refusal_message(change: JournalChange) -> str: return ( - "sol config: refused: " + "journal config: refused: " f"current is {_state_label(change.current_active)} and target is " f"{_state_label(change.target_active)}; valid flags: {_valid_flags(change)}" ) def _move_target_exists_message(change: JournalChange) -> str: - return f"sol config: refused: move target already exists: {change.target_path}" + return f"journal config: refused: move target already exists: {change.target_path}" def _move_missing_current_message(change: JournalChange) -> str: - return f"sol config: refused: move source does not exist: {change.current_path}" + return f"journal config: refused: move source does not exist: {change.current_path}" def _move_missing_parent_message(change: JournalChange) -> str: - return f"sol config: refused: move target parent does not exist: {change.target_path.parent}" + return f"journal config: refused: move target parent does not exist: {change.target_path.parent}" def _move_cross_filesystem_message(change: JournalChange) -> str: return ( - "sol config: refused: cannot move across filesystems " + "journal config: refused: cannot move across filesystems " f"(current device={change.current_device}, target parent device={change.target_parent_device}); " "use 'sol call journal merge ' instead" ) @@ -151,7 +151,7 @@ def _move_cross_filesystem_message(change: JournalChange) -> str: def _move_requires_inactive_target_message(change: JournalChange) -> str: return ( - "sol config: refused: " + "journal config: refused: " f"--move requires a not active target; current is {_state_label(change.current_active)} " f"and target is {_state_label(change.target_active)}; valid flags: --switch, --merge, --force" ) @@ -224,7 +224,7 @@ def _rewrite_wrapper(change: JournalChange) -> str | None: current_content = alias.read_text(encoding="utf-8") except OSError as exc: print( - f"sol config: refused: cannot read {alias}: {exc}", + f"journal config: refused: cannot read {alias}: {exc}", file=sys.stderr, ) return None @@ -254,7 +254,7 @@ def _maybe_restart_current_service(change: JournalChange) -> None: _service_command(change.service_bin, "start") except FileNotFoundError as exc: print( - f"sol config: rollback warning: could not restart service ({exc})", + f"journal config: rollback warning: could not restart service ({exc})", file=sys.stderr, ) @@ -264,7 +264,7 @@ def _run_switch(change: JournalChange) -> int: change.target_path.mkdir(parents=True, exist_ok=True) except OSError as exc: print( - f"sol config: refused: cannot create {change.target_path}: {exc}", + f"journal config: refused: cannot create {change.target_path}: {exc}", file=sys.stderr, ) return 1 @@ -273,7 +273,7 @@ def _run_switch(change: JournalChange) -> int: restart_sol = _rewrite_wrapper(change) except OSError as exc: print( - f"sol config: refused: cannot rewrite {change.alias}: {exc}", + f"journal config: refused: cannot rewrite {change.alias}: {exc}", file=sys.stderr, ) return 1 @@ -296,14 +296,14 @@ def _run_switch(change: JournalChange) -> int: ) except FileNotFoundError as exc: print( - f"sol config: wrapper rewritten to {change.target_path} but journal service restart could not run ({exc}); restart manually", + f"journal config: wrapper rewritten to {change.target_path} but journal service restart could not run ({exc}); restart manually", file=sys.stderr, ) return 2 if result.returncode != 0: print( - "sol config: wrapper rewritten to " + "journal config: wrapper rewritten to " f"{change.target_path} but 'journal service restart --if-installed' exited " f"{result.returncode}; investigate and restart manually", file=sys.stderr, @@ -336,13 +336,13 @@ def _run_move(change: JournalChange) -> int: stop_result = _service_command(change.service_bin, "stop") except FileNotFoundError as exc: print( - f"sol config: could not stop service before move ({exc})", + f"journal config: could not stop service before move ({exc})", file=sys.stderr, ) return 2 if stop_result.returncode != 0: print( - "sol config: could not stop service before move", + "journal config: could not stop service before move", file=sys.stderr, ) return 2 @@ -351,7 +351,7 @@ def _run_move(change: JournalChange) -> int: os.rename(current, target) except OSError as exc: _maybe_restart_current_service(change) - print(f"sol config: move failed: {exc}", file=sys.stderr) + print(f"journal config: move failed: {exc}", file=sys.stderr) return 1 try: @@ -364,11 +364,11 @@ def _run_move(change: JournalChange) -> int: except OSError as rollback_exc: rollback_ok = False print( - f"sol config: rollback failed after wrapper write error: {rollback_exc}", + f"journal config: rollback failed after wrapper write error: {rollback_exc}", file=sys.stderr, ) _maybe_restart_current_service(change) - message = f"sol config: move failed during wrapper update: {exc}" + message = f"journal config: move failed during wrapper update: {exc}" if rollback_ok: message += "; restored original journal" print(message, file=sys.stderr) @@ -379,7 +379,7 @@ def _run_move(change: JournalChange) -> int: os.rename(target, current) except OSError as rollback_exc: print( - f"sol config: rollback failed after wrapper validation error: {rollback_exc}", + f"journal config: rollback failed after wrapper validation error: {rollback_exc}", file=sys.stderr, ) _maybe_restart_current_service(change) @@ -414,7 +414,7 @@ def _run_move(change: JournalChange) -> int: def _run_noop(change: JournalChange, _decision: Decision) -> int: - print(f"sol config: journal already set to {change.target_path}") + print(f"journal config: journal already set to {change.target_path}") return 0 @@ -471,7 +471,7 @@ def decide(change: JournalChange) -> Decision: def execute(change: JournalChange, decision: Decision) -> int: if change.action is RequestedAction.FORCE: print( - "sol config: warning: --force bypasses confirmation and target activity checks", + "journal config: warning: --force bypasses confirmation and target activity checks", file=sys.stderr, ) @@ -548,7 +548,7 @@ def cmd_show() -> int: try: path, info_source = get_journal_info() except SolstoneNotConfigured as exc: - print(f"sol config: {exc}", file=sys.stderr) + print(f"journal config: {exc}", file=sys.stderr) return 1 if info_source == "env": @@ -585,14 +585,14 @@ def cmd_journal( try: validate_journal_path_for_wrapper(target_str) except ValueError as exc: - print(f"sol config: refused: {exc}", file=sys.stderr) + print(f"journal config: refused: {exc}", file=sys.stderr) return 1 project_root = Path(get_project_root()) source_tree_journal = (project_root / "journal").resolve() if target == source_tree_journal and not is_source_checkout(): print( - "sol config: refused: " + "journal config: refused: " f"{target_str} is the source-tree fallback path but this is not a " "source checkout", file=sys.stderr, @@ -601,7 +601,7 @@ def cmd_journal( if action is RequestedAction.MOVE and not target.parent.exists(): print( - f"sol config: refused: move target parent does not exist: {target.parent}", + f"journal config: refused: move target parent does not exist: {target.parent}", file=sys.stderr, ) return 1 @@ -614,7 +614,7 @@ def cmd_journal( try: content = alias.read_text(encoding="utf-8") except OSError as exc: - print(f"sol config: refused: cannot read {alias}: {exc}", file=sys.stderr) + print(f"journal config: refused: cannot read {alias}: {exc}", file=sys.stderr) return 1 parsed = parse_wrapper(content) diff --git a/solstone/think/service.py b/solstone/think/service.py index e7bf40e83..5e43ae134 100644 --- a/solstone/think/service.py +++ b/solstone/think/service.py @@ -23,8 +23,10 @@ from __future__ import annotations import os import plistlib +import shlex import subprocess import sys +from dataclasses import dataclass from pathlib import Path from xml.parsers.expat import ExpatError @@ -38,6 +40,14 @@ DEFAULT_SERVICE_PORT = 5015 READY_TIMEOUT_SECONDS = 60.0 +@dataclass(frozen=True) +class Reconciled: + was_stale: bool + stale_binary: str | None + stale_verb: str | None + canonical_path: Path | None + + def _ready_timeout_message() -> str: return ( f"Service did not become ready within {READY_TIMEOUT_SECONDS:g}s — " @@ -134,7 +144,7 @@ def _generate_plist( plist = { "Label": SERVICE_LABEL, - "ProgramArguments": [journal, "supervisor", str(port)], + "ProgramArguments": [journal, "start", str(port)], "EnvironmentVariables": env, "StandardOutPath": service_log, "StandardErrorPath": service_log, @@ -144,6 +154,153 @@ def _generate_plist( return plistlib.dumps(plist) +def _not_loaded_markers() -> tuple[str, ...]: + return ( + "could not find", + "service not found", + "no such process", + "not currently loaded", + ) + + +def _parse_exec_start(line: str) -> list[str] | None: + if not line.startswith("ExecStart="): + return None + value = line.removeprefix("ExecStart=").strip() + if not value: + return None + return shlex.split(value) + + +def _exec_start_parts_from_lines(path: Path, lines: list[str]) -> list[str] | None: + for line in lines: + try: + parts = _parse_exec_start(line.rstrip("\n")) + except ValueError as exc: + print(f"skipping {path}: invalid ExecStart: {exc}", file=sys.stderr) + return None + if parts is not None: + return parts + print(f"skipping {path}: no ExecStart", file=sys.stderr) + return None + + +def _systemd_exec_start_parts(path: Path) -> tuple[list[str] | None, list[str]]: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + parts = _exec_start_parts_from_lines(path, lines) + return parts, lines + + +def _classify_unit_args(args: list[str]) -> tuple[str, str, list[str], bool] | None: + if len(args) < 2: + return None + binary = Path(args[0]).name + verb = args[1] + rest = args[2:] + if binary not in {"sol", "journal"}: + return None + is_canonical = binary == "journal" and verb == "start" + return binary, verb, rest, is_canonical + + +def _canonical_start_args(rest: list[str]) -> list[str]: + return [_managed_wrapper("journal"), "start", *rest] + + +def _reconcile_systemd_unit() -> Reconciled: + path = _unit_path() + if not path.exists(): + return Reconciled(False, None, None, None) + + parts, lines = _systemd_exec_start_parts(path) + if parts is None: + return Reconciled(False, None, None, path) + + classified = _classify_unit_args(parts) + if classified is None: + return Reconciled(False, None, None, path) + + binary, verb, rest, is_canonical = classified + canonical = _canonical_start_args(rest) + if is_canonical and parts == canonical: + return Reconciled(False, None, None, path) + + if verb not in {"supervisor", "start"}: + return Reconciled(False, None, None, path) + + new_exec = f"ExecStart={shlex.join(canonical)}\n" + rewritten = [] + replaced = False + for line in lines: + if not replaced and line.startswith("ExecStart="): + rewritten.append(new_exec if line.endswith("\n") else new_exec.rstrip("\n")) + replaced = True + else: + rewritten.append(line) + + path.write_text("".join(rewritten), encoding="utf-8") + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + return Reconciled(True, binary, verb, path) + + +def _reconcile_launchd_plist() -> Reconciled: + path = _plist_path() + if not path.exists(): + return Reconciled(False, None, None, None) + + try: + with path.open("rb") as handle: + data = plistlib.load(handle) + except (plistlib.InvalidFileException, ValueError, ExpatError) as exc: + print(f"skipping {path}: {type(exc).__name__}: {exc}", file=sys.stderr) + return Reconciled(False, None, None, path) + + program_arguments = data.get("ProgramArguments") + if not isinstance(program_arguments, list): + print(f"skipping {path}: no ProgramArguments", file=sys.stderr) + return Reconciled(False, None, None, path) + + args = [str(arg) for arg in program_arguments] + classified = _classify_unit_args(args) + if classified is None: + return Reconciled(False, None, None, path) + + binary, verb, rest, is_canonical = classified + canonical = _canonical_start_args(rest) + if is_canonical and args == canonical: + return Reconciled(False, None, None, path) + + if verb not in {"supervisor", "start"}: + return Reconciled(False, None, None, path) + + data["ProgramArguments"] = canonical + path.write_bytes(plistlib.dumps(data)) + + uid = os.getuid() + subprocess.run( + ["launchctl", "bootout", f"gui/{uid}", str(path)], + capture_output=True, + ) + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"launchctl bootstrap {path}: {result.stderr.strip()}") + + return Reconciled(True, binary, verb, path) + + +def reconcile_installed_unit() -> Reconciled: + """Rewrite installed service units to the canonical ``journal start`` shape.""" + if sys.platform == "darwin": + return _reconcile_launchd_plist() + if sys.platform.startswith("linux"): + return _reconcile_systemd_unit() + return Reconciled(False, None, None, None) + + def remove_stale_plists() -> tuple[int, int]: """Remove stale launchd plists from prior installs.""" if sys.platform != "darwin": @@ -157,12 +314,7 @@ def remove_stale_plists() -> tuple[int, int]: uid = os.getuid() removed = 0 failed = 0 - not_loaded_markers = ( - "could not find", - "service not found", - "no such process", - "not currently loaded", - ) + not_loaded_markers = _not_loaded_markers() for path in sorted(scan_dir.glob("*.plist")): try: @@ -228,6 +380,60 @@ def remove_stale_plists() -> tuple[int, int]: return (removed, failed) +def _systemd_unit_references_solstone(path: Path, lines: list[str]) -> bool: + if path.name == f"{SYSTEMD_UNIT}.service": + return True + return any( + line.startswith("Description=") and "solstone" in line.lower() for line in lines + ) + + +def remove_stale_systemd_units() -> tuple[int, int]: + """Remove stale systemd user units from prior installs.""" + if not sys.platform.startswith("linux"): + return (0, 0) + + scan_dir = _unit_path().parent + if not scan_dir.is_dir(): + return (0, 0) + + current_wrappers = {_managed_wrapper("sol"), _managed_wrapper("journal")} + removed = 0 + failed = 0 + + for path in sorted(scan_dir.glob("*.service")): + try: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + except OSError as exc: + print(f"skipping {path}: {type(exc).__name__}: {exc}", file=sys.stderr) + continue + if not _systemd_unit_references_solstone(path, lines): + continue + parts = _exec_start_parts_from_lines(path, lines) + if parts is None: + continue + + extracted = parts[0] + if ( + Path(extracted).name not in {"sol", "journal"} + or extracted not in current_wrappers + ): + try: + path.unlink() + except (OSError, PermissionError) as exc: + print(f"failed to remove {path}: {exc}", file=sys.stderr) + failed += 1 + continue + + print( + f"Removed stale systemd unit {path} " + f"(referenced {extracted}, current wrappers are {sorted(current_wrappers)})" + ) + removed += 1 + + return (removed, failed) + + def _generate_systemd_unit( env: dict[str, str], *, @@ -249,7 +455,7 @@ def _generate_systemd_unit( f"\n" f"[Service]\n" f"Type=notify\n" - f"ExecStart={journal} supervisor {port}\n" + f"ExecStart={journal} start {port}\n" f"Restart=on-failure\n" f"RestartSec=5\n" f"KillMode=control-group\n" @@ -316,6 +522,7 @@ def _install(port: int = DEFAULT_SERVICE_PORT) -> int: print("Service loaded into launchd") else: + remove_stale_systemd_units() unit_content = _generate_systemd_unit(env, port=port, journal_path=journal_path) path = _unit_path() path.parent.mkdir(parents=True, exist_ok=True) diff --git a/solstone/think/setup.py b/solstone/think/setup.py index 382a6e68f..188077848 100644 --- a/solstone/think/setup.py +++ b/solstone/think/setup.py @@ -741,11 +741,13 @@ def doctor_command(ctx: SetupContext, *, jsonl: bool = False) -> list[str]: ] +def journal_console_command() -> list[str]: + return [str(Path(sys.executable).parent / "journal")] + + def install_models_command(ctx: SetupContext) -> list[str]: return [ - sys.executable, - "-m", - "solstone.think.sol_cli", + *journal_console_command(), "install-models", "--variant", ctx.variant, @@ -784,9 +786,7 @@ def wrapper_command() -> list[str]: def service_install_command(ctx: SetupContext) -> list[str]: return [ - sys.executable, - "-m", - "solstone.think.sol_cli", + *journal_console_command(), "service", "install", "--port", @@ -1766,7 +1766,7 @@ def _resume_service( ) run_step_subprocess( ctx, - [sys.executable, "-m", "solstone.think.sol_cli", "service", "restart"], + [*journal_console_command(), "service", "restart"], timeout=None, ) if health_check() == 0: diff --git a/solstone/think/sol_cli.py b/solstone/think/sol_cli.py index 66d12604b..fe2571502 100644 --- a/solstone/think/sol_cli.py +++ b/solstone/think/sol_cli.py @@ -59,6 +59,11 @@ JOURNAL_ACCESS_CMD_ERROR = ( "'{cmd}' is a journal-access command — run it with 'sol {cmd}' instead.\n" "('journal' surfaces only journal-service commands; see 'journal --help'.)" ) +SOL_SERVICE_CMD_REMOVED_ERROR = ( + "'{cmd}' moved to 'journal {cmd}' in solstone 0.4.0 — run that instead.\n" + "('sol' is the journal-access surface; 'journal' surfaces journal-service " + "commands; see 'journal --help'.)" +) SOL_HELP_GROUP_CONVERSATION = "Conversation" SOL_HELP_GROUP_YOUR_JOURNAL = "Your journal" @@ -73,6 +78,7 @@ COMMANDS: dict[str, Command] = { "import": Command("solstone.think.importers.cli", "access"), "think": Command("solstone.think.thinking", "service"), "indexer": Command("solstone.think.indexer", "access"), + "start": Command("solstone.think.start", "service"), "supervisor": Command("solstone.think.supervisor", "service"), "schedule": Command("solstone.think.scheduler", "service"), "top": Command("solstone.think.top", "access"), @@ -123,7 +129,6 @@ COMMANDS: dict[str, Command] = { # ============================================================================= ALIASES: dict[str, Alias] = { - "start": Alias("solstone.think.supervisor", [], "service"), "up": Alias("solstone.think.service", ["up"], "service"), "down": Alias("solstone.think.service", ["down"], "service"), } @@ -131,9 +136,7 @@ ALIASES: dict[str, Alias] = { # Owner-facing command groupings for `sol --help`. # # Access-tagged commands are assigned to one of the four intent groups below. -# Service-tagged commands are rendered in the Journal service group derived from -# COMMANDS, preserving registry order. Future access commands must be assigned -# here deliberately; future service commands join Journal service by tag. +# Future access commands must be assigned here deliberately. ACCESS_HELP_GROUPS: tuple[HelpGroup, ...] = ( HelpGroup(SOL_HELP_GROUP_CONVERSATION, ("chat", "engage")), HelpGroup( @@ -190,7 +193,7 @@ def service_help_group() -> HelpGroup: def help_groups() -> tuple[HelpGroup, ...]: """Return all owner-facing help groups in display order.""" - return ACCESS_HELP_GROUPS + (service_help_group(),) + return ACCESS_HELP_GROUPS def _print_help_group(group: HelpGroup) -> None: @@ -236,13 +239,6 @@ def print_help() -> None: except Exception: pass - # Print aliases if any - if ALIASES: - print(SOL_HELP_GROUP_ALIASES) - for alias, command_alias in ALIASES.items(): - print(f" {alias:16} (= {_alias_target_label(command_alias)})") - print() - def print_journal_help() -> None: """Print help for the journal service command surface.""" @@ -400,12 +396,27 @@ def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: return # Resolve command to module path + rest = sys.argv[2:] try: module_path, preset_args, surface = resolve_command(cmd) except ValueError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) + if binary == "sol" and surface == "service": + from solstone.think.service import _managed_wrapper, reconcile_installed_unit + + reconciled = reconcile_installed_unit() + if ( + reconciled.was_stale + and reconciled.stale_binary == "sol" + and reconciled.stale_verb == cmd + ): + journal_wrapper = _managed_wrapper("journal") + os.execv(str(journal_wrapper), [str(journal_wrapper), cmd, *rest]) + print(SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=cmd), file=sys.stderr) + sys.exit(2) + if allowed_surfaces is not None and surface not in allowed_surfaces: sys.stderr.write(JOURNAL_ACCESS_CMD_ERROR.format(cmd=cmd) + "\n") sys.exit(2) @@ -417,8 +428,7 @@ def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: # Original: ["sol", "import", "--day", "20250101"] # Becomes: ["sol import", "--day", "20250101"] # This makes argparse show "usage: ..." in help. - remaining_args = sys.argv[2:] - sys.argv = [f"{binary} {cmd}"] + preset_args + remaining_args + sys.argv = [f"{binary} {cmd}"] + preset_args + rest # Run the command exit_code = run_command(module_path) diff --git a/solstone/think/start.py b/solstone/think/start.py new file mode 100644 index 000000000..9a9aa7850 --- /dev/null +++ b/solstone/think/start.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Canonical journal service start entry point.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +import solstone +from solstone.think.entities.core import atomic_write +from solstone.think.install_guard import alias_paths, install_wrappers +from solstone.think.service import reconcile_installed_unit +from solstone.think.skills_cli import install_project +from solstone.think.user_config import config_path +from solstone.think.utils import get_journal, get_project_root + +logger = logging.getLogger(__name__) + + +def _version_marker_path() -> Path: + return config_path().parent / ".last-start-version" + + +def _version_marker_is_current(path: Path) -> bool: + try: + return path.read_text(encoding="utf-8") == f"{solstone.__version__}\n" + except FileNotFoundError: + return False + + +def _install_current_wrappers() -> None: + bin_dir = Path(sys.executable).parent + paths = alias_paths() + sol_bins = {binary: str(bin_dir / binary) for binary in paths} + install_wrappers(get_journal(), sol_bins, paths=paths) + + +def _refresh_skill_links() -> None: + report = install_project(Path(get_project_root()), Path(get_journal()), ["all"]) + if report.error_count: + raise RuntimeError(f"skill refresh failed with {report.error_count} error(s)") + + +def _refresh_for_version_marker() -> None: + marker_path = _version_marker_path() + if _version_marker_is_current(marker_path): + return + + _install_current_wrappers() + reconcile_installed_unit() + _refresh_skill_links() + atomic_write( + marker_path, + f"{solstone.__version__}\n", + prefix=".last_start_version_", + ) + + +def main() -> None: + try: + reconcile_installed_unit() + _refresh_for_version_marker() + except Exception: + logger.exception("journal start failed during service reconciliation") + sys.exit(1) + + from solstone.think import supervisor + + supervisor.main() diff --git a/tests/fixtures/health_logs_classified.jsonl b/tests/fixtures/health_logs_classified.jsonl index 5ae05d547..59a4ec4e5 100644 --- a/tests/fixtures/health_logs_classified.jsonl +++ b/tests/fixtures/health_logs_classified.jsonl @@ -41,7 +41,7 @@ {"service":"llama-server","stream":"stderr","line":"0.00.006.766 I log_info: verbosity = 3 (adjust with the `-lv N` CLI arg)","level":"info"} {"service":"llama-server","stream":"stderr","line":"0.00.301.436 W load: control-looking token: 128247 '' was not control-type; this is probably a bug in the model. its type will be overridden","level":"warning"} {"service":"llama-server","stream":"stderr","line":"0.13.987.953 W common_speculative_init: no implementations specified for speculative decoding","level":"warning"} -{"service":"daily_dream","stream":"stderr","line":"sol: solstone isn't running. Start it with 'sol up' and retry.","level":"error"} -{"service":"segment_dream","stream":"stderr","line":"sol: solstone isn't running. Start it with 'sol up' and retry.","level":"error"} +{"service":"daily_dream","stream":"stderr","line":"sol: solstone isn't running. Start it with 'journal up' and retry.","level":"error"} +{"service":"segment_dream","stream":"stderr","line":"sol: solstone isn't running. Start it with 'journal up' and retry.","level":"error"} {"service":"heartbeat","stream":"log","line":"duration=549s outcome=success","level":"info"} {"service":"observe-transcribe","stream":"stderr","line":"DEBUG:speechbrain.utils.checkpoints:Registered checkpoint save hook for _speechbrain_save","level":"debug"} diff --git a/tests/test_cli_prog_fidelity.py b/tests/test_cli_prog_fidelity.py index 1be21f9ab..75a2c874e 100644 --- a/tests/test_cli_prog_fidelity.py +++ b/tests/test_cli_prog_fidelity.py @@ -21,11 +21,8 @@ def _console_script(binary: str) -> Path: @pytest.mark.parametrize( ("binary", "args", "usage"), [ - ("sol", ["heartbeat", "--help"], "usage: sol heartbeat"), ("journal", ["heartbeat", "--help"], "usage: journal heartbeat"), - ("sol", ["setup", "--help"], "usage: sol setup"), ("journal", ["setup", "--help"], "usage: journal setup"), - ("sol", ["config", "journal", "--help"], "usage: sol config journal"), ( "journal", ["config", "journal", "--help"], diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index f2a729c2e..0c241e628 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -193,7 +193,7 @@ def test_journal_noops_when_path_already_embedded( assert rc == 0 assert captured.err == "" - assert captured.out == f"sol config: journal already set to {target_path}\n" + assert captured.out == f"journal config: journal already set to {target_path}\n" assert alias.read_text(encoding="utf-8") == original run_mock.assert_not_called() @@ -264,7 +264,7 @@ def test_journal_rewrite_mid_failure_rolls_back_both_wrappers( assert rc == 1 assert captured.out == "" - assert "sol config: refused: cannot rewrite" in captured.err + assert "journal config: refused: cannot rewrite" in captured.err for binary, alias in aliases.items(): assert alias.read_bytes() == before[binary] diff --git a/tests/test_journal_cli_migration.py b/tests/test_journal_cli_migration.py index 31773ff98..3bf8ff2c8 100644 --- a/tests/test_journal_cli_migration.py +++ b/tests/test_journal_cli_migration.py @@ -42,7 +42,7 @@ ACCESS_POSITIVE_EXPECTATIONS = { def _is_text_surface(path: Path) -> bool: if any(part in EXCLUDED_DIRS for part in path.parts): return False - if path.name == "CHANGELOG.md" or path.name == "SKILL.md": + if path.name == "CHANGELOG.md": return False if path.name in ROOT_TEXT_FILES: return True @@ -58,8 +58,6 @@ def _candidate_files() -> list[Path]: def _skip_line(path: Path, line: str) -> bool: - if "sol config:" in line: - return True if path in { Path("tests/test_cli_prog_fidelity.py"), Path("tests/test_journal_cli_migration.py"), diff --git a/tests/test_journal_start.py b/tests/test_journal_start.py new file mode 100644 index 000000000..5f54c0e7f --- /dev/null +++ b/tests/test_journal_start.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from solstone.think import start +from solstone.think.service import Reconciled + + +def _patch_marker(monkeypatch: pytest.MonkeyPatch, marker: Path) -> None: + monkeypatch.setattr(start, "_version_marker_path", lambda: marker) + + +def test_start_reconcile_idempotent_no_rewrite(monkeypatch, tmp_path): + marker = tmp_path / ".last-start-version" + marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") + _patch_marker(monkeypatch, marker) + reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) + supervisor = MagicMock() + monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) + monkeypatch.setattr("solstone.think.supervisor.main", supervisor) + + start.main() + + reconcile.assert_called_once_with() + supervisor.assert_called_once_with() + + +def test_start_version_marker_mismatch_triggers_refresh(monkeypatch, tmp_path): + marker = tmp_path / ".last-start-version" + marker.write_text("old-version\n", encoding="utf-8") + _patch_marker(monkeypatch, marker) + calls: list[str] = [] + monkeypatch.setattr( + start, "_install_current_wrappers", lambda: calls.append("wrappers") + ) + monkeypatch.setattr( + start, + "reconcile_installed_unit", + lambda: calls.append("reconcile") or Reconciled(False, None, None, None), + ) + monkeypatch.setattr(start, "_refresh_skill_links", lambda: calls.append("skills")) + + start._refresh_for_version_marker() + + assert calls == ["wrappers", "reconcile", "skills"] + assert marker.read_text(encoding="utf-8") == f"{start.solstone.__version__}\n" + + +def test_start_version_marker_match_is_noop(monkeypatch, tmp_path): + marker = tmp_path / ".last-start-version" + marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") + _patch_marker(monkeypatch, marker) + monkeypatch.setattr( + start, + "_install_current_wrappers", + lambda: pytest.fail("wrappers should not refresh"), + ) + monkeypatch.setattr( + start, + "reconcile_installed_unit", + lambda: pytest.fail("reconcile should not refresh"), + ) + monkeypatch.setattr( + start, + "_refresh_skill_links", + lambda: pytest.fail("skills should not refresh"), + ) + + start._refresh_for_version_marker() + + +def test_start_invokes_supervisor(monkeypatch, tmp_path): + marker = tmp_path / ".last-start-version" + marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") + _patch_marker(monkeypatch, marker) + monkeypatch.setattr( + start, + "reconcile_installed_unit", + lambda: Reconciled(False, None, None, None), + ) + supervisor = MagicMock() + monkeypatch.setattr("solstone.think.supervisor.main", supervisor) + + start.main() + + supervisor.assert_called_once_with() + + +def test_start_reconcile_failure_exits_nonzero(monkeypatch, tmp_path): + marker = tmp_path / ".last-start-version" + marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") + _patch_marker(monkeypatch, marker) + monkeypatch.setattr( + start, + "reconcile_installed_unit", + MagicMock(side_effect=OSError("boom")), + ) + + with pytest.raises(SystemExit) as exc_info: + start.main() + + assert exc_info.value.code == 1 + + +def test_start_skill_refresh_error_exits_nonzero(monkeypatch, tmp_path): + marker = tmp_path / ".last-start-version" + marker.write_text("old-version\n", encoding="utf-8") + _patch_marker(monkeypatch, marker) + monkeypatch.setattr( + start, + "reconcile_installed_unit", + lambda: Reconciled(False, None, None, None), + ) + monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) + monkeypatch.setattr( + start, + "_refresh_skill_links", + MagicMock(side_effect=RuntimeError("skill refresh failed")), + ) + + with pytest.raises(SystemExit) as exc_info: + start.main() + + assert exc_info.value.code == 1 + assert marker.read_text(encoding="utf-8") == "old-version\n" diff --git a/tests/test_service.py b/tests/test_service.py index 5d49ffc4f..85c5304b1 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -48,7 +48,7 @@ class TestPlistGeneration: assert plist["ProgramArguments"][0] == str( Path.home() / ".local" / "bin" / "journal" ) - assert plist["ProgramArguments"][1] == "supervisor" + assert plist["ProgramArguments"][1] == "start" assert plist["EnvironmentVariables"] == env assert plist["EnvironmentVariables"]["PYTHONUNBUFFERED"] == "1" assert plist["KeepAlive"] == {"SuccessfulExit": False} @@ -99,10 +99,9 @@ class TestSystemdUnit: assert f"StandardOutput=append:{service_log}" in unit assert "StandardError=inherit" in unit assert ( - f"ExecStart={Path.home() / '.local' / 'bin' / 'journal'} supervisor 5015" - in unit + f"ExecStart={Path.home() / '.local' / 'bin' / 'journal'} start 5015" in unit ) - assert "supervisor" in unit + assert "start" in unit assert "Environment=HOME=/home/test" in unit assert "Environment=PATH=/usr/bin" in unit assert "Environment=PYTHONUNBUFFERED=1" in unit diff --git a/tests/test_service_unit_reconcile.py b/tests/test_service_unit_reconcile.py new file mode 100644 index 000000000..be1cbc6cd --- /dev/null +++ b/tests/test_service_unit_reconcile.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import plistlib +import shlex +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, call + +import pytest + +from solstone.think import service + +STALE_SHAPES = [ + ("sol-supervisor-no-port", "sol", ["supervisor"], []), + ("sol-supervisor-port", "sol", ["supervisor", "5015"], ["5015"]), + ("journal-supervisor-no-port", "journal", ["supervisor"], []), + ("journal-supervisor-port", "journal", ["supervisor", "5015"], ["5015"]), +] + + +def _managed(tmp_path: Path): + return lambda binary: str(tmp_path / "current" / binary) + + +def _old_binary(tmp_path: Path, binary: str) -> str: + return str(tmp_path / "old" / binary) + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd reconcile shape") +@pytest.mark.parametrize(("case", "binary", "args", "tail"), STALE_SHAPES) +def test_reconcile_rewrites_stale_systemd_unit_to_journal_start( + monkeypatch, + tmp_path, + case, + binary, + args, + tail, +): + del case + unit_path = tmp_path / "solstone.service" + old_args = [_old_binary(tmp_path, binary), *args] + unit_path.write_text( + "[Unit]\n" + "Description=Solstone Supervisor\n" + "[Service]\n" + f"ExecStart={shlex.join(old_args)}\n" + "Restart=on-failure\n", + encoding="utf-8", + ) + run = MagicMock(return_value=subprocess.CompletedProcess([], 0)) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(service, "_unit_path", lambda: unit_path) + monkeypatch.setattr(service, "_managed_wrapper", _managed(tmp_path)) + monkeypatch.setattr(service.subprocess, "run", run) + + result = service.reconcile_installed_unit() + + expected = [str(tmp_path / "current" / "journal"), "start", *tail] + assert result.was_stale is True + assert result.stale_binary == binary + assert result.stale_verb == "supervisor" + assert result.canonical_path == unit_path + assert f"ExecStart={shlex.join(expected)}\n" in unit_path.read_text( + encoding="utf-8" + ) + run.assert_called_once_with(["systemctl", "--user", "daemon-reload"], check=True) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="launchd reconcile shape") +@pytest.mark.parametrize(("case", "binary", "args", "tail"), STALE_SHAPES) +def test_reconcile_rewrites_stale_launchd_plist_to_journal_start( + monkeypatch, + tmp_path, + case, + binary, + args, + tail, +): + del case + plist_path = tmp_path / "org.solpbc.solstone.plist" + plist_path.write_bytes( + plistlib.dumps( + { + "Label": service.SERVICE_LABEL, + "ProgramArguments": [_old_binary(tmp_path, binary), *args], + } + ) + ) + run = MagicMock(return_value=subprocess.CompletedProcess([], 0, "", "")) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(service, "_plist_path", lambda: plist_path) + monkeypatch.setattr(service, "_managed_wrapper", _managed(tmp_path)) + monkeypatch.setattr(service.os, "getuid", lambda: 501) + monkeypatch.setattr(service.subprocess, "run", run) + + result = service.reconcile_installed_unit() + + data = plistlib.loads(plist_path.read_bytes()) + assert result.was_stale is True + assert result.stale_binary == binary + assert result.stale_verb == "supervisor" + assert data["ProgramArguments"] == [ + str(tmp_path / "current" / "journal"), + "start", + *tail, + ] + assert run.call_args_list == [ + call( + ["launchctl", "bootout", "gui/501", str(plist_path)], + capture_output=True, + ), + call( + ["launchctl", "bootstrap", "gui/501", str(plist_path)], + capture_output=True, + text=True, + ), + ] + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd reconcile shape") +@pytest.mark.parametrize("tail", [[], ["5015"]]) +def test_reconcile_canonical_systemd_noop(monkeypatch, tmp_path, tail): + unit_path = tmp_path / "solstone.service" + current = [str(tmp_path / "current" / "journal"), "start", *tail] + unit_path.write_text( + "[Unit]\n" + "Description=Solstone Supervisor\n" + "[Service]\n" + f"ExecStart={shlex.join(current)}\n", + encoding="utf-8", + ) + run = MagicMock() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(service, "_unit_path", lambda: unit_path) + monkeypatch.setattr(service, "_managed_wrapper", _managed(tmp_path)) + monkeypatch.setattr(service.subprocess, "run", run) + + result = service.reconcile_installed_unit() + + assert result.was_stale is False + assert unit_path.read_text(encoding="utf-8").count("ExecStart=") == 1 + run.assert_not_called() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="launchd reconcile shape") +@pytest.mark.parametrize("tail", [[], ["5015"]]) +def test_reconcile_canonical_launchd_noop(monkeypatch, tmp_path, tail): + plist_path = tmp_path / "org.solpbc.solstone.plist" + current = [str(tmp_path / "current" / "journal"), "start", *tail] + plist_path.write_bytes( + plistlib.dumps({"Label": service.SERVICE_LABEL, "ProgramArguments": current}) + ) + run = MagicMock() + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(service, "_plist_path", lambda: plist_path) + monkeypatch.setattr(service, "_managed_wrapper", _managed(tmp_path)) + monkeypatch.setattr(service.subprocess, "run", run) + + result = service.reconcile_installed_unit() + + assert result.was_stale is False + assert plistlib.loads(plist_path.read_bytes())["ProgramArguments"] == current + run.assert_not_called() + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd reconcile shape") +def test_reconcile_no_installed_unit_noop(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(service, "_unit_path", lambda: tmp_path / "missing.service") + + result = service.reconcile_installed_unit() + + assert result == service.Reconciled(False, None, None, None) + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd reconcile shape") +def test_reconcile_parse_error_logs_and_skips(monkeypatch, tmp_path, capsys): + unit_path = tmp_path / "solstone.service" + unit_path.write_text( + '[Unit]\nDescription=Solstone Supervisor\n[Service]\nExecStart="unterminated\n', + encoding="utf-8", + ) + run = MagicMock() + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(service, "_unit_path", lambda: unit_path) + monkeypatch.setattr(service.subprocess, "run", run) + + result = service.reconcile_installed_unit() + + assert result.was_stale is False + assert "invalid ExecStart" in capsys.readouterr().err + run.assert_not_called() diff --git a/tests/test_setup.py b/tests/test_setup.py index c5fd7ad91..6dd9532e8 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -183,9 +183,7 @@ def expected_doctor_command(port: int = 5015) -> list[str]: def expected_install_models_command() -> list[str]: return [ - sys.executable, - "-m", - "solstone.think.sol_cli", + str(Path(sys.executable).parent / "journal"), "install-models", "--variant", "auto", @@ -224,9 +222,7 @@ def expected_wrapper_command() -> list[str]: def expected_service_install_command(port: int = 5015) -> list[str]: return [ - sys.executable, - "-m", - "solstone.think.sol_cli", + str(Path(sys.executable).parent / "journal"), "service", "install", "--port", @@ -235,7 +231,7 @@ def expected_service_install_command(port: int = 5015) -> list[str]: def expected_service_restart_command() -> list[str]: - return [sys.executable, "-m", "solstone.think.sol_cli", "service", "restart"] + return [str(Path(sys.executable).parent / "journal"), "service", "restart"] def assert_command( @@ -1475,19 +1471,7 @@ def test_packaged_install_runs_service_step( assert unsupported_message not in out assert "solstone is running at http://localhost:5015" in out assert_command(calls, 0, expected_doctor_command()) - assert_command( - calls, - 1, - [ - sys.executable, - "-m", - "solstone.think.sol_cli", - "service", - "install", - "--port", - "5015", - ], - ) + assert_command(calls, 1, expected_service_install_command()) assert len(calls) == 2 steps = read_manifest(journal)["steps"] assert steps[-2]["status"] == "skipped" diff --git a/tests/test_setup_jsonl.py b/tests/test_setup_jsonl.py index 14c557f43..28bee7c60 100644 --- a/tests/test_setup_jsonl.py +++ b/tests/test_setup_jsonl.py @@ -489,20 +489,18 @@ def test_setup_jsonl_end_to_end_first_line_setup_started(tmp_path: Path) -> None home.mkdir() journal = tmp_path / "journal" env = {**os.environ, "HOME": str(home)} + code = ( + "from solstone.think.sol_cli import journal_main; " + "import sys; " + "sys.argv = [" + "'journal', 'setup', '--jsonl', '--yes', '--journal', " + f"{str(journal)!r}, " + "'--skip-models', '--skip-skills', '--skip-service'" + "]; " + "journal_main()" + ) proc = subprocess.Popen( - [ - sys.executable, - "-m", - "solstone.think.sol_cli", - "setup", - "--jsonl", - "--yes", - "--journal", - str(journal), - "--skip-models", - "--skip-skills", - "--skip-service", - ], + [sys.executable, "-c", code], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, diff --git a/tests/test_sol.py b/tests/test_sol.py index 20fad48f5..0d335b7e1 100644 --- a/tests/test_sol.py +++ b/tests/test_sol.py @@ -183,17 +183,16 @@ class TestRunCommand: assert exit_code == 1 def test_main_propagates_integer_return_code_via_real_subprocess(self, tmp_path): - """Would fail on the parent commit because cmd_journal() returned 1 but sol exited 0.""" + """Would fail on the parent commit because cmd_journal() returned 1 but journal exited 0.""" env = {**os.environ, "SOLSTONE_JOURNAL": str(tmp_path)} + code = ( + "from solstone.think.sol_cli import journal_main; " + "import sys; " + "sys.argv = ['journal', 'config', 'journal', '/tmp/with$dollar']; " + "journal_main()" + ) result = subprocess.run( - [ - sys.executable, - "-m", - "solstone.think.sol_cli", - "config", - "journal", - "/tmp/with$dollar", - ], + [sys.executable, "-c", code], capture_output=True, text=True, env=env, @@ -391,26 +390,20 @@ class TestCommandRegistry: f"Alias '{name}' has invalid surface '{command_alias.surface}'" ) - def test_service_entries_dispatch_through_sol_and_journal(self, monkeypatch): - """Service commands and aliases preserve module and preset args on both binaries.""" + def test_service_entries_dispatch_through_journal(self, monkeypatch): + """Service commands and aliases preserve module and preset args on journal.""" for name in service_command_names(): - sol_result = run_dispatch(monkeypatch, "sol", name) journal_result = run_dispatch(monkeypatch, "journal", name) command = sol.COMMANDS[name] - assert sol_result["module"] == command.module assert journal_result["module"] == command.module - assert sol_result["argv"] == [f"sol {name}"] assert journal_result["argv"] == [f"journal {name}"] for name in service_alias_names(): - sol_result = run_dispatch(monkeypatch, "sol", name) journal_result = run_dispatch(monkeypatch, "journal", name) command_alias = sol.ALIASES[name] - assert sol_result["module"] == command_alias.module assert journal_result["module"] == command_alias.module - assert sol_result["argv"] == [f"sol {name}"] + command_alias.preset_args assert ( journal_result["argv"] == [f"journal {name}"] + command_alias.preset_args @@ -456,8 +449,8 @@ class TestCommandRegistry: assert name not in result.stdout assert "sol call" not in result.stdout - def test_sol_help_still_lists_registry_groups_and_aliases(self): - """sol --help still renders all registered top-level entries.""" + def test_sol_help_lists_access_groups_only(self): + """sol --help renders only access top-level entries.""" code = ( "from solstone.think.sol_cli import main; " "import sys; " @@ -497,9 +490,9 @@ class TestCommandRegistry: if name in sol.ALIASES: rendered_aliases.add(name) - assert rendered_commands == set(sol.COMMANDS.keys()) + assert rendered_commands == set(access_command_names()) assert rendered_group_headers == expected_group_headers - assert rendered_aliases == set(sol.ALIASES.keys()) + assert rendered_aliases == set() def test_setproctitle_prefix_uses_active_binary(self, monkeypatch): """The process title identifies whether sol or journal dispatched the command.""" @@ -507,7 +500,7 @@ class TestCommandRegistry: monkeypatch.setattr(sol, "run_command", lambda _module_path: 0) monkeypatch.setattr(sol.setproctitle, "setproctitle", titles.append) - monkeypatch.setattr(sys, "argv", ["sol", "supervisor"]) + monkeypatch.setattr(sys, "argv", ["sol", "health"]) with pytest.raises(SystemExit): sol.main() diff --git a/tests/test_sol_cli_help.py b/tests/test_sol_cli_help.py index b20d489b4..8399a8fb9 100644 --- a/tests/test_sol_cli_help.py +++ b/tests/test_sol_cli_help.py @@ -12,8 +12,6 @@ HELP_HEADINGS = [ sol_cli.SOL_HELP_GROUP_YOUR_JOURNAL, sol_cli.SOL_HELP_GROUP_DIAGNOSE, sol_cli.SOL_HELP_GROUP_TOOLS, - sol_cli.SOL_HELP_GROUP_SERVICE_HEADING, - sol_cli.SOL_HELP_GROUP_ALIASES, ] @@ -25,12 +23,33 @@ def _assigned_groups(command_name: str) -> list[str]: ] -@pytest.mark.parametrize("command_name", sorted(sol_cli.COMMANDS)) +def _access_command_names() -> list[str]: + return sorted( + name + for name, command in sol_cli.COMMANDS.items() + if command.surface == "access" + ) + + +def _service_command_names() -> list[str]: + return sorted( + name + for name, command in sol_cli.COMMANDS.items() + if command.surface == "service" + ) + + +@pytest.mark.parametrize("command_name", _access_command_names()) def test_sol_help_group_assignment_is_exact_partition(command_name: str) -> None: assigned = _assigned_groups(command_name) assert len(assigned) == 1, f"{command_name!r} appears in {assigned!r}" +@pytest.mark.parametrize("command_name", _service_command_names()) +def test_sol_help_group_excludes_service_commands(command_name: str) -> None: + assert _assigned_groups(command_name) == [] + + def test_sol_help_groups_reference_only_registered_commands() -> None: grouped = [] for group in sol_cli.help_groups(): @@ -38,7 +57,7 @@ def test_sol_help_groups_reference_only_registered_commands() -> None: for command_name in group.commands: assert command_name in sol_cli.COMMANDS - assert set(grouped) == set(sol_cli.COMMANDS) + assert set(grouped) == set(_access_command_names()) assert len(grouped) == len(set(grouped)) @@ -55,8 +74,9 @@ def test_sol_help_heading_order_and_apps_position(monkeypatch, capsys) -> None: ) apps_position = lines.index("Apps (sol call ):") - assert heading_positions[sol_cli.SOL_HELP_GROUP_SERVICE_HEADING] < apps_position - assert apps_position < heading_positions[sol_cli.SOL_HELP_GROUP_ALIASES] + assert heading_positions[sol_cli.SOL_HELP_GROUP_TOOLS] < apps_position + assert sol_cli.SOL_HELP_GROUP_SERVICE_HEADING not in lines + assert sol_cli.SOL_HELP_GROUP_ALIASES not in lines assert "Direct module syntax: sol [args]" not in lines diff --git a/tests/test_sol_service_backcompat.py b/tests/test_sol_service_backcompat.py deleted file mode 100644 index 68a26152f..000000000 --- a/tests/test_sol_service_backcompat.py +++ /dev/null @@ -1,52 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -from solstone.think.sol_cli import ALIASES, COMMANDS - - -def _console_script(binary: str) -> Path: - path = Path(sys.executable).parent / binary - if not path.exists(): - pytest.skip(f"{path} is not installed") - return path - - -SERVICE_SURFACE = sorted( - { - *(name for name, command in COMMANDS.items() if command.surface == "service"), - *(name for name, alias in ALIASES.items() if alias.surface == "service"), - } -) - - -@pytest.mark.parametrize("binary", ["sol", "journal"]) -@pytest.mark.parametrize("command", SERVICE_SURFACE) -def test_service_surface_help_works_without_deprecation( - binary: str, - command: str, -) -> None: - env = os.environ.copy() - env["SOLSTONE_JOURNAL"] = str(Path("tests/fixtures/journal").resolve()) - - result = subprocess.run( - [str(_console_script(binary)), command, "--help"], - check=False, - capture_output=True, - text=True, - env=env, - ) - - combined = f"{result.stdout}\n{result.stderr}" - assert result.returncode == 0 - assert "usage:" in result.stdout.lower() - assert "DeprecationWarning" not in combined - assert "deprecat" not in combined.lower() diff --git a/tests/test_sol_service_hard_error.py b/tests/test_sol_service_hard_error.py new file mode 100644 index 000000000..d6fd48edf --- /dev/null +++ b/tests/test_sol_service_hard_error.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from solstone.think import sol_cli +from solstone.think.service import Reconciled + + +def service_command_names() -> list[str]: + return sorted( + name + for name, command in sol_cli.COMMANDS.items() + if command.surface == "service" + ) + + +def service_alias_names() -> list[str]: + return sorted( + name for name, alias in sol_cli.ALIASES.items() if alias.surface == "service" + ) + + +def _patch_no_stale_unit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "solstone.think.service.reconcile_installed_unit", + lambda: Reconciled(False, None, None, None), + ) + + +@pytest.mark.parametrize("name", service_command_names()) +def test_sol_service_commands_hard_error(monkeypatch, capsys, name): + _patch_no_stale_unit(monkeypatch) + monkeypatch.setattr( + sol_cli, + "run_command", + lambda _module_path: pytest.fail("service command should not run"), + ) + monkeypatch.setattr(sys, "argv", ["sol", name]) + + with pytest.raises(SystemExit) as exc_info: + sol_cli.main() + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=name) in captured.err + + +@pytest.mark.parametrize("name", service_alias_names()) +def test_sol_service_aliases_hard_error(monkeypatch, capsys, name): + _patch_no_stale_unit(monkeypatch) + monkeypatch.setattr( + sol_cli, + "run_command", + lambda _module_path: pytest.fail("service alias should not run"), + ) + monkeypatch.setattr(sys, "argv", ["sol", name]) + + with pytest.raises(SystemExit) as exc_info: + sol_cli.main() + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd=name) in captured.err + + +def test_sol_access_commands_still_dispatch(monkeypatch): + result: dict[str, object] = {} + + def fake_run_command(module_path: str) -> int: + result["module"] = module_path + result["argv"] = sys.argv[:] + return 0 + + monkeypatch.setattr(sol_cli, "run_command", fake_run_command) + monkeypatch.setattr(sol_cli.setproctitle, "setproctitle", lambda _title: None) + monkeypatch.setattr(sys, "argv", ["sol", "health"]) + + with pytest.raises(SystemExit) as exc_info: + sol_cli.main() + + assert exc_info.value.code == 0 + assert result == { + "module": sol_cli.COMMANDS["health"].module, + "argv": ["sol health"], + } + + +def test_stale_sol_unit_execs_journal(monkeypatch): + execv = MagicMock(side_effect=RuntimeError("execv called")) + monkeypatch.setattr( + "solstone.think.service.reconcile_installed_unit", + lambda: Reconciled(True, "sol", "supervisor", Path("unit")), + ) + monkeypatch.setattr( + "solstone.think.service._managed_wrapper", + lambda binary: f"/tmp/{binary}", + ) + monkeypatch.setattr(sol_cli.os, "execv", execv) + monkeypatch.setattr(sys, "argv", ["sol", "supervisor", "5015"]) + + with pytest.raises(RuntimeError, match="execv called"): + sol_cli.main() + + execv.assert_called_once_with( + "/tmp/journal", ["/tmp/journal", "supervisor", "5015"] + ) + + +def test_mismatched_stale_unit_does_not_unlock_human_sol_service(monkeypatch, capsys): + monkeypatch.setattr( + "solstone.think.service.reconcile_installed_unit", + lambda: Reconciled(True, "sol", "heartbeat", Path("unit")), + ) + monkeypatch.setattr(sys, "argv", ["sol", "supervisor"]) + + with pytest.raises(SystemExit) as exc_info: + sol_cli.main() + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert ( + sol_cli.SOL_SERVICE_CMD_REMOVED_ERROR.format(cmd="supervisor") in captured.err + ) + + +def test_sol_service_hard_error_exit_code_is_2(monkeypatch): + _patch_no_stale_unit(monkeypatch) + monkeypatch.setattr(sys, "argv", ["sol", "supervisor"]) + + with pytest.raises(SystemExit) as exc_info: + sol_cli.main() + + assert exc_info.value.code == 2 diff --git a/uv.lock b/uv.lock index 25b039d02..308ec2e64 100644 --- a/uv.lock +++ b/uv.lock @@ -4317,7 +4317,7 @@ wheels = [ [[package]] name = "solstone" -version = "0.3.10" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, diff --git a/vpe/workspace/plan-l7-services-scout-device-code-disable.md b/vpe/workspace/plan-l7-services-scout-device-code-disable.md index 53b466c3a..a62bd8824 100644 --- a/vpe/workspace/plan-l7-services-scout-device-code-disable.md +++ b/vpe/workspace/plan-l7-services-scout-device-code-disable.md @@ -1,6 +1,6 @@ # L7 Plan: scout device-code enable + disable -Ship a headless device-code enable flow for `sol services enable scout`, add `sol services disable scout`, and remove the `headless_no_browser` token. +Ship a headless device-code enable flow for `journal services enable scout`, add `journal services disable scout`, and remove the `headless_no_browser` token. ## D1 - Disable-verification fingerprint @@ -222,5 +222,5 @@ Ship a headless device-code enable flow for `sol services enable scout`, add `so ## Risks / open questions - Worker-contract exact values for `DEVICE_CODE_PREFIX`, `DEVICE_CODE_REGEX.pattern`, device-code POST path, and device-code entry URL are not present in this repo; implement from the L7 scope/portal contract, not from local inference. -- `unknown_service` copy currently says `Use sol services enable scout.`; acceptable for current token but may read oddly for `disable foo`. Keep unless product copy explicitly changes. +- `unknown_service` copy currently says `Use journal services enable scout.`; acceptable for current token but may read oddly for `disable foo`. Keep unless product copy explicitly changes. - Preserve empty `env` / `services` sections conservatively unless tests or existing config conventions require cleanup. diff --git a/vpe/workspace/review-l5c-services-enable-scout.md b/vpe/workspace/review-l5c-services-enable-scout.md index 527270af9..85c4fa132 100644 --- a/vpe/workspace/review-l5c-services-enable-scout.md +++ b/vpe/workspace/review-l5c-services-enable-scout.md @@ -1,4 +1,4 @@ -# L5C Review Gate: `sol services enable scout` + storage module +# L5C Review Gate: `journal services enable scout` + storage module Parent plan: vpe/workspace/plan-arc-a-back-channel-rederivation.md (Wave 4 — L5C) Worker contract: services.solstone.app (shipped 2026-05-24, source not vendored) @@ -23,7 +23,7 @@ Trade-off accepted — adjacent singular/plural groups are mildly ambiguous in ` ## §4 — Wall-clock budget & rationale -Decision: expose `--wait ` on `sol services enable scout`, default `900`, clamped to `[60, 3600]` by the argparse `type=` converter. Rationale: owner-patience budget for the in-browser consent flow. Do not describe it as matching `HANDOFF_TTL_MS`; the worker constant is five minutes and governs the post-consent handoff row, not the whole human flow. +Decision: expose `--wait ` on `journal services enable scout`, default `900`, clamped to `[60, 3600]` by the argparse `type=` converter. Rationale: owner-patience budget for the in-browser consent flow. Do not describe it as matching `HANDOFF_TTL_MS`; the worker constant is five minutes and governs the post-consent handoff row, not the whole human flow. Per-poll request timeout: `35s`, giving five seconds of local slack over the worker’s documented `HANDOFF_POLL_BUDGET_MS = 30s`. The CLI should keep polling until the wall-clock budget expires, a terminal worker result arrives, or a local boundary failure occurs. @@ -49,8 +49,8 @@ Error tokens: - `already_enabled`: Scout is already enabled. No change needed. - `manual_key_present`: A manual Gemini key is already present in journal config. Use --force to overwrite with a portal-provisioned key. - `headless_no_browser`: No browser is available from this shell. Rerun from a desktop session. -- `journal_not_initialized`: Journal config file is missing. Run `sol setup`, then retry. -- `unknown_service`: Unknown service name. Use `sol services enable scout`. +- `journal_not_initialized`: Journal config file is missing. Run `journal setup`, then retry. +- `unknown_service`: Unknown service name. Use `journal services enable scout`. | Token | Trigger | |-------|---------| @@ -65,7 +65,7 @@ Error tokens: | `manual_key_present` | `is_manual_key_present()` returns True before browser opens | | `headless_no_browser` | `_is_headless()` returns True before browser opens | | `journal_not_initialized` | journal config file does not exist at flow start | -| `unknown_service` | argparse-level: `sol services enable ` | +| `unknown_service` | argparse-level: `journal services enable ` | Exit codes: `already_enabled` and `manual_key_present` exit 0; `headless_no_browser` and `unknown_service` exit 2; all other eight tokens exit 1. Grep-tested the 15 CLI strings against `r"sign(?:ed)?\s+in|signing\s+in|log(?:ged)?\s+in|your\s+account|account\s+settings|linked|authenticate"`: zero matches. -- 2.51.2