store.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. """Filesystem reference adapters for orchestration ports."""
  2. from __future__ import annotations
  3. import asyncio
  4. import base64
  5. import hashlib
  6. import json
  7. import os
  8. import tempfile
  9. from dataclasses import asdict
  10. from pathlib import Path
  11. from typing import Any, Dict, List, Optional, Sequence, Tuple
  12. from filelock import FileLock
  13. from .models import (
  14. ArtifactSnapshot,
  15. AttemptSubmission,
  16. BackgroundOperation,
  17. EventDraft,
  18. EventPage,
  19. OperationStatus,
  20. OrchestrationEvent,
  21. TaskLedger,
  22. json_values,
  23. utc_now,
  24. )
  25. from .protocols import CommitResult
  26. class TaskStoreError(RuntimeError):
  27. pass
  28. class TaskStoreNotFound(TaskStoreError):
  29. pass
  30. class RevisionConflict(TaskStoreError):
  31. pass
  32. class ArtifactConflict(TaskStoreError):
  33. pass
  34. class FileSystemTaskStore:
  35. """Atomic JSON store with cross-process locking and optimistic revisions."""
  36. _EVENTS_KEY = "_events"
  37. _NEXT_EVENT_SEQUENCE_KEY = "_next_event_sequence"
  38. def __init__(self, base_path: str = ".trace") -> None:
  39. self.base_path = Path(base_path).resolve()
  40. def orchestration_dir(self, root_trace_id: str) -> Path:
  41. return _safe_child(self.base_path, root_trace_id, "orchestration")
  42. def ledger_path(self, root_trace_id: str) -> Path:
  43. return self.orchestration_dir(root_trace_id) / "ledger.json"
  44. def _lock_path(self, root_trace_id: str) -> Path:
  45. return self.orchestration_dir(root_trace_id) / ".ledger.lock"
  46. async def load(self, root_trace_id: str) -> TaskLedger:
  47. return await asyncio.to_thread(self._load_locked, root_trace_id)
  48. def _load_locked(self, root_trace_id: str) -> TaskLedger:
  49. lock_path = self._lock_path(root_trace_id)
  50. lock_path.parent.mkdir(parents=True, exist_ok=True)
  51. with FileLock(str(lock_path)):
  52. raw = self._read_raw(root_trace_id)
  53. try:
  54. return TaskLedger.from_dict(raw)
  55. except (AttributeError, KeyError, TypeError, ValueError) as exc:
  56. raise TaskStoreError(f"Cannot parse task ledger {self.ledger_path(root_trace_id)}: {exc}") from exc
  57. def _read_raw(self, root_trace_id: str) -> Dict[str, Any]:
  58. path = self.ledger_path(root_trace_id)
  59. if not path.exists():
  60. raise TaskStoreNotFound(f"No task ledger for root trace {root_trace_id}")
  61. try:
  62. data = json.loads(path.read_text(encoding="utf-8"))
  63. if not isinstance(data, dict):
  64. raise TypeError("ledger root must be an object")
  65. return data
  66. except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
  67. raise TaskStoreError(f"Cannot load task ledger {path}: {exc}") from exc
  68. async def commit(
  69. self,
  70. ledger: TaskLedger,
  71. expected_revision: int,
  72. idempotency_key: Optional[str] = None,
  73. event: Optional[EventDraft] = None,
  74. ) -> CommitResult:
  75. # idempotency_key remains accepted for V1 callers. Durable command
  76. # replay is represented by TaskLedger.command_records in V2.
  77. del idempotency_key
  78. return await asyncio.to_thread(self._commit_locked, ledger, expected_revision, event)
  79. def _commit_locked(
  80. self,
  81. ledger: TaskLedger,
  82. expected_revision: int,
  83. event: Optional[EventDraft],
  84. ) -> CommitResult:
  85. root_trace_id = ledger.root_trace_id
  86. lock_path = self._lock_path(root_trace_id)
  87. lock_path.parent.mkdir(parents=True, exist_ok=True)
  88. with FileLock(str(lock_path)):
  89. raw: Dict[str, Any] = {}
  90. if self.ledger_path(root_trace_id).exists():
  91. raw = self._read_raw(root_trace_id)
  92. current_revision = int(raw.get("revision", -1))
  93. if current_revision != expected_revision:
  94. raise RevisionConflict(
  95. f"Task ledger {root_trace_id} revision conflict: "
  96. f"expected {expected_revision}, actual {current_revision}"
  97. )
  98. new_revision = expected_revision + 1
  99. updated_at = utc_now()
  100. data = ledger.to_dict()
  101. data["revision"] = new_revision
  102. data["updated_at"] = updated_at
  103. events = self._read_events(raw)
  104. next_sequence = self._next_sequence(raw, events)
  105. if event and not self._event_already_recorded(events, event):
  106. recorded = OrchestrationEvent(
  107. schema_version=1,
  108. event_id=_event_id(root_trace_id, next_sequence),
  109. sequence=next_sequence,
  110. root_trace_id=root_trace_id,
  111. ledger_revision=new_revision,
  112. event_type=event.event_type,
  113. occurred_at=utc_now(),
  114. payload=json_values(event.payload),
  115. command_id=_public_command_id(event.command_id),
  116. operation_id=event.operation_id,
  117. )
  118. events.append(json_values(asdict(recorded)))
  119. next_sequence += 1
  120. if events:
  121. data[self._EVENTS_KEY] = events
  122. data[self._NEXT_EVENT_SEQUENCE_KEY] = next_sequence
  123. path = self.ledger_path(root_trace_id)
  124. path.parent.mkdir(parents=True, exist_ok=True)
  125. self._atomic_json_write(path, data)
  126. ledger.revision = new_revision
  127. ledger.updated_at = updated_at
  128. return CommitResult(revision=ledger.revision, ledger=ledger)
  129. async def list_events(
  130. self,
  131. root_trace_id: str,
  132. cursor: Optional[str] = None,
  133. limit: int = 100,
  134. ) -> EventPage:
  135. if not 1 <= limit <= 1000:
  136. raise ValueError("Event page limit must be between 1 and 1000")
  137. after = _decode_cursor(cursor, root_trace_id) if cursor else 0
  138. return await asyncio.to_thread(self._list_events_locked, root_trace_id, after, limit, cursor)
  139. def _list_events_locked(
  140. self,
  141. root_trace_id: str,
  142. after: int,
  143. limit: int,
  144. prior_cursor: Optional[str],
  145. ) -> EventPage:
  146. lock_path = self._lock_path(root_trace_id)
  147. lock_path.parent.mkdir(parents=True, exist_ok=True)
  148. with FileLock(str(lock_path)):
  149. raw = self._read_raw(root_trace_id)
  150. try:
  151. available = [
  152. OrchestrationEvent.from_dict(item)
  153. for item in self._read_events(raw)
  154. if int(item.get("sequence", 0)) > after
  155. ]
  156. except (KeyError, TypeError, ValueError) as exc:
  157. raise TaskStoreError("Ledger durable event journal contains an invalid event") from exc
  158. page = available[:limit]
  159. next_cursor = _encode_cursor(root_trace_id, page[-1].sequence) if page else prior_cursor
  160. return EventPage(events=page, next_cursor=next_cursor, has_more=len(available) > limit)
  161. async def list_recoverable(self, root_trace_id: str) -> List[BackgroundOperation]:
  162. ledger = await self.load(root_trace_id)
  163. recoverable = {
  164. OperationStatus.PENDING,
  165. OperationStatus.RUNNING,
  166. OperationStatus.STOP_REQUESTED,
  167. }
  168. return [operation for operation in ledger.operations.values() if operation.status in recoverable]
  169. @classmethod
  170. def _read_events(cls, raw: Dict[str, Any]) -> List[Dict[str, Any]]:
  171. events = raw.get(cls._EVENTS_KEY, [])
  172. if not isinstance(events, list) or any(not isinstance(item, dict) for item in events):
  173. raise TaskStoreError("Ledger durable event journal is invalid")
  174. return list(events)
  175. @classmethod
  176. def _next_sequence(cls, raw: Dict[str, Any], events: Sequence[Dict[str, Any]]) -> int:
  177. fallback = max((int(item.get("sequence", 0)) for item in events), default=0) + 1
  178. value = int(raw.get(cls._NEXT_EVENT_SEQUENCE_KEY, fallback))
  179. return max(value, fallback, 1)
  180. @staticmethod
  181. def _event_already_recorded(events: Sequence[Dict[str, Any]], event: EventDraft) -> bool:
  182. command_id = _public_command_id(event.command_id)
  183. return bool(command_id) and any(item.get("command_id") == command_id for item in events)
  184. @staticmethod
  185. def _atomic_json_write(path: Path, data: Dict[str, Any]) -> None:
  186. fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
  187. try:
  188. with os.fdopen(fd, "w", encoding="utf-8") as handle:
  189. json.dump(data, handle, ensure_ascii=False, indent=2, sort_keys=True)
  190. handle.flush()
  191. os.fsync(handle.fileno())
  192. os.replace(tmp_name, path)
  193. _fsync_directory(path.parent)
  194. except Exception:
  195. try:
  196. os.unlink(tmp_name)
  197. except OSError:
  198. pass
  199. raise
  200. class FileSystemArtifactStore:
  201. """Immutable snapshots with same-attempt first-write-wins semantics."""
  202. def __init__(self, base_path: str = ".trace", root_trace_id: Optional[str] = None) -> None:
  203. self.base_path = Path(base_path).resolve()
  204. self.root_trace_id = root_trace_id
  205. def for_root(self, root_trace_id: str) -> "FileSystemArtifactStore":
  206. """V1 compatibility adapter; new callers should pass root_trace_id."""
  207. return FileSystemArtifactStore(str(self.base_path), root_trace_id=root_trace_id)
  208. def _artifacts_dir(self, root_trace_id: str) -> Path:
  209. return _safe_child(self.base_path, root_trace_id, "orchestration", "artifacts")
  210. def _bound_root(self) -> str:
  211. if not self.root_trace_id:
  212. raise ValueError("root_trace_id is required")
  213. return self.root_trace_id
  214. async def freeze(
  215. self,
  216. root_trace_id: str,
  217. attempt_id: Any,
  218. submission: Optional[AttemptSubmission] = None,
  219. ) -> ArtifactSnapshot:
  220. if submission is None:
  221. # V1 bound form: bound.freeze(attempt_id, submission)
  222. submission = attempt_id
  223. attempt_id = root_trace_id
  224. root_trace_id = self._bound_root()
  225. if not isinstance(attempt_id, str) or not isinstance(submission, AttemptSubmission):
  226. raise TypeError("freeze requires root_trace_id, attempt_id and AttemptSubmission")
  227. return await asyncio.to_thread(self._freeze_locked, root_trace_id, attempt_id, submission)
  228. def _freeze_locked(
  229. self,
  230. root_trace_id: str,
  231. attempt_id: str,
  232. submission: AttemptSubmission,
  233. ) -> ArtifactSnapshot:
  234. artifacts_dir = self._artifacts_dir(root_trace_id)
  235. artifacts_dir.mkdir(parents=True, exist_ok=True)
  236. with FileLock(str(artifacts_dir / ".artifacts.lock")):
  237. existing = self._find_for_attempt(artifacts_dir, attempt_id)
  238. normalized, digest = _normalize_submission(submission)
  239. if existing:
  240. if existing.sha256 == digest:
  241. return existing
  242. raise ArtifactConflict(f"Attempt {attempt_id} already has a different artifact snapshot")
  243. snapshot = ArtifactSnapshot(
  244. snapshot_id=_snapshot_id(attempt_id),
  245. attempt_id=attempt_id,
  246. normalized_content=normalized,
  247. sha256=digest,
  248. artifact_refs=list(submission.artifact_refs),
  249. evidence_refs=list(submission.evidence_refs),
  250. )
  251. FileSystemTaskStore._atomic_json_write(
  252. artifacts_dir / f"{snapshot.snapshot_id}.json",
  253. json_values(asdict(snapshot)),
  254. )
  255. return snapshot
  256. async def get(self, root_trace_id: str, snapshot_id: Optional[str] = None) -> ArtifactSnapshot:
  257. if snapshot_id is None:
  258. snapshot_id = root_trace_id
  259. root_trace_id = self._bound_root()
  260. return await asyncio.to_thread(self._get_locked, root_trace_id, snapshot_id)
  261. def _get_locked(self, root_trace_id: str, snapshot_id: str) -> ArtifactSnapshot:
  262. artifacts_dir = self._artifacts_dir(root_trace_id)
  263. path = _safe_child(artifacts_dir, f"{snapshot_id}.json")
  264. artifacts_dir.mkdir(parents=True, exist_ok=True)
  265. with FileLock(str(artifacts_dir / ".artifacts.lock")):
  266. if not path.exists():
  267. raise FileNotFoundError(f"Artifact snapshot not found: {snapshot_id}")
  268. return self._read_snapshot(path)
  269. async def get_for_attempt(
  270. self,
  271. root_trace_id: str,
  272. attempt_id: Optional[str] = None,
  273. ) -> Optional[ArtifactSnapshot]:
  274. if attempt_id is None:
  275. attempt_id = root_trace_id
  276. root_trace_id = self._bound_root()
  277. return await asyncio.to_thread(self._get_for_attempt_locked, root_trace_id, attempt_id)
  278. def _get_for_attempt_locked(self, root_trace_id: str, attempt_id: str) -> Optional[ArtifactSnapshot]:
  279. artifacts_dir = self._artifacts_dir(root_trace_id)
  280. artifacts_dir.mkdir(parents=True, exist_ok=True)
  281. with FileLock(str(artifacts_dir / ".artifacts.lock")):
  282. return self._find_for_attempt(artifacts_dir, attempt_id)
  283. async def list_orphans(
  284. self,
  285. root_trace_id: str,
  286. known_attempt_ids: Sequence[str],
  287. ) -> List[ArtifactSnapshot]:
  288. artifacts_dir = self._artifacts_dir(root_trace_id)
  289. return await asyncio.to_thread(self._list_orphans_locked, artifacts_dir, set(known_attempt_ids))
  290. def _list_orphans_locked(self, artifacts_dir: Path, known: set[str]) -> List[ArtifactSnapshot]:
  291. artifacts_dir.mkdir(parents=True, exist_ok=True)
  292. with FileLock(str(artifacts_dir / ".artifacts.lock")):
  293. return [snapshot for snapshot in self._read_all(artifacts_dir) if snapshot.attempt_id not in known]
  294. async def cleanup_orphans(
  295. self,
  296. root_trace_id: str,
  297. known_attempt_ids: Sequence[str],
  298. ) -> List[str]:
  299. artifacts_dir = self._artifacts_dir(root_trace_id)
  300. return await asyncio.to_thread(self._cleanup_orphans_locked, artifacts_dir, set(known_attempt_ids))
  301. def _cleanup_orphans_locked(self, artifacts_dir: Path, known: set[str]) -> List[str]:
  302. artifacts_dir.mkdir(parents=True, exist_ok=True)
  303. with FileLock(str(artifacts_dir / ".artifacts.lock")):
  304. orphans = [
  305. (path, self._read_snapshot(path))
  306. for path in sorted(artifacts_dir.glob("*.json"))
  307. ]
  308. orphans = [(path, snapshot) for path, snapshot in orphans if snapshot.attempt_id not in known]
  309. for path, _snapshot in orphans:
  310. # Delete only the directory entry that was actually scanned;
  311. # snapshot JSON is untrusted and must not select a path.
  312. path.unlink()
  313. if orphans:
  314. _fsync_directory(artifacts_dir)
  315. return [snapshot.snapshot_id for _path, snapshot in orphans]
  316. def _find_for_attempt(self, artifacts_dir: Path, attempt_id: str) -> Optional[ArtifactSnapshot]:
  317. return next((item for item in self._read_all(artifacts_dir) if item.attempt_id == attempt_id), None)
  318. def _read_all(self, artifacts_dir: Path) -> List[ArtifactSnapshot]:
  319. return [self._read_snapshot(path) for path in sorted(artifacts_dir.glob("*.json"))]
  320. @staticmethod
  321. def _read_snapshot(path: Path) -> ArtifactSnapshot:
  322. try:
  323. return ArtifactSnapshot.from_dict(json.loads(path.read_text(encoding="utf-8")))
  324. except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
  325. raise TaskStoreError(f"Cannot load artifact snapshot {path}: {exc}") from exc
  326. class TraceEventSink:
  327. """Best-effort legacy event mirror; durable events live in TaskStore."""
  328. def __init__(self, base_path: str = ".trace") -> None:
  329. self.base_path = Path(base_path).resolve()
  330. async def emit(self, root_trace_id: str, event_type: str, payload: Dict[str, Any]) -> None:
  331. await asyncio.to_thread(self._emit_locked, root_trace_id, event_type, payload)
  332. def _emit_locked(self, root_trace_id: str, event_type: str, payload: Dict[str, Any]) -> None:
  333. directory = _safe_child(self.base_path, root_trace_id, "orchestration")
  334. directory.mkdir(parents=True, exist_ok=True)
  335. path = directory / "events.jsonl"
  336. event = {
  337. "event_id": _event_id(root_trace_id, os.urandom(8).hex()),
  338. "event_type": event_type,
  339. "root_trace_id": root_trace_id,
  340. "created_at": utc_now(),
  341. "payload": json_values(payload),
  342. }
  343. with FileLock(str(directory / ".events.lock")):
  344. with path.open("a", encoding="utf-8") as handle:
  345. handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
  346. handle.flush()
  347. def _safe_child(base: Path, *parts: str) -> Path:
  348. if any(not isinstance(part, str) or not part for part in parts):
  349. raise ValueError("Path identifiers must be non-empty strings")
  350. candidate = base.joinpath(*parts).resolve()
  351. try:
  352. candidate.relative_to(base.resolve())
  353. except ValueError as exc:
  354. raise ValueError("Path identifier escapes the configured base path") from exc
  355. return candidate
  356. def _normalize_submission(submission: AttemptSubmission) -> Tuple[Dict[str, Any], str]:
  357. normalized = {
  358. "summary": submission.summary.strip(),
  359. "artifact_refs": [json_values(asdict(item)) for item in submission.artifact_refs],
  360. "evidence_refs": [json_values(asdict(item)) for item in submission.evidence_refs],
  361. }
  362. canonical = json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  363. return normalized, hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  364. def _snapshot_id(attempt_id: str) -> str:
  365. return "attempt-" + hashlib.sha256(attempt_id.encode("utf-8")).hexdigest()
  366. def _event_id(root_trace_id: str, sequence: Any) -> str:
  367. source = f"{root_trace_id}:{sequence}".encode("utf-8")
  368. return hashlib.sha256(source).hexdigest()
  369. def _public_command_id(command_id: Optional[str]) -> Optional[str]:
  370. if command_id is None:
  371. return None
  372. return "cmd_" + hashlib.sha256(command_id.encode("utf-8")).hexdigest()
  373. def _encode_cursor(root_trace_id: str, sequence: int) -> str:
  374. root_digest = hashlib.sha256(root_trace_id.encode("utf-8")).hexdigest()[:16]
  375. raw = json.dumps({"v": 1, "root": root_digest, "after": sequence}, separators=(",", ":"))
  376. return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
  377. def _decode_cursor(cursor: str, root_trace_id: str) -> int:
  378. try:
  379. padding = "=" * (-len(cursor) % 4)
  380. data = json.loads(base64.urlsafe_b64decode(cursor + padding).decode("utf-8"))
  381. expected = hashlib.sha256(root_trace_id.encode("utf-8")).hexdigest()[:16]
  382. if data.get("v") != 1 or data.get("root") != expected:
  383. raise ValueError
  384. sequence = int(data["after"])
  385. if sequence < 0:
  386. raise ValueError
  387. return sequence
  388. except (KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
  389. raise ValueError("Invalid event cursor") from exc
  390. def _fsync_directory(path: Path) -> None:
  391. try:
  392. descriptor = os.open(path, os.O_RDONLY)
  393. except OSError:
  394. return
  395. try:
  396. os.fsync(descriptor)
  397. finally:
  398. os.close(descriptor)
  399. __all__ = [
  400. "TaskStoreError", "TaskStoreNotFound", "RevisionConflict", "ArtifactConflict",
  401. "FileSystemTaskStore", "FileSystemArtifactStore", "TraceEventSink",
  402. ]