feat: preserve Today timing ledger across handoff (Closes #1276)
This commit is contained in:
1 parent
e0d8ab9f10
commit
548b95ddb6
9 files changed
+267
-20
No files matched your search
+16
-1
@@ -656,18 +656,33 @@ class TodayRecap(BaseModel):
|
||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TodaySessionEntry(BaseModel):
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
|
||||
class TodaySessionUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
device_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
entries: list[TodaySessionEntry] | None = Field(default=None, max_length=20)
|
||||
running: bool
|
||||
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_break_is_paused(self):
|
||||
def validate_session_state(self):
|
||||
if self.running and self.break_deadline_at is not None:
|
||||
raise ValueError("break must remain paused")
|
||||
if self.entries is not None:
|
||||
identities = [entry.identity for entry in self.entries]
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
active = [entry for entry in self.entries if entry.identity == self.identity]
|
||||
if self.identity and (len(active) != 1 or active[0].elapsed_ms != self.elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
if not self.identity and self.entries:
|
||||
raise ValueError("inactive Today session ledger must be empty")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
+45
-4
@@ -1001,9 +1001,31 @@ class TodayStore:
|
||||
def _empty_session() -> dict:
|
||||
return {
|
||||
"revision": 0, "device_id": "", "identity": "",
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
"elapsed_ms": 0, "entries": [], "running": False,
|
||||
"break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_session_entries(entries: list[dict]) -> list[dict]:
|
||||
if not isinstance(entries, list) or len(entries) > 20:
|
||||
raise ValueError("Today session entries are limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in entries:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
elapsed_ms = item.get("elapsed_ms") if isinstance(item, dict) else None
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("Today session entry identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
if (not isinstance(elapsed_ms, int) or isinstance(elapsed_ms, bool)
|
||||
or elapsed_ms < 0 or elapsed_ms > 7 * 24 * 60 * 60 * 1000):
|
||||
raise ValueError("Today session elapsed time is invalid")
|
||||
seen.add(identity)
|
||||
normalized.append({"identity": identity, "elapsed_ms": elapsed_ms})
|
||||
return normalized
|
||||
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session(), False
|
||||
@@ -1011,17 +1033,28 @@ class TodayStore:
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if "entries" not in payload:
|
||||
payload["entries"] = ([{
|
||||
"identity": payload.get("identity", ""),
|
||||
"elapsed_ms": int(payload.get("elapsed_ms", 0)),
|
||||
}] if payload.get("identity") else [])
|
||||
legacy = True
|
||||
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
|
||||
return {
|
||||
session = {
|
||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||
"break_deadline_at": row[5], "updated_at": row[6],
|
||||
}, True
|
||||
}
|
||||
session["entries"] = ([{
|
||||
"identity": session["identity"], "elapsed_ms": session["elapsed_ms"],
|
||||
}] if session["identity"] else [])
|
||||
return session, True
|
||||
|
||||
def _sealed_session(self, login: str, session: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"device_id": session["device_id"], "identity": session["identity"],
|
||||
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
|
||||
"entries": session["entries"],
|
||||
"break_deadline_at": session["break_deadline_at"],
|
||||
}, binding=f"session:{login}")
|
||||
|
||||
@@ -1044,6 +1077,7 @@ class TodayStore:
|
||||
def update_session(
|
||||
self, login: str, *, base_revision: int, device_id: str,
|
||||
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
||||
entries: list[dict] | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
updated_at = self.clock()
|
||||
@@ -1058,9 +1092,16 @@ class TodayStore:
|
||||
if base_revision != current_revision:
|
||||
raise TodaySessionConflict(current_session)
|
||||
revision = current_revision + 1
|
||||
normalized_entries = self._normalize_session_entries(
|
||||
entries if entries is not None else
|
||||
([{"identity": identity, "elapsed_ms": elapsed_ms}] if identity else [])
|
||||
)
|
||||
active_entries = [entry for entry in normalized_entries if entry["identity"] == identity]
|
||||
if identity and (len(active_entries) != 1 or active_entries[0]["elapsed_ms"] != elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
session = {
|
||||
"revision": revision, "device_id": device_id, "identity": identity,
|
||||
"elapsed_ms": elapsed_ms, "running": bool(running),
|
||||
"elapsed_ms": elapsed_ms, "entries": normalized_entries, "running": bool(running),
|
||||
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
|
||||
}
|
||||
connection.execute(
|
||||
|
||||
Reference in new issue
Block a user