feat(harness): add local session repository
This commit is contained in:
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"requests>=2.31",
|
||||
"tqdm>=4.66",
|
||||
"yake>=0.4",
|
||||
"portalocker>=2.10",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from tht.session.models import local_principal
|
||||
|
||||
|
||||
def test_local_identity_uuid_is_stable_across_restarts(tmp_path):
|
||||
first = local_principal(tmp_path)
|
||||
restarted = local_principal(tmp_path)
|
||||
|
||||
assert first == restarted
|
||||
assert first.issuer == "local"
|
||||
assert uuid.UUID(first.subject, version=4).version == 4
|
||||
assert json.loads((tmp_path / "identity.json").read_text()) == {
|
||||
"issuer": "local",
|
||||
"subject": first.subject,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import uuid
|
||||
|
||||
from tht.config import DatabaseConfig, load_config
|
||||
from tht.decisions import DecisionInput
|
||||
from tht.session.filesystem_repository import FilesystemSessionRepository
|
||||
from tht.session.models import PrincipalContext, SessionManifest
|
||||
from tht.session.repository import build_session_repository
|
||||
from tht.session.store import create_session
|
||||
|
||||
|
||||
def _db() -> DatabaseConfig:
|
||||
return DatabaseConfig(database="testdb", schema="public", user="u", password="p") # noqa: S106
|
||||
|
||||
|
||||
def _config(tmp_path):
|
||||
path = tmp_path / "workspace.yaml"
|
||||
path.write_text(
|
||||
"dwh:\n"
|
||||
" type: postgres_direct\n"
|
||||
" connection:\n"
|
||||
" database: testdb\n"
|
||||
" schema: public\n"
|
||||
" user: user\n"
|
||||
" password: secret\n"
|
||||
)
|
||||
return load_config(path)
|
||||
|
||||
|
||||
def _manifest(session_id: str) -> SessionManifest:
|
||||
return SessionManifest(
|
||||
id=session_id,
|
||||
created_at="2026-07-16T10:00:00Z",
|
||||
question="Which patients had an ablation?",
|
||||
database="testdb",
|
||||
schema="public",
|
||||
)
|
||||
|
||||
|
||||
def test_new_sessions_use_uuid4_ids(tmp_path):
|
||||
manifest = create_session("Which patients had an ablation?", _db(), tmp_path)
|
||||
|
||||
session_uuid = uuid.UUID(manifest.id, version=4)
|
||||
assert str(session_uuid) == manifest.id
|
||||
assert session_uuid.version == 4
|
||||
|
||||
|
||||
def test_filesystem_repository_scopes_sessions_to_local_principal_root(tmp_path):
|
||||
config = _config(tmp_path)
|
||||
principal = PrincipalContext(issuer="local", subject="alice")
|
||||
repository = build_session_repository(config, principal, home=tmp_path / "alice-home")
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
repository.create(_manifest(session_id))
|
||||
|
||||
assert repository.root == tmp_path / "alice-home/workspaces/workspace/sessions"
|
||||
assert (repository.root / session_id / "session_manifest.yaml").exists()
|
||||
|
||||
|
||||
def test_filesystem_repository_reads_phase_artifacts_and_appends_decisions(tmp_path):
|
||||
repository = FilesystemSessionRepository(
|
||||
tmp_path / "home", "demo", PrincipalContext(issuer="local", subject="alice")
|
||||
)
|
||||
session_id = str(uuid.uuid4())
|
||||
repository.create(_manifest(session_id))
|
||||
|
||||
repository.write_artifact(session_id, "sql_final", "SELECT 1")
|
||||
decisions = repository.append_decisions(
|
||||
session_id,
|
||||
[DecisionInput(type="sql_approved", subject="phase:7", detail="SELECT 1")],
|
||||
)
|
||||
snapshot = repository.get(session_id)
|
||||
|
||||
assert repository.read_artifact(session_id, "sql_final") == "SELECT 1"
|
||||
assert snapshot.artifacts["sql_final"] == "SELECT 1"
|
||||
assert [decision.type for decision in snapshot.decisions] == ["sql_approved"]
|
||||
assert decisions[0].seq == 1
|
||||
|
||||
|
||||
def test_tht_home_overrides_local_repository_root(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("THT_HOME", str(tmp_path / "override"))
|
||||
config = _config(tmp_path)
|
||||
|
||||
repository = build_session_repository(
|
||||
config, PrincipalContext(issuer="local", subject="alice")
|
||||
)
|
||||
|
||||
assert config.paths.sessions == tmp_path / "override/workspaces/workspace/sessions"
|
||||
assert repository.root == tmp_path / "override/workspaces/workspace/sessions"
|
||||
|
||||
|
||||
def test_filesystem_repository_keeps_preferences_per_principal(tmp_path):
|
||||
home = tmp_path / "home"
|
||||
alice = FilesystemSessionRepository(home, "demo", PrincipalContext(issuer="local", subject="alice"))
|
||||
bob = FilesystemSessionRepository(home, "demo", PrincipalContext(issuer="local", subject="bob"))
|
||||
|
||||
alice.set_preferences({"model": "glm"})
|
||||
|
||||
assert alice.get_preferences() == {"model": "glm"}
|
||||
assert bob.get_preferences() == {}
|
||||
@@ -16,6 +16,11 @@ class ConfigError(Exception):
|
||||
"""Errore di configurazione, con messaggio leggibile per l'utente."""
|
||||
|
||||
|
||||
def local_tht_home() -> Path:
|
||||
"""Return the private local ThothII home, honoring the explicit override."""
|
||||
return Path(os.environ.get("THT_HOME", "~/.thothii")).expanduser()
|
||||
|
||||
|
||||
def _expand_env(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
|
||||
@@ -356,7 +361,9 @@ 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")
|
||||
# THT_HOME is the local-user storage root. Retain THT_DATA_ROOT as the
|
||||
# portable-deployment compatibility name until all callers use repositories.
|
||||
data_root = os.environ.get("THT_HOME") or os.environ.get("THT_DATA_ROOT")
|
||||
if data_root:
|
||||
# Import locally: paths owns resolution, while ConfigError remains the public
|
||||
# configuration exception callers already handle.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Private local filesystem implementation of the session repository contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
import portalocker
|
||||
import yaml
|
||||
|
||||
from tht.decisions import DecisionInput, DecisionRecord, append_decisions, list_decisions
|
||||
from tht.session.models import PrincipalContext, SessionManifest, SessionSnapshot
|
||||
from tht.session.store import MANIFEST, SessionError
|
||||
|
||||
_ARTIFACT_FILES = {
|
||||
"question": "question.md",
|
||||
"schema_linking": "schema_linking.json",
|
||||
"sql_final": "sql_final.sql",
|
||||
"validation_report": "validation_report.md",
|
||||
"retrieval_pack": "retrieval_pack.md",
|
||||
"cte_tests": "cte_tests.json",
|
||||
}
|
||||
_ARTIFACT_KEYS = {filename: key for key, filename in _ARTIFACT_FILES.items()}
|
||||
_SAFE_CTE_NAME = re.compile(r"[A-Za-z0-9_-]+\Z")
|
||||
|
||||
|
||||
class FilesystemSessionRepository:
|
||||
"""Readable phase documents under one private local user's ThothII home."""
|
||||
|
||||
def __init__(self, home: Path, workspace: str, principal: PrincipalContext):
|
||||
self.home = Path(home).expanduser()
|
||||
self.workspace = workspace
|
||||
self.principal = principal
|
||||
self.root = self.home / "workspaces" / workspace / "sessions"
|
||||
|
||||
@property
|
||||
def preferences_path(self) -> Path:
|
||||
digest = hashlib.sha256(
|
||||
f"{self.principal.issuer}\0{self.principal.subject}".encode()
|
||||
).hexdigest()
|
||||
return self.home / "principals" / digest / "preferences.json"
|
||||
|
||||
def create(self, manifest: SessionManifest) -> SessionSnapshot:
|
||||
self._require_uuid4(manifest.id)
|
||||
session_dir = self.root / manifest.id
|
||||
with self._lock(session_dir):
|
||||
if (session_dir / MANIFEST).exists():
|
||||
raise SessionError(f"Sessione esiste gia': {manifest.id}")
|
||||
self._private_directory(session_dir)
|
||||
self._write_manifest(session_dir, manifest)
|
||||
return self.get(manifest.id)
|
||||
|
||||
def get(self, session_id: str) -> SessionSnapshot:
|
||||
session_dir = self._session_dir(session_id)
|
||||
manifest_path = session_dir / MANIFEST
|
||||
if not manifest_path.exists():
|
||||
raise SessionError(f"Sessione non trovata: {session_id} (atteso {manifest_path})")
|
||||
manifest = SessionManifest.from_yaml(manifest_path)
|
||||
artifacts = self._read_artifacts(session_dir)
|
||||
return SessionSnapshot(
|
||||
principal=self.principal,
|
||||
manifest=manifest,
|
||||
artifacts=artifacts,
|
||||
decisions=list_decisions(session_dir),
|
||||
)
|
||||
|
||||
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot:
|
||||
session_dir = self._session_dir(manifest.id)
|
||||
with self._lock(session_dir):
|
||||
self._require_existing(session_dir, manifest.id)
|
||||
self._write_manifest(session_dir, manifest)
|
||||
return self.get(manifest.id)
|
||||
|
||||
def read_artifact(self, session_id: str, key: str) -> str | None:
|
||||
session_dir = self._session_dir(session_id)
|
||||
self._require_existing(session_dir, session_id)
|
||||
path = self._artifact_path(session_dir, key)
|
||||
return path.read_text() if path.exists() else None
|
||||
|
||||
def write_artifact(self, session_id: str, key: str, content: str) -> None:
|
||||
session_dir = self._session_dir(session_id)
|
||||
with self._lock(session_dir):
|
||||
self._require_existing(session_dir, session_id)
|
||||
self._write_private_text(self._artifact_path(session_dir, key), content)
|
||||
|
||||
def append_decisions(
|
||||
self, session_id: str, decisions: Sequence[DecisionInput | dict]
|
||||
) -> list[DecisionRecord]:
|
||||
session_dir = self._session_dir(session_id)
|
||||
with self._lock(session_dir):
|
||||
self._require_existing(session_dir, session_id)
|
||||
# The legacy ledger helper remains the canonical record format. Its
|
||||
# separate lock also keeps direct workflow callers safe during Task 3.
|
||||
return append_decisions(session_dir, list(decisions))
|
||||
|
||||
def get_preferences(self) -> dict:
|
||||
path = self.preferences_path
|
||||
if not path.exists():
|
||||
return {}
|
||||
data = json.loads(path.read_text())
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Invalid preferences: {path}")
|
||||
return data
|
||||
|
||||
def set_preferences(self, preferences: dict) -> None:
|
||||
if not isinstance(preferences, dict):
|
||||
raise TypeError("preferences must be a dictionary")
|
||||
path = self.preferences_path
|
||||
self._private_directory(path.parent)
|
||||
with portalocker.Lock(path.with_suffix(".lock"), mode="a+", timeout=10):
|
||||
self._write_private_text(path, json.dumps(preferences, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
def delete(self, session_id: str) -> None:
|
||||
session_dir = self._session_dir(session_id)
|
||||
with self._lock(session_dir):
|
||||
self._require_existing(session_dir, session_id)
|
||||
shutil.rmtree(session_dir)
|
||||
|
||||
def _session_dir(self, session_id: str) -> Path:
|
||||
self._require_uuid4(session_id)
|
||||
return self.root / session_id
|
||||
|
||||
@staticmethod
|
||||
def _require_uuid4(session_id: str) -> None:
|
||||
try:
|
||||
parsed = uuid.UUID(session_id, version=4)
|
||||
except ValueError as exc:
|
||||
raise SessionError(f"ID sessione non UUIDv4: {session_id}") from exc
|
||||
if str(parsed) != session_id or parsed.version != 4:
|
||||
raise SessionError(f"ID sessione non UUIDv4: {session_id}")
|
||||
|
||||
@staticmethod
|
||||
def _private_directory(path: Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o700)
|
||||
|
||||
def _lock(self, session_dir: Path):
|
||||
self._private_directory(session_dir)
|
||||
return portalocker.Lock(session_dir / ".repository.lock", mode="a+", timeout=10)
|
||||
|
||||
@staticmethod
|
||||
def _require_existing(session_dir: Path, session_id: str) -> None:
|
||||
if not (session_dir / MANIFEST).exists():
|
||||
raise SessionError(f"Sessione non trovata: {session_id}")
|
||||
|
||||
def _artifact_path(self, session_dir: Path, key: str) -> Path:
|
||||
if key in _ARTIFACT_FILES:
|
||||
return session_dir / _ARTIFACT_FILES[key]
|
||||
if key.startswith("cte_sql:"):
|
||||
name = key.removeprefix("cte_sql:")
|
||||
if _SAFE_CTE_NAME.fullmatch(name):
|
||||
return session_dir / "ctes" / f"{name}.sql"
|
||||
raise ValueError(f"Unsupported artifact key: {key}")
|
||||
|
||||
def _read_artifacts(self, session_dir: Path) -> dict[str, str]:
|
||||
artifacts = {
|
||||
key: (session_dir / filename).read_text()
|
||||
for filename, key in _ARTIFACT_KEYS.items()
|
||||
if (session_dir / filename).is_file()
|
||||
}
|
||||
ctes = session_dir / "ctes"
|
||||
if ctes.is_dir():
|
||||
artifacts.update(
|
||||
{
|
||||
f"cte_sql:{path.stem}": path.read_text()
|
||||
for path in ctes.glob("*.sql")
|
||||
if _SAFE_CTE_NAME.fullmatch(path.stem)
|
||||
}
|
||||
)
|
||||
return artifacts
|
||||
|
||||
def _write_manifest(self, session_dir: Path, manifest: SessionManifest) -> None:
|
||||
content = yaml.safe_dump(
|
||||
manifest.model_dump(by_alias=True, mode="json", exclude_defaults=False),
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=120,
|
||||
)
|
||||
self._write_private_text(session_dir / MANIFEST, content)
|
||||
|
||||
def _write_private_text(self, path: Path, content: str) -> None:
|
||||
self._private_directory(path.parent)
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if os.name != "nt":
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, path)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o600)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -1,10 +1,17 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal, Self
|
||||
|
||||
import portalocker
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from tht.decisions import DecisionRecord
|
||||
|
||||
|
||||
# Stub locale di _YamlModel. In the reference implementation questa base vive in
|
||||
# mschema/models.py; qui la si replica perche' SessionManifest ha bisogno di
|
||||
@@ -26,6 +33,74 @@ class _YamlModel(BaseModel):
|
||||
return cls.model_validate(raw)
|
||||
|
||||
|
||||
class PrincipalContext(BaseModel):
|
||||
"""Trusted owner identity supplied by the runtime, never by a session document."""
|
||||
|
||||
issuer: str
|
||||
subject: str
|
||||
display_name: str | None = None
|
||||
is_admin: bool = False
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
def _private_directory(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o700)
|
||||
return path
|
||||
|
||||
|
||||
def _write_private_json(path: Path, data: dict[str, str]) -> None:
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
json.dump(data, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if os.name != "nt":
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, path)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o600)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def local_principal(home: Path | None = None) -> PrincipalContext:
|
||||
"""Load or create the stable UUID identity for a local ThothII home."""
|
||||
if home is None:
|
||||
from tht.config import local_tht_home
|
||||
|
||||
home = local_tht_home()
|
||||
identity_path = _private_directory(home) / "identity.json"
|
||||
lock_path = identity_path.with_suffix(".lock")
|
||||
with portalocker.Lock(lock_path, mode="a+", timeout=10):
|
||||
if identity_path.exists():
|
||||
try:
|
||||
identity = json.loads(identity_path.read_text())
|
||||
principal = PrincipalContext.model_validate(identity)
|
||||
except (json.JSONDecodeError, OSError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid local identity: {identity_path}") from exc
|
||||
if principal.issuer != "local":
|
||||
raise ValueError(f"Invalid local identity issuer: {identity_path}")
|
||||
try:
|
||||
parsed = uuid.UUID(principal.subject, version=4)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid local identity subject: {identity_path}") from exc
|
||||
if str(parsed) != principal.subject or parsed.version != 4:
|
||||
raise ValueError(f"Invalid local identity subject: {identity_path}")
|
||||
return principal
|
||||
|
||||
principal = PrincipalContext(issuer="local", subject=str(uuid.uuid4()))
|
||||
_write_private_json(
|
||||
identity_path, {"issuer": principal.issuer, "subject": principal.subject}
|
||||
)
|
||||
return principal
|
||||
|
||||
|
||||
class SessionManifest(_YamlModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
@@ -47,6 +122,15 @@ class SessionManifest(_YamlModel):
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class SessionSnapshot(BaseModel):
|
||||
"""The current persisted session state, excluding the non-persistent chat stream."""
|
||||
|
||||
principal: PrincipalContext | None = None
|
||||
manifest: SessionManifest
|
||||
artifacts: dict[str, str] = Field(default_factory=dict)
|
||||
decisions: list[DecisionRecord] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Candidate(BaseModel):
|
||||
kind: Literal["table", "column"]
|
||||
name: str
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Storage contract for the durable, user-owned session workflow state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from tht.decisions import DecisionInput, DecisionRecord
|
||||
from tht.session.models import PrincipalContext, SessionManifest, SessionSnapshot
|
||||
|
||||
|
||||
class SessionRepository(Protocol):
|
||||
"""Persistence boundary shared by local files and the server database adapter."""
|
||||
|
||||
principal: PrincipalContext
|
||||
|
||||
def create(self, manifest: SessionManifest) -> SessionSnapshot: ...
|
||||
|
||||
def get(self, session_id: str) -> SessionSnapshot: ...
|
||||
|
||||
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot: ...
|
||||
|
||||
def read_artifact(self, session_id: str, key: str) -> str | None: ...
|
||||
|
||||
def write_artifact(self, session_id: str, key: str, content: str) -> None: ...
|
||||
|
||||
def append_decisions(
|
||||
self, session_id: str, decisions: Sequence[DecisionInput | dict]
|
||||
) -> list[DecisionRecord]: ...
|
||||
|
||||
def get_preferences(self) -> dict: ...
|
||||
|
||||
def set_preferences(self, preferences: dict) -> None: ...
|
||||
|
||||
def delete(self, session_id: str) -> None: ...
|
||||
|
||||
|
||||
def build_session_repository(config, principal: PrincipalContext, *, home=None) -> SessionRepository:
|
||||
"""Build the local adapter for Task 1; server selection arrives with Task 2."""
|
||||
from tht.config import local_tht_home
|
||||
from tht.session.filesystem_repository import FilesystemSessionRepository
|
||||
|
||||
workspace = getattr(config, "_workspace_id", "default")
|
||||
return FilesystemSessionRepository(home or local_tht_home(), workspace, principal)
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,7 +9,6 @@ import yake
|
||||
|
||||
from tht.config import DatabaseConfig
|
||||
from tht.session.models import SessionManifest
|
||||
from tht.textutil import slugify
|
||||
|
||||
MANIFEST = "session_manifest.yaml"
|
||||
MAX_SLUG_CHARS = 40
|
||||
@@ -88,12 +88,13 @@ def render_question_md(question: str, assumptions: list[str] | None = None) -> s
|
||||
|
||||
|
||||
def _new_id(question: str, sessions_root: Path, stamp: str) -> str:
|
||||
base = f"{stamp}-{slugify(question)[:MAX_SLUG_CHARS].rstrip('-')}"
|
||||
candidate, n = base, 1
|
||||
while (sessions_root / candidate).exists():
|
||||
n += 1
|
||||
candidate = f"{base}-{n}"
|
||||
return candidate
|
||||
"""Create an opaque UUIDv4 session identifier.
|
||||
|
||||
``question``, ``sessions_root`` and ``stamp`` remain accepted temporarily so
|
||||
existing workflow callers do not need to change as the repository boundary
|
||||
is introduced.
|
||||
"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def create_session(
|
||||
|
||||
Reference in New Issue
Block a user