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}
`;