feat(harness): add local session repository
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user