Merge pull request 'Resume queued delivery after operator sign-in' (#267) from timmy/266-resume-queued-delivery into main
All checks were successful
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s

This commit is contained in:
timmy 2026-08-08 05:34:02 +00:00
commit 741d768fc3
7 changed files with 93 additions and 6 deletions

View File

@ -152,6 +152,10 @@ work finishes**; permission is requested only from that user gesture and the cho
is stored for the confirmed account in the private background outbox database. is stored for the confirmed account in the private background outbox database.
Successful deliveries produce privacy-safe receipts that open the created issue or Successful deliveries produce privacy-safe receipts that open the created issue or
source conversation, while permanent validation failures open Drafts for recovery. source conversation, while permanent validation failures open Drafts for recovery.
Authentication expiry during a worker mutation is recoverable: the worker releases the
claim without creating an Attention receipt, stops that drain, and keeps the original
idempotency key. Loading the authenticated dashboard after signing in asks the worker
to resume queued delivery automatically.
Notification text never includes issue titles, comment bodies, or validation details. Notification text never includes issue titles, comment bodies, or validation details.
The preference is off by default, unsupported or denied browsers retain foreground The preference is off by default, unsupported or denied browsers retain foreground
reconciliation, and **Sign out & clear this device** removes the account-bound choice. reconciliation, and **Sign out & clear this device** removes the account-bound choice.

View File

@ -224,6 +224,10 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
} catch (error) { } catch (error) {
const status = Number(error?.status || 0); const status = Number(error?.status || 0);
if (status === 401) {
await store.release(item.id);
throw error;
}
if (status >= 400 && status < 500) { if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240)); await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
return { attention: true, error, receipt: receiptFor(item, 'attention') }; return { attention: true, error, receipt: receiptFor(item, 'attention') };

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v17'; const CACHE = 'stackchain-dashboard-shell-v18';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,
@ -96,6 +96,10 @@ self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
}); });
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
});
self.addEventListener('notificationclick', event => { self.addEventListener('notificationclick', event => {
event.notification.close(); event.notification.close();
const route = String(event.notification.data?.route || ''); const route = String(event.notification.data?.route || '');

View File

@ -12,6 +12,7 @@
sessionStorage: root.sessionStorage, sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB, indexedDB: root.indexedDB,
caches: root.caches, caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
location: root.location, location: root.location,
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
}); });
@ -19,13 +20,14 @@
const attach = () => { const attach = () => {
const button = root.document.getElementById('sign-out'); const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut()); if (button) button.addEventListener('click', () => boundary.signOut());
boundary.resumeQueuedWork();
}; };
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach(); else attach();
root.stackchainSession = boundary; root.stackchainSession = boundary;
} }
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location, cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
onExpired = () => {}, onExpired = () => {},
}) { }) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@ -88,5 +90,12 @@
} }
} }
return { fetch: sessionFetch, signOut, clearPrivateDeviceData }; async function resumeQueuedWork() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
}); });

View File

@ -260,6 +260,44 @@ const fetchJson = async url => {{
assert output["result"]["blocked"] == 1 assert output["result"]["blocked"] == 1
def test_session_expiry_during_delivery_releases_claim_without_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const queued = [
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
];
const state = {{released:[],failed:[],mutationKeys:[]}};
const store = {{
claimNext: async () => queued.shift() || null,
release: async id => state.released.push(id),
fail: async (id,message) => state.failed.push({{id,message}}),
countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
state.mutationKeys.push(options.headers['Idempotency-Key']);
const error = new Error('Authentication required'); error.status = 401; throw error;
}};
(async () => {{
let error = null;
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output == {
"state": {
"released": ["capture-auth"],
"failed": [],
"mutationKeys": ["stable-key"],
},
"error": {"message": "Authentication required", "status": 401},
}
def test_transient_delivery_failure_releases_claim_and_requests_another_sync(): def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
script = f""" script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict: def run_session_scenario(scenario: str) -> dict:
harness = f""" harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }}; const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }};
const storage = {{ const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }}, get length() {{ return this.values.size; }},
@ -33,6 +33,7 @@ const boundary = createSessionBoundary({{
sessionStorage: storage, sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }}, indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }}, caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }}, location: {{ assign: value => {{ state.assigned = value; }} }},
}}); }});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
@ -72,6 +73,17 @@ process.stdout.write(JSON.stringify(state));
assert result["assigned"] == "/dashboard/login" assert result["assigned"] == "/dashboard/login"
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
result = run_session_scenario(
"""
await boundary.resumeQueuedWork();
process.stdout.write(JSON.stringify(state));
"""
)
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
@pytest.mark.anyio @pytest.mark.anyio
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
html = await dashboard() html = await dashboard()

View File

@ -68,6 +68,11 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }}); listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending; if (pending) await pending;
}} }}
async function dispatchMessage(data) {{
let pending;
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{ async function dispatchNotificationClick(route) {{
let pending; let pending;
listeners.notificationclick({{ listeners.notificationclick({{
@ -86,10 +91,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout) return json.loads(completed.stdout)
def test_background_delivery_receipts_ship_in_a_new_shell_cache(): def test_session_resume_flow_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v17" in source assert "stackchain-dashboard-shell-v18" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@ -104,6 +109,17 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
assert result["backgroundFlushes"] == 1 assert result["backgroundFlushes"] == 1
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-resume-outbox'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["backgroundFlushes"] == 1
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route(): def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario( result = run_worker_scenario(
""" """