Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/dashboard_refresh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions pulse/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -573,6 +584,7 @@ def render_html(views, now: str | None = None, campaign_data: dict | None = None
+ "<main>"
+ 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),
Expand Down
4 changes: 3 additions & 1 deletion pulse/campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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",
),
'<section class="controls" aria-label="Profiling actions">',
action("fix", "Fix Profiling Systematically", FIX_PROMPT),
Expand Down
20 changes: 16 additions & 4 deletions pulse/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
27 changes: 26 additions & 1 deletion tests/test_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 "<title>PyAutoPulse dashboard</title>" 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])
12 changes: 10 additions & 2 deletions tests/test_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
Loading