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]