Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion web/src/lib/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -44,6 +44,12 @@ export async function submitEvaluateForm(form: HTMLFormElement): Promise<void> {
const r = await api<Decision>("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);
Expand Down
70 changes: 68 additions & 2 deletions web/src/lib/timeline.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string, number>();

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<void> {
const r = await api<Decision[]>("GET", "/timeline");
const rows = document.getElementById("timeline-rows");
Expand All @@ -21,6 +74,13 @@ export async function loadTimeline(): Promise<void> {
}
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()
Expand All @@ -36,6 +96,12 @@ export async function loadTimeline(): Promise<void> {
.map((rs) => `<div>→ ${renderReason(rs)}</div>`)
.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 `
<article class="timeline-item">
<div class="timeline-time">
Expand All @@ -50,7 +116,7 @@ export async function loadTimeline(): Promise<void> {
</div>
${rules ? `<div class="timeline-row-meta">${rules}</div>` : ""}
${playbookCell}
<div class="timeline-row-anchor">${anchorPillHtml(d.anchor)}</div>
<div class="timeline-row-anchor">${pillHtml}</div>
<div class="timeline-row-reasons">${reasons}</div>
</div>
</article>`;
Expand Down