feat: hand off Today lock-screen actions privately (Closes #1172)
All checks were successful
CI / lint (pull_request) Successful in 3m4s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m2s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-20 08:02:45 +00:00
parent f56ca6373c
commit 988f6646c2
18 changed files with 228 additions and 68 deletions

View File

@ -6,6 +6,7 @@
'stackchain-voice-transcripts-v1',
'stackchain-search-reply-drafts-v1',
'stackchain-conversation-reply-drafts-v1',
'stackchain-today-action-mailbox-v1',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;

View File

@ -1,12 +1,67 @@
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-v125';
const CACHE = 'stackchain-dashboard-shell-v126';
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;
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
function createTodayActionStore() {
const dbName = 'stackchain-today-action-mailbox-v1';
const storeName = 'commands';
const open = () => new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'});
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.'));
});
return {
async put(clientId, command) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put({...command, clientId});
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
async claim(clientId, now) {
const database = await open();
let command = null;
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.get(clientId);
request.onsuccess = () => {
command = request.result || null;
if (command) store.delete(clientId);
};
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.'));
transaction.onabort = transaction.onerror;
});
database.close();
return command && command.expiresAt > now ? command : null;
},
async purge() {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).clear();
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
};
}
const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore();
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@ -473,6 +528,7 @@ self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
await todayActionStore.purge();
await deletePrivateDatabases();
await updateTodayLockScreen(false, false);
event.ports?.[0]?.postMessage({ ok: true });
@ -481,12 +537,33 @@ self.addEventListener('message', event => {
}
})());
if (event.data?.type === 'stackchain-today-lock-screen') {
event.waitUntil(updateTodayLockScreen(
event.data.active === true,
event.data.running === true,
String(event.data.actionToken || ''),
Number(event.data.breakDeadlineAt || 0)
));
event.waitUntil((async () => {
const active = event.data.active === true;
if (!active) await todayActionStore.purge();
await updateTodayLockScreen(
active,
event.data.running === true,
String(event.data.actionToken || ''),
Number(event.data.breakDeadlineAt || 0)
);
})());
}
if (event.data?.type === 'stackchain-claim-today-action') {
event.waitUntil((async () => {
const source = event.source;
if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return;
let command;
try { command = await todayActionStore.claim(source.id, Date.now()); }
catch (_error) { return; }
if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return;
if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return;
if (command.action === 'complete' && !command.actionToken) return;
source.postMessage?.({
type:'stackchain-today-timer-action',
action:command.action,
...(command.actionToken ? {actionToken:command.actionToken} : {}),
});
})());
}
});
@ -608,10 +685,17 @@ async function applyTodayTimerAction(action, actionToken = '') {
});
return client.focus?.();
}
const query = '?today_timer_action=' + action +
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
const target = new URL(BASE + query + route, self.location.origin).href;
return self.clients.openWindow(target);
const target = new URL(BASE + route, self.location.origin).href;
const opened = await self.clients.openWindow(target);
if (!opened?.id) return opened;
try {
await todayActionStore.put(opened.id, {
action,
...(actionToken ? {actionToken} : {}),
expiresAt:Date.now() + TODAY_ACTION_TTL_MS,
});
} catch (_error) { /* The clean Today route remains safe; fail the action closed. */ }
return opened;
}
self.addEventListener('notificationclick', event => {

View File

@ -149,16 +149,9 @@ function createTodayLockScreen({
},
consumeAction,
async consumeLaunchAction() {
let url;
try { url = new URL(locationRef?.href || ''); }
catch (_error) { return false; }
const action = url.searchParams.get('today_timer_action');
if (!['pause', 'resume', 'complete'].includes(action)) return false;
const token = url.searchParams.get('today_action_token') || '';
url.searchParams.delete('today_timer_action');
url.searchParams.delete('today_action_token');
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
return await consumeAction(action, token);
if (!enabled()) return false;
await post({type:'stackchain-claim-today-action'});
return true;
},
render,
};

View File

@ -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-v125" in worker
assert "stackchain-dashboard-shell-v126" in worker

View File

@ -364,6 +364,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@ -391,6 +392,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@ -523,6 +525,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@ -547,6 +550,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@ -644,6 +648,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"

View File

@ -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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -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-v125" in worker
assert "stackchain-dashboard-shell-v126" in worker

View File

@ -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-v125" in worker
assert "stackchain-dashboard-shell-v126" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -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-v125" in worker
assert "stackchain-dashboard-shell-v126" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -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-v125" in worker
assert "stackchain-dashboard-shell-v126" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -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-v125" in service_worker
assert "stackchain-dashboard-shell-v126" in service_worker
@pytest.mark.anyio

View File

@ -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-v125" in source
assert "stackchain-dashboard-shell-v126" 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

View File

@ -33,6 +33,7 @@ process.stdout.write(JSON.stringify(databases));
"stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
}

View File

@ -50,6 +50,7 @@ const clear = createPrivateDeviceDataPurger({{
"stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]

View File

@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@ -66,12 +66,24 @@ const context = {{
get: async id => state.sharedRecords[id] || null,
delete: async id => {{ delete state.sharedRecords[id]; }},
}},
__STACKCHAIN_TODAY_ACTION_STORE: {{
put: async (clientId, command) => {{
if (state.failTodayCommandPut) throw new Error('quota');
state.todayCommands[clientId] = command;
}},
claim: async (clientId, now) => {{
const command = state.todayCommands[clientId] || null;
delete state.todayCommands[clientId];
return command && command.expiresAt > now ? command : null;
}},
purge: async () => {{ state.todayCommands = {{}}; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
claim: async () => {{ state.claimed = true; }},
matchAll: async () => state.clientList || [],
openWindow: async url => {{ state.opened.push(url); }},
openWindow: async url => {{ state.opened.push(url); return state.openedClient || {{id:'opened-client',url}}; }},
}},
registration: {{
showNotification: async (title, options) => state.notifications.push({{title,options}}),
@ -133,9 +145,9 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchMessage(data, ports = []) {{
async function dispatchMessage(data, ports = [], source = null) {{
let pending;
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
listeners.message({{ data, ports, source, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null, actionToken = '') {{
@ -165,16 +177,16 @@ async function dispatchPush(payload) {{
return json.loads(completed.stdout)
def test_offline_activation_migration_rolls_the_shell_cache():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v125" in source
assert "stackchain-dashboard-shell-v126" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v125" in source
assert "stackchain-dashboard-shell-v126" 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 +195,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-v125" in source
assert "stackchain-dashboard-shell-v126" 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 +204,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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@ -200,14 +212,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-v125" in source
assert "stackchain-dashboard-shell-v126" 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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -215,7 +227,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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -223,7 +235,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-v125" in source
assert "stackchain-dashboard-shell-v126" 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 +245,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-v125" in source
assert "stackchain-dashboard-shell-v126" 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-v125" in source
assert "stackchain-dashboard-shell-v126" 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 +261,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-v125" in source
assert "stackchain-dashboard-shell-v126" 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-v125" in source
assert "stackchain-dashboard-shell-v126" 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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -451,6 +463,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
"stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
@ -610,17 +623,81 @@ def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation()
assert result["opened"] == []
def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed():
def test_today_lock_screen_action_opens_clean_route_and_privately_hands_off_once_when_closed():
result = run_worker_scenario(
"""
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session');
state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'};
await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
const messages=[];
const source={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>messages.push(message)};
await dispatchMessage({type:'stackchain-claim-today-action'}, [], source);
await dispatchMessage({type:'stackchain-claim-today-action'}, [], source);
state.claimMessages=messages;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["opened"] == [
"https://forge.example/dashboard/?today_timer_action=resume#/my-work/today"
"https://forge.example/dashboard/#/my-work/today"
]
assert "today_timer_action" not in result["opened"][0]
assert "today_action_token" not in result["opened"][0]
assert result["claimMessages"] == [{
"type": "stackchain-today-timer-action",
"action": "resume",
"actionToken": "opaque-token-1234567890",
}]
assert result["todayCommands"] == {}
def test_today_lock_screen_private_handoff_rejects_wrong_client_and_expires_closed():
result = run_worker_scenario(
"""
state.openedClient={id:'intended-client',url:'https://forge.example/dashboard/#/my-work/today'};
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
const wrong=[];
await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'other-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>wrong.push(message)});
state.todayCommands['intended-client'].expiresAt=0;
const expired=[];
await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'intended-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>expired.push(message)});
process.stdout.write(JSON.stringify({wrong,expired,commands:state.todayCommands}));
"""
)
assert result == {"wrong": [], "expired": [], "commands": {}}
def test_today_action_mailbox_persistence_failure_opens_clean_route_without_mutation():
result = run_worker_scenario(
"""
state.failTodayCommandPut=true;
state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'};
const outcome=await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890')
.then(()=> 'safe', error=>error.message);
process.stdout.write(JSON.stringify({outcome,opened:state.opened,commands:state.todayCommands}));
"""
)
assert result == {
"outcome": "safe",
"opened": ["https://forge.example/dashboard/#/my-work/today"],
"commands": {},
}
def test_private_data_purge_removes_pending_today_action_handoffs():
result = run_worker_scenario(
"""
state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'};
await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890');
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]);
const messages=[];
await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>messages.push(message)});
process.stdout.write(JSON.stringify({messages,commands:state.todayCommands}));
"""
)
assert result == {"messages": [], "commands": {}}
def test_finish_today_lock_screen_action_forwards_only_its_opaque_token():
@ -707,6 +784,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
"stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["replies"] == [{"ok": True}]
@ -1167,7 +1245,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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/queue-today.js'" in source
@ -1465,6 +1543,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
"stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]

View File

@ -74,21 +74,19 @@ def test_disabling_or_ending_session_removes_notification():
]
def test_valid_notification_action_is_consumed_once_and_removed_from_url():
def test_cold_launch_requests_one_private_action_claim_without_url_capabilities():
result = run_scenario(r"""
values.set('stackchain.today-lock-screen.v1.timmy','1');
locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today';
const consumed = await lockScreen.consumeLaunchAction();
const second = await lockScreen.consumeLaunchAction();
listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}});
process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href}));
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href,messages}));
""")
assert result == {
"consumed": True,
"second": False,
"actions": [["pause", None], ["resume", None]],
"actions": [["resume", None]],
"href": "https://forge.example/dashboard/#/my-work/today",
"messages": [{"type": "stackchain-claim-today-action"}],
}
@ -133,20 +131,18 @@ def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break():
assert "private/repo" not in json.dumps(result["messages"])
def test_cold_launch_finish_action_is_removed_from_history_before_completion():
def test_private_cold_launch_finish_action_is_bound_to_the_active_identity():
result = run_scenario(r"""
await lockScreen.enable();
await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true);
locationRef.href='https://forge.example/dashboard/?today_timer_action=complete&today_action_token=opaque-token-1234567890#/my-work/today';
const consumed = await lockScreen.consumeLaunchAction();
process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href}));
await lockScreen.consumeLaunchAction();
await listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'complete',actionToken:'opaque-token-1234567890'}});
process.stdout.write(JSON.stringify({actions,href:locationRef.href,messages}));
""")
assert result == {
"consumed": True,
"actions": [["complete", "issue:private/repo:42:"]],
"href": "https://forge.example/dashboard/#/my-work/today",
}
assert result["actions"] == [["complete", "issue:private/repo:42:"]]
assert result["href"] == "https://forge.example/dashboard/#/my-work/today"
assert result["messages"][-1] == {"type": "stackchain-claim-today-action"}
def test_permission_denial_is_truthful_and_does_not_persist_opt_in():

View File

@ -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-v125';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v126';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,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-v125" in source
assert "stackchain-dashboard-shell-v126" in source
assert "BASE + 'static/today-sync.js'" in source