feat: hand off active Today sessions across devices (Closes #1022)
This commit is contained in:
1 parent
51f4223f4b
commit
8d12a0384b
14 files changed
+596
-7
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user