diff --git a/src/page.html b/src/page.html index 67015a0..88fb292 100644 --- a/src/page.html +++ b/src/page.html @@ -3,11 +3,11 @@ -strata - +strata · stream + - +
- ~  s t r a t a  ~ + ~  s t r e a m  ~ · reading the archive -

the stream archive, cut across. every lexicon on the network is a layer: thickness is how much of it there is, shade is how it comes and goes.

+

strata — what's in the archive. every app on the network writes its own kinds of records; this is how much of each there is, and when it arrived.

-
—segments
-
—events
-
—namespaces
-
—collections
+
—records
+
—apps
+
—record types
+
—archive chunks
+

each row is an app, named by the identifier its records carry (app.bsky is Bluesky, sh.tangled is Tangled). a taller row has written more records overall. a brighter cell means that app was busier in that slice of the archive. ▸ opens an app to show its record types.

+
- - + - - - + + +
- -
hover a layer to read it · click a namespace to split it into its collections
+ +
move over the picture to read a cell · click an app's name to open it
- nothing in that column - fewermore + nothing from that app in this slice + quieterbusier
-

columns are archive position: sealed segments in order, ≈3.2M events each. the dates are when stream witnessed the events, not when they happened — the bootstrap band replayed years of history inside six days from 2026-07-28, so it is dense; the live band is about two minutes per segment. account, identity and sync markers carry no collection and are not counted. "other" is every namespace below the ones shown.

+

the archive is stored in chunks of about 3 million records; the columns are those chunks in order, oldest on the left. the dates are when the archive received the records, not when people wrote them: in its first six days (from 2026-07-28) it replayed the network's whole history, so those columns are packed — history, replayed. after that each chunk is a few minutes of live traffic — as it happened. account and identity changes belong to no app and aren't counted. everything else bundles the apps not shown.

-
ask the tail
+
look inside an app
+

pick an app and every record it has ever written is searched right here, in your browser, from the public archive. Bluesky itself is too big for that and is shown from summaries only.

