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: