feat: sync mobile recent work across devices (Closes #1475)
This commit is contained in:
parent
12a7a3c4b0
commit
19732ea5a6
10
README.md
10
README.md
|
|
@ -131,6 +131,14 @@ losing either order. Account changes discard stale responses and retry work. Res
|
||||||
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
|
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
|
||||||
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
|
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
|
||||||
|
|
||||||
|
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
|
||||||
|
review, Filed item, or update records its canonical detail route locally before navigation and marks
|
||||||
|
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
|
||||||
|
merge the server list without duplicate routes, while each confirmed account remains bounded to its
|
||||||
|
five most recent items. Titles, repositories, and routes are encrypted at rest with the shared
|
||||||
|
private-state key; stale responses from a prior account are discarded. Set
|
||||||
|
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
|
||||||
|
|
||||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||||
|
|
@ -310,7 +318,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
||||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
|
||||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||||
|
|
|
||||||
|
|
@ -435,9 +435,11 @@
|
||||||
mobileRecentWork = createMobileRecentWork({
|
mobileRecentWork = createMobileRecentWork({
|
||||||
storage:localStorage,
|
storage:localStorage,
|
||||||
getLogin:() => confirmedOwnerLogin,
|
getLogin:() => confirmedOwnerLogin,
|
||||||
|
fetchJson:fetchReviewJson,
|
||||||
document,
|
document,
|
||||||
section:qs('#mobile-recent-work'),
|
section:qs('#mobile-recent-work'),
|
||||||
list:qs('#mobile-recent-work-list'),
|
list:qs('#mobile-recent-work-list'),
|
||||||
|
status:qs('#mobile-recent-work-status'),
|
||||||
openRoute:fragment => {
|
openRoute:fragment => {
|
||||||
const sheet = qs('#mobile-queue-sheet');
|
const sheet = qs('#mobile-queue-sheet');
|
||||||
if (sheet.open) sheet.close();
|
if (sheet.open) sheet.close();
|
||||||
|
|
@ -445,6 +447,7 @@
|
||||||
workRoute.sync();
|
workRoute.sync();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
mobileRecentWork.startLifecycle({window, document});
|
||||||
mobileQueuePriority = createMobileQueuePriority({
|
mobileQueuePriority = createMobileQueuePriority({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => confirmedOwnerLogin,
|
getLogin: () => confirmedOwnerLogin,
|
||||||
|
|
@ -5665,6 +5668,7 @@
|
||||||
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
||||||
mobileQueuePriority.render();
|
mobileQueuePriority.render();
|
||||||
renderMobileQueuePresentation();
|
renderMobileQueuePresentation();
|
||||||
|
void mobileRecentWork.load();
|
||||||
void mobileQueuePriority.load();
|
void mobileQueuePriority.load();
|
||||||
void refreshPhotoDraftInbox();
|
void refreshPhotoDraftInbox();
|
||||||
timerView.restore(todaySync.flush());
|
timerView.restore(todaySync.flush());
|
||||||
|
|
|
||||||
|
|
@ -2185,6 +2185,7 @@
|
||||||
</section>
|
</section>
|
||||||
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
||||||
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
||||||
|
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
|
||||||
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
||||||
</section>
|
</section>
|
||||||
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,17 @@
|
||||||
|
|
||||||
const storage = options.storage;
|
const storage = options.storage;
|
||||||
const getLogin = options.getLogin;
|
const getLogin = options.getLogin;
|
||||||
|
const fetchJson = options.fetchJson;
|
||||||
const limit = Math.max(1, Number(options.limit) || 5);
|
const limit = Math.max(1, Number(options.limit) || 5);
|
||||||
const prefix = 'stackchain.mobile-recent-work.v1.';
|
const prefix = 'stackchain.mobile-recent-work.v1.';
|
||||||
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||||
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
||||||
|
const setTimer = options.setTimeout || setTimeout;
|
||||||
|
const clearTimer = options.clearTimeout || clearTimeout;
|
||||||
|
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||||
|
let syncFlight = null;
|
||||||
|
let syncAccount = '';
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
function login() {
|
function login() {
|
||||||
return String(getLogin?.() || '').trim().toLowerCase();
|
return String(getLogin?.() || '').trim().toLowerCase();
|
||||||
|
|
@ -35,40 +42,157 @@
|
||||||
}
|
}
|
||||||
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
|
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
return {
|
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
||||||
kind,
|
|
||||||
...(repository ? { repository } : {}),
|
|
||||||
number,
|
|
||||||
title,
|
|
||||||
route,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function items() {
|
function normalizeList(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const unique = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
const item = normalize(candidate);
|
||||||
|
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
||||||
|
if (unique.length === limit) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
const storageKey = key();
|
const storageKey = key();
|
||||||
if (!storageKey) return [];
|
if (!storageKey) return {items:[], pending:[]};
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||||
if (!Array.isArray(parsed)) return [];
|
if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]};
|
||||||
return parsed.map(normalize).filter(Boolean).slice(0, limit);
|
return {
|
||||||
|
items:normalizeList(parsed?.items),
|
||||||
|
pending:normalizeList(parsed?.pending),
|
||||||
|
};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return [];
|
return {items:[], pending:[]};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(item) {
|
function persist(value, accountKey = key()) {
|
||||||
const storageKey = key();
|
if (!accountKey || accountKey !== key()) return false;
|
||||||
const normalized = normalize(item);
|
|
||||||
if (!storageKey || !normalized) return false;
|
|
||||||
const next = [normalized, ...items().filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(storageKey, JSON.stringify(next));
|
storage.setItem(accountKey, JSON.stringify({
|
||||||
|
items:normalizeList(value.items), pending:normalizeList(value.pending),
|
||||||
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function announce(value = read(), status = null) {
|
||||||
|
if (!options.status) return;
|
||||||
|
options.status.textContent = status || (value.pending.length ? 'Sync pending.' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function items() {
|
||||||
|
return read().items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function state() {
|
||||||
|
const value = read();
|
||||||
|
return {pending:value.pending.length > 0, pendingCount:value.pending.length};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync() {
|
||||||
|
if (!fetchJson || !key()) return false;
|
||||||
|
if (debounceTimer) clearTimer(debounceTimer);
|
||||||
|
debounceTimer = setTimer(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
void sync();
|
||||||
|
}, debounceMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(item) {
|
||||||
|
const accountKey = key();
|
||||||
|
const normalized = normalize(item);
|
||||||
|
if (!accountKey || !normalized) return false;
|
||||||
|
const current = read();
|
||||||
|
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||||
|
current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||||
|
if (!persist(current, accountKey)) return false;
|
||||||
|
announce(current);
|
||||||
|
render();
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(snapshot, accountKey, pending = []) {
|
||||||
|
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
||||||
|
const remote = normalizeList(snapshot.items);
|
||||||
|
const unsent = normalizeList(pending);
|
||||||
|
const value = {
|
||||||
|
items:normalizeList([...unsent, ...remote]),
|
||||||
|
pending:unsent,
|
||||||
|
};
|
||||||
|
persist(value, accountKey);
|
||||||
|
announce(value);
|
||||||
|
render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(accountKey) {
|
||||||
|
while (key() === accountKey) {
|
||||||
|
const current = read();
|
||||||
|
if (!current.pending.length) return current;
|
||||||
|
const sending = current.pending[current.pending.length - 1];
|
||||||
|
announce(current, 'Syncing recent work…');
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchJson('api/v1/recent-work', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
|
||||||
|
});
|
||||||
|
if (key() !== accountKey) return read();
|
||||||
|
const latest = read();
|
||||||
|
const pending = latest.pending.filter(item => item.route !== sending.route);
|
||||||
|
if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.');
|
||||||
|
} catch (_error) {
|
||||||
|
if (key() === accountKey) announce(read());
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
||||||
|
const accountKey = key();
|
||||||
|
if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read());
|
||||||
|
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
||||||
|
syncAccount = accountKey;
|
||||||
|
syncFlight = drain(accountKey).finally(() => {
|
||||||
|
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
|
||||||
|
});
|
||||||
|
return syncFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const accountKey = key();
|
||||||
|
if (!fetchJson || !accountKey) return read();
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchJson('api/v1/recent-work');
|
||||||
|
if (key() !== accountKey) return read();
|
||||||
|
const current = read();
|
||||||
|
adopt(snapshot, accountKey, current.pending);
|
||||||
|
return current.pending.length ? sync() : read();
|
||||||
|
} catch (_error) {
|
||||||
|
if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.');
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLifecycle(lifecycle = {}) {
|
||||||
|
const reconcile = () => read().pending.length ? sync() : load();
|
||||||
|
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
||||||
|
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
||||||
|
if (!lifecycle.document.hidden) void reconcile();
|
||||||
|
});
|
||||||
|
return reconcile;
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const recent = items();
|
const recent = items();
|
||||||
const list = options.list;
|
const list = options.list;
|
||||||
|
|
@ -98,5 +222,5 @@
|
||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, record, render };
|
return {items, record, render, load, sync, startLifecycle, state};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,9 @@ STORES = (
|
||||||
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
|
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
|
||||||
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
|
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
|
||||||
)),
|
)),
|
||||||
|
Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", (
|
||||||
|
Table("recent_work", ("login",), (Field("items", "items:{login}"),)),
|
||||||
|
)),
|
||||||
Store(
|
Store(
|
||||||
"completed-filed-reviews",
|
"completed-filed-reviews",
|
||||||
"completed-filed-reviews",
|
"completed-filed-reviews",
|
||||||
|
|
|
||||||
49
src/main.py
49
src/main.py
|
|
@ -72,6 +72,7 @@ from src.push_subscription_store import build_push_subscription_store
|
||||||
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.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
|
from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
|
||||||
|
from src.recent_work_store import RecentWorkStore
|
||||||
from src.following_store import FollowingStore
|
from src.following_store import FollowingStore
|
||||||
from src.unfiled_draft_store import (
|
from src.unfiled_draft_store import (
|
||||||
UnfiledDraftConflict,
|
UnfiledDraftConflict,
|
||||||
|
|
@ -971,6 +972,14 @@ class QueuePriorityCollection(BaseModel):
|
||||||
order: list[str] = Field(min_length=9, max_length=9)
|
order: list[str] = Field(min_length=9, max_length=9)
|
||||||
|
|
||||||
|
|
||||||
|
class RecentWorkItem(BaseModel):
|
||||||
|
kind: Literal["issue", "filed", "pull", "review", "update"]
|
||||||
|
repository: str = Field(default="", max_length=200)
|
||||||
|
number: PositiveInt
|
||||||
|
title: str = Field(min_length=1, max_length=180)
|
||||||
|
route: str = Field(min_length=1, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
class CompletedFiledReviewReceipt(BaseModel):
|
class CompletedFiledReviewReceipt(BaseModel):
|
||||||
repository: str = Field(
|
repository: str = Field(
|
||||||
min_length=3,
|
min_length=3,
|
||||||
|
|
@ -1790,7 +1799,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(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/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") 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/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/recent-work", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -3114,6 +3123,12 @@ def _queue_priority_store() -> QueuePriorityStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_work_store() -> RecentWorkStore:
|
||||||
|
return RecentWorkStore(
|
||||||
|
os.getenv("STACKCHAIN_RECENT_WORK_DB", str(_state_dir / "recent-work.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _following_store() -> FollowingStore:
|
def _following_store() -> FollowingStore:
|
||||||
return FollowingStore(
|
return FollowingStore(
|
||||||
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
|
||||||
|
|
@ -3421,6 +3436,38 @@ async def replace_queue_priority(payload: QueuePriorityCollection):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/recent-work")
|
||||||
|
async def get_recent_work(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_recent_work_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/recent-work")
|
||||||
|
async def record_recent_work(payload: RecentWorkItem):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_recent_work_store().record, login, payload.model_dump()
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/unfiled-drafts")
|
@app.get("/api/v1/unfiled-drafts")
|
||||||
async def get_unfiled_drafts(response: Response):
|
async def get_unfiled_drafts(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
131
src/recent_work_store.py
Normal file
131
src/recent_work_store.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Encrypted, account-scoped recent work shared by signed-in devices."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||||
|
|
||||||
|
|
||||||
|
KINDS = frozenset({"issue", "filed", "pull", "review", "update"})
|
||||||
|
|
||||||
|
|
||||||
|
class RecentWorkStore:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
encryption_key: bytes | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.timeout = timeout
|
||||||
|
self.limit = max(1, int(limit))
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||||
|
store="recent-work",
|
||||||
|
)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS recent_work ("
|
||||||
|
"login TEXT PRIMARY KEY, items TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return connect_private_sqlite(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 _normalize(item: dict) -> dict:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
kind = item.get("kind")
|
||||||
|
number = item.get("number")
|
||||||
|
title = item.get("title")
|
||||||
|
repository = item.get("repository", "")
|
||||||
|
if (
|
||||||
|
kind not in KINDS
|
||||||
|
or not isinstance(number, int)
|
||||||
|
or isinstance(number, bool)
|
||||||
|
or number < 1
|
||||||
|
or not isinstance(title, str)
|
||||||
|
or not title.strip()
|
||||||
|
):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
title = title.strip()[:180]
|
||||||
|
if kind == "update":
|
||||||
|
if repository:
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
route = f"#/my-work/update/{number}"
|
||||||
|
normalized = {"kind": kind, "number": number, "title": title, "route": route}
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
not isinstance(repository, str)
|
||||||
|
or repository.count("/") != 1
|
||||||
|
or any(not part or not all(character.isalnum() or character in "_.-" for character in part)
|
||||||
|
for part in repository.split("/"))
|
||||||
|
):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
route = f"#/my-work/{kind}/{repository}/{number}"
|
||||||
|
normalized = {
|
||||||
|
"kind": kind,
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"title": title,
|
||||||
|
"route": route,
|
||||||
|
}
|
||||||
|
if item.get("route", route) != route:
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _items(self, row, login: str) -> tuple[list[dict], bool]:
|
||||||
|
if row is None:
|
||||||
|
return [], False
|
||||||
|
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
try:
|
||||||
|
return [self._normalize(item) for item in payload][: self.limit], legacy
|
||||||
|
except ValueError as error:
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
items, legacy = self._items(row, login)
|
||||||
|
if row is not None and legacy:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
|
||||||
|
(self._cipher.seal(items, binding=f"items:{login}"), login, row[0]),
|
||||||
|
)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
def record(self, login: str, item: dict) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
normalized = self._normalize(item)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
current, _legacy = self._items(row, login)
|
||||||
|
items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])]
|
||||||
|
items = items[: self.limit]
|
||||||
|
sealed = self._cipher.seal(items, binding=f"items:{login}")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
|
||||||
|
(login, sealed),
|
||||||
|
)
|
||||||
|
return {"items": items}
|
||||||
|
|
@ -125,6 +125,37 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_records_offline_first_then_merges_the_server_snapshot():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const values = new Map(); const calls=[]; const status={{textContent:''}};
|
||||||
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||||
|
const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}};
|
||||||
|
const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}};
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage,getLogin:()=>'alice',status,debounceMs:99999,
|
||||||
|
fetchJson:async (url, options={{}})=>{{
|
||||||
|
calls.push([url,options.method||'GET']);
|
||||||
|
return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
recent.record(local);
|
||||||
|
const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}};
|
||||||
|
await recent.sync();
|
||||||
|
process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["immediate"]["items"][0]["number"] == 7
|
||||||
|
assert payload["immediate"]["status"] == "Sync pending."
|
||||||
|
assert payload["immediate"]["state"]["pending"] is True
|
||||||
|
assert [item["number"] for item in payload["settled"]] == [7, 9]
|
||||||
|
assert payload["status"] == ""
|
||||||
|
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||||
html = INDEX.read_text()
|
html = INDEX.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
@ -132,8 +163,13 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||||
|
|
||||||
assert 'id="mobile-recent-work"' in html
|
assert 'id="mobile-recent-work"' in html
|
||||||
assert 'id="mobile-recent-work-list"' in html
|
assert 'id="mobile-recent-work-list"' in html
|
||||||
|
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
|
||||||
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
||||||
assert "createMobileRecentWork({" in dashboard
|
assert "createMobileRecentWork({" in dashboard
|
||||||
|
assert "fetchJson:fetchReviewJson" in dashboard
|
||||||
|
assert "status:qs('#mobile-recent-work-status')" in dashboard
|
||||||
|
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
|
||||||
|
assert "void mobileRecentWork.load();" in dashboard
|
||||||
assert "mobileRecentWork.record(item)" in dashboard
|
assert "mobileRecentWork.record(item)" in dashboard
|
||||||
assert "mobileRecentWork.render()" in dashboard
|
assert "mobileRecentWork.render()" in dashboard
|
||||||
assert "workRoute.sync()" in dashboard
|
assert "workRoute.sync()" in dashboard
|
||||||
|
|
|
||||||
53
tests/test_recent_work_api.py
Normal file
53
tests/test_recent_work_api.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_account_scoped(
|
||||||
|
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_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||||
|
active_login = "Timmy"
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": active_login}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
entry = {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 1475,
|
||||||
|
"title": "Sync recent work",
|
||||||
|
"route": "#/my-work/issue/stackchain/dashboard/1475",
|
||||||
|
}
|
||||||
|
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/recent-work", json=entry)
|
||||||
|
headers = {
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
saved = await client.post("/api/v1/recent-work", json=entry, headers=headers)
|
||||||
|
fetched = await client.get("/api/v1/recent-work")
|
||||||
|
active_login = "Alexander"
|
||||||
|
other_account = await client.get("/api/v1/recent-work")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json() == {"items": [entry]}
|
||||||
|
assert fetched.json() == saved.json()
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
|
assert other_account.json() == {"items": []}
|
||||||
47
tests/test_recent_work_store.py
Normal file
47
tests/test_recent_work_store.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from src.recent_work_store import RecentWorkStore
|
||||||
|
|
||||||
|
|
||||||
|
def item(number: int, *, title: str | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": number,
|
||||||
|
"title": title or f"Issue {number}",
|
||||||
|
"route": f"#/my-work/issue/stackchain/dashboard/{number}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_path):
|
||||||
|
database = tmp_path / "recent-work.sqlite3"
|
||||||
|
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||||
|
|
||||||
|
for number in range(1, 7):
|
||||||
|
store.record(" Timmy ", item(number))
|
||||||
|
expected = store.record("timmy", item(3, title="Issue 3 updated"))
|
||||||
|
|
||||||
|
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
|
||||||
|
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
|
||||||
|
assert store.get("alexander") == {"items": []}
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v1:")
|
||||||
|
assert "Issue 3 updated" not in payload
|
||||||
|
assert "#/my-work/issue" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
|
||||||
|
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
|
||||||
|
|
||||||
|
invalid = item(1)
|
||||||
|
invalid["route"] = "https://attacker.example/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
store.record("timmy", invalid)
|
||||||
|
except ValueError as error:
|
||||||
|
assert str(error) == "recent work item is invalid"
|
||||||
|
else:
|
||||||
|
raise AssertionError("invalid route was accepted")
|
||||||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||||
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
|
||||||
|
from src.recent_work_store import RecentWorkStore
|
||||||
from src.saved_search_store import SavedSearchStore
|
from src.saved_search_store import SavedSearchStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -138,3 +139,33 @@ def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
|
||||||
assert QueuePriorityStore(
|
assert QueuePriorityStore(
|
||||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||||
).get("timmy") == expected
|
).get("timmy") == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_command_rewraps_recent_work_without_printing_titles(tmp_path):
|
||||||
|
state = tmp_path / "state"
|
||||||
|
path = state / "recent-work.sqlite3"
|
||||||
|
private_item = {
|
||||||
|
"kind": "issue",
|
||||||
|
"repository": "private/canary",
|
||||||
|
"number": 1475,
|
||||||
|
"title": "Secret release investigation",
|
||||||
|
"route": "#/my-work/issue/private/canary/1475",
|
||||||
|
}
|
||||||
|
expected = RecentWorkStore(path, encryption_key=b"o" * 32).record("timmy", private_item)
|
||||||
|
|
||||||
|
completed = run_rotation(state)
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
assert json.loads(completed.stdout)["recent-work"] == {
|
||||||
|
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||||
|
}
|
||||||
|
assert private_item["title"] not in completed.stdout
|
||||||
|
assert "timmy" not in completed.stdout
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v2:next:")
|
||||||
|
assert RecentWorkStore(
|
||||||
|
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||||
|
).get("timmy") == expected
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user