Compare commits

..

No commits in common. "e0bc7271faa62c31dd5c8d52b615aa030e37f2a9" and "8b71de70bd329be12de1cb703129c92e86881a67" have entirely different histories.

8 changed files with 14 additions and 281 deletions

View File

@ -97,13 +97,9 @@ API responses and mutations are never cached by the service worker. New issue ca
issue comments, pull-request comments, and unread-update replies use bounded local issue comments, pull-request comments, and unread-update replies use bounded local
outboxes when connectivity or a retryable server failure prevents delivery. Drafts outboxes when connectivity or a retryable server failure prevents delivery. Drafts
shows queued and needs-attention messages with explicit send/discard controls; reconnect shows queued and needs-attention messages with explicit send/discard controls; reconnect
flushes messages sequentially with their original idempotency keys. Tabs coordinate each flushes messages sequentially with their original idempotency keys. State-sensitive
queue through the browser lock manager (with an expiring local lease fallback), so an actions such as reviews, merges, closures, labels, milestones, and assignments are never
installed PWA and a browser tab cannot submit the same operation concurrently. Queue queued. Service-worker upgrades are atomic and remove only older
changes are broadcast to other tabs and refresh Drafts without polling; an abandoned
lease can be reclaimed after expiry. State-sensitive actions such as reviews, merges,
closures, labels, milestones, and assignments are never queued. Service-worker upgrades
are atomic and remove only older
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin. `stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
Run the test suite with: Run the test suite with:

View File

@ -1,4 +1,4 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) { function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1'; const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() => const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@ -16,7 +16,6 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
function write(items) { function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
} }
function enqueue(message) { function enqueue(message) {
@ -113,7 +112,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
finally { if (pending.get(item.id) === request) pending.delete(item.id); } finally { if (pending.get(item.id) === request) pending.delete(item.id); }
} }
async function flushQueue(currentLogin) { async function flush(currentLogin) {
const confirmed = []; const confirmed = [];
let blocked = 0; let blocked = 0;
currentLogin = String(currentLogin || '').trim(); currentLogin = String(currentLogin || '').trim();
@ -127,12 +126,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
return { confirmed, remaining: read(), blocked }; return { confirmed, remaining: read(), blocked };
} }
async function flush(currentLogin) { async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
return flushQueue(currentLogin);
}
async function retryItem(id, currentLogin) {
const item = read().find(candidate => candidate.id === id); const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() }; if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim(); currentLogin = String(currentLogin || '').trim();
@ -144,11 +138,6 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 }; return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
} }
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
} }

View File

@ -739,7 +739,6 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/drafts.js"></script> <script src="static/drafts.js"></script>
<script src="static/outbox-coordinator.js"></script>
<script src="static/issue-outbox.js"></script> <script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script> <script src="static/authored-outbox.js"></script>
<script src="static/offline-work.js"></script> <script src="static/offline-work.js"></script>
@ -885,14 +884,11 @@ textarea { resize: vertical; min-height: 120px; }
loadMilestones: item => issueController.loadMilestones(item), loadMilestones: item => issueController.loadMilestones(item),
}); });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const issueOutbox = createIssueOutbox({ const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
getOwnerLogin: () => confirmedOwnerLogin,
}); });
const authoredOutbox = createAuthoredOutbox({ const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
getOwnerLogin: () => confirmedOwnerLogin,
}); });
const shareParams = new URLSearchParams(location.search); const shareParams = new URLSearchParams(location.search);
const sharedLaunch = { const sharedLaunch = {
@ -904,7 +900,6 @@ textarea { resize: vertical; min-height: 120px; }
issueCapture.stageSharedContent(sharedLaunch) : null; issueCapture.stageSharedContent(sharedLaunch) : null;
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
const findWorkController = createFindWork({ const findWorkController = createFindWork({
fetchJson: fetchReviewJson, fetchJson: fetchReviewJson,
@ -2032,7 +2027,6 @@ textarea { resize: vertical; min-height: 120px; }
} }
function applyOutboxResult(result, openCreated = false) { function applyOutboxResult(result, openCreated = false) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
(result.confirmed || []).forEach(confirmed => { (result.confirmed || []).forEach(confirmed => {
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
}); });
@ -2059,7 +2053,6 @@ textarea { resize: vertical; min-height: 120px; }
} }
function applyAuthoredOutboxResult(result) { function applyAuthoredOutboxResult(result) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
refreshMyWorkView(); refreshMyWorkView();
const attention = (result.remaining || []).some(item => item.status === 'attention'); const attention = (result.remaining || []).some(item => item.status === 'attention');
qs('#my-work-action-status').textContent = result.confirmed?.length ? qs('#my-work-action-status').textContent = result.confirmed?.length ?

View File

@ -1,4 +1,4 @@
function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) { function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1'; const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() => const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@ -15,7 +15,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = ()
function write(items) { function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('issue');
} }
function enqueue(draft) { function enqueue(draft) {
@ -103,7 +102,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = ()
finally { if (pending.get(item.id) === request) pending.delete(item.id); } finally { if (pending.get(item.id) === request) pending.delete(item.id); }
} }
async function flushQueue(currentLogin) { async function flush(currentLogin) {
const confirmed = []; const confirmed = [];
let blocked = 0; let blocked = 0;
currentLogin = String(currentLogin || '').trim(); currentLogin = String(currentLogin || '').trim();
@ -117,12 +116,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = ()
return { confirmed, remaining: read(), blocked }; return { confirmed, remaining: read(), blocked };
} }
async function flush(currentLogin) { async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
return flushQueue(currentLogin);
}
async function retryItem(id, currentLogin) {
const item = read().find(candidate => candidate.id === id); const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() }; if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim(); currentLogin = String(currentLogin || '').trim();
@ -134,11 +128,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = ()
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 }; return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
} }
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
} }

View File

@ -1,98 +0,0 @@
function createOutboxCoordinator({
storage,
locks = globalThis.navigator?.locks,
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
addStorageListener = globalThis.addEventListener?.bind(globalThis),
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
leaseMs = 15000,
} = {}) {
const changeKey = 'stackchain.outbox-change.v1';
const subscribers = new Set();
const channel = channelFactory ? channelFactory() : null;
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
}
function publish(change) {
subscribers.forEach(listener => listener(change));
}
if (channel) {
channel.onmessage = event => {
const change = validChange(event?.data);
if (change && change.tabId !== tabId) publish(change);
};
}
const onStorage = event => {
if (event?.key !== changeKey || !event.newValue) return;
try {
const change = validChange(JSON.parse(event.newValue));
if (change && change.tabId !== tabId) publish(change);
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
};
if (addStorageListener) addStorageListener('storage', onStorage);
function notify(queue) {
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
if (!validChange(change)) return;
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
}
function skipped() {
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
}
async function withFallbackLease(queue, work) {
const key = 'stackchain.outbox-lease.' + queue + '.v1';
const token = tabId + ':' + (++sequence);
const timestamp = Number(now());
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
const claimed = JSON.parse(storage?.getItem(key) || 'null');
if (claimed?.token !== token) return skipped();
} catch (_error) {
return work();
}
try {
return await work();
} finally {
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token === token) storage?.removeItem(key);
} catch (_error) { /* An expired lease will be reclaimed. */ }
}
}
async function runExclusive(queue, work) {
if (locks?.request) {
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
lock => lock ? work() : skipped());
}
return withFallbackLease(queue, work);
}
function subscribe(listener) {
if (typeof listener !== 'function') return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
}
function close() {
subscribers.clear();
try { channel?.close(); } catch (_error) { /* No-op. */ }
if (removeStorageListener) removeStorageListener('storage', onStorage);
}
return { runExclusive, notify, subscribe, close };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;

View File

@ -1,4 +1,4 @@
const CACHE = 'stackchain-dashboard-shell-v10'; const CACHE = 'stackchain-dashboard-shell-v9';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
const SHELL = [ const SHELL = [
@ -11,7 +11,6 @@ const SHELL = [
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/widgets.js', BASE + 'static/widgets.js',
BASE + 'static/drafts.js', BASE + 'static/drafts.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js', BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js', BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js', BASE + 'static/offline-work.js',

View File

@ -1,134 +0,0 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
ROOT = Path(__file__).parents[1]
COORDINATOR = ROOT / "frontend" / "outbox-coordinator.js"
ISSUE_OUTBOX = ROOT / "frontend" / "issue-outbox.js"
AUTHORED_OUTBOX = ROOT / "frontend" / "authored-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_two_tabs_share_one_flush_lease_for_each_outbox():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const createIssueOutbox = require({json.dumps(str(ISSUE_OUTBOX))});
const createAuthoredOutbox = require({json.dumps(str(AUTHORED_OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const held = new Set();
const locks = {{request: async (name, options, work) => {{
if (held.has(name)) return work(null);
held.add(name);
try {{ return await work({{name}}); }} finally {{ held.delete(name); }}
}}}};
let releaseIssue, releaseMessage;
const issueGate = new Promise(resolve => releaseIssue = resolve);
const messageGate = new Promise(resolve => releaseMessage = resolve);
let issueCalls = 0, messageCalls = 0;
const issueA = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'issue-1',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{issueCalls++;await issueGate;return {{number:1}};}}}});
const issueB = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{issueCalls++;return {{number:1}};}}}});
issueA.enqueue({{repository:'o/r',title:'One'}});
const messageA = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{messageCalls++;await messageGate;return {{id:1}};}}}});
const messageB = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{messageCalls++;return {{id:1}};}}}});
messageA.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Reply',operationId:'message-1'}});
(async()=>{{
const issueFirst=issueA.flush('timmy'); const issueSecond=issueB.retry('issue-1', 'timmy');
const messageFirst=messageA.flush('timmy'); const messageSecond=messageB.retry('message-1', 'timmy');
await Promise.resolve();
releaseIssue(); releaseMessage();
const results=await Promise.all([issueFirst,issueSecond,messageFirst,messageSecond]);
process.stdout.write(JSON.stringify({{issueCalls,messageCalls,results,issueRemaining:issueA.list(),messageRemaining:messageA.list()}}));
}})();
"""
output = run_node(script)
assert output["issueCalls"] == 1
assert output["messageCalls"] == 1
assert output["issueRemaining"] == []
assert output["messageRemaining"] == []
assert sum(result.get("lease_skipped", False) for result in output["results"]) == 2
def test_fallback_lease_expires_and_stale_owner_cannot_release_successor():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let clock=100;
let releaseFirst;
const firstGate=new Promise(resolve=>releaseFirst=resolve);
const first=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'first',now:()=>clock,leaseMs:50}});
const second=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'second',now:()=>clock,leaseMs:50}});
(async()=>{{
const running=first.runExclusive('issue', async()=>{{await firstGate;return 'first-done';}});
await Promise.resolve();
const blocked=await second.runExclusive('issue', async()=> 'too-early');
clock=151;
const takeover=await second.runExclusive('issue', async()=> 'taken-over');
releaseFirst();
const original=await running;
const after=await second.runExclusive('issue', async()=> 'after-release');
process.stdout.write(JSON.stringify({{blocked,takeover,original,after,lease:storage.getItem('stackchain.outbox-lease.issue.v1')}}));
}})();
"""
output = run_node(script)
assert output["blocked"]["lease_skipped"] is True
assert output["takeover"] == "taken-over"
assert output["original"] == "first-done"
assert output["after"] == "after-release"
assert output["lease"] is None
def test_queue_change_notifications_cross_tabs_and_can_unsubscribe():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[];
function channelFactory() {{
const channel={{onmessage:null,postMessage(message){{channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data:message}}));}},close(){{}}}};
channels.push(channel); return channel;
}}
const first=createCoordinator({{storage,channelFactory,tabId:'first'}});
const second=createCoordinator({{storage,channelFactory,tabId:'second'}});
const seen=[];
const unsubscribe=second.subscribe(change=>seen.push(change));
first.notify('issue');
first.notify('authored');
unsubscribe();
first.notify('issue');
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
"""
output = run_node(script)
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
assert output["signal"]["queue"] == "issue"
assert output["signal"]["tabId"] == "first"
@pytest.mark.anyio
async def test_dashboard_wires_cross_tab_draft_refresh_and_precaches_coordinator():
html = await dashboard()
worker = (ROOT / "frontend" / "service-worker.js").read_text()
assert '<script src="static/outbox-coordinator.js"></script>' in html
assert "const outboxCoordinator = createOutboxCoordinator" in html
assert "coordinator: outboxCoordinator" in html
assert "outboxCoordinator.subscribe" in html
assert "refreshMyWorkView()" in html
assert "BASE + 'static/outbox-coordinator.js'" in worker

View File

@ -63,10 +63,10 @@ async function dispatch(name, request) {{
return json.loads(completed.stdout) return json.loads(completed.stdout)
def test_cross_tab_outbox_coordination_ships_in_a_new_shell_cache(): def test_review_to_merge_flow_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v10" in source assert "stackchain-dashboard-shell-v9" in source
def test_install_precaches_complete_subpath_scoped_app_shell(): def test_install_precaches_complete_subpath_scoped_app_shell():
@ -89,7 +89,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-preview.js", "/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",
"/dashboard/static/drafts.js", "/dashboard/static/drafts.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js", "/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js", "/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js", "/dashboard/static/offline-work.js",