Retire completed work from the synced Today plan #360
|
|
@ -32,7 +32,11 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
|
||||||
the private content. Issue capture and authored mobile actions (issue
|
the private content. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
another worker replays a confirmed result instead of posting duplicate content. When
|
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||||
|
five-item Today plan syncs across the operator's devices. After a healthy, fully paginated
|
||||||
|
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||||
|
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||||
|
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||||
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
||||||
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
||||||
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
||||||
|
|
|
||||||
|
|
@ -811,8 +811,16 @@
|
||||||
});
|
});
|
||||||
activeMyWork = partitioned.active;
|
activeMyWork = partitioned.active;
|
||||||
laterMyWork = partitioned.later;
|
laterMyWork = partitioned.later;
|
||||||
|
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
|
||||||
|
!lastContextSnapshot?.error &&
|
||||||
|
!Object.values(workPagination).some(page => page?.has_more);
|
||||||
todayMyWork = todayWork.reconcile(lastMyWork, {
|
todayMyWork = todayWork.reconcile(lastMyWork, {
|
||||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
pruneMissing: authoritativeTodayReconciliation,
|
||||||
|
onPrune: retiredIds => {
|
||||||
|
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
|
||||||
|
if (queued) todaySync.flush();
|
||||||
|
return queued;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const counts = countMyWork(activeMyWork);
|
const counts = countMyWork(activeMyWork);
|
||||||
counts.today = todayMyWork.length;
|
counts.today = todayMyWork.length;
|
||||||
|
|
|
||||||
|
|
@ -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-v45';
|
const CACHE = 'stackchain-dashboard-shell-v46';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
|
|
||||||
function enqueue(action, itemId, direction = null) {
|
function enqueue(action, itemId, direction = null) {
|
||||||
const operations = pending();
|
const operations = pending();
|
||||||
|
if (action === 'remove' && operations.some(operation =>
|
||||||
|
operation.action === 'remove' && operation.item_id === itemId
|
||||||
|
)) {
|
||||||
|
onStatus?.('pending');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
|
operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
|
||||||
const saved = save(operations);
|
const saved = save(operations);
|
||||||
onStatus?.(saved ? 'pending' : 'error');
|
onStatus?.(saved ? 'pending' : 'error');
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||||
return write(ids);
|
return write(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcile(items, { pruneMissing = false } = {}) {
|
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
|
||||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||||
const ids = read();
|
const ids = read();
|
||||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||||
if (retained.length !== ids.length) write(retained);
|
const retired = ids.filter(id => !retained.includes(id));
|
||||||
|
if (retired.length) {
|
||||||
|
const reported = onPrune?.(retired);
|
||||||
|
if (reported !== false) write(retained);
|
||||||
|
}
|
||||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v45" in worker
|
assert "stackchain-dashboard-shell-v46" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v45" in worker
|
assert "stackchain-dashboard-shell-v46" in worker
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v45" in source
|
assert "stackchain-dashboard-shell-v46" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -117,14 +117,14 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v45" in source
|
assert "stackchain-dashboard-shell-v46" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v45" in source
|
assert "stackchain-dashboard-shell-v46" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,37 @@ sync.enqueue('move', 'issue:r:2:', 'up');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_pending_retirements_collapse_to_one_effective_remove():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map();
|
||||||
|
let sequence = 0;
|
||||||
|
const statuses = [];
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin: () => 'timmy', createOperationId: () => 'remove-' + (++sequence),
|
||||||
|
fetchJson: async () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:value=>statuses.push(value),
|
||||||
|
}});
|
||||||
|
const first = sync.enqueue('remove', 'issue:r:1:');
|
||||||
|
const duplicate = sync.enqueue('remove', 'issue:r:1:');
|
||||||
|
process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(), statuses}}));
|
||||||
|
"""
|
||||||
|
result = json.loads(
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||||
|
)
|
||||||
|
assert result == {
|
||||||
|
"first": True,
|
||||||
|
"duplicate": True,
|
||||||
|
"pending": [{
|
||||||
|
"operation_id": "remove-1",
|
||||||
|
"action": "remove",
|
||||||
|
"item_id": "issue:r:1:",
|
||||||
|
"direction": None,
|
||||||
|
}],
|
||||||
|
"statuses": ["pending", "pending"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_existing_device_queue_is_migrated_only_once():
|
def test_existing_device_queue_is_migrated_only_once():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,55 @@ process.stdout.write(JSON.stringify([
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
|
||||||
|
script = f"""
|
||||||
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||||
|
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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||||
|
const item = number => ({{kind:'issue', repository:'r', number}});
|
||||||
|
queue.add(item(1));
|
||||||
|
queue.add(item(2));
|
||||||
|
const reports = [];
|
||||||
|
const visible = queue.reconcile([item(2)], {{
|
||||||
|
pruneMissing: true,
|
||||||
|
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
|
||||||
|
}});
|
||||||
|
queue.reconcile([item(2)], {{
|
||||||
|
pruneMissing: true,
|
||||||
|
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"reports": [{
|
||||||
|
"retiredIds": ["issue:r:1:"],
|
||||||
|
"idsDuringReport": ["issue:r:1:", "issue:r:2:"],
|
||||||
|
}],
|
||||||
|
"visible": [2],
|
||||||
|
"stored": ["issue:r:2:"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_retirement_queue_keeps_local_identity_for_retry():
|
||||||
|
script = f"""
|
||||||
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||||
|
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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||||
|
const item = number => ({{kind:'issue', repository:'r', number}});
|
||||||
|
queue.add(item(1));
|
||||||
|
const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}});
|
||||||
|
process.stdout.write(JSON.stringify({{visible, stored:queue.read()}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"visible": [],
|
||||||
|
"stored": ["issue:r:1:"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_today_queue_adopts_bounded_server_order():
|
def test_today_queue_adopts_bounded_server_order():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||||
|
|
@ -115,6 +164,11 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||||
assert "todaySync.enqueue('add'" in html
|
assert "todaySync.enqueue('add'" in html
|
||||||
assert "todaySync.enqueue('remove'" in html
|
assert "todaySync.enqueue('remove'" in html
|
||||||
assert "todaySync.enqueue('move'" in html
|
assert "todaySync.enqueue('move'" in html
|
||||||
|
assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html
|
||||||
|
assert "!lastContextSnapshot?.error &&" in html
|
||||||
|
assert "onPrune: retiredIds =>" in html
|
||||||
|
assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html
|
||||||
|
assert "if (queued) todaySync.flush();" in html
|
||||||
assert "todaySync.flush();" in html
|
assert "todaySync.flush();" in html
|
||||||
assert "Another device filled Today · showing its saved plan." in html
|
assert "Another device filled Today · showing its saved plan." in html
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user