- + pick one - + +
- -
every row this namespace ever wrote — rows(seq, time_us, did, kind, op, collection, rkey) — queried in your browser with duckdb, straight from the public archive. app.bsky is aggregates-only by policy.
+ +
@@ -140,9 +148,11 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} const css = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); const STEPS = [1, 2, 5, 10, 25, 50, 100]; const FLOOR = 0.14; + const OTHER = "other", OTHER_LABEL = "everything else"; const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + const name = (key) => key === OTHER ? OTHER_LABEL : key; - // one hue, dark → light, with a floor so a seam with anything in it never fades into the well + // one hue, dark → light, with a floor so a cell with anything in it never fades into the empty tone const shade = (t) => { const u = FLOOR + (1 - FLOOR) * Math.max(0, Math.min(1, t)); return `oklch(${(0.27 + 0.66 * u).toFixed(3)} ${(0.045 + 0.08 * u * (1 - u)).toFixed(3)} 222)`; @@ -151,28 +161,27 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} const view = { top: 12, expanded: new Map(), pinned: [], namespaces: [] }; let data = null, layout = null, hover = null, progress = null, stepChosen = false; const stepSelect = $("step"); - for (const s of STEPS) { const o = document.createElement("option"); o.value = s; o.textContent = s === 1 ? "1 segment each" : `${s} segments each`; stepSelect.append(o); } + for (const s of STEPS) { const o = document.createElement("option"); o.value = s; o.textContent = s === 1 ? "1 chunk per column" : `${s} chunks per column`; stepSelect.append(o); } async function api(path) { const r = await fetch(path); return r.json(); } async function loadProgress() { - progress = await api("/api/progress"); + progress = await api("api/progress"); $("s-segments").textContent = fmt.format(progress.segments); $("s-events").textContent = compact.format(progress.events); $("s-namespaces").textContent = fmt.format(progress.namespaces); $("s-collections").textContent = fmt.format(progress.collections); if (progress.updatedAt) { const d = new Date(progress.updatedAt); - $("live").innerHTML = `· ${fmt.format(progress.segments)} segments · through seq ${fmt.format(progress.maxSeq)}`; + $("live").innerHTML = `· ${fmt.format(progress.segments)} chunks read · ${compact.format(progress.events)} records`; $("updated").textContent = `updated ${dayFmt.format(d)} ${timeFmt.format(d)} utc`; } if (!stepChosen) stepSelect.value = String(STEPS.find((s) => Math.ceil(progress.segments / s) <= 160) ?? STEPS[STEPS.length - 1]); if (!view.namespaces.length) { - const { namespaces } = await api("/api/namespaces"); + const { namespaces } = await api("api/namespaces"); view.namespaces = namespaces; - $("ns-list").innerHTML = namespaces.map((n) => ``).join(""); - const askNs = $("ask-ns"); - askNs.innerHTML = `` + namespaces.filter((n) => n.ns !== "app.bsky").map((n) => ``).join(""); + $("ns-list").innerHTML = namespaces.map((n) => ``).join(""); + $("ask-ns").innerHTML = `` + namespaces.filter((n) => n.ns !== "app.bsky").map((n) => ``).join(""); } } @@ -182,21 +191,21 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} async function load() { await loadProgress(); const step = Number(stepSelect.value); - const heat = await api(`/api/heat?step=${step}&top=${view.top}`); + const heat = await api(`api/heat?step=${step}&top=${view.top}`); if (heat.error) { reader.innerHTML = `${esc(heat.error)}`; return; } const buckets = heat.buckets, bucketIndex = new Map(buckets.map((b, i) => [b.bucket, i])); const keys = new Map(heat.keys.map((k) => [k.key, k])); const order = heat.keys.map((k) => k.key); const extra = view.pinned.filter((p) => !keys.has(p.ns)); - const extraHeat = await Promise.all(extra.map((p) => api(`/api/heat?step=${step}&ns=${p.ns}`))); + const extraHeat = await Promise.all(extra.map((p) => api(`api/heat?step=${step}&ns=${p.ns}`))); extra.forEach((p, i) => { keys.set(p.ns, { key: p.ns, count: p.count, members: p.members, cells: extraHeat[i].cells }); order.push(p.ns); }); - if (heat.cells.some((c) => c.key === "other")) { order.push("other"); keys.set("other", { key: "other", count: 0, members: 0 }); } + if (heat.cells.some((c) => c.key === OTHER)) { order.push(OTHER); keys.set(OTHER, { key: OTHER, count: 0, members: 0 }); } const rows = []; for (const key of order) { const k = keys.get(key); const values = k.cells ? (() => { const a = new Float64Array(buckets.length); for (const c of k.cells) { const i = bucketIndex.get(c.bucket); if (i !== undefined) a[i] += c.count; } return a; })() : series(heat.cells, key, bucketIndex, buckets.length); - const total = key === "other" ? values.reduce((a, b) => a + b, 0) : k.count; - rows.push({ key, depth: 0, total, members: k.members, values, expandable: key !== "other" && k.members > 1 }); + const total = key === OTHER ? values.reduce((a, b) => a + b, 0) : k.count; + rows.push({ key, depth: 0, total, members: k.members, values, expandable: key !== OTHER && k.members > 1 }); const sub = view.expanded.get(key); if (sub && sub.step === step) rows.push(...sub.rows); } @@ -207,7 +216,7 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} async function expand(key) { const step = Number(stepSelect.value); - const sub = await api(`/api/heat?step=${step}&ns=${key}`); + const sub = await api(`api/heat?step=${step}&ns=${key}`); const bucketIndex = new Map(sub.buckets.map((b, i) => [b.bucket, i])); const rows = sub.keys.map((k) => ({ key: k.key, depth: 1, total: k.count, members: 1, values: series(sub.cells, k.key, bucketIndex, sub.buckets.length), expandable: false })); view.expanded.set(key, { step, rows }); @@ -221,9 +230,9 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} } function collapse(key) { view.expanded.delete(key); if (data) collapseRows(key); draw(); renderTable(); } - // the whole-network bootstrap ran 2026-07-28 → 2026-08-03 ~07:00 utc (stream /status: "backfill took 6d 2h"); - // witnessed spans inside bootstrap segments are not a reliable tell, the date is + // the whole-network bootstrap ran 2026-07-28 → 2026-08-03 ~07:00 utc (stream /status: "backfill took 6d 2h") const BOOTSTRAP_END_US = 1785740400000000; + const ERA = { bootstrap: "history, replayed", live: "as it happened" }; const eras = (buckets) => buckets.map((b) => b.min_witnessed_at < BOOTSTRAP_END_US ? "bootstrap" : "live"); function draw() { @@ -232,7 +241,7 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = canvas.parentElement.clientWidth, narrow = width < 640; ctx.font = "12px ui-monospace, Menlo, monospace"; - const labelW = narrow ? Math.round(width * 0.36) : Math.min(300, Math.max(...rows.map((r) => ctx.measureText(r.key).width + (r.depth ? 26 : 16))) + 10); + const labelW = narrow ? Math.round(width * 0.36) : Math.min(300, Math.max(...rows.map((r) => ctx.measureText(name(r.key)).width + (r.depth ? 26 : 16))) + 10); const countW = narrow ? 0 : 68; const top = 6, axisH = 44; const maxTotal = Math.max(...rows.filter((r) => r.depth === 0).map((r) => r.total)); @@ -248,7 +257,7 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} ctx.font = "12px ui-monospace, Menlo, monospace"; ctx.textBaseline = "middle"; rows.forEach((r, ri) => { const y0 = offsets[ri], h = heights[ri]; - const label = r.depth ? r.key.slice(r.key.indexOf(".", r.key.indexOf(".") + 1) + 1) : r.key; + const label = r.depth ? r.key.slice(r.key.indexOf(".", r.key.indexOf(".") + 1) + 1) : name(r.key); ctx.textAlign = "right"; ctx.fillStyle = hover && hover.ri === ri ? hot : r.depth ? dim : ink; const glyph = r.expandable ? (view.expanded.has(r.key) ? "▾ " : "▸ ") : (r.depth ? "· " : ""); ctx.fillText(fitLabel(glyph + label, labelW - 12), labelW - 10, y0 + h / 2); @@ -267,14 +276,14 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} const x0 = plotX + start * cellW, x1 = plotX + bi * cellW; ctx.fillStyle = era[start] === "live" ? water : faint; ctx.fillRect(x0, axisY, Math.max(x1 - x0 - 1, 1), 2); ctx.fillStyle = era[start] === "live" ? water : dim; - if (x1 - x0 > 60) ctx.fillText(era[start], x0, axisY + 6); + if (x1 - x0 > 120) ctx.fillText(ERA[era[start]], x0, axisY + 6); start = bi; } } ctx.fillStyle = dim; let lastDay = "", lastX = -Infinity; for (let bi = 0; bi < buckets.length; bi++) { const b = buckets[bi], day = dayFmt.format(new Date(b.min_witnessed_at / 1000)), x = plotX + bi * cellW; - if (day !== lastDay && x - lastX > 96) { ctx.fillText(`${day} · #${b.first_idx}`, x, axisY + 22); lastDay = day; lastX = x; } + if (day !== lastDay && x - lastX > 96) { ctx.fillText(day, x, axisY + 22); lastDay = day; lastX = x; } } if (hover) { const { bi, ri } = hover; @@ -282,16 +291,16 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} ctx.strokeStyle = hot; ctx.lineWidth = 1.5; ctx.strokeRect(plotX + bi * cellW - 0.5, offsets[ri] - 0.5, Math.max(cellW - gap, 0.6) + 1, heights[ri] + 1); } $("legend-bar").style.background = `linear-gradient(90deg, ${[0, .25, .5, .75, 1].map(shade).join(",")})`; - $("legend-note").textContent = scale === "row" ? "· each layer shaded to its own peak" : "· log events, one scale for every layer"; + $("legend-note").textContent = scale === "row" ? "· brightest = that app's busiest column" : "· same scale for every app (log)"; } - function fitLabel(name, maxW) { - if (ctx.measureText(name).width <= maxW) return name; - const parts = name.split("."); + function fitLabel(text, maxW) { + if (ctx.measureText(text).width <= maxW) return text; + const parts = text.split("."); while (parts.length > 1 && ctx.measureText("…" + parts.join(".")).width > maxW) parts.shift(); - let text = "…" + parts.join("."); - while (text.length > 2 && ctx.measureText(text).width > maxW) text = "…" + text.slice(2); - return text; + let out = "…" + parts.join("."); + while (out.length > 2 && ctx.measureText(out).width > maxW) out = "…" + out.slice(2); + return out; } function cellAt(ev) { if (!layout || !data) return null; @@ -303,12 +312,13 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} } function read(c) { const r = data.rows[c.ri], b = c.bi >= 0 ? data.buckets[c.bi] : null; - const kind = r.depth ? "collection" : r.key === "other" ? "everything below the layers shown" : "namespace"; - if (!b) { reader.innerHTML = `${esc(r.key)} · ${kind} · ${fmt.format(r.total)} events${r.expandable ? ` across ${r.members} collections · click to split` : ""}`; return; } + const what = r.depth ? "record type" : r.key === OTHER ? "the apps not shown" : "app"; + if (!b) { reader.innerHTML = `${esc(name(r.key))} · ${what} · ${fmt.format(r.total)} records in the archive${r.expandable ? ` across ${r.members} record types · click to open` : ""}`; return; } const v = r.values[c.bi], share = b.event_count ? 100 * v / b.event_count : 0; const from = dayFmt.format(new Date(b.min_witnessed_at / 1000)), to = dayFmt.format(new Date(b.max_witnessed_at / 1000)); - reader.innerHTML = `${esc(r.key)} · ${fmt.format(v)} events in this column · ${share < 0.01 && v ? "<0.01" : share.toFixed(2)}% of its ${compact.format(b.event_count)}
` + - `segments #${b.first_idx}${b.last_idx !== b.first_idx ? `–${b.last_idx}` : ""} · seq ${fmt.format(b.min_seq)} – ${fmt.format(b.max_seq)} · witnessed ${from}${to !== from ? ` → ${to}` : ""} utc`; + const chunk = b.last_idx !== b.first_idx ? `chunks #${b.first_idx}–${b.last_idx}` : `chunk #${b.first_idx}`; + reader.innerHTML = `${esc(name(r.key))} wrote ${fmt.format(v)} records in this slice — ${share < 0.01 && v ? "under 0.01" : share.toFixed(2)}% of the ${compact.format(b.event_count)} in it
` + + `archive ${chunk} · received ${from}${to !== from ? ` → ${to}` : ""} utc · record numbers ${fmt.format(b.min_seq)} – ${fmt.format(b.max_seq)}`; } canvas.addEventListener("pointermove", (ev) => { const c = cellAt(ev); if (!c) { clearHover(); return; } @@ -317,15 +327,15 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} canvas.style.cursor = c.onLabel && data.rows[c.ri].expandable ? "pointer" : "crosshair"; read(c); }); - function clearHover() { if (hover) { hover = null; draw(); } reader.innerHTML = 'hover a layer to read it · click a namespace to split it into its collections'; } + function clearHover() { if (hover) { hover = null; draw(); } reader.innerHTML = 'move over the picture to read a cell · click an app\'s name to open it'; } canvas.addEventListener("pointerleave", clearHover); canvas.addEventListener("click", (ev) => { const c = cellAt(ev); if (!c) return; const r = data.rows[c.ri]; if (!r.expandable) return; view.expanded.has(r.key) ? collapse(r.key) : expand(r.key); }); function renderTable() { const wrap = $("table"); if (!data || wrap.hidden) return; const { rows, buckets } = data; - wrap.innerHTML = `${buckets.map((b) => ``).join("")}` + - rows.map((r) => `${Array.from(r.values, (v) => ``).join("")}`).join("") + `
layertotal#${b.first_idx}
${r.depth ? "  · " : ""}${esc(r.key)}${fmt.format(r.total)}${v ? fmt.format(v) : ""}
`; + wrap.innerHTML = `${buckets.map((b) => ``).join("")}` + + rows.map((r) => `${Array.from(r.values, (v) => ``).join("")}`).join("") + `
app / record typerecords#${b.first_idx}
${r.depth ? "  · " : ""}${esc(name(r.key))}${fmt.format(r.total)}${v ? fmt.format(v) : ""}
`; } $("table-toggle").addEventListener("click", (ev) => { const w = $("table"); w.hidden = !w.hidden; ev.currentTarget.setAttribute("aria-pressed", String(!w.hidden)); renderTable(); }); $("more").addEventListener("click", () => { view.top = Math.min(60, view.top + 12); $("more").disabled = view.top >= 60; load(); }); @@ -333,27 +343,28 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} $("scale").addEventListener("change", draw); $("find").addEventListener("change", (ev) => { const ns = ev.target.value.trim().toLowerCase(), hit = view.namespaces.find((n) => n.ns === ns); - if (!hit) { reader.innerHTML = `no namespace called ${esc(ns)} in the archive yet`; return; } + if (!hit) { reader.innerHTML = `no app called ${esc(ns)} has written to the archive yet`; return; } if (!view.pinned.some((p) => p.ns === ns)) view.pinned.push(hit); ev.target.value = ""; load(); }); window.addEventListener("resize", draw); - // ---- ask the tail: duckdb-wasm over the namespace's public parquet partitions ---- + // ---- look inside an app: duckdb-wasm over the app's public parquet files ---- const PROMPTS = [ - ["by collection", "select collection, op, count(*) as events, count(distinct did) as accounts\nfrom rows group by all order by events desc"], - ["adoption by day", "select strftime(make_timestamp(time_us), '%Y-%m-%d') as day, count(distinct did) as accounts, count(*) as events\nfrom rows where kind = 'commit' group by 1 order by 1"], - ["first seen", "select collection, min(seq) as first_seq, strftime(make_timestamp(min(time_us)), '%Y-%m-%d %H:%M') as first_witnessed\nfrom rows where kind = 'commit' group by 1 order by 2"], - ["busiest accounts", "select did, count(*) as events, count(distinct collection) as collections\nfrom rows where kind = 'commit' group by 1 order by 2 desc limit 20"], - ["op mix", "select op, count(*) as events from rows where kind = 'commit' group by 1 order by 2 desc"], + ["what it writes", "select collection as record_type, op as action, count(*) as records, count(distinct did) as accounts\nfrom rows group by all order by records desc"], + ["people per day", "select strftime(make_timestamp(time_us), '%Y-%m-%d') as day, count(distinct did) as accounts, count(*) as records\nfrom rows where kind = 'commit' group by 1 order by 1"], + ["when each type first appeared", "select collection as record_type, strftime(make_timestamp(min(time_us)), '%Y-%m-%d %H:%M') as first_seen, count(*) as records\nfrom rows where kind = 'commit' group by 1 order by min(time_us)"], + ["most active accounts", "select did as account, count(*) as records, count(distinct collection) as record_types\nfrom rows where kind = 'commit' group by 1 order by 2 desc limit 20"], + ["creates vs deletes", "select op as action, count(*) as records from rows where kind = 'commit' group by 1 order by 2 desc"], ]; const askNs = $("ask-ns"), askSql = $("ask-sql"), askOut = $("ask-out"), askRun = $("ask-run"), askTable = $("ask-table"); - for (const [name, sql] of PROMPTS) { const b = document.createElement("button"); b.type = "button"; b.textContent = name; b.addEventListener("click", () => { askSql.value = sql; if (askNs.value) runAsk(); }); $("prompts").append(b); } + for (const [label, sql] of PROMPTS) { const b = document.createElement("button"); b.type = "button"; b.textContent = label; b.addEventListener("click", () => { askSql.value = sql; if (askNs.value) runAsk(); }); $("prompts").append(b); } askSql.value = PROMPTS[0][1]; + $("ask-edit").addEventListener("click", (ev) => { askSql.hidden = !askSql.hidden; ev.currentTarget.setAttribute("aria-pressed", String(!askSql.hidden)); if (!askSql.hidden) askSql.focus(); }); let duck = null, partition = null; async function duckdb() { if (duck) return duck; - askOut.innerHTML = "loading duckdb-wasm…"; + askOut.innerHTML = "loading the query engine…"; const mod = await import("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm"); const bundle = await mod.selectBundle(mod.getJsDelivrBundles()); const worker = new Worker(URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }))); @@ -363,13 +374,14 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} return duck; } askNs.addEventListener("change", async () => { - partition = null; askRun.disabled = true; askTable.innerHTML = ""; + partition = null; askRun.disabled = true; askTable.innerHTML = ""; askOut.innerHTML = ""; if (!askNs.value) { $("ask-size").textContent = "pick one"; return; } - $("ask-size").textContent = "listing…"; - const p = await api(`/api/partitions?ns=${askNs.value}`); + $("ask-size").textContent = "checking…"; + const p = await api(`api/partitions?ns=${askNs.value}`); if (p.error) { $("ask-size").textContent = p.error; return; } partition = p; - $("ask-size").textContent = p.files.length ? `${p.files.length} segment file${p.files.length === 1 ? "" : "s"} · ${p.bytes < 1 << 20 ? `${Math.round(p.bytes / 1024)} KB` : `${(p.bytes / (1 << 20)).toFixed(1)} MB`} to scan` : "not drained yet"; + const size = p.bytes < 1 << 20 ? `${Math.round(p.bytes / 1024)} KB` : `${(p.bytes / (1 << 20)).toFixed(1)} MB`; + $("ask-size").textContent = p.files.length ? `${size} to read from ${p.files.length} chunk${p.files.length === 1 ? "" : "s"}` : "not copied out of the archive yet"; askRun.disabled = !p.files.length; }); async function runAsk() { @@ -377,15 +389,14 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} askRun.disabled = true; try { const conn = await duckdb(); - const list = partition.files.map((f) => `'${f}'`).join(", "); - await conn.query(`create or replace view rows as select * from read_parquet([${list}])`); + await conn.query(`create or replace view rows as select * from read_parquet([${partition.files.map((f) => `'${f}'`).join(", ")}])`); const t = performance.now(); const res = await conn.query(askSql.value); const cols = res.schema.fields.map((f) => f.name); const out = res.toArray().map((r) => r.toJSON()); - askOut.innerHTML = `${fmt.format(out.length)} row${out.length === 1 ? "" : "s"} in ${(performance.now() - t).toFixed(0)} ms over ${partition.files.length} file${partition.files.length === 1 ? "" : "s"}`; - const cell = (v) => v === null || v === undefined ? "" : typeof v === "bigint" ? fmt.format(v) : typeof v === "number" ? fmt.format(v) : esc(v); - askTable.innerHTML = `${cols.map((c) => ``).join("")}${out.slice(0, 500).map((r) => `${cols.map((c) => ``).join("")}`).join("")}
${esc(c)}
${cell(r[c])}
` + (out.length > 500 ? `

showing 500 of ${fmt.format(out.length)}

` : ""); + askOut.innerHTML = `${fmt.format(out.length)} row${out.length === 1 ? "" : "s"} in ${(performance.now() - t).toFixed(0)} ms`; + const cell = (v) => v === null || v === undefined ? "" : typeof v === "bigint" || typeof v === "number" ? fmt.format(v) : esc(v); + askTable.innerHTML = `${cols.map((c) => ``).join("")}${out.slice(0, 500).map((r) => `${cols.map((c) => ``).join("")}`).join("")}
${esc(c.replaceAll("_", " "))}
${cell(r[c])}
` + (out.length > 500 ? `

showing 500 of ${fmt.format(out.length)}

` : ""); } catch (err) { askOut.innerHTML = `${esc(err && err.message || err)}`; } finally { askRun.disabled = false; }