--retry-failed reaches the UI

The CLI could retry failed upload jobs; the web app couldn't, so a run
that hit a bug (twice today) left work only a terminal could reclaim.
/api/pipeline now reports the failed count, and the upload card grows a
"retry N failed" checkbox — shown only when there are failures — that
rides along with both Dry run and the real Upload. Help explains why
failures are skipped by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 22:44:23 -04:00
co-authored by Claude Fable 5
parent 9283a3e980
commit e49d1234d6
4 changed files with 37 additions and 13 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it, and the next extract run re-reads it.</p> <p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it, and the next extract run re-reads it.</p>
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p> <p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p> <p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first.</p> <p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox.</p>
<p><b><a href="/library">Library</a></b> — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.</p> <p><b><a href="/library">Library</a></b> — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.</p>
</div> </div>
+14 -5
View File
@@ -58,18 +58,27 @@ function render() {
stageCard(5, "upload", uploadFacts, stageCard(5, "upload", uploadFacts,
`${runBtn("upload", "Dry run")} `${runBtn("upload", "Dry run")}
<button class="danger" data-upload-real ${RUNNING || P.stub_data ? "disabled" : ""}>Upload</button> <button class="danger" data-upload-real ${RUNNING || P.stub_data ? "disabled" : ""}>Upload</button>
<label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>`), <label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>
${P.upload_failed
? `<label title="failed jobs are skipped on normal runs so a broken one can't loop">
<input type="checkbox" id="upretry"> retry ${P.upload_failed} failed</label>`
: ""}`),
stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")), stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")),
].join(""); ].join("");
document.querySelectorAll("[data-run]").forEach(b => document.querySelectorAll("[data-run]").forEach(b =>
b.addEventListener("click", () => runStage(b.dataset.run, b.addEventListener("click", () => runStage(b.dataset.run,
b.dataset.run === "upload" ? {dry_run: true} : {}))); b.dataset.run === "upload" ? {dry_run: true, ...uploadOpts()} : {})));
const uploadOpts = () => ({
limit: Number(document.getElementById("uplimit").value) || null,
retry_failed: !!document.getElementById("upretry")?.checked,
});
const real = document.querySelector("[data-upload-real]"); const real = document.querySelector("[data-upload-real]");
if (real) real.addEventListener("click", () => { if (real) real.addEventListener("click", () => {
const limit = Number(document.getElementById("uplimit").value) || null; const {limit, retry_failed} = uploadOpts();
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return; if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}` +
runStage("upload", {dry_run: false, limit}); `${retry_failed ? ", retrying previously failed jobs" : ""}? A browser window will open.`)) return;
runStage("upload", {dry_run: false, limit, retry_failed});
}); });
const job = P.job; const job = P.job;
+14 -3
View File
@@ -194,6 +194,7 @@ class RemoveBody(BaseModel):
class RunBody(BaseModel): class RunBody(BaseModel):
dry_run: bool = True # upload only; the safe direction is the default dry_run: bool = True # upload only; the safe direction is the default
limit: int | None = None limit: int | None = None
retry_failed: bool = False
class DismissBody(BaseModel): class DismissBody(BaseModel):
@@ -222,10 +223,14 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
return run_diff(cfg) return run_diff(cfg)
def upload(dry_run: bool = True, limit: int | None = None) -> object: def upload(
dry_run: bool = True,
limit: int | None = None,
retry_failed: bool = False,
) -> object:
from bggpipe.upload import run_upload from bggpipe.upload import run_upload
return run_upload(cfg, dry_run=dry_run, limit=limit) return run_upload(cfg, dry_run=dry_run, limit=limit, retry_failed=retry_failed)
def enrich() -> object: def enrich() -> object:
from bggpipe.enrich import run_enrich from bggpipe.enrich import run_enrich
@@ -824,6 +829,7 @@ def create_app(
"to_add": _csv_count(cfg.to_add_path), "to_add": _csv_count(cfg.to_add_path),
"to_update": _csv_count(cfg.to_update_path), "to_update": _csv_count(cfg.to_update_path),
"upload_log": dict(log_counts), "upload_log": dict(log_counts),
"upload_failed": log_counts.get("failed", 0),
"games": games, "games": games,
"job": jobs.snapshot(), "job": jobs.snapshot(),
} }
@@ -834,7 +840,12 @@ def create_app(
raise HTTPException(404, f"unknown stage {stage!r}") raise HTTPException(404, f"unknown stage {stage!r}")
body = body or RunBody() body = body or RunBody()
if stage == "upload": if stage == "upload":
fn = partial(stages["upload"], dry_run=body.dry_run, limit=body.limit) fn = partial(
stages["upload"],
dry_run=body.dry_run,
limit=body.limit,
retry_failed=body.retry_failed,
)
else: else:
fn = stages[stage] fn = stages[stage]
if not jobs.start(stage, fn): if not jobs.start(stage, fn):
+8 -4
View File
@@ -113,17 +113,21 @@ def test_upload_defaults_to_dry_run(tmp_path):
calls = [] calls = []
jobs = JobRunner() jobs = JobRunner()
def upload(dry_run=True, limit=None): def upload(dry_run=True, limit=None, retry_failed=False):
calls.append({"dry_run": dry_run, "limit": limit}) calls.append({"dry_run": dry_run, "limit": limit, "retry_failed": retry_failed})
web = _app(cfg, stages={"upload": upload}, jobs=jobs) web = _app(cfg, stages={"upload": upload}, jobs=jobs)
web.post("/api/run/upload") # no body: the safe direction web.post("/api/run/upload") # no body: the safe direction
jobs.wait() jobs.wait()
web.post("/api/run/upload", json={"dry_run": False, "limit": 2}) web.post("/api/run/upload", json={"dry_run": False, "limit": 2})
jobs.wait() jobs.wait()
# a failed job is skipped on normal runs; the UI opts back in
web.post("/api/run/upload", json={"dry_run": False, "retry_failed": True})
jobs.wait()
assert calls == [ assert calls == [
{"dry_run": True, "limit": None}, {"dry_run": True, "limit": None, "retry_failed": False},
{"dry_run": False, "limit": 2}, {"dry_run": False, "limit": 2, "retry_failed": False},
{"dry_run": False, "limit": None, "retry_failed": True},
] ]