From 13ced9edf689d770df55f04de865b07c25282f91 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sun, 11 Oct 2026 21:13:38 +0100 Subject: [PATCH] feat: validate optional organ connections before saving profiles --- STARTER.md | 48 ++++++++++ scripts/connect_organ.py | 126 +++++++++++++++++++++++++ tests/test_connect_organ.py | 180 ++++++++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 scripts/connect_organ.py create mode 100644 tests/test_connect_organ.py diff --git a/STARTER.md b/STARTER.md index b6359b5..2de4eb2 100644 --- a/STARTER.md +++ b/STARTER.md @@ -122,6 +122,54 @@ reads that feed during rendering and browser refresh; it never installs or runs an organ. Grey available-to-add project resources are described below. Resumable organ activation and independent deployment remain subsequent epic phases. +### Validate a connection before saving it + +For an organ that already has a public dashboard, use the connection helper. +First register its actual repository identity in your Mind map. For example, +`my-guides` may identify your independently named Broca repository; `broca` +remains the framework organ key. Supply the actual board URL, not a guessed +repository-name suffix: + +```sh +PYAUTO_BRAIN=/path/to/PyAutoBrain python scripts/connect_organ.py \ + --instance /path/to/instance/instance.json --repos /path/to/Mind/repos.yaml \ + --organ broca --repo my-guides --pages-url https://research.example/guides/ +``` + +This previews the connection after checking the existing instance/projects and +reading only the selected board's `state.json`. The feed must match Brain's state +contract, organ key, canonical repository name and board URL. The command checks +declared identity, not repository access or ownership. Repeat with `--apply` to +validate again and save the connection. A valid red or old feed can be connected: +the command reports its status and timestamp, not a healthy/ready verdict. + +Failed validation leaves the profile unchanged. Fix the missing prerequisite +and rerun the same command; no reinitialization is needed. An identical connection +is a no-op after validation. A different existing connection is refused for +manual review. The helper atomically replaces only `instance.json`, preserves its +permissions and other configuration, and refuses input changes detected during +validation. Avoid concurrent manual edits while applying. Projects, canonical +identities, research records and organ source remain untouched. + +Once connected, missing or stale evidence stays visible as evidence trouble; +it does not turn the organ back into an unconfigured capability. This step does +not create an organ, publish it, configure upkeep, or validate its scientific or +communication quality. Broca's project-owned guides/workspaces and separate +assistants retain their own source; connecting Broca copies none of them. + +To resume with an AI assistant: + +> Read my profile, canonical map and existing Mind task first. Resume the selected +> organ connection using connect_organ.py. Ask only for missing repository identity +> or the public board URL. Preview and explain the change before applying it. +> If the organ or its feed is not ready, keep setup incomplete in this Mind task; +> do not invent a feed or mark it connected. Preserve all existing records and +> report connection validation separately from readiness. Do not create or publish +> anything without its own approved setup task. + +Organ provisioning, resumable owner-specific setup, pause controls and deployment +are later activation phases. This helper covers the validated connection boundary. + ## Discover what a project can add The separate `examples/connected-project/` fixture shows **Mira Scientist** with diff --git a/scripts/connect_organ.py b/scripts/connect_organ.py new file mode 100644 index 0000000..9afae45 --- /dev/null +++ b/scripts/connect_organ.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Preview or connect an already prepared organ; validate before changing records.""" + +import argparse +import json +import os +from pathlib import Path +import stat +import tempfile + +import yaml + +import instance_profile +import organism_board +import project_resources + + +def plan(instance, repos, organ, repo, pages_url): + """Return validated replacement bytes and input snapshots, without writing. + + A connection is a location/contract check, never installation or a health + verdict. Even an identical retry checks current evidence before reporting OK. + """ + instance, repos = Path(instance), Path(repos) + if instance.is_symlink(): + raise ValueError("instance profile must not be a symlink") + original = instance.read_bytes() + body_bytes = repos.read_bytes() + config = json.loads(original) + theme = organism_board.theme() + _, projects = instance_profile.load(instance, repos, theme) + project_bytes = projects.read_bytes() + project_resources.load(projects, repos) + family = theme.board_links(config["pages_url"], current="organism") + if organ not in family: + raise ValueError("organ must be a canonical Brain organ key") + github = instance_profile._identity(repo, yaml.safe_load(body_bytes)["repos"]) + connection = {"repo": repo, "pages_url": instance_profile._pages(pages_url)} + existing = config["organs"].get(organ) + if existing is not None: + normalized = dict(existing, pages_url=instance_profile._pages(existing["pages_url"])) + if normalized != connection: + raise ValueError("organ already has a different connection; review it manually") + + from _state import validate_state # theme() selects the matching Brain checkout. + + feed = json.loads(organism_board._get(connection["pages_url"] + "state.json")) + errors = validate_state(feed) + if errors: + raise ValueError("invalid organ feed: " + "; ".join(errors)) + if feed["organ"] != organ or feed["repo"] != github.split("/")[1]: + raise ValueError("organ feed identity does not match the selected organ/repository") + if instance_profile._pages(feed["pages_url"]) != connection["pages_url"]: + raise ValueError("organ feed pages_url does not match the selected location") + + config["organs"][organ] = connection + return { + "instance": instance, + "inputs": {instance: original, repos: body_bytes, projects: project_bytes}, + "mode": stat.S_IMODE(instance.stat().st_mode), + "replacement": (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode(), + "changed": existing is None, + "organ": organ, + "connection": connection, + "status": feed["status"], + "updated": feed["updated"], + } + + +def _unchanged(prepared): + if prepared["instance"].is_symlink(): + raise ValueError("instance profile became a symlink; rerun the preview") + for path, original in prepared["inputs"].items(): + if path.read_bytes() != original: + raise ValueError(f"input changed during validation: {path}; rerun the preview") + + +def apply(prepared): + """Preserve existing records on validation/write failure; replace one file.""" + _unchanged(prepared) + if not prepared["changed"]: + return False + path = prepared["instance"] + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".connect-organ-", delete=False) as stream: + temporary = Path(stream.name) + os.fchmod(stream.fileno(), prepared["mode"]) + stream.write(prepared["replacement"]) + stream.flush() + os.fsync(stream.fileno()) + _unchanged(prepared) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return True + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--instance", type=Path, required=True) + parser.add_argument("--repos", type=Path, required=True) + parser.add_argument("--organ", required=True, help="stable Brain organ key, e.g. broca") + parser.add_argument("--repo", required=True, help="repository key in the canonical identity map") + parser.add_argument("--pages-url", required=True, help="explicit public HTTPS board directory") + parser.add_argument("--apply", action="store_true", help="connect after validation; default previews only") + args = parser.parse_args(argv) + try: + prepared = plan(args.instance, args.repos, args.organ, args.repo, args.pages_url) + _unchanged(prepared) + if args.apply: + changed = apply(prepared) + print("Connected" if changed else "Already connected", args.organ) + else: + print("Preview only; pass --apply to connect" if prepared["changed"] else "Already connected; no change") + print(json.dumps({args.organ: prepared["connection"]}, indent=2)) + print(f"Feed: {prepared['status']} ยท updated {prepared['updated']}") + print("Connection validated; organ readiness and freshness remain with its owner.") + except (OSError, ValueError, RuntimeError, yaml.YAMLError) as exc: + parser.exit(2, f"connect-organ: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_connect_organ.py b/tests/test_connect_organ.py new file mode 100644 index 0000000..6b27cdc --- /dev/null +++ b/tests/test_connect_organ.py @@ -0,0 +1,180 @@ +"""Connection validation, record preservation and resumed setup through Broca.""" +import copy +import json +from pathlib import Path +import stat +import sys +import urllib.error + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import connect_organ as connector +import instance_profile +import organism_board as board + + +@pytest.fixture +def pilot(tmp_path, monkeypatch): + instance, repos = tmp_path / "instance.json", tmp_path / "repos.yaml" + instance.write_text(json.dumps({"schema_version": 1, "name": "Mira Scientist", + "repo": "scientist", "pages_url": "https://example.invalid/mira/", + "projects": "projects.json", "organs": {}})) + instance.chmod(0o640) + repos.write_text("repos:\n scientist:\n github: Research/Mira\n" + " guides:\n github: Research/Communication\n") + (tmp_path / "projects.json").write_text(json.dumps({"schema_version": 1, + "resources": {}, "projects": {"spectra": {"name": "Spectra", + "kind": "scientific_project", "public": True, "resources": {}}}})) + (tmp_path / "research-note.md").write_text("Keep baseline selection A.\n") + feed = {"schema_version": 1, "organ": "broca", "repo": "Communication", + "status": "green", "headline": "No assistant required", "items": [], + "updated": "2020-01-01T00:00:00Z", "pages_url": "https://example.invalid/guides/"} + requests = [] + def get(url): + requests.append(url) + return json.dumps(feed) + monkeypatch.setattr(board, "_get", get) + args = ["--instance", str(instance), "--repos", str(repos), "--organ", "broca", + "--repo", "guides", "--pages-url", "https://example.invalid/guides/"] + return instance, repos, feed, requests, args + + +def snapshot(root): + return {p.name: p.read_bytes() for p in root.iterdir() if p.is_file()} + + +def test_preview_connect_resume_and_render(pilot, monkeypatch, capsys): + instance, repos, feed, requests, args = pilot + original = snapshot(instance.parent) + assert connector.main(args) == 0 + assert snapshot(instance.parent) == original + assert "Preview only" in capsys.readouterr().out + assert connector.main(args + ["--apply"]) == 0 + connected = snapshot(instance.parent) + assert [k for k in original if original[k] != connected[k]] == ["instance.json"] + assert stat.S_IMODE(instance.stat().st_mode) == 0o640 + assert connector.main(args + ["--apply"]) == 0 + assert snapshot(instance.parent) == connected + assert requests == ["https://example.invalid/guides/state.json"] * 3 + profile, _ = instance_profile.load(instance, repos, board.theme()) + rendered = board.render(board.collect(instance=profile), "html") + assert 'id="board-broca"' in rendered + assert "Mira Scientist" in rendered + assert 'id="board-heart"' not in rendered + def unavailable(url): + raise urllib.error.URLError("offline") + monkeypatch.setattr(board, "_get", unavailable) + data = board.collect(instance=profile) + assert data["boards"][0]["error"] == "Evidence unavailable" + assert 'id="board-broca"' in board.render(data, "html") + with pytest.raises(SystemExit): + connector.main(args + ["--apply"]) + assert snapshot(instance.parent) == connected + + +@pytest.mark.parametrize("change", [ + {"organ": "heart"}, {"repo": "PyAutoBroca"}, {"schema_version": 999}, + {"pages_url": "https://example.invalid/somewhere-else/"}, + {"updated": "not-a-date"}, {"status": "success"}, +]) +def test_invalid_evidence_never_connects(pilot, change): + instance, _, feed, _, args = pilot + before = snapshot(instance.parent) + feed.update(change) + with pytest.raises(SystemExit) as exc: + connector.main(args + ["--apply"]) + assert exc.value.code == 2 + assert snapshot(instance.parent) == before + + +def test_failed_setup_can_resume_without_reinitializing(pilot, monkeypatch): + instance, _, feed, _, args = pilot + before = snapshot(instance.parent) + monkeypatch.setattr(board, "_get", lambda _: "not json") + with pytest.raises(SystemExit): + connector.main(args + ["--apply"]) + assert snapshot(instance.parent) == before + monkeypatch.setattr(board, "_get", lambda _: json.dumps(feed)) + assert connector.main(args + ["--apply"]) == 0 + assert json.loads(instance.read_text())["organs"]["broca"]["repo"] == "guides" + + +@pytest.mark.parametrize("status", ["red", "yellow", "grey", "green"]) +def test_connection_does_not_require_healthy_or_fresh_evidence(pilot, status, capsys): + _, _, feed, _, args = pilot + feed["status"] = status + connector.main(args + ["--apply"]) + output = capsys.readouterr().out + assert f"Feed: {status}" in output and "2020-01-01" in output + + +def test_conflicting_connection_refuses_without_fetch(pilot, monkeypatch): + instance, _, _, _, args = pilot + connector.main(args + ["--apply"]) + before = instance.read_bytes() + monkeypatch.setattr(board, "_get", lambda _: pytest.fail("conflict fetched evidence")) + args[-1] = "https://example.invalid/different/" + with pytest.raises(SystemExit): + connector.main(args + ["--apply"]) + assert instance.read_bytes() == before + + +@pytest.mark.parametrize("filename", ["instance.json", "repos.yaml", "projects.json"]) +def test_changed_inputs_reject_stale_plan(pilot, filename): + instance, repos, _, _, _ = pilot + prepared = connector.plan(instance, repos, "broca", "guides", "https://example.invalid/guides/") + path = instance.parent / filename + path.write_bytes(path.read_bytes() + b"\n") + before = snapshot(instance.parent) + with pytest.raises(ValueError, match="input changed"): + connector.apply(prepared) + assert snapshot(instance.parent) == before + + +def test_atomic_replace_failure_preserves_original_and_cleans_temp(pilot, monkeypatch): + instance, repos, _, _, _ = pilot + prepared = connector.plan(instance, repos, "broca", "guides", "https://example.invalid/guides/") + before = snapshot(instance.parent) + def fail(*args): + raise OSError("write refused") + monkeypatch.setattr(connector.os, "replace", fail) + with pytest.raises(OSError): + connector.apply(prepared) + assert snapshot(instance.parent) == before + + +def test_other_connections_are_preserved(pilot): + instance, _, _, requests, args = pilot + config = json.loads(instance.read_text()) + config["organs"]["mind"] = {"repo": "scientist", "pages_url": "https://example.invalid/plans/"} + previous = copy.deepcopy(config) + instance.write_text(json.dumps(config)) + connector.main(args + ["--apply"]) + config = json.loads(instance.read_text()) + del config["organs"]["broca"] + assert config == previous + assert requests == ["https://example.invalid/guides/state.json"] + + +@pytest.mark.parametrize("key,value", [("--organ", "unknown"), ("--repo", "missing"), + ("--pages-url", "http://example.invalid/")]) +def test_invalid_choices_do_not_fetch_or_write(pilot, monkeypatch, key, value): + instance, _, _, _, args = pilot + before = snapshot(instance.parent) + args[args.index(key) + 1] = value + monkeypatch.setattr(board, "_get", lambda _: pytest.fail("invalid choice fetched")) + with pytest.raises(SystemExit): + connector.main(args + ["--apply"]) + assert snapshot(instance.parent) == before + + +def test_symlink_profile_is_refused(pilot): + instance, _, _, _, args = pilot + real = instance.with_name("real.json") + instance.rename(real) + instance.symlink_to(real) + original = real.read_bytes() + with pytest.raises(SystemExit): + connector.main(args + ["--apply"]) + assert instance.is_symlink() and real.read_bytes() == original