Continue unfiled issue drafts across signed-in devices #852

Merged
timmy merged 1 commits from timmy/851-cross-device-unfiled-drafts into main 2026-08-14 21:42:30 +00:00
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
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

View File

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

View File

@ -1222,6 +1222,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.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/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>

View File

@ -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',

View File

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

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",),
"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",

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()

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
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
)
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"

View File

@ -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",

View File

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

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]