--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:
co-authored by
Claude Fable 5
parent
9283a3e980
commit
e49d1234d6
@@ -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="/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="/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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,18 +58,27 @@ function render() {
|
||||
stageCard(5, "upload", uploadFacts,
|
||||
`${runBtn("upload", "Dry run")}
|
||||
<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")),
|
||||
].join("");
|
||||
|
||||
document.querySelectorAll("[data-run]").forEach(b =>
|
||||
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]");
|
||||
if (real) real.addEventListener("click", () => {
|
||||
const limit = Number(document.getElementById("uplimit").value) || null;
|
||||
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return;
|
||||
runStage("upload", {dry_run: false, limit});
|
||||
const {limit, retry_failed} = uploadOpts();
|
||||
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${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;
|
||||
|
||||
@@ -194,6 +194,7 @@ class RemoveBody(BaseModel):
|
||||
class RunBody(BaseModel):
|
||||
dry_run: bool = True # upload only; the safe direction is the default
|
||||
limit: int | None = None
|
||||
retry_failed: bool = False
|
||||
|
||||
|
||||
class DismissBody(BaseModel):
|
||||
@@ -222,10 +223,14 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
from bggpipe.enrich import run_enrich
|
||||
@@ -824,6 +829,7 @@ def create_app(
|
||||
"to_add": _csv_count(cfg.to_add_path),
|
||||
"to_update": _csv_count(cfg.to_update_path),
|
||||
"upload_log": dict(log_counts),
|
||||
"upload_failed": log_counts.get("failed", 0),
|
||||
"games": games,
|
||||
"job": jobs.snapshot(),
|
||||
}
|
||||
@@ -834,7 +840,12 @@ def create_app(
|
||||
raise HTTPException(404, f"unknown stage {stage!r}")
|
||||
body = body or RunBody()
|
||||
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:
|
||||
fn = stages[stage]
|
||||
if not jobs.start(stage, fn):
|
||||
|
||||
@@ -113,17 +113,21 @@ def test_upload_defaults_to_dry_run(tmp_path):
|
||||
calls = []
|
||||
jobs = JobRunner()
|
||||
|
||||
def upload(dry_run=True, limit=None):
|
||||
calls.append({"dry_run": dry_run, "limit": limit})
|
||||
def upload(dry_run=True, limit=None, retry_failed=False):
|
||||
calls.append({"dry_run": dry_run, "limit": limit, "retry_failed": retry_failed})
|
||||
|
||||
web = _app(cfg, stages={"upload": upload}, jobs=jobs)
|
||||
web.post("/api/run/upload") # no body: the safe direction
|
||||
jobs.wait()
|
||||
web.post("/api/run/upload", json={"dry_run": False, "limit": 2})
|
||||
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 == [
|
||||
{"dry_run": True, "limit": None},
|
||||
{"dry_run": False, "limit": 2},
|
||||
{"dry_run": True, "limit": None, "retry_failed": False},
|
||||
{"dry_run": False, "limit": 2, "retry_failed": False},
|
||||
{"dry_run": False, "limit": None, "retry_failed": True},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user