From 7e19f81173ee77db6fc4ab7c6a85527b244edebc Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 29 Jun 2026 19:25:23 +0200 Subject: [PATCH] feat(harness): tht ollama ensure CLI command (hard-fail preflight) --- harness/tests/test_ollama_ensure.py | 41 +++++++++++++++++++++++++++++ harness/tht/cli/__init__.py | 2 ++ harness/tht/cli/ollama_cmd.py | 35 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/harness/tests/test_ollama_ensure.py b/harness/tests/test_ollama_ensure.py index d5aeb10e..e29f310e 100644 --- a/harness/tests/test_ollama_ensure.py +++ b/harness/tests/test_ollama_ensure.py @@ -138,3 +138,44 @@ def test_initial_probe_exception_is_server_error(): warm=lambda cfg: None, ) assert r["ok"] is False and r["stage"] == "server" + + +import json as _json + +from typer.testing import CliRunner + +from tht.cli.ollama_cmd import ollama_app +from tht.cli import ollama_cmd + + +def _patch(monkeypatch, result): + monkeypatch.setattr(ollama_cmd, "_load_config_or_exit", lambda _c: SimpleNamespace(embeddings=object())) + monkeypatch.setattr(ollama_cmd, "ensure_ollama", lambda cfg, **kw: result) + + +def test_cli_ok_exit_zero_and_json_pristine(monkeypatch): + _patch(monkeypatch, {"ok": True, "server": "up", "model": "warmed", "model_name": "m"}) + res = CliRunner().invoke(ollama_app, ["--json"]) + assert res.exit_code == 0, res.output + assert _json.loads(res.output) == {"ok": True, "server": "up", "model": "warmed", "model_name": "m"} + + +def test_cli_error_exit_one_and_json_on_stdout(monkeypatch): + _patch(monkeypatch, {"ok": False, "stage": "model", "error": "missing"}) + res = CliRunner().invoke(ollama_app, ["--json"]) + assert res.exit_code == 1 + assert _json.loads(res.output) == {"ok": False, "stage": "model", "error": "missing"} + + +def test_cli_error_human_mode_exit_one(monkeypatch): + _patch(monkeypatch, {"ok": False, "stage": "server", "error": "down"}) + res = CliRunner().invoke(ollama_app, []) + assert res.exit_code == 1 + + +def test_cli_registered_on_root_app(): + from tht.cli import app # the root Typer app + runner = CliRunner() + res = runner.invoke(app, ["ollama", "--help"]) + assert res.exit_code == 0 + assert "ensure" in res.output diff --git a/harness/tht/cli/__init__.py b/harness/tht/cli/__init__.py index 397de75f..f9c20444 100644 --- a/harness/tht/cli/__init__.py +++ b/harness/tht/cli/__init__.py @@ -45,6 +45,7 @@ from tht.cli.evidence_cmd import evidence_app # noqa: E402 from tht.cli.formula_cmd import formula_app # noqa: E402 from tht.cli.lsh_cmd import lsh_app # noqa: E402 from tht.cli.memory_cmd import memory_app # noqa: E402 +from tht.cli.ollama_cmd import ollama_app # noqa: E402 from tht.cli.phase_cmd import phase_app # noqa: E402 from tht.cli.schema_cmd import schema_app # noqa: E402 from tht.cli.search_cmd import search_app # noqa: E402 @@ -67,3 +68,4 @@ app.add_typer(sql_app, name="sql") app.add_typer(cte_app, name="cte") app.add_typer(datamart_app, name="datamart") app.add_typer(lsh_app, name="lsh") +app.add_typer(ollama_app, name="ollama") diff --git a/harness/tht/cli/ollama_cmd.py b/harness/tht/cli/ollama_cmd.py index 969ea63e..ec37548b 100644 --- a/harness/tht/cli/ollama_cmd.py +++ b/harness/tht/cli/ollama_cmd.py @@ -5,8 +5,17 @@ error (the caller refuses the session). "Load" = warm the already-installed mode """ from __future__ import annotations +import json import subprocess import time +from pathlib import Path + +import typer + +from tht.cli.config_cmd import CONFIG_OPT +from tht.cli.schema_cmd import _load_config_or_exit + +ollama_app = typer.Typer(help="Ollama (embeddings) -- preflight.") # --- low-level ops (real implementations; injected as fakes in tests) ---------- @@ -118,3 +127,29 @@ def ensure_ollama( "error": f"warm del modello '{emb.model}' fallito: {e}"} return {"ok": True, "server": server_state, "model": "warmed", "model_name": emb.model} + + +# --- CLI command ---------- + + +@ollama_app.command("ensure") +def ensure_cmd( + timeout: int = typer.Option(60, "--timeout", help="Secondi di attesa per l'avvio di Ollama."), + no_start: bool = typer.Option(False, "--no-start", help="Non avviare Ollama (solo verifica)."), + json_out: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."), + config: Path = CONFIG_OPT, +) -> None: + """Assicura Ollama attivo + modello di embedding caricato; errore se non possibile.""" + cfg = _load_config_or_exit(config) + result = ensure_ollama(cfg, timeout=timeout, no_start=no_start) + if json_out: + typer.echo(json.dumps(result, ensure_ascii=False)) + elif result["ok"]: + typer.secho( + f"OK: Ollama {result['server']}, modello {result['model_name']} {result['model']}.", + fg=typer.colors.GREEN, + ) + else: + typer.secho(f"ERRORE [{result['stage']}]: {result['error']}", fg=typer.colors.RED, err=True) + if not result["ok"]: + raise typer.Exit(code=1)