feat(storage): resolve portable workspace roots

This commit is contained in:
2026-07-11 21:21:06 +02:00
parent 3e2ad7955c
commit eacb139e22
6 changed files with 299 additions and 0 deletions
+2
View File
@@ -41,6 +41,7 @@ from tht.cli.cte_cmd import cte_app # noqa: E402
from tht.cli.datamart_cmd import datamart_app # noqa: E402
from tht.cli.db_cmd import db_app # noqa: E402
from tht.cli.decision_cmd import decision_app # noqa: E402
from tht.cli.doctor_cmd import doctor # noqa: E402
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
@@ -69,3 +70,4 @@ 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")
app.command("doctor")(doctor)
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import typer
from tht.cli.config_cmd import CONFIG_OPT
from tht.config import ConfigError, load_config
def _emit(payload: dict[str, Any], as_json: bool) -> None:
if as_json:
# A single serializer call keeps stdout valid for machine consumers.
typer.echo(json.dumps(payload, sort_keys=True))
return
for component, result in payload["components"].items():
typer.echo(f"{component}: {result['status']}")
def doctor(
config: Path = CONFIG_OPT,
as_json: bool = typer.Option(False, "--json", help="Emette diagnostica JSON."),
) -> None:
"""Validate portable storage configuration without contacting external services."""
data_root = os.environ.get("THT_DATA_ROOT")
components: dict[str, dict[str, Any]] = {
"config": {"status": "ok"},
"data_root": {"status": "ok" if data_root else "warning"},
}
try:
cfg = load_config(config)
except ConfigError as exc:
path_error = "outside workspace root" in str(exc)
target = "workspace_paths" if path_error else "config"
# Validation errors can contain Pydantic input excerpts, including credentials.
message = str(exc) if path_error else "configuration is invalid"
components[target] = {"status": "error", "message": message}
payload = {"ok": False, "components": components}
_emit(payload, as_json)
raise typer.Exit(code=1)
legacy_absolute = [
name
for name in ("sessions", "artifacts", "indexes")
if getattr(cfg.roots, name).is_absolute()
]
components["workspace_paths"] = {
"status": "warning" if legacy_absolute else "ok",
"legacy_absolute": legacy_absolute,
}
_emit({"ok": True, "components": components}, as_json)
+20
View File
@@ -254,6 +254,26 @@ def load_config(path: Path) -> Config:
raise ConfigError(
f"transport: rest richiede la sezione `rest` (base_url, api_key) in {path}."
)
data_root = os.environ.get("THT_DATA_ROOT")
if data_root:
# Import locally: paths owns resolution, while ConfigError remains the public
# configuration exception callers already handle.
from tht.paths import resolve_workspace_paths
resolved = resolve_workspace_paths(path, cfg, Path(data_root))
cfg = cfg.model_copy(
update={
"paths": PathsConfig(
sessions=resolved.sessions,
artifacts=resolved.artifacts,
indexes=resolved.indexes,
)
}
)
elif not used_legacy:
# Modern `roots` replace `paths`; without a mounted data root retain the old
# working-directory-relative behavior used by local development.
cfg = cfg.model_copy(update={"paths": PathsConfig(**cfg.roots.model_dump())})
if used_legacy:
warnings.warn(
"DEPRECATION: legacy workspace resource keys are deprecated; "
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from tht.config import ConfigError
if TYPE_CHECKING:
from tht.config import Config
@dataclass(frozen=True)
class ResolvedPaths:
workspace: Path
sessions: Path
artifacts: Path
indexes: Path
corpus: Path
def _resolve_root(path: Path, workspace: Path, name: str) -> Path:
if path.is_absolute():
# Existing deployments commonly point at an external workspace checkout. Keep
# those paths working while `tht doctor` identifies them for migration.
return path
resolved = (workspace / path).resolve()
if not resolved.is_relative_to(workspace):
raise ConfigError(f"paths.{name} resolves outside workspace root")
return resolved
def resolve_workspace_paths(
config_path: Path, cfg: Config, data_root: Path
) -> ResolvedPaths:
"""Resolve portable paths under ``<data_root>/workspaces/<config stem>``.
Relative roots are sandboxed to the logical workspace. Absolute roots are a
compatibility bridge for existing installations and are never rewritten.
"""
workspace = (data_root / "workspaces" / config_path.stem).resolve()
roots = cfg.roots
return ResolvedPaths(
workspace=workspace,
sessions=_resolve_root(roots.sessions, workspace, "sessions"),
artifacts=_resolve_root(roots.artifacts, workspace, "artifacts"),
indexes=_resolve_root(roots.indexes, workspace, "indexes"),
corpus=(workspace / "corpus").resolve(),
)