diff --git a/.github/workflows/cortex_check.yml b/.github/workflows/cortex_check.yml index 27422f3..f7be531 100644 --- a/.github/workflows/cortex_check.yml +++ b/.github/workflows/cortex_check.yml @@ -19,6 +19,7 @@ on: - "checkin.yaml" - "dashboard.md" - "dashboard.html" + - "state.json" - "scripts/**" - "tests/**" - ".github/workflows/cortex_check.yml" @@ -30,6 +31,7 @@ on: - "checkin.yaml" - "dashboard.md" - "dashboard.html" + - "state.json" - "scripts/**" - "tests/**" - ".github/workflows/cortex_check.yml" diff --git a/.github/workflows/dashboard_refresh.yml b/.github/workflows/dashboard_refresh.yml index 004d06e..8d9f91a 100644 --- a/.github/workflows/dashboard_refresh.yml +++ b/.github/workflows/dashboard_refresh.yml @@ -35,6 +35,7 @@ on: - "checkin.yaml" - "dashboard.md" - "dashboard.html" + - "state.json" pull_request: paths: - "projects/**" @@ -43,6 +44,7 @@ on: - "checkin.yaml" - "dashboard.md" - "dashboard.html" + - "state.json" workflow_dispatch: # contents: write is needed by the self-heal push on main; PR runs never push. @@ -156,6 +158,9 @@ jobs: exit 1 fi git add dashboard.md dashboard.html + # state.json is the organ-cockpit feed rendered by the same run + # (PyAutoBrain#418); guarded because a missing pathspec is fatal. + if [ -f state.json ]; then git add state.json; fi git commit -m "cortex: self-heal stale dashboard pages" if git push origin HEAD:main; then echo "healed on attempt ${attempt}" diff --git a/.github/workflows/ledger_merge.yml b/.github/workflows/ledger_merge.yml index 1e00444..6a3bf1c 100644 --- a/.github/workflows/ledger_merge.yml +++ b/.github/workflows/ledger_merge.yml @@ -197,14 +197,15 @@ jobs: exit 0 fi git checkout -B _ledger_merge origin/main - # Built without committing: the two generated pages are the one + # Built without committing: the generated renders (the two pages and + # the state.json cockpit feed, PyAutoBrain#418) are the one # conflict every ledger branch can produce (each re-renders them), # and a render is never a reason to strand a ledger — take main's # copy, let the merged tree be re-rendered on main by # dashboard_refresh.yml. Anything else unmerged is a human's. if ! git merge --no-ff --no-commit "$HEAD_SHA" >/dev/null 2>&1; then UNMERGED=$(git diff --name-only --diff-filter=U) - OTHER=$(echo "$UNMERGED" | grep -vE '^(dashboard\.md|dashboard\.html)$' || true) + OTHER=$(echo "$UNMERGED" | grep -vE '^(dashboard\.md|dashboard\.html|state\.json)$' || true) if [ -n "$OTHER" ]; then git merge --abort || true echo "'$BRANCH' conflicts with main on: $(echo $OTHER)" > /tmp/ledger_merge_reason diff --git a/.github/workflows/pages_dashboard.yml b/.github/workflows/pages_dashboard.yml index a79fd77..1608c7a 100644 --- a/.github/workflows/pages_dashboard.yml +++ b/.github/workflows/pages_dashboard.yml @@ -17,12 +17,16 @@ name: Pages Dashboard # Only the uploaded files reach Pages — nothing else in the repo, and in # particular not `projects/` or `projects.yaml`, whose absolute laptop # paths are not for a public site. +# The same render writes `state.json`, the organ-cockpit feed (PyAutoBrain +# board/_state.py v1, PyAutoBrain#418); it is published at /state.json after +# validating against the Brain's contract. on: push: branches: [main] paths: - "dashboard.html" + - "state.json" workflow_dispatch: permissions: @@ -42,6 +46,12 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 + # The cockpit-feed contract lives with the Brain (board/_state.py); the + # Cortex only publishes what the Brain's cortex renderer wrote. + - uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoBrain + path: PyAutoBrain # enablement: true creates the Pages site on first run, so no manual # Settings → Pages step is needed. - uses: actions/configure-pages@v5 @@ -51,6 +61,17 @@ jobs: run: | mkdir -p _site cp dashboard.html _site/index.html + # The organ-cockpit feed, when the renderer has written it. + if [ -f state.json ]; then cp state.json _site/state.json; fi + # A feed that has drifted from the contract fails the job here, before + # Pages publishes something the cockpit would reject. + - name: Validate the cockpit feed against the Brain contract + run: | + if [ -f _site/state.json ]; then + python3 PyAutoBrain/board/_state.py _site/state.json + else + echo "no state.json rendered yet — nothing to validate" + fi - uses: actions/upload-pages-artifact@v3 with: path: _site diff --git a/scripts/ledger_merge.py b/scripts/ledger_merge.py index b481557..1900915 100755 --- a/scripts/ledger_merge.py +++ b/scripts/ledger_merge.py @@ -76,7 +76,9 @@ # push. If they were code, every ordinary ledger branch would stop for a human # on two files nobody wrote by hand. `checkin.yaml` is the same kind of thing: # one machine-written stamp, pushed by the check-in that earned it. -LEDGER_FILES = ("checkin.yaml", "dashboard.md", "dashboard.html") +# `state.json` is the third render of the same census — the organ-cockpit feed +# (PyAutoBrain board/_state.py v1, PyAutoBrain#418) — so it rides with them. +LEDGER_FILES = ("checkin.yaml", "dashboard.md", "dashboard.html", "state.json") # Names that are ledger by location but must not ride along, for two different # reasons. diff --git a/tests/test_ledger_merge.py b/tests/test_ledger_merge.py index 91f1e3a..301078e 100644 --- a/tests/test_ledger_merge.py +++ b/tests/test_ledger_merge.py @@ -34,6 +34,8 @@ def test_ledger_dir_and_registry_files_are_ledger(): # generated from the ledger, self-healed on main by dashboard_refresh.yml "dashboard.md", "dashboard.html", + # the organ-cockpit feed, rendered by the same run (PyAutoBrain#418) + "state.json", ): assert ledger_merge.is_ledger_path(path), path @@ -341,4 +343,13 @@ def test_no_scheduled_job_mutates_the_ledger(): if "schedule:" in text: assert p.name == "dashboard_refresh.yml", p.name assert "git add dashboard.md dashboard.html" in text + assert "then git add state.json; fi" in text assert "projects/" not in text.split("git add", 1)[1].split("\n")[0] + + +def test_a_checkin_push_carrying_the_cockpit_feed_needs_no_human(): + """`cortex checkin --push` stages state.json beside the pages it renders.""" + ledger, blocked = ledger_merge.classify( + ["checkin.yaml", "dashboard.md", "dashboard.html", "state.json"]) + assert blocked == [] and "state.json" in ledger + assert not ledger_merge.is_ledger_path("projects/state.json/../../scripts/x.py")