feat: sync unfiled drafts across devices (Closes #851)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 21:39:22 +00:00
1 parent 871e35c10b
commit 3dd0ba2024
16 files changed
+759 -6

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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()
+3
View File
@@ -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 (
+177
View File
@@ -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}