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)
|
||||
@@ -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