From ae062c4ce326675b3ce990c1c28083cc356d343b Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 12 Jun 2026 23:30:00 -0600 Subject: [PATCH] refactor(sol): make base access surface import-clean without journal-host deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the thin-client `sol` access surface import-clean and runnable when journal-host dependency families (Flask/web, provider SDKs, media/ML libs, importer/indexer modules) are absent. - Defer Flask in solstone/apps/__init__.py (TYPE_CHECKING Blueprint) — cascade clears flask/werkzeug/jinja2 from chat, call, link, apps. Same for the secure_listener werkzeug path on link. - Classify provider errors SDK-free in providers/shared.py (string match on exception module/name) — clears anthropic/openai/google.genai/httpx/PIL from the providers.state / provider_readiness path. - Lazy-load writer/indexer/readiness chains: chat_stream writers + chat_cli chat_view (numpy); tools.health + tools.call + facets (numpy/PIL/soundfile/ frontmatter). - Re-cut `sol import` as a thin Convey-HTTP client (solstone/think/import_client.py): KEEP modes over HTTP (/app/import/api/save|save-path|start), REJECT journal-host modes (--dry-run/--sync/--backends/--list-importers/--auto ) with a service hint, RELOCATE journal-source to `sol call import`. No silent flag drop. - Move the heavy importer engine behind a new `journal importer` service command; repoint the import-start + Plaud/Granola/Obsidian sync web queues off `sol import` (breaks the recursive supervisor loop). - Add a dispatch-level journal-extra hint in run_command() for service commands that fail on a missing journal-host dep. - Add scripts/check_access_imports_clean.py (CI-wired smoke guard) + self-test that blocks the heavy families and asserts the full access matrix + hint cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 7 + scripts/check_access_imports_clean.py | 285 +++++++++++++++ solstone/apps/__init__.py | 9 +- solstone/apps/import/routes.py | 2 +- solstone/apps/settings/routes.py | 10 +- .../tests/test_facet_config_routes.py | 22 +- solstone/convey/chat_stream.py | 12 +- solstone/convey/secure_listener/__init__.py | 22 +- solstone/think/call.py | 10 + solstone/think/chat_cli.py | 3 +- solstone/think/facets.py | 3 +- solstone/think/import_client.py | 344 ++++++++++++++++++ solstone/think/importers/cli.py | 10 +- solstone/think/link/list_cli.py | 3 +- solstone/think/providers/shared.py | 166 +++++---- solstone/think/sol_cli.py | 42 ++- solstone/think/tools/call.py | 7 +- solstone/think/tools/health.py | 6 +- tests/spl/test_service.py | 6 +- tests/test_access_imports_lazy.py | 94 +++++ tests/test_backup_cli.py | 6 +- tests/test_chat_stream_sol_initiated.py | 4 +- tests/test_check_access_imports_clean.py | 48 +++ tests/test_convey_apps.py | 4 +- tests/test_convey_chat_sol_initiated.py | 4 +- tests/test_import_client.py | 294 +++++++++++++++ tests/test_import_start.py | 37 +- tests/test_importer_audio_sync.py | 4 +- tests/test_local_command_home.py | 6 +- ...nt_005_migrate_dream_to_think_schedules.py | 2 +- tests/test_provider_state.py | 102 ++++++ tests/test_scheduler.py | 25 +- tests/test_sol.py | 105 +++++- tests/test_sol_service_hard_error.py | 14 +- tests/test_supervisor.py | 24 +- 35 files changed, 1583 insertions(+), 159 deletions(-) create mode 100644 scripts/check_access_imports_clean.py create mode 100644 solstone/think/import_client.py create mode 100644 tests/test_access_imports_lazy.py create mode 100644 tests/test_check_access_imports_clean.py create mode 100644 tests/test_import_client.py diff --git a/Makefile b/Makefile index add8ca027..38793fbdd 100644 --- a/Makefile +++ b/Makefile @@ -401,6 +401,9 @@ install-checks: .installed @echo "=== Running tools-http-only check ===" @$(MAKE) check-tools-http-only @echo "" + @echo "=== Running access-imports-clean check ===" + @$(MAKE) check-access-imports-clean + @echo "" @echo "=== Running cogitate-prompt check ===" @$(MAKE) check-cogitate-prompts @echo "" @@ -489,6 +492,10 @@ check-call-http-only: .installed check-tools-http-only: .installed $(VENV_BIN)/python scripts/check_tools_http_only.py +# Thin sol access surface import-clean gate +check-access-imports-clean: .installed + $(VENV_BIN)/python scripts/check_access_imports_clean.py + # Cogitate-prompt static gate (prompts use only on-contract command forms) check-cogitate-prompts: .installed $(VENV_BIN)/python scripts/check_cogitate_prompts.py diff --git a/scripts/check_access_imports_clean.py b/scripts/check_access_imports_clean.py new file mode 100644 index 000000000..dcd553589 --- /dev/null +++ b/scripts/check_access_imports_clean.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Smoke guard for import-clean `sol` access commands.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BLOCKED_FAMILIES = ( + "flask", + "werkzeug", + "jinja2", + "anthropic", + "openai", + "google.genai", + "google.generativeai", + "httpx", + "numpy", + "PIL", + "soundfile", + "av", + "pypdf", + "frontmatter", +) +ACCESS_CASES: tuple[tuple[str, list[str]], ...] = ( + ("sol", ["sol"]), + ("sol --help", ["sol", "--help"]), + ("sol --version", ["sol", "--version"]), + ("sol --path", ["sol", "--path"]), + ("sol root", ["sol", "root"]), + ("sol chat --help", ["sol", "chat", "--help"]), + ("sol call --help", ["sol", "call", "--help"]), + ("sol import --help", ["sol", "import", "--help"]), + ("sol notify --help", ["sol", "notify", "--help"]), + ("sol skills --help", ["sol", "skills", "--help"]), + ("sol link --help", ["sol", "link", "--help"]), + ("sol doctor --help", ["sol", "doctor", "--help"]), +) +HINT_CASES: tuple[tuple[str, list[str]], ...] = ( + ("journal convey --help", ["journal", "convey", "--help"]), + ("journal transcribe --help", ["journal", "transcribe", "--help"]), +) +ROUTING_CASES: tuple[tuple[str, list[str], str], ...] = ( + ( + "service-routing help case", + ["sol", "think", "--help"], + "moved to 'journal think'", + ), + ( + "journal import --help", + ["journal", "import", "--help"], + "is a journal-access command", + ), +) + +CHILD = r""" +import importlib +import json +import os +import sys + +payload = json.loads(sys.argv[1]) +root = payload["root"] +if root not in sys.path: + sys.path.insert(0, root) + +blocked = tuple(payload["blocked"]) + +def blocked_family(fullname): + return any(fullname == family or fullname.startswith(family + ".") for family in blocked) + +class BlockHeavyFinder: + def find_spec(self, fullname, path=None, target=None): + if blocked_family(fullname): + raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) + return None + +sys.meta_path.insert(0, BlockHeavyFinder()) + +real_import_module = importlib.import_module +inject_heavy_module = os.environ.get("SOLSTONE_ACCESS_GUARD_INJECT_HEAVY_MODULE") +inject_mounted_app = os.environ.get("SOLSTONE_ACCESS_GUARD_INJECT_MOUNTED_APP") + +def guarded_import_module(name, package=None): + if inject_heavy_module and name == inject_heavy_module: + __import__("numpy") + if ( + inject_mounted_app + and os.environ.get("SOLSTONE_STRICT_CALL_DISCOVERY") == "1" + and name == f"solstone.apps.{inject_mounted_app}.call" + ): + raise RuntimeError(f"injected mounted app failure: {inject_mounted_app}") + return real_import_module(name, package) + +importlib.import_module = guarded_import_module + +from solstone.think import sol_cli + +sys.argv = payload["argv"] +if payload["argv"][0] == "journal": + sol_cli.journal_main() +else: + sol_cli.main() +""" + + +def _call_app_names(root: Path) -> list[str]: + apps_dir = root / "solstone" / "apps" + if not apps_dir.is_dir(): + return [] + return sorted( + app_dir.name + for app_dir in apps_dir.iterdir() + if app_dir.is_dir() + and not app_dir.name.startswith("_") + and (app_dir / "call.py").is_file() + ) + + +def _run_case( + root: Path, + label: str, + argv: list[str], + *, + strict_call_discovery: bool = False, + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.setdefault("SOLSTONE_JOURNAL", str(root / "tests" / "fixtures" / "journal")) + env["PYTHONPATH"] = ( + str(root) + if not env.get("PYTHONPATH") + else str(root) + os.pathsep + env["PYTHONPATH"] + ) + if strict_call_discovery: + env["SOLSTONE_STRICT_CALL_DISCOVERY"] = "1" + if extra_env: + env.update(extra_env) + payload = { + "root": str(root), + "argv": argv, + "blocked": BLOCKED_FAMILIES, + "label": label, + } + return subprocess.run( + [sys.executable, "-c", CHILD, json.dumps(payload)], + cwd=root, + env=env, + capture_output=True, + text=True, + timeout=90, + ) + + +def _format_failure(label: str, result: subprocess.CompletedProcess[str]) -> str: + return ( + f"access-imports-clean: FAIL {label} exited {result.returncode}\n" + f"--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}" + ) + + +def _has_traceback(result: subprocess.CompletedProcess[str]) -> bool: + return "Traceback (most recent call last)" in result.stdout + result.stderr + + +def _check_access_case( + root: Path, + label: str, + argv: list[str], + *, + extra_env: dict[str, str] | None = None, +) -> list[str]: + strict = label == "sol call --help" + result = _run_case( + root, + label, + argv, + strict_call_discovery=strict, + extra_env=extra_env, + ) + failures: list[str] = [] + if result.returncode != 0: + failures.append(_format_failure(label, result)) + return failures + if _has_traceback(result): + failures.append(f"access-imports-clean: FAIL {label} printed a traceback") + if strict: + missing = [ + app_name + for app_name in _call_app_names(root) + if app_name not in result.stdout + ] + if missing: + failures.append( + f"access-imports-clean: FAIL sol call --help omitted apps: {missing}" + ) + return failures + + +def _check_hint_case(root: Path, label: str, argv: list[str]) -> list[str]: + result = _run_case(root, label, argv) + output = result.stdout + result.stderr + failures: list[str] = [] + if result.returncode == 0: + failures.append(_format_failure(label, result)) + for expected in ( + "this command needs the journal host dependencies", + "pip install 'solstone[journal]'", + "uv tool install 'solstone[journal]'", + ): + if expected not in output: + failures.append( + f"access-imports-clean: FAIL {label} missing hint: {expected}" + ) + if _has_traceback(result): + failures.append(f"access-imports-clean: FAIL {label} printed a traceback") + return failures + + +def _check_routing_case( + root: Path, + label: str, + argv: list[str], + expected: str, +) -> list[str]: + result = _run_case(root, label, argv) + output = result.stdout + result.stderr + failures: list[str] = [] + if result.returncode == 0: + failures.append(_format_failure(label, result)) + if expected not in output: + failures.append( + f"access-imports-clean: FAIL {label} missing routing text: {expected}" + ) + if _has_traceback(result): + failures.append(f"access-imports-clean: FAIL {label} printed a traceback") + return failures + + +def run_checks(root: Path, *, extra_env: dict[str, str] | None = None) -> list[str]: + failures: list[str] = [] + for label, argv in ACCESS_CASES: + failures.extend(_check_access_case(root, label, argv, extra_env=extra_env)) + for label, argv in HINT_CASES: + failures.extend(_check_hint_case(root, label, argv)) + for label, argv, expected in ROUTING_CASES: + failures.extend(_check_routing_case(root, label, argv, expected)) + return failures + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--inject-heavy-module") + parser.add_argument("--inject-mounted-app") + args = parser.parse_args(argv) + + extra_env = {} + if args.inject_heavy_module: + extra_env["SOLSTONE_ACCESS_GUARD_INJECT_HEAVY_MODULE"] = ( + args.inject_heavy_module + ) + if args.inject_mounted_app: + extra_env["SOLSTONE_ACCESS_GUARD_INJECT_MOUNTED_APP"] = args.inject_mounted_app + + failures = run_checks(args.root.resolve(), extra_env=extra_env or None) + if failures: + for failure in failures: + print(failure, file=sys.stderr) + return 1 + print("access-imports-clean: pass") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solstone/apps/__init__.py b/solstone/apps/__init__.py index 7c929f0d7..e97b265cb 100644 --- a/solstone/apps/__init__.py +++ b/solstone/apps/__init__.py @@ -46,9 +46,10 @@ import json import logging from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional -from flask import Blueprint +if TYPE_CHECKING: + from flask import Blueprint logger = logging.getLogger(__name__) @@ -157,6 +158,8 @@ class AppRegistry: def _load_routes_blueprint( self, app_name: str, app_path: Path ) -> tuple[Any, Blueprint]: + from flask import Blueprint + routes_module = importlib.import_module(f"solstone.apps.{app_name}.routes") # Find blueprint - look for *_bp attribute @@ -297,6 +300,8 @@ class AppRegistry: Returns: Blueprint with proper naming and URL prefix """ + from flask import Blueprint + blueprint = Blueprint( f"app:{app_name}", __name__, diff --git a/solstone/apps/import/routes.py b/solstone/apps/import/routes.py index 92800b306..ccd4bdf4c 100644 --- a/solstone/apps/import/routes.py +++ b/solstone/apps/import/routes.py @@ -862,7 +862,7 @@ def import_start() -> Any: setting = metadata.get("setting") # Build command - cmd = ["sol", "import", path, ts] + cmd = ["journal", "importer", path, ts] if facet: cmd.extend(["--facet", facet]) if setting: diff --git a/solstone/apps/settings/routes.py b/solstone/apps/settings/routes.py index e42ff88ce..5204b762d 100644 --- a/solstone/apps/settings/routes.py +++ b/solstone/apps/settings/routes.py @@ -2506,7 +2506,7 @@ def update_sync() -> Any: # Ensure the entry exists with full config if "sync:plaud" not in schedules: schedules["sync:plaud"] = { - "cmd": ["sol", "import", "--sync", "plaud", "--save"], + "cmd": ["journal", "importer", "--sync", "plaud", "--save"], "every": "hourly", } schedules["sync:plaud"]["enabled"] = enabled @@ -2537,8 +2537,8 @@ def update_sync() -> Any: if "sync:granola" not in schedules: schedules["sync:granola"] = { "cmd": [ - "sol", - "import", + "journal", + "importer", "--sync", "granola", "--save", @@ -2573,8 +2573,8 @@ def update_sync() -> Any: if "sync:obsidian" not in schedules: schedules["sync:obsidian"] = { "cmd": [ - "sol", - "import", + "journal", + "importer", "--sync", "obsidian", "--save", diff --git a/solstone/apps/settings/tests/test_facet_config_routes.py b/solstone/apps/settings/tests/test_facet_config_routes.py index b732656b0..6b5cf3fa1 100644 --- a/solstone/apps/settings/tests/test_facet_config_routes.py +++ b/solstone/apps/settings/tests/test_facet_config_routes.py @@ -213,14 +213,28 @@ def test_update_sync_preserves_unrelated_schedule_entry(settings_env): response = client.put( "/app/settings/api/sync", - json={"plaud": {"enabled": True}}, + json={ + "plaud": {"enabled": True}, + "granola": {"enabled": True}, + "obsidian": {"enabled": True}, + }, ) assert response.status_code == 200 raw = json.loads(schedules_path.read_text(encoding="utf-8")) assert raw["unrelated"] == unrelated assert raw["sync:plaud"] == { - "cmd": ["sol", "import", "--sync", "plaud", "--save"], + "cmd": ["journal", "importer", "--sync", "plaud", "--save"], + "every": "hourly", + "enabled": True, + } + assert raw["sync:granola"] == { + "cmd": ["journal", "importer", "--sync", "granola", "--save"], + "every": "hourly", + "enabled": True, + } + assert raw["sync:obsidian"] == { + "cmd": ["journal", "importer", "--sync", "obsidian", "--save"], "every": "hourly", "enabled": True, } @@ -232,3 +246,7 @@ def test_update_sync_preserves_unrelated_schedule_entry(settings_env): assert set(payload["obsidian"]) == {"available", "enabled", "configured"} assert payload["plaud"]["enabled"] is True assert payload["plaud"]["configured"] is True + assert payload["granola"]["enabled"] is True + assert payload["granola"]["configured"] is True + assert payload["obsidian"]["enabled"] is True + assert payload["obsidian"]["configured"] is True diff --git a/solstone/convey/chat_stream.py b/solstone/convey/chat_stream.py index 4001ad23a..634e74e78 100644 --- a/solstone/convey/chat_stream.py +++ b/solstone/convey/chat_stream.py @@ -12,10 +12,6 @@ from datetime import date, datetime from pathlib import Path from typing import Any -from solstone.think.callosum import callosum_send -from solstone.think.indexer.journal import index_file -from solstone.think.journal_io import atomic_replace -from solstone.think.streams import update_stream, write_segment_stream from solstone.think.utils import ( day_path, get_journal, @@ -120,6 +116,8 @@ def _prepare_chat_events( def _append_prepared_chat_events_locked_already_held( events: list[tuple[str, dict[str, Any]]], ) -> list[dict[str, Any]]: + from solstone.think.streams import update_stream, write_segment_stream + stored_events: list[dict[str, Any]] = [] for kind, event in events: @@ -151,6 +149,8 @@ def _append_prepared_chat_events_locked_already_held( def _finalize_chat_event_appends(stored_events: list[dict[str, Any]]) -> None: + from solstone.think.indexer.journal import index_file + indexed_paths: set[Path] = set() for stored_event in stored_events: chat_path = _APPENDED_CHAT_PATHS.pop(id(stored_event), None) @@ -301,6 +301,8 @@ def _validate_event(kind: str, event: dict[str, Any]) -> None: def _broadcast_chat_event(stored_event: dict[str, Any]) -> None: + from solstone.think.callosum import callosum_send + chat_module = sys.modules.get("solstone.convey.chat") runtime = ( getattr(chat_module, "_runtime", None) if chat_module is not None else None @@ -413,5 +415,7 @@ def _read_events_file(path: Path) -> list[dict[str, Any]]: def _write_events_file(path: Path, events: list[dict[str, Any]]) -> None: + from solstone.think.journal_io import atomic_replace + body = "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in events) atomic_replace(path, body) diff --git a/solstone/convey/secure_listener/__init__.py b/solstone/convey/secure_listener/__init__.py index c9fa402c8..2199a0e06 100644 --- a/solstone/convey/secure_listener/__init__.py +++ b/solstone/convey/secure_listener/__init__.py @@ -5,8 +5,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any + from .identity import ConveyIdentity -from .runtime import get_authorized_clients, start_secure_listener, stop_secure_listener + +if TYPE_CHECKING: + from .runtime import ( + get_authorized_clients, + start_secure_listener, + stop_secure_listener, + ) __all__ = [ "ConveyIdentity", @@ -14,3 +22,15 @@ __all__ = [ "start_secure_listener", "stop_secure_listener", ] + + +def __getattr__(name: str) -> Any: + if name in { + "get_authorized_clients", + "start_secure_listener", + "stop_secure_listener", + }: + from . import runtime + + return getattr(runtime, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/solstone/think/call.py b/solstone/think/call.py index 7af26549e..b03df576f 100644 --- a/solstone/think/call.py +++ b/solstone/think/call.py @@ -13,6 +13,7 @@ Discovery scans ``apps/*/call.py``, imports modules, and mounts subcommands. import importlib import logging +import os from pathlib import Path import typer @@ -35,6 +36,7 @@ def _discover_app_calls() -> None: Errors in one app do not prevent others from loading. """ + strict = os.environ.get("SOLSTONE_STRICT_CALL_DISCOVERY") == "1" apps_dir = Path(__file__).parent.parent / "apps" if not apps_dir.exists(): @@ -56,6 +58,10 @@ def _discover_app_calls() -> None: sub_app = getattr(module, "app", None) if not isinstance(sub_app, typer.Typer): + if strict: + raise ImportError( + f"apps/{app_name}/call.py has no 'app' Typer instance" + ) logger.warning( f"apps/{app_name}/call.py has no 'app' Typer instance, skipping" ) @@ -64,6 +70,10 @@ def _discover_app_calls() -> None: call_app.add_typer(sub_app, name=app_name) logger.info(f"Loaded CLI commands from app: {app_name}") except Exception as e: + if strict: + raise ImportError( + f"Failed to load CLI from app '{app_name}': {e}" + ) from e logger.error( f"Failed to load CLI from app '{app_name}': {e}", exc_info=True ) diff --git a/solstone/think/chat_cli.py b/solstone/think/chat_cli.py index 9f1a7014b..5d866d2b7 100644 --- a/solstone/think/chat_cli.py +++ b/solstone/think/chat_cli.py @@ -20,7 +20,6 @@ from solstone.apps.chat.copy import ( talent_label_for, ) from solstone.convey.chat_stream import read_chat_events -from solstone.convey.provider_readiness import chat_view from solstone.think.callosum import CallosumConnection from solstone.think.utils import read_service_port, require_solstone, setup_cli @@ -187,6 +186,8 @@ def _terminal_error_message( use_id: str, day: str, ) -> str: + from solstone.convey.provider_readiness import chat_view + resolved_provider = provider or "" if not resolved_provider and use_id: persisted = _persisted_terminal(use_id, day) diff --git a/solstone/think/facets.py b/solstone/think/facets.py index e746783f8..fde3d431a 100644 --- a/solstone/think/facets.py +++ b/solstone/think/facets.py @@ -12,7 +12,6 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Optional -from solstone.think.entities import get_identity_names from solstone.think.journal_io import append_text, atomic_replace, hold_lock from solstone.think.utils import day_dirs, day_path, get_journal, iter_segments @@ -23,6 +22,8 @@ def _get_principal_display_name() -> str | None: Returns the first identity name (preferred if set, else full name). Returns None if identity is not configured. """ + from solstone.think.entities import get_identity_names + names = get_identity_names() return names[0] if names else None diff --git a/solstone/think/import_client.py b/solstone/think/import_client.py new file mode 100644 index 000000000..9cd9a8da2 --- /dev/null +++ b/solstone/think/import_client.py @@ -0,0 +1,344 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Thin `sol import` client for the local Convey import API.""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from pathlib import Path +from typing import Any + +from solstone.think.convey_client import ( + ConveyClient, + ConveyClientError, + ConveyUnreachableError, + get_client, +) + +IMPORT_API = "/app/import/api" +MALFORMED_RESPONSE = "I couldn't read the journal response." +JOURNAL_HOST_HINT = "Run this on the journal host with `journal importer`." +MODE_DISPOSITIONS = { + "positional_media": "http-client", + "--timestamp": "http-client", + "--facet": "http-client", + "--setting": "http-client", + "--source": "http-client", + "--force": "http-client", + "--auto": "http-client", + "--dry-run": "reject-journal-host", + "--json": "client-output", + "-v/--verbose": "client-logging", + "--backends": "reject-journal-host", + "--sync": "reject-journal-host", + "--save": "reject-journal-host", + "--path": "reject-journal-host", + "--list-importers": "reject-journal-host", + "journal-source": "relocate-sol-call-import", +} + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Import media through the journal") + parser.add_argument("media", nargs="?", help="Path to a file or journal-host path") + parser.add_argument("extra", nargs="*", help=argparse.SUPPRESS) + parser.add_argument( + "--timestamp", help="Timestamp YYYYMMDD_HHMMSS for journal entry" + ) + parser.add_argument("--facet", help="Facet name for this import") + parser.add_argument( + "--setting", + help="Contextual setting description to store with import metadata", + ) + parser.add_argument( + "--source", + help="Import source type (apple, plaud, audio, text, or a file importer name)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Force re-import by deleting existing import directory", + ) + parser.add_argument( + "--auto", + nargs="?", + const=True, + default=None, + help="Accept the server-detected timestamp", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be imported without writing to the journal", + ) + parser.add_argument( + "--backends", + action="store_true", + help="List syncable importer backends", + ) + parser.add_argument( + "--sync", + metavar="BACKEND", + help="Sync catalog from a backend", + ) + parser.add_argument( + "--save", + action="store_true", + help="With --sync: download and import new files", + ) + parser.add_argument( + "--path", + help="With --sync: override the default source directory path", + ) + parser.add_argument( + "--list-importers", + action="store_true", + help="List available file importers", + ) + parser.add_argument("--json", action="store_true", help="Output JSON") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") + return parser + + +def _exit_rejected(parser: argparse.ArgumentParser, message: str) -> int: + parser.exit(2, f"sol import: {message}\n") + return 2 + + +def _reject_unsupported_modes( + parser: argparse.ArgumentParser, + args: argparse.Namespace, +) -> int | None: + if args.media == "journal-source": + return _exit_rejected( + parser, + "journal-source management moved to `sol call import `.", + ) + if args.dry_run: + return _exit_rejected( + parser, f"`--dry-run` requires the journal host. {JOURNAL_HOST_HINT}" + ) + if args.backends: + return _exit_rejected( + parser, f"`--backends` requires the journal host. {JOURNAL_HOST_HINT}" + ) + if args.list_importers: + return _exit_rejected( + parser, + f"`--list-importers` requires the journal host. {JOURNAL_HOST_HINT}", + ) + if args.sync: + return _exit_rejected( + parser, f"`--sync` requires the journal host. {JOURNAL_HOST_HINT}" + ) + if args.save: + return _exit_rejected( + parser, f"`--save` requires the journal host. {JOURNAL_HOST_HINT}" + ) + if args.path: + return _exit_rejected( + parser, f"`--path` requires the journal host. {JOURNAL_HOST_HINT}" + ) + if args.auto not in (None, True): + return _exit_rejected( + parser, + "`--auto ` requires the journal host. " + "Use `--timestamp` here or run `journal importer`.", + ) + return None + + +def _payload_value(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _save_media(client: ConveyClient, args: argparse.Namespace) -> dict[str, Any]: + media_path = Path(args.media).expanduser() + data = { + key: value + for key, value in { + "facet": _payload_value(args.facet), + "setting": _payload_value(args.setting), + }.items() + if value is not None + } + if media_path.exists() and media_path.is_file(): + return client.upload( + f"{IMPORT_API}/save", + files={ + "file": ( + media_path.name, + media_path, + "application/octet-stream", + ) + }, + data=data, + ) + return client.request( + "POST", + f"{IMPORT_API}/save-path", + json={ + **data, + "path": str(media_path), + }, + ) + + +def _start_import( + client: ConveyClient, + args: argparse.Namespace, + save_response: dict[str, Any], +) -> dict[str, Any]: + path = save_response.get("path") + timestamp = args.timestamp or save_response.get("timestamp") + if not isinstance(path, str) or not path: + raise ConveyClientError(MALFORMED_RESPONSE) + if not isinstance(timestamp, str) or not timestamp: + raise ConveyClientError(MALFORMED_RESPONSE) + + payload: dict[str, Any] = { + "path": path, + "timestamp": timestamp, + "force": bool(args.force), + } + for key, value in { + "facet": _payload_value(args.facet), + "setting": _payload_value(args.setting), + "source": _payload_value(args.source), + }.items(): + if value is not None: + payload[key] = value + start_response = client.request("POST", f"{IMPORT_API}/start", json=payload) + if not isinstance(start_response, dict): + raise ConveyClientError(MALFORMED_RESPONSE) + task_id = start_response.get("task_id") + if not isinstance(task_id, str) or not task_id: + raise ConveyClientError(MALFORMED_RESPONSE) + return start_response + + +def _is_malformed(err: ConveyClientError) -> bool: + return err.error == MALFORMED_RESPONSE + + +def _print_client_error(operation: str, err: ConveyClientError) -> None: + if _is_malformed(err): + print("sol import: couldn't read journal response", file=sys.stderr) + return + print(f"sol import: failed to {operation}: {err.error}", file=sys.stderr) + if err.detail: + print(f"sol import: {err.detail}", file=sys.stderr) + + +def _print_partial_error(staged_path: str, err: ConveyClientError) -> None: + if _is_malformed(err): + print( + f"sol import: staged {staged_path} but processing was not queued: " + "couldn't read journal response", + file=sys.stderr, + ) + return + print( + f"sol import: staged {staged_path} but processing was not queued: {err.error}", + file=sys.stderr, + ) + if err.detail: + print(f"sol import: {err.detail}", file=sys.stderr) + + +def _print_success( + save_response: dict[str, Any], + start_response: dict[str, Any], + *, + json_out: bool, +) -> None: + timestamp = save_response.get("timestamp") + path = save_response.get("path") + if json_out: + print( + json.dumps( + { + "status": "queued", + "path": path, + "timestamp": timestamp, + "save": save_response, + "start": start_response, + }, + sort_keys=True, + ) + ) + return + + print(f"staged {path}") + if timestamp: + print(f"timestamp {timestamp}") + task_id = start_response.get("task_id") + if task_id: + print(f"queued processing task {task_id}") + else: + print("queued processing") + + +def _run(args: argparse.Namespace, client: ConveyClient) -> int: + try: + save_response = _save_media(client, args) + if not isinstance(save_response, dict): + raise ConveyClientError(MALFORMED_RESPONSE) + except ConveyUnreachableError: + print( + "sol import: couldn't reach the journal. Start it with 'journal up' and retry.", + file=sys.stderr, + ) + return 1 + except ConveyClientError as err: + _print_client_error("stage import", err) + return 1 + + staged_path = str(save_response.get("path") or args.media) + try: + start_response = _start_import(client, args, save_response) + except ConveyUnreachableError: + print( + f"sol import: staged {staged_path} but processing was not queued: " + "couldn't reach the journal", + file=sys.stderr, + ) + return 1 + except ConveyClientError as err: + _print_partial_error(staged_path, err) + return 1 + + _print_success(save_response, start_response, json_out=bool(args.json)) + return 0 + + +def main( + argv: list[str] | None = None, + *, + client: ConveyClient | None = None, +) -> int: + parser = _build_parser() + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + if args.verbose: + logging.basicConfig(level=logging.DEBUG) + + rejected = _reject_unsupported_modes(parser, args) + if rejected is not None: + return rejected + if args.extra: + parser.error(f"unexpected argument(s): {' '.join(args.extra)}") + if not args.media: + parser.error("the following arguments are required: media") + + return _run(args, client or get_client()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solstone/think/importers/cli.py b/solstone/think/importers/cli.py index 5b12bce3e..8a982336d 100644 --- a/solstone/think/importers/cli.py +++ b/solstone/think/importers/cli.py @@ -334,9 +334,11 @@ def _run_sync( print("Run with --save to import:") src = sync_kwargs.get("source_path") if src: - print(f" sol import --sync {backend_name} --save --path {src}") + print( + f" journal importer --sync {backend_name} --save --path {src}" + ) else: - print(f" sol import --sync {backend_name} --save") + print(f" journal importer --sync {backend_name} --save") if verbose and state: files = state.get("files", {}) @@ -492,7 +494,9 @@ def _import_one_from_args(args: argparse.Namespace) -> dict[str, Any] | None: else: print(f"Detected timestamp: {detected_timestamp} ({display})") print("\nRun:") - print(f" sol import {args.media} --timestamp {detected_timestamp}") + print( + f" journal importer {args.media} --timestamp {detected_timestamp}" + ) return { "skipped": True, "reason": "timestamp_required", diff --git a/solstone/think/link/list_cli.py b/solstone/think/link/list_cli.py index a479047f4..1dcf44ab8 100644 --- a/solstone/think/link/list_cli.py +++ b/solstone/think/link/list_cli.py @@ -13,7 +13,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from solstone.convey.utils import relative_time from solstone.think.link.observer_paths import observer_spl_root from solstone.think.utils import get_journal @@ -154,6 +153,8 @@ def _absolute(path: Path) -> str: def _relative_time(iso: str | None) -> str: + from solstone.convey.utils import relative_time + if not iso: return "never" try: diff --git a/solstone/think/providers/shared.py b/solstone/think/providers/shared.py index a94819eed..1a0818876 100644 --- a/solstone/think/providers/shared.py +++ b/solstone/think/providers/shared.py @@ -12,7 +12,6 @@ This module contains: from __future__ import annotations -import importlib import json from typing import Any, Callable, Literal, Optional, Union @@ -172,58 +171,8 @@ _CLI_AUTH_PATTERNS = ( ) -def _import_exception_type(module_name: str, name: str) -> type[BaseException] | None: - try: - module = importlib.import_module(module_name) - except ImportError: - return None - value = getattr(module, name, None) - if isinstance(value, type) and issubclass(value, BaseException): - return value - return None - - -_ANTHROPIC_API_STATUS_ERROR = _import_exception_type("anthropic", "APIStatusError") -_ANTHROPIC_API_CONNECTION_ERROR = _import_exception_type( - "anthropic", "APIConnectionError" -) -_ANTHROPIC_API_TIMEOUT_ERROR = _import_exception_type("anthropic", "APITimeoutError") -_ANTHROPIC_AUTHENTICATION_ERROR = _import_exception_type( - "anthropic", "AuthenticationError" -) -_ANTHROPIC_PERMISSION_DENIED_ERROR = _import_exception_type( - "anthropic", "PermissionDeniedError" -) -_ANTHROPIC_RATE_LIMIT_ERROR = _import_exception_type("anthropic", "RateLimitError") - -_OPENAI_API_STATUS_ERROR = _import_exception_type("openai", "APIStatusError") -_OPENAI_API_CONNECTION_ERROR = _import_exception_type("openai", "APIConnectionError") -_OPENAI_API_TIMEOUT_ERROR = _import_exception_type("openai", "APITimeoutError") -_OPENAI_AUTHENTICATION_ERROR = _import_exception_type("openai", "AuthenticationError") -_OPENAI_PERMISSION_DENIED_ERROR = _import_exception_type( - "openai", "PermissionDeniedError" -) -_OPENAI_RATE_LIMIT_ERROR = _import_exception_type("openai", "RateLimitError") -_OPENAI_INTERNAL_SERVER_ERROR = _import_exception_type("openai", "InternalServerError") - -_GOOGLE_CLIENT_ERROR = _import_exception_type("google.genai.errors", "ClientError") -_GOOGLE_SERVER_ERROR = _import_exception_type("google.genai.errors", "ServerError") -_GOOGLE_UNKNOWN_RESPONSE_ERROR = _import_exception_type( - "google.genai.errors", "UnknownApiResponseError" -) - -_HTTPX_HTTP_STATUS_ERROR = _import_exception_type("httpx", "HTTPStatusError") -_HTTPX_NETWORK_ERROR = _import_exception_type("httpx", "NetworkError") -_HTTPX_REQUEST_ERROR = _import_exception_type("httpx", "RequestError") -_HTTPX_TIMEOUT_EXCEPTION = _import_exception_type("httpx", "TimeoutException") - - -def _isinstance(exc: BaseException, cls: type[BaseException] | None) -> bool: - return cls is not None and isinstance(exc, cls) - - def _status_code(exc: BaseException) -> int | None: - for attr in ("status_code", "code"): + for attr in ("status_code", "_status_code", "code"): value = getattr(exc, attr, None) if isinstance(value, int): return value @@ -233,13 +182,28 @@ def _status_code(exc: BaseException) -> int | None: def _status_text(exc: BaseException) -> str: - return str(getattr(exc, "status", "") or "").upper() + return str( + getattr(exc, "status", "") + or getattr(exc, "_status", "") + or getattr(exc, "_status_text", "") + or "" + ).upper() def _contains_any(text: str, patterns: tuple[str, ...]) -> bool: return any(pattern in text for pattern in patterns) +def _module_matches(module_name: str, package: str) -> bool: + return module_name == package or module_name.startswith(f"{package}.") + + +def _exception_name_matches( + exc_name: str, exc_qualname: str, names: tuple[str, ...] +) -> bool: + return exc_name in names or any(exc_qualname.endswith(f".{name}") for name in names) + + RUNTIME_REASON_CODES = frozenset( { "context_window_exceeded", @@ -259,7 +223,10 @@ def classify_provider_error(exc: BaseException, provider: str) -> str: """Return a chat reason code for a provider exception.""" try: exc_name = type(exc).__name__ + exc_qualname = type(exc).__qualname__ + exc_module = type(exc).__module__ exc_name_lower = exc_name.lower() + exc_identity_lower = f"{exc_module}.{exc_qualname}".lower() message_lower = str(exc).lower() explicit_reason_code = getattr(exc, "reason_code", None) if isinstance(explicit_reason_code, str) and explicit_reason_code: @@ -277,56 +244,99 @@ def classify_provider_error(exc: BaseException, provider: str) -> str: if isinstance(exc, ValueError) and "no response from model" in message_lower: return "provider_response_invalid" - if _isinstance(exc, _ANTHROPIC_AUTHENTICATION_ERROR) or _isinstance( - exc, _ANTHROPIC_PERMISSION_DENIED_ERROR + is_anthropic = _module_matches(exc_module, "anthropic") + is_openai = _module_matches(exc_module, "openai") + is_google = _module_matches(exc_module, "google.genai") + is_httpx = _module_matches(exc_module, "httpx") + status_code = _status_code(exc) + status_text = _status_text(exc) + + if (is_anthropic or is_openai) and _exception_name_matches( + exc_name, + exc_qualname, + ("AuthenticationError", "PermissionDeniedError"), ): return "provider_key_invalid" - if _isinstance(exc, _OPENAI_AUTHENTICATION_ERROR) or _isinstance( - exc, _OPENAI_PERMISSION_DENIED_ERROR + if ( + is_google + and _exception_name_matches(exc_name, exc_qualname, ("ClientError",)) + and status_code in (401, 403) ): return "provider_key_invalid" - if _isinstance(exc, _GOOGLE_CLIENT_ERROR) and _status_code(exc) in (401, 403): - return "provider_key_invalid" - if _isinstance(exc, _ANTHROPIC_RATE_LIMIT_ERROR) or _isinstance( - exc, _OPENAI_RATE_LIMIT_ERROR + if (is_anthropic or is_openai) and _exception_name_matches( + exc_name, exc_qualname, ("RateLimitError",) ): return "provider_quota_exceeded" - if _isinstance(exc, _GOOGLE_CLIENT_ERROR) and ( - _status_code(exc) == 429 or _status_text(exc) == "RESOURCE_EXHAUSTED" + if ( + is_google + and _exception_name_matches(exc_name, exc_qualname, ("ClientError",)) + and (status_code == 429 or status_text == "RESOURCE_EXHAUSTED") ): return "provider_quota_exceeded" - if _isinstance(exc, _ANTHROPIC_API_TIMEOUT_ERROR) or _isinstance( - exc, _OPENAI_API_TIMEOUT_ERROR + if (is_anthropic or is_openai) and _exception_name_matches( + exc_name, exc_qualname, ("APITimeoutError",) ): return "chat_timeout" - if _isinstance(exc, _HTTPX_TIMEOUT_EXCEPTION): + if is_httpx and ( + "timeout" in exc_name_lower + or _exception_name_matches( + exc_name, + exc_qualname, + ( + "TimeoutException", + "ConnectTimeout", + "PoolTimeout", + "ReadTimeout", + "WriteTimeout", + ), + ) + ): return "chat_timeout" - if _isinstance(exc, _ANTHROPIC_API_CONNECTION_ERROR) or _isinstance( - exc, _OPENAI_API_CONNECTION_ERROR + if (is_anthropic or is_openai) and _exception_name_matches( + exc_name, exc_qualname, ("APIConnectionError",) ): return "network_unreachable" - if _isinstance(exc, _HTTPX_NETWORK_ERROR) or _isinstance( - exc, _HTTPX_REQUEST_ERROR + if is_httpx and ( + _exception_name_matches( + exc_name, + exc_qualname, + ("NetworkError", "RequestError", "ConnectError"), + ) + or "connection" in exc_name_lower + or "connect" in exc_name_lower ): return "network_unreachable" if isinstance(exc, ConnectionError): return "network_unreachable" - if _isinstance(exc, _OPENAI_INTERNAL_SERVER_ERROR) or _isinstance( - exc, _GOOGLE_SERVER_ERROR + if is_openai and _exception_name_matches( + exc_name, exc_qualname, ("InternalServerError",) + ): + return "provider_unavailable" + if is_google and _exception_name_matches( + exc_name, exc_qualname, ("ServerError",) ): return "provider_unavailable" if ( - _isinstance(exc, _ANTHROPIC_API_STATUS_ERROR) - or _isinstance(exc, _OPENAI_API_STATUS_ERROR) - or _isinstance(exc, _HTTPX_HTTP_STATUS_ERROR) - ) and (_status_code(exc) or 0) >= 500: + ( + (is_anthropic or is_openai) + and _exception_name_matches(exc_name, exc_qualname, ("APIStatusError",)) + ) + or ( + is_httpx + and _exception_name_matches( + exc_name, exc_qualname, ("HTTPStatusError",) + ) + ) + ) and (status_code or 0) >= 500: return "provider_unavailable" - if _isinstance(exc, _GOOGLE_UNKNOWN_RESPONSE_ERROR): + if is_google and _exception_name_matches( + exc_name, exc_qualname, ("UnknownApiResponseError",) + ): return "provider_response_invalid" if isinstance(exc, RuntimeError): @@ -357,7 +367,7 @@ def classify_provider_error(exc: BaseException, provider: str) -> str: return "network_unreachable" if ( "responsevalidation" in exc_name_lower - or "unknownapiresponse" in exc_name_lower + or "unknownapiresponse" in exc_identity_lower ): return "provider_response_invalid" if "internalservererror" in exc_name_lower or "servererror" in exc_name_lower: diff --git a/solstone/think/sol_cli.py b/solstone/think/sol_cli.py index ea538f2c5..8c8550333 100644 --- a/solstone/think/sol_cli.py +++ b/solstone/think/sol_cli.py @@ -6,12 +6,12 @@ Usage: sol Show status and available commands sol [args] Run a subcommand - sol [args] Run by module path (e.g., sol solstone.think.importers.cli) + journal [args] Run a service module by dotted path Examples: sol import data.json Import data into journal journal think 20250101 Run daily processing for a day - sol solstone.think.talents -h Show help for specific module + journal solstone.think.talents -h Show help for a service module """ from __future__ import annotations @@ -65,6 +65,12 @@ SOL_SERVICE_CMD_REMOVED_ERROR = ( "('sol' is the journal-access surface; 'journal' surfaces journal-service " "commands; see 'journal --help'.)" ) +JOURNAL_EXTRA_INSTALL_HINT = ( + "Error: this command needs the journal host dependencies.\n" + "Install them with:\n" + " pip install 'solstone[journal]'\n" + " uv tool install 'solstone[journal]'" +) SOL_HELP_GROUP_CONVERSATION = "Conversation" SOL_HELP_GROUP_YOUR_JOURNAL = "Your journal" @@ -77,7 +83,8 @@ SOL_HELP_GROUP_ALIASES = "Aliases" COMMANDS: dict[str, Command] = { # think package - daily processing and analysis "backup": Command("solstone.think.backup_cli", "service"), - "import": Command("solstone.think.importers.cli", "access"), + "import": Command("solstone.think.import_client", "access"), + "importer": Command("solstone.think.importers.cli", "service"), "think": Command("solstone.think.thinking", "service"), "indexer": Command("solstone.think.indexer", "service"), "start": Command("solstone.think.start", "service"), @@ -314,11 +321,29 @@ def resolve_command(name: str) -> tuple[str, list[str], str]: ) -def run_command(module_path: str) -> int: +def _missing_journal_host_dep( + exc: ImportError, + *, + surface: str, + binary: str, +) -> bool: + if not isinstance(exc, ModuleNotFoundError): + return False + if surface != "service" and not (surface == "universal" and binary == "journal"): + return False + missing_name = exc.name + if not missing_name: + return False + return missing_name != "solstone" and not missing_name.startswith("solstone.") + + +def run_command(module_path: str, *, surface: str, binary: str) -> int: """Import and run a module's main() function. Args: module_path: Dotted module path (e.g., "solstone.think.importers.cli") + surface: Resolved command surface. + binary: CLI binary used for dispatch (`sol` or `journal`). Returns: Exit code (0 for success) @@ -326,6 +351,9 @@ def run_command(module_path: str) -> int: try: module = importlib.import_module(module_path) except ImportError as e: + if _missing_journal_host_dep(e, surface=surface, binary=binary): + print(JOURNAL_EXTRA_INSTALL_HINT, file=sys.stderr) + return 1 print(f"Error: Could not import module '{module_path}': {e}", file=sys.stderr) return 1 @@ -444,13 +472,13 @@ def _dispatch(binary: str, allowed_surfaces: frozenset[str] | None) -> None: setproctitle.setproctitle(f"{binary}:{cmd}") # Adjust sys.argv for the subcommand - # Original: ["sol", "import", "--day", "20250101"] - # Becomes: ["sol import", "--day", "20250101"] + # Original: ["sol", "chat", "--help"] + # Becomes: ["sol chat", "--help"] # This makes argparse show "usage: ..." in help. sys.argv = [f"{binary} {cmd}"] + preset_args + rest # Run the command - exit_code = run_command(module_path) + exit_code = run_command(module_path, surface=surface, binary=binary) sys.exit(exit_code) diff --git a/solstone/think/tools/call.py b/solstone/think/tools/call.py index 1e85e4768..f21cb9954 100644 --- a/solstone/think/tools/call.py +++ b/solstone/think/tools/call.py @@ -22,7 +22,6 @@ from pathlib import Path import typer -from solstone.think.entities import scan_facet_relationships from solstone.think.facets import ( create_facet, delete_facet, @@ -42,8 +41,6 @@ from solstone.think.importers.utils import ( get_import_details, list_import_timestamps, ) -from solstone.think.indexer.journal import search_counts as search_counts_impl -from solstone.think.indexer.journal import search_journal as search_journal_impl from solstone.think.journal_config import write_journal_config from solstone.think.utils import ( day_path, @@ -104,6 +101,9 @@ def search( ), ) -> None: """Search the journal index.""" + from solstone.think.indexer.journal import search_counts as search_counts_impl + from solstone.think.indexer.journal import search_journal as search_journal_impl + kwargs = {} if day is not None: kwargs["day"] = day @@ -381,6 +381,7 @@ def merge( ), ) -> None: """Merge all data from SOURCE facet into DEST facet, then delete SOURCE.""" + from solstone.think.entities import scan_facet_relationships from solstone.think.entities.observations import ( load_observations, save_observations, diff --git a/solstone/think/tools/health.py b/solstone/think/tools/health.py index 39c532d4e..a43f43349 100644 --- a/solstone/think/tools/health.py +++ b/solstone/think/tools/health.py @@ -9,9 +9,7 @@ from typing import NoReturn, Optional import typer -from solstone.convey.readiness_snapshot import highest_severity_group from solstone.think.convey_client import ConveyClientError, get_client -from solstone.think.pipeline_health import summarize_pipeline_day app = typer.Typer( help="Health: journal-data trust signals (for infrastructure/service liveness, use `journal health`).", @@ -37,6 +35,8 @@ def _dash(value: object) -> object: def _render_summary(report: dict) -> None: + from solstone.convey.readiness_snapshot import highest_severity_group + capture = report["capture_health"] synthesis = report["synthesis_health"] consumer_signal = report["consumer_signal"] @@ -217,6 +217,8 @@ def pipeline( ), ) -> None: """Summarize think pipeline health for one day.""" + from solstone.think.pipeline_health import summarize_pipeline_day + if day is not None and yesterday: typer.echo("--day and --yesterday are mutually exclusive", err=True) raise typer.Exit(1) diff --git a/tests/spl/test_service.py b/tests/spl/test_service.py index 86664dd01..3c329e356 100644 --- a/tests/spl/test_service.py +++ b/tests/spl/test_service.py @@ -262,9 +262,11 @@ def test_dispatch_surfaces_for_journal_spl_and_sol_link( result: dict[str, object] = {} titles: list[str] = [] - def fake_run_command(module_path: str) -> int: + def fake_run_command(module_path: str, *, surface: str, binary: str) -> int: result["module"] = module_path result["argv"] = sys.argv[:] + result["surface"] = surface + result["binary"] = binary return 0 monkeypatch.setattr(sol_cli, "run_command", fake_run_command) @@ -278,6 +280,8 @@ def test_dispatch_surfaces_for_journal_spl_and_sol_link( assert result == { "module": "solstone.think.spl", "argv": ["journal spl"], + "surface": "service", + "binary": "journal", } assert titles == ["journal:spl"] diff --git a/tests/test_access_imports_lazy.py b/tests/test_access_imports_lazy.py new file mode 100644 index 000000000..79e8181bb --- /dev/null +++ b/tests/test_access_imports_lazy.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Import-firewall regression for the thin `sol` access surface.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +FORBIDDEN = { + "flask", + "werkzeug", + "jinja2", + "anthropic", + "openai", + "google.genai", + "httpx", + "numpy", + "PIL", + "soundfile", + "av", + "pypdf", + "frontmatter", +} + +MODULES = ( + "solstone.think.chat_cli", + "solstone.think.call", + "solstone.think.link", + "solstone.apps", + "solstone.think.notify_cli", + "solstone.think.skills_cli", + "solstone.think.doctor", + "solstone.convey.reasons", + "solstone.think.sol_cli", + "solstone.think.import_client", + "solstone.convey.provider_readiness", + "solstone.think.providers.state", +) + +PROBE = """ +import importlib +import json +import sys + +module = sys.argv[1] +importlib.import_module(module) +print("MODULES_JSON:" + json.dumps(sorted(sys.modules))) +""" + + +def _probe_modules(module: str) -> set[str]: + result = subprocess.run( + [sys.executable, "-c", PROBE, module], + capture_output=True, + text=True, + timeout=90, + ) + assert result.returncode == 0, ( + f"probe for {module} exited {result.returncode}\n" + f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + sentinel = [ + line for line in result.stdout.splitlines() if line.startswith("MODULES_JSON:") + ] + assert len(sentinel) == 1, ( + f"expected exactly one MODULES_JSON line, got {len(sentinel)}\n" + f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + return set(json.loads(sentinel[0][len("MODULES_JSON:") :])) + + +def _leaked_families(modules: set[str]) -> list[str]: + leaked: list[str] = [] + for forbidden in sorted(FORBIDDEN): + if any( + loaded == forbidden or loaded.startswith(f"{forbidden}.") + for loaded in modules + ): + leaked.append(forbidden) + return leaked + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize("module", MODULES) +def test_access_surface_import_stays_light(module: str) -> None: + modules = _probe_modules(module) + assert module in modules + leaked = _leaked_families(modules) + assert not leaked, f"{module} pulled in forbidden modules: {leaked}" diff --git a/tests/test_backup_cli.py b/tests/test_backup_cli.py index 6e5fefa00..2fba1ffa7 100644 --- a/tests/test_backup_cli.py +++ b/tests/test_backup_cli.py @@ -81,9 +81,11 @@ def test_registry_and_command_tree(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} - def fake_run_command(module_path: str) -> int: + def fake_run_command(module_path: str, *, surface: str, binary: str) -> int: captured["module"] = module_path captured["argv"] = list(sys.argv) + captured["surface"] = surface + captured["binary"] = binary return 0 monkeypatch.setattr(sol_cli, "run_command", fake_run_command) @@ -97,6 +99,8 @@ def test_registry_and_command_tree(monkeypatch: pytest.MonkeyPatch) -> None: assert captured == { "module": "solstone.think.backup_cli", "argv": ["journal backup", "status"], + "surface": "service", + "binary": "journal", } runner = CliRunner() diff --git a/tests/test_chat_stream_sol_initiated.py b/tests/test_chat_stream_sol_initiated.py index d4543247a..b444474bf 100644 --- a/tests/test_chat_stream_sol_initiated.py +++ b/tests/test_chat_stream_sol_initiated.py @@ -34,7 +34,9 @@ def _setup_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: journal = tmp_path / "journal" journal.mkdir() monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - monkeypatch.setattr("solstone.convey.chat_stream.index_file", lambda *_args: True) + monkeypatch.setattr( + "solstone.think.indexer.journal.index_file", lambda *_args: True + ) return journal diff --git a/tests/test_check_access_imports_clean.py b/tests/test_check_access_imports_clean.py new file mode 100644 index 000000000..a69182b08 --- /dev/null +++ b/tests/test_check_access_imports_clean.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Self-test for scripts/check_access_imports_clean.py.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "check_access_imports_clean.py" + + +def _run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=180, + ) + + +def test_repo_tree_is_green() -> None: + result = _run() + + assert result.returncode == 0, result.stdout + result.stderr + assert "access-imports-clean: pass" in result.stdout + + +def test_injected_access_heavy_import_goes_red_and_names_offender() -> None: + result = _run("--inject-heavy-module", "solstone.think.notify_cli") + + assert result.returncode == 1 + assert "sol notify --help" in result.stderr + assert "solstone.think.notify_cli" in result.stderr + assert "numpy" in result.stderr + + +def test_injected_mounted_app_failure_goes_red_and_names_app() -> None: + result = _run("--inject-mounted-app", "import") + + assert result.returncode == 1 + assert "sol call --help" in result.stderr + assert "import" in result.stderr + assert "injected mounted app failure: import" in result.stderr diff --git a/tests/test_convey_apps.py b/tests/test_convey_apps.py index 494136f85..289f3ee96 100644 --- a/tests/test_convey_apps.py +++ b/tests/test_convey_apps.py @@ -16,7 +16,9 @@ from solstone.convey.sol_initiated.copy import CATEGORIES, KIND_SOL_CHAT_REQUEST def _temp_journal(monkeypatch, tmp_path): """Ensure journaling defaults remain isolated from developer data.""" monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr("solstone.convey.chat_stream.index_file", lambda *_args: True) + monkeypatch.setattr( + "solstone.think.indexer.journal.index_file", lambda *_args: True + ) def _context( diff --git a/tests/test_convey_chat_sol_initiated.py b/tests/test_convey_chat_sol_initiated.py index e0fef77e7..7dff361d2 100644 --- a/tests/test_convey_chat_sol_initiated.py +++ b/tests/test_convey_chat_sol_initiated.py @@ -19,7 +19,9 @@ def _setup_journal(tmp_path, monkeypatch): journal = tmp_path / "journal" journal.mkdir() monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - monkeypatch.setattr("solstone.convey.chat_stream.index_file", lambda *_args: True) + monkeypatch.setattr( + "solstone.think.indexer.journal.index_file", lambda *_args: True + ) return journal diff --git a/tests/test_import_client.py b/tests/test_import_client.py new file mode 100644 index 000000000..da9031ba2 --- /dev/null +++ b/tests/test_import_client.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from solstone.think import import_client +from solstone.think.convey_client import ConveyClientError, ConveyUnreachableError + + +class FakeClient: + def __init__( + self, + *, + upload_response: Any = None, + request_responses: list[Any] | None = None, + upload_error: Exception | None = None, + request_errors: list[Exception | None] | None = None, + ) -> None: + self.upload_response = upload_response or { + "path": "/journal/imports/20260101_120000/sample.txt", + "timestamp": "20260101_120000", + } + self.request_responses = request_responses or [ + {"status": "ok", "task_id": "task-1"} + ] + self.upload_error = upload_error + self.request_errors = request_errors or [] + self.uploads: list[dict[str, Any]] = [] + self.requests: list[dict[str, Any]] = [] + + def upload(self, path: str, *, files: dict[str, Any], data: Any = None) -> Any: + self.uploads.append({"path": path, "files": files, "data": data}) + if self.upload_error is not None: + raise self.upload_error + return self.upload_response + + def request( + self, + method: str, + path: str, + *, + params: Any = None, + json: Any = None, + ) -> Any: + self.requests.append( + {"method": method, "path": path, "params": params, "json": json} + ) + index = len(self.requests) - 1 + if index < len(self.request_errors) and self.request_errors[index] is not None: + raise self.request_errors[index] + return self.request_responses[index] + + +def test_mode_disposition_table_covers_d5_modes() -> None: + assert import_client.MODE_DISPOSITIONS == { + "positional_media": "http-client", + "--timestamp": "http-client", + "--facet": "http-client", + "--setting": "http-client", + "--source": "http-client", + "--force": "http-client", + "--auto": "http-client", + "--dry-run": "reject-journal-host", + "--json": "client-output", + "-v/--verbose": "client-logging", + "--backends": "reject-journal-host", + "--sync": "reject-journal-host", + "--save": "reject-journal-host", + "--path": "reject-journal-host", + "--list-importers": "reject-journal-host", + "journal-source": "relocate-sol-call-import", + } + + +def test_file_save_then_start( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient() + + code = import_client.main([str(media)], client=client) # type: ignore[arg-type] + + assert code == 0 + assert client.uploads == [ + { + "path": "/app/import/api/save", + "files": { + "file": ("sample.txt", media, "application/octet-stream"), + }, + "data": {}, + } + ] + assert client.requests == [ + { + "method": "POST", + "path": "/app/import/api/start", + "params": None, + "json": { + "path": "/journal/imports/20260101_120000/sample.txt", + "timestamp": "20260101_120000", + "force": False, + }, + } + ] + assert "queued processing task task-1" in capsys.readouterr().out + + +def test_save_path_then_start(tmp_path: Path) -> None: + media_dir = tmp_path / "vault" + media_dir.mkdir() + client = FakeClient( + request_responses=[ + { + "path": str(media_dir), + "timestamp": "20260101_130000", + }, + {"status": "ok", "task_id": "task-2"}, + ] + ) + + code = import_client.main([str(media_dir)], client=client) # type: ignore[arg-type] + + assert code == 0 + assert client.uploads == [] + assert client.requests[0] == { + "method": "POST", + "path": "/app/import/api/save-path", + "params": None, + "json": {"path": str(media_dir)}, + } + assert client.requests[1]["path"] == "/app/import/api/start" + assert client.requests[1]["json"]["timestamp"] == "20260101_130000" + + +def test_timestamp_override_only_goes_to_start(tmp_path: Path) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient() + + code = import_client.main( + [str(media), "--timestamp", "20260202_030405"], + client=client, # type: ignore[arg-type] + ) + + assert code == 0 + assert "timestamp" not in client.uploads[0]["data"] + assert client.requests[0]["json"]["timestamp"] == "20260202_030405" + + +def test_metadata_and_start_options_forward(tmp_path: Path) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient() + + code = import_client.main( + [ + str(media), + "--facet", + "work", + "--setting", + "office", + "--source", + "ics", + "--force", + ], + client=client, # type: ignore[arg-type] + ) + + assert code == 0 + assert client.uploads[0]["data"] == {"facet": "work", "setting": "office"} + assert client.requests[0]["json"] == { + "path": "/journal/imports/20260101_120000/sample.txt", + "timestamp": "20260101_120000", + "force": True, + "facet": "work", + "setting": "office", + "source": "ics", + } + + +def test_json_output_shape(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient() + + code = import_client.main([str(media), "--json"], client=client) # type: ignore[arg-type] + + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "status": "queued", + "path": "/journal/imports/20260101_120000/sample.txt", + "timestamp": "20260101_120000", + "save": { + "path": "/journal/imports/20260101_120000/sample.txt", + "timestamp": "20260101_120000", + }, + "start": {"status": "ok", "task_id": "task-1"}, + } + + +def test_unreachable_is_clean( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient(upload_error=ConveyUnreachableError("down")) + + code = import_client.main([str(media)], client=client) # type: ignore[arg-type] + + assert code == 1 + assert "couldn't reach the journal" in capsys.readouterr().err + + +def test_typed_error_is_clean( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient( + upload_error=ConveyClientError("bad request", detail="invalid file") + ) + + code = import_client.main([str(media)], client=client) # type: ignore[arg-type] + + captured = capsys.readouterr() + assert code == 1 + assert "failed to stage import: bad request" in captured.err + assert "invalid file" in captured.err + + +def test_malformed_response_is_clean( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient(upload_response={"timestamp": "20260101_120000"}) + + code = import_client.main([str(media)], client=client) # type: ignore[arg-type] + + assert code == 1 + assert "couldn't read journal response" in capsys.readouterr().err + + +def test_partial_save_but_queue_failed( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + media = tmp_path / "sample.txt" + media.write_text("hello", encoding="utf-8") + client = FakeClient(request_errors=[ConveyClientError("queue failed")]) + + code = import_client.main([str(media)], client=client) # type: ignore[arg-type] + + captured = capsys.readouterr() + assert code == 1 + assert ( + "staged /journal/imports/20260101_120000/sample.txt " + "but processing was not queued: queue failed" + ) in captured.err + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + (["media.txt", "--dry-run"], "`--dry-run` requires the journal host"), + (["--backends"], "`--backends` requires the journal host"), + (["--list-importers"], "`--list-importers` requires the journal host"), + (["--sync", "plaud"], "`--sync` requires the journal host"), + (["media.txt", "--save"], "`--save` requires the journal host"), + (["media.txt", "--path", "/tmp/src"], "`--path` requires the journal host"), + ( + ["media.txt", "--auto", "timestamps are Pacific"], + "`--auto ` requires the journal host", + ), + (["journal-source", "list"], "sol call import "), + ], +) +def test_reject_modes_exit_cleanly( + argv: list[str], + expected: str, + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc_info: + import_client.main(argv, client=FakeClient()) # type: ignore[arg-type] + + assert exc_info.value.code == 2 + assert expected in capsys.readouterr().err diff --git a/tests/test_import_start.py b/tests/test_import_start.py index 0ce29ef3a..374052bef 100644 --- a/tests/test_import_start.py +++ b/tests/test_import_start.py @@ -33,7 +33,17 @@ def client(journal_env): return app.test_client() -def test_import_start_moves_staging_dir_and_updates_file_path(client, journal_env): +def test_import_start_moves_staging_dir_and_updates_file_path( + client, journal_env, monkeypatch +): + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + import_routes, + "emit", + lambda tract, event, **payload: emitted.append( + {"tract": tract, "event": event, **payload} + ), + ) old_ts = "20260101_120000" new_ts = "20260101_121500" import_dir = journal_env / "imports" / old_ts @@ -46,7 +56,12 @@ def test_import_start_moves_staging_dir_and_updates_file_path(client, journal_en response = client.post( "/app/import/api/start", - json={"path": str(media_path), "timestamp": new_ts}, + json={ + "path": str(media_path), + "timestamp": new_ts, + "source": "audio", + "force": True, + }, ) assert response.status_code == 200 @@ -56,6 +71,24 @@ def test_import_start_moves_staging_dir_and_updates_file_path(client, journal_en assert not import_dir.exists() metadata = json.loads((new_dir / "import.json").read_text(encoding="utf-8")) assert metadata["file_path"] == str(new_dir / media_path.name) + assert emitted == [ + { + "tract": "supervisor", + "event": "request", + "ref": response.get_json()["task_id"], + "cmd": [ + "journal", + "importer", + str(new_dir / media_path.name), + new_ts, + "--facet", + "work", + "--source", + "audio", + "--force", + ], + } + ] def test_import_start_missing_source_returns_import_not_found(client, journal_env): diff --git a/tests/test_importer_audio_sync.py b/tests/test_importer_audio_sync.py index 7cb04cc6a..95f05a6fa 100644 --- a/tests/test_importer_audio_sync.py +++ b/tests/test_importer_audio_sync.py @@ -730,7 +730,7 @@ def test_run_sync_dry_run_hint_includes_path_for_audio(tmp_path, monkeypatch, ca mod._run_sync("audio", dry_run=True, source_path=source_dir) output = capsys.readouterr().out - assert f"sol import --sync audio --save --path {source_dir}" in output + assert f"journal importer --sync audio --save --path {source_dir}" in output def test_run_sync_dry_run_hint_omits_path_for_pathless_backend( @@ -778,7 +778,7 @@ def test_run_sync_dry_run_hint_omits_path_for_pathless_backend( mod._run_sync("audio", dry_run=True, source_path=source_dir) output = capsys.readouterr().out - assert "sol import --sync audio --save" in output + assert "journal importer --sync audio --save" in output assert "--path" not in output diff --git a/tests/test_local_command_home.py b/tests/test_local_command_home.py index c6a656235..e600c3a7c 100644 --- a/tests/test_local_command_home.py +++ b/tests/test_local_command_home.py @@ -42,9 +42,11 @@ def test_local_commands_run_under_journal( ) -> None: captured = {} - def run_command(module_path: str) -> int: + def run_command(module_path: str, *, surface: str, binary: str) -> int: captured["module_path"] = module_path captured["argv"] = list(sys.argv) + captured["surface"] = surface + captured["binary"] = binary return 0 monkeypatch.setattr(sol_cli, "run_command", run_command) @@ -57,6 +59,8 @@ def test_local_commands_run_under_journal( assert captured == { "module_path": LOCAL_COMMANDS[command], "argv": [f"journal {command}", *extra_args], + "surface": "service", + "binary": "journal", } diff --git a/tests/test_maint_005_migrate_dream_to_think_schedules.py b/tests/test_maint_005_migrate_dream_to_think_schedules.py index 85345d15a..5ace50bf4 100644 --- a/tests/test_maint_005_migrate_dream_to_think_schedules.py +++ b/tests/test_maint_005_migrate_dream_to_think_schedules.py @@ -79,7 +79,7 @@ def test_non_dream_entries_preserved_byte_for_byte(tmp_path): initial = { "daily_time": "03:17", "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud", "--save"], + "cmd": ["journal", "importer", "--sync", "plaud", "--save"], "every": "hourly", "enabled": True, }, diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py index fcc072a46..d25b4eb1f 100644 --- a/tests/test_provider_state.py +++ b/tests/test_provider_state.py @@ -2,6 +2,7 @@ # Copyright (c) 2026 sol pbc import json +from types import SimpleNamespace from solstone.think.models import LOCAL_MODEL from solstone.think.providers import local_install, local_server, local_vulkan, state @@ -11,6 +12,20 @@ from solstone.think.providers.shared import ( ) +def _provider_exc( + module: str, + name: str, + *, + message: str = "provider error", + attrs: dict[str, object] | None = None, +) -> BaseException: + exc_type = type(name, (Exception,), {"__module__": module}) + exc = exc_type(message) + for attr, value in (attrs or {}).items(): + setattr(exc, attr, value) + return exc + + def _readiness( *, binary: bool = True, @@ -61,6 +76,93 @@ def test_runtime_reason_codes_are_state_reason_codes(): } <= RUNTIME_REASON_CODES +def test_classify_provider_error_matches_provider_exception_names(): + cases = [ + ( + _provider_exc("anthropic", "AuthenticationError"), + "anthropic", + "provider_key_invalid", + ), + ( + _provider_exc("openai", "PermissionDeniedError"), + "openai", + "provider_key_invalid", + ), + ( + _provider_exc( + "google.genai.errors", + "ClientError", + attrs={"_status_code": 403}, + ), + "google", + "provider_key_invalid", + ), + ( + _provider_exc("anthropic", "RateLimitError"), + "anthropic", + "provider_quota_exceeded", + ), + ( + _provider_exc( + "google.genai.errors", + "ClientError", + attrs={"_status_code": 429}, + ), + "google", + "provider_quota_exceeded", + ), + ( + _provider_exc( + "google.genai.errors", + "ClientError", + attrs={"_status_text": "RESOURCE_EXHAUSTED"}, + ), + "google", + "provider_quota_exceeded", + ), + (_provider_exc("openai", "APITimeoutError"), "openai", "chat_timeout"), + (_provider_exc("httpx", "TimeoutException"), "openai", "chat_timeout"), + ( + _provider_exc("anthropic", "APIConnectionError"), + "anthropic", + "network_unreachable", + ), + (_provider_exc("httpx", "RequestError"), "openai", "network_unreachable"), + ( + _provider_exc("openai", "InternalServerError"), + "openai", + "provider_unavailable", + ), + ( + _provider_exc("google.genai.errors", "ServerError"), + "google", + "provider_unavailable", + ), + ( + _provider_exc("anthropic", "APIStatusError", attrs={"status_code": 503}), + "anthropic", + "provider_unavailable", + ), + ( + _provider_exc( + "httpx", + "HTTPStatusError", + attrs={"response": SimpleNamespace(status_code=502)}, + ), + "openai", + "provider_unavailable", + ), + ( + _provider_exc("google.genai.errors", "UnknownApiResponseError"), + "google", + "provider_response_invalid", + ), + ] + + for exc, provider, expected in cases: + assert classify_provider_error(exc, provider) == expected + + def test_cloud_readiness_missing_key(monkeypatch): monkeypatch.setattr(state, "cloud_key_configured", lambda _env_key: False) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 05840dad1..e7bc850c2 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -104,7 +104,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", }, }, @@ -114,7 +114,12 @@ class TestLoadConfig: entries = load_config() assert "sync:plaud" in entries assert entries["sync:plaud"]["every"] == "hourly" - assert entries["sync:plaud"]["cmd"] == ["sol", "import", "--sync", "plaud"] + assert entries["sync:plaud"]["cmd"] == [ + "journal", + "importer", + "--sync", + "plaud", + ] def test_missing_file_returns_empty(self, journal_path): from solstone.think.scheduler import load_config @@ -220,7 +225,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", "max_runtime": "30m", }, @@ -236,7 +241,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", "max_runtime": 1800, }, @@ -254,7 +259,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", "max_runtime": -5, }, @@ -271,7 +276,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", "max_runtime": "garbage", }, @@ -288,7 +293,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", "max_runtime": [1, 2], }, @@ -305,7 +310,7 @@ class TestLoadConfig: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", "max_runtime": "30m", }, @@ -320,7 +325,7 @@ class TestLoadConfig: mod.init(Mock()) assert mod.collect_runtime_caps() == [ - (["sol", "import", "--sync", "plaud"], 1800) + (["journal", "importer", "--sync", "plaud"], 1800) ] @@ -1607,7 +1612,7 @@ class TestCLI: journal_path, { "sync:plaud": { - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "every": "hourly", }, }, diff --git a/tests/test_sol.py b/tests/test_sol.py index 7d80e3a84..3b12e2878 100644 --- a/tests/test_sol.py +++ b/tests/test_sol.py @@ -48,8 +48,10 @@ def service_alias_names() -> list[str]: def run_dispatch(monkeypatch, binary: str, name: str) -> dict[str, object]: result: dict[str, object] = {} - def fake_run_command(module_path: str) -> int: + def fake_run_command(module_path: str, *, surface: str, binary: str) -> int: result["module"] = module_path + result["surface"] = surface + result["binary"] = binary result["argv"] = sys.argv[:] return 0 @@ -73,10 +75,17 @@ class TestResolveCommand: def test_resolve_known_command(self): """Test resolving a known command from registry.""" module_path, preset_args, surface = sol.resolve_command("import") - assert module_path == "solstone.think.importers.cli" + assert module_path == "solstone.think.import_client" assert preset_args == [] assert surface == "access" + def test_resolve_importer_service_command(self): + """Test resolving the service-side import engine.""" + module_path, preset_args, surface = sol.resolve_command("importer") + assert module_path == "solstone.think.importers.cli" + assert preset_args == [] + assert surface == "service" + def test_resolve_direct_module_path(self): """Test resolving a direct module path with dot.""" module_path, preset_args, surface = sol.resolve_command( @@ -139,7 +148,9 @@ class TestRunCommand: mock_module.main = MagicMock(return_value=None) with patch("importlib.import_module", return_value=mock_module): - exit_code = sol.run_command("test.module") + exit_code = sol.run_command( + "test.module", surface="service", binary="journal" + ) assert exit_code == 0 mock_module.main.assert_called_once() @@ -149,7 +160,9 @@ class TestRunCommand: mock_module.main = MagicMock(side_effect=SystemExit(0)) with patch("importlib.import_module", return_value=mock_module): - exit_code = sol.run_command("test.module") + exit_code = sol.run_command( + "test.module", surface="service", binary="journal" + ) assert exit_code == 0 def test_run_command_with_nonzero_exit(self): @@ -158,7 +171,9 @@ class TestRunCommand: mock_module.main = MagicMock(side_effect=SystemExit(1)) with patch("importlib.import_module", return_value=mock_module): - exit_code = sol.run_command("test.module") + exit_code = sol.run_command( + "test.module", surface="service", binary="journal" + ) assert exit_code == 1 def test_run_command_with_string_exit(self, capsys): @@ -167,7 +182,9 @@ class TestRunCommand: mock_module.main = MagicMock(side_effect=SystemExit("Error: something failed")) with patch("importlib.import_module", return_value=mock_module): - exit_code = sol.run_command("test.module") + exit_code = sol.run_command( + "test.module", surface="service", binary="journal" + ) assert exit_code == 1 captured = capsys.readouterr() @@ -178,15 +195,46 @@ class TestRunCommand: with patch( "importlib.import_module", side_effect=ImportError("No module named 'fake'") ): - exit_code = sol.run_command("fake.module") + exit_code = sol.run_command( + "fake.module", surface="service", binary="journal" + ) assert exit_code == 1 + def test_run_command_missing_journal_dependency_prints_hint(self, capsys): + """Service commands missing third-party deps get the journal-extra hint.""" + missing = ModuleNotFoundError("No module named 'flask'", name="flask") + with patch("importlib.import_module", side_effect=missing): + exit_code = sol.run_command( + "solstone.convey.cli", surface="service", binary="journal" + ) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "this command needs the journal host dependencies" in captured.err + assert "pip install 'solstone[journal]'" in captured.err + assert "uv tool install 'solstone[journal]'" in captured.err + + def test_run_command_access_import_error_keeps_raw_error(self, capsys): + """Access command import errors do not get the journal-extra hint.""" + missing = ModuleNotFoundError("No module named 'numpy'", name="numpy") + with patch("importlib.import_module", side_effect=missing): + exit_code = sol.run_command( + "solstone.think.notify_cli", surface="access", binary="sol" + ) + + captured = capsys.readouterr() + assert exit_code == 1 + assert "Could not import module 'solstone.think.notify_cli'" in captured.err + assert "solstone[journal]" not in captured.err + def test_run_command_no_main_function(self): """Test handling module without main() function.""" mock_module = MagicMock(spec=[]) # No 'main' attribute with patch("importlib.import_module", return_value=mock_module): - exit_code = sol.run_command("test.module") + exit_code = sol.run_command( + "test.module", surface="service", binary="journal" + ) assert exit_code == 1 def test_main_propagates_integer_return_code_via_real_subprocess(self, tmp_path): @@ -280,8 +328,10 @@ class TestMain: captured: dict[str, object] = {} calls = [] - def fake_run_command(module_path: str) -> int: + def fake_run_command(module_path: str, *, surface: str, binary: str) -> int: captured["module"] = module_path + captured["surface"] = surface + captured["binary"] = binary captured["argv"] = sys.argv[:] return 0 @@ -303,7 +353,9 @@ class TestMain: rewritten_argv = captured["argv"] assert exc_info.value.code == 0 - assert captured["module"] == "solstone.think.importers.cli" + assert captured["module"] == "solstone.think.import_client" + assert captured["surface"] == "access" + assert captured["binary"] == "sol" assert isinstance(rewritten_argv, list) assert rewritten_argv[0] == "sol import" assert "-v" not in rewritten_argv @@ -512,7 +564,9 @@ class TestCommandRegistry: monkeypatch.setattr( sol, "run_command", - lambda _module_path: pytest.fail("access command should not run"), + lambda _module_path, **_kwargs: pytest.fail( + "access command should not run" + ), ) monkeypatch.setattr(sys, "argv", ["journal", name]) @@ -523,6 +577,24 @@ class TestCommandRegistry: assert exc_info.value.code == 2 assert JOURNAL_ACCESS_CMD_ERROR.format(cmd=name) in captured.err + def test_journal_import_keeps_access_routing_error(self, monkeypatch, capsys): + """The service-side engine is `journal importer`; `journal import` remains invalid.""" + monkeypatch.setattr( + sol, + "run_command", + lambda _module_path, **_kwargs: pytest.fail( + "journal import should not run" + ), + ) + monkeypatch.setattr(sys, "argv", ["journal", "import", "--help"]) + + with pytest.raises(SystemExit) as exc_info: + sol.journal_main() + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert JOURNAL_ACCESS_CMD_ERROR.format(cmd="import") in captured.err + def test_journal_help_lists_service_and_universal_surfaces(self): """journal --help renders service and universal command lists.""" code = ( @@ -540,10 +612,15 @@ class TestCommandRegistry: ) assert result.returncode == 0, result.stderr + rendered_commands = { + line.strip().split()[0] + for line in result.stdout.splitlines() + if line.startswith(" ") and line.strip() + } for name in service_command_names() + universal_command_names(): - assert name in result.stdout + assert name in rendered_commands for name in access_command_names(): - assert name not in result.stdout + assert name not in rendered_commands assert "sol call" not in result.stdout def test_sol_help_lists_access_groups_only(self): @@ -596,7 +673,7 @@ class TestCommandRegistry: def test_setproctitle_prefix_uses_active_binary(self, monkeypatch): """The process title identifies whether sol or journal dispatched the command.""" titles = [] - monkeypatch.setattr(sol, "run_command", lambda _module_path: 0) + monkeypatch.setattr(sol, "run_command", lambda _module_path, **_kwargs: 0) monkeypatch.setattr(sol.setproctitle, "setproctitle", titles.append) monkeypatch.setattr(sys, "argv", ["sol", "chat"]) diff --git a/tests/test_sol_service_hard_error.py b/tests/test_sol_service_hard_error.py index adbde4fa6..043584454 100644 --- a/tests/test_sol_service_hard_error.py +++ b/tests/test_sol_service_hard_error.py @@ -40,7 +40,9 @@ def test_sol_service_commands_hard_error(monkeypatch, capsys, name): monkeypatch.setattr( sol_cli, "run_command", - lambda _module_path: pytest.fail("service command should not run"), + lambda _module_path, *, surface, binary: pytest.fail( + "service command should not run" + ), ) monkeypatch.setattr(sys, "argv", ["sol", name]) @@ -58,7 +60,9 @@ def test_sol_service_aliases_hard_error(monkeypatch, capsys, name): monkeypatch.setattr( sol_cli, "run_command", - lambda _module_path: pytest.fail("service alias should not run"), + lambda _module_path, *, surface, binary: pytest.fail( + "service alias should not run" + ), ) monkeypatch.setattr(sys, "argv", ["sol", name]) @@ -73,9 +77,11 @@ def test_sol_service_aliases_hard_error(monkeypatch, capsys, name): def test_sol_access_commands_still_dispatch(monkeypatch): result: dict[str, object] = {} - def fake_run_command(module_path: str) -> int: + def fake_run_command(module_path: str, *, surface: str, binary: str) -> int: result["module"] = module_path result["argv"] = sys.argv[:] + result["surface"] = surface + result["binary"] = binary return 0 monkeypatch.setattr(sol_cli, "run_command", fake_run_command) @@ -89,6 +95,8 @@ def test_sol_access_commands_still_dispatch(monkeypatch): assert result == { "module": sol_cli.COMMANDS["chat"].module, "argv": ["sol chat"], + "surface": "access", + "binary": "sol", } diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 30a657e78..dafc81530 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -1539,9 +1539,11 @@ def test_task_history_records_cap_kill_as_timeout(monkeypatch): def test_handle_task_request_skips_still_running(monkeypatch): mod = importlib.import_module("solstone.think.supervisor") queue = mod.TaskQueue(on_queue_change=None) - managed = _TaskManagedStub(cmd=["sol", "import"], start_time=100.0) + managed = _TaskManagedStub( + cmd=["journal", "importer", "--sync", "plaud"], start_time=100.0 + ) queue._active["active-ref"] = managed - queue.set_cap("import", 50) + queue.set_cap("importer", 50) callosum = MagicMock() monkeypatch.setattr(mod, "_task_queue", queue) @@ -1552,7 +1554,7 @@ def test_handle_task_request_skips_still_running(monkeypatch): { "tract": "supervisor", "event": "request", - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "ref": "requested-ref", "scheduler_name": "sync-plaud", } @@ -1564,7 +1566,7 @@ def test_handle_task_request_skips_still_running(monkeypatch): reason="still_running", ref="requested-ref", active_ref="active-ref", - cmd=["sol", "import", "--sync", "plaud"], + cmd=["journal", "importer", "--sync", "plaud"], scheduler_name="sync-plaud", ) assert queue._queues == {} @@ -1573,9 +1575,11 @@ def test_handle_task_request_skips_still_running(monkeypatch): def test_handle_task_request_skips_wedged(monkeypatch): mod = importlib.import_module("solstone.think.supervisor") queue = mod.TaskQueue(on_queue_change=None) - managed = _TaskManagedStub(cmd=["sol", "import"], start_time=100.0) + managed = _TaskManagedStub( + cmd=["journal", "importer", "--sync", "plaud"], start_time=100.0 + ) queue._active["active-ref"] = managed - queue.set_cap("import", 50) + queue.set_cap("importer", 50) callosum = MagicMock() monkeypatch.setattr(mod, "_task_queue", queue) @@ -1586,7 +1590,7 @@ def test_handle_task_request_skips_wedged(monkeypatch): { "tract": "supervisor", "event": "request", - "cmd": ["sol", "import", "--sync", "plaud"], + "cmd": ["journal", "importer", "--sync", "plaud"], "ref": "requested-ref", } ) @@ -1635,11 +1639,11 @@ def test_enforce_deadlines_terminates_when_elapsed_exceeds_cap(caplog, monkeypat mod = importlib.import_module("solstone.think.supervisor") queue = mod.TaskQueue(on_queue_change=None) managed = _TaskManagedStub( - cmd=["sol", "import", "--sync", "plaud", "--save"], + cmd=["journal", "importer", "--sync", "plaud", "--save"], start_time=100.0, ) queue._active["ref-1"] = managed - queue.set_cap("import", 50) + queue.set_cap("importer", 50) def terminate_now(key, managed_arg, timeout, reason): assert key == "ref-1" @@ -1654,7 +1658,7 @@ def test_enforce_deadlines_terminates_when_elapsed_exceeds_cap(caplog, monkeypat managed.terminate.assert_called_once_with(timeout=2.0) assert ( - "Task import (cmd=sol import --sync plaud --save, ref=ref-1) exceeded " + "Task importer (cmd=journal importer --sync plaud --save, ref=ref-1) exceeded " "max_runtime of 50s (elapsed=100s); terminating" ) in caplog.text -- 2.51.2