From 9d2310592beaf21e2a38e787b8adbe0a11b0dca2 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Sun, 3 May 2026 18:11:18 +0530 Subject: [PATCH] =?UTF-8?q?extend=20the=20optimistic-anchor=20pattern=20fr?= =?UTF-8?q?om=20policies=20to=20decisions=20so=20the=20timeline=20never=20?= =?UTF-8?q?sits=20on=20'=E2=80=94=20not=20anchored'=20for=20fresh=20rows:?= =?UTF-8?q?=20until=20this=20commit,=20hitting=20evaluate=20caused=20the?= =?UTF-8?q?=20new=20decision=20to=20render=20in=20the=20timeline=20immedia?= =?UTF-8?q?tely=20with=20the=20terminal=20'=E2=80=94=20not=20anchored'=20m?= =?UTF-8?q?arker=20because=20the=20server=20returns=20the=20decision=20bef?= =?UTF-8?q?ore=20the=200g=20storage=20upload=20settles=20(the=20upload=20i?= =?UTF-8?q?s=20fire-and-forget=20per=20zeroGStore.scheduleAnchor=20for=20t?= =?UTF-8?q?he=20same=20'50ms=20hot=20path'=20reason=20that=20policy=20crea?= =?UTF-8?q?tion=20uses),=20so=20users=20sat=20staring=20at=20a=20row=20tha?= =?UTF-8?q?t=20looked=20like=20it=20had=20failed=20when=20in=20reality=20t?= =?UTF-8?q?he=20anchor=20would=20land=205-30s=20later=20=E2=80=94=20they?= =?UTF-8?q?=20had=20no=20way=20to=20know=20the=20difference=20without=20ma?= =?UTF-8?q?nually=20refreshing=20or=20watching=20the=20lime=20'0G=20|=200x?= =?UTF-8?q?root...'=20pill=20appear;=20web/src/lib/timeline.ts=20now=20mir?= =?UTF-8?q?rors=20the=20policies.ts=20treatment=20with=20a=20module-level?= =?UTF-8?q?=20pendingDecisionAnchors=20Map=20and=20a=20si?= =?UTF-8?q?ngle=20shared=20poll=20loop=20(one=20window.setInterval=20share?= =?UTF-8?q?d=20across=20all=20in-flight=20ids,=20vs=20one=20timer=20per=20?= =?UTF-8?q?id)=20that=20fetches=20/timeline=20every=202s=20for=20up=20to?= =?UTF-8?q?=2030s=20per=20id;=20markDecisionPending(id)=20is=20exported=20?= =?UTF-8?q?so=20the=20evaluate=20flow=20can=20register=20a=20fresh=20decis?= =?UTF-8?q?ion=20the=20instant=20POST=20/evaluate=20returns,=20the=20next?= =?UTF-8?q?=20loadTimeline=20render=20reads=20from=20the=20map=20and=20emi?= =?UTF-8?q?ts=20anchorPendingPillHtml()=20(pulsing=20lime=20dot=20+=20ital?= =?UTF-8?q?ic=20'anchoring'=20text=20+=20breathing=20opacity)=20instead=20?= =?UTF-8?q?of=20the=20terminal=20anchorPillHtml(undefined)=20for=20any=20i?= =?UTF-8?q?d=20still=20in=20flight;=20loadTimeline=20drains=20the=20map=20?= =?UTF-8?q?on=20every=20render=20=E2=80=94=20for=20each=20row=20whose=20re?= =?UTF-8?q?sponse=20now=20carries=20a=20non-empty=20rootHash=20the=20id=20?= =?UTF-8?q?is=20pulled=20from=20the=20map=20and=20the=20pill=20flips=20to?= =?UTF-8?q?=20the=20green=20link=20without=20a=20visual=20jolt;=20the=20sh?= =?UTF-8?q?ared=20timer=20self-stops=20when=20the=20map=20drains=20(size?= =?UTF-8?q?=20=3D=3D=3D=200)=20so=20we=20don't=20burn=20an=20interval=20fo?= =?UTF-8?q?rever=20after=20all=20anchors=20land,=20and=20the=20per-id=20ti?= =?UTF-8?q?meout=20(30s)=20kicks=20any=20id=20that=20fails=20to=20anchor?= =?UTF-8?q?=20(network=20blip,=20indexer=20down,=20wallet=20drained)=20out?= =?UTF-8?q?=20of=20the=20map=20so=20the=20pill=20drops=20to=20the=20termin?= =?UTF-8?q?al=20'=E2=80=94=20not=20anchored'=20state=20rather=20than=20pul?= =?UTF-8?q?sing=20forever;=20web/src/lib/evaluate.ts=20imports=20markDecis?= =?UTF-8?q?ionPending=20and=20calls=20it=20from=20submitEvaluateForm=20rig?= =?UTF-8?q?ht=20after=20the=20post=20returns,=20gated=20on=20the=20respons?= =?UTF-8?q?e=20carrying=20an=20id=20and=20lacking=20a=20rootHash=20so=20ex?= =?UTF-8?q?isting=20rows=20with=20already-landed=20anchors=20don't=20re-en?= =?UTF-8?q?ter=20the=20pending=20state=20on=20a=20retry;=20the=20call=20ha?= =?UTF-8?q?ppens=20before=20await=20loadTimeline()=20so=20the=20very=20fir?= =?UTF-8?q?st=20render=20of=20the=20new=20row=20already=20shows=20the=20pe?= =?UTF-8?q?nding=20pill=20rather=20than=20flashing=20'not=20anchored'=20fo?= =?UTF-8?q?r=20one=20frame=20before=20the=20next=20poll=20catches=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/lib/evaluate.ts | 8 ++++- web/src/lib/timeline.ts | 70 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/web/src/lib/evaluate.ts b/web/src/lib/evaluate.ts index 9a57723..829ab18 100644 --- a/web/src/lib/evaluate.ts +++ b/web/src/lib/evaluate.ts @@ -8,7 +8,7 @@ import { verdictWord, } from "./format.js"; import { showJsonModal } from "./modal.js"; -import { loadTimeline } from "./timeline.js"; +import { loadTimeline, markDecisionPending } from "./timeline.js"; import { ATTACKER, COLD_VAULT, TOKEN, TREASURY } from "./policies.js"; import type { Decision } from "./types.js"; @@ -44,6 +44,12 @@ export async function submitEvaluateForm(form: HTMLFormElement): Promise { const r = await api("POST", "/evaluate", body); stopLoader(); renderEvaluate(r); + if (r.ok && r.data && typeof r.data === "object" && "id" in r.data) { + const created = r.data as Decision; + if (created.id && (!created.anchor || !created.anchor.rootHash)) { + markDecisionPending(created.id); + } + } await loadTimeline(); } finally { setSubmitBusy(submitBtn, false, originalLabel); diff --git a/web/src/lib/timeline.ts b/web/src/lib/timeline.ts index bbc98fa..3fad357 100644 --- a/web/src/lib/timeline.ts +++ b/web/src/lib/timeline.ts @@ -1,5 +1,12 @@ import { api } from "./api.js"; -import { anchorPillHtml, escapeHtml, renderReason, verdictKlass, verdictWord } from "./format.js"; +import { + anchorPendingPillHtml, + anchorPillHtml, + escapeHtml, + renderReason, + verdictKlass, + verdictWord, +} from "./format.js"; import { showJsonModal } from "./modal.js"; import type { Decision } from "./types.js"; @@ -9,6 +16,52 @@ declare global { } } +/** + * Decision ids whose 0G anchor upload is still in flight on the server. + * Keyed by id, valued by `performance.now()` at submission time so the + * shared poll loop can time out individual ids after + * {@link ANCHOR_POLL_TIMEOUT_MS} without affecting other in-flight rows. + * Set by `markDecisionPending` from the evaluate path; drained by + * `loadTimeline` once the matching row's response carries a non-empty + * rootHash. + */ +const pendingDecisionAnchors = new Map(); + +const ANCHOR_POLL_INTERVAL_MS = 2_000; +const ANCHOR_POLL_TIMEOUT_MS = 30_000; + +let pollingTimer: number | null = null; + +/** + * Mark a freshly created decision as awaiting its 0G anchor. The next + * `loadTimeline()` render uses the pulsing "anchoring" pill instead of + * the terminal "not anchored" marker, and a single shared polling loop + * fetches /timeline every 2s for up to 30s per id, flipping each pill + * to the lime "0G | 0xroot..." link the moment the anchor lands. + */ +export function markDecisionPending(id: string): void { + pendingDecisionAnchors.set(id, performance.now()); + if (pollingTimer === null) startAnchorPolling(); +} + +function startAnchorPolling(): void { + pollingTimer = window.setInterval(() => { + const now = performance.now(); + for (const [id, start] of pendingDecisionAnchors) { + if (now - start > ANCHOR_POLL_TIMEOUT_MS) pendingDecisionAnchors.delete(id); + } + if (pendingDecisionAnchors.size === 0) { + if (pollingTimer !== null) { + window.clearInterval(pollingTimer); + pollingTimer = null; + } + void loadTimeline(); + return; + } + void loadTimeline(); + }, ANCHOR_POLL_INTERVAL_MS); +} + export async function loadTimeline(): Promise { const r = await api("GET", "/timeline"); const rows = document.getElementById("timeline-rows"); @@ -21,6 +74,13 @@ export async function loadTimeline(): Promise { } empty.style.display = "none"; window._timeline = r.data; + + for (const d of r.data) { + if (pendingDecisionAnchors.has(d.id) && d.anchor && d.anchor.rootHash) { + pendingDecisionAnchors.delete(d.id); + } + } + rows.innerHTML = r.data .slice() .reverse() @@ -36,6 +96,12 @@ export async function loadTimeline(): Promise { .map((rs) => `
→ ${renderReason(rs)}
`) .join(""); const realIndex = (r.data as Decision[]).length - 1 - idx; + const pillHtml = + d.anchor && d.anchor.rootHash + ? anchorPillHtml(d.anchor) + : pendingDecisionAnchors.has(d.id) + ? anchorPendingPillHtml() + : anchorPillHtml(undefined); return `
@@ -50,7 +116,7 @@ export async function loadTimeline(): Promise {
${rules ? `
${rules}
` : ""} ${playbookCell} -
${anchorPillHtml(d.anchor)}
+
${pillHtml}
${reasons}
`;