diff --git a/.github/workflows/dashboard_refresh.yml b/.github/workflows/dashboard_refresh.yml index 55024a7..fcccaf7 100644 --- a/.github/workflows/dashboard_refresh.yml +++ b/.github/workflows/dashboard_refresh.yml @@ -16,10 +16,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/insight/board.py b/insight/board.py index 1e309af..dc7a1cf 100644 --- a/insight/board.py +++ b/insight/board.py @@ -242,6 +242,17 @@ def _detail(s, now=None): 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.parse_utc(v.refreshed_at) for v in views] + return min(times) if all(times) else None + + def render_html(views, now=None, campaign_data=None): data = campaign_data if campaign_data is not None else campaigns.load() shared = theme() @@ -270,6 +281,7 @@ def render_html(views, now=None, campaign_data=None): + input_marker(views, data) + campaigns.render_html( data, + refreshed_at=_captured_at(views), work_links=[ {"label": "PyAutoInsight", "href": REPO_URL}, *({"label": v.instance.repo, "href": v.instance.github_url} for v in views), diff --git a/insight/campaigns.py b/insight/campaigns.py index 9da5ac6..3259103 100644 --- a/insight/campaigns.py +++ b/insight/campaigns.py @@ -198,7 +198,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) @@ -236,7 +236,14 @@ def table(headers, rows): return ( marker(data) + theme().orchestration_panel( - "insight", "", "", PROMPT, work_links=work_links, organ="insight" + "insight", + "", + "", + PROMPT, + work_links=work_links, + organ="insight", + refreshed_at=refreshed_at, + refresh_url="https://github.com/PyAutoLabs/PyAutoInsight/actions/workflows/dashboard_refresh.yml", ) + f'

Last check-in: {e(data.get("last_checkin") or "not recorded yet")}.

' '

Active campaigns

' diff --git a/insight/ingest.py b/insight/ingest.py index 7c35e1a..dfafd29 100644 --- a/insight/ingest.py +++ b/insight/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. @@ -110,6 +112,7 @@ class Snapshot: dirty: bool | None = None checkout: str | None = None source_branch: str | None = None + refreshed_at: str | None = None # latest successful observation, independent of capture @property def failed(self) -> bool: @@ -180,6 +183,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, source_branch=receipt.get("branch"), ) if receipt is not None: @@ -296,7 +300,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) @@ -337,4 +347,6 @@ def _ingest_local(inst: Instance, path: Path, wanted) -> Snapshot: view.outcome, view.errors = "invalid", ["project identity differs from registry"] 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 f445630..366d010 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -17,6 +17,7 @@ def view(registry_file, fake_mind, name="lens_summary_v1.json"): doc=fixture_doc(name), commit="a" * 40, fetched_at="2026-10-04T00:00:00Z", + refreshed_at="2026-10-04T00:00:00Z", ) @@ -80,3 +81,19 @@ def test_render_respects_supplied_clock_for_freshness(registry_file, fake_mind): s.doc["valid_until"] = "2020-01-03T00:00:00Z" assert "valid · deadline" in board.render_html([s], now="2020-01-02T00:00:00Z") assert "stale · deadline" in board.render_html([s], now="2020-01-04T00:00:00Z") + + +def test_capture_freshness_is_conservative(registry_file, fake_mind): + s = view(registry_file, fake_mind) + html = board.render_html([s]) + assert 'data-refreshed-at="' + s.refreshed_at + '"' in html + assert ( + "https://github.com/PyAutoLabs/PyAutoInsight/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 4b45414..cee9318 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]