feat: sync unfiled drafts across devices (Closes #851)
This commit is contained in:
1 parent
871e35c10b
commit
3dd0ba2024
16 files changed
+759
-6
No files matched your search
@@ -31,7 +31,7 @@ FEATURE_SOURCES = {
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.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-queue-launcher.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-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
|
||||
+77
@@ -58,6 +58,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
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
|
||||
@@ -632,6 +633,33 @@ class SavedSearchCollection(BaseModel):
|
||||
views: list[SavedSearchView] = Field(max_length=20)
|
||||
|
||||
|
||||
class UnfiledDraftBlocker(BaseModel):
|
||||
repository: str = Field(min_length=3, max_length=200)
|
||||
number: int = Field(ge=1)
|
||||
title: str = Field(default="", max_length=255)
|
||||
|
||||
|
||||
class UnfiledDraftEvidence(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=255)
|
||||
content_type: Literal["image/png", "image/jpeg", "image/webp"]
|
||||
note: str = Field(default="", max_length=240)
|
||||
data: str = Field(max_length=14_000_000)
|
||||
|
||||
|
||||
class UnfiledDraft(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
saved_at: int = Field(ge=0)
|
||||
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
|
||||
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
|
||||
|
||||
|
||||
class UnfiledDraftCollection(BaseModel):
|
||||
revision: int = Field(ge=0)
|
||||
drafts: list[UnfiledDraft] = Field(max_length=20)
|
||||
|
||||
|
||||
class NotificationLaterRequest(BaseModel):
|
||||
wake_at: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@@ -2138,6 +2166,12 @@ def _saved_search_store() -> SavedSearchStore:
|
||||
)
|
||||
|
||||
|
||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||
return UnfiledDraftStore(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
async def _confirmed_login() -> str:
|
||||
try:
|
||||
user = await asyncio.wait_for(
|
||||
@@ -2196,6 +2230,49 @@ async def replace_saved_searches(payload: SavedSearchCollection):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/unfiled-drafts")
|
||||
async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.put("/api/v1/unfiled-drafts")
|
||||
async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_unfiled_draft_store().replace,
|
||||
login,
|
||||
payload.revision,
|
||||
[draft.model_dump() for draft in payload.drafts],
|
||||
)
|
||||
except UnfiledDraftConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Drafts changed on another device.",
|
||||
"snapshot": exc.snapshot,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/today")
|
||||
async def get_today_plan():
|
||||
login = await _confirmed_login()
|
||||
|
||||
@@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
|
||||
normalized_method = method.upper()
|
||||
if normalized_method == "POST" and path == "/api/v1/session":
|
||||
return SESSION_BODY_LIMIT
|
||||
if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
|
||||
return UNFILED_DRAFT_SYNC_BODY_LIMIT
|
||||
if (
|
||||
normalized_method == "POST"
|
||||
and (
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Durable, account-scoped unfiled issue drafts and ordered evidence."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
|
||||
|
||||
class UnfiledDraftConflict(ValueError):
|
||||
"""Raised when a client attempts to replace a stale draft collection."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("unfiled drafts changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class UnfiledDraftStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
max_total_bytes: int = 12 * 1024 * 1024,
|
||||
timeout: float = 1.0,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.max_total_bytes = max_total_bytes
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS unfiled_drafts (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
drafts TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "drafts": []} if row is None else {
|
||||
"revision": int(row[0]), "drafts": json.loads(row[1])
|
||||
}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||
(self._login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def _normalize(self, drafts: list[dict]) -> list[dict]:
|
||||
if not isinstance(drafts, list):
|
||||
raise ValueError("drafts must be a list")
|
||||
if len(drafts) > self.limit:
|
||||
raise ValueError(f"unfiled drafts are limited to {self.limit}")
|
||||
normalized = []
|
||||
seen = set()
|
||||
decoded_total = 0
|
||||
for raw in drafts:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("draft must be an object")
|
||||
draft_id = raw.get("id")
|
||||
if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
|
||||
raise ValueError("draft id is invalid")
|
||||
if draft_id in seen:
|
||||
raise ValueError("draft ids must be unique")
|
||||
title = raw.get("title")
|
||||
body = raw.get("body", "")
|
||||
saved_at = raw.get("saved_at")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
|
||||
raise ValueError("title is invalid")
|
||||
if not isinstance(body, str) or len(body) > 10_000:
|
||||
raise ValueError("body is invalid")
|
||||
if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
|
||||
raise ValueError("saved_at is invalid")
|
||||
blockers = raw.get("blockers", [])
|
||||
if not isinstance(blockers, list) or len(blockers) > 5:
|
||||
raise ValueError("blockers are invalid")
|
||||
clean_blockers = []
|
||||
for blocker in blockers:
|
||||
repository = blocker.get("repository") if isinstance(blocker, dict) else None
|
||||
number = blocker.get("number") if isinstance(blocker, dict) else None
|
||||
blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("blocker repository is invalid")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("blocker number is invalid")
|
||||
if not isinstance(blocker_title, str) or len(blocker_title) > 255:
|
||||
raise ValueError("blocker title is invalid")
|
||||
clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
|
||||
evidence = raw.get("evidence", [])
|
||||
if not isinstance(evidence, list) or len(evidence) > 5:
|
||||
raise ValueError("evidence is invalid")
|
||||
clean_evidence = []
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("evidence is invalid")
|
||||
filename = item.get("filename")
|
||||
content_type = item.get("content_type")
|
||||
note = item.get("note", "")
|
||||
data = item.get("data")
|
||||
if not isinstance(filename, str) or not filename or len(filename) > 255:
|
||||
raise ValueError("evidence filename is invalid")
|
||||
if content_type not in _CONTENT_TYPES:
|
||||
raise ValueError("evidence content type is invalid")
|
||||
if not isinstance(note, str) or len(note) > 240:
|
||||
raise ValueError("evidence note is invalid")
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("evidence data is invalid")
|
||||
try:
|
||||
decoded_total += len(base64.b64decode(data, validate=True))
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise ValueError("evidence data must be valid base64") from error
|
||||
if decoded_total > self.max_total_bytes:
|
||||
raise ValueError("synchronized evidence is too large")
|
||||
clean_evidence.append({
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
**({"note": note} if note else {}),
|
||||
"data": data,
|
||||
})
|
||||
seen.add(draft_id)
|
||||
normalized.append({
|
||||
"id": draft_id,
|
||||
"title": title.strip(),
|
||||
"body": body,
|
||||
"saved_at": saved_at,
|
||||
**({"blockers": clean_blockers} if clean_blockers else {}),
|
||||
**({"evidence": clean_evidence} if clean_evidence else {}),
|
||||
})
|
||||
return normalized
|
||||
|
||||
def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(drafts)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise UnfiledDraftConflict(current)
|
||||
revision = expected_revision + 1
|
||||
connection.execute(
|
||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
return {"revision": revision, "drafts": normalized}
|
||||
Reference in new issue
Block a user