diff --git a/frontend/mobile-first-task.js b/frontend/mobile-first-task.js index 215db54..3d41561 100644 --- a/frontend/mobile-first-task.js +++ b/frontend/mobile-first-task.js @@ -102,7 +102,6 @@ function completeOutcome() { if (state() !== 'coaching') return false; store('complete'); - options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete')); renderCoach(); if (options.receipt) { options.receipt.hidden = false; @@ -128,5 +127,6 @@ options.eventTarget?.addEventListener('offline', render); } + globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching'; return {required, open, refresh, render, start, completeOutcome, adoptRemote}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e7b945b..b0e22f7 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/frontend/today-completion.js b/frontend/today-completion.js index 30dcf15..b00ce7b 100644 --- a/frontend/today-completion.js +++ b/frontend/today-completion.js @@ -1,4 +1,5 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null, + completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(), now = Date.now, ttlMs = 10000, onOffer = null, onClear = null, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, 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.'); 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(); refresh(); warm(); diff --git a/frontend/today-sync.js b/frontend/today-sync.js index a32ae4a..7d97630 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -13,6 +13,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl let discardedCount = 0; let recoveryNotice = { discarded: 0, until: 0 }; const knownOperationKeys = new Set(); + const operationRecordKeys = new Map(); function cancelRetry() { if (retryTimer !== null) clearTimer?.(retryTimer); @@ -131,11 +132,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl discardedCount += 1; continue; } - const operation = record?.operation; - const valid = operation && typeof operation.operation_id === 'string' && + const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation]; + const valid = operations.length && operations.every(operation => operation && + typeof operation.operation_id === 'string' && ['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && - typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at)); - if (valid) records.push({ ...record, recordKey }); + typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at)); + 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 { storage.removeItem(recordKey); knownOperationKeys.delete(recordKey); @@ -170,10 +175,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl function removeOperation(operationId) { const storageKey = key(); if (!storageKey || !storage) return false; - const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId); + const recordKey = operationRecordKeys.get(operationId) || + storageKey + '.operation.' + encodeURIComponent(operationId); try { storage.removeItem(recordKey); knownOperationKeys.delete(recordKey); + operationRecordKeys.delete(operationId); return true; } catch (_error) { return false; @@ -186,6 +193,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl 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 = {}) { const operations = pending(); 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, base_revision: Math.max(0, savedRevision()), ...fields, }; - const storageKey = key(); - if (!storageKey || !storage) return false; - const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); - let saved = false; - try { - storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length })); - knownOperationKeys.add(recordKey); - saved = true; - coordinator?.notify('today'); - } catch (_error) { /* Report the persistence failure below. */ } - onStatus?.(saved ? 'pending' : 'error'); - return saved; + return saveOperations([operation], now() + operations.length); + } + + function enqueueBatch(specifications) { + const operations = specifications.map(specification => ({ + operation_id: operationId(), direction: specification.direction ?? null, + base_revision: Math.max(0, savedRevision()), ...specification, + })); + return saveOperations(operations); } function enqueueConfiguration(capacityMinutes, estimates) { @@ -354,7 +378,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl 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; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 3e00e33..1d2b9c0 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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 button { min-height:44px;' in html 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 diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 1aff82b..cbbf381 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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(): 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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 65f6f95..14fad4b 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 pre { max-width:100%; overflow-x:auto;" 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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 9546762..a21ab0a 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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])) 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(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index a4fdbd5..885b063 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard 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-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_first_task.py b/tests/test_mobile_first_task.py index 7d04d3d..fefa5d8 100644 --- a/tests/test_mobile_first_task.py +++ b/tests/test_mobile_first_task.py @@ -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 async def test_dashboard_renders_and_wires_phone_safe_first_task_activation(): html = await dashboard() diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 5ff5d13..8984a91 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): 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 diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 54e9f39..d7b362a 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -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 "max-width:100%; overflow-wrap:anywhere;" in html 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 diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index cc54b18..4b19a67 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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(): 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-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 97199ec..b401f08 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -168,13 +168,13 @@ async function dispatchPush(payload) {{ def test_offline_activation_migration_rolls_the_shell_cache(): 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(): 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/dashboard.js'" 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(): 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/authored-outbox.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(): 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-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(): 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 def test_offline_review_next_ships_today_completion_atomically(): 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/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(): 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/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(): 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/checklist-conflict.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(): 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 def test_navigation_deadline_ships_in_a_new_shell_cache(): 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.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(): 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 def test_mobile_search_viewport_ships_in_a_new_offline_shell(): 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 def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): 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 @@ -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(): 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 diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 51453ee..099cf54 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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(): 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 diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index aefea3e..7826cf1 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -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(): script = f""" 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(): 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 diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 0e4d01c..01ba0ee 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -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(): script = f""" const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});