feat: sync unfiled drafts across devices (Closes #851)
All checks were successful
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 2026-08-14 21:39:22 +00:00
parent 871e35c10b
commit 3dd0ba2024
16 changed files with 759 additions and 6 deletions

View File

@ -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 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 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 account-bound title/description captures without selecting a repository or entering the
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
the same Gitea login, **Choose repository** restores the capture to the normal planning reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
and durable delivery flow. A different or unconfirmed account can only copy or discard 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 the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to idempotency keys, so retrying after a timeout, reload, process restart, or handoff to

View File

@ -304,6 +304,7 @@
if (!response.ok) { if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.'); const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status; error.status = response.status;
error.code = payload.detail?.code; error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After'); const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter); error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
@ -530,6 +531,9 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin, getCurrentLogin: () => activeFlushLogin,
}); });
createUnfiledDraftSync(
unfiledCaptures, fetchReviewJson
);
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)}); const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, { dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController, captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
@ -2782,7 +2786,9 @@
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return; if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)]; 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 === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id); else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id); else if (item) draftInbox.discard(item.id);

View File

@ -1222,6 +1222,7 @@
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/drafts.js"></script> <script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script> <script src="static/unfiled-captures.js"></script>
<script src="static/unfiled-draft-sync.js"></script>
<script src="static/shared-image-capture.js"></script> <script src="static/shared-image-capture.js"></script>
<script src="static/draft-filing-session.js"></script> <script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script> <script src="static/draft-capacity-dialog.js"></script>

View File

@ -24,6 +24,7 @@ const SHELL = [
BASE + 'static/widgets.js', BASE + 'static/widgets.js',
BASE + 'static/drafts.js', BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js', BASE + 'static/unfiled-captures.js',
BASE + 'static/unfiled-draft-sync.js',
BASE + 'static/shared-image-capture.js', BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js', BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js', BASE + 'static/draft-capacity-dialog.js',

View File

@ -8,6 +8,8 @@ function createUnfiledCaptures({
maxItems = 20, maxItems = 20,
}) { }) {
const storageKey = 'stackchain.unfiled-issues.v1'; const storageKey = 'stackchain.unfiled-issues.v1';
const listeners = new Set();
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
function read() { function read() {
try { try {
@ -94,6 +96,7 @@ function createUnfiledCaptures({
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {}); if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error; throw error;
} }
onChange(item.id, false);
return item; return item;
}; };
const stage = prepared.hasAttachment const stage = prepared.hasAttachment
@ -150,6 +153,7 @@ function createUnfiledCaptures({
const removed = items.find(item => item.id === id); const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) { if (!removed?.hasAttachment) {
write(remaining); write(remaining);
onChange(id, true);
return true; return true;
} }
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment => return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
@ -159,6 +163,7 @@ function createUnfiledCaptures({
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {}); if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error; throw error;
} }
onChange(id, true);
return true; return true;
}) })
); );
@ -191,7 +196,86 @@ function createUnfiledCaptures({
function completeResume(id) { return discard(id); } 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; if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;

View File

@ -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;
});

View File

@ -31,7 +31,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "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-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/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/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", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",

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.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore 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.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute from src.suggestion_engine import compute
from src.later_store import LaterStore from src.later_store import LaterStore
@ -632,6 +633,33 @@ class SavedSearchCollection(BaseModel):
views: list[SavedSearchView] = Field(max_length=20) 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): class NotificationLaterRequest(BaseModel):
wake_at: str = Field(min_length=1, max_length=100) 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: async def _confirmed_login() -> str:
try: try:
user = await asyncio.wait_for( 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") @app.get("/api/v1/today")
async def get_today_plan(): async def get_today_plan():
login = await _confirmed_login() login = await _confirmed_login()

View File

@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024 API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024 ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024 LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"}) MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
normalized_method = method.upper() normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session": if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT return SESSION_BODY_LIMIT
if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
return UNFILED_DRAFT_SYNC_BODY_LIMIT
if ( if (
normalized_method == "POST" normalized_method == "POST"
and ( and (

177
src/unfiled_draft_store.py Normal file
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}

View File

@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
== 64 * 1024 == 64 * 1024
) )
assert main.request_body_limit("GET", "/api/v1/context") is None 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 ( assert (
main.request_body_limit( main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments" "POST", "/api/v1/repos/stackchain/project/issues/17/attachments"

View File

@ -797,6 +797,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",
"/dashboard/static/drafts.js", "/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js", "/dashboard/static/unfiled-captures.js",
"/dashboard/static/unfiled-draft-sync.js",
"/dashboard/static/shared-image-capture.js", "/dashboard/static/shared-image-capture.js",
"/dashboard/static/draft-filing-session.js", "/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js", "/dashboard/static/draft-capacity-dialog.js",

View File

@ -104,6 +104,47 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
assert output["stored"] == output["notes"] 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(): def test_unfiled_capture_restores_selected_blockers():
script = f""" script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); const createUnfiledCaptures=require({json.dumps(str(UNFILED))});

View File

@ -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 '<script src="static/unfiled-draft-sync.js"></script>' 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

View File

@ -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": []}

View File

@ -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]