diff --git a/README.md b/README.md
index db86887..8ab89a8 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,14 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
+The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
+remains immediate when offline and is marked **Sync pending** until connectivity returns. The complete
+routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
+concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
+losing either order. Resetting publishes the 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.
+
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.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 90ca0c9..ce0d300 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -1525,6 +1525,9 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
.mobile-queue-priority-footer button { min-height:44px; }
+ .mobile-queue-priority-conflict { margin:0 8px 10px; padding:10px; border:1px solid #f59e0b; border-radius:10px; background:#3b2808; }
+ .mobile-queue-priority-conflict p { margin:0 0 8px; }
+ .mobile-queue-priority-conflict button { min-height:44px; margin:4px 4px 0 0; }
.mobile-queue-group { margin-top:16px; }
.mobile-queue-group h3 { font-size:1rem; }
.mobile-queue-all { margin-top:16px; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 219844b..86b5e6f 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -433,17 +433,25 @@
mobileQueuePriority = createMobileQueuePriority({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
+ fetchJson: fetchReviewJson,
document,
list: qs('#mobile-queue-priority-list'),
resetButton: qs('#reset-mobile-queue-priority'),
status: qs('#mobile-queue-priority-status'),
+ conflict: qs('#mobile-queue-priority-conflict'),
+ keepLocalButton: qs('#keep-local-mobile-queue-priority'),
+ useRemoteButton: qs('#use-remote-mobile-queue-priority'),
labels: {
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
},
- onChange: () => renderMobileQueuePresentation(),
+ onChange: () => {
+ renderMobileQueuePresentation();
+ void mobileQueuePriority.sync();
+ },
});
mobileQueuePriority.start();
+ window.addEventListener('online', () => { void mobileQueuePriority.sync(); });
let rR = null;
function rRC() {
if (rR) return rR;
@@ -676,6 +684,7 @@
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
+ error.payload = payload;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
@@ -5638,6 +5647,7 @@
confirmedOwnerLogin = activeFlushLogin;
mobileQueuePriority.render();
renderMobileQueuePresentation();
+ void mobileQueuePriority.load();
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
diff --git a/frontend/index.html b/frontend/index.html
index 5b64cb2..fd3396c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2191,6 +2191,11 @@
+
+
This routine was changed on another device. Choose which complete order to keep.
+
+
+
Active now
diff --git a/frontend/mobile-queue-priority.js b/frontend/mobile-queue-priority.js
index 702e437..b3e6b91 100644
--- a/frontend/mobile-queue-priority.js
+++ b/frontend/mobile-queue-priority.js
@@ -7,9 +7,12 @@
];
const storage = options.storage;
const getLogin = options.getLogin || (() => '');
+ const fetchJson = options.fetchJson;
const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
+ let memory = null;
+ const syncFlights = new Map();
function key() {
const login = String(getLogin() || '').trim().toLowerCase();
@@ -19,32 +22,77 @@
function valid(order) {
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
new Set(order).size === DEFAULT_ORDER.length &&
- order.every((name, index) => DEFAULT_ORDER.includes(name) && typeof name === 'string');
+ order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
}
- function getOrder() {
+ function fresh() {
+ return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
+ }
+
+ function read() {
const accountKey = key();
- if (!accountKey || !storage) return DEFAULT_ORDER.slice();
+ if (!accountKey || !storage) return fresh();
+ if (memory?.key === accountKey) return memory.value;
+ let value = fresh();
try {
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
- return valid(saved) ? saved.slice() : DEFAULT_ORDER.slice();
- } catch (_error) {
- return DEFAULT_ORDER.slice();
- }
+ if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
+ else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
+ value = {
+ revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
+ status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
+ remote:saved.remote && valid(saved.remote.order) ? {
+ revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
+ } : null,
+ };
+ }
+ } catch (_error) {}
+ memory = {key:accountKey, value};
+ return value;
}
- function save(order) {
+ function persist(value) {
const accountKey = key();
- if (!accountKey || !storage || !valid(order)) return false;
+ if (!accountKey || !storage) return false;
+ memory = {key:accountKey, value};
try {
- storage.setItem(accountKey, JSON.stringify(order));
- options.onChange?.(order.slice());
+ storage.setItem(accountKey, JSON.stringify(value));
return true;
} catch (_error) {
return false;
}
}
+ function snapshot() {
+ const value = read();
+ return {
+ revision:value.revision, order:value.order.slice(), pending:value.pending,
+ status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
+ };
+ }
+
+ function announce(value) {
+ if (options.status) {
+ options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
+ syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
+ }
+ options.onState?.(snapshot());
+ }
+
+ function getOrder() {
+ return read().order.slice();
+ }
+
+ function save(order) {
+ if (!key() || !valid(order)) return false;
+ const current = read();
+ const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
+ if (!persist(value)) return false;
+ options.onChange?.(order.slice());
+ announce(value);
+ return true;
+ }
+
function move(name, delta) {
const order = getOrder();
const index = order.indexOf(name);
@@ -56,15 +104,120 @@
}
function reset() {
- const accountKey = key();
- if (accountKey && storage) {
- try { storage.removeItem(accountKey); } catch (_error) {}
- }
const order = DEFAULT_ORDER.slice();
- options.onChange?.(order.slice());
+ if (fetchJson && key()) save(order);
+ else {
+ const accountKey = key();
+ if (accountKey && storage) {
+ try { storage.removeItem(accountKey); } catch (_error) {}
+ }
+ memory = null;
+ options.onChange?.(order.slice());
+ }
return order;
}
+ function adopt(snapshotValue, accountKey = key()) {
+ if (!accountKey || key() !== accountKey) return snapshot();
+ if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
+ throw new Error('Queue priority response is invalid.');
+ }
+ const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
+ persist(value);
+ options.onChange?.(value.order.slice());
+ announce(value);
+ render();
+ return snapshot();
+ }
+
+ async function load() {
+ const accountKey = key();
+ if (!fetchJson || !accountKey) return snapshot();
+ try {
+ const remote = await fetchJson('api/v1/queue-priority');
+ if (key() !== accountKey) return snapshot();
+ const local = read();
+ if (local.pending) {
+ local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
+ persist(local);
+ return sync();
+ }
+ return adopt(remote, accountKey);
+ } catch (_error) {
+ if (key() !== accountKey) return snapshot();
+ const current = read();
+ current.status = current.pending ? 'pending' : 'error';
+ persist(current); announce(current);
+ return snapshot();
+ }
+ }
+
+ async function drain(accountKey) {
+ while (key() === accountKey) {
+ const current = read();
+ if (!current.pending) return snapshot();
+ const sent = {revision:current.revision, order:current.order.slice()};
+ current.status = 'syncing'; persist(current); announce(current);
+ try {
+ const saved = await fetchJson('api/v1/queue-priority', {
+ method:'PUT', headers:{'Content-Type':'application/json'},
+ body:JSON.stringify(sent),
+ });
+ if (key() !== accountKey) return snapshot();
+ if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
+ throw new Error('Queue priority response is invalid.');
+ }
+ const latest = read();
+ if (latest.order.some((name, index) => name !== sent.order[index])) {
+ latest.revision = saved.revision; latest.pending = true;
+ latest.status = 'pending'; latest.remote = null;
+ persist(latest); announce(latest);
+ continue;
+ }
+ return adopt(saved, accountKey);
+ } catch (error) {
+ if (key() !== accountKey) return snapshot();
+ const latest = read();
+ const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
+ if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
+ latest.status = 'conflict'; latest.pending = true;
+ latest.remote = {revision:remote.revision, order:remote.order.slice()};
+ } else {
+ latest.status = 'pending'; latest.pending = true;
+ }
+ persist(latest); announce(latest); render();
+ return snapshot();
+ }
+ }
+ return snapshot();
+ }
+
+ function sync() {
+ const accountKey = key();
+ const current = read();
+ if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
+ if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
+ const flight = drain(accountKey).finally(() => {
+ if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
+ });
+ syncFlights.set(accountKey, flight);
+ return flight;
+ }
+
+ async function useLocal() {
+ const current = read();
+ if (!current.remote) return snapshot();
+ current.revision = current.remote.revision;
+ current.remote = null; current.pending = true; current.status = 'pending';
+ persist(current);
+ return sync();
+ }
+
+ function useRemote() {
+ const current = read();
+ return current.remote ? adopt(current.remote) : snapshot();
+ }
+
function displayName(name) {
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
}
@@ -82,42 +235,37 @@
const controls = documentRef.createElement('span');
controls.setAttribute('class', 'mobile-queue-priority-controls');
const earlier = documentRef.createElement('button');
- earlier.textContent = 'Earlier';
- earlier.setAttribute('type', 'button');
+ earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
earlier.disabled = !signedIn || index === 0;
earlier.addEventListener('click', () => {
- move(name, -1);
- if (options.status) options.status.textContent = displayName(name) + ' moved earlier.';
- render();
+ move(name, -1); render();
});
const later = documentRef.createElement('button');
- later.textContent = 'Later';
- later.setAttribute('type', 'button');
+ later.textContent = 'Later'; later.setAttribute('type', 'button');
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
later.disabled = !signedIn || index === order.length - 1;
later.addEventListener('click', () => {
- move(name, 1);
- if (options.status) options.status.textContent = displayName(name) + ' moved later.';
- render();
+ move(name, 1); render();
});
- controls.append(earlier, later);
- row.append(label, controls);
+ controls.append(earlier, later); row.append(label, controls);
return row;
});
options.list.replaceChildren(...rows);
if (options.resetButton) options.resetButton.disabled = !signedIn;
+ if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
return order;
}
function start() {
options.resetButton?.addEventListener('click', () => {
- reset();
- if (options.status) options.status.textContent = 'Routine order reset.';
- render();
+ reset(); render();
});
+ options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
+ options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
return render();
}
- return {getOrder, move, reset, render, start, defaultOrder: () => DEFAULT_ORDER.slice()};
+ return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
+ state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});
diff --git a/scripts/rotate_private_state.py b/scripts/rotate_private_state.py
index 8b1248d..80d37ba 100755
--- a/scripts/rotate_private_state.py
+++ b/scripts/rotate_private_state.py
@@ -78,6 +78,9 @@ STORES = (
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
)),
+ Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
+ Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
+ )),
Store(
"completed-filed-reviews",
"completed-filed-reviews",
diff --git a/src/main.py b/src/main.py
index 92b291b..12bc6c9 100644
--- a/src/main.py
+++ b/src/main.py
@@ -71,6 +71,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
+from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
from src.following_store import FollowingStore
from src.unfiled_draft_store import (
UnfiledDraftConflict,
@@ -965,6 +966,11 @@ class SavedSearchCollection(BaseModel):
views: list[SavedSearchView] = Field(max_length=20)
+class QueuePriorityCollection(BaseModel):
+ revision: int = Field(ge=0)
+ order: list[str] = Field(min_length=9, max_length=9)
+
+
class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field(
min_length=3,
@@ -1784,7 +1790,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/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/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/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/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -3102,6 +3108,12 @@ def _saved_search_store() -> SavedSearchStore:
)
+def _queue_priority_store() -> QueuePriorityStore:
+ return QueuePriorityStore(
+ os.getenv("STACKCHAIN_QUEUE_PRIORITY_DB", str(_state_dir / "queue-priority.sqlite3"))
+ )
+
+
def _following_store() -> FollowingStore:
return FollowingStore(
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
@@ -3369,6 +3381,46 @@ async def replace_saved_searches(payload: SavedSearchCollection):
)
+@app.get("/api/v1/queue-priority")
+async def get_queue_priority(response: Response):
+ login = await _confirmed_login()
+ try:
+ snapshot = await asyncio.to_thread(_queue_priority_store().get, login)
+ except (OSError, sqlite3.Error, PrivateStateEncryptionError):
+ raise HTTPException(
+ status_code=503,
+ detail="Queue priority synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+ response.headers["Cache-Control"] = "no-store"
+ return snapshot
+
+
+@app.put("/api/v1/queue-priority")
+async def replace_queue_priority(payload: QueuePriorityCollection):
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(
+ _queue_priority_store().replace, login, payload.revision, payload.order
+ )
+ except QueuePriorityConflict as exc:
+ raise HTTPException(
+ status_code=409,
+ detail={
+ "message": "Queue priority changed on another device.",
+ "snapshot": exc.snapshot,
+ },
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc))
+ except (OSError, sqlite3.Error, PrivateStateEncryptionError):
+ raise HTTPException(
+ status_code=503,
+ detail="Queue priority synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+
+
@app.get("/api/v1/unfiled-drafts")
async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
diff --git a/src/queue_priority_store.py b/src/queue_priority_store.py
new file mode 100644
index 0000000..344d2d4
--- /dev/null
+++ b/src/queue_priority_store.py
@@ -0,0 +1,100 @@
+"""Encrypted, revisioned mobile routine queue priority."""
+
+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
+
+
+DEFAULT_QUEUE_ORDER = (
+ "attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft",
+)
+
+
+class QueuePriorityConflict(ValueError):
+ """Raised when a stale client attempts to replace the queue order."""
+
+ def __init__(self, snapshot: dict):
+ super().__init__("queue priority changed on another device")
+ self.snapshot = snapshot
+
+
+class QueuePriorityStore:
+ def __init__(self, path: str | Path, *, timeout: float = 1.0, encryption_key: bytes | None = None):
+ self.path = Path(path)
+ self.timeout = timeout
+ self._cipher = PrivateStateCipher(
+ encryption_key if encryption_key is not None else private_state_encryption_config(),
+ store="queue-priority",
+ )
+ with self._connect() as connection:
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS queue_priorities ("
+ "login TEXT PRIMARY KEY, revision INTEGER NOT NULL, queue_order 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(order: list[str] | tuple[str, ...]) -> list[str]:
+ if not isinstance(order, (list, tuple)) or len(order) != len(DEFAULT_QUEUE_ORDER):
+ raise ValueError("order must contain every routine queue")
+ if any(not isinstance(name, str) for name in order) or set(order) != set(DEFAULT_QUEUE_ORDER):
+ raise ValueError("order must contain every routine queue exactly once")
+ return list(order)
+
+ def _snapshot(self, row, login: str) -> tuple[dict, bool]:
+ if row is None:
+ return {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}, False
+ order, legacy = self._cipher.open(row[1], binding=f"order:{login}")
+ try:
+ normalized = self._normalize(order)
+ except ValueError as error:
+ raise PrivateStateEncryptionError("private state could not be decrypted") from error
+ return {"revision": int(row[0]), "order": normalized}, legacy
+
+ def get(self, login: str) -> dict:
+ login = self._login(login)
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
+ ).fetchone()
+ snapshot, legacy = self._snapshot(row, login)
+ if row is not None and legacy:
+ connection.execute(
+ "UPDATE queue_priorities SET queue_order = ? WHERE login = ? AND queue_order = ?",
+ (self._cipher.seal(snapshot["order"], binding=f"order:{login}"), login, row[1]),
+ )
+ return snapshot
+
+ def replace(self, login: str, expected_revision: int, order: list[str]) -> 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(order)
+ with self._connect() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
+ ).fetchone()
+ current, _legacy = self._snapshot(row, login)
+ if current["revision"] != expected_revision:
+ raise QueuePriorityConflict(current)
+ revision = expected_revision + 1
+ sealed = self._cipher.seal(normalized, binding=f"order:{login}")
+ connection.execute(
+ "INSERT INTO queue_priorities(login, revision, queue_order) VALUES (?, ?, ?) "
+ "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, queue_order=excluded.queue_order",
+ (login, revision, sealed),
+ )
+ return {"revision": revision, "order": normalized}
diff --git a/tests/e2e/test_adaptive_mobile_queues_release.py b/tests/e2e/test_adaptive_mobile_queues_release.py
index 6534a2d..70a6a42 100644
--- a/tests/e2e/test_adaptive_mobile_queues_release.py
+++ b/tests/e2e/test_adaptive_mobile_queues_release.py
@@ -215,7 +215,7 @@ def test_operator_reorders_routine_queues_without_leaking_priority_between_accou
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["following", "attention", "authored"]
- expect(page.locator("#mobile-queue-priority-status")).to_have_text("Following moved earlier.")
+ expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
controls = page.locator(".mobile-queue-priority-controls button")
assert controls.count() == 18
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py
index b88d62f..eb04e8f 100644
--- a/tests/test_mobile_task_dock.py
+++ b/tests/test_mobile_task_dock.py
@@ -89,6 +89,133 @@ process.stdout.write(JSON.stringify({{
}
+def test_mobile_queue_priority_hydrates_syncs_and_preserves_local_order_on_conflict():
+ script = f"""
+const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
+const values = new Map();
+const defaults = ['attention','today','update','agenda','following','authored','filed','later','draft'];
+const remote = defaults.slice(); remote.splice(remote.indexOf('following'), 1); remote.splice(1, 0, 'following');
+let server = {{revision:2, order:remote.slice()}};
+let conflict = false;
+const calls = [];
+const fetchJson = async (url, init={{}}) => {{
+ calls.push([url, init.method || 'GET']);
+ if (!init.method) return JSON.parse(JSON.stringify(server));
+ const payload = JSON.parse(init.body);
+ if (conflict) {{
+ const error = new Error('conflict'); error.status=409;
+ error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(server))}}}};
+ throw error;
+ }}
+ server={{revision:payload.revision + 1, order:payload.order.slice()}};
+ return JSON.parse(JSON.stringify(server));
+}};
+(async () => {{
+ const priority=createPriority({{
+ storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
+ getLogin:()=>'alice', fetchJson,
+ }});
+ await priority.load();
+ const hydrated=priority.getOrder();
+ priority.move('authored', -1);
+ const local=priority.getOrder();
+ conflict=true;
+ server={{revision:3, order:defaults.slice()}};
+ await priority.sync();
+ const conflicted=priority.state();
+ conflict=false;
+ await priority.useLocal();
+ process.stdout.write(JSON.stringify({{hydrated,local,conflicted,settled:priority.state(),server,calls}}));
+}})().catch(error => {{ console.error(error); process.exit(1); }});
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["hydrated"][1] == "following"
+ assert payload["local"] == payload["conflicted"]["order"]
+ assert payload["conflicted"]["status"] == "conflict"
+ assert payload["conflicted"]["remote"] == {
+ "revision": 3,
+ "order": ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"],
+ }
+ assert payload["settled"]["status"] == "ready"
+ assert payload["server"]["revision"] == 4
+ assert payload["server"]["order"] == payload["local"]
+ assert payload["calls"] == [
+ ["api/v1/queue-priority", "GET"],
+ ["api/v1/queue-priority", "PUT"],
+ ["api/v1/queue-priority", "PUT"],
+ ]
+
+
+def test_mobile_queue_priority_serializes_rapid_edits_and_publishes_latest_order():
+ script = f"""
+const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
+const values = new Map(); const pending=[]; const payloads=[];
+const fetchJson = async (_url, init) => new Promise(resolve => {{
+ const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}}));
+}});
+(async () => {{
+ let priority;
+ priority=createPriority({{
+ storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
+ getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }},
+ }});
+ priority.move('following', -1);
+ priority.move('following', -1);
+ await new Promise(resolve => setImmediate(resolve));
+ const firstPending=pending.length;
+ pending.shift()();
+ await new Promise(resolve => setImmediate(resolve));
+ const secondPending=pending.length;
+ pending.shift()();
+ await new Promise(resolve => setImmediate(resolve));
+ process.stdout.write(JSON.stringify({{firstPending,secondPending,payloads,state:priority.state()}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["firstPending"] == 1
+ assert payload["secondPending"] == 1
+ assert len(payload["payloads"]) == 2
+ assert payload["payloads"][0]["revision"] == 0
+ assert payload["payloads"][1]["revision"] == 1
+ assert payload["payloads"][1]["order"] == payload["state"]["order"]
+ assert payload["state"]["revision"] == 2
+ assert payload["state"]["status"] == "ready"
+
+
+def test_mobile_queue_priority_discards_in_flight_results_after_account_switch():
+ script = f"""
+const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
+const values = new Map(); let login='alice'; let rejectLoad;
+const fetchJson = () => new Promise((_resolve, reject) => {{ rejectLoad=reject; }});
+(async () => {{
+ const priority=createPriority({{
+ storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
+ getLogin:()=>login, fetchJson,
+ }});
+ const loading=priority.load();
+ login='bob';
+ rejectLoad(new Error('alice offline'));
+ await loading;
+ process.stdout.write(JSON.stringify({{state:priority.state(),keys:Array.from(values.keys())}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["state"]["status"] == "ready"
+ assert payload["state"]["order"] == [
+ "attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"
+ ]
+ assert payload["keys"] == []
+
+
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
@@ -127,7 +254,7 @@ process.stdout.write(JSON.stringify({{
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
"earlierLabel": "Move Following earlier",
"laterLabel": "Move Following later",
- "status": "Following moved earlier.",
+ "status": "Sync pending.",
}
@@ -399,6 +526,21 @@ async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe()
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
+@pytest.mark.anyio
+async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_conflict_actions():
+ html = await dashboard()
+
+ assert 'id="mobile-queue-priority-conflict"' in html
+ assert 'id="keep-local-mobile-queue-priority"' in html
+ assert 'id="use-remote-mobile-queue-priority"' in html
+ assert "fetchJson: fetchReviewJson" in html
+ assert "void mobileQueuePriority.load();" in html
+ assert "void mobileQueuePriority.sync();" in html
+ assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
+ assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
+ assert "error.payload = payload;" in html
+
+
@pytest.mark.anyio
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
html = await dashboard()
diff --git a/tests/test_queue_priority_store.py b/tests/test_queue_priority_store.py
new file mode 100644
index 0000000..514ceb9
--- /dev/null
+++ b/tests/test_queue_priority_store.py
@@ -0,0 +1,75 @@
+import sqlite3
+
+import httpx
+import pytest
+
+from src import main
+from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
+
+
+def test_queue_priority_is_revisioned_encrypted_and_account_scoped(tmp_path):
+ database = tmp_path / "queue-priority.sqlite3"
+ store = QueuePriorityStore(database, encryption_key=b"q" * 32)
+ preferred = list(DEFAULT_QUEUE_ORDER)
+ preferred.remove("following")
+ preferred.insert(1, "following")
+
+ created = store.replace(" Timmy ", 0, preferred)
+
+ assert created == {"revision": 1, "order": preferred}
+ assert QueuePriorityStore(database, encryption_key=b"q" * 32).get("timmy") == created
+ assert store.get("alexander") == {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}
+ with sqlite3.connect(database) as connection:
+ payload = connection.execute(
+ "SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
+ ).fetchone()[0]
+ assert payload.startswith("v1:")
+ assert "following" not in payload
+
+
+@pytest.mark.anyio
+async def test_queue_priority_api_is_authenticated_csrf_protected_no_store_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_QUEUE_PRIORITY_DB", str(tmp_path / "queue-priority.sqlite3"))
+
+ async def user():
+ return {"id": 1, "login": "Timmy"}
+
+ monkeypatch.setattr(main, "current_user", user)
+ preferred = list(DEFAULT_QUEUE_ORDER)
+ preferred.remove("following")
+ preferred.insert(1, "following")
+ 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.put(
+ "/api/v1/queue-priority", json={"revision": 0, "order": preferred}
+ )
+ headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
+ saved = await client.put(
+ "/api/v1/queue-priority", json={"revision": 0, "order": preferred}, headers=headers
+ )
+ stale = await client.put(
+ "/api/v1/queue-priority",
+ json={"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)},
+ headers=headers,
+ )
+ fetched = await client.get("/api/v1/queue-priority")
+
+ assert forbidden.status_code == 403
+ assert saved.status_code == 200
+ assert saved.json() == {"revision": 1, "order": preferred}
+ assert stale.status_code == 409
+ assert stale.json()["detail"] == {
+ "message": "Queue priority changed on another device.",
+ "snapshot": saved.json(),
+ }
+ assert fetched.json() == saved.json()
+ assert fetched.headers["cache-control"] == "no-store"
diff --git a/tests/test_rotate_private_state.py b/tests/test_rotate_private_state.py
index 5f05ec8..60e9545 100644
--- a/tests/test_rotate_private_state.py
+++ b/tests/test_rotate_private_state.py
@@ -7,6 +7,7 @@ import sys
from pathlib import Path
from src.completed_filed_review_store import CompletedFiledReviewStore
+from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
from src.saved_search_store import SavedSearchStore
@@ -111,3 +112,29 @@ def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tm
assert CompletedFiledReviewStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == {"receipts": [private_receipt]}
+
+
+def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
+ state = tmp_path / "state"
+ path = state / "queue-priority.sqlite3"
+ preferred = list(DEFAULT_QUEUE_ORDER)
+ preferred.remove("following")
+ preferred.insert(1, "following")
+ expected = QueuePriorityStore(path, encryption_key=b"o" * 32).replace(
+ "timmy", 0, preferred
+ )
+
+ completed = run_rotation(state)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout)["queue-priority"] == {
+ "current": 0, "failed": 0, "migrated": 1, "total": 1
+ }
+ with sqlite3.connect(path) as connection:
+ payload = connection.execute(
+ "SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
+ ).fetchone()[0]
+ assert payload.startswith("v2:next:")
+ assert QueuePriorityStore(
+ path, encryption_key=({"next": b"n" * 32}, "next")
+ ).get("timmy") == expected