refactor(harness): route workflow persistence through repositories
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user