Sync completed Filed acknowledgements across devices #881

Merged
timmy merged 1 commits from timmy/880-cross-device-filed-ack into main 2026-08-15 08:31:06 +00:00
7 changed files with 380 additions and 9 deletions

View File

@ -85,6 +85,13 @@ device can reopen the exact Search with one tap while stale writes surface a con
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
Acknowledgements hide the exact Gitea `updated_at` revision immediately on the current device, synchronize in
bounded batches to the confirmed account, and suppress that outcome on other signed-in devices. A later Gitea
update reopens review. Offline or failed synchronization keeps the local acknowledgement and retries on the next
healthy dashboard refresh without blocking **Acknowledge & next**. Set
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
`.stackchain-state/completed-filed-reviews.sqlite3` path.
Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued

View File

@ -220,6 +220,7 @@
storage: localStorage,
getLogin() { return planningOwnerLogin; },
});
let completedFiledSyncFlight = null;
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
@ -330,6 +331,35 @@
return payload;
}
function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight;
const pending = completedFiledReview.pending();
const options = pending.length ? {
method:'POST',
headers:{Accept:'application/json', 'Content-Type':'application/json'},
body:JSON.stringify({ receipts:pending }),
} : { headers:{Accept:'application/json'} };
completedFiledSyncFlight = fetchReviewJson('api/v1/completed-filed-reviews', options)
.then(snapshot => {
const changed = completedFiledReview.adopt(snapshot);
if (lastContextSnapshot && changed) {
lastMyWork = completedFiledReview.visible(buildMyWork(lastContextSnapshot));
refreshMyWorkView();
}
if (pending.length) qs('#my-work-action-status').textContent =
'Completed Filed review saved to account.';
return true;
})
.catch(() => {
if (pending.length) qs('#my-work-action-status').textContent =
'Acknowledged on this device · sync pending.';
return false;
})
.finally(() => { completedFiledSyncFlight = null; });
return completedFiledSyncFlight;
}
let commentActions = { isOwned: () => false, wire: () => {} };
function loadMentionCandidates(repository, query) {
@ -2517,6 +2547,7 @@
function paintMyWork(data) {
lastMyWork = completedFiledReview.visible(buildMyWork(data));
refreshMyWorkView();
void syncCompletedFiledReviews();
}
function listDrafts() {
@ -3446,7 +3477,9 @@
refreshMyWorkView();
const target = filedFollowUpTarget(lastMyWork);
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
(target ? ' Opening the next Filed item.' : ' Filed review is complete.');
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
' Filed review is complete. Acknowledgement sync pending.');
void syncCompletedFiledReviews();
if (target) {
const index = lastMyWork.indexOf(target.item);
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');

View File

@ -678,28 +678,64 @@ function createCompletedFiledReview({
const ownerKey = () => key + ':' + login();
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
const read = () => {
if (!login()) return {};
if (!login()) return { items:{}, pending:{} };
try {
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
return value && value.version === 1 && value.items && typeof value.items === 'object' ?
value.items : {};
} catch (_error) { return {}; }
return value && value.version === 1 && value.items && typeof value.items === 'object' ? {
items:value.items,
pending:value.pending && typeof value.pending === 'object' ? value.pending : {},
} : { items:{}, pending:{} };
} catch (_error) { return { items:{}, pending:{} }; }
};
const receipt = (item, stamp = String(item?.updated_at || '')) => ({
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
});
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
return {
visible(items) {
const acknowledged = read();
const acknowledged = read().items;
return (items || []).filter(item =>
!item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '')
);
},
pending() {
const state = read();
return Object.entries(state.pending).map(([itemIdentity, stamp]) => {
const boundary = itemIdentity.lastIndexOf('#');
return receipt({
repository:itemIdentity.slice(0, boundary),
number:Number(itemIdentity.slice(boundary + 1)),
}, stamp);
});
},
adopt(snapshot) {
const state = read();
let changed = false;
(snapshot?.receipts || []).forEach(remote => {
const itemIdentity = identity(remote);
const stamp = String(remote?.updated_at || '');
if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return;
if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) {
state.items[itemIdentity] = stamp;
changed = true;
}
if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) {
delete state.pending[itemIdentity];
}
});
try { save(state); } catch (_error) { return false; }
return changed;
},
acknowledge(item) {
const stamp = String(item?.updated_at || '');
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
return false;
}
const entries = Object.entries({ ...read(), [identity(item)]: stamp }).slice(-200);
const state = read();
const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200);
const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200);
try {
storage?.setItem(ownerKey(), JSON.stringify({ version:1, items:Object.fromEntries(entries) }));
save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) });
return true;
} catch (_error) { return false; }
},

