+
+
Delete saved run
+
Delete this reconciliation run?
+
+ This permanently removes the saved result, source rows, matches, and review history from this local RowBridge data directory.
+
+
+
+
+
- Files
+ - {{ run.filename_a }} → {{ run.filename_b }}
+
+
+
- Created
+ - {{ run.created_at[:10] }} {{ run.created_at[11:19] }} UTC
+
+
+
- Source rows
+ - {{ run.total_a }} + {{ run.total_b }}
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/rowbridge/templates/runs.html b/src/rowbridge/templates/runs.html
new file mode 100644
index 0000000..8d29c32
--- /dev/null
+++ b/src/rowbridge/templates/runs.html
@@ -0,0 +1,88 @@
+{% extends "base.html" %}
+{% block title %}Runs | RowBridge{% endblock %}
+{% block content %}
+
+
+
+ {% if runs %}
+
+
+
{{ total_runs }} saved run{% if total_runs != 1 %}s{% endif %}
+
Newest first
+
+
+
+ {% if total_pages > 1 %}
+
+ {% endif %}
+
+ {% else %}
+
+
No saved runs
+
Your reconciliation history will appear here.
+
Start a reconciliation and RowBridge will keep the result in the local data directory.
+
Start a reconciliation
+
+ {% endif %}
+
+{% endblock %}
diff --git a/src/rowbridge/web.py b/src/rowbridge/web.py
index 2f25459..2d0b2bd 100644
--- a/src/rowbridge/web.py
+++ b/src/rowbridge/web.py
@@ -291,6 +291,50 @@ def create_run(
discard_staged_upload(stage_dir)
return RedirectResponse(url=f"/runs/{run_id}", status_code=303)
+ @app.get("/runs", response_class=HTMLResponse)
+ def run_history(request: Request, page: int = 1) -> HTMLResponse:
+ if page < 1:
+ raise HTTPException(status_code=400, detail="Page number must be positive")
+
+ total_runs = repository.count_runs()
+ page_size = app_settings.runs_page_size
+ total_pages = max(1, math.ceil(total_runs / page_size))
+ if page > total_pages:
+ raise HTTPException(status_code=404, detail="Run history page not found")
+
+ runs = repository.list_runs(
+ limit=page_size,
+ offset=(page - 1) * page_size,
+ )
+ return templates.TemplateResponse(
+ request=request,
+ name="runs.html",
+ context={
+ "runs": runs,
+ "page": page,
+ "total_pages": total_pages,
+ "total_runs": total_runs,
+ "pagination_pages": _pagination_window(page, total_pages),
+ },
+ )
+
+ @app.get("/runs/{run_id}/delete", response_class=HTMLResponse)
+ def confirm_delete_run(request: Request, run_id: str) -> HTMLResponse:
+ run = repository.get_run(run_id)
+ if run is None:
+ raise HTTPException(status_code=404, detail="Run not found")
+ return templates.TemplateResponse(
+ request=request,
+ name="delete_run.html",
+ context={"run": run},
+ )
+
+ @app.post("/runs/{run_id}/delete")
+ def delete_run(run_id: str) -> RedirectResponse:
+ if not repository.delete_run(run_id):
+ raise HTTPException(status_code=404, detail="Run not found")
+ return RedirectResponse(url="/runs", status_code=303)
+
@app.get("/runs/{run_id}", response_class=HTMLResponse)
def results(
request: Request,
diff --git a/tests/test_storage.py b/tests/test_storage.py
index b1bec8e..cf74c98 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import sqlite3
from datetime import UTC, datetime
from pathlib import Path
@@ -80,3 +81,133 @@ def test_unmatched_lookup_by_source_row_supports_large_manual_link_workflows(
assert b_match is not None
assert a_match.a_payload == {"id": "A-001"}
assert b_match.b_payload == {"id": "B-000"}
+
+
+def test_run_history_is_newest_first_and_uses_current_status_counts(
+ tmp_path: Path,
+) -> None:
+ repository = make_repository(tmp_path)
+
+ repository.save_run(
+ run_id="c" * 32,
+ created_at="2026-10-04T09:00:00+00:00",
+ filename_a="orders.csv",
+ filename_b="payments.csv",
+ mapping=FieldMapping(primary_a="id", primary_b="id"),
+ settings=MatchSettings(),
+ rows_a=({"id": "A-1"}, {"id": "A-2"}),
+ rows_b=({"id": "B-1"}, {"id": "B-2"}),
+ decisions=(
+ MatchDecision(0, 0, 1.0, MatchStatus.AUTO_MATCHED, ()),
+ MatchDecision(1, None, 0.0, MatchStatus.UNMATCHED, ()),
+ MatchDecision(None, 1, 0.0, MatchStatus.UNMATCHED, ()),
+ ),
+ )
+ repository.save_run(
+ run_id="d" * 32,
+ created_at="2026-10-04T10:00:00+00:00",
+ filename_a="ledger.csv",
+ filename_b="bank.xlsx",
+ mapping=FieldMapping(primary_a="id", primary_b="id"),
+ settings=MatchSettings(),
+ rows_a=({"id": "A-9"},),
+ rows_b=({"id": "B-9"},),
+ decisions=(MatchDecision(0, 0, 0.8, MatchStatus.REVIEW, ()),),
+ )
+
+ review_match = repository.list_matches(
+ "d" * 32,
+ statuses=(MatchStatus.REVIEW,),
+ limit=1,
+ )[0]
+ repository.accept_review("d" * 32, review_match.id)
+
+ assert repository.count_runs() == 2
+
+ first_page = repository.list_runs(limit=1)
+ second_page = repository.list_runs(limit=1, offset=1)
+
+ assert [item.id for item in first_page] == ["d" * 32]
+ assert [item.id for item in second_page] == ["c" * 32]
+
+ newest = first_page[0]
+ assert newest.filename_a == "ledger.csv"
+ assert newest.filename_b == "bank.xlsx"
+ assert newest.total_a == 1
+ assert newest.total_b == 1
+ assert newest.auto_matched == 0
+ assert newest.human_matched == 1
+ assert newest.review == 0
+ assert newest.unmatched == 0
+
+ older = second_page[0]
+ assert older.auto_matched == 1
+ assert older.human_matched == 0
+ assert older.review == 0
+ assert older.unmatched == 2
+
+
+def test_delete_run_cascades_children_without_touching_other_runs(
+ tmp_path: Path,
+) -> None:
+ repository = make_repository(tmp_path)
+ deleted_run_id = "e" * 32
+ kept_run_id = "f" * 32
+
+ repository.save_run(
+ run_id=deleted_run_id,
+ created_at="2026-10-04T11:00:00+00:00",
+ filename_a="delete-a.csv",
+ filename_b="delete-b.csv",
+ mapping=FieldMapping(primary_a="id", primary_b="id"),
+ settings=MatchSettings(),
+ rows_a=({"id": "A-1"},),
+ rows_b=({"id": "B-1"},),
+ decisions=(MatchDecision(0, 0, 0.8, MatchStatus.REVIEW, ()),),
+ )
+ review_match = repository.list_matches(
+ deleted_run_id,
+ statuses=(MatchStatus.REVIEW,),
+ limit=1,
+ )[0]
+ repository.accept_review(deleted_run_id, review_match.id)
+
+ repository.save_run(
+ run_id=kept_run_id,
+ created_at="2026-10-04T12:00:00+00:00",
+ filename_a="keep-a.csv",
+ filename_b="keep-b.csv",
+ mapping=FieldMapping(primary_a="id", primary_b="id"),
+ settings=MatchSettings(),
+ rows_a=({"id": "A-2"},),
+ rows_b=({"id": "B-2"},),
+ decisions=(MatchDecision(0, 0, 1.0, MatchStatus.AUTO_MATCHED, ()),),
+ )
+
+ assert repository.delete_run(deleted_run_id) is True
+ assert repository.delete_run(deleted_run_id) is False
+ assert repository.get_run(deleted_run_id) is None
+ assert repository.get_run(kept_run_id) is not None
+
+ with sqlite3.connect(repository.database_path) as connection:
+ for table in ("source_rows", "matches", "review_events"):
+ deleted_count = connection.execute(
+ f"SELECT COUNT(*) FROM {table} WHERE run_id = ?",
+ (deleted_run_id,),
+ ).fetchone()
+ assert deleted_count is not None
+ assert deleted_count[0] == 0
+
+ kept_source_rows = connection.execute(
+ "SELECT COUNT(*) FROM source_rows WHERE run_id = ?",
+ (kept_run_id,),
+ ).fetchone()
+ kept_matches = connection.execute(
+ "SELECT COUNT(*) FROM matches WHERE run_id = ?",
+ (kept_run_id,),
+ ).fetchone()
+
+ assert kept_source_rows is not None
+ assert kept_matches is not None
+ assert kept_source_rows[0] == 2
+ assert kept_matches[0] == 1
diff --git a/tests/test_web.py b/tests/test_web.py
index 607572d..376d8c7 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -66,6 +66,137 @@ def review_match_id(html: str) -> int:
return int(match.group(1))
+def test_runs_page_shows_empty_state_and_header_link(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+
+ home = client.get("/")
+ history = client.get("/runs")
+
+ assert home.status_code == 200
+ assert 'href="/runs">Runs' in home.text
+ assert history.status_code == 200
+ assert "Reconciliation runs" in history.text
+ assert "No saved runs" in history.text
+ assert "Start a reconciliation" in history.text
+
+ missing = client.get("/runs?page=2")
+ assert missing.status_code == 404
+ assert "Run history page not found" in missing.text
+
+
+def test_runs_page_lists_saved_run_with_current_summary(tmp_path: Path) -> None:
+ client = make_client(tmp_path)
+ location = create_sample_run(client)
+ results = client.get(location)
+ match_id = review_match_id(results.text)
+ accepted = client.post(
+ f"{location}/matches/{match_id}/accept",
+ follow_redirects=False,
+ )
+ assert accepted.status_code == 303
+
+ history = client.get("/runs")
+
+ assert history.status_code == 200
+ assert "orders.csv" in history.text
+ assert "payments.csv" in history.text
+ assert f'href="{location}">Open' in history.text
+ assert '