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:
|
||||
|
||||
Reference in New Issue
Block a user