From f4a73494f7453c042c7e0b4bdaa33364ac0d99a6 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 08:53:56 +0100 Subject: [PATCH] feat: adopt shared dashboard freshness footer --- .github/workflows/dashboard_refresh.yml | 9 +++++---- pulse/board.py | 12 +++++++++++ pulse/campaigns.py | 4 +++- pulse/ingest.py | 20 ++++++++++++++---- tests/test_board.py | 27 ++++++++++++++++++++++++- tests/test_ingest.py | 12 +++++++++-- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dashboard_refresh.yml b/.github/workflows/dashboard_refresh.yml index 3624dee..7f8c30d 100644 --- a/.github/workflows/dashboard_refresh.yml +++ b/.github/workflows/dashboard_refresh.yml @@ -17,10 +17,11 @@ name: Dashboard Refresh # - pull_request: a dry run (ingest + render, no commit) so a reader change # is exercised against the live feeds before it merges. # -# Receipts and snapshots carry their `fetched_at` while nothing changes, the -# pages carry no wall-clock stamp, and state.json's `updated` moves only when -# the feed's content does, so a night with no new project commit commits -# nothing. A failed fetch keeps the committed snapshot (shown as cached, never +# Successful refreshes update receipt observation metadata and panel freshness. +# Snapshot capture/evidence dates remain unchanged on identical input; state.json +# `updated` still moves only when feed content changes. A refresh can commit +# observation metadata even when project evidence is unchanged. A failed fetch +# keeps the committed snapshot (shown as cached, never # re-aged). The body map is read from a checkout of PyAutoMind (public). The # bot commit uses GITHUB_TOKEN, which never triggers other workflows, so after # a commit this workflow dispatches pages_dashboard.yml explicitly. diff --git a/pulse/board.py b/pulse/board.py index 07c0a72..ebf0106 100644 --- a/pulse/board.py +++ b/pulse/board.py @@ -533,6 +533,17 @@ def _html_detail(s: Snapshot, now: str | None = None) -> str: return "".join(parts) +def _captured_at(views): + """Oldest successful observation of displayed inputs; failed or missing inputs stay unknown. + + Capture time is separate from producer evidence and content-only state.updated. + """ + if not views or any(v.outcome != "ok" or not v.refreshed_at for v in views): + return None + times = [summary_mod.parse_utc(v.refreshed_at) for v in views] + return min(times) if all(times) else None + + def render_html(views, now: str | None = None, campaign_data: dict | None = None) -> str: now = now or _utc_now() shared = setup_browser.theme() @@ -573,6 +584,7 @@ def render_html(views, now: str | None = None, campaign_data: dict | None = None + "
" + campaigns.render_html( campaign_data if campaign_data is not None else campaigns.load(), + refreshed_at=_captured_at(views), work_links=[ {"label": "PyAutoPulse", "href": REPO_URL}, *({"label": v.instance.repo, "href": v.instance.github_url} for v in views), diff --git a/pulse/campaigns.py b/pulse/campaigns.py index 21196ae..39e319d 100644 --- a/pulse/campaigns.py +++ b/pulse/campaigns.py @@ -176,7 +176,7 @@ def markdown(data: dict) -> str: return "\n".join(rows) -def render_html(data: dict, work_links=()) -> str: +def render_html(data: dict, work_links=(), refreshed_at=None) -> str: def e(value): return html.escape(str(value), quote=True) @@ -199,6 +199,8 @@ def action(key, title, prompt, review=""): work_links=work_links, copy_label="Profiling Check In", organ="pulse", + refreshed_at=refreshed_at, + refresh_url="https://github.com/PyAutoLabs/PyAutoPulse/actions/workflows/dashboard_refresh.yml", ), '
', action("fix", "Fix Profiling Systematically", FIX_PROMPT), diff --git a/pulse/ingest.py b/pulse/ingest.py index 776e096..c7c2f34 100644 --- a/pulse/ingest.py +++ b/pulse/ingest.py @@ -10,9 +10,11 @@ that snapshot and the board shows it labelled **cached**, with its original evidence and fetch times and the new error: nothing re-ages it. -Receipts and snapshots are written only when their content changes (the -``fetched_at`` of an unchanged outcome at the same commit is carried), so a -nightly refresh against an unchanged project commits nothing. A local +Snapshots preserve capture history for unchanged input. Receipts separately +record ``refreshed_at`` for each successful observation, so real refreshes +can commit metadata even when evidence is unchanged. Offline replay retains +the recorded observation time; a failed attempt exposes no successful refresh. +A local ``--from`` read reports the checkout's revision and dirty state, is labelled "local checkout", and writes no receipt or snapshot: it cannot masquerade as a published capture. @@ -109,6 +111,7 @@ class Snapshot: attempt_at: str | None = None dirty: bool | None = None checkout: str | None = None + refreshed_at: str | None = None # latest successful observation, independent of capture @property def failed(self) -> bool: @@ -179,6 +182,7 @@ def _from_files(inst: Instance, out_dir: Path) -> Snapshot: errors=[str(e) for e in receipt.get("errors") or []], attempt_commit=receipt.get("resolved_commit"), attempt_at=receipt.get("fetched_at"), + refreshed_at=receipt.get("refreshed_at") if receipt.get("outcome") == "ok" else None, ) if snap is not None: view.doc = snap["summary"] @@ -258,7 +262,13 @@ def ingest( "errors": errors, "cached_from": last_good.get("fetched_at") if last_good and outcome != "ok" else None, } - _carry(receipt_path(out_dir, inst.instance), receipt) + # Preserve capture history while recording a real successful observation. + prior = read_receipt(out_dir, inst.instance) + strip = lambda d: {k: v for k, v in d.items() if k not in ("fetched_at", "refreshed_at")} # noqa: E731 + if prior is not None and strip(prior) == strip(receipt): + receipt["fetched_at"] = prior["fetched_at"] + receipt["refreshed_at"] = now if outcome == "ok" else None + _carry(receipt_path(out_dir, inst.instance), receipt, keys=()) return _from_files(inst, out_dir) @@ -297,4 +307,6 @@ def _ingest_local(inst: Instance, path: Path, wanted) -> Snapshot: view.outcome, view.errors = summary_mod.classify(view.doc, wanted) if view.failed: view.doc = None + else: + view.refreshed_at = view.fetched_at return view diff --git a/tests/test_board.py b/tests/test_board.py index 8859891..b81b670 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -184,8 +184,18 @@ def test_render_is_deterministic(lens, web, tmp_path): s = _view(lens, web, tmp_path, "lens_summary_v1.json") board.write([s], tmp_path, now=NOW) first = {p.name: p.read_bytes() for p in tmp_path.glob("*.*") if p.is_file()} - board.write([ingest.ingest(lens, tmp_path, now="2026-10-03T00:00:00Z")], tmp_path, now=NOW) + board.write([s], tmp_path, now=NOW) assert {p.name: p.read_bytes() for p in tmp_path.glob("*.*") if p.is_file()} == first + fresh = ingest.ingest(lens, tmp_path, now="2026-10-03T00:00:00Z") + board.write([fresh], tmp_path, now=NOW) + after = {p.name: p.read_bytes() for p in tmp_path.glob("*.*") if p.is_file()} + assert after["state.json"] == first["state.json"] + assert after["dashboard.md"] == first["dashboard.md"] + assert after["dashboard.html"] != first["dashboard.html"] + normalize = board.setup_browser.theme().normalize_refresh_stamp + assert normalize(after["dashboard.html"].decode()) == normalize( + first["dashboard.html"].decode() + ) def test_no_league_table_across_projects(lens, web, tmp_path): @@ -195,3 +205,18 @@ def test_no_league_table_across_projects(lens, web, tmp_path): assert word not in md html = board.render_html([s], NOW) assert "PyAutoPulse dashboard" in html and "data-age-from=" in html + + +def test_capture_freshness_is_conservative(lens, web, tmp_path): + s = _view(lens, web, tmp_path, "lens_summary_v1.json") + html = board.render_html([s]) + assert 'data-refreshed-at="' + s.refreshed_at + '"' in html + assert ( + "https://github.com/PyAutoLabs/PyAutoPulse/actions/workflows/dashboard_refresh.yml" in html + ) + s.outcome = "unavailable" + s.cached = True + assert "Last updated unavailable" in board.render_html([s]) + s.outcome = "ok" + s.refreshed_at = None + assert "Last updated unavailable" in board.render_html([s]) diff --git a/tests/test_ingest.py b/tests/test_ingest.py index 3f98687..25b1aa7 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -36,12 +36,19 @@ def test_ok_reads_the_file_at_the_resolved_commit(lens, web, tmp_path, monkeypat assert snap["commit"] == SHA_A and snap["fetched_at"] == "2026-10-02T00:00:00Z" -def test_unchanged_reingest_rewrites_nothing(lens, web, tmp_path): +def test_unchanged_refresh_advances_observation_not_capture(lens, web, tmp_path): web.publish(GH, SHA_A, PATH, fixture_doc("lens_summary_v1.json")) ingest.ingest(lens, tmp_path, now="2026-10-02T00:00:00Z") before = {p: p.read_bytes() for p in tmp_path.glob("*s/lens.json")} ingest.ingest(lens, tmp_path, now="2026-10-03T00:00:00Z") - assert {p: p.read_bytes() for p in tmp_path.glob("*s/lens.json")} == before + assert ( + ingest.snapshot_path(tmp_path, "lens").read_bytes() + == before[ingest.snapshot_path(tmp_path, "lens")] + ) + replay = ingest.ingest(lens, tmp_path, offline=True) + assert replay.fetched_at == "2026-10-02T00:00:00Z" + assert replay.refreshed_at == "2026-10-03T00:00:00Z" + assert ingest.read_receipt(tmp_path, "lens")["fetched_at"] == "2026-10-02T00:00:00Z" web.publish(GH, SHA_B, PATH, fixture_doc("lens_summary_v1.json")) s = ingest.ingest(lens, tmp_path, now="2026-10-04T00:00:00Z") assert s.commit == SHA_B and s.fetched_at == "2026-10-04T00:00:00Z" @@ -53,6 +60,7 @@ def test_unavailable_keeps_the_cached_snapshot_with_its_original_times(lens, web web.down = True s = ingest.ingest(lens, tmp_path, now="2026-10-09T00:00:00Z") assert s.outcome == "unavailable" and s.cached + assert s.refreshed_at is None assert s.commit == SHA_A and s.fetched_at == "2026-10-02T00:00:00Z" assert s.doc["evidence_updated_at"] == good.doc["evidence_updated_at"] assert "connection reset" in s.errors[0]