View File

@ -0,0 +1,121 @@
"""Durable, account-scoped completed Filed review receipts."""
import re
import sqlite3
from datetime import datetime
from pathlib import Path
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
class CompletedFiledReviewStore:
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._initialize()
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
login TEXT NOT NULL,
repository TEXT NOT NULL,
issue_number INTEGER NOT NULL,
updated_at TEXT NOT NULL,
touched_at INTEGER NOT NULL,
PRIMARY KEY (login, repository, issue_number)
)
"""
)
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 _receipt(raw: dict) -> tuple[str, int, str]:
if not isinstance(raw, dict):
raise ValueError("receipt must be an object")
repository = raw.get("repository")
number = raw.get("number")
updated_at = raw.get("updated_at")
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
raise ValueError("repository is invalid")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError("number is invalid")
if not isinstance(updated_at, str):
raise ValueError("updated_at is invalid")
try:
parsed = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("updated_at is invalid") from error
if parsed.tzinfo is None:
raise ValueError("updated_at is invalid")
return repository, number, updated_at
@staticmethod
def _snapshot(rows) -> dict:
return {"receipts": [
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
for row in rows
]}
def get(self, login: str) -> dict:
with self._connect() as connection:
rows = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(self._login(login),),
).fetchall()
return self._snapshot(rows)
def merge(self, login: str, receipts: list[dict]) -> dict:
login = self._login(login)
if not isinstance(receipts, list) or len(receipts) > self.limit:
raise ValueError(f"receipts are limited to {self.limit}")
normalized = [self._receipt(receipt) for receipt in receipts]
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
touched = int(connection.execute(
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
(login,),
).fetchone()[0])
for repository, number, updated_at in normalized:
current = connection.execute(
"SELECT updated_at FROM completed_filed_reviews "
"WHERE login = ? AND repository = ? AND issue_number = ?",
(login, repository, number),
).fetchone()
if current is not None and current[0] >= updated_at:
continue
touched += 1
connection.execute(
"INSERT INTO completed_filed_reviews "
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
(login, repository, number, updated_at, touched),
)
connection.execute(
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
"ORDER BY touched_at DESC LIMIT ?)",
(login, login, self.limit),
)
rows = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(login,),
).fetchall()
return self._snapshot(rows)

View File

@ -26,6 +26,7 @@ from starlette.datastructures import UploadFile
from src import dashboard_auth, gitea_proxy, passkeys
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
from src.completed_filed_review_store import CompletedFiledReviewStore
from src.compression import NegotiatedGZipMiddleware
from src.gitea_proxy import (
activity_events,
@ -633,6 +634,20 @@ class SavedSearchCollection(BaseModel):
views: list[SavedSearchView] = Field(max_length=20)
class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field(
min_length=3,
max_length=200,
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
)
number: PositiveInt
updated_at: str = Field(min_length=1, max_length=100)
class CompletedFiledReviewBatch(BaseModel):
receipts: list[CompletedFiledReviewReceipt] = Field(max_length=200)
class UnfiledDraftBlocker(BaseModel):
repository: str = Field(min_length=3, max_length=200)
number: int = Field(ge=1)
@ -1273,7 +1288,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@ -2210,6 +2225,15 @@ def _saved_search_store() -> SavedSearchStore:
)
def _completed_filed_review_store() -> CompletedFiledReviewStore:
return CompletedFiledReviewStore(
os.getenv(
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
str(_state_dir / "completed-filed-reviews.sqlite3"),
)
)
def _unfiled_draft_store() -> UnfiledDraftStore:
return UnfiledDraftStore(
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
@ -2231,6 +2255,40 @@ async def _confirmed_login() -> str:
return login.strip().lower()
@app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Completed Filed review synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@app.post("/api/v1/completed-filed-reviews")
async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_completed_filed_review_store().merge,
login,
[receipt.model_dump() for receipt in payload.receipts],
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Completed Filed review synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/saved-searches")
async def get_saved_searches(response: Response):
login = await _confirmed_login()

View File

@ -0,0 +1,73 @@
import httpx
import pytest
from src import main
from src.completed_filed_review_store import CompletedFiledReviewStore
def receipt(repository="stackchain/api", number=9, updated_at="2026-08-15T12:00:00Z"):
return {"repository": repository, "number": number, "updated_at": updated_at}
def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path):
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
first = store.merge(" Timmy ", [receipt()])
second = store.merge("timmy", [receipt("stackchain/web", 4)])
assert first == {"receipts": [receipt()]}
assert second == {"receipts": [receipt(), receipt("stackchain/web", 4)]}
assert store.get("TIMMY") == second
assert store.get("alexander") == {"receipts": []}
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
newer = receipt(updated_at="2026-08-16T12:00:00Z")
store.merge("timmy", [newer])
assert store.merge("timmy", [receipt()]) == {"receipts": [newer]}
bounded = store.merge("timmy", [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)])
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
@pytest.mark.parametrize("candidate", [
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
{"repository": "stackchain/api", "number": 1, "updated_at": "yesterday"},
])
def test_receipts_reject_invalid_records(tmp_path, candidate):
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
with pytest.raises(ValueError):
store.merge("timmy", [candidate])
@pytest.mark.anyio
async def test_completed_filed_review_api_is_authenticated_csrf_protected_and_no_store(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_COMPLETED_FILED_REVIEW_DB", str(tmp_path / "completed.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
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.post("/api/v1/completed-filed-reviews", json={"receipts": [receipt()]})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
saved = await client.post(
"/api/v1/completed-filed-reviews", json={"receipts": [receipt()]}, headers=headers
)
fetched = await client.get("/api/v1/completed-filed-reviews")
assert forbidden.status_code == 403
assert saved.status_code == 200
assert saved.json() == {"receipts": [receipt()]}
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"

View File

@ -141,6 +141,46 @@ if (typeof create !== 'function') {{
}
def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
}};
const item = (repository, number, updated_at) => ({{
kind:'issue', key:repository + '#' + number, repository, number,
is_filed:true, is_completed:true, updated_at,
}});
const local = item('stackchain/api', 9, '2026-08-15T12:00:00Z');
const remote = item('stackchain/web', 4, '2026-08-14T12:00:00Z');
const review = buildMyWork.createCompletedFiledReview({{storage, getLogin:() => 'timmy'}});
review.acknowledge(local);
const pendingBefore = review.pending();
const changed = review.adopt({{receipts:[remote]}});
const visible = review.visible([local, remote]).map(value => value.key);
const pendingAfterRemote = review.pending();
review.adopt({{receipts:[
{{repository:local.repository, number:local.number, updated_at:local.updated_at}},
{{repository:remote.repository, number:remote.number, updated_at:remote.updated_at}},
]}});
process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAfterRemote, pendingFinal:review.pending()}}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"pendingBefore": [receipt := {
"repository": "stackchain/api", "number": 9, "updated_at": "2026-08-15T12:00:00Z"
}],
"changed": True,
"visible": [],
"pendingAfterRemote": [receipt],
"pendingFinal": [],
}
@pytest.mark.anyio
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
@ -155,6 +195,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
assert "filed: 'filed issues'" in source
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
assert "syncCompletedFiledReviews()" in source
assert "api/v1/completed-filed-reviews" in source
assert "sync pending" in source
assert "filedFollowUpTarget(lastMyWork)" in handler
assert "openRoutedWork" in handler
assert ".completed-filed-actions" in css