diff --git a/README.md b/README.md index 0bdb54d..075b068 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,14 @@ repository milestone and due date; the dashboard validates both and sends them w self-assignment in the single create request, so planned work appears in its release lane immediately. On a cold offline launch, **Save for filing** stores up to 20 account-bound title/description captures without selecting a repository or entering the -mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms -the same Gitea login, **Choose repository** restores the capture to the normal planning -and durable delivery flow. A different or unconfirmed account can only copy or discard +mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh +reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title, +description, blockers, and ordered screenshot evidence; another signed-in device can then use +**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report +whether they are synced or still pending locally. Concurrent changes surface a conflict, and +successful discard or outbox admission propagates deletion so a stale device cannot resurrect the +draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure +never blocks local capture. A different or unconfirmed account can only copy or discard the private content. Issue capture and authored mobile actions (issue comments, pull-request comments, notification replies, and reviews) persist per-draft idempotency keys, so retrying after a timeout, reload, process restart, or handoff to diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 204c7be..cc6b4c7 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -304,6 +304,7 @@ if (!response.ok) { const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.'); error.status = response.status; + error.code = payload.detail?.code; const retryAfter = response.headers.get('Retry-After'); error.retryAfter = retryAfter === null ? undefined : Number(retryAfter); @@ -530,6 +531,9 @@ getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), getCurrentLogin: () => activeFlushLogin, }); + createUnfiledDraftSync( + unfiledCaptures, fetchReviewJson + ); const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)}); dFS.attach(qs, { captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController, @@ -2782,7 +2786,9 @@ button.addEventListener('click', async () => { if (!window.confirm('Discard this unfinished draft?')) return; const item = lastDrafts[Number(button.dataset.draftIndex)]; - if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id); + if (item?.kind === 'unfiled-issue') { + await unfiledCaptures.discard(item.capture_id); + } else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id); else if (item) draftInbox.discard(item.id); diff --git a/frontend/index.html b/frontend/index.html index 473fdcd..57887ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1222,6 +1222,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 9d01b89..c8943c4 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -24,6 +24,7 @@ const SHELL = [ BASE + 'static/widgets.js', BASE + 'static/drafts.js', BASE + 'static/unfiled-captures.js', + BASE + 'static/unfiled-draft-sync.js', BASE + 'static/shared-image-capture.js', BASE + 'static/draft-filing-session.js', BASE + 'static/draft-capacity-dialog.js', diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index a8136b5..da3ff34 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -8,6 +8,8 @@ function createUnfiledCaptures({ maxItems = 20, }) { const storageKey = 'stackchain.unfiled-issues.v1'; + const listeners = new Set(); + const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed)); function read() { try { @@ -94,6 +96,7 @@ function createUnfiledCaptures({ if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {}); throw error; } + onChange(item.id, false); return item; }; const stage = prepared.hasAttachment @@ -150,6 +153,7 @@ function createUnfiledCaptures({ const removed = items.find(item => item.id === id); if (!removed?.hasAttachment) { write(remaining); + onChange(id, true); return true; } return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment => @@ -159,6 +163,7 @@ function createUnfiledCaptures({ if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {}); throw error; } + onChange(id, true); return true; }) ); @@ -191,7 +196,86 @@ function createUnfiledCaptures({ function completeResume(id) { return discard(id); } - return {list, capacity, save, replaceOldest, discard, resume, completeResume}; + function encodeBytes(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); + } + + function decodeBytes(value, contentType) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return new Blob([bytes], {type:contentType}); + } + + async function exportOwned(login) { + const owner = String(login || '').trim(); + const exported = []; + for (const item of read().filter(candidate => candidate.ownerLogin === owner)) { + let evidence = []; + if (item.hasAttachment) { + const stored = await attachmentStore?.get(item.id); + const attachments = Array.isArray(stored?.attachments) ? stored.attachments : + (stored?.blob ? [stored] : []); + if (attachments.length !== Number(item.attachmentCount || 1)) { + throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.'); + } + evidence = await Promise.all(attachments.map(async attachment => ({ + filename:attachment.filename, + content_type:attachment.contentType, + ...(attachment.note ? {note:attachment.note} : {}), + data:encodeBytes(await attachment.blob.arrayBuffer()), + }))); + } + exported.push({ + id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt), + ...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}), + ...(evidence.length ? {evidence} : {}), + }); + } + return exported; + } + + async function mergeRemote(drafts, login) { + const ownerLogin = String(login || '').trim(); + if (!ownerLogin || !Array.isArray(drafts)) return 0; + const existing = read(); + const known = new Set(existing.map(item => item.id)); + const imported = []; + for (const remote of drafts) { + if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue; + const evidence = Array.isArray(remote.evidence) ? remote.evidence : []; + if (evidence.length && !attachmentStore) continue; + const item = { + id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''), + savedAt:Number(remote.saved_at), + ...(Array.isArray(remote.blockers) && remote.blockers.length ? { + blockers:remote.blockers, blockerCount:remote.blockers.length, + } : {}), + ...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}), + }; + if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue; + if (evidence.length) { + await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({ + filename:String(entry.filename), contentType:String(entry.content_type), + blob:decodeBytes(String(entry.data), String(entry.content_type)), + ...(entry.note ? {note:String(entry.note)} : {}), + }))}); + } + known.add(item.id); + imported.push(item); + } + if (imported.length) write(imported.concat(existing)); + return imported.length; + } + + return {list, capacity, save, replaceOldest, discard, resume, completeResume, + exportOwned, mergeRemote, currentLogin:getCurrentLogin, + subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))}; } if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures; diff --git a/frontend/unfiled-draft-sync.js b/frontend/unfiled-draft-sync.js new file mode 100644 index 0000000..6e0dde2 --- /dev/null +++ b/frontend/unfiled-draft-sync.js @@ -0,0 +1,139 @@ +(function (root, factory) { + const createUnfiledDraftSync = factory(); + if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync; + if (root) root.createUnfiledDraftSync = createUnfiledDraftSync; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + function createUnfiledDraftSync(options, mountedFetch) { + if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch); + const {captures, fetchJson, storage, getLogin = () => '', onState = () => {}} = options; + const key = 'stackchain.unfiled-draft-sync.v1'; + let inFlight = null; + let rerun = false; + + function read(login) { + try { + const all = JSON.parse(storage?.getItem(key) || '{}'); + const state = all?.[login]; + return { + all:all && typeof all === 'object' ? all : {}, + known:new Set(Array.isArray(state?.known) ? state.known : []), + deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []), + }; + } catch (_error) { + return {all:{}, known:new Set(), deleted:new Set()}; + } + } + + function write(login, state) { + state.all[login] = {known:[...state.known], deleted:[...state.deleted]}; + storage?.setItem(key, JSON.stringify(state.all)); + } + + function publish(status, message) { + onState({status, ...(message ? {message} : {})}); + const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status'); + if (message && visibleStatus) visibleStatus.textContent = message; + } + + function sameDrafts(left, right) { + return JSON.stringify(left) === JSON.stringify(right); + } + + async function runSync(login) { + const owner = String(login || '').trim(); + if (!owner) return false; + publish('syncing', 'Syncing Drafts…'); + const state = read(owner); + try { + const remote = await fetchJson('api/v1/unfiled-drafts'); + const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : []; + const remoteIds = new Set(remoteDrafts.map(item => item.id)); + const before = await captures.exportOwned(owner); + + for (const local of before) { + if (state.known.has(local.id) && !remoteIds.has(local.id) && !state.deleted.has(local.id)) { + await captures.discard(local.id); + } + } + await captures.mergeRemote( + remoteDrafts.filter(item => !state.deleted.has(item.id)), owner + ); + const local = await captures.exportOwned(owner); + const merged = []; + const byId = new Map(); + for (const item of remoteDrafts) if (!state.deleted.has(item.id)) byId.set(item.id, item); + for (const item of local) if (!state.deleted.has(item.id)) byId.set(item.id, item); + for (const item of byId.values()) merged.push(item); + merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at)); + + let finalSnapshot = remote; + if (!sameDrafts(merged, remoteDrafts)) { + finalSnapshot = await fetchJson('api/v1/unfiled-drafts', { + method:'PUT', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}), + }); + } + state.known = new Set((finalSnapshot.drafts || []).map(item => item.id)); + state.deleted.clear(); + write(owner, state); + publish('ready', 'Drafts synced across devices.'); + return true; + } catch (error) { + if (error?.status === 409) { + publish('conflict', 'Drafts changed on another device. Sync again to combine them.'); + } else { + publish('pending', 'Saved on this device · sync pending'); + } + throw error; + } + } + + function sync(login) { + const owner = String(login || '').trim(); + if (!owner) return Promise.resolve(false); + if (inFlight) { + rerun = true; + return inFlight; + } + inFlight = (async () => { + let result = false; + do { + rerun = false; + result = await runSync(owner); + } while (rerun); + return result; + })().finally(() => { inFlight = null; }); + return inFlight; + } + + async function remove(id, login) { + const owner = String(login || '').trim(); + if (!owner) return false; + const state = read(owner); + state.deleted.add(String(id)); + write(owner, state); + await captures.discard(String(id)); + return sync(owner); + } + + captures.subscribe?.((id, removed) => { + const login = getLogin(); + if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {}); + }); + + return {sync, remove}; + } + createUnfiledDraftSync.mount = (captures, fetchJson) => { + const getLogin = captures.currentLogin; + const controller = createUnfiledDraftSync({captures, fetchJson, storage:globalThis.localStorage, getLogin}); + const connect = () => { + const login = getLogin(); + if (login) void controller.sync(login).catch(() => {}); + else globalThis.setTimeout(connect, 250); + }; + globalThis.setTimeout(connect, 0); + globalThis.addEventListener?.('online', connect); + return controller; + }; + return createUnfiledDraftSync; +}); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index b1f0fb0..44b64c6 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -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", diff --git a/src/main.py b/src/main.py index a7d314f..ab6fde9 100644 --- a/src/main.py +++ b/src/main.py @@ -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() diff --git a/src/request_boundary.py b/src/request_boundary.py index 0914555..06ecc50 100644 --- a/src/request_boundary.py +++ b/src/request_boundary.py @@ -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 ( diff --git a/src/unfiled_draft_store.py b/src/unfiled_draft_store.py new file mode 100644 index 0000000..1968af1 --- /dev/null +++ b/src/unfiled_draft_store.py @@ -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} diff --git a/tests/test_request_boundary.py b/tests/test_request_boundary.py index d2a3d7c..7e710f3 100644 --- a/tests/test_request_boundary.py +++ b/tests/test_request_boundary.py @@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations(): == 64 * 1024 ) assert main.request_body_limit("GET", "/api/v1/context") is None + assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024 assert ( main.request_body_limit( "POST", "/api/v1/repos/stackchain/project/issues/17/attachments" diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 6c3f4f8..a3ba7d5 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -797,6 +797,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/widgets.js", "/dashboard/static/drafts.js", "/dashboard/static/unfiled-captures.js", + "/dashboard/static/unfiled-draft-sync.js", "/dashboard/static/shared-image-capture.js", "/dashboard/static/draft-filing-session.js", "/dashboard/static/draft-capacity-dialog.js", diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index de27cdd..8f33fb9 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -104,6 +104,47 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at assert output["stored"] == output["notes"] +def test_unfiled_captures_export_and_import_ordered_evidence_between_devices(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +function device() {{ + const values=new Map(), blobs=new Map(); + return {{ + captures:createUnfiledCaptures({{ + storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}, + attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}}, + getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42, + }}), blobs, + }}; +}} +(async()=>{{ + const phone=device(), desktop=device(); + const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}}); + await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}}); + const exported=await phone.captures.exportOwned('timmy'); + const imported=await desktop.captures.mergeRemote(exported,'timmy'); + const resumed=await desktop.captures.resume('phone-draft','timmy'); + process.stdout.write(JSON.stringify({{ + exported:exported[0], imported, + names:resumed.attachments.map(item=>item.filename), + notes:resumed.attachments.map(item=>item.note), + bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())), + blockers:resumed.blockers, + }})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["exported"]["id"] == "phone-draft" + assert output["exported"]["saved_at"] == 42 + assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"] + assert output["imported"] == 1 + assert output["names"] == ["one.png", "two.png"] + assert output["notes"] == ["First", "Second"] + assert output["bytes"] == ["one.png", "two.png"] + assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}] + + def test_unfiled_capture_restores_selected_blockers(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); diff --git a/tests/test_unfiled_draft_integration.py b/tests/test_unfiled_draft_integration.py new file mode 100644 index 0000000..d119c1e --- /dev/null +++ b/tests/test_unfiled_draft_integration.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from src.frontend_bundle import FEATURE_SOURCES, build_frontend + + +ROOT = Path(__file__).parents[1] + + +def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow(): + html = (ROOT / "frontend" / "index.html").read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + + assert '' in html + assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"] + assert "createUnfiledDraftSync(" in dashboard + assert "unfiledCaptures, fetchReviewJson" in dashboard + assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text() + + built = build_frontend(ROOT / "frontend") + assert "unfiled-draft-sync.js" not in built.dashboard_html + assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes diff --git a/tests/test_unfiled_draft_store.py b/tests/test_unfiled_draft_store.py new file mode 100644 index 0000000..bf2a840 --- /dev/null +++ b/tests/test_unfiled_draft_store.py @@ -0,0 +1,92 @@ +import httpx +import pytest + +from src import main +from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore + + +def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None): + return { + "id": draft_id, + "title": title, + "body": "Steps from the field", + "saved_at": 1_723_600_000_000, + "blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}], + "evidence": (evidence if evidence is not None else [ + { + "filename": "checkout.png", + "content_type": "image/png", + "note": "Error after tapping Pay", + "data": "cG5nLWJ5dGVz", + } + ]), + } + + +def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path): + store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") + + created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")]) + + assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]} + assert store.get("timmy") == created + assert store.get("alexander") == {"revision": 0, "drafts": []} + with pytest.raises(UnfiledDraftConflict) as conflict: + store.replace("timmy", 0, [draft(title="Stale overwrite")]) + assert conflict.value.snapshot == created + + +def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path): + store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12) + + with pytest.raises(ValueError, match="limited to 2"): + store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])]) + with pytest.raises(ValueError, match="evidence is too large"): + store.replace("timmy", 0, [draft(evidence=[{ + "filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=", + }])]) + with pytest.raises(ValueError, match="valid base64"): + store.replace("timmy", 0, [draft(evidence=[{ + "filename": "bad.png", "content_type": "image/png", "data": "not base64!", + }])]) + + +@pytest.mark.anyio +async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") + monkeypatch.setenv( + "STACKCHAIN_DASHBOARD_SESSION_SECRET", + "a-separate-session-signing-secret-with-enough-entropy", + ) + monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) + monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3")) + monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3")) + identity = {"login": "Timmy"} + + async def user(): + return {"id": 1, "login": identity["login"]} + + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + payload = {"revision": 0, "drafts": [draft(evidence=[])]} + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"}) + forbidden = await client.put("/api/v1/unfiled-drafts", json=payload) + headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]} + saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers) + stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers) + fetched = await client.get("/api/v1/unfiled-drafts") + identity["login"] = "Alexander" + isolated = await client.get("/api/v1/unfiled-drafts") + + assert forbidden.status_code == 403 + assert saved.status_code == 200 + expected = draft(evidence=[]) + expected.pop("evidence") + assert saved.json() == {"revision": 1, "drafts": [expected]} + assert stale.status_code == 409 + assert stale.json()["detail"]["snapshot"] == saved.json() + assert fetched.json() == saved.json() + assert fetched.headers["cache-control"] == "no-store" + assert isolated.json() == {"revision": 0, "drafts": []} diff --git a/tests/test_unfiled_draft_sync.py b/tests/test_unfiled_draft_sync.py new file mode 100644 index 0000000..876bf83 --- /dev/null +++ b/tests/test_unfiled_draft_sync.py @@ -0,0 +1,104 @@ +import json +import subprocess +from pathlib import Path + + +SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js" + + +def run_node(script: str): + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let remote={{revision:0,drafts:[]}}; +function api(url,init={{}}) {{ + if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote))); + const body=JSON.parse(init.body); + if (body.revision!==remote.revision) {{ + const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error); + }} + remote={{revision:remote.revision+1,drafts:body.drafts}}; + return Promise.resolve(JSON.parse(JSON.stringify(remote))); +}} +function device(initial=[]) {{ + let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[]; + const captures={{ + exportOwned:async()=>drafts.map(item=>({{...item}})), + mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}}, + discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}}, + }}; + const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}; + const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}}); + return {{sync,states,drafts:()=>drafts}}; +}} +(async()=>{{ + const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]); + await phone.sync.sync('timmy'); + const desktop=device(); + await desktop.sync.sync('timmy'); + await desktop.sync.remove('field','timmy'); + await phone.sync.sync('timmy'); + process.stdout.write(JSON.stringify({{ + remote,desktop:desktop.drafts(),phone:phone.drafts(), + phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status), + }})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + + assert output["remote"]["revision"] == 2 + assert output["remote"]["drafts"] == [] + assert output["desktop"] == [] + assert output["phone"] == [] + assert output["phoneStates"][-1] == "ready" + assert output["desktopStates"][-1] == "ready" + + +def test_unfiled_draft_sync_surfaces_revision_conflict_without_losing_local_draft(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let calls=0;const states=[];let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}]; +const conflict={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}}; +const sync=createSync({{ + captures:{{exportOwned:async()=>drafts,mergeRemote:async()=>0,discard:async()=>true}}, + storage:{{getItem:()=>null,setItem:()=>{{}}}}, + fetchJson:async(_url,init)=>{{calls++;if(!init)return {{revision:1,drafts:[]}};const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:conflict}}}};throw error}}, + onState:state=>states.push(state), +}}); +(async()=>{{let message='';try{{await sync.sync('timmy')}}catch(error){{message=error.message}}process.stdout.write(JSON.stringify({{calls,states,message,drafts}}))}})(); +""" + output = run_node(script) + + assert output["calls"] == 2 + assert output["states"][-1]["status"] == "conflict" + assert output["states"][-1]["message"] == "Drafts changed on another device. Sync again to combine them." + assert output["drafts"][0]["id"] == "local" + + +def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict(): + script = f""" +const createSync=require({json.dumps(str(SYNC))}); +let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release; +const gate=new Promise(resolve=>release=resolve); +const api=async(_url,init)=>{{ + if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}} + puts++;const body=JSON.parse(init.body); + if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}} + remote={{revision:remote.revision+1,drafts:body.drafts}};return remote; +}}; +const draft={{id:'local',title:'Local',body:'',saved_at:1}}; +const sync=createSync({{ + captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}}, + storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api, +}}); +(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})(); +""" + output = run_node(script) + + assert output["puts"] == 1 + assert output["remote"]["drafts"][0]["id"] == "local" + assert output["results"] == [True, True]