diff --git a/.github/workflows/firewall_gate.yml b/.github/workflows/firewall_gate.yml index ef9bb7057..88b032579 100644 --- a/.github/workflows/firewall_gate.yml +++ b/.github/workflows/firewall_gate.yml @@ -48,6 +48,16 @@ name: Tenant Firewall Gate # — and drops the `organism-map blocks (generated)` leg with the same loud # banner. Independent of the other two decisions; graded on push to main. # +# The Codex adapters are the fourth (PyAutoMind#520): a PR that changes what +# an opted-in repo's `.codex/hooks.json` should say (a new hook definition, a +# repo opting in) cannot make the copies on the cloned mains match — they are +# written by session_hook_propagate.yml after merge. The decision renders every +# repo's adapter from the PR's base tree and from its head +# (`scripts/codex_hooks_render.py`) and sets `codex_pr` only when they differ, +# so a repos.yaml edit that leaves the adapters alone still grades them. It +# asserts what the PR controls — the generator's hermetic tests — and drops the +# `generated Codex hooks` leg with the same loud banner; graded on push to main. +# # All three organs are checked out. PyAutoHands was omitted at first — its # firewall clear waited behind the version-stamp task that claimed the repo # (PyAutoHands#235) — and joined once that leg landed as PyAutoHands#237, @@ -58,6 +68,7 @@ on: branches: [main] paths: - "scripts/repos_sync.py" + - "scripts/codex_hooks_render.py" - "policy/session_start_hook.sh" - "policy/end_at_deliverable_hook.sh" - "policy/where_to_file.md" @@ -69,6 +80,7 @@ on: pull_request: paths: - "scripts/repos_sync.py" + - "scripts/codex_hooks_render.py" - "policy/session_start_hook.sh" - "policy/end_at_deliverable_hook.sh" - "policy/where_to_file.md" @@ -168,6 +180,28 @@ jobs: echo "map_pr=false" >> "$GITHUB_OUTPUT" fi + # Independent of the three decisions above (PyAutoMind#520): does this PR + # change what any repo's .codex/hooks.json should say? Rendered from the + # base tree and the head tree, never guessed from which files changed — + # a repos.yaml edit that leaves every adapter alone still grades them. + - name: Codex-adapter PR? + id: codexpr + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + mkdir -p codex_base + git -C PyAutoMind archive "origin/${BASE_REF}" scripts repos.yaml | tar -x -C codex_base + python3 PyAutoMind/scripts/codex_hooks_render.py --mind codex_base > codex_base.json + python3 PyAutoMind/scripts/codex_hooks_render.py --mind PyAutoMind > codex_head.json + if cmp -s codex_base.json codex_head.json; then + echo "codex_pr=false" >> "$GITHUB_OUTPUT" + else + echo "Codex adapters this PR changes:" + diff codex_base.json codex_head.json | sed -n 's/^> *"\([^"]*\)": .*/ \1/p' + echo "codex_pr=true" >> "$GITHUB_OUTPUT" + fi + # The compensating control, and it runs FIRST so it is never shadowed by # the leg below: PyAutoMind is the one repo the propagation workflow # never writes to, so its own installed copies are exactly what this PR @@ -198,6 +232,16 @@ jobs: python3 -m pytest PyAutoMind/tests/test_repos_sync_organism_map.py -q \ -k "renders_from_the_manifest" + # The compensating control for the Codex adapters: the installed copies + # are written after merge, so what this PR controls is that the generator + # renders them — every opted-in repo, deterministically — and its tests pass. + - name: Assert the Codex adapters render (copies are written after merge) + if: steps.codexpr.outputs.codex_pr == 'true' + run: | + pip install pytest + python3 -m pytest PyAutoMind/tests/test_codex_hook_sync.py -q + ! grep -q '"error: ' codex_head.json + # The shared-standards rollout (#474) is complete: all 46 registered # repos carry the generated block on main (PyAutoArray#614 and # euclid_strong_lens_modeling_pipeline#110 were the last). This step @@ -212,11 +256,12 @@ jobs: --only "shared-standards blocks (generated)" --repo PyAutoMind - name: Drift check (canonical-hook, where-to-file or repos.yaml PR — unmeetable legs skipped) - if: steps.hookpr.outputs.hook_pr == 'true' || steps.hookpr.outputs.filing_pr == 'true' || steps.hookpr.outputs.map_pr == 'true' + if: steps.hookpr.outputs.hook_pr == 'true' || steps.hookpr.outputs.filing_pr == 'true' || steps.hookpr.outputs.map_pr == 'true' || steps.codexpr.outputs.codex_pr == 'true' env: HOOK_PR: ${{ steps.hookpr.outputs.hook_pr }} FILING_PR: ${{ steps.hookpr.outputs.filing_pr }} MAP_PR: ${{ steps.hookpr.outputs.map_pr }} + CODEX_PR: ${{ steps.codexpr.outputs.codex_pr }} run: | skip=() if [ "$HOOK_PR" = "true" ]; then @@ -267,11 +312,26 @@ jobs: echo "==============================================================" skip+=(--skip "organism-map blocks (generated)") fi + if [ "$CODEX_PR" = "true" ]; then + echo "==============================================================" + echo "SKIPPED LEG: 'generated Codex hooks'" + echo "WHY: this PR changes what an opted-in repo's .codex/hooks.json" + echo " should say (base and head trees render differently). The" + echo " sibling organs are checked out at their mains, so their" + echo " adapters are stale BY CONSTRUCTION until" + echo " .github/workflows/session_hook_propagate.yml writes them on the" + echo " push to main after this PR merges." + echo "STILL ENFORCED: the generator renders every opted-in adapter" + echo " without error and its tests pass (asserted in the step above)." + echo "ON push TO main this leg is never skipped." + echo "==============================================================" + skip+=(--skip "generated Codex hooks") + fi python3 PyAutoMind/scripts/repos_sync.py --check \ --root "$GITHUB_WORKSPACE" "${skip[@]}" # Every leg, the shared-standards leg included, graded on the cloned mains. - name: Drift check (all legs — absent repos are skipped) - if: steps.hookpr.outputs.hook_pr != 'true' && steps.hookpr.outputs.filing_pr != 'true' && steps.hookpr.outputs.map_pr != 'true' + if: steps.hookpr.outputs.hook_pr != 'true' && steps.hookpr.outputs.filing_pr != 'true' && steps.hookpr.outputs.map_pr != 'true' && steps.codexpr.outputs.codex_pr != 'true' run: | python3 PyAutoMind/scripts/repos_sync.py --check --root "$GITHUB_WORKSPACE" diff --git a/repos.yaml b/repos.yaml index ab2ec98b1..c887f0008 100644 --- a/repos.yaml +++ b/repos.yaml @@ -109,7 +109,7 @@ repos: organ: Brain role: "Reasoning/orchestration layer; how work is decomposed and routed; the specialist agents." public_role: "Works out *how*: classifies, plans and routes work through specialist reasoning and coding agents." - codex_hooks: [mind-commit-guard, end-at-deliverable] + codex_hooks: [mind-commit-guard, scheduler-action-guard, end-at-deliverable] PyAutoBroca: board_owner: true path: organs/PyAutoBroca diff --git a/scripts/codex_hooks_render.py b/scripts/codex_hooks_render.py new file mode 100755 index 000000000..b1baf05ab --- /dev/null +++ b/scripts/codex_hooks_render.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Print every repo's rendered `.codex/hooks.json`, as one JSON object, for a Mind tree. + + python3 scripts/codex_hooks_render.py --mind + +The tree's own `scripts/repos_sync.py` and `repos.yaml` do the rendering, so the +same helper renders a pull request's base tree and its head alike. The firewall +gate compares the two to decide whether a PR changes what the Codex adapters +should say: if it does, every opted-in repo's installed `.codex/hooks.json` is +stale by construction until `session_hook_propagate.yml` runs after merge. +A repo whose `codex_hooks` the tree cannot render prints as its error, so a +broken manifest still reads as a change rather than hiding one. +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import sys +from pathlib import Path + + +def rendered(mind_root: Path) -> dict: + sys.path.insert(0, str(mind_root / "scripts")) + try: + repos_sync = importlib.import_module("repos_sync") + finally: + sys.path.pop(0) + _, repos = repos_sync.load_manifest(mind_root) + out = {} + for name, spec in sorted(repos.items()): + if not repos_sync.codex_hook_names(spec): + continue + try: + out[name] = repos_sync.render_codex_hooks(spec) + except ValueError as error: + out[name] = f"error: {error}" + return out + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--mind", type=Path, required=True) + args = parser.parse_args(argv) + print(json.dumps(rendered(args.mind.resolve()), sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/repos_sync.py b/scripts/repos_sync.py index 377a09b0c..04be543a2 100644 --- a/scripts/repos_sync.py +++ b/scripts/repos_sync.py @@ -358,6 +358,13 @@ def all_checkouts(root): ), "statusMessage": "Checking shared PyAutoMind commit safety", }, + "scheduler-action-guard": { + "matcher": "Bash", + "command": ( + 'python3 "$(git rev-parse --show-toplevel)/bin/scheduler_action_guard.py"' + ), + "statusMessage": "Checking for raw scheduler actions", + }, "pyauto-api-gate": { "matcher": "Bash", "command": ( diff --git a/tests/test_codex_hook_sync.py b/tests/test_codex_hook_sync.py index 171b81be5..1899fe437 100644 --- a/tests/test_codex_hook_sync.py +++ b/tests/test_codex_hook_sync.py @@ -115,3 +115,22 @@ def test_live_manifest_opts_in_exactly_the_phase_two_repos(): "PyAutoMind", "PyAutoBrain", "autofit_assistant", "autogalaxy_assistant", "autolens_assistant", "autocti_assistant", } + + +def test_render_helper_renders_the_opted_in_repos_of_a_tree(): + """`codex_hooks_render.py` is what the firewall gate diffs between a PR's base + and head; it renders through the tree's own generator and manifest.""" + import subprocess + + mind = Path(__file__).resolve().parents[1] + out = subprocess.run( + [sys.executable, str(mind / "scripts" / "codex_hooks_render.py"), "--mind", str(mind)], + capture_output=True, text=True, check=True, + ).stdout + rendered = json.loads(out) + _, repos = repos_sync.load_manifest(mind) + opted_in = sorted(name for name, spec in repos.items() if repos_sync.codex_hook_names(spec)) + assert sorted(rendered) == opted_in + assert all(not text.startswith("error: ") for text in rendered.values()) + assert rendered["PyAutoBrain"] == repos_sync.render_codex_hooks(repos["PyAutoBrain"]) + assert "scheduler_action_guard.py" in rendered["PyAutoBrain"] diff --git a/tests/test_session_hook_sync.py b/tests/test_session_hook_sync.py index 1e72d7f51..0acd3946b 100644 --- a/tests/test_session_hook_sync.py +++ b/tests/test_session_hook_sync.py @@ -630,10 +630,12 @@ def gate_steps(): # Either decision enables the one skipping step; only neither runs every leg. SKIP_IF = ("steps.hookpr.outputs.hook_pr == 'true' || " "steps.hookpr.outputs.filing_pr == 'true' || " - "steps.hookpr.outputs.map_pr == 'true'") + "steps.hookpr.outputs.map_pr == 'true' || " + "steps.codexpr.outputs.codex_pr == 'true'") FULL_IF = ("steps.hookpr.outputs.hook_pr != 'true' && " "steps.hookpr.outputs.filing_pr != 'true' && " - "steps.hookpr.outputs.map_pr != 'true'") + "steps.hookpr.outputs.map_pr != 'true' && " + "steps.codexpr.outputs.codex_pr != 'true'") def test_the_skip_is_reachable_only_from_a_pull_request(): @@ -805,7 +807,41 @@ def test_the_gate_skips_the_map_leg_by_its_printed_label(): run = _skip_step()["run"] assert f'--skip "{repos_sync.MAP_BLOCKS}"' in run, run assert "repos.yaml" in run - assert run.count("ON push TO main this leg is never skipped") == 3 + assert run.count("ON push TO main this leg is never skipped") == 4 + + +# -------------------------------------------------------------------------- +# The gate's Codex-adapter PR path (PyAutoMind#520) — same shape, own step +# -------------------------------------------------------------------------- + +def test_the_gate_skips_the_codex_leg_by_its_printed_label(): + """A renamed leg must break here rather than silently stop being skipped.""" + run = _skip_step()["run"] + assert f'--skip "{repos_sync.CODEX_HOOKS}"' in run, run + assert 'if [ "$CODEX_PR" = "true" ]' in run + + +def test_the_codex_decision_is_pr_only_and_renders_both_trees(): + """The decision compares rendered adapters, base against head — never a + guess from which files changed — and runs only on a pull request.""" + step = next(step for step in gate_steps() if step.get("id") == "codexpr") + assert step["if"] == "github.event_name == 'pull_request'" + assert step["run"].count("scripts/codex_hooks_render.py") == 2 + assert 'origin/${BASE_REF}' in step["run"] + assert "codex_pr=true" in step["run"] and "codex_pr=false" in step["run"] + + +def test_the_gate_still_asserts_the_codex_adapters_render(): + """The compensating control for the Codex skip runs the generator's tests + and refuses a render error, before the skipping drift check.""" + step = next(step for step in gate_steps() + if step.get("if") == "steps.codexpr.outputs.codex_pr == 'true'" + and "pytest" in (step.get("run") or "")) + assert "tests/test_codex_hook_sync.py" in step["run"] + assert "codex_head.json" in step["run"] + steps = gate_steps() + decision = next(s for s in steps if s.get("id") == "codexpr") + assert steps.index(decision) < steps.index(step) < steps.index(_skip_step()) def test_the_gate_still_asserts_the_manifest_renders_the_map():