feat: hand off active Today sessions across devices (Closes #1022)
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 12:44:01 +00:00
1 parent 51f4223f4b
commit 8d12a0384b
14 files changed
+596 -7

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
+41 -2
View File
@@ -64,7 +64,7 @@ from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
from src.views import FRONTEND_BUILD, router as frontend_router
@@ -588,6 +588,14 @@ class TodayRecap(BaseModel):
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
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)
running: bool
class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
@@ -1322,7 +1330,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -2456,6 +2464,37 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
@app.get("/api/v1/today/session")
async def get_today_session():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_session, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503, detail="Today session synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.patch("/api/v1/today/session")
async def update_today_session(payload: TodaySessionUpdate):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_today_store().update_session, login, **payload.model_dump()
)
except TodaySessionConflict as error:
raise HTTPException(
status_code=409,
detail={"code": "session_changed", "session": error.session},
)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503, detail="Today session synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today/recaps")
async def get_today_recaps(response: Response):
login = await _confirmed_login()
+74 -2
View File
@@ -13,6 +13,14 @@ class TodayPlanFull(ValueError):
"""Raised when an add would exceed the bounded Today plan."""
class TodaySessionConflict(ValueError):
"""Raised when a device updates an obsolete active-session revision."""
def __init__(self, session: dict):
super().__init__("active Today session changed on another device")
self.session = session
class TodayStore:
def __init__(
self,
@@ -36,7 +44,7 @@ class TodayStore:
def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
if connection.execute("PRAGMA user_version").fetchone()[0] >= 2:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
@@ -106,7 +114,20 @@ class TodayStore:
)
"""
)
connection.execute("PRAGMA user_version = 1")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_sessions (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
device_id TEXT NOT NULL,
identity TEXT NOT NULL,
elapsed_ms INTEGER NOT NULL,
running INTEGER NOT NULL,
updated_at REAL NOT NULL
)
"""
)
connection.execute("PRAGMA user_version = 2")
connection.commit()
connection.close()
@@ -163,6 +184,57 @@ class TodayStore:
).fetchone()
return self._snapshot(row)
@staticmethod
def _empty_session() -> dict:
return {
"revision": 0, "device_id": "", "identity": "",
"elapsed_ms": 0, "running": False, "updated_at": None,
}
def get_session(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, device_id, identity, elapsed_ms, running, updated_at "
"FROM today_sessions WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
if row is None:
return self._empty_session()
return {
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
"elapsed_ms": int(row[3]), "running": bool(row[4]), "updated_at": row[5],
}
def update_session(
self, login: str, *, base_revision: int, device_id: str,
identity: str, elapsed_ms: int, running: bool,
) -> dict:
login = self._normalize_login(login)
updated_at = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT revision, device_id, identity, elapsed_ms, running, updated_at "
"FROM today_sessions WHERE login = ?", (login,)
).fetchone()
current_revision = int(current[0]) if current else 0
if base_revision != current_revision:
session = self._empty_session() if current is None else {
"revision": current_revision, "device_id": current[1], "identity": current[2],
"elapsed_ms": int(current[3]), "running": bool(current[4]), "updated_at": current[5],
}
raise TodaySessionConflict(session)
revision = current_revision + 1
connection.execute(
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
"updated_at=excluded.updated_at",
(login, revision, device_id, identity, elapsed_ms, int(running), updated_at),
)
return self.get_session(login)
@staticmethod
def _normalize_recap_items(items: list[dict]) -> list[dict]:
if not isinstance(items, list) or not items: