feat: recover interrupted Human Gate decisions (Closes #1446)
This commit is contained in:
1 parent
60f140d26a
commit
b303683ba1
21 files changed
+251
-58
No files matched your search
@@ -29,7 +29,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||
current = {"gate": "g1"}
|
||||
evidence_url = {"value": "/evidence/manifest"}
|
||||
list_requests: list[str] = []
|
||||
decision_requests: list[str] = []
|
||||
decision_requests: list[dict] = []
|
||||
browser_errors: list[str] = []
|
||||
|
||||
def gate(gate_id: str) -> dict:
|
||||
@@ -62,7 +62,14 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||
def human_gates_route(route):
|
||||
path = route.request.url.split("?", 1)[0]
|
||||
if route.request.method == "POST":
|
||||
decision_requests.append(path)
|
||||
decision_requests.append({
|
||||
"path": path,
|
||||
"key": route.request.headers.get("idempotency-key"),
|
||||
"body": route.request.post_data_json,
|
||||
})
|
||||
if len(decision_requests) == 1:
|
||||
route.abort("failed")
|
||||
return
|
||||
payload = {"receipt_id": "receipt-1", "state": "released"}
|
||||
elif path.endswith("/api/v1/human-gates"):
|
||||
list_requests.append(current["gate"])
|
||||
@@ -131,9 +138,28 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||
assert progress_key and progress_key.endswith(":g1:1")
|
||||
assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
|
||||
page.locator('[data-gate-decision="release"]').click()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||
expect(page.locator("[data-gate-recover]")).to_have_count(1)
|
||||
decision_key = page.evaluate(
|
||||
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-decision.v1:'))"
|
||||
)
|
||||
assert decision_key
|
||||
assert len(decision_requests) == 1
|
||||
|
||||
page.reload(wait_until="networkidle")
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||
recover = page.locator("[data-gate-recover]")
|
||||
recover.scroll_into_view_if_needed()
|
||||
expect(recover).to_be_visible()
|
||||
recover_bounds = recover.bounding_box()
|
||||
assert recover_bounds and recover_bounds["height"] >= 44
|
||||
recover.click()
|
||||
expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
|
||||
expect(page.locator(".human-gate-decision-tray")).to_have_count(0)
|
||||
assert len(decision_requests) == 1
|
||||
assert len(decision_requests) == 2
|
||||
assert decision_requests[0] == decision_requests[1]
|
||||
assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key)
|
||||
assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
|
||||
|
||||
page.evaluate("document.querySelector('#close-human-gates').click()")
|
||||
|
||||
@@ -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-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
@@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
||||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v149" in service_worker
|
||||
assert "stackchain-dashboard-shell-v150" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
|
||||
@@ -470,7 +470,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_
|
||||
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
||||
|
||||
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
|
||||
@@ -548,6 +548,76 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
|
||||
assert output["keys"][0] == output["keys"][1]
|
||||
|
||||
|
||||
def test_interrupted_decision_is_recovered_after_reload_with_the_exact_operation():
|
||||
output = run_node(r"""
|
||||
const stored=new Map(); const requests=[]; let failResponse=true, committed=false;
|
||||
const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)};
|
||||
const item={id:'g1',title:'Release candidate',candidate_hash:'a1',revision:3,checks:[]};
|
||||
const createController=()=>{
|
||||
const detail={innerHTML:'',addEventListener(){},querySelectorAll:()=>[],querySelector:()=>null};
|
||||
const gates=createHumanGates({
|
||||
storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,
|
||||
nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
|
||||
fetchJson:async(path,options={})=>{
|
||||
if(options.method==='POST') {
|
||||
requests.push({key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)});
|
||||
if(failResponse) throw new Error('response interrupted');
|
||||
return {receipt_id:'r1',state:'held'};
|
||||
}
|
||||
return committed ? {pending_count:0,items:[]} : {pending_count:1,items:[item]};
|
||||
},
|
||||
});
|
||||
return {gates,detail};
|
||||
};
|
||||
(async()=>{
|
||||
const first=createController(); await first.gates.load(); first.gates.reviewNext();
|
||||
first.gates.saveProgress({reason:'Awaiting approval',checklist:{}});
|
||||
try { await first.gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {}
|
||||
const immediateHtml=first.detail.innerHTML;
|
||||
const decisionStorageKey=[...stored.keys()].find(key=>key.startsWith('stackchain.human-gate-decision.v1:'));
|
||||
const pendingBefore=JSON.parse(stored.get(decisionStorageKey));
|
||||
committed=true; failResponse=false;
|
||||
const second=createController(); await second.gates.load(); second.gates.reviewNext();
|
||||
const recoveryHtml=second.detail.innerHTML;
|
||||
const result=await second.gates.recoverDecision();
|
||||
process.stdout.write(JSON.stringify({
|
||||
decisionStorageKey,pendingBefore,immediateHtml,recoveryHtml,requests,result,
|
||||
pendingCleared:!stored.has(decisionStorageKey),
|
||||
progressCleared:![...stored.keys()].some(key=>key.startsWith('stackchain.human-gate-review.v1:')),
|
||||
snapshot:second.gates.snapshot(),
|
||||
}));
|
||||
})().catch(error=>process.stdout.write(JSON.stringify({fatal:error.stack})));
|
||||
""")
|
||||
assert "fatal" not in output, output.get("fatal")
|
||||
assert output["decisionStorageKey"] == "stackchain.human-gate-decision.v1:7:timmy"
|
||||
assert output["pendingBefore"]["gate_id"] == "g1"
|
||||
assert output["pendingBefore"]["revision"] == 3
|
||||
assert output["pendingBefore"]["decision"] == "hold"
|
||||
assert "Decision outcome unknown" in output["immediateHtml"]
|
||||
assert "Decision outcome unknown" in output["recoveryHtml"]
|
||||
assert "data-gate-recover" in output["recoveryHtml"]
|
||||
assert len(output["requests"]) == 2
|
||||
assert output["requests"][0] == output["requests"][1]
|
||||
assert output["pendingCleared"] is True
|
||||
assert output["progressCleared"] is True
|
||||
assert output["snapshot"] == {"pending_count": 0, "items": []}
|
||||
assert output["result"]["receipt"]["receipt_id"] == "r1"
|
||||
|
||||
|
||||
def test_interrupted_decision_recovery_is_wired_for_progressive_and_hydrated_mobile_shells():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
progressive = PROGRESSIVE.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert "closest('[data-gate-recover]')" in dashboard
|
||||
assert "humanGates.recoverDecision()" in dashboard
|
||||
assert "closest?.('[data-gate-recover]')" in progressive
|
||||
assert "controller.recoverDecision()" in progressive
|
||||
assert ".human-gate-decision-recovery" in css
|
||||
assert ".human-gate-decision-recovery button{min-height:44px" in css
|
||||
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it():
|
||||
output = run_node(r"""
|
||||
const stored=new Map(); let attempts=0;
|
||||
@@ -707,7 +777,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
||||
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
|
||||
assert "counts.gate = queueCounts.gate" in dashboard
|
||||
assert "gate:preparationItems.gate || []" in dashboard
|
||||
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
|
||||
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell():
|
||||
@@ -728,7 +798,7 @@ def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(
|
||||
assert ".human-gate-views button{min-height:44px" in css
|
||||
assert ".human-gate-history-card time" in css
|
||||
assert ".human-gate-history-more{min-height:44px;width:100%" in css
|
||||
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
|
||||
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
|
||||
|
||||
@@ -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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" 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]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
|
||||
|
||||
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 "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -274,5 +274,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-v149" in worker
|
||||
assert "stackchain-dashboard-shell-v150" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -469,7 +469,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-v149" in service_worker
|
||||
assert "stackchain-dashboard-shell-v150" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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
|
||||
@@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
|
||||
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/progressive-live-snapshot.js'" in source
|
||||
|
||||
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
@@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -226,7 +226,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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
|
||||
@@ -235,7 +235,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -243,14 +243,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -258,7 +258,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -266,7 +266,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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
|
||||
@@ -276,14 +276,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -292,21 +292,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -1390,7 +1390,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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" 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():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v149';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v150';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -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-v149" in source
|
||||
assert "stackchain-dashboard-shell-v150" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user