Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit befc694178c5cb78eed418baa3c7c238cf8c9090
parent 3e5791ac2416dc60d6ad7debe1b049d6e929ca4d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 19:28:36 -0400

umtool: close the "not fetched yet" gap in one press

`ready N of M needing judgement` is the walk's own measure of what is waiting
on a download rather than on a person, and closing it meant opening every row
marked "not fetched yet" and pressing "fetch more" on each. The project page
now offers that as one button above the cut.

ONE AT A TIME, awaiting each job before starting the next. The job runner
serialises anyway (a second POST is a 409), but more to the point a burst is
what trips a bot check, and not bursting is the whole reason the fetch moved
into the editor. Stop abandons rather than kills: the in-flight fetch is
already paid for and its bytes are worth keeping, so Stop means "start no
more". A refusal is shown in the route's own words — "already cached to −N s"
and "a job is already running (build)" are different problems.

unfetchedNeedingJudgement is the list behind the count, so the button and
`ready N of M` cannot disagree about which clips those are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Aumtool/components/projects/FetchUnfetchedButton.tsx | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ReportProject.tsx | 19+++++++++++++++++++
Mumtool/lib/projects/report.mjs | 18++++++++++++++++++
3 files changed, 186 insertions(+), 0 deletions(-)

diff --git a/umtool/components/projects/FetchUnfetchedButton.tsx b/umtool/components/projects/FetchUnfetchedButton.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useRef, useState } from "react"; +import { buttonVariants } from "@/components/ui/button"; + +// --------------------------------------------------------------------------- +// "Fetch the clips nobody can watch yet." +// +// The walk skips a clip with nothing cached: a half-covered clip cannot be +// watched through, so there is nothing to judge. That gap — `ready N of M` — +// used to be closed one bench page at a time, pressing "fetch more" on every +// row marked "not fetched yet". This is that, as one button. +// +// ONE AT A TIME, and it has to be. Each press is a download from somebody +// else's server, the job runner serialises anyway (a second POST is a 409), and +// the whole reason this now goes through the editor is that a burst is what +// trips a bot check. So it awaits each job before starting the next, and says +// which one it is on. +// +// STOP ABANDONS, IT DOES NOT KILL. The in-flight fetch is already paid for and +// its bytes are worth keeping; what Stop means is "start no more". +// --------------------------------------------------------------------------- + +type Pending = { id: string; padBefore: number; padAfter: number }; + +type JobView = { id: string; state: "running" | "done" | "failed"; error: string | null }; + +export default function FetchUnfetchedButton({ + project, + pending, +}: { + project: string; + // The clips that need judgement and have nothing cached holding them, in + // walk order. Computed on the server, where `fetched` already is. + pending: Pending[]; +}) { + const [running, setRunning] = useState(false); + const [done, setDone] = useState(0); + const [msg, setMsg] = useState<string | null>(null); + const [failed, setFailed] = useState(false); + const stop = useRef(false); + + if (!pending.length && !running) return null; + + const total = pending.length; + + async function poll(): Promise<JobView | null> { + for (;;) { + await new Promise((r) => setTimeout(r, 1000)); + const r = await fetch("/api/report/fetch", { cache: "no-store" }); + const j = (await r.json()) as { job: JobView | null }; + if (!j.job) return null; + if (j.job.state !== "running") return j.job; + } + } + + async function run() { + stop.current = false; + setRunning(true); + setFailed(false); + setDone(0); + setMsg(null); + let i = 0; + for (const clip of pending) { + if (stop.current) { + setMsg(`stopped after ${i} of ${total}`); + break; + } + setMsg(`fetching ${clip.id} — ${i + 1} of ${total}`); + const res = await fetch("/api/report/fetch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + project, + clip: clip.id, + padBefore: clip.padBefore, + padAfter: clip.padAfter, + }), + }); + const body = (await res.json().catch(() => ({}))) as { + error?: string; + job?: JobView; + }; + if (res.status === 409) { + // The route's own words, not a paraphrase: "already cached to −N s" + // and "a job is already running (build)" are different problems and + // only one of them is this button's to solve. + setFailed(true); + setMsg(body.error ?? "refused"); + break; + } + if (!res.ok) { + setFailed(true); + setMsg(body.error ?? `HTTP ${res.status}`); + break; + } + const finished = await poll(); + if (finished?.state === "failed") { + setFailed(true); + setMsg(`${clip.id}: ${finished.error ?? "fetch failed"}`); + break; + } + i += 1; + setDone(i); + } + setRunning(false); + if (!failed && !stop.current && i === total) { + setMsg(`fetched ${total} clip${total === 1 ? "" : "s"} — reload to see them`); + } + } + + return ( + <div className="flex flex-wrap items-center gap-2" data-fetch-unfetched=""> + <button + type="button" + data-action="fetch-unfetched" + data-state={running ? "busy" : failed ? "failed" : done ? "done" : "idle"} + disabled={running || !pending.length} + className={buttonVariants({ size: "sm" })} + onClick={() => void run()} + > + {running + ? `fetching ${done + 1} of ${total}…` + : `fetch ${total} unfetched clip${total === 1 ? "" : "s"} via the editor`} + </button> + {running && ( + <button + type="button" + data-action="fetch-unfetched-stop" + className={buttonVariants({ variant: "ghost", size: "sm" })} + onClick={() => { + stop.current = true; + setMsg("stopping after this one…"); + }} + > + stop + </button> + )} + {msg && ( + <span + data-fetch-unfetched-msg="" + className={`text-[11px] ${failed ? "text-[var(--color-bad)]" : "text-[var(--color-dim)]"}`} + > + {msg} + </span> + )} + </div> + ); +} diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx @@ -11,11 +11,13 @@ import { readClipDetail, reviewOf, sourcesOf, + unfetchedNeedingJudgement, walkReadiness, } from "@/lib/projects/report.mjs"; import { EXPORT_FORMATS, exportableVariants } from "@/lib/report/export.mjs"; import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs"; import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs"; +import FetchUnfetchedButton from "./FetchUnfetchedButton"; import ReportBuildChain from "./ReportBuildChain"; import SnapshotButton from "./SnapshotButton"; import { decisionsForProject } from "@/lib/projects"; @@ -95,6 +97,9 @@ export default async function ReportProject({ // still need judgement and have something to play, so the gap between these // two numbers is what is waiting on a download rather than on somebody. const readiness = walkReadiness(clips); + // What the walk is waiting on: the same clips the rows below mark "not + // fetched yet". + const pendingFetch = unfetchedNeedingJudgement(clips) as string[]; const walkStart = (readiness.readyIds[0] as string | undefined) ?? clips[0]?.id; const nonClips = entries.filter((e) => e.kind !== "clip"); const runtime = clips.reduce((n, e) => n + Math.max(0, e.end - e.start), 0); @@ -401,6 +406,20 @@ export default async function ReportProject({ <h2 className="micro mb-1.5"> the cut — {entries.length} entries, in array order </h2> + {/* The gap between "ready" and "needing", closed in one press. Every + row below it that reads "not fetched yet" is one of these, and + closing them one bench page at a time was the loop this replaces. + The default pad is the fetch route's own (±20 s), so a clip + fetched here and a clip fetched from the bench land in the same + file. */} + {pendingFetch.length > 0 && ( + <div className="mb-1.5"> + <FetchUnfetchedButton + project={project.id} + pending={pendingFetch.map((id) => ({ id, padBefore: 20, padAfter: 20 }))} + /> + </div> + )} <ul className="space-y-1"> {entries.map((e) => { // Anything that is not a CLIP renders generically. The timeline's diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -203,6 +203,24 @@ export function reviewOf(m) { * * @param {{id: string, kind?: string, fetched?: boolean}[]} entries readClipDetail's entries */ +/** + * The clips the walk is WAITING ON: needing judgement, with nothing cached that + * holds them end to end. Exactly the gap between `ready` and `needing` above, + * as a list — so "ready 3 of 7" and the button that closes it cannot disagree + * about which four clips those are. + * + * In timeline order, because that is the order somebody would watch them in + * and therefore the order they are worth having. + * + * @param {{id: string, kind?: string, fetched?: boolean}[]} entries + */ +export function unfetchedNeedingJudgement(entries) { + return (entries ?? []) + .filter((e) => (e.kind ?? e.type) === "clip") + .filter((e) => clipVerdict(e) === "unreviewed" && !e.fetched) + .map((e) => e.id); +} + export function walkReadiness(entries) { const clips = (entries ?? []).filter((e) => (e.kind ?? e.type) === "clip"); const needing = clips.filter((e) => clipVerdict(e) === "unreviewed");