Compare commits
No commits in common. "ca520d2eb6a3a7865595c93c3f090fcbec5dd23b" and "8a57ea73fb0c86245c292ebfb3ad2c7ff78d5f3c" have entirely different histories.
ca520d2eb6
...
8a57ea73fb
|
|
@ -285,7 +285,6 @@
|
||||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||||
<div class="small" id="today-session-sync-status" role="status" aria-live="polite" hidden></div>
|
|
||||||
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,9 @@ function createTodaySessionSync({
|
||||||
let current = null;
|
let current = null;
|
||||||
let ownedRevision = 0;
|
let ownedRevision = 0;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let requestTail = Promise.resolve();
|
|
||||||
let latestSnapshot = null;
|
|
||||||
let publishRequested = 0;
|
|
||||||
let publishSent = 0;
|
|
||||||
const endpoint = 'api/v1/today/session';
|
const endpoint = 'api/v1/today/session';
|
||||||
const deviceId = () => String(getDeviceId?.() || '').trim();
|
const deviceId = () => String(getDeviceId?.() || '').trim();
|
||||||
|
|
||||||
function serialize(operation) {
|
|
||||||
const result = requestTail.then(operation, operation);
|
|
||||||
requestTail = result.catch(() => null);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adopt(session) {
|
function adopt(session) {
|
||||||
if (!session || !Number.isInteger(session.revision)) return null;
|
if (!session || !Number.isInteger(session.revision)) return null;
|
||||||
const previousOwned = current?.device_id === deviceId() && current?.running;
|
const previousOwned = current?.device_id === deviceId() && current?.running;
|
||||||
|
|
@ -37,9 +27,8 @@ function createTodaySessionSync({
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshNow() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
onStatus('syncing');
|
|
||||||
const session = await fetchJson(endpoint);
|
const session = await fetchJson(endpoint);
|
||||||
onStatus('online');
|
onStatus('online');
|
||||||
return adopt(session);
|
return adopt(session);
|
||||||
|
|
@ -49,39 +38,29 @@ function createTodaySessionSync({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = () => serialize(refreshNow);
|
async function claim() {
|
||||||
|
if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
||||||
function claim() {
|
try {
|
||||||
return serialize(async () => {
|
const session = await fetchJson(endpoint, {
|
||||||
if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||||
try {
|
body:JSON.stringify({
|
||||||
onStatus('syncing');
|
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||||
const session = await fetchJson(endpoint, {
|
elapsed_ms:current.elapsed_ms, running:true,
|
||||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
}),
|
||||||
body:JSON.stringify({
|
});
|
||||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
adopt(session);
|
||||||
elapsed_ms:current.elapsed_ms, running:true,
|
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||||
}),
|
return session;
|
||||||
});
|
} catch (error) {
|
||||||
adopt(session);
|
onStatus('conflict', error);
|
||||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
await refresh();
|
||||||
return session;
|
return null;
|
||||||
} catch (error) {
|
}
|
||||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
|
||||||
onStatus('conflict', error);
|
|
||||||
await refreshNow();
|
|
||||||
} else {
|
|
||||||
onStatus('offline', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishNow(snapshot) {
|
async function publish(snapshot = timer?.snapshot?.()) {
|
||||||
if (!snapshot?.identity || !deviceId()) return null;
|
if (!snapshot?.identity || !deviceId()) return null;
|
||||||
try {
|
try {
|
||||||
onStatus('syncing');
|
|
||||||
const session = await fetchJson(endpoint, {
|
const session = await fetchJson(endpoint, {
|
||||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({
|
body:JSON.stringify({
|
||||||
|
|
@ -93,36 +72,11 @@ function createTodaySessionSync({
|
||||||
adopt(session);
|
adopt(session);
|
||||||
return session;
|
return session;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
onStatus('offline', error);
|
||||||
onStatus('conflict', error);
|
|
||||||
await refreshNow();
|
|
||||||
} else {
|
|
||||||
onStatus('offline', error);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function publish(snapshot = timer?.snapshot?.()) {
|
|
||||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
|
||||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
|
||||||
latestSnapshot = snapshot;
|
|
||||||
publishRequested += 1;
|
|
||||||
return serialize(async () => {
|
|
||||||
if (publishSent === publishRequested) return current;
|
|
||||||
if (current?.device_id && current.device_id !== deviceId()) {
|
|
||||||
publishSent = publishRequested;
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const version = publishRequested;
|
|
||||||
const result = await publishNow(latestSnapshot);
|
|
||||||
publishSent = current?.device_id && current.device_id !== deviceId()
|
|
||||||
? publishRequested
|
|
||||||
: version;
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pulse = () => current?.device_id === deviceId() && current?.running
|
const pulse = () => current?.device_id === deviceId() && current?.running
|
||||||
? publish()
|
? publish()
|
||||||
: refresh();
|
: refresh();
|
||||||
|
|
@ -159,17 +113,8 @@ function attachTodaySessionHandoff({
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
let offered = null;
|
let offered = null;
|
||||||
const sessionStatus = qs('#today-session-sync-status');
|
|
||||||
const showSessionStatus = message => {
|
|
||||||
sessionStatus.textContent = message;
|
|
||||||
sessionStatus.hidden = !message;
|
|
||||||
};
|
|
||||||
const sync = createTodaySessionSync({
|
const sync = createTodaySessionSync({
|
||||||
fetchJson, getDeviceId, timer,
|
fetchJson, getDeviceId, timer,
|
||||||
onStatus:state => showSessionStatus(
|
|
||||||
state === 'syncing' ? 'Session syncing…' :
|
|
||||||
(state === 'offline' ? 'Session offline · will retry.' : '')
|
|
||||||
),
|
|
||||||
onRemote:session => {
|
onRemote:session => {
|
||||||
offered = session;
|
offered = session;
|
||||||
const handoff = qs('#today-session-handoff');
|
const handoff = qs('#today-session-handoff');
|
||||||
|
|
@ -181,7 +126,6 @@ function attachTodaySessionHandoff({
|
||||||
`${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
`${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||||
},
|
},
|
||||||
onTransferred:() => {
|
onTransferred:() => {
|
||||||
showSessionStatus('Session continued on another device.');
|
|
||||||
announce('Today continued on another device. Timer paused here.');
|
announce('Today continued on another device. Timer paused here.');
|
||||||
renderTimer();
|
renderTimer();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,9 @@ def test_dashboard_packages_a_mobile_today_session_handoff():
|
||||||
|
|
||||||
assert 'id="today-session-handoff"' in html
|
assert 'id="today-session-handoff"' in html
|
||||||
assert 'id="continue-today-session"' in html
|
assert 'id="continue-today-session"' in html
|
||||||
assert 'id="today-session-sync-status"' in html
|
|
||||||
assert 'aria-live="polite"' in html
|
assert 'aria-live="polite"' in html
|
||||||
assert "createTodaySessionSync" in today_bundle
|
assert "createTodaySessionSync" in today_bundle
|
||||||
assert "attachTodaySessionHandoff" in today_bundle
|
assert "attachTodaySessionHandoff" in today_bundle
|
||||||
assert "Session syncing…" in today_bundle
|
|
||||||
assert "Session offline · will retry." in today_bundle
|
|
||||||
assert "Session continued on another device." in today_bundle
|
|
||||||
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
|
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
|
||||||
assert "todaySessionSync?.publish(snapshot)" in dashboard
|
assert "todaySessionSync?.publish(snapshot)" in dashboard
|
||||||
assert "min-height:44px" in css
|
assert "min-height:44px" in css
|
||||||
|
|
|
||||||
|
|
@ -119,199 +119,3 @@ const sync = createTodaySessionSync({
|
||||||
"running": True,
|
"running": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_publish_conflict_adopts_remote_owner_and_pauses_once():
|
|
||||||
result = run_node(
|
|
||||||
r"""
|
|
||||||
const calls = [];
|
|
||||||
const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
||||||
const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500};
|
|
||||||
let pauses = 0;
|
|
||||||
const transfers = [];
|
|
||||||
const statuses = [];
|
|
||||||
let reads = 0;
|
|
||||||
const sync = createTodaySessionSync({
|
|
||||||
getDeviceId:()=> 'desktop-a',
|
|
||||||
fetchJson:async (url, options={}) => {
|
|
||||||
calls.push(options.method || 'GET');
|
|
||||||
if (options.method === 'PATCH') {
|
|
||||||
const error = new Error('session changed');
|
|
||||||
error.status = 409;
|
|
||||||
error.code = 'session_changed';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
reads += 1;
|
|
||||||
return reads === 1 ? owned : remote;
|
|
||||||
},
|
|
||||||
timer:{
|
|
||||||
snapshot:()=>({identity:owned.identity, elapsed_ms:1500, running:true}),
|
|
||||||
pause:()=>{pauses += 1;},
|
|
||||||
},
|
|
||||||
onTransferred:session=>transfers.push(session.device_id),
|
|
||||||
onStatus:status=>statuses.push(status),
|
|
||||||
});
|
|
||||||
(async()=>{
|
|
||||||
await sync.refresh();
|
|
||||||
await sync.publish();
|
|
||||||
await sync.refresh();
|
|
||||||
process.stdout.write(JSON.stringify({calls, pauses, transfers, statuses, session:sync.session()}));
|
|
||||||
})().catch(error=>{console.error(error);process.exit(1);});
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["calls"] == ["GET", "PATCH", "GET", "GET"]
|
|
||||||
assert result["session"]["device_id"] == "phone-b"
|
|
||||||
assert result["pauses"] == 1
|
|
||||||
assert result["transfers"] == ["phone-b"]
|
|
||||||
assert "conflict" in result["statuses"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rapid_publishes_are_single_flight_and_coalesce_latest_snapshot():
|
|
||||||
result = run_node(
|
|
||||||
r"""
|
|
||||||
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:0, running:true};
|
|
||||||
const bodies = [];
|
|
||||||
let active = 0;
|
|
||||||
let peak = 0;
|
|
||||||
let releaseFirst;
|
|
||||||
let patchCount = 0;
|
|
||||||
const firstPatch = new Promise(resolve=>{releaseFirst=resolve;});
|
|
||||||
const sync = createTodaySessionSync({
|
|
||||||
getDeviceId:()=> 'desktop-a',
|
|
||||||
fetchJson:async (url, options={}) => {
|
|
||||||
if (!options.method) return owned;
|
|
||||||
active += 1;
|
|
||||||
peak = Math.max(peak, active);
|
|
||||||
patchCount += 1;
|
|
||||||
const body = JSON.parse(options.body);
|
|
||||||
bodies.push(body);
|
|
||||||
if (patchCount === 1) await firstPatch;
|
|
||||||
active -= 1;
|
|
||||||
return {...owned, revision:1 + patchCount, elapsed_ms:body.elapsed_ms, running:body.running};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
(async()=>{
|
|
||||||
await sync.refresh();
|
|
||||||
const first = sync.publish({identity:owned.identity, elapsed_ms:1000, running:true});
|
|
||||||
await Promise.resolve();
|
|
||||||
const middle = sync.publish({identity:owned.identity, elapsed_ms:2000, running:false});
|
|
||||||
const latest = sync.publish({identity:owned.identity, elapsed_ms:3000, running:true});
|
|
||||||
releaseFirst();
|
|
||||||
await Promise.all([first, middle, latest]);
|
|
||||||
process.stdout.write(JSON.stringify({peak, bodies, session:sync.session()}));
|
|
||||||
})().catch(error=>{console.error(error);process.exit(1);});
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["peak"] == 1
|
|
||||||
assert [body["elapsed_ms"] for body in result["bodies"]] == [1000, 3000]
|
|
||||||
assert result["session"]["elapsed_ms"] == 3000
|
|
||||||
|
|
||||||
|
|
||||||
def test_remote_conflict_discards_queued_local_publishes_until_explicit_claim():
|
|
||||||
result = run_node(
|
|
||||||
r"""
|
|
||||||
const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
||||||
const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500};
|
|
||||||
const calls = [];
|
|
||||||
let reads = 0;
|
|
||||||
let releaseConflict;
|
|
||||||
const conflictReady = new Promise(resolve=>{releaseConflict=resolve;});
|
|
||||||
const sync = createTodaySessionSync({
|
|
||||||
getDeviceId:()=> 'desktop-a',
|
|
||||||
fetchJson:async (url, options={}) => {
|
|
||||||
calls.push(options.method || 'GET');
|
|
||||||
if (options.method === 'PATCH') {
|
|
||||||
await conflictReady;
|
|
||||||
const error = new Error('session changed');
|
|
||||||
error.status = 409;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
reads += 1;
|
|
||||||
return reads === 1 ? owned : remote;
|
|
||||||
},
|
|
||||||
timer:{pause:()=>{}},
|
|
||||||
});
|
|
||||||
(async()=>{
|
|
||||||
await sync.refresh();
|
|
||||||
const stale = sync.publish({identity:owned.identity, elapsed_ms:1500, running:true});
|
|
||||||
await Promise.resolve();
|
|
||||||
const queued = sync.publish({identity:owned.identity, elapsed_ms:2000, running:true});
|
|
||||||
releaseConflict();
|
|
||||||
await Promise.all([stale, queued]);
|
|
||||||
process.stdout.write(JSON.stringify({calls, session:sync.session()}));
|
|
||||||
})().catch(error=>{console.error(error);process.exit(1);});
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["calls"] == ["GET", "PATCH", "GET"]
|
|
||||||
assert result["session"]["device_id"] == "phone-b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_transient_failure_retains_latest_intent_for_retry():
|
|
||||||
result = run_node(
|
|
||||||
r"""
|
|
||||||
const owned = {revision:4, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
||||||
const bodies = [];
|
|
||||||
const statuses = [];
|
|
||||||
let attempts = 0;
|
|
||||||
const sync = createTodaySessionSync({
|
|
||||||
getDeviceId:()=> 'desktop-a',
|
|
||||||
fetchJson:async (url, options={}) => {
|
|
||||||
if (!options.method) return owned;
|
|
||||||
attempts += 1;
|
|
||||||
bodies.push(JSON.parse(options.body));
|
|
||||||
if (attempts === 1) {
|
|
||||||
const error = new Error('temporarily unavailable');
|
|
||||||
error.status = 503;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return {...owned, revision:5, elapsed_ms:bodies.at(-1).elapsed_ms};
|
|
||||||
},
|
|
||||||
onStatus:status=>statuses.push(status),
|
|
||||||
});
|
|
||||||
(async()=>{
|
|
||||||
await sync.refresh();
|
|
||||||
const first = await sync.publish({identity:owned.identity, elapsed_ms:4500, running:true});
|
|
||||||
const retried = await sync.publish();
|
|
||||||
process.stdout.write(JSON.stringify({first, retried, bodies, statuses}));
|
|
||||||
})().catch(error=>{console.error(error);process.exit(1);});
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["first"] is None
|
|
||||||
assert [body["elapsed_ms"] for body in result["bodies"]] == [4500, 4500]
|
|
||||||
assert result["retried"]["revision"] == 5
|
|
||||||
assert "offline" in result["statuses"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_transport_failure_stays_retryable_without_false_conflict():
|
|
||||||
result = run_node(
|
|
||||||
r"""
|
|
||||||
const remote = {revision:7, device_id:'phone-b', identity:'issue:r:42:', elapsed_ms:9000, running:true};
|
|
||||||
const calls = [];
|
|
||||||
const statuses = [];
|
|
||||||
const sync = createTodaySessionSync({
|
|
||||||
getDeviceId:()=> 'desktop-a',
|
|
||||||
fetchJson:async (url, options={}) => {
|
|
||||||
calls.push(options.method || 'GET');
|
|
||||||
if (!options.method) return remote;
|
|
||||||
const error = new Error('gateway unavailable');
|
|
||||||
error.status = 503;
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
onStatus:status=>statuses.push(status),
|
|
||||||
});
|
|
||||||
(async()=>{
|
|
||||||
await sync.refresh();
|
|
||||||
const claimed = await sync.claim();
|
|
||||||
process.stdout.write(JSON.stringify({claimed, calls, statuses}));
|
|
||||||
})().catch(error=>{console.error(error);process.exit(1);});
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["claimed"] is None
|
|
||||||
assert result["calls"] == ["GET", "PATCH"]
|
|
||||||
assert result["statuses"][-1] == "offline"
|
|
||||||
assert "conflict" not in result["statuses"]
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user