From c5a66712f292ebc8cb63b5651930c4f309e59a42 Mon Sep 17 00:00:00 2001 From: s Date: Thu, 22 Oct 2026 11:54:50 -0500 Subject: [PATCH 1/9] tools: add report-only node-status freshness refresh (LANES-20261022 Lane 3) --- .github/workflows/current-site.yml | 23 +++ .../node_status_refresh/current_revs.json | 4 + tests/fixtures/node_status_refresh/fresh.json | 18 ++ tests/fixtures/node_status_refresh/stale.json | 18 ++ .../fixtures/node_status_refresh/unknown.json | 18 ++ tests/test_node_status_refresh.py | 99 +++++++++++ tools/node_status_refresh.py | 168 ++++++++++++++++++ 7 files changed, 348 insertions(+) create mode 100644 tests/fixtures/node_status_refresh/current_revs.json create mode 100644 tests/fixtures/node_status_refresh/fresh.json create mode 100644 tests/fixtures/node_status_refresh/stale.json create mode 100644 tests/fixtures/node_status_refresh/unknown.json create mode 100644 tests/test_node_status_refresh.py create mode 100644 tools/node_status_refresh.py diff --git a/.github/workflows/current-site.yml b/.github/workflows/current-site.yml index a9a5b19a2..fceee3827 100644 --- a/.github/workflows/current-site.yml +++ b/.github/workflows/current-site.yml @@ -12,6 +12,9 @@ name: Current research site - 'tools/current_site_history.py' - 'tools/current_site_publication.py' - 'tests/test_current_site*' + - 'tools/node_status_refresh.py' + - 'tests/test_node_status_refresh.py' + - 'tests/fixtures/node_status_refresh/**' - '.github/workflows/current-site.yml' schedule: - cron: '*/30 * * * *' @@ -41,6 +44,26 @@ jobs: python3 -B -O -S -m unittest discover -s tests -p 'test_current_site*.py' -v node --test tests/test_current_site.mjs + node-status-refresh: + # LANES-20261022 Lane 3: report-only freshness of records/node_status. + # Non-blocking: never gates build/deploy, writes nothing, freshness is not scientific validity. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Offline freshness tests + run: python3 -B -S -m unittest tests.test_node_status_refresh -v + - name: Report-only freshness refresh (read-only GitHub API) + if: github.event_name != 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 -B -S tools/node_status_refresh.py records/node_status | tee -a "$GITHUB_STEP_SUMMARY" + build: needs: controls if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'd6g8k5htny-coder/main' diff --git a/tests/fixtures/node_status_refresh/current_revs.json b/tests/fixtures/node_status_refresh/current_revs.json new file mode 100644 index 000000000..59d5aaeaf --- /dev/null +++ b/tests/fixtures/node_status_refresh/current_revs.json @@ -0,0 +1,4 @@ +{ + "https://github.com/example/repo/blob/main/a.md": "aaa111", + "https://github.com/example/repo/pull/1": "ccc333" +} diff --git a/tests/fixtures/node_status_refresh/fresh.json b/tests/fixtures/node_status_refresh/fresh.json new file mode 100644 index 000000000..170d9e86b --- /dev/null +++ b/tests/fixtures/node_status_refresh/fresh.json @@ -0,0 +1,18 @@ +{ + "missing_input": [], + "next_action": "review", + "node_id": "example.fresh", + "observed_at": "2026-10-22T00:00:00Z", + "owner": null, + "schema_version": 0, + "scientific_effect": "NONE", + "source_rev": "aaa111", + "sources": [ + { + "rev": "aaa111", + "url": "https://github.com/example/repo/blob/main/a.md" + } + ], + "status": "fresh", + "stops": [] +} diff --git a/tests/fixtures/node_status_refresh/stale.json b/tests/fixtures/node_status_refresh/stale.json new file mode 100644 index 000000000..209e19e21 --- /dev/null +++ b/tests/fixtures/node_status_refresh/stale.json @@ -0,0 +1,18 @@ +{ + "missing_input": [], + "next_action": "review", + "node_id": "example.stale", + "observed_at": "2026-10-22T00:00:00Z", + "owner": null, + "schema_version": 0, + "scientific_effect": "NONE", + "source_rev": "bbb222", + "sources": [ + { + "rev": "bbb222", + "url": "https://github.com/example/repo/pull/1" + } + ], + "status": "fresh", + "stops": [] +} diff --git a/tests/fixtures/node_status_refresh/unknown.json b/tests/fixtures/node_status_refresh/unknown.json new file mode 100644 index 000000000..ba7b7deae --- /dev/null +++ b/tests/fixtures/node_status_refresh/unknown.json @@ -0,0 +1,18 @@ +{ + "missing_input": [], + "next_action": "review", + "node_id": "example.unknown", + "observed_at": "2026-10-22T00:00:00Z", + "owner": null, + "schema_version": 0, + "scientific_effect": "NONE", + "source_rev": "2026-10-01T00:00:00Z", + "sources": [ + { + "rev": "2026-10-01T00:00:00Z", + "url": "https://github.com/example/repo/issues/2#issuecomment-3" + } + ], + "status": "fresh", + "stops": [] +} diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py new file mode 100644 index 000000000..9e88edef6 --- /dev/null +++ b/tests/test_node_status_refresh.py @@ -0,0 +1,99 @@ +"""Offline tests for tools/node_status_refresh.py (LANES-20261022 Lane 3). No network.""" +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import node_status_refresh as nsr # noqa: E402 + +FIX = ROOT / "tests" / "fixtures" / "node_status_refresh" +NOW = "2026-10-22T17:00:00Z" +REVS = json.loads((FIX / "current_revs.json").read_text()) + + +def offline_fetch(url): + if url not in REVS: + raise nsr.SourceError("unreachable (offline fixture)") + return REVS[url] + + +def load(name): + return json.loads((FIX / name).read_text()) + + +class RefreshTests(unittest.TestCase): + def setUp(self): + def no_net(*a, **k): + raise AssertionError("network used in tests") + self._orig = nsr._api_get + nsr._api_get = no_net + + def tearDown(self): + nsr._api_get = self._orig + + def test_unchanged_is_fresh(self): + out = nsr.refresh_record(load("fresh.json"), offline_fetch, NOW) + self.assertEqual(out["status"], "fresh") + self.assertEqual(out["source_rev"], "aaa111") + self.assertEqual(out["observed_at"], NOW) + self.assertNotIn("refresh_error", out) + + def test_changed_rev_is_stale(self): + out = nsr.refresh_record(load("stale.json"), offline_fetch, NOW) + self.assertEqual(out["status"], "stale") + self.assertEqual(out["source_rev"], "ccc333") + + def test_unreachable_is_unknown_with_error(self): + rec = load("unknown.json") + out = nsr.refresh_record(rec, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertIn("unreachable", out["refresh_error"]) + self.assertEqual(out["source_rev"], rec["source_rev"]) # not advanced on failure + + def test_failure_never_fresh_even_if_other_source_ok(self): + rec = load("fresh.json") + rec["sources"].append({"url": "https://github.com/example/repo/issues/9", "rev": "x"}) + self.assertEqual(nsr.refresh_record(rec, offline_fetch, NOW)["status"], "unknown") + + def test_no_sources_or_bad_schema_is_unknown(self): + rec = load("fresh.json") + rec["sources"] = [] + self.assertEqual(nsr.refresh_record(rec, offline_fetch, NOW)["status"], "unknown") + rec = load("fresh.json") + rec["schema_version"] = 1 + self.assertEqual(nsr.refresh_record(rec, offline_fetch, NOW)["status"], "unknown") + + def test_owner_and_scientific_effect_untouched(self): + rec = load("stale.json") + rec["owner"] = {"id": "agent", "evidence_url": "https://github.com/example/repo/issues/2#issuecomment-1"} + out = nsr.refresh_record(rec, offline_fetch, NOW) + self.assertEqual(out["owner"], rec["owner"]) + self.assertEqual(out["scientific_effect"], "NONE") + + def test_dir_report_only_does_not_write(self): + with tempfile.TemporaryDirectory() as tmp: + for n in ("fresh.json", "stale.json", "unknown.json"): + shutil.copy(FIX / n, tmp) + before = {p.name: p.read_bytes() for p in Path(tmp).glob("*.json")} + report = nsr.refresh_dir(Path(tmp), offline_fetch, write=False, now=NOW) + self.assertEqual({r["file"]: r["status"] for r in report}, + {"fresh.json": "fresh", "stale.json": "stale", "unknown.json": "unknown"}) + self.assertEqual(before, {p.name: p.read_bytes() for p in Path(tmp).glob("*.json")}) + + def test_dir_write_mode(self): + with tempfile.TemporaryDirectory() as tmp: + shutil.copy(FIX / "stale.json", tmp) + nsr.refresh_dir(Path(tmp), offline_fetch, write=True, now=NOW) + self.assertEqual(json.loads((Path(tmp) / "stale.json").read_text())["status"], "stale") + + def test_unsupported_url_raises_source_error(self): + with self.assertRaises(nsr.SourceError): + nsr.github_fetch("https://example.org/x") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/node_status_refresh.py b/tools/node_status_refresh.py new file mode 100644 index 000000000..a0e6363a6 --- /dev/null +++ b/tools/node_status_refresh.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Recompute freshness for node-status records (LANES-20261022 Lane 3). + +Interface: Lane 2 v0 (main#475 comment 6346276856), one JSON record per node: + {schema_version:0, node_id, owner, next_action, missing_input, stops, + sources:[{url, rev}], source_rev, observed_at, status, scientific_effect:"NONE"} + +Only status, observed_at, source_rev (and, on failure, refresh_error) are recomputed. +status is about freshness only. It is NEVER a statement of scientific validity; +scientific_effect stays "NONE". Owner fields are never touched or inferred. + +Rules: + - every source rev unchanged -> "fresh" + - any source rev changed -> "stale" + - any source unreachable / unparseable -> "unknown" (error recorded; never "fresh") +Precedence: unknown > stale > fresh. + +The default fetcher reads the public GitHub REST API (read-only, optional +GITHUB_TOKEN from the workflow). Tests inject an offline fetcher. +Report-only: without --write, records are not modified. +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import re +import sys +import urllib.request +from pathlib import Path +from typing import Callable + +SCHEMA_VERSION = 0 +STATUSES = ("fresh", "stale", "unknown") +API = "https://api.github.com" + +Fetcher = Callable[[str], str] + + +class SourceError(Exception): + """A source could not be resolved to a current revision.""" + + +def _utc_now() -> str: + return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _api_get(path: str) -> object: + req = urllib.request.Request(API + path, headers={"Accept": "application/vnd.github+json", + "User-Agent": "node-status-refresh"}) + token = os.environ.get("GITHUB_TOKEN") + if token: + req.add_header("Authorization", "Bearer " + token) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return json.load(resp) + except Exception as exc: # network, HTTP, JSON + raise SourceError(f"{type(exc).__name__}: {exc}") from exc + + +_GH = re.compile(r"^https://github\.com/([^/]+)/([^/#?]+)(/.*)?$") + + +def github_fetch(url: str) -> str: + """Return the current revision string for a GitHub URL (read-only).""" + m = _GH.match(url) + if not m: + raise SourceError(f"unsupported source url: {url}") + owner, repo, rest = m.group(1), m.group(2), m.group(3) or "" + base = f"/repos/{owner}/{repo}" + c = re.search(r"#(?:issuecomment|discussion_r)-(\d+)$", rest) + if c: + kind = "issues" if "issuecomment" in rest else "pulls" + return str(_api_get(f"{base}/{kind}/comments/{c.group(1)}")["updated_at"]) + p = re.match(r"^/pull/(\d+)", rest) + if p: + return str(_api_get(f"{base}/pulls/{p.group(1)}")["head"]["sha"]) + i = re.match(r"^/issues/(\d+)", rest) + if i: + return str(_api_get(f"{base}/issues/{i.group(1)}")["updated_at"]) + b = re.match(r"^/(?:blob|tree)/([^/]+)/(.+)$", rest) + if b: + commits = _api_get(f"{base}/commits?sha={b.group(1)}&path={b.group(2)}&per_page=1") + if not commits: + raise SourceError(f"no commits for path: {url}") + return str(commits[0]["sha"]) + if rest in ("", "/"): + return str(_api_get(f"{base}/commits/HEAD")["sha"]) + raise SourceError(f"unsupported source url: {url}") + + +def refresh_record(record: dict, fetch: Fetcher, now: str | None = None) -> dict: + """Return a new record with freshness recomputed. Never raises for source errors.""" + out = dict(record) + out.pop("refresh_error", None) + out["observed_at"] = now or _utc_now() + out["scientific_effect"] = "NONE" + errors: list[str] = [] + changed = False + current: list[str] = [] + sources = record.get("sources") + if record.get("schema_version") != SCHEMA_VERSION: + errors.append(f"unsupported schema_version: {record.get('schema_version')!r}") + if not isinstance(sources, list) or not sources: + errors.append("no sources to check") + sources = [] + for src in sources: + url = src.get("url") if isinstance(src, dict) else None + if not url: + errors.append("source without url") + continue + try: + rev = fetch(url) + except Exception as exc: # any fetch failure is unknown, never fresh + errors.append(f"{url}: {exc}") + continue + if not isinstance(rev, str) or not rev: + errors.append(f"{url}: empty revision") + continue + current.append(rev) + if rev != src.get("rev"): + changed = True + if errors: + out["status"] = "unknown" + out["refresh_error"] = "; ".join(errors) + else: + out["status"] = "stale" if changed else "fresh" + out["source_rev"] = current[0] if len(current) == 1 else ",".join(current) + return out + + +def refresh_dir(directory: Path, fetch: Fetcher, write: bool, now: str | None = None) -> list[dict]: + report = [] + for path in sorted(directory.glob("*.json")): + try: + record = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(record, dict): + raise ValueError("record is not an object") + except Exception as exc: + report.append({"file": path.name, "status": "unknown", "refresh_error": f"unreadable: {exc}"}) + continue + new = refresh_record(record, fetch, now) + report.append({"file": path.name, "node_id": new.get("node_id"), "status": new["status"], + "previous_status": record.get("status"), + "refresh_error": new.get("refresh_error")}) + if write: + path.write_text(json.dumps(new, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return report + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("records", nargs="?", default="records/node_status") + ap.add_argument("--write", action="store_true", help="rewrite records (default: report only)") + args = ap.parse_args(argv) + directory = Path(args.records) + if not directory.is_dir(): + print(json.dumps({"records": str(directory), "report": [], "note": "no records directory; nothing to refresh", + "scientific_effect": "NONE"}, indent=2)) + return 0 + report = refresh_dir(directory, github_fetch, args.write) + print(json.dumps({"records": str(directory), "report": report, "scientific_effect": "NONE"}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 6cd5a096624c72681a81730a5529e5db35937e8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 17:06:34 +0000 Subject: [PATCH 2/9] tests: kill three node-status freshness mutants Force scientific_effect to NONE, clear a stale refresh_error on success, and reject an empty or missing source revision. Point the acceptance command at python3 -B -m unittest tests.test_node_status_refresh. Co-authored-by: Dylan Roy --- .github/workflows/current-site.yml | 2 +- tests/test_node_status_refresh.py | 58 ++++++++++++++++++++++++++++++ tools/node_status_refresh.py | 5 +++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/workflows/current-site.yml b/.github/workflows/current-site.yml index fceee3827..f11db0b71 100644 --- a/.github/workflows/current-site.yml +++ b/.github/workflows/current-site.yml @@ -57,7 +57,7 @@ jobs: with: persist-credentials: false - name: Offline freshness tests - run: python3 -B -S -m unittest tests.test_node_status_refresh -v + run: python3 -B -m unittest tests.test_node_status_refresh - name: Report-only freshness refresh (read-only GitHub API) if: github.event_name != 'pull_request' env: diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py index 9e88edef6..d09b98d0d 100644 --- a/tests/test_node_status_refresh.py +++ b/tests/test_node_status_refresh.py @@ -94,6 +94,64 @@ def test_unsupported_url_raises_source_error(self): with self.assertRaises(nsr.SourceError): nsr.github_fetch("https://example.org/x") + def test_scientific_effect_forced_none_not_passed_through(self): + rec = load("fresh.json") + rec["scientific_effect"] = "ACCEPTED" + out = nsr.refresh_record(rec, offline_fetch, NOW) + self.assertEqual(out["status"], "fresh") + self.assertEqual(out["scientific_effect"], "NONE") + self.assertNotEqual(out["scientific_effect"], "ACCEPTED") + + rec = load("unknown.json") + rec["scientific_effect"] = "ACCEPTED" + out = nsr.refresh_record(rec, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertEqual(out["scientific_effect"], "NONE") + + def test_successful_refresh_clears_stale_refresh_error(self): + rec = load("fresh.json") + rec["refresh_error"] = "leftover from an earlier run" + out = nsr.refresh_record(rec, offline_fetch, NOW) + self.assertEqual(out["status"], "fresh") + self.assertNotIn("refresh_error", out) + + rec = load("stale.json") + rec["refresh_error"] = "leftover from an earlier run" + out = nsr.refresh_record(rec, offline_fetch, NOW) + self.assertEqual(out["status"], "stale") + self.assertNotIn("refresh_error", out) + + def test_empty_or_missing_source_revision_never_fresh(self): + rec = load("fresh.json") + + def empty_fetch(url): + return "" + + out = nsr.refresh_record(rec, empty_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertIn("refresh_error", out) + self.assertNotEqual(out["status"], "fresh") + + def missing_fetch(url): + return None + + out = nsr.refresh_record(load("fresh.json"), missing_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertNotEqual(out["status"], "fresh") + + missing = load("fresh.json") + del missing["sources"][0]["rev"] + out = nsr.refresh_record(missing, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertIn("refresh_error", out) + self.assertNotEqual(out["status"], "fresh") + + blank = load("fresh.json") + blank["sources"][0]["rev"] = "" + out = nsr.refresh_record(blank, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertNotEqual(out["status"], "fresh") + if __name__ == "__main__": unittest.main() diff --git a/tools/node_status_refresh.py b/tools/node_status_refresh.py index a0e6363a6..8731a053c 100644 --- a/tools/node_status_refresh.py +++ b/tools/node_status_refresh.py @@ -13,6 +13,7 @@ - every source rev unchanged -> "fresh" - any source rev changed -> "stale" - any source unreachable / unparseable -> "unknown" (error recorded; never "fresh") + - empty or missing source rev -> "unknown" (never "fresh") Precedence: unknown > stale > fresh. The default fetcher reads the public GitHub REST API (read-only, optional @@ -110,6 +111,10 @@ def refresh_record(record: dict, fetch: Fetcher, now: str | None = None) -> dict if not url: errors.append("source without url") continue + recorded = src.get("rev") + if not isinstance(recorded, str) or not recorded: + errors.append(f"{url}: empty or missing source revision") + continue try: rev = fetch(url) except Exception as exc: # any fetch failure is unknown, never fresh From e8b6d0490e322a674cf8f03a08e887d50cb0a4b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 17:38:05 +0000 Subject: [PATCH 3/9] refresh: align node-status freshness with interface v0 source_rev is one 40-hex sha, the moving-ref rev with the latest observed update. Only pinned blob and tree URLs on main and Math- are valid sources. refresh_error is a string or null, and a later RELEASE comment sets owner.released_url without changing other owner fields. Co-authored-by: Dylan Roy --- .../node_status_refresh/current_revs.json | 10 +- tests/fixtures/node_status_refresh/fresh.json | 8 +- tests/fixtures/node_status_refresh/stale.json | 8 +- .../fixtures/node_status_refresh/unknown.json | 8 +- tests/test_node_status_refresh.py | 163 ++++++++++++- tools/node_status_refresh.py | 230 ++++++++++++++---- 6 files changed, 357 insertions(+), 70 deletions(-) diff --git a/tests/fixtures/node_status_refresh/current_revs.json b/tests/fixtures/node_status_refresh/current_revs.json index 59d5aaeaf..2ffca90af 100644 --- a/tests/fixtures/node_status_refresh/current_revs.json +++ b/tests/fixtures/node_status_refresh/current_revs.json @@ -1,4 +1,10 @@ { - "https://github.com/example/repo/blob/main/a.md": "aaa111", - "https://github.com/example/repo/pull/1": "ccc333" + "https://github.com/d6g8k5htny-coder/main/blob/1111111111111111111111111111111111111111/a.md": { + "rev": "1111111111111111111111111111111111111111", + "observed_at": "2026-10-22T12:00:00Z" + }, + "https://github.com/d6g8k5htny-coder/main/blob/2222222222222222222222222222222222222222/b.md": { + "rev": "3333333333333333333333333333333333333333", + "observed_at": "2026-10-22T15:00:00Z" + } } diff --git a/tests/fixtures/node_status_refresh/fresh.json b/tests/fixtures/node_status_refresh/fresh.json index 170d9e86b..dab562983 100644 --- a/tests/fixtures/node_status_refresh/fresh.json +++ b/tests/fixtures/node_status_refresh/fresh.json @@ -1,16 +1,16 @@ { "missing_input": [], "next_action": "review", - "node_id": "example.fresh", + "node_id": "fresh", "observed_at": "2026-10-22T00:00:00Z", "owner": null, "schema_version": 0, "scientific_effect": "NONE", - "source_rev": "aaa111", + "source_rev": "1111111111111111111111111111111111111111", "sources": [ { - "rev": "aaa111", - "url": "https://github.com/example/repo/blob/main/a.md" + "rev": "1111111111111111111111111111111111111111", + "url": "https://github.com/d6g8k5htny-coder/main/blob/1111111111111111111111111111111111111111/a.md" } ], "status": "fresh", diff --git a/tests/fixtures/node_status_refresh/stale.json b/tests/fixtures/node_status_refresh/stale.json index 209e19e21..ad686c991 100644 --- a/tests/fixtures/node_status_refresh/stale.json +++ b/tests/fixtures/node_status_refresh/stale.json @@ -1,16 +1,16 @@ { "missing_input": [], "next_action": "review", - "node_id": "example.stale", + "node_id": "stale", "observed_at": "2026-10-22T00:00:00Z", "owner": null, "schema_version": 0, "scientific_effect": "NONE", - "source_rev": "bbb222", + "source_rev": "2222222222222222222222222222222222222222", "sources": [ { - "rev": "bbb222", - "url": "https://github.com/example/repo/pull/1" + "rev": "2222222222222222222222222222222222222222", + "url": "https://github.com/d6g8k5htny-coder/main/blob/2222222222222222222222222222222222222222/b.md" } ], "status": "fresh", diff --git a/tests/fixtures/node_status_refresh/unknown.json b/tests/fixtures/node_status_refresh/unknown.json index ba7b7deae..db3da1e6b 100644 --- a/tests/fixtures/node_status_refresh/unknown.json +++ b/tests/fixtures/node_status_refresh/unknown.json @@ -1,16 +1,16 @@ { "missing_input": [], "next_action": "review", - "node_id": "example.unknown", + "node_id": "unknown", "observed_at": "2026-10-22T00:00:00Z", "owner": null, "schema_version": 0, "scientific_effect": "NONE", - "source_rev": "2026-10-01T00:00:00Z", + "source_rev": "4444444444444444444444444444444444444444", "sources": [ { - "rev": "2026-10-01T00:00:00Z", - "url": "https://github.com/example/repo/issues/2#issuecomment-3" + "rev": "4444444444444444444444444444444444444444", + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/4444444444444444444444444444444444444444/c.md" } ], "status": "fresh", diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py index d09b98d0d..f10892589 100644 --- a/tests/test_node_status_refresh.py +++ b/tests/test_node_status_refresh.py @@ -18,7 +18,8 @@ def offline_fetch(url): if url not in REVS: raise nsr.SourceError("unreachable (offline fixture)") - return REVS[url] + item = REVS[url] + return item["rev"], item["observed_at"] def load(name): @@ -38,14 +39,16 @@ def tearDown(self): def test_unchanged_is_fresh(self): out = nsr.refresh_record(load("fresh.json"), offline_fetch, NOW) self.assertEqual(out["status"], "fresh") - self.assertEqual(out["source_rev"], "aaa111") + self.assertEqual(out["source_rev"], "1111111111111111111111111111111111111111") + self.assertNotIn(",", out["source_rev"]) self.assertEqual(out["observed_at"], NOW) - self.assertNotIn("refresh_error", out) + self.assertIsNone(out["refresh_error"]) def test_changed_rev_is_stale(self): out = nsr.refresh_record(load("stale.json"), offline_fetch, NOW) self.assertEqual(out["status"], "stale") - self.assertEqual(out["source_rev"], "ccc333") + self.assertEqual(out["source_rev"], "3333333333333333333333333333333333333333") + self.assertNotIn(",", out["source_rev"]) def test_unreachable_is_unknown_with_error(self): rec = load("unknown.json") @@ -113,13 +116,13 @@ def test_successful_refresh_clears_stale_refresh_error(self): rec["refresh_error"] = "leftover from an earlier run" out = nsr.refresh_record(rec, offline_fetch, NOW) self.assertEqual(out["status"], "fresh") - self.assertNotIn("refresh_error", out) + self.assertIsNone(out["refresh_error"]) rec = load("stale.json") rec["refresh_error"] = "leftover from an earlier run" out = nsr.refresh_record(rec, offline_fetch, NOW) self.assertEqual(out["status"], "stale") - self.assertNotIn("refresh_error", out) + self.assertIsNone(out["refresh_error"]) def test_empty_or_missing_source_revision_never_fresh(self): rec = load("fresh.json") @@ -152,6 +155,154 @@ def missing_fetch(url): self.assertEqual(out["status"], "unknown") self.assertNotEqual(out["status"], "fresh") + def test_source_rev_is_single_newest_observed_update(self): + older = "5" * 40 + newer = "6" * 40 + rec = load("fresh.json") + rec["sources"] = [ + {"url": "https://github.com/d6g8k5htny-coder/main/blob/" + older + "/old.md", "rev": older}, + {"url": "https://github.com/d6g8k5htny-coder/Math-/tree/" + newer + "/new", "rev": newer}, + ] + + def fetch(url): + if url.endswith("/old.md"): + return older, "2026-10-22T10:00:00Z" + return newer, "2026-10-22T16:00:00Z" + + out = nsr.refresh_record(rec, fetch, NOW) + self.assertEqual(out["status"], "fresh") + self.assertEqual(out["source_rev"], newer) + self.assertNotIn(",", out["source_rev"]) + self.assertEqual(len(out["source_rev"]), 40) + + def tied(url): + if url.endswith("/old.md"): + return older, "2026-10-22T16:00:00Z" + return newer, "2026-10-22T16:00:00Z" + + tied_out = nsr.refresh_record(rec, tied, NOW) + self.assertEqual(tied_out["source_rev"], newer) + + def test_pinned_sha_accepted_and_other_rev_forms_unknown(self): + math_sha = "a" * 40 + rec = load("fresh.json") + rec["sources"] = [{ + "url": "https://github.com/d6g8k5htny-coder/Math-/tree/" + math_sha + "/frontiers/downstream_gate_20260925", + "rev": math_sha, + }] + out = nsr.refresh_record(rec, lambda url: (math_sha, "2026-10-22T12:00:00Z"), NOW) + self.assertEqual(out["status"], "fresh") + self.assertIsNone(out["refresh_error"]) + + issue = load("fresh.json") + issue["sources"] = [{ + "url": "https://github.com/d6g8k5htny-coder/main/issues/475", + "rev": "2026-10-22T17:30:25Z", + }] + out = nsr.refresh_record(issue, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertIsInstance(out["refresh_error"], str) + self.assertNotEqual(out["source_rev"], "2026-10-22T17:30:25Z") + + comment = load("fresh.json") + comment["sources"] = [{ + "url": "https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347046676", + "rev": "2026-10-22T17:30:25Z", + }] + self.assertEqual(nsr.refresh_record(comment, offline_fetch, NOW)["status"], "unknown") + + other = load("fresh.json") + other["sources"][0]["url"] = "https://github.com/example/repo/blob/" + ("1" * 40) + "/a.md" + other["sources"][0]["rev"] = "1" * 40 + out = nsr.refresh_record(other, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertIsInstance(out["refresh_error"], str) + + mismatch = load("fresh.json") + mismatch["sources"][0]["rev"] = "2" * 40 + out = nsr.refresh_record(mismatch, offline_fetch, NOW) + self.assertEqual(out["status"], "unknown") + self.assertIn("appear in its url", out["refresh_error"]) + + upper = load("fresh.json") + upper_sha = "A" * 40 + upper["sources"] = [{ + "url": "https://github.com/d6g8k5htny-coder/main/blob/" + upper_sha + "/a.md", + "rev": upper_sha, + }] + self.assertEqual(nsr.refresh_record(upper, offline_fetch, NOW)["status"], "unknown") + + def test_refresh_error_is_string_or_null(self): + fresh = nsr.refresh_record(load("fresh.json"), offline_fetch, NOW) + self.assertIn("refresh_error", fresh) + self.assertIsNone(fresh["refresh_error"]) + unknown = nsr.refresh_record(load("unknown.json"), offline_fetch, NOW) + self.assertIsInstance(unknown["refresh_error"], str) + self.assertTrue(unknown["refresh_error"]) + self.assertIn(type(fresh["refresh_error"]), (str, type(None))) + self.assertIn(type(unknown["refresh_error"]), (str, type(None))) + + def test_release_comment_sets_released_url_only(self): + pickup = "https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6346922771" + release = "https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347046676" + body = "**RELEASE: nonauthor review of #495**\n\n- Releases PICKUP 6346922771.\n" + rec = load("fresh.json") + rec["owner"] = {"id": "agent-9", "evidence_url": pickup, "lane": "2"} + + def comments(url): + self.assertEqual(url, pickup) + return [ + {"url": "https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6346922770", + "body": "**RELEASE: too early**\n\nPICKUP 6346922771\n"}, + {"url": "https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6347000000", + "body": "**RELEASE: other**\n\nPICKUP 111\n"}, + {"url": release, "body": body}, + ] + + out = nsr.refresh_record(rec, offline_fetch, NOW, comments) + self.assertEqual(out["status"], "fresh") + self.assertEqual(out["owner"]["id"], "agent-9") + self.assertEqual(out["owner"]["evidence_url"], pickup) + self.assertEqual(out["owner"]["lane"], "2") + self.assertEqual(out["owner"]["released_url"], release) + self.assertEqual(set(out["owner"]), {"id", "evidence_url", "lane", "released_url"}) + + quiet = nsr.refresh_record(rec, offline_fetch, NOW, lambda url: []) + self.assertEqual(quiet["owner"], rec["owner"]) + self.assertNotIn("released_url", quiet["owner"]) + + def boom(url): + raise nsr.SourceError("comment listing incomplete") + + failed = nsr.refresh_record(rec, offline_fetch, NOW, boom) + self.assertEqual(failed["status"], "unknown") + self.assertIsInstance(failed["refresh_error"], str) + self.assertEqual(failed["owner"], rec["owner"]) + + def test_github_fetch_uses_moving_ref_for_pinned_url(self): + seen = [] + + def fake(path): + seen.append(path) + return [{"sha": "ab" * 20, "commit": {"committer": {"date": "2026-10-22T18:00:00+00:00"}}}] + + nsr._api_get = fake + pin = "1" * 40 + url = "https://github.com/d6g8k5htny-coder/Math-/blob/" + pin + "/frontiers/downstream_gate_20260925/GRAPH.json" + rev, observed = nsr.github_fetch(url) + self.assertEqual(rev, "ab" * 20) + self.assertEqual(observed, "2026-10-22T18:00:00Z") + self.assertEqual(len(seen), 1) + self.assertIn("/repos/d6g8k5htny-coder/Math-/commits?", seen[0]) + self.assertNotIn("sha=", seen[0]) + self.assertNotIn(pin, seen[0]) + with self.assertRaises(nsr.SourceError): + nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/issues/475") + with self.assertRaises(nsr.SourceError): + nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/pull/495") + with self.assertRaises(nsr.SourceError): + nsr.github_fetch("https://github.com/example/repo/blob/" + pin + "/a.md") + if __name__ == "__main__": unittest.main() diff --git a/tools/node_status_refresh.py b/tools/node_status_refresh.py index 8731a053c..b0bedb288 100644 --- a/tools/node_status_refresh.py +++ b/tools/node_status_refresh.py @@ -1,19 +1,34 @@ #!/usr/bin/env python3 """Recompute freshness for node-status records (LANES-20261022 Lane 3). -Interface: Lane 2 v0 (main#475 comment 6346276856), one JSON record per node: - {schema_version:0, node_id, owner, next_action, missing_input, stops, - sources:[{url, rev}], source_rev, observed_at, status, scientific_effect:"NONE"} +Interface v0 is #495's schema at 5a58e3e0 (architecture/node-status.schema.json). +Where that schema differs from the Lane 3 note, this tool follows #495: -Only status, observed_at, source_rev (and, on failure, refresh_error) are recomputed. -status is about freshness only. It is NEVER a statement of scientific validity; -scientific_effect stays "NONE". Owner fields are never touched or inferred. + - source_rev is one 40-hex commit. Comma-joining is refused. + - A source is valid only as a pinned blob or tree URL on + d6g8k5htny-coder/main or Math-, and sources[].rev must be the 40-hex + SHA embedded in that URL. Issue, pull, and comment updated_at strings + are not source revs (#495's validator allows only the pinned form). + - #495 does not say which SHA is source_rev when sources disagree. + This tool uses the current rev of the source with the latest observed + update. The observed update is the moving-ref committer time (the + repository default branch, not the pinned SHA). Equal times: the later + source in the list wins. That choice is not defined by #495. + - #495's record shape has no refresh_error and no owner.released_url. + The note still requires both: refresh_error is a string or null, and a + later RELEASE comment that names the owner's PICKUP sets + owner.released_url without changing any other owner field. + +Only status, observed_at, source_rev, refresh_error, and that one owner +field are written. status is freshness only, never scientific validity. +scientific_effect stays "NONE". Owner identity is never inferred. Rules: - every source rev unchanged -> "fresh" - any source rev changed -> "stale" - - any source unreachable / unparseable -> "unknown" (error recorded; never "fresh") - - empty or missing source rev -> "unknown" (never "fresh") + - any source unreachable / unparseable -> "unknown" (never "fresh") + - empty, missing, or non-40-hex rev -> "unknown" (never "fresh") + - anything other than a pinned blob/tree -> "unknown" (never "fresh") Precedence: unknown > stale > fresh. The default fetcher reads the public GitHub REST API (read-only, optional @@ -31,12 +46,26 @@ import urllib.request from pathlib import Path from typing import Callable +from urllib.parse import quote SCHEMA_VERSION = 0 STATUSES = ("fresh", "stale", "unknown") API = "https://api.github.com" +_COMMENT_PAGE = 100 +_COMMENT_PAGE_LIMIT = 10 + +_SHA = re.compile(r"^[0-9a-f]{40}$") +_UTC = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") +_PINNED = re.compile( + r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(blob|tree)/" + r"([0-9a-f]{40})/([A-Za-z0-9_./-]+)$") +_COMMENT = re.compile( + r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/" + r"([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$") +_RELEASE = re.compile(r"(?im)^\s*\**\s*RELEASE\b") -Fetcher = Callable[[str], str] +Fetcher = Callable[[str], object] +Comments = Callable[[str], list] class SourceError(Exception): @@ -47,6 +76,14 @@ def _utc_now() -> str: return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") +def _as_z(value: object) -> str: + if not isinstance(value, str): + return "" + if value.endswith("+00:00"): + value = value[:-6] + "Z" + return value + + def _api_get(path: str) -> object: req = urllib.request.Request(API + path, headers={"Accept": "application/vnd.github+json", "User-Agent": "node-status-refresh"}) @@ -60,38 +97,116 @@ def _api_get(path: str) -> object: raise SourceError(f"{type(exc).__name__}: {exc}") from exc -_GH = re.compile(r"^https://github\.com/([^/]+)/([^/#?]+)(/.*)?$") +def github_fetch(url: str) -> tuple[str, str]: + """Return (moving-ref SHA, committer time) for a pinned blob or tree URL. - -def github_fetch(url: str) -> str: - """Return the current revision string for a GitHub URL (read-only).""" - m = _GH.match(url) + The pin in the URL is the recorded rev. Freshness compares it with the + latest commit on the repository default branch that touches the path. + """ + m = _PINNED.match(url) if not m: raise SourceError(f"unsupported source url: {url}") - owner, repo, rest = m.group(1), m.group(2), m.group(3) or "" - base = f"/repos/{owner}/{repo}" - c = re.search(r"#(?:issuecomment|discussion_r)-(\d+)$", rest) - if c: - kind = "issues" if "issuecomment" in rest else "pulls" - return str(_api_get(f"{base}/{kind}/comments/{c.group(1)}")["updated_at"]) - p = re.match(r"^/pull/(\d+)", rest) - if p: - return str(_api_get(f"{base}/pulls/{p.group(1)}")["head"]["sha"]) - i = re.match(r"^/issues/(\d+)", rest) - if i: - return str(_api_get(f"{base}/issues/{i.group(1)}")["updated_at"]) - b = re.match(r"^/(?:blob|tree)/([^/]+)/(.+)$", rest) - if b: - commits = _api_get(f"{base}/commits?sha={b.group(1)}&path={b.group(2)}&per_page=1") - if not commits: - raise SourceError(f"no commits for path: {url}") - return str(commits[0]["sha"]) - if rest in ("", "/"): - return str(_api_get(f"{base}/commits/HEAD")["sha"]) - raise SourceError(f"unsupported source url: {url}") - - -def refresh_record(record: dict, fetch: Fetcher, now: str | None = None) -> dict: + repo, path = m.group(1), m.group(4) + query = f"/repos/d6g8k5htny-coder/{repo}/commits?path={quote(path, safe='/')}&per_page=1" + commits = _api_get(query) + if not isinstance(commits, list) or not commits or not isinstance(commits[0], dict): + raise SourceError(f"no commits for path: {url}") + sha = commits[0].get("sha") + commit = commits[0].get("commit") + committer = commit.get("committer") if isinstance(commit, dict) else None + observed = _as_z(committer.get("date") if isinstance(committer, dict) else "") + if not isinstance(sha, str) or not _SHA.fullmatch(sha): + raise SourceError(f"{url}: current revision is not a 40-hex sha") + if not _UTC.fullmatch(observed): + raise SourceError(f"{url}: missing observed update time") + return sha, observed + + +def github_comments(evidence_url: str) -> list[dict]: + """Read issue comments for a PICKUP URL. One short page means the read ended.""" + m = _COMMENT.fullmatch(evidence_url) + if not m: + raise SourceError(f"unsupported owner evidence url: {evidence_url}") + repo, number = m.group(1), m.group(3) + found: list[dict] = [] + page = 1 + while True: + batch = _api_get( + f"/repos/d6g8k5htny-coder/{repo}/issues/{number}/comments?per_page={_COMMENT_PAGE}&page={page}") + if not isinstance(batch, list): + raise SourceError("comment listing is not a list") + for item in batch: + if not isinstance(item, dict): + raise SourceError("comment is not an object") + found.append({"url": item.get("html_url"), "body": item.get("body") or ""}) + if len(batch) < _COMMENT_PAGE: + return found + page += 1 + if page > _COMMENT_PAGE_LIMIT: + raise SourceError("comment listing incomplete") + + +def _release_url(evidence_url: str, thread: object) -> str | None: + """URL of the latest RELEASE comment that names this PICKUP and follows it.""" + pickup = _COMMENT.fullmatch(evidence_url) + if not pickup or not isinstance(thread, list): + return None + pickup_id = int(pickup.group(4)) + best_id = pickup_id + best_url = None + for item in thread: + if not isinstance(item, dict): + continue + url = item.get("url") + body = item.get("body") if isinstance(item.get("body"), str) else "" + if not isinstance(url, str): + continue + comment = _COMMENT.fullmatch(url) + if not comment: + continue + comment_id = int(comment.group(4)) + if comment_id <= pickup_id or comment_id <= best_id: + continue + if not _RELEASE.search(body): + continue + if not re.search(rf"(?i)\bPICKUP\b[\s:#-]*{pickup_id}\b", body): + continue + best_id = comment_id + best_url = url + return best_url + + +def _current(fetched: object) -> tuple[object, str]: + if isinstance(fetched, tuple) and len(fetched) == 2: + return fetched[0], _as_z(fetched[1]) + return fetched, "" + + +def _apply_release(out: dict, record: dict, comments: Comments | None, errors: list[str]) -> None: + owner = record.get("owner") + if not isinstance(owner, dict): + return + evidence = owner.get("evidence_url") + if not isinstance(evidence, str) or not _COMMENT.fullmatch(evidence): + return + try: + thread = github_comments(evidence) if comments is None else comments(evidence) + except Exception as exc: # incomplete or failed read stays unknown + errors.append(f"{evidence}: {exc}") + return + if not isinstance(thread, list): + errors.append(f"{evidence}: comment listing is not a list") + return + released = _release_url(evidence, thread) + if not released: + return + updated = dict(owner) + updated["released_url"] = released + out["owner"] = updated + + +def refresh_record(record: dict, fetch: Fetcher, now: str | None = None, + comments: Comments | None = None) -> dict: """Return a new record with freshness recomputed. Never raises for source errors.""" out = dict(record) out.pop("refresh_error", None) @@ -99,7 +214,7 @@ def refresh_record(record: dict, fetch: Fetcher, now: str | None = None) -> dict out["scientific_effect"] = "NONE" errors: list[str] = [] changed = False - current: list[str] = [] + current: list[tuple[str, str]] = [] sources = record.get("sources") if record.get("schema_version") != SCHEMA_VERSION: errors.append(f"unsupported schema_version: {record.get('schema_version')!r}") @@ -108,34 +223,49 @@ def refresh_record(record: dict, fetch: Fetcher, now: str | None = None) -> dict sources = [] for src in sources: url = src.get("url") if isinstance(src, dict) else None - if not url: - errors.append("source without url") + pinned = _PINNED.match(url) if isinstance(url, str) else None + if not pinned: + errors.append(f"{url}: source url is not a pinned blob or tree on main or Math-") continue recorded = src.get("rev") - if not isinstance(recorded, str) or not recorded: + if not isinstance(recorded, str) or not _SHA.fullmatch(recorded): errors.append(f"{url}: empty or missing source revision") continue + if recorded != pinned.group(3): + errors.append(f"{url}: source revision must appear in its url") + continue try: - rev = fetch(url) + fetched = fetch(url) except Exception as exc: # any fetch failure is unknown, never fresh errors.append(f"{url}: {exc}") continue - if not isinstance(rev, str) or not rev: - errors.append(f"{url}: empty revision") + rev, observed = _current(fetched) + if not isinstance(rev, str) or not _SHA.fullmatch(rev): + errors.append(f"{url}: current revision is not a 40-hex sha") + continue + if not _UTC.fullmatch(observed): + errors.append(f"{url}: missing observed update time") continue - current.append(rev) - if rev != src.get("rev"): + current.append((rev, observed)) + if rev != recorded: changed = True + _apply_release(out, record, comments, errors) if errors: out["status"] = "unknown" out["refresh_error"] = "; ".join(errors) else: + best_rev, best_at = current[0] + for rev, observed in current[1:]: + if observed >= best_at: + best_rev, best_at = rev, observed out["status"] = "stale" if changed else "fresh" - out["source_rev"] = current[0] if len(current) == 1 else ",".join(current) + out["source_rev"] = best_rev + out["refresh_error"] = None return out -def refresh_dir(directory: Path, fetch: Fetcher, write: bool, now: str | None = None) -> list[dict]: +def refresh_dir(directory: Path, fetch: Fetcher, write: bool, now: str | None = None, + comments: Comments | None = None) -> list[dict]: report = [] for path in sorted(directory.glob("*.json")): try: @@ -145,7 +275,7 @@ def refresh_dir(directory: Path, fetch: Fetcher, write: bool, now: str | None = except Exception as exc: report.append({"file": path.name, "status": "unknown", "refresh_error": f"unreadable: {exc}"}) continue - new = refresh_record(record, fetch, now) + new = refresh_record(record, fetch, now, comments) report.append({"file": path.name, "node_id": new.get("node_id"), "status": new["status"], "previous_status": record.get("status"), "refresh_error": new.get("refresh_error")}) From 7adcfaa1be6685955ad9bfe9a2036e6747806a4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 17:42:11 +0000 Subject: [PATCH 4/9] refresh: keep source_rev on the primary source source_rev stays sources[0].rev. A moving-ref sha is compared and never stored. Issue, pull, and comment sources use updated_at and go stale only when that timestamp is newer than the recorded rev. Co-authored-by: Dylan Roy --- .../node_status_refresh/current_revs.json | 13 +- tests/fixtures/node_status_refresh/issue.json | 18 ++ tests/test_node_status_refresh.py | 129 +++++++----- tools/node_status_refresh.py | 188 +++++++++++------- 4 files changed, 216 insertions(+), 132 deletions(-) create mode 100644 tests/fixtures/node_status_refresh/issue.json diff --git a/tests/fixtures/node_status_refresh/current_revs.json b/tests/fixtures/node_status_refresh/current_revs.json index 2ffca90af..8bd3f5eab 100644 --- a/tests/fixtures/node_status_refresh/current_revs.json +++ b/tests/fixtures/node_status_refresh/current_revs.json @@ -1,10 +1,7 @@ { - "https://github.com/d6g8k5htny-coder/main/blob/1111111111111111111111111111111111111111/a.md": { - "rev": "1111111111111111111111111111111111111111", - "observed_at": "2026-10-22T12:00:00Z" - }, - "https://github.com/d6g8k5htny-coder/main/blob/2222222222222222222222222222222222222222/b.md": { - "rev": "3333333333333333333333333333333333333333", - "observed_at": "2026-10-22T15:00:00Z" - } + "https://github.com/d6g8k5htny-coder/main/blob/1111111111111111111111111111111111111111/a.md": "1111111111111111111111111111111111111111", + "https://github.com/d6g8k5htny-coder/main/blob/2222222222222222222222222222222222222222/b.md": "3333333333333333333333333333333333333333", + "https://github.com/d6g8k5htny-coder/main/issues/475": "2026-10-22T18:00:00Z", + "https://github.com/d6g8k5htny-coder/main/pull/490": "2026-10-22T12:00:00Z", + "https://github.com/d6g8k5htny-coder/Math-/pull/463#issuecomment-6198606495": "2026-10-15T17:50:29Z" } diff --git a/tests/fixtures/node_status_refresh/issue.json b/tests/fixtures/node_status_refresh/issue.json new file mode 100644 index 000000000..3a791bf5f --- /dev/null +++ b/tests/fixtures/node_status_refresh/issue.json @@ -0,0 +1,18 @@ +{ + "missing_input": [], + "next_action": "review", + "node_id": "issue", + "observed_at": "2026-10-22T00:00:00Z", + "owner": null, + "schema_version": 0, + "scientific_effect": "NONE", + "source_rev": "2026-10-22T17:00:00Z", + "sources": [ + { + "rev": "2026-10-22T17:00:00Z", + "url": "https://github.com/d6g8k5htny-coder/main/issues/475" + } + ], + "status": "fresh", + "stops": [] +} diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py index f10892589..9061f1462 100644 --- a/tests/test_node_status_refresh.py +++ b/tests/test_node_status_refresh.py @@ -18,8 +18,7 @@ def offline_fetch(url): if url not in REVS: raise nsr.SourceError("unreachable (offline fixture)") - item = REVS[url] - return item["rev"], item["observed_at"] + return REVS[url] def load(name): @@ -45,10 +44,12 @@ def test_unchanged_is_fresh(self): self.assertIsNone(out["refresh_error"]) def test_changed_rev_is_stale(self): - out = nsr.refresh_record(load("stale.json"), offline_fetch, NOW) + rec = load("stale.json") + out = nsr.refresh_record(rec, offline_fetch, NOW) self.assertEqual(out["status"], "stale") - self.assertEqual(out["source_rev"], "3333333333333333333333333333333333333333") - self.assertNotIn(",", out["source_rev"]) + self.assertEqual(out["source_rev"], rec["sources"][0]["rev"]) + self.assertEqual(out["source_rev"], "2222222222222222222222222222222222222222") + self.assertNotIn("3333333333333333333333333333333333333333", json.dumps(out)) def test_unreachable_is_unknown_with_error(self): rec = load("unknown.json") @@ -155,33 +156,29 @@ def missing_fetch(url): self.assertEqual(out["status"], "unknown") self.assertNotEqual(out["status"], "fresh") - def test_source_rev_is_single_newest_observed_update(self): - older = "5" * 40 - newer = "6" * 40 + def test_source_rev_is_primary_and_moving_ref_is_not_stored(self): + primary = "5" * 40 + other = "6" * 40 + moved = "7" * 40 rec = load("fresh.json") + rec["source_rev"] = moved rec["sources"] = [ - {"url": "https://github.com/d6g8k5htny-coder/main/blob/" + older + "/old.md", "rev": older}, - {"url": "https://github.com/d6g8k5htny-coder/Math-/tree/" + newer + "/new", "rev": newer}, + {"url": "https://github.com/d6g8k5htny-coder/main/blob/" + primary + "/old.md", "rev": primary}, + {"url": "https://github.com/d6g8k5htny-coder/Math-/tree/" + other + "/new", "rev": other}, ] def fetch(url): if url.endswith("/old.md"): - return older, "2026-10-22T10:00:00Z" - return newer, "2026-10-22T16:00:00Z" + return primary + return moved out = nsr.refresh_record(rec, fetch, NOW) - self.assertEqual(out["status"], "fresh") - self.assertEqual(out["source_rev"], newer) - self.assertNotIn(",", out["source_rev"]) - self.assertEqual(len(out["source_rev"]), 40) - - def tied(url): - if url.endswith("/old.md"): - return older, "2026-10-22T16:00:00Z" - return newer, "2026-10-22T16:00:00Z" - - tied_out = nsr.refresh_record(rec, tied, NOW) - self.assertEqual(tied_out["source_rev"], newer) + self.assertEqual(out["status"], "stale") + self.assertEqual(out["source_rev"], primary) + self.assertEqual(out["sources"][0]["rev"], primary) + self.assertEqual(out["sources"][1]["rev"], other) + self.assertNotIn(moved, json.dumps(out)) + self.assertIsNone(out["refresh_error"]) def test_pinned_sha_accepted_and_other_rev_forms_unknown(self): math_sha = "a" * 40 @@ -190,27 +187,11 @@ def test_pinned_sha_accepted_and_other_rev_forms_unknown(self): "url": "https://github.com/d6g8k5htny-coder/Math-/tree/" + math_sha + "/frontiers/downstream_gate_20260925", "rev": math_sha, }] - out = nsr.refresh_record(rec, lambda url: (math_sha, "2026-10-22T12:00:00Z"), NOW) + out = nsr.refresh_record(rec, lambda url: math_sha, NOW) self.assertEqual(out["status"], "fresh") + self.assertEqual(out["source_rev"], math_sha) self.assertIsNone(out["refresh_error"]) - issue = load("fresh.json") - issue["sources"] = [{ - "url": "https://github.com/d6g8k5htny-coder/main/issues/475", - "rev": "2026-10-22T17:30:25Z", - }] - out = nsr.refresh_record(issue, offline_fetch, NOW) - self.assertEqual(out["status"], "unknown") - self.assertIsInstance(out["refresh_error"], str) - self.assertNotEqual(out["source_rev"], "2026-10-22T17:30:25Z") - - comment = load("fresh.json") - comment["sources"] = [{ - "url": "https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347046676", - "rev": "2026-10-22T17:30:25Z", - }] - self.assertEqual(nsr.refresh_record(comment, offline_fetch, NOW)["status"], "unknown") - other = load("fresh.json") other["sources"][0]["url"] = "https://github.com/example/repo/blob/" + ("1" * 40) + "/a.md" other["sources"][0]["rev"] = "1" * 40 @@ -222,7 +203,7 @@ def test_pinned_sha_accepted_and_other_rev_forms_unknown(self): mismatch["sources"][0]["rev"] = "2" * 40 out = nsr.refresh_record(mismatch, offline_fetch, NOW) self.assertEqual(out["status"], "unknown") - self.assertIn("appear in its url", out["refresh_error"]) + self.assertIn("40-hex sha in its url", out["refresh_error"]) upper = load("fresh.json") upper_sha = "A" * 40 @@ -232,6 +213,48 @@ def test_pinned_sha_accepted_and_other_rev_forms_unknown(self): }] self.assertEqual(nsr.refresh_record(upper, offline_fetch, NOW)["status"], "unknown") + def test_discussion_updated_at_newer_is_stale_and_unfetched_is_unknown(self): + issue = nsr.refresh_record(load("issue.json"), offline_fetch, NOW) + self.assertEqual(issue["status"], "stale") + self.assertEqual(issue["source_rev"], "2026-10-22T17:00:00Z") + self.assertNotIn("2026-10-22T18:00:00Z", json.dumps(issue)) + self.assertIsNone(issue["refresh_error"]) + + pull = load("fresh.json") + pull["sources"] = [{ + "url": "https://github.com/d6g8k5htny-coder/main/pull/490", + "rev": "2026-10-22T12:00:00Z", + }] + pull["source_rev"] = "9999999999999999999999999999999999999999" + same = nsr.refresh_record(pull, offline_fetch, NOW) + self.assertEqual(same["status"], "fresh") + self.assertEqual(same["source_rev"], "2026-10-22T12:00:00Z") + + comment_url = "https://github.com/d6g8k5htny-coder/Math-/pull/463#issuecomment-6198606495" + comment = load("fresh.json") + comment["sources"] = [{"url": comment_url, "rev": "2026-10-15T18:00:00Z"}] + older = nsr.refresh_record(comment, offline_fetch, NOW) + self.assertEqual(older["status"], "fresh") + self.assertEqual(older["source_rev"], "2026-10-15T18:00:00Z") + + missing = load("fresh.json") + missing["sources"] = [{ + "url": "https://github.com/d6g8k5htny-coder/main/issues/1", + "rev": "2026-10-22T17:00:00Z", + }] + missing["source_rev"] = "2026-10-22T17:00:00Z" + unknown = nsr.refresh_record(missing, offline_fetch, NOW) + self.assertEqual(unknown["status"], "unknown") + self.assertIsInstance(unknown["refresh_error"], str) + self.assertIn("unreachable", unknown["refresh_error"]) + self.assertEqual(unknown["source_rev"], "2026-10-22T17:00:00Z") + + bad_rev = load("issue.json") + bad_rev["sources"][0]["rev"] = "not-a-timestamp" + bad = nsr.refresh_record(bad_rev, offline_fetch, NOW) + self.assertEqual(bad["status"], "unknown") + self.assertIn("UTC Z", bad["refresh_error"]) + def test_refresh_error_is_string_or_null(self): fresh = nsr.refresh_record(load("fresh.json"), offline_fetch, NOW) self.assertIn("refresh_error", fresh) @@ -284,22 +307,28 @@ def test_github_fetch_uses_moving_ref_for_pinned_url(self): def fake(path): seen.append(path) - return [{"sha": "ab" * 20, "commit": {"committer": {"date": "2026-10-22T18:00:00+00:00"}}}] + if "/commits?" in path: + return [{"sha": "ab" * 20, "commit": {"committer": {"date": "2026-10-22T18:00:00+00:00"}}}] + return {"updated_at": "2026-10-22T17:30:25+00:00"} nsr._api_get = fake pin = "1" * 40 url = "https://github.com/d6g8k5htny-coder/Math-/blob/" + pin + "/frontiers/downstream_gate_20260925/GRAPH.json" - rev, observed = nsr.github_fetch(url) - self.assertEqual(rev, "ab" * 20) - self.assertEqual(observed, "2026-10-22T18:00:00Z") + self.assertEqual(nsr.github_fetch(url), "ab" * 20) self.assertEqual(len(seen), 1) self.assertIn("/repos/d6g8k5htny-coder/Math-/commits?", seen[0]) self.assertNotIn("sha=", seen[0]) self.assertNotIn(pin, seen[0]) + self.assertEqual(nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/issues/475"), "2026-10-22T17:30:25Z") + self.assertIn("/repos/d6g8k5htny-coder/main/issues/475", seen[-1]) + self.assertEqual(nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/pull/495"), "2026-10-22T17:30:25Z") + self.assertIn("/repos/d6g8k5htny-coder/main/pulls/495", seen[-1]) + self.assertEqual( + nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347046676"), + "2026-10-22T17:30:25Z") + self.assertIn("/repos/d6g8k5htny-coder/main/issues/comments/6347046676", seen[-1]) with self.assertRaises(nsr.SourceError): - nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/issues/475") - with self.assertRaises(nsr.SourceError): - nsr.github_fetch("https://github.com/d6g8k5htny-coder/main/pull/495") + nsr.github_fetch("https://example.org/x") with self.assertRaises(nsr.SourceError): nsr.github_fetch("https://github.com/example/repo/blob/" + pin + "/a.md") diff --git a/tools/node_status_refresh.py b/tools/node_status_refresh.py index b0bedb288..ce2174c6c 100644 --- a/tools/node_status_refresh.py +++ b/tools/node_status_refresh.py @@ -1,34 +1,27 @@ #!/usr/bin/env python3 """Recompute freshness for node-status records (LANES-20261022 Lane 3). -Interface v0 is #495's schema at 5a58e3e0 (architecture/node-status.schema.json). -Where that schema differs from the Lane 3 note, this tool follows #495: - - - source_rev is one 40-hex commit. Comma-joining is refused. - - A source is valid only as a pinned blob or tree URL on - d6g8k5htny-coder/main or Math-, and sources[].rev must be the 40-hex - SHA embedded in that URL. Issue, pull, and comment updated_at strings - are not source revs (#495's validator allows only the pinned form). - - #495 does not say which SHA is source_rev when sources disagree. - This tool uses the current rev of the source with the latest observed - update. The observed update is the moving-ref committer time (the - repository default branch, not the pinned SHA). Equal times: the later - source in the list wins. That choice is not defined by #495. - - #495's record shape has no refresh_error and no owner.released_url. - The note still requires both: refresh_error is a string or null, and a - later RELEASE comment that names the owner's PICKUP sets - owner.released_url without changing any other owner field. - -Only status, observed_at, source_rev, refresh_error, and that one owner -field are written. status is freshness only, never scientific validity. -scientific_effect stays "NONE". Owner identity is never inferred. +Settled rule: source_rev is sources[0].rev, the primary source. For a pinned +blob or tree on d6g8k5htny-coder/main or Math-, that rev is the 40-hex sha in +the URL. The moving-ref sha is never written into the record. If that moving +ref has moved past source_rev, status is stale. If it cannot be resolved, +status is unknown and refresh_error says why. + +#495 is adding the other three forms this tool already accepts: an updated_at +rev (UTC Z string) for an issue, pull, or comment URL on main or Math-; an +optional refresh_error (string or null); and an optional owner.released_url. +An issue, pull, or comment source is stale when the object's updated_at is +newer than its rev, and unknown when that updated_at cannot be fetched. + +Only status, observed_at, source_rev (set to the primary rev, not a fetched +tip), refresh_error, and that one owner field are written. status is freshness +only, never scientific validity. scientific_effect stays "NONE". Owner identity +is never inferred. Rules: - - every source rev unchanged -> "fresh" - - any source rev changed -> "stale" - - any source unreachable / unparseable -> "unknown" (never "fresh") - - empty, missing, or non-40-hex rev -> "unknown" (never "fresh") - - anything other than a pinned blob/tree -> "unknown" (never "fresh") + - no source moved past its recorded rev -> "fresh" + - any source moved past its recorded rev -> "stale" + - any source unreachable / not a valid rev -> "unknown" (never "fresh") Precedence: unknown > stale > fresh. The default fetcher reads the public GitHub REST API (read-only, optional @@ -59,6 +52,9 @@ _PINNED = re.compile( r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(blob|tree)/" r"([0-9a-f]{40})/([A-Za-z0-9_./-]+)$") +_DISCUSSION = re.compile( + r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/" + r"([1-9][0-9]*)(?:#issuecomment-([1-9][0-9]*))?$") _COMMENT = re.compile( r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/" r"([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$") @@ -97,29 +93,59 @@ def _api_get(path: str) -> object: raise SourceError(f"{type(exc).__name__}: {exc}") from exc -def github_fetch(url: str) -> tuple[str, str]: - """Return (moving-ref SHA, committer time) for a pinned blob or tree URL. +def _classify(url: object) -> tuple[str, re.Match[str]] | tuple[None, None]: + if not isinstance(url, str): + return None, None + pinned = _PINNED.match(url) + if pinned: + return "sha", pinned + discussion = _DISCUSSION.fullmatch(url) + if discussion: + return "time", discussion + return None, None + + +def _recorded_ok(kind: str | None, match: re.Match[str] | None, recorded: object) -> bool: + if kind == "sha" and match is not None: + return isinstance(recorded, str) and bool(_SHA.fullmatch(recorded)) and recorded == match.group(3) + if kind == "time": + return isinstance(recorded, str) and bool(_UTC.fullmatch(recorded)) + return False - The pin in the URL is the recorded rev. Freshness compares it with the - latest commit on the repository default branch that touches the path. + +def github_fetch(url: str) -> str: + """Return the current moving-ref sha, or a discussion object's updated_at. + + Neither value is written into the record. Callers compare it with the + recorded rev and keep sources[0].rev as source_rev. """ - m = _PINNED.match(url) - if not m: - raise SourceError(f"unsupported source url: {url}") - repo, path = m.group(1), m.group(4) - query = f"/repos/d6g8k5htny-coder/{repo}/commits?path={quote(path, safe='/')}&per_page=1" - commits = _api_get(query) - if not isinstance(commits, list) or not commits or not isinstance(commits[0], dict): - raise SourceError(f"no commits for path: {url}") - sha = commits[0].get("sha") - commit = commits[0].get("commit") - committer = commit.get("committer") if isinstance(commit, dict) else None - observed = _as_z(committer.get("date") if isinstance(committer, dict) else "") - if not isinstance(sha, str) or not _SHA.fullmatch(sha): - raise SourceError(f"{url}: current revision is not a 40-hex sha") - if not _UTC.fullmatch(observed): - raise SourceError(f"{url}: missing observed update time") - return sha, observed + kind, match = _classify(url) + if kind == "sha" and match is not None: + repo, path = match.group(1), match.group(4) + query = f"/repos/d6g8k5htny-coder/{repo}/commits?path={quote(path, safe='/')}&per_page=1" + commits = _api_get(query) + if not isinstance(commits, list) or not commits or not isinstance(commits[0], dict): + raise SourceError(f"no commits for path: {url}") + sha = commits[0].get("sha") + if not isinstance(sha, str) or not _SHA.fullmatch(sha): + raise SourceError(f"{url}: moving ref is not a 40-hex sha") + return sha + if kind == "time" and match is not None: + repo, disc_kind, number, comment_id = match.group(1), match.group(2), match.group(3), match.group(4) + if comment_id: + path = f"/repos/d6g8k5htny-coder/{repo}/issues/comments/{comment_id}" + elif disc_kind == "pull": + path = f"/repos/d6g8k5htny-coder/{repo}/pulls/{number}" + else: + path = f"/repos/d6g8k5htny-coder/{repo}/issues/{number}" + data = _api_get(path) + if not isinstance(data, dict): + raise SourceError(f"{url}: updated_at could not be read") + updated = _as_z(data.get("updated_at")) + if not _UTC.fullmatch(updated): + raise SourceError(f"{url}: updated_at is not a UTC Z timestamp") + return updated + raise SourceError(f"unsupported source url: {url}") def github_comments(evidence_url: str) -> list[dict]: @@ -176,10 +202,10 @@ def _release_url(evidence_url: str, thread: object) -> str | None: return best_url -def _current(fetched: object) -> tuple[object, str]: - if isinstance(fetched, tuple) and len(fetched) == 2: - return fetched[0], _as_z(fetched[1]) - return fetched, "" +def _current(fetched: object) -> object: + if isinstance(fetched, tuple) and fetched: + return fetched[0] + return fetched def _apply_release(out: dict, record: dict, comments: Comments | None, errors: list[str]) -> None: @@ -205,6 +231,16 @@ def _apply_release(out: dict, record: dict, comments: Comments | None, errors: l out["owner"] = updated +def _primary_rev(sources: object) -> str | None: + if not isinstance(sources, list) or not sources or not isinstance(sources[0], dict): + return None + kind, match = _classify(sources[0].get("url")) + recorded = sources[0].get("rev") + if _recorded_ok(kind, match, recorded) and isinstance(recorded, str): + return recorded + return None + + def refresh_record(record: dict, fetch: Fetcher, now: str | None = None, comments: Comments | None = None) -> dict: """Return a new record with freshness recomputed. Never raises for source errors.""" @@ -214,8 +250,8 @@ def refresh_record(record: dict, fetch: Fetcher, now: str | None = None, out["scientific_effect"] = "NONE" errors: list[str] = [] changed = False - current: list[tuple[str, str]] = [] sources = record.get("sources") + primary = _primary_rev(sources) if record.get("schema_version") != SCHEMA_VERSION: errors.append(f"unsupported schema_version: {record.get('schema_version')!r}") if not isinstance(sources, list) or not sources: @@ -223,43 +259,47 @@ def refresh_record(record: dict, fetch: Fetcher, now: str | None = None, sources = [] for src in sources: url = src.get("url") if isinstance(src, dict) else None - pinned = _PINNED.match(url) if isinstance(url, str) else None - if not pinned: - errors.append(f"{url}: source url is not a pinned blob or tree on main or Math-") + kind, match = _classify(url) + recorded = src.get("rev") if isinstance(src, dict) else None + if kind is None: + errors.append(f"{url}: source url is not a pinned blob or tree, or a main/Math- issue, pull, or comment") continue - recorded = src.get("rev") - if not isinstance(recorded, str) or not _SHA.fullmatch(recorded): + if not isinstance(recorded, str) or not recorded: errors.append(f"{url}: empty or missing source revision") continue - if recorded != pinned.group(3): - errors.append(f"{url}: source revision must appear in its url") + if not _recorded_ok(kind, match, recorded): + if kind == "sha": + errors.append(f"{url}: source revision must be the 40-hex sha in its url") + else: + errors.append(f"{url}: source revision must be a UTC Z timestamp") continue try: fetched = fetch(url) except Exception as exc: # any fetch failure is unknown, never fresh errors.append(f"{url}: {exc}") continue - rev, observed = _current(fetched) - if not isinstance(rev, str) or not _SHA.fullmatch(rev): - errors.append(f"{url}: current revision is not a 40-hex sha") - continue - if not _UTC.fullmatch(observed): - errors.append(f"{url}: missing observed update time") - continue - current.append((rev, observed)) - if rev != recorded: - changed = True + current = _current(fetched) + if kind == "sha": + if not isinstance(current, str) or not _SHA.fullmatch(current): + errors.append(f"{url}: moving ref is not a 40-hex sha") + continue + if current != recorded: + changed = True + else: + current_z = _as_z(current) + if not _UTC.fullmatch(current_z): + errors.append(f"{url}: updated_at is not a UTC Z timestamp") + continue + if current_z > recorded: + changed = True _apply_release(out, record, comments, errors) + if primary is not None: + out["source_rev"] = primary if errors: out["status"] = "unknown" out["refresh_error"] = "; ".join(errors) else: - best_rev, best_at = current[0] - for rev, observed in current[1:]: - if observed >= best_at: - best_rev, best_at = rev, observed out["status"] = "stale" if changed else "fresh" - out["source_rev"] = best_rev out["refresh_error"] = None return out From dfc84c6bef2cfbb271c34f2a92f62c7bf279f287 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 18:03:19 +0000 Subject: [PATCH 5/9] ci: drop lane-3 path filters and test PICKUP id match The current-site pull_request paths match main again. A RELEASE sets owner.released_url only when it names the owner's PICKUP id. Co-authored-by: Dylan Roy --- .github/workflows/current-site.yml | 3 --- tests/test_node_status_refresh.py | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/current-site.yml b/.github/workflows/current-site.yml index f11db0b71..0683a52e2 100644 --- a/.github/workflows/current-site.yml +++ b/.github/workflows/current-site.yml @@ -12,9 +12,6 @@ name: Current research site - 'tools/current_site_history.py' - 'tools/current_site_publication.py' - 'tests/test_current_site*' - - 'tools/node_status_refresh.py' - - 'tests/test_node_status_refresh.py' - - 'tests/fixtures/node_status_refresh/**' - '.github/workflows/current-site.yml' schedule: - cron: '*/30 * * * *' diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py index 9061f1462..10cac5512 100644 --- a/tests/test_node_status_refresh.py +++ b/tests/test_node_status_refresh.py @@ -302,6 +302,29 @@ def boom(url): self.assertIsInstance(failed["refresh_error"], str) self.assertEqual(failed["owner"], rec["owner"]) + def test_release_sets_released_url_only_for_matching_pickup_id(self): + pickup = "https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6346922771" + other = "https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347042124" + match = "https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347046676" + rec = load("fresh.json") + rec["owner"] = {"id": "agent-9", "evidence_url": pickup} + + def refresh(body, url): + return nsr.refresh_record(rec, offline_fetch, NOW, lambda evidence: [{"url": url, "body": body}]) + + different = refresh("**RELEASE: other pickup**\n\n- Releases PICKUP 111.\n", other) + self.assertNotIn("released_url", different["owner"]) + self.assertEqual(different["owner"], rec["owner"]) + + unnamed = refresh("**RELEASE: no pickup id**\n\nThe review is finished.\n", other) + self.assertNotIn("released_url", unnamed["owner"]) + self.assertEqual(unnamed["owner"], rec["owner"]) + + matched = refresh("**RELEASE: this pickup**\n\n- Releases PICKUP 6346922771.\n", match) + self.assertEqual(matched["owner"]["released_url"], match) + self.assertEqual(matched["owner"]["id"], "agent-9") + self.assertEqual(matched["owner"]["evidence_url"], pickup) + def test_github_fetch_uses_moving_ref_for_pinned_url(self): seen = [] From 9f893952348bc81ab88308d9c68d80867fb6b66b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 21:15:39 +0000 Subject: [PATCH 6/9] ci: restore node-status refresh pull_request paths Co-authored-by: Dylan Roy --- .github/workflows/current-site.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/current-site.yml b/.github/workflows/current-site.yml index 0683a52e2..f11db0b71 100644 --- a/.github/workflows/current-site.yml +++ b/.github/workflows/current-site.yml @@ -12,6 +12,9 @@ name: Current research site - 'tools/current_site_history.py' - 'tools/current_site_publication.py' - 'tests/test_current_site*' + - 'tools/node_status_refresh.py' + - 'tests/test_node_status_refresh.py' + - 'tests/fixtures/node_status_refresh/**' - '.github/workflows/current-site.yml' schedule: - cron: '*/30 * * * *' From 41dc7bbd69aa6a019c44bce506f2122c858e2b1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 21:49:33 +0000 Subject: [PATCH 7/9] refresh: reject duplicate JSON keys before observing a record A repeated key was kept as its last value and could be reported fresh. The raw-record loader now refuses it, leaves the file untouched, and records unknown. Co-authored-by: Dylan Roy --- tests/test_node_status_refresh.py | 31 +++++++++++++++++++++++++++++++ tools/node_status_refresh.py | 12 +++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py index 10cac5512..4d67b9731 100644 --- a/tests/test_node_status_refresh.py +++ b/tests/test_node_status_refresh.py @@ -325,6 +325,37 @@ def refresh(body, url): self.assertEqual(matched["owner"]["id"], "agent-9") self.assertEqual(matched["owner"]["evidence_url"], pickup) + def test_duplicate_json_key_is_unknown_and_not_rewritten(self): + text = json.dumps(load("fresh.json")) + owner = text.replace('"owner": null', '"owner": {"id": "unbacked"}', 1) + raw_owner = owner[:-1] + ',"owner": null}' + item = ('{"id": "hold-1", "text": "Unresolved synthetic item", "status": "open", ' + '"source_url": "https://github.com/example/repo/issues/1"}') + raw_carried = text[:-1] + ',"carried_forward": [' + item + '], "carried_forward": []}' + calls = [] + + def fetch(url): + calls.append(url) + return offline_fetch(url) + + for label, raw, key in ( + ("owner", raw_owner, "owner"), + ("carried_forward", raw_carried, "carried_forward"), + ): + with self.subTest(label=label): + calls.clear() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "fresh.json" + path.write_text(raw, encoding="utf-8") + before = path.read_bytes() + for write in (False, True): + report = nsr.refresh_dir(Path(tmp), fetch, write=write, now=NOW) + self.assertEqual(len(report), 1) + self.assertEqual(report[0]["status"], "unknown") + self.assertIn("duplicate JSON key: " + key, report[0]["refresh_error"]) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(calls, []) + def test_github_fetch_uses_moving_ref_for_pinned_url(self): seen = [] diff --git a/tools/node_status_refresh.py b/tools/node_status_refresh.py index ce2174c6c..7a84e4844 100644 --- a/tools/node_status_refresh.py +++ b/tools/node_status_refresh.py @@ -304,12 +304,22 @@ def refresh_record(record: dict, fetch: Fetcher, now: str | None = None, return out +def _unique_pairs(pairs): + """Reject duplicate JSON names before observing or rewriting a record.""" + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + def refresh_dir(directory: Path, fetch: Fetcher, write: bool, now: str | None = None, comments: Comments | None = None) -> list[dict]: report = [] for path in sorted(directory.glob("*.json")): try: - record = json.loads(path.read_text(encoding="utf-8")) + record = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique_pairs) if not isinstance(record, dict): raise ValueError("record is not an object") except Exception as exc: From ea85291a63624ade0a6f43c7619fdeb465d9d71d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 21:54:17 +0000 Subject: [PATCH 8/9] ci: retrigger checks (runner flake in run 39860339360) Co-authored-by: Dylan Roy From 216e4cbc104ca14507a4e8a464a4556632186729 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 23:08:14 +0000 Subject: [PATCH 9/9] refresh: show a discovered released_url in the report-only output The non-write report dropped owner.released_url after refresh_record found a matching RELEASE. The report row now includes that URL. Co-authored-by: Dylan Roy --- tests/test_node_status_refresh.py | 21 +++++++++++++++++++++ tools/node_status_refresh.py | 10 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/test_node_status_refresh.py b/tests/test_node_status_refresh.py index 4d67b9731..4303fa615 100644 --- a/tests/test_node_status_refresh.py +++ b/tests/test_node_status_refresh.py @@ -325,6 +325,27 @@ def refresh(body, url): self.assertEqual(matched["owner"]["id"], "agent-9") self.assertEqual(matched["owner"]["evidence_url"], pickup) + def test_report_only_includes_discovered_released_url(self): + pickup = "https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6346922771" + release = "https://github.com/d6g8k5htny-coder/main/pull/495#issuecomment-6347046676" + body = "**RELEASE: nonauthor review**\n\n- Releases PICKUP 6346922771.\n" + rec = load("fresh.json") + rec["owner"] = {"id": "agent-9", "evidence_url": pickup} + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "fresh.json" + path.write_text(json.dumps(rec), encoding="utf-8") + before = path.read_bytes() + report = nsr.refresh_dir( + Path(tmp), offline_fetch, write=False, now=NOW, + comments=lambda url: [{"url": release, "body": body}]) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(report[0]["status"], "fresh") + self.assertEqual(report[0].get("released_url"), release) + quiet = nsr.refresh_dir( + Path(tmp), offline_fetch, write=False, now=NOW, comments=lambda url: []) + self.assertNotIn("released_url", quiet[0]) + self.assertEqual(path.read_bytes(), before) + def test_duplicate_json_key_is_unknown_and_not_rewritten(self): text = json.dumps(load("fresh.json")) owner = text.replace('"owner": null', '"owner": {"id": "unbacked"}', 1) diff --git a/tools/node_status_refresh.py b/tools/node_status_refresh.py index 7a84e4844..4fa5945b1 100644 --- a/tools/node_status_refresh.py +++ b/tools/node_status_refresh.py @@ -326,9 +326,13 @@ def refresh_dir(directory: Path, fetch: Fetcher, write: bool, now: str | None = report.append({"file": path.name, "status": "unknown", "refresh_error": f"unreadable: {exc}"}) continue new = refresh_record(record, fetch, now, comments) - report.append({"file": path.name, "node_id": new.get("node_id"), "status": new["status"], - "previous_status": record.get("status"), - "refresh_error": new.get("refresh_error")}) + row = {"file": path.name, "node_id": new.get("node_id"), "status": new["status"], + "previous_status": record.get("status"), + "refresh_error": new.get("refresh_error")} + owner = new.get("owner") + if isinstance(owner, dict) and isinstance(owner.get("released_url"), str) and owner["released_url"]: + row["released_url"] = owner["released_url"] + report.append(row) if write: path.write_text(json.dumps(new, indent=2, sort_keys=True) + "\n", encoding="utf-8") return report