--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="/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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user