feat: recover interrupted Human Gate decisions (Closes #1446)
This commit is contained in:
parent
60f140d26a
commit
b303683ba1
|
|
@ -859,7 +859,10 @@ Authenticated release producers use `POST /api/v1/human-gates/intake` with an
|
|||
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
|
||||
binds each queue to the upstream principal ID and login, including its offline
|
||||
browser cache. Review decisions carry `expected_revision`, a stable idempotency
|
||||
key across network retries, and return durable receipts. If producer evidence
|
||||
key across network retries and mobile app restarts, and return durable receipts.
|
||||
An interrupted decision response is restored as **Decision outcome unknown**;
|
||||
the operator verifies the exact persisted operation instead of creating a second
|
||||
decision. The client clears that operation only after recovering a receipt. If producer evidence
|
||||
changes after a release or hold, the update reopens the exact hash for a new
|
||||
revision-checked decision instead of silently retaining the old outcome.
|
||||
New hashes mark older pending candidates `superseded` without removing their audit
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ The immutable identity is authenticated account + `source` + `project` + `candid
|
|||
|
||||
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
|
||||
|
||||
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
|
||||
|
||||
## Privacy-safe notification contract
|
||||
|
||||
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
|
||||
|
|
|
|||
|
|
@ -1626,5 +1626,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
|
||||
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
|
||||
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
|
||||
.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px}
|
||||
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
|
||||
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}
|
||||
|
|
@ -704,6 +704,15 @@
|
|||
qs('#human-gates-status').textContent = error.message;
|
||||
}));
|
||||
qs('#human-gate-detail').addEventListener('click', event => {
|
||||
const recovery = event.target.closest('[data-gate-recover]');
|
||||
if (recovery) {
|
||||
recovery.disabled = true;
|
||||
humanGates.recoverDecision().catch(error => {
|
||||
qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.';
|
||||
recovery.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
|
||||
if (!decision) return;
|
||||
humanGates.submitDecision(decision).catch(error => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ function createHumanGates(options = {}) {
|
|||
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
|
||||
const progressKey = item => 'stackchain.human-gate-review.v1:' +
|
||||
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
|
||||
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
|
||||
String(getAccountKey() || '').trim().toLowerCase();
|
||||
const setText = (node, value) => { if (node) node.textContent = value; };
|
||||
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
|
||||
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
|
||||
|
|
@ -65,6 +67,26 @@ function createHumanGates(options = {}) {
|
|||
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function restorePendingDecision(item = null) {
|
||||
if (!getLogin()) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
|
||||
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
|
||||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
|
||||
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
|
||||
return value;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function savePendingDecision(value) {
|
||||
storage.setItem(decisionStorageKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearPendingDecision(item) {
|
||||
if (!restorePendingDecision(item)) return;
|
||||
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
|
||||
}
|
||||
|
||||
function valuesFromDetail() {
|
||||
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
return {
|
||||
|
|
@ -215,6 +237,7 @@ function createHumanGates(options = {}) {
|
|||
return;
|
||||
}
|
||||
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
|
||||
const pendingDecision = restorePendingDecision(item);
|
||||
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
|
||||
const checks = (item.checks || []).map(check =>
|
||||
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
|
||||
|
|
@ -236,11 +259,14 @@ function createHumanGates(options = {}) {
|
|||
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
|
||||
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
|
||||
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
|
||||
'<div class="human-gate-decision-tray"><div class="human-gate-decision-state">' +
|
||||
'<strong data-gate-readiness>0 of 3 confirmations complete</strong>' +
|
||||
'<span data-gate-error role="alert" hidden></span></div>' +
|
||||
'<div class="human-gate-decision-actions"><button type="button" data-gate-decision="hold">Hold & next</button>' +
|
||||
'<button type="button" data-gate-decision="release">Release & next</button></div></div></article>'
|
||||
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
|
||||
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
|
||||
' response was interrupted. Verify it before making another decision.</span>' :
|
||||
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
|
||||
'<div class="human-gate-decision-actions">' + (pendingDecision ?
|
||||
'<button type="button" data-gate-recover>Verify decision</button>' :
|
||||
'<button type="button" data-gate-decision="hold">Hold & next</button><button type="button" data-gate-decision="release">Release & next</button>') +
|
||||
'</div></div></article>'
|
||||
);
|
||||
updateReadiness();
|
||||
}
|
||||
|
|
@ -266,6 +292,16 @@ function createHumanGates(options = {}) {
|
|||
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
||||
if (!live) throw new Error('Human Gates response is invalid.');
|
||||
queue = { pending_count: live.pending_count, items: live.items.slice() };
|
||||
const interrupted = restorePendingDecision();
|
||||
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
|
||||
const recoveryItem = {...interrupted.item, decision_recovery:true};
|
||||
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
|
||||
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
|
||||
else {
|
||||
queue.items.unshift(recoveryItem);
|
||||
queue.pending_count += 1;
|
||||
}
|
||||
}
|
||||
save(queue);
|
||||
render();
|
||||
publish({available:true, authoritative:true});
|
||||
|
|
@ -311,6 +347,8 @@ function createHumanGates(options = {}) {
|
|||
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
|
||||
function idempotencyKey(item, decision, payload) {
|
||||
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
|
||||
const pending = restorePendingDecision(item);
|
||||
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
|
||||
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
|
||||
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
|
||||
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
|
||||
|
|
@ -318,6 +356,30 @@ function createHumanGates(options = {}) {
|
|||
return { operation, key };
|
||||
}
|
||||
|
||||
function completeDecision(item, decision, operation, receipt) {
|
||||
decisionKeys.delete(operation);
|
||||
clearPendingDecision(item);
|
||||
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
|
||||
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
|
||||
queue.pending_count = Math.max(0, queue.pending_count - 1);
|
||||
save(queue); render();
|
||||
publish({available:true, authoritative:true, decision:true});
|
||||
reviewIndex += 1;
|
||||
const next = current();
|
||||
if (next) reviewNext(); else renderDetail(null);
|
||||
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
|
||||
return { receipt, next };
|
||||
}
|
||||
|
||||
async function postDecision(item, pending) {
|
||||
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
|
||||
body: JSON.stringify(pending.payload),
|
||||
});
|
||||
return completeDecision(item, pending.decision, pending.operation, receipt);
|
||||
}
|
||||
|
||||
function decisionError(message, selector) {
|
||||
const error = new Error(message);
|
||||
error.targetSelector = selector;
|
||||
|
|
@ -351,22 +413,19 @@ function createHumanGates(options = {}) {
|
|||
if (decisionFlight) return decisionFlight;
|
||||
const operation = (async () => {
|
||||
const decisionKey = idempotencyKey(item, decision, payload);
|
||||
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
decisionKeys.delete(decisionKey.operation);
|
||||
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
|
||||
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
|
||||
queue.pending_count = Math.max(0, queue.pending_count - 1);
|
||||
save(queue); render();
|
||||
publish({available:true, authoritative:true, decision:true});
|
||||
reviewIndex += 1;
|
||||
const next = current();
|
||||
if (next) reviewNext(); else renderDetail(null);
|
||||
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
|
||||
return { receipt, next };
|
||||
const pending = {
|
||||
gate_id:item.id, revision:item.revision, decision, payload,
|
||||
operation:decisionKey.operation, idempotency_key:decisionKey.key,
|
||||
item:JSON.parse(JSON.stringify(item)),
|
||||
};
|
||||
savePendingDecision(pending);
|
||||
try {
|
||||
return await postDecision(item, pending);
|
||||
} catch (error) {
|
||||
renderDetail(item);
|
||||
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
decisionFlight = operation;
|
||||
try {
|
||||
|
|
@ -376,6 +435,20 @@ function createHumanGates(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
async function recoverDecision() {
|
||||
const item = current();
|
||||
if (!item) throw new Error('No gate is selected.');
|
||||
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const pending = restorePendingDecision(item);
|
||||
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
|
||||
if (decisionFlight) return decisionFlight;
|
||||
const operation = postDecision(item, pending);
|
||||
decisionFlight = operation;
|
||||
try { return await operation; }
|
||||
finally { if (decisionFlight === operation) decisionFlight = null; }
|
||||
}
|
||||
|
||||
async function submitDecision(decision) {
|
||||
const values = valuesFromDetail();
|
||||
updateReadiness(values);
|
||||
|
|
@ -425,7 +498,7 @@ function createHumanGates(options = {}) {
|
|||
|
||||
return {
|
||||
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
|
||||
decideAndNext, submitDecision, current, saveProgress,
|
||||
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
|
||||
setOnChange(callback) { onChange = callback; },
|
||||
restoreCached: restore,
|
||||
snapshot: () => JSON.parse(JSON.stringify(queue)),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ function createProgressiveHumanGates(options = {}) {
|
|||
if (card) controller.select(card.dataset.humanGateId);
|
||||
});
|
||||
nodes.detail?.addEventListener?.('click', event => {
|
||||
const recovery = event.target?.closest?.('[data-gate-recover]');
|
||||
if (recovery) {
|
||||
recovery.disabled = true;
|
||||
controller.recoverDecision().catch(error => {
|
||||
showError(error);
|
||||
recovery.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
|
||||
if (!decision) return;
|
||||
controller.submitDecision(decision).catch(error => {
|
||||
|
|
|
|||
|
|
@ -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-v149';
|
||||
const CACHE = 'stackchain-dashboard-shell-v150';
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user