feat(sync): add local sync journal

This commit is contained in:
Shay 2026-05-29 19:45:20 -07:00
parent 3741c1321a
commit d11c792ef2

197
core/sync/journal.py Normal file
View file

@ -0,0 +1,197 @@
"""Local sync journal for edge/cloud artifact transfer state.
The journal is deliberately local and transport-agnostic. It records pending
uploads/downloads and their outcomes without blocking active reasoning,
refusal, safety, or action gates.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class JournalDirection(str, Enum):
"""Direction of a sync journal entry."""
UPLOAD = "upload"
DOWNLOAD = "download"
class JournalStatus(str, Enum):
"""Lifecycle status for a sync journal entry."""
PENDING = "pending"
ACKNOWLEDGED = "acknowledged"
COMPLETED = "completed"
REJECTED = "rejected"
FAILED = "failed"
@dataclass(frozen=True, slots=True)
class JournalEntry:
"""One local sync work item or outcome."""
entry_id: str
direction: JournalDirection
artifact_id: str
artifact_type: str
status: JournalStatus = JournalStatus.PENDING
target_uri: str | None = None
reason: str | None = None
attempts: int = 0
@dataclass(frozen=True, slots=True)
class JournalDecision:
"""Result of mutating the local journal."""
accepted: bool
reason: str
entry: JournalEntry | None = None
@dataclass(slots=True)
class LocalSyncJournal:
"""In-memory local journal for sync work.
A later file-backed journal can implement the same semantics. This first
version proves the contract: sync failures are recorded locally and never
imply hot-path failure or active release mutation.
"""
_entries: dict[str, JournalEntry] = field(default_factory=dict)
def append_upload(
self,
*,
entry_id: str,
artifact_id: str,
artifact_type: str,
target_uri: str | None = None,
) -> JournalDecision:
return self._append(
entry_id=entry_id,
direction=JournalDirection.UPLOAD,
artifact_id=artifact_id,
artifact_type=artifact_type,
target_uri=target_uri,
)
def append_download(
self,
*,
entry_id: str,
artifact_id: str,
artifact_type: str,
target_uri: str | None = None,
) -> JournalDecision:
return self._append(
entry_id=entry_id,
direction=JournalDirection.DOWNLOAD,
artifact_id=artifact_id,
artifact_type=artifact_type,
target_uri=target_uri,
)
def mark_acknowledged(self, entry_id: str) -> JournalDecision:
return self._transition(entry_id, JournalStatus.ACKNOWLEDGED, "acknowledged")
def mark_completed(self, entry_id: str) -> JournalDecision:
return self._transition(entry_id, JournalStatus.COMPLETED, "completed")
def mark_rejected(self, entry_id: str, reason: str) -> JournalDecision:
return self._transition(entry_id, JournalStatus.REJECTED, reason)
def mark_failed(self, entry_id: str, reason: str) -> JournalDecision:
return self._transition(entry_id, JournalStatus.FAILED, reason, increment_attempts=True)
def retry(self, entry_id: str) -> JournalDecision:
entry = self._entries.get(entry_id)
if entry is None:
return JournalDecision(False, "unknown_entry")
if entry.status not in {JournalStatus.FAILED, JournalStatus.REJECTED}:
return JournalDecision(False, "retry_not_needed", entry)
retried = JournalEntry(
entry_id=entry.entry_id,
direction=entry.direction,
artifact_id=entry.artifact_id,
artifact_type=entry.artifact_type,
status=JournalStatus.PENDING,
target_uri=entry.target_uri,
reason=None,
attempts=entry.attempts,
)
self._entries[entry_id] = retried
return JournalDecision(True, "pending", retried)
def get(self, entry_id: str) -> JournalEntry | None:
return self._entries.get(entry_id)
def pending(self) -> tuple[JournalEntry, ...]:
return tuple(entry for entry in self._entries.values() if entry.status is JournalStatus.PENDING)
def entries(self) -> tuple[JournalEntry, ...]:
return tuple(self._entries.values())
def as_dicts(self) -> tuple[dict[str, object], ...]:
return tuple(
{
"entry_id": entry.entry_id,
"direction": entry.direction.value,
"artifact_id": entry.artifact_id,
"artifact_type": entry.artifact_type,
"status": entry.status.value,
"target_uri": entry.target_uri,
"reason": entry.reason,
"attempts": entry.attempts,
}
for entry in self._entries.values()
)
def _append(
self,
*,
entry_id: str,
direction: JournalDirection,
artifact_id: str,
artifact_type: str,
target_uri: str | None,
) -> JournalDecision:
if not entry_id:
return JournalDecision(False, "missing_entry_id")
if entry_id in self._entries:
return JournalDecision(False, "duplicate_entry_id", self._entries[entry_id])
entry = JournalEntry(
entry_id=entry_id,
direction=direction,
artifact_id=artifact_id,
artifact_type=artifact_type,
target_uri=target_uri,
)
self._entries[entry_id] = entry
return JournalDecision(True, "pending", entry)
def _transition(
self,
entry_id: str,
status: JournalStatus,
reason: str,
*,
increment_attempts: bool = False,
) -> JournalDecision:
entry = self._entries.get(entry_id)
if entry is None:
return JournalDecision(False, "unknown_entry")
updated = JournalEntry(
entry_id=entry.entry_id,
direction=entry.direction,
artifact_id=entry.artifact_id,
artifact_type=entry.artifact_type,
status=status,
target_uri=entry.target_uri,
reason=reason,
attempts=entry.attempts + 1 if increment_attempts else entry.attempts,
)
self._entries[entry_id] = updated
return JournalDecision(True, reason, updated)