feat: refresh Human Gates review sessions (Closes #1433)
All checks were successful
CI / lint (pull_request) Successful in 3m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m28s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-26 13:50:11 +00:00
parent 7dc32138e4
commit 2e9d108c6f
17 changed files with 275 additions and 35 deletions

View File

@ -13,6 +13,7 @@ function createHumanGates(options = {}) {
let loadEpoch = 0;
const decisionKeys = new Map();
let decisionFlight = null;
let openFlight = null;
let onChange = options.onChange;
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
@ -203,7 +204,19 @@ function createHumanGates(options = {}) {
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
return load().then(() => reviewNext());
if (openFlight) return openFlight;
const operation = (async () => {
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
return reviewNext();
})();
openFlight = operation;
const clearFlight = () => {
if (openFlight === operation) openFlight = null;
};
operation.then(clearFlight, clearFlight);
return operation;
}
return {

View File

@ -60,10 +60,7 @@ function createProgressiveHumanGates(options = {}) {
async function start(force = false) {
if (!force && location.hash !== '#/my-work/human-gates') return false;
if (started) {
if (nodes.panel) nodes.panel.hidden = false;
return true;
}
if (started) return controller.open().then(() => true);
if (startFlight) return startFlight;
startFlight = (async () => {
const identity = await getIdentity();

View File

@ -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-v144';
const CACHE = 'stackchain-dashboard-shell-v145';
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;

View File

@ -0,0 +1,95 @@
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Human Gates journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
current = {"gate": "g1"}
list_requests: list[str] = []
browser_errors: list[str] = []
def gate(gate_id: str) -> dict:
return {
"id": gate_id,
"title": "First candidate" if gate_id == "g1" else "Fresh candidate",
"project": "stackchain/stackchain-dashboard",
"candidate_hash": "a1" if gate_id == "g1" else "b2",
"revision": 1,
"priority": 5,
"checks": [],
"artifacts": [],
"links": [],
"provenance": {},
"history": [],
}
try:
with release_server(
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
def human_gates_route(route):
path = route.request.url.split("?", 1)[0]
if path.endswith("/api/v1/human-gates"):
list_requests.append(current["gate"])
payload = {"pending_count": 1, "items": [gate(current["gate"])]}
else:
payload = gate(path.rsplit("/", 1)[-1])
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
page.route("**/api/v1/human-gates**", human_gates_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Human Gates release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
page.evaluate("document.querySelector('#close-human-gates').click()")
before_reopen = len(list_requests)
current["gate"] = "g2"
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gate-detail")).to_contain_text("Fresh candidate")
expect(page.locator("#human-gates-list")).to_contain_text("Fresh candidate")
assert len(list_requests) == before_reopen + 1
assert page.evaluate("window.innerWidth") == width
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
bounds = page.locator('[data-human-gate-id="g2"]').bounding_box()
assert bounds and bounds["height"] >= 44
assert not browser_errors
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)

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-v144" in worker
assert "stackchain-dashboard-shell-v145" in worker

View File

@ -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-v144" in service_worker
assert "stackchain-dashboard-shell-v145" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -272,6 +272,92 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
assert [item["id"] for item in output["snapshot"]["items"]] == ["g2"]
def test_concurrent_reopen_calls_share_one_fresh_list_request():
output = run_node(r"""
let listCalls=0, releaseList;
const pending=new Promise(resolve=>releaseList=resolve);
const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{hidden:true},detail:{}},location:{hash:''},
fetchJson:async path=>{
if(path.endsWith('/g1')) return item;
listCalls += 1;
await pending;
return {pending_count:1,items:[item]};
},
});
(async()=>{
const first=gates.open();
const second=gates.open();
releaseList();
const reviewed=await Promise.all([first,second]);
process.stdout.write(JSON.stringify({listCalls,ids:reviewed.map(item=>item.id)}));
})();
""")
assert output == {"listCalls": 1, "ids": ["g1", "g1"]}
def test_failed_reopen_clears_single_flight_and_can_retry():
output = run_node(r"""
let listCalls=0;
const item={id:'g1',title:'Recovered',candidate_hash:'a1',revision:1,checks:[]};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{hidden:true},detail:{}},location:{hash:''},
fetchJson:async path=>{
if(path.endsWith('/g1')) return item;
listCalls += 1;
if(listCalls === 1) throw new Error('offline');
return {pending_count:1,items:[item]};
},
});
(async()=>{
let firstError='';
try { await gates.open(); } catch(error) { firstError=error.message; }
const recovered=await gates.open();
process.stdout.write(JSON.stringify({firstError,listCalls,recovered:recovered.id}));
})();
""")
assert output == {"firstError": "offline", "listCalls": 2, "recovered": "g1"}
def test_reopen_refreshes_queue_and_starts_a_fresh_atomic_review_snapshot():
output = run_node(r"""
const first={id:'g1',title:'First candidate',project:'p/one',candidate_hash:'a1',revision:1,checks:[]};
const second={id:'g2',title:'Second candidate',project:'p/two',candidate_hash:'b2',revision:1,checks:[]};
let listCalls=0;
const nodes={count:{},list:{innerHTML:''},status:{},panel:{hidden:true},detail:{innerHTML:''}};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},
fetchJson:async path=>{
if(path.endsWith('/g1')) return first;
if(path.endsWith('/g2')) return second;
listCalls += 1;
return {pending_count:1,items:[listCalls === 1 ? first : second]};
},
});
(async()=>{
await gates.open();
await new Promise(resolve=>setTimeout(resolve,0));
const before=gates.current().id;
await gates.open();
const selected=gates.select('g2');
await new Promise(resolve=>setTimeout(resolve,0));
process.stdout.write(JSON.stringify({
before,listCalls,selected:selected.id,current:gates.current().id,
listHtml:nodes.list.innerHTML,detailHtml:nodes.detail.innerHTML,
}));
})();
""")
assert output["before"] == "g1"
assert output["listCalls"] == 2
assert output["selected"] == "g2"
assert output["current"] == "g2"
assert "Second candidate" in output["listHtml"]
assert "Second candidate" in output["detailHtml"]
def test_selecting_a_queue_card_opens_that_exact_gate():
output = run_node(r"""
const details={
@ -306,7 +392,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-v144" in WORKER.read_text()
assert "stackchain-dashboard-shell-v145" in WORKER.read_text()
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
@ -346,6 +432,55 @@ const app=createProgressiveHumanGates({{
}
def test_progressive_reopen_refreshes_the_review_session_before_hydration():
script = f"""
const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))});
const listeners={{}};
const nodes={{
'#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}},
'#human-gates-status':{{}}, '#human-gates':{{hidden:true}},
'#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}},
'#open-human-gates':{{addEventListener:(name,fn)=>listeners.open=fn}},
'#close-human-gates':{{addEventListener:(name,fn)=>listeners.close=fn}},
}};
let listCalls=0;
const first={{id:'g1',title:'First',candidate_hash:'a1',revision:1,checks:[]}};
const second={{id:'g2',title:'Second',candidate_hash:'b2',revision:1,checks:[]}};
const app=createProgressiveHumanGates({{
document:{{querySelector:selector=>nodes[selector]||null}},
location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}},
storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true,
getIdentity:async()=>({{login:'timmy',accountKey:'7:timmy'}}),
fetchJson:async path=>{{
if(path.endsWith('/g1')) return first;
if(path.endsWith('/g2')) return second;
listCalls += 1;
return {{pending_count:1,items:[listCalls === 1 ? first : second]}};
}},
}});
(async()=>{{
await app.start();
listeners.close();
await listeners.open();
await new Promise(resolve=>setTimeout(resolve,0));
const controller=app.handoff().controller;
process.stdout.write(JSON.stringify({{
listCalls,current:controller.current().id,hidden:nodes['#human-gates'].hidden,
listHtml:nodes['#human-gates-list'].innerHTML,
detailHasSecond:nodes['#human-gate-detail'].innerHTML.includes('Second'),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
assert json.loads(result.stdout) == {
"listCalls": 2,
"current": "g2",
"hidden": False,
"listHtml": '<button class="human-gate-card" type="button" data-human-gate-id="g2"><strong>Second</strong><code>b2</code><span>Priority 0</span></button>',
"detailHasSecond": True,
}
def test_dashboard_adopts_progressive_human_gates_without_a_second_list_load():
dashboard = DASHBOARD.read_text()
index = INDEX.read_text()

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

View File

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

View File

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

View File

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

@ -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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" 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-v144" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -1361,7 +1361,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-v144" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/queue-today.js'" in source

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-v144';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v145';" 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-v144" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/today-sync.js'" in source