refactor(harness): route workflow persistence through repositories

This commit is contained in:
User
2026-07-16 18:01:29 +02:00
parent 259f021313
commit c1cddaa667
30 changed files with 925 additions and 348 deletions
+52 -3
View File
@@ -27,19 +27,24 @@ _ARTIFACT_FILES = {
"validation_report": "validation_report.md",
"retrieval_pack": "retrieval_pack.md",
"cte_tests": "cte_tests.json",
"cte_plan": "cte_plan.json",
"cte_plan_doc": "cte_plan_doc.json",
}
_ARTIFACT_KEYS = {filename: key for key, filename in _ARTIFACT_FILES.items()}
_SAFE_CTE_NAME = re.compile(r"[A-Za-z0-9_-]+\Z")
_LEGACY_SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
class FilesystemSessionRepository:
"""Readable phase documents under one private local user's ThothII home."""
def __init__(self, home: Path, workspace: str, principal: PrincipalContext):
def __init__(
self, home: Path, workspace: str, principal: PrincipalContext, *, root: Path | None = None
):
self.home = Path(home).expanduser()
self.workspace = workspace
self.principal = principal
self.root = self.home / "workspaces" / workspace / "sessions"
self.root = Path(root) if root is not None else self.home / "workspaces" / workspace / "sessions"
@property
def preferences_path(self) -> Path:
@@ -49,6 +54,8 @@ class FilesystemSessionRepository:
return self.home / "principals" / digest / "preferences.json"
def create(self, manifest: SessionManifest) -> SessionSnapshot:
# New records are opaque UUIDv4 only. Historical timestamp ids remain
# readable/mutable through _session_dir but are never created here.
self._require_uuid4(manifest.id)
session_dir = self.root / manifest.id
with self._lock(session_dir):
@@ -72,6 +79,18 @@ class FilesystemSessionRepository:
decisions=list_decisions(session_dir),
)
def list(self) -> list[SessionSnapshot]:
if not self.root.exists():
return []
snapshots = []
for path in self.root.iterdir():
if path.is_dir() and (path / MANIFEST).exists():
try:
snapshots.append(self.get(path.name))
except SessionError:
continue
return sorted(snapshots, key=lambda item: item.manifest.created_at, reverse=True)
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot:
session_dir = self._session_dir(manifest.id)
with self._lock(session_dir):
@@ -91,6 +110,12 @@ class FilesystemSessionRepository:
self._require_existing(session_dir, session_id)
self._write_private_text(self._artifact_path(session_dir, key), content)
def delete_artifact(self, session_id: str, key: str) -> None:
session_dir = self._session_dir(session_id)
with self._lock(session_dir):
self._require_existing(session_dir, session_id)
self._artifact_path(session_dir, key).unlink(missing_ok=True)
def append_decisions(
self, session_id: str, decisions: Sequence[DecisionInput | dict]
) -> list[DecisionRecord]:
@@ -101,6 +126,21 @@ class FilesystemSessionRepository:
# separate lock also keeps direct workflow callers safe during Task 3.
return append_decisions(session_dir, list(decisions))
def finalize(self, manifest: SessionManifest, artifacts: dict[str, str]) -> SessionSnapshot:
"""Commit verified final artifacts before publishing finalized status.
The finalized manifest is the filesystem commit marker: a crash can leave
an open session with already-rendered artifacts, but never a finalized
session without its report and evidence.
"""
session_dir = self._session_dir(manifest.id)
with self._lock(session_dir):
self._require_existing(session_dir, manifest.id)
for key, content in artifacts.items():
self._write_private_text(self._artifact_path(session_dir, key), content)
self._write_manifest(session_dir, manifest)
return self.get(manifest.id)
def get_preferences(self) -> dict:
path = self.preferences_path
if not path.exists():
@@ -125,9 +165,18 @@ class FilesystemSessionRepository:
shutil.rmtree(session_dir)
def _session_dir(self, session_id: str) -> Path:
self._require_uuid4(session_id)
self._require_session_id(session_id)
return self.root / session_id
@classmethod
def _require_session_id(cls, session_id: str) -> None:
try:
cls._require_uuid4(session_id)
except SessionError:
if session_id not in {".", ".."} and _LEGACY_SESSION_ID.fullmatch(session_id):
return
raise
@staticmethod
def _require_uuid4(session_id: str) -> None:
try:
@@ -33,6 +33,8 @@ _ARTIFACT_KEYS = {
"validation_report",
"retrieval_pack",
"cte_tests",
"cte_plan",
"cte_plan_doc",
}
_LOCK_KEY = 8_420_613_069_444_020_731
_RUNTIME_ROLE = "thoth_sessions_runtime"
@@ -278,6 +280,13 @@ class PostgresSessionRepository:
decisions=decisions,
)
def list(self) -> list[SessionSnapshot]:
with self._transaction() as connection:
ids = [row[0] for row in connection.execute(
text("SELECT id FROM thoth_sessions.sessions ORDER BY created_at DESC")
).all()]
return [self.get(session_id) for session_id in ids]
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot:
self._require_uuid4(manifest.id)
with self._transaction() as connection:
@@ -322,6 +331,17 @@ class PostgresSessionRepository:
{"id": session_id, "key": key, "content": content},
)
def delete_artifact(self, session_id: str, key: str) -> None:
self._require_uuid4(session_id)
self._require_artifact_key(key)
with self._transaction() as connection:
self._lock_session(connection, session_id)
self._require_session(connection, session_id)
connection.execute(
text("DELETE FROM thoth_sessions.session_artifacts WHERE session_id = :id AND artifact_key = :key"),
{"id": session_id, "key": key},
)
def append_decisions(
self, session_id: str, decisions: Sequence[DecisionInput | dict]
) -> list[DecisionRecord]:
@@ -357,6 +377,36 @@ class PostgresSessionRepository:
)
return records
def finalize(self, manifest: SessionManifest, artifacts: dict[str, str]) -> SessionSnapshot:
"""Atomically publish DWH-verified artifacts and the final manifest."""
self._require_uuid4(manifest.id)
for key in artifacts:
self._require_artifact_key(key)
with self._transaction() as connection:
self._lock_session(connection, manifest.id)
self._require_session(connection, manifest.id)
for key, content in artifacts.items():
connection.execute(
text(
"INSERT INTO thoth_sessions.session_artifacts (session_id, artifact_key, content) "
"VALUES (:id, :key, :content) "
"ON CONFLICT (session_id, artifact_key) DO UPDATE "
"SET content = EXCLUDED.content, updated_at = pg_catalog.now()"
),
{"id": manifest.id, "key": key, "content": content},
)
result = connection.execute(
text(
"UPDATE thoth_sessions.sessions "
"SET manifest = CAST(:manifest AS jsonb), updated_at = pg_catalog.now() "
"WHERE id = :id"
),
{"id": manifest.id, "manifest": json.dumps(manifest.model_dump(by_alias=True, mode="json"))},
)
if result.rowcount != 1:
raise SessionError(f"Sessione non trovata: {manifest.id}")
return self.get(manifest.id)
def get_preferences(self) -> dict:
with self._transaction() as connection:
principal_id = self._upsert_principal(connection)
+40 -2
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import os
from typing import Protocol, Sequence
from tht.decisions import DecisionInput, DecisionRecord
from tht.session.models import PrincipalContext, SessionManifest, SessionSnapshot
from tht.session.store import SessionError
class SessionRepository(Protocol):
@@ -17,16 +19,22 @@ class SessionRepository(Protocol):
def get(self, session_id: str) -> SessionSnapshot: ...
def list(self) -> list[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 delete_artifact(self, session_id: str, key: str) -> None: ...
def append_decisions(
self, session_id: str, decisions: Sequence[DecisionInput | dict]
) -> list[DecisionRecord]: ...
def finalize(self, manifest: SessionManifest, artifacts: dict[str, str]) -> SessionSnapshot: ...
def get_preferences(self) -> dict: ...
def set_preferences(self, preferences: dict) -> None: ...
@@ -34,8 +42,35 @@ class SessionRepository(Protocol):
def delete(self, session_id: str) -> None: ...
def build_session_repository(config, principal: PrincipalContext, *, home=None) -> SessionRepository:
def resolve_principal(config) -> PrincipalContext:
"""Resolve the only principal source permitted for this CLI invocation.
The backend injects these values from its authenticated request context before
spawning ``tht``. A server-storage command without them must fail closed;
substituting a workstation identity would cross user ownership boundaries.
"""
if getattr(config, "session_storage", None) is None:
from tht.session.models import local_principal
return local_principal()
issuer = os.environ.get("THT_PRINCIPAL_ISSUER", "").strip()
subject = os.environ.get("THT_PRINCIPAL_SUBJECT", "").strip()
if not issuer or not subject:
raise SessionError(
"THT_PRINCIPAL_ISSUER e THT_PRINCIPAL_SUBJECT sono obbligatori per session storage server"
)
display_name = os.environ.get("THT_PRINCIPAL_DISPLAY_NAME", "").strip() or None
is_admin = os.environ.get("THT_PRINCIPAL_IS_ADMIN", "").strip().lower() in {"1", "true"}
return PrincipalContext(
issuer=issuer, subject=subject, display_name=display_name, is_admin=is_admin
)
def build_session_repository(
config, principal: PrincipalContext | None = None, *, home=None
) -> SessionRepository:
"""Build the configured private session persistence adapter."""
principal = principal or resolve_principal(config)
session_storage = getattr(config, "session_storage", None)
if session_storage is not None:
from tht.session.postgres_repository import PostgresSessionRepository
@@ -46,4 +81,7 @@ def build_session_repository(config, principal: PrincipalContext, *, home=None)
from tht.session.filesystem_repository import FilesystemSessionRepository
workspace = getattr(config, "_workspace_id", "default")
return FilesystemSessionRepository(home or local_tht_home(), workspace, principal)
return FilesystemSessionRepository(
home or local_tht_home(), workspace, principal,
root=None if home is not None else getattr(config.paths, "sessions", None),
)
+80
View File
@@ -130,6 +130,35 @@ def create_session(
return manifest
def new_session_manifest(
question: str, db: DatabaseConfig, *, provider=None, model=None, thinking=None, name=None
) -> SessionManifest:
"""Create an unsaved UUIDv4 manifest for a repository-owned session."""
now = datetime.now(UTC)
return SessionManifest(
id=str(uuid.uuid4()), created_at=now, question=question,
database=db.database, schema=db.db_schema, author=current_author(),
summary=_summarize(question), updated_at=now, updated_by=current_author(),
provider=provider, model=model, thinking=thinking, name=name,
)
def build_snapshot_documents(snapshot) -> list[dict]:
docs = [{"phase": "—", "key": "question", "title": "Original question", "format": "text", "content": snapshot.manifest.question}]
spec = [
("question", "F3", "revised_question", "Revised question", "markdown"),
("schema_linking", "F4", "schema_linking", "Schema linking", "schema-linking"),
("sql_final", "F7", "sql", "Final SQL", "sql"),
("validation_report", "finalize", "validation_report", "Validation report", "markdown"),
]
for artifact, phase, key, title, fmt in spec:
if artifact in snapshot.artifacts:
docs.append({"phase": phase, "key": key, "title": title, "format": fmt, "content": snapshot.artifacts[artifact]})
if snapshot.decisions:
docs.append({"phase": "—", "key": "decisions", "title": "Decisions", "format": "decisions", "content": "\n".join(d.model_dump_json() for d in snapshot.decisions) + "\n"})
return docs
def touch_manifest(
session_id: str, sessions_root: Path, *, updated_by: str | None = None
) -> SessionManifest:
@@ -227,6 +256,33 @@ def sync_schema_linking(session_id: str, sessions_root: Path) -> Path:
return set_schema_linking(session_id, data, sessions_root)
def sync_schema_linking_snapshot(snapshot) -> str:
"""Repository projection of effective F4 decisions into schema_linking JSON."""
from tht.phase import effective_decisions
existing = json.loads(snapshot.artifacts.get("schema_linking", "{}"))
latest: dict[str, str] = {}
for decision in effective_decisions(snapshot):
if decision.type in {"table_promoted", "table_excluded", "column_promoted", "column_excluded"}:
latest[decision.subject] = decision.type
candidates, excluded = [], []
for subject, kind in latest.items():
entity = "column" if "." in subject else "table"
if kind.endswith("promoted"):
candidates.append({"kind": entity, "name": subject, "decision": "promoted"})
else:
excluded.append({"kind": entity, "name": subject})
from tht.session.models import SchemaLinking
model = SchemaLinking.model_validate({
"question": existing.get("question") or snapshot.manifest.question,
"candidates": candidates, "excluded": excluded,
"joins": existing.get("joins", []), "open_questions": existing.get("open_questions", []),
"concept_formulas": existing.get("concept_formulas", []),
})
return json.dumps(model.model_dump(by_alias=True), indent=2, ensure_ascii=False)
def load_session(session_id: str, sessions_root: Path) -> SessionManifest:
path = sessions_root / session_id / MANIFEST
if not path.exists():
@@ -298,6 +354,30 @@ def delete_session(session_id: str, sessions_root: Path) -> None:
shutil.rmtree(sessions_root / session_id)
def persist_verified_finalization(
repository,
session_id: str,
*,
validation_report: str,
evidence: str,
) -> SessionManifest:
"""Publish DWH-verified final artifacts and status at one repository boundary.
The caller must complete static validation, EXPLAIN and preview before this
function is entered. It intentionally does not touch solved-question
indexing: that derivative is best-effort and happens after the durable commit.
"""
snapshot = repository.get(session_id)
manifest = snapshot.manifest.model_copy(deep=True)
manifest.status = "finalized"
manifest.updated_at = datetime.now(UTC)
manifest.updated_by = current_author()
return repository.finalize(
manifest,
{"validation_report": validation_report, "evidence": evidence},
).manifest
def build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]:
"""Ordered, read-only document bundle for the UI panel. Only documents that exist
on disk are returned. CTE artifacts (F6) are intentionally excluded (intermediate)."""