diff --git a/apps/awareness/call.py b/apps/awareness/call.py index 16f7e232e..f6b96aa48 100644 --- a/apps/awareness/call.py +++ b/apps/awareness/call.py @@ -10,9 +10,16 @@ import json import typer +from think.utils import require_solstone + app = typer.Typer(help="Awareness system — solstone's self-knowledge.") +@app.callback() +def _require_up() -> None: + require_solstone() + + @app.command("status") def status( section: str | None = typer.Argument( diff --git a/apps/calendar/call.py b/apps/calendar/call.py index 177cd4597..dbd8b9707 100644 --- a/apps/calendar/call.py +++ b/apps/calendar/call.py @@ -15,10 +15,16 @@ import typer from apps.calendar import event from think.facets import log_call_action +from think.utils import require_solstone app = typer.Typer(help="Calendar event management.") +@app.callback() +def _require_up() -> None: + require_solstone() + + def _print_day_facet(day: str, facet: str) -> bool: """Print calendar events for a single day+facet. Returns True if any exist.""" event_day = event.EventDay.load(day, facet) diff --git a/apps/entities/call.py b/apps/entities/call.py index 5001e2f27..1e4717032 100644 --- a/apps/entities/call.py +++ b/apps/entities/call.py @@ -42,11 +42,22 @@ from think.indexer.journal import ( get_entity_strength, search_entities, ) -from think.utils import get_journal, now_ms, resolve_sol_day, resolve_sol_facet +from think.utils import ( + get_journal, + now_ms, + require_solstone, + resolve_sol_day, + resolve_sol_facet, +) app = typer.Typer(help="Entity management.") +@app.callback() +def _require_up() -> None: + require_solstone() + + def _clear_all_caches(): """Clear all underlying think entity caches.""" clear_entity_loading_cache() diff --git a/apps/import/call.py b/apps/import/call.py index cb17206bc..51f4ae6a1 100644 --- a/apps/import/call.py +++ b/apps/import/call.py @@ -29,10 +29,16 @@ from think.entities.relationships import ( load_facet_relationship, save_facet_relationship, ) -from think.utils import get_journal +from think.utils import get_journal, require_solstone app = typer.Typer(help="Import review and resolution.") + +@app.callback() +def _require_up() -> None: + require_solstone() + + ingest = import_module("apps.import.ingest") journal_sources = import_module("apps.import.journal_sources") diff --git a/apps/photos/call.py b/apps/photos/call.py index 9c47971d8..d5dd972d3 100644 --- a/apps/photos/call.py +++ b/apps/photos/call.py @@ -5,6 +5,8 @@ import sys import typer +from think.utils import require_solstone + app = typer.Typer( name="photos", help="Photo intelligence from macOS Photos library.", @@ -12,6 +14,11 @@ app = typer.Typer( ) +@app.callback() +def _require_up() -> None: + require_solstone() + + @app.command("sync") def sync( library: str | None = typer.Option( diff --git a/apps/settings/call.py b/apps/settings/call.py index 804eafbce..e9ec0eff6 100644 --- a/apps/settings/call.py +++ b/apps/settings/call.py @@ -14,9 +14,18 @@ from pathlib import Path import typer +from think.utils import require_solstone + app = typer.Typer( help="Journal settings — keys, providers, transcription, identity, and observer." ) + + +@app.callback() +def _require_up() -> None: + require_solstone() + + keys_app = typer.Typer(help="API key management.") app.add_typer(keys_app, name="keys") providers_app = typer.Typer(help="AI provider configuration.") diff --git a/apps/sol/call.py b/apps/sol/call.py index 711290b19..7bbe3a156 100644 --- a/apps/sol/call.py +++ b/apps/sol/call.py @@ -14,9 +14,16 @@ from pathlib import Path import typer +from think.utils import require_solstone + app = typer.Typer(help="Agent identity — name and status.") +@app.callback() +def _require_up() -> None: + require_solstone() + + def _get_agent_config() -> dict: """Read agent config from journal config.""" from think.utils import get_config diff --git a/apps/speakers/call.py b/apps/speakers/call.py index 7b725ce67..6dcf6ff0c 100644 --- a/apps/speakers/call.py +++ b/apps/speakers/call.py @@ -27,6 +27,8 @@ from __future__ import annotations import typer +from think.utils import require_solstone + app = typer.Typer( name="speakers", help="Speaker voiceprint management.", @@ -34,6 +36,11 @@ app = typer.Typer( ) +@app.callback() +def _require_up() -> None: + require_solstone() + + @app.command("status") def status( section: str | None = typer.Argument( diff --git a/apps/todos/call.py b/apps/todos/call.py index d8d045ca8..0fe53616a 100644 --- a/apps/todos/call.py +++ b/apps/todos/call.py @@ -12,11 +12,16 @@ import typer from apps.todos import todo from think.facets import log_call_action -from think.utils import get_journal +from think.utils import get_journal, require_solstone app = typer.Typer(help="Todo checklist management.") +@app.callback() +def _require_up() -> None: + require_solstone() + + def _print_day_facet(day: str, facet: str) -> bool: """Print todos for a single day+facet. Returns True if any items exist.""" checklist = todo.TodoChecklist.load(day, facet) diff --git a/convey/restart.py b/convey/restart.py index 074ce4495..0104b5340 100644 --- a/convey/restart.py +++ b/convey/restart.py @@ -12,7 +12,7 @@ import threading import time from think.callosum import CallosumConnection -from think.utils import read_service_port, setup_cli +from think.utils import read_service_port, require_solstone, setup_cli def _format_log(timestamp: float, stream: str, line: str) -> str: @@ -178,6 +178,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() from think.utils import get_journal diff --git a/convey/screenshot.py b/convey/screenshot.py index f76457f0a..6a760218f 100644 --- a/convey/screenshot.py +++ b/convey/screenshot.py @@ -12,7 +12,7 @@ import sys from playwright.sync_api import sync_playwright -from think.utils import read_service_port, setup_cli +from think.utils import read_service_port, require_solstone, setup_cli class _HelpOnErrorParser(argparse.ArgumentParser): @@ -169,6 +169,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() # Determine port: CLI arg takes precedence, then port file, then error if args.port is not None: diff --git a/observe/describe.py b/observe/describe.py index e1983d098..d08303b21 100644 --- a/observe/describe.py +++ b/observe/describe.py @@ -41,6 +41,7 @@ from think.utils import ( get_config, get_journal, journal_relative_path, + require_solstone, setup_cli, ) @@ -902,6 +903,7 @@ async def async_main(): help="Reprocess file, overwriting existing outputs", ) args = setup_cli(parser) + require_solstone() video_path = Path(args.video_path) if not video_path.exists(): diff --git a/observe/observer_cli.py b/observe/observer_cli.py index 2b799372d..b6028621a 100644 --- a/observe/observer_cli.py +++ b/observe/observer_cli.py @@ -33,7 +33,7 @@ from apps.observer.utils import ( save_observer, ) from apps.utils import log_app_action -from think.utils import now_ms, setup_cli +from think.utils import now_ms, require_solstone, setup_cli logger = logging.getLogger(__name__) @@ -458,6 +458,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() # Bridge journal path to convey.state so apps.utils resolves correctly # (setup_cli initializes the journal, but convey.state needs it too) diff --git a/observe/sense.py b/observe/sense.py index 5bc64caad..ccbd1f742 100644 --- a/observe/sense.py +++ b/observe/sense.py @@ -33,6 +33,7 @@ from think.utils import ( iter_segments, journal_relative_path, now_ms, + require_solstone, resolve_journal_path, setup_cli, ) @@ -1069,6 +1070,7 @@ def main(): help="Show what would be processed (or deleted with --reprocess) without making changes", ) args = setup_cli(parser) + require_solstone() journal = Path(get_journal()) diff --git a/observe/transcribe/main.py b/observe/transcribe/main.py index 9365effd1..7066efa23 100644 --- a/observe/transcribe/main.py +++ b/observe/transcribe/main.py @@ -78,6 +78,7 @@ from think.utils import ( get_journal, iter_segments, journal_relative_path, + require_solstone, resolve_journal_path, setup_cli, ) @@ -806,6 +807,7 @@ def main(): help=f"STT backend to use (overrides config, default: {DEFAULT_BACKEND})", ) args = setup_cli(parser) + require_solstone() if args.all and args.audio_path: parser.error("--all and audio_path are mutually exclusive") diff --git a/observe/transfer.py b/observe/transfer.py index 9099579ea..b61678299 100644 --- a/observe/transfer.py +++ b/observe/transfer.py @@ -35,6 +35,7 @@ from think.utils import ( get_journal, iter_segments, now_ms, + require_solstone, setup_cli, ) @@ -692,6 +693,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() if args.command == "export": try: diff --git a/tests/conftest.py b/tests/conftest.py index e28647a8a..a5191eef0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,7 @@ def set_test_journal_path(request, monkeypatch): "_SOLSTONE_JOURNAL_OVERRIDE", str(Path("tests/fixtures/journal").resolve()), ) + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") @pytest.fixture(autouse=True) diff --git a/tests/test_think_utils.py b/tests/test_think_utils.py index 7e4b563d4..42cccf14e 100644 --- a/tests/test_think_utils.py +++ b/tests/test_think_utils.py @@ -6,6 +6,7 @@ import argparse import json import os +import socket import sys import tempfile from datetime import time @@ -827,6 +828,101 @@ class TestPortDiscovery: assert (health_dir / "new_service.port").read_text() == "9999" +class TestSolstoneGuard: + """Tests for solstone availability guard helpers.""" + + def test_is_solstone_up_false_without_port_file(self, monkeypatch, tmp_path): + """Missing convey port file reports stack down.""" + from think.utils import is_solstone_up + + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + assert is_solstone_up() is False + + def test_is_solstone_up_false_with_closed_port(self, monkeypatch, tmp_path): + """Stale convey port file reports stack down.""" + from think.utils import is_solstone_up, write_service_port + + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + stale_port = sock.getsockname()[1] + + write_service_port("convey", stale_port) + assert is_solstone_up() is False + + def test_is_solstone_up_true_with_listening_server(self, monkeypatch, tmp_path): + """Listening convey port reports stack up.""" + from think.utils import is_solstone_up, write_service_port + + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + with socket.socket() as server: + server.bind(("127.0.0.1", 0)) + server.listen(1) + write_service_port("convey", server.getsockname()[1]) + assert is_solstone_up() is True + + def test_require_solstone_exits_with_message_when_down( + self, monkeypatch, tmp_path, capsys + ): + """Guard exits with the expected message when convey is unavailable.""" + from think.utils import require_solstone + + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + with pytest.raises(SystemExit) as excinfo: + require_solstone() + + captured = capsys.readouterr() + assert excinfo.value.code == 1 + assert captured.out == "" + assert ( + captured.err + == "sol: solstone isn't running. Start it with 'sol up' and retry.\n" + ) + + def test_require_solstone_returns_silently_when_up( + self, monkeypatch, tmp_path, capsys + ): + """Guard returns None without output when convey is reachable.""" + from think.utils import require_solstone, write_service_port + + monkeypatch.delenv("SOL_SKIP_SUPERVISOR_CHECK", raising=False) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + + with socket.socket() as server: + server.bind(("127.0.0.1", 0)) + server.listen(1) + write_service_port("convey", server.getsockname()[1]) + assert require_solstone() is None + + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_require_solstone_skips_check_with_env_override( + self, monkeypatch, tmp_path + ): + """SOL_SKIP_SUPERVISOR_CHECK bypasses availability probing.""" + import think.utils as utils + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") + monkeypatch.setattr( + utils, + "is_solstone_up", + lambda timeout=0.2: (_ for _ in ()).throw(AssertionError("should not run")), + ) + + assert utils.require_solstone() is None + + class TestIterSegments: def test_skips_health_directory(self, tmp_path): """iter_segments does not return segments from health/ dirs.""" diff --git a/think/agents.py b/think/agents.py index a7e1cd9ed..62034a5fa 100644 --- a/think/agents.py +++ b/think/agents.py @@ -46,6 +46,7 @@ from think.utils import ( format_segment_times, get_journal, now_ms, + require_solstone, segment_parse, setup_cli, ) @@ -1500,6 +1501,7 @@ async def main_async() -> None: ) args = setup_cli(parser) + require_solstone() if args.subcommand == "check": await _run_check(args) return diff --git a/think/chat_cli.py b/think/chat_cli.py index 6c2d282bc..0b49ad428 100644 --- a/think/chat_cli.py +++ b/think/chat_cli.py @@ -11,7 +11,7 @@ import threading from think.callosum import CallosumConnection from think.cortex_client import cortex_request, read_agent_events -from think.utils import setup_cli +from think.utils import require_solstone, setup_cli def main() -> None: @@ -27,6 +27,7 @@ def main() -> None: "--talent", default="unified", help="Talent agent name (default: unified)" ) args = setup_cli(parser) + require_solstone() from think.awareness import ensure_sol_directory diff --git a/think/cortex.py b/think/cortex.py index bd314bbc1..02827ebae 100644 --- a/think/cortex.py +++ b/think/cortex.py @@ -771,10 +771,11 @@ def main() -> None: """CLI entry point for the Cortex service.""" import argparse - from think.utils import setup_cli + from think.utils import require_solstone, setup_cli parser = argparse.ArgumentParser(description="solstone Cortex Agent Manager") args = setup_cli(parser) + require_solstone() # Set up logging logging.basicConfig( diff --git a/think/dream.py b/think/dream.py index 0d1834e17..ee44198c4 100644 --- a/think/dream.py +++ b/think/dream.py @@ -46,6 +46,7 @@ from think.utils import ( iso_date, iter_segments, now_ms, + require_solstone, setup_cli, updated_days, ) @@ -2764,6 +2765,7 @@ def main() -> None: parser = parse_args() args = setup_cli(parser) + require_solstone() from think.awareness import ensure_sol_directory diff --git a/think/engage.py b/think/engage.py index 875246ede..1121b3e2d 100644 --- a/think/engage.py +++ b/think/engage.py @@ -10,6 +10,8 @@ import sys import typer +from think.utils import require_solstone + engage_app = typer.Typer(name="engage") @@ -96,4 +98,8 @@ def engage( def main() -> None: """Entry point for ``sol engage``.""" + if any(arg in {"-h", "--help"} for arg in sys.argv[1:]): + engage_app() + return + require_solstone() engage_app() diff --git a/think/heartbeat.py b/think/heartbeat.py index fae6b718b..2c11fa9e2 100644 --- a/think/heartbeat.py +++ b/think/heartbeat.py @@ -15,7 +15,7 @@ from pathlib import Path from think.awareness import ensure_sol_directory from think.cortex_client import cortex_request, wait_for_agents -from think.utils import get_journal, setup_cli +from think.utils import get_journal, require_solstone, setup_cli logger = logging.getLogger(__name__) @@ -54,6 +54,7 @@ def main() -> None: help="Run full check regardless of recency", ) args = setup_cli(parser) + require_solstone() journal = Path(get_journal()) ensure_sol_directory() diff --git a/think/importers/cli.py b/think/importers/cli.py index 898bfe3d2..8fc6b7aa1 100644 --- a/think/importers/cli.py +++ b/think/importers/cli.py @@ -25,7 +25,14 @@ from think.importers.shared import ( from think.importers.text import _read_transcript, process_transcript from think.importers.utils import save_import_segments from think.streams import stream_name, update_stream, write_segment_stream -from think.utils import day_path, get_journal, get_rev, segment_key, setup_cli +from think.utils import ( + day_path, + get_journal, + get_rev, + require_solstone, + segment_key, + setup_cli, +) logger = logging.getLogger(__name__) @@ -323,6 +330,7 @@ def main() -> None: help="Output results as JSON (file importers only)", ) args, extra = setup_cli(parser, parse_known=True) + require_solstone() if extra and not args.timestamp: args.timestamp = extra[0] diff --git a/think/indexer/cli.py b/think/indexer/cli.py index 51668f3ce..0942afc7e 100644 --- a/think/indexer/cli.py +++ b/think/indexer/cli.py @@ -6,7 +6,7 @@ import argparse from typing import Any -from think.utils import get_journal, journal_log, setup_cli +from think.utils import get_journal, journal_log, require_solstone, setup_cli from .journal import ( index_file, @@ -155,6 +155,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() journal = get_journal() if ( diff --git a/think/notify_cli.py b/think/notify_cli.py index 100496137..5d4f4bcd2 100644 --- a/think/notify_cli.py +++ b/think/notify_cli.py @@ -5,7 +5,7 @@ import argparse import sys from think.callosum import callosum_send -from think.utils import setup_cli +from think.utils import require_solstone, setup_cli def main() -> None: @@ -32,6 +32,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() message = " ".join(args.message) kwargs = {"message": message} diff --git a/think/password_cli.py b/think/password_cli.py index d39134bca..c521a6b56 100644 --- a/think/password_cli.py +++ b/think/password_cli.py @@ -19,7 +19,7 @@ from pathlib import Path from werkzeug.security import generate_password_hash -from think.utils import get_config, get_journal, setup_cli +from think.utils import get_config, get_journal, require_solstone, setup_cli def _set_password() -> None: @@ -54,6 +54,7 @@ def main() -> None: subparsers.add_parser("reset", help="Reset the convey password") args = setup_cli(parser) + require_solstone() if args.subcommand in ("set", "reset"): _set_password() diff --git a/think/scheduler.py b/think/scheduler.py index ced565807..5445e0225 100644 --- a/think/scheduler.py +++ b/think/scheduler.py @@ -22,7 +22,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any -from think.utils import get_journal, now_ms, setup_cli +from think.utils import get_journal, now_ms, require_solstone, setup_cli logger = logging.getLogger(__name__) @@ -546,6 +546,7 @@ def main() -> None: """CLI entry point for sol schedule.""" parser = argparse.ArgumentParser(description="Show scheduled tasks") setup_cli(parser) + require_solstone() journal = Path(get_journal()) config_path = journal / "config" / "schedules.json" diff --git a/think/segment.py b/think/segment.py index 73572374f..355352a04 100644 --- a/think/segment.py +++ b/think/segment.py @@ -26,6 +26,7 @@ from think.utils import ( day_path, get_journal, iter_segments, + require_solstone, segment_parse, setup_cli, ) @@ -861,6 +862,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() if args.subcommand is None: parser.print_help() diff --git a/think/streams.py b/think/streams.py index 709f48119..e49881a4d 100644 --- a/think/streams.py +++ b/think/streams.py @@ -382,7 +382,7 @@ def main() -> None: """CLI entry point for sol streams.""" import argparse - from think.utils import setup_cli + from think.utils import require_solstone, setup_cli parser = argparse.ArgumentParser(description="Inspect and manage stream identity") parser.add_argument( @@ -397,6 +397,7 @@ def main() -> None: ) args = setup_cli(parser) + require_solstone() if args.rebuild: summary = rebuild_stream_state(name=args.name) diff --git a/think/tools/call.py b/think/tools/call.py index 0c09e8d0f..0bb641178 100644 --- a/think/tools/call.py +++ b/think/tools/call.py @@ -44,6 +44,7 @@ from think.utils import ( day_path, get_journal, iter_segments, + require_solstone, resolve_sol_day, resolve_sol_facet, resolve_sol_segment, @@ -52,6 +53,13 @@ from think.utils import ( app = typer.Typer(help="Journal search and browsing.") facet_app = typer.Typer(help="Facet management.") + + +@app.callback() +def _require_up() -> None: + require_solstone() + + app.add_typer(facet_app, name="facet") retention_app = typer.Typer(help="Media retention management.") app.add_typer(retention_app, name="retention") diff --git a/think/tools/navigate.py b/think/tools/navigate.py index ace40a806..d002ff9ce 100644 --- a/think/tools/navigate.py +++ b/think/tools/navigate.py @@ -8,6 +8,8 @@ Mounted by ``think.call`` as ``sol call navigate ...``. import typer +from think.utils import require_solstone + app = typer.Typer() @@ -17,6 +19,7 @@ def navigate( facet: str = typer.Option(None, "--facet", "-f", help="Facet to switch to."), ) -> None: """Navigate the browser to a path and/or switch facet.""" + require_solstone() if not path and not facet: typer.echo("Error: provide a path and/or --facet", err=True) raise typer.Exit(1) diff --git a/think/tools/routines.py b/think/tools/routines.py index 115d399e4..35ed8337b 100644 --- a/think/tools/routines.py +++ b/think/tools/routines.py @@ -18,11 +18,16 @@ import frontmatter import typer from think.routines import _run_routine, cron_matches, get_config, save_config -from think.utils import get_journal +from think.utils import get_journal, require_solstone app = typer.Typer(help="Manage custom routines.") +@app.callback() +def _require_up() -> None: + require_solstone() + + def _resolve_id(config: dict[str, dict], prefix: str) -> str: """Resolve a routine by UUID prefix or exact name (case-insensitive).""" matches = sorted( diff --git a/think/tools/sol.py b/think/tools/sol.py index ce4872f48..2ee3b4f3a 100644 --- a/think/tools/sol.py +++ b/think/tools/sol.py @@ -24,13 +24,18 @@ from think.awareness import ( update_self_md_section, ) from think.entities.core import atomic_write -from think.utils import day_dirs, day_path +from think.utils import day_dirs, day_path, require_solstone app = typer.Typer( help="Sol identity directory — self.md, partner.md, agency.md, pulse.md, awareness.md, and morning briefing." ) +@app.callback() +def _require_up() -> None: + require_solstone() + + def _sol_dir(): """Return the sol/ directory path, creating it if needed.""" return ensure_sol_directory() diff --git a/think/utils.py b/think/utils.py index ed81ee96f..f965cd18f 100644 --- a/think/utils.py +++ b/think/utils.py @@ -16,6 +16,7 @@ import json import logging import os import re +import socket import sys import time from datetime import datetime @@ -931,3 +932,28 @@ def read_service_port(service: str) -> int | None: return int(port_file.read_text().strip()) except (FileNotFoundError, ValueError): return None + + +def is_solstone_up(timeout: float = 0.2) -> bool: + """Return True if convey is accepting TCP connections on its recorded port.""" + port = read_service_port("convey") + if port is None: + return False + try: + with socket.create_connection(("127.0.0.1", port), timeout=timeout): + return True + except OSError: + return False + + +def require_solstone() -> None: + """Exit(1) with a clear message if solstone's stack isn't running.""" + if os.environ.get("SOL_SKIP_SUPERVISOR_CHECK") == "1": + return + if is_solstone_up(): + return + print( + "sol: solstone isn't running. Start it with 'sol up' and retry.", + file=sys.stderr, + ) + sys.exit(1)