feat: make Today completion durably atomic (Closes #1150)
This commit is contained in:
parent
f785134dfe
commit
d0780a72a7
|
|
@ -102,7 +102,6 @@
|
||||||
function completeOutcome() {
|
function completeOutcome() {
|
||||||
if (state() !== 'coaching') return false;
|
if (state() !== 'coaching') return false;
|
||||||
store('complete');
|
store('complete');
|
||||||
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
|
||||||
renderCoach();
|
renderCoach();
|
||||||
if (options.receipt) {
|
if (options.receipt) {
|
||||||
options.receipt.hidden = false;
|
options.receipt.hidden = false;
|
||||||
|
|
@ -128,5 +127,6 @@
|
||||||
options.eventTarget?.addEventListener('offline', render);
|
options.eventTarget?.addEventListener('offline', render);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
|
||||||
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
const CACHE = 'stackchain-dashboard-shell-v124';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
||||||
|
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
|
||||||
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
||||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
||||||
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
||||||
|
|
@ -23,7 +24,18 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
|
||||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
todaySync.enqueue('remove', todayWork.identity(item));
|
const operations = [
|
||||||
|
{action:'remove', item_id:identity},
|
||||||
|
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
|
||||||
|
];
|
||||||
|
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
|
||||||
|
todaySync.enqueue('remove', identity);
|
||||||
|
if (!admitted) {
|
||||||
|
todayWork.restore?.(snapshot);
|
||||||
|
announce(options.admissionFailureMessage ||
|
||||||
|
'Device storage is full. Free space and try again.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
todaySync.flush();
|
todaySync.flush();
|
||||||
refresh();
|
refresh();
|
||||||
warm();
|
warm();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
let discardedCount = 0;
|
let discardedCount = 0;
|
||||||
let recoveryNotice = { discarded: 0, until: 0 };
|
let recoveryNotice = { discarded: 0, until: 0 };
|
||||||
const knownOperationKeys = new Set();
|
const knownOperationKeys = new Set();
|
||||||
|
const operationRecordKeys = new Map();
|
||||||
|
|
||||||
function cancelRetry() {
|
function cancelRetry() {
|
||||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||||
|
|
@ -131,11 +132,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
discardedCount += 1;
|
discardedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const operation = record?.operation;
|
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
|
||||||
const valid = operation && typeof operation.operation_id === 'string' &&
|
const valid = operations.length && operations.every(operation => operation &&
|
||||||
|
typeof operation.operation_id === 'string' &&
|
||||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||||
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
|
||||||
if (valid) records.push({ ...record, recordKey });
|
if (valid) operations.forEach((operation, index) => {
|
||||||
|
operationRecordKeys.set(operation.operation_id, recordKey);
|
||||||
|
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
|
||||||
|
});
|
||||||
else {
|
else {
|
||||||
storage.removeItem(recordKey);
|
storage.removeItem(recordKey);
|
||||||
knownOperationKeys.delete(recordKey);
|
knownOperationKeys.delete(recordKey);
|
||||||
|
|
@ -170,10 +175,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
function removeOperation(operationId) {
|
function removeOperation(operationId) {
|
||||||
const storageKey = key();
|
const storageKey = key();
|
||||||
if (!storageKey || !storage) return false;
|
if (!storageKey || !storage) return false;
|
||||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
const recordKey = operationRecordKeys.get(operationId) ||
|
||||||
|
storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||||
try {
|
try {
|
||||||
storage.removeItem(recordKey);
|
storage.removeItem(recordKey);
|
||||||
knownOperationKeys.delete(recordKey);
|
knownOperationKeys.delete(recordKey);
|
||||||
|
operationRecordKeys.delete(operationId);
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -186,6 +193,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveOperations(operations, queuedAt = now()) {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey || !storage) return false;
|
||||||
|
const batched = operations.length > 1;
|
||||||
|
const recordKey = storageKey + '.operation.' + (batched ? 'batch-' : '') +
|
||||||
|
encodeURIComponent(operations[0].operation_id);
|
||||||
|
try {
|
||||||
|
storage.setItem(recordKey, JSON.stringify(batched ?
|
||||||
|
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
|
||||||
|
knownOperationKeys.add(recordKey);
|
||||||
|
operations.forEach(operation => operationRecordKeys.set(operation.operation_id, recordKey));
|
||||||
|
coordinator?.notify('today');
|
||||||
|
onStatus?.('pending');
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
onStatus?.('error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function enqueue(action, itemId, direction = null, fields = {}) {
|
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||||
const operations = pending();
|
const operations = pending();
|
||||||
if (action === 'remove' && operations.some(operation =>
|
if (action === 'remove' && operations.some(operation =>
|
||||||
|
|
@ -198,18 +225,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
operation_id: operationId(), action, item_id: itemId, direction,
|
operation_id: operationId(), action, item_id: itemId, direction,
|
||||||
base_revision: Math.max(0, savedRevision()), ...fields,
|
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||||
};
|
};
|
||||||
const storageKey = key();
|
return saveOperations([operation], now() + operations.length);
|
||||||
if (!storageKey || !storage) return false;
|
}
|
||||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
|
||||||
let saved = false;
|
function enqueueBatch(specifications) {
|
||||||
try {
|
const operations = specifications.map(specification => ({
|
||||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length }));
|
operation_id: operationId(), direction: specification.direction ?? null,
|
||||||
knownOperationKeys.add(recordKey);
|
base_revision: Math.max(0, savedRevision()), ...specification,
|
||||||
saved = true;
|
}));
|
||||||
coordinator?.notify('today');
|
return saveOperations(operations);
|
||||||
} catch (_error) { /* Report the persistence failure below. */ }
|
|
||||||
onStatus?.(saved ? 'pending' : 'error');
|
|
||||||
return saved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||||
|
|
@ -354,7 +378,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
if (change.queue === 'today' && pending().length) flush();
|
if (change.queue === 'today' && pending().length) flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v123" in worker
|
assert "stackchain-dashboard-shell-v124" in worker
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,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-v123" in worker
|
assert "stackchain-dashboard-shell-v124" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ 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-v123" in worker
|
assert "stackchain-dashboard-shell-v124" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v123" in worker
|
assert "stackchain-dashboard-shell-v124" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,34 @@ process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_task_exposes_completion_operation_before_showing_local_success():
|
||||||
|
result = run_node(
|
||||||
|
"""
|
||||||
|
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
||||||
|
let dispatched = 0;
|
||||||
|
const controller = createFirstTask({
|
||||||
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
|
||||||
|
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
|
||||||
|
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
||||||
|
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
||||||
|
eventTarget:{dispatchEvent:()=>{dispatched += 1}}, setTimer:()=>({unref(){}}),
|
||||||
|
});
|
||||||
|
const before = globalThis.stackchainFirstTaskCompleting();
|
||||||
|
const globalProbe = globalThis.stackchainFirstTaskCompleting();
|
||||||
|
const completed = controller.completeOutcome();
|
||||||
|
process.stdout.write(JSON.stringify({before,globalProbe,completed,stored:values.get('stackchain.first-task.v1:timmy'),dispatched}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"before": True,
|
||||||
|
"globalProbe": True,
|
||||||
|
"completed": True,
|
||||||
|
"stored": "complete",
|
||||||
|
"dispatched": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in worker
|
assert "stackchain-dashboard-shell-v124" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v123" in service_worker
|
assert "stackchain-dashboard-shell-v124" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
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-v123" in source
|
assert "stackchain-dashboard-shell-v124" 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
|
||||||
|
|
@ -249,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
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-v123" in source
|
assert "stackchain-dashboard-shell-v124" 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-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1137,7 +1137,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v123';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v124';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,74 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_batch_admission_leaves_no_partial_operations_when_storage_fails():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map(); let writes = 0; let sequence = 0;
|
||||||
|
const storage = {{
|
||||||
|
get length() {{ return values.size; }}, key: index => [...values.keys()][index] || null,
|
||||||
|
getItem: key => values.get(key) || null,
|
||||||
|
setItem: (key, value) => {{ writes += 1; throw new Error('quota'); }},
|
||||||
|
removeItem: key => values.delete(key),
|
||||||
|
}};
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
storage, getLogin: () => 'timmy', createOperationId: () => 'batch-' + (++sequence),
|
||||||
|
fetchJson: async () => {{ throw new Error('must not deliver'); }}, onStatus: () => {{}},
|
||||||
|
}});
|
||||||
|
const admitted = sync.enqueueBatch([
|
||||||
|
{{action:'remove', item_id:'issue:r:1:'}},
|
||||||
|
{{action:'activate', item_id:'first-task', activation_state:'complete'}},
|
||||||
|
]);
|
||||||
|
process.stdout.write(JSON.stringify({{admitted, writes, pending:sync.pending(), keys:[...values.keys()]}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"admitted": False,
|
||||||
|
"writes": 1,
|
||||||
|
"pending": [],
|
||||||
|
"keys": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_batch_delivers_remove_and_activation_together_then_clears_receipts():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map(); let sequence = 0; const patches = [];
|
||||||
|
const storage = {{
|
||||||
|
get length() {{ return values.size; }}, key: index => [...values.keys()][index] || null,
|
||||||
|
getItem: key => values.get(key) || null, setItem: (key,value) => values.set(key,value),
|
||||||
|
removeItem: key => values.delete(key),
|
||||||
|
}};
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
storage, getLogin: () => 'timmy', createOperationId: () => 'batch-' + (++sequence),
|
||||||
|
fetchJson: async (_url, options={{}}) => {{
|
||||||
|
const operations = JSON.parse(options.body).operations; patches.push(operations);
|
||||||
|
return {{revision:3, ids:[], first_task_state:'complete',
|
||||||
|
accepted_operation_ids:operations.map(item => item.operation_id)}};
|
||||||
|
}}, onRemoteIds: () => {{}}, onStatus: () => {{}},
|
||||||
|
}});
|
||||||
|
const admitted = sync.enqueueBatch([
|
||||||
|
{{action:'remove', item_id:'issue:r:1:'}},
|
||||||
|
{{action:'activate', item_id:'first-task', activation_state:'complete'}},
|
||||||
|
]);
|
||||||
|
const before = sync.pending();
|
||||||
|
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{
|
||||||
|
admitted, before, patches, after:sync.pending(), operationKeys:[...values.keys()].filter(key=>key.includes('.operation.')),
|
||||||
|
}}));}})();
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
assert data["admitted"] is True
|
||||||
|
assert [[item["action"] for item in batch] for batch in data["patches"]] == [["remove", "activate"]]
|
||||||
|
assert data["before"] == data["patches"][0]
|
||||||
|
assert data["after"] == []
|
||||||
|
assert data["operationKeys"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_first_task_activation_uses_today_outbox_and_adopts_remote_completion():
|
def test_first_task_activation_uses_today_outbox_and_adopts_remote_completion():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
|
@ -228,7 +296,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v123" in source
|
assert "stackchain-dashboard-shell-v124" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,43 @@ process.stdout.write(JSON.stringify({{completed, calls}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_for_today_restores_plan_and_keeps_session_when_durable_batch_admission_fails():
|
||||||
|
script = f"""
|
||||||
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||||
|
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||||
|
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 todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
|
||||||
|
const item = {{kind:'issue', repository:'r', number:1}};
|
||||||
|
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
|
||||||
|
const calls = []; let offered = false;
|
||||||
|
globalThis.stackchainFirstTaskCompleting = () => true;
|
||||||
|
const complete = createTodayCompletion({{
|
||||||
|
todayWork,
|
||||||
|
todaySync: {{enqueueBatch: operations => {{calls.push(['batch', operations]); return false;}},
|
||||||
|
flush: () => calls.push(['flush'])}},
|
||||||
|
refresh: () => calls.push(['refresh']), warm: () => calls.push(['warm']),
|
||||||
|
workSession: {{complete: () => calls.push(['advance'])}},
|
||||||
|
announce: message => calls.push(['announce', message]), onOffer: () => {{offered = true;}},
|
||||||
|
}});
|
||||||
|
const completed = complete(item);
|
||||||
|
process.stdout.write(JSON.stringify({{completed, ids:todayWork.read(), calls, offered}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"completed": False,
|
||||||
|
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||||
|
"calls": [
|
||||||
|
["batch", [
|
||||||
|
{"action": "remove", "item_id": "issue:r:1:"},
|
||||||
|
{"action": "activate", "item_id": "first-task", "activation_state": "complete"},
|
||||||
|
]],
|
||||||
|
["announce", "Device storage is full. Free space and try again."],
|
||||||
|
],
|
||||||
|
"offered": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_today_completion_reports_context_specific_success_and_partial_failure():
|
def test_today_completion_reports_context_specific_success_and_partial_failure():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user