diff --git a/STARTER.md b/STARTER.md index 75c5c09..e329935 100644 --- a/STARTER.md +++ b/STARTER.md @@ -69,7 +69,9 @@ Start with the empty project file. Supplying `--instance` never falls back to this repository's live project configuration. Use `--projects` only when you intentionally want to override the profile's file. Keep this configuration and your records when updating the framework; this change neither migrates nor -rewrites them. A supported upgrade procedure remains future work. +rewrites them. For the minimal Scientist/Brain pair, use the [pinned runtime and recovery +procedure](docs/instance-upgrades.md). Owner-organ and environment upgrades remain +separate. Omitting `--instance` retains the existing PyAutoLabs-compatible dashboard path. The current Actions publishing workflow still uses that path; this local preview @@ -248,4 +250,6 @@ The fictitious `examples/lifecycle/` profile contains a pending Broca, paused Mi and connected Heart with explicit independent names. Its `example.invalid` URLs are placeholders; it makes no claim of live deployment. Use the normal renderer with `--instance` and `--repos` for a local preview. Optional-organ provisioning, -real independent deployment and framework upgrades remain separate tasks. +real independent deployment and owner-organ upgrades remain separate tasks. +The [runtime upgrade guide](docs/instance-upgrades.md) covers Scientist/Brain +selection and recovery while preserving existing records. diff --git a/docs/instance-upgrades.md b/docs/instance-upgrades.md new file mode 100644 index 0000000..1fd4912 --- /dev/null +++ b/docs/instance-upgrades.md @@ -0,0 +1,114 @@ +# Upgrade a local Scientist without replacing user records + +This procedure selects an existing local Scientist/Brain checkout pair. User +configuration, the canonical Mind map, tasks, research and Broca records stay in +their existing directories. It does not upgrade packages, environments, owner-organ +code, schemas, harness settings or hosted dashboards. + +Keep both framework pairs available until recovery is no longer needed. Select +trusted framework code: validation executes its Python scripts. Record fingerprints +are a drift check, not a sandbox or backup against malicious or faulty code. + +## Register the current runtime + +From a Scientist checkout that contains `scripts/instance_runtime.py`: + +```sh +python scripts/instance_runtime.py initialize \ + --manifest /path/to/my-instance/runtime.json \ + --instance /path/to/my-instance/instance.json \ + --repos /path/to/MyMind/repos.yaml \ + --scientist /path/to/scientist-current --brain /path/to/brain-current \ + --protect /path/to/MyMind/active \ + --protect /path/to/research-records --protect /path/to/my-broca-data +``` + +The command previews the manifest and runs offline profile/project validation. +Review the protected paths and selected revisions, then repeat with `--apply`. +The manifest must be new, its parent must exist, and it must be outside framework +checkouts. Keep it private: it contains local paths. Existing starter instances can +register directly; no reinitialization or profile migration is required. + +Framework paths must be clean Git checkout roots with tracked entry points. Exact +commit IDs are recorded for both Scientist and Brain. Tracked modifications and +untracked, nonignored files are refused. Ignored framework files and the Python +environment are not version pins; use trusted, controlled environments. + +The profile directory and canonical map are always protected. Add each separate +record directory you need checked with repeated `--protect`. Only declared paths +are covered; research source and task records elsewhere are not implicitly scanned. +The scan streams file contents and records file/directory modes, so large data +trees cost time. `.git` entries are excluded; all other files, including hidden +files, are covered. Symlinks and special files are refused rather than silently +following external data. The runtime manifest itself is excluded from these record +hashes and checked separately. There is no stored copy of protected records. + +## Use the selected runtime + +```sh +python scripts/instance_runtime.py check --manifest /path/to/my-instance/runtime.json +python scripts/instance_runtime.py render --manifest /path/to/my-instance/runtime.json > preview.html +``` + +`check` is offline and uses the selected Scientist with the selected Brain. +`render` explicitly requests normal HTML rendering, including configured unpaused +organ feeds. Write its stdout outside protected directories. Both refuse changed +framework pins and detect record changes during execution. The Python interpreter +running this launcher supplies the environment; use the same compatible environment +for validation and normal use. Subprocess execution is capped at 120 seconds. + +Direct calls to a different `organism_board.py` do not consult the manifest. Use +this launcher when you want the saved selection to control execution. The launcher +can live in a retained known-good checkout; selected checkouts only need the +existing profile validation and rendering entry point. + +## Preview and apply an upgrade + +Prepare the candidate Scientist and Brain checkouts separately using normal Git +procedures. This helper does not fetch, pull, checkout, reset or install anything. + +```sh +python scripts/instance_runtime.py upgrade \ + --manifest /path/to/my-instance/runtime.json \ + --scientist /path/to/scientist-candidate --brain /path/to/brain-candidate +``` + +The candidate validates against **current** user configuration. Review the paths, +revisions, protected paths and validation output, then repeat with `--apply`. +Only the manifest changes: its current pair becomes the recovery pair and its +candidate becomes current. An identical selection is a no-op and does not discard +recovery information. Input drift or a write failure stops the switch; temporary +manifest files are removed on ordinary failures. Avoid concurrent edits during +preview/apply. Checks detect changes but are not a multi-user locking protocol. + +## Recover without losing newer work + +```sh +python scripts/instance_runtime.py rollback --manifest /path/to/my-instance/runtime.json +``` + +Review, then repeat with `--apply`. The previous pair must still exist at its +recorded revisions and must validate the current profile. Rollback switches the +framework selection; it does **not** restore yesterday's profile, task notes or +measurements. It can recover from a missing or dirty current pair. The two pairs +swap roles after success; only one previous selection is retained. + +If newer configuration is incompatible with the previous pair, rollback refuses +and preserves selection/records. Inspect the incompatibility before making manual +changes. If validation changes protected data, the helper reports drift and does +not apply the selection; inspect the affected files and your normal backups. It +never automatically overwrites those files to hide the change. + +If the launcher itself is unusable, run its copy from a retained known-good +Scientist checkout. Inspect the private manifest for the previous paths and commits, +verify those checkouts, then use their ordinary offline `--check-instance` command +with explicit `PYAUTO_BRAIN`, `--instance` and `--repos`. A missing/corrupt manifest +can be reconstructed into a **new** manifest with `initialize` after inspecting the +known-good selection; preserve the original file for diagnosis. Do not reset user +records or rerun the starter. This is recovery guidance, not a promise that arbitrary +schema downgrades or environment changes are compatible. + +For a fresh chat, provide the private manifest and existing Mind task, and ask the +assistant to inspect the current/previous revisions, validate offline and preview +the intended transition before applying it. Deployment and owner-organ upgrades +remain separate tasks. diff --git a/scripts/instance_runtime.py b/scripts/instance_runtime.py new file mode 100644 index 0000000..40567b7 --- /dev/null +++ b/scripts/instance_runtime.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Select a pinned local Scientist/Brain pair without upgrading user records.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile + +TIMEOUT = 120 +PAIR_KEYS = {"scientist", "brain"} + + +def plain(path): + path = Path(os.path.abspath(path)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise ValueError(f"symlink paths are not supported: {path}") + return path + + +def git(root, *args): + try: + return subprocess.check_output(["git", "-C", str(root), *args], text=True, + stderr=subprocess.PIPE, timeout=30).strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise ValueError(f"cannot inspect Git checkout: {root}") from exc + + +def pin(root, kind): + root = plain(root) + if Path(git(root, "rev-parse", "--show-toplevel")) != root: + raise ValueError("framework path must be a Git checkout root") + if git(root, "status", "--porcelain", "--untracked-files=all"): + raise ValueError(f"framework checkout is dirty: {root}") + required = {"scientist": ["scripts/organism_board.py"], + "brain": ["board/_theme.py", "board/_state.py"]}[kind] + for name in required: + path = plain(root / name) + if not path.is_file() or not git(root, "ls-files", "--", name): + raise ValueError(f"missing tracked {kind} entry point: {name}") + return {"path": str(root), "revision": git(root, "rev-parse", "HEAD")} + + +def verify_pair(pair): + if not isinstance(pair, dict) or set(pair) != PAIR_KEYS: + raise ValueError("runtime must pin Scientist and Brain") + for kind, expected in pair.items(): + if (not isinstance(expected, dict) or set(expected) != {"path", "revision"} + or not isinstance(expected["path"], str) or not Path(expected["path"]).is_absolute() + or not isinstance(expected["revision"], str) + or not re.fullmatch(r"[0-9a-f]{40,64}", expected["revision"])): + raise ValueError("invalid framework pin") + if pin(expected["path"], kind) != expected: + raise ValueError(f"{kind} revision changed; review before selecting it again") + + +def load(manifest): + manifest = plain(manifest) + data = json.loads(manifest.read_bytes()) + fields = {"schema_version", "instance", "repos", "protected", "current", "previous"} + if not isinstance(data, dict) or set(data) != fields or type(data["schema_version"]) is not int or data["schema_version"] != 1: + raise ValueError("invalid runtime manifest schema") + for key in ("instance", "repos"): + if not isinstance(data[key], str) or not Path(data[key]).is_absolute() or not plain(data[key]).is_file(): + raise ValueError(f"runtime {key} must name an existing absolute file") + protected = data["protected"] + if (not isinstance(protected, list) or not protected or + any(not isinstance(p, str) or not Path(p).is_absolute() for p in protected)): + raise ValueError("invalid protected paths") + if str(Path(data["instance"]).parent) not in protected or data["repos"] not in protected: + raise ValueError("profile directory and canonical map must remain protected") + if manifest == Path(data['instance']) or manifest == Path(data['repos']): + raise ValueError("manifest must be separate from user configuration") + return data + + +def fingerprints(paths, exclude=()): + """Hash regular files and directory modes; reject links and special files. + + Git metadata alone is excluded. No scientific/data directory is implicitly + skipped, and exclusions are limited to this tool's manifest/temporary file. + """ + excluded = {Path(p) for p in exclude} + result = {} + + def visit(path): + if path in excluded or path.name == ".git": + return + if path.is_symlink(): + raise ValueError(f"protected symlink is unsupported: {path}") + mode = path.stat().st_mode + if stat.S_ISDIR(mode): + result[str(path)] = ("directory", stat.S_IMODE(mode)) + for child in sorted(path.iterdir()): + visit(child) + elif stat.S_ISREG(mode): + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + result[str(path)] = (digest.hexdigest(), stat.S_IMODE(mode)) + else: + raise ValueError(f"protected path must be a regular file or directory: {path}") + + for path in paths: + visit(plain(path)) + return result + + +def boundaries(data, manifest, pair): + for kind in PAIR_KEYS: + code = Path(pair[kind]["path"]) + if manifest == code or code in manifest.parents: + raise ValueError("runtime manifest must live outside framework checkouts") + for value in data["protected"]: + record = Path(value) + if record == code or code in record.parents or record in code.parents: + raise ValueError("protected data and framework checkouts must be separate") + + +def execute(data, pair, render=False): + verify_pair(pair) + args = [sys.executable, '-B', str(Path(pair['scientist']['path']) / 'scripts/organism_board.py'), + '--instance', data['instance'], '--repos', data['repos'], + '--html' if render else '--check-instance'] + env = dict(os.environ, PYAUTO_BRAIN=pair['brain']['path'], PYTHONDONTWRITEBYTECODE='1') + try: + result = subprocess.run(args, cwd=pair['scientist']['path'], env=env, + capture_output=True, text=True, timeout=TIMEOUT) + except subprocess.TimeoutExpired as exc: + raise ValueError("selected runtime timed out") from exc + if result.returncode: + raise ValueError("selected runtime failed: " + (result.stderr or result.stdout)[-4000:]) + return result.stdout + + +def observe(data, manifest, pair, *, render=False): + boundaries(data, manifest, pair) + before = fingerprints(data['protected'], [manifest]) + original_manifest = manifest.read_bytes() if manifest.exists() else None + try: + output = execute(data, pair, render) + finally: + if (manifest.read_bytes() if manifest.exists() else None) != original_manifest: + raise ValueError("runtime manifest changed during execution; inspect manually") + if fingerprints(data['protected'], [manifest]) != before: + raise ValueError("protected records changed during runtime execution; inspect manually (no restore attempted)") + verify_pair(pair) + return before, output + + +def prepare(action, manifest, *, instance=None, repos=None, scientist=None, brain=None, protect=()): + manifest = plain(manifest) + if not manifest.parent.is_dir(): + raise ValueError("manifest parent directory must exist") + original = manifest.read_bytes() if manifest.exists() else None + if action == 'initialize': + if original is not None: + raise ValueError("runtime manifest exists; use upgrade or check") + instance, repos = plain(instance), plain(repos) + if not instance.is_file() or not repos.is_file() or manifest in (instance, repos): + raise ValueError("profile/map must exist separately from the manifest") + data = {"schema_version": 1, "instance": str(instance), "repos": str(repos), + "protected": sorted({str(instance.parent), str(repos), *(str(plain(p)) for p in protect)}), + "current": None, "previous": None} + else: + if original is None: + raise ValueError("runtime manifest is missing") + data = load(manifest) + if action in ('initialize', 'upgrade'): + candidate = {"scientist": pin(scientist, 'scientist'), "brain": pin(brain, 'brain')} + elif action == 'rollback': + candidate = data['previous'] + if candidate is None: + raise ValueError("no previous runtime is recorded") + verify_pair(candidate) + else: + raise ValueError("unknown runtime transition") + # An upgrade must retain a usable current pair as the recovery target. + # Rollback may recover from a missing or broken current pair. + if action == 'upgrade': + verify_pair(data['current']) + before, output = observe(data, manifest, candidate) + unchanged = (manifest.read_bytes() if manifest.exists() else None) == original + if not unchanged: + raise ValueError("manifest changed during validation; rerun preview") + changed = candidate != data['current'] + if changed: + data['previous'], data['current'] = data['current'], candidate + return {"manifest": manifest, "original": original, "data": data, + "records": before, "changed": changed, "validation": output, + "recovery": data['previous'] if action == 'upgrade' and changed else None} + + +def apply(prepared): + path, data = prepared['manifest'], prepared['data'] + + def unchanged(extra=()): + plain(path) + if (path.read_bytes() if path.exists() else None) != prepared['original']: + raise ValueError("manifest changed; rerun preview") + if fingerprints(data['protected'], [path, *extra]) != prepared['records']: + raise ValueError("protected records changed; rerun preview") + verify_pair(data['current']) + if prepared['recovery'] is not None: + verify_pair(prepared['recovery']) + + unchanged() + if not prepared['changed']: + return False + temporary = None + try: + fd, name = tempfile.mkstemp(prefix='.scientist-runtime-', dir=path.parent) + temporary = Path(name) + with os.fdopen(fd, 'w') as stream: + if prepared['original'] is not None: + os.fchmod(stream.fileno(), stat.S_IMODE(path.stat().st_mode)) + json.dump(data, stream, indent=2); stream.write('\n') + stream.flush(); os.fsync(stream.fileno()) + unchanged([temporary]) + if prepared['original'] is None: + os.link(temporary, path) # never replace a competing initializer + else: + os.replace(temporary, path) + return True + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['initialize', 'upgrade', 'rollback', 'check', 'render']) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--instance', type=Path) + parser.add_argument('--repos', type=Path) + parser.add_argument('--scientist', type=Path) + parser.add_argument('--brain', type=Path) + parser.add_argument('--protect', type=Path, action='append', default=[]) + parser.add_argument('--apply', action='store_true') + args = parser.parse_args(argv) + if args.action == 'initialize' and (args.instance is None or args.repos is None): + parser.error('initialize requires --instance and --repos') + if args.action in ('initialize', 'upgrade') and (args.scientist is None or args.brain is None): + parser.error('initialize/upgrade require --scientist and --brain') + if args.action != 'initialize' and (args.instance or args.repos or args.protect): + parser.error('profile, map and protected paths are selected at initialization') + if args.action not in ('initialize', 'upgrade') and (args.scientist or args.brain): + parser.error('this action uses the recorded framework selection') + if args.action in ('check', 'render') and args.apply: + parser.error('check/render do not accept --apply') + try: + if args.action in ('check', 'render'): + data = load(args.manifest) + _, output = observe(data, plain(args.manifest), data['current'], render=args.action == 'render') + print(output, end='') + else: + prepared = prepare(args.action, args.manifest, instance=args.instance, repos=args.repos, + scientist=args.scientist, brain=args.brain, protect=args.protect) + print(json.dumps(prepared['data'], indent=2)) + print(prepared['validation'], end='') + if args.apply: + print('Selected runtime updated' if apply(prepared) else 'Selection unchanged') + else: + print('Preview only; pass --apply to select this runtime') + except (OSError, ValueError, TypeError) as exc: + parser.exit(2, f'instance-runtime: {exc}\n') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_instance_runtime.py b/tests/test_instance_runtime.py new file mode 100644 index 0000000..551538d --- /dev/null +++ b/tests/test_instance_runtime.py @@ -0,0 +1,237 @@ +"""Pinned selection and failure recovery never restore old user records.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +import instance_runtime as runtime + + +def command(*args, cwd=None): + return subprocess.check_output(args, cwd=cwd, stderr=subprocess.STDOUT, text=True).strip() + + +def commit(root): + command('git', 'add', '.', cwd=root) + command('git', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', + 'commit', '-qm', 'fixture', cwd=root) + + +def repo(root, kind, version='A', reject=False): + root.mkdir() + command('git', 'init', '-q', cwd=root) + if kind == 'brain': + (root / 'board').mkdir() + for name in ['_theme.py', '_state.py']: + (root / 'board' / name).write_text('# fixture '+version+'\n') + else: + (root / 'scripts').mkdir() + (root / 'scripts/organism_board.py').write_text(f'''import sys, os, json +from pathlib import Path +profile = json.loads(Path(sys.argv[sys.argv.index('--instance') + 1]).read_text()) +assert not profile.get('unsupported'), 'incompatible current configuration' +assert os.environ['PYTHONDONTWRITEBYTECODE'] == '1' +assert Path(os.environ['PYAUTO_BRAIN'], 'board/_state.py').is_file() +if {reject!r}: raise SystemExit('candidate rejected') +print({'Runtime '+version!r}) +''') + commit(root) + return root + + +@pytest.fixture +def pilot(tmp_path): + data = tmp_path / 'data'; data.mkdir() + profile = data / 'instance.json'; profile.write_text('{"name":"Mira"}') + (data / 'projects.json').write_text('{"Spectra":"existing"}') + records = tmp_path / 'records'; records.mkdir() + for name in ['mind-task.md','research.md','broca-receipt.json']: + (records / name).write_text('Keep baseline-A: '+name) + repos = tmp_path / 'repos.yaml'; repos.write_text('repos: {}') + brain = repo(tmp_path / 'brain', 'brain') + old = repo(tmp_path / 'old', 'scientist', 'A') + new = repo(tmp_path / 'new', 'scientist', 'B') + manifest = data / 'runtime.json' + prepared = runtime.prepare('initialize',manifest,instance=profile,repos=repos, + scientist=old,brain=brain,protect=[records]) + return manifest, profile, repos, records, brain, old, new, prepared + + +def test_initialize_upgrade_rollback_and_launcher_preserve_current_records(pilot, capsys): + manifest, profile, repos, records, brain, old, new, prepared = pilot + assert not manifest.exists() + before = runtime.fingerprints([profile.parent, repos, records], [manifest]) + assert runtime.apply(prepared) + assert runtime.main(['check','--manifest',str(manifest)]) == 0 + assert 'Runtime A' in capsys.readouterr().out + upgrade = runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + assert json.loads(manifest.read_text())['current']['scientist']['path'] == str(old) + assert runtime.apply(upgrade) + assert runtime.main(['render','--manifest',str(manifest)]) == 0 + assert 'Runtime B' in capsys.readouterr().out + assert runtime.fingerprints([profile.parent,repos,records],[manifest]) == before + (records / 'research.md').write_text('Newer baseline-B after upgrade') + later = runtime.fingerprints([profile.parent,repos,records],[manifest]) + assert runtime.apply(runtime.prepare('rollback',manifest)) + assert runtime.fingerprints([profile.parent,repos,records],[manifest]) == later + assert runtime.main(['check','--manifest',str(manifest)]) == 0 + assert 'Runtime A' in capsys.readouterr().out + assert not runtime.apply(runtime.prepare('upgrade',manifest,scientist=old,brain=brain)) + + +def test_bad_candidate_never_changes_selection_or_records(pilot): + manifest, profile, repos, records, brain, old, new, prepared = pilot + runtime.apply(prepared) + before = runtime.fingerprints([profile.parent,repos,records]) + script=new/'scripts/organism_board.py'; script.write_text("raise SystemExit('invalid candidate')\n"); commit(new) + with pytest.raises(ValueError,match='invalid candidate'): + runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + assert runtime.fingerprints([profile.parent,repos,records]) == before + + +def test_dirty_or_changed_pin_is_refused(pilot): + manifest, _, _, _, brain, old, new, prepared = pilot + runtime.apply(prepared) + (new/'untracked.txt').write_text('unreviewed') + with pytest.raises(ValueError,match='dirty'): + runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + (old/'version.txt').write_text('changed revision');commit(old) + with pytest.raises(ValueError,match='revision changed'): + runtime.observe(runtime.load(manifest),manifest,runtime.load(manifest)['current']) + + +@pytest.mark.parametrize('which',['manifest','profile','record','pin']) +def test_stale_plan_never_applies(pilot,which): + manifest, profile, _, records, brain, old, new, prepared=pilot + runtime.apply(prepared) + change=runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + target={'manifest':manifest,'profile':profile,'record':records/'research.md','pin':new/'new.txt'}[which] + target.write_bytes(target.read_bytes()+b'\n' if target.exists() else b'new') + before=manifest.read_bytes() + with pytest.raises(ValueError): runtime.apply(change) + assert manifest.read_bytes()==before + assert not list(manifest.parent.glob('.scientist-runtime-*')) + + +def test_failed_atomic_replace_preserves_manifest(pilot,monkeypatch): + manifest, _, _, _, brain, _, new, prepared=pilot + runtime.apply(prepared) + change=runtime.prepare('upgrade',manifest,scientist=new,brain=brain); before=manifest.read_bytes() + def fail(*args): raise OSError('write refused') + monkeypatch.setattr(runtime.os,'replace',fail) + with pytest.raises(OSError): runtime.apply(change) + assert manifest.read_bytes()==before + assert not list(manifest.parent.glob('.scientist-runtime-*')) + + +def test_rollback_requires_previous_compatible_with_current_data(pilot): + manifest, profile, _, _, brain, old, new, prepared=pilot + runtime.apply(prepared) + with pytest.raises(ValueError,match='no previous'): runtime.prepare('rollback',manifest) + runtime.apply(runtime.prepare('upgrade',manifest,scientist=new,brain=brain)) + profile.write_text('{"unsupported":true}') + before=manifest.read_bytes() + with pytest.raises(ValueError,match='incompatible'): runtime.prepare('rollback',manifest) + assert manifest.read_bytes()==before and profile.read_text()=='{"unsupported":true}' + profile.write_text('{}') + (old/'scripts/organism_board.py').unlink() + with pytest.raises(ValueError): runtime.prepare('rollback',manifest) + assert manifest.read_bytes()==before + + +def test_rollback_can_recover_from_broken_current_runtime(pilot): + manifest, _, _, _, brain, _, new, prepared=pilot + runtime.apply(prepared) + runtime.apply(runtime.prepare('upgrade',manifest,scientist=new,brain=brain)) + (new/'scripts/organism_board.py').unlink() + assert runtime.apply(runtime.prepare('rollback',manifest)) + + +def test_mutating_candidate_is_detected_not_silently_restored(pilot): + manifest, _, _, records, brain, _, new, prepared=pilot + runtime.apply(prepared);before=manifest.read_bytes() + (new/'scripts/organism_board.py').write_text(f"from pathlib import Path\nPath({str(records/'research.md')!r}).write_text('unexpected edit')\n") + commit(new) + with pytest.raises(ValueError,match='protected records changed'): + runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + assert manifest.read_bytes()==before + assert (records/'research.md').read_text()=='unexpected edit' + + +def test_protected_symlinks_and_framework_overlap_refused(pilot): + manifest, profile, _, records, brain, _, new, prepared=pilot + runtime.apply(prepared) + (records/'link').symlink_to(profile) + with pytest.raises(ValueError,match='symlink'): + runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + data=runtime.load(manifest);data['protected'].append(str(new)) + with pytest.raises(ValueError,match='separate'): + runtime.boundaries(data,manifest,{'scientist':runtime.pin(new,'scientist'),'brain':runtime.pin(brain,'brain')}) + + +def test_real_owner_validator_with_independent_configuration(tmp_path): + import organism_board + source=Path(__file__).resolve().parents[1] + brain_source=Path(organism_board.theme().__file__).resolve().parents[1] + scientist=tmp_path/'scientist';brain=tmp_path/'brain' + for src,dst in [(source,scientist),(brain_source,brain)]: + command('git','clone','--quiet','--shared',str(src),str(dst)) + data=tmp_path/'config';data.mkdir() + (data/'instance.json').write_text(json.dumps({'schema_version':1,'name':'Mira','repo':'scientist', + 'pages_url':'https://example.invalid/mira/','projects':'projects.json','organs':{}})) + (data/'projects.json').write_text('{"schema_version":1,"resources":{},"projects":{}}') + repos=tmp_path/'repos.yaml';repos.write_text('repos:\n scientist:\n github: Research/Mira\n') + manifest=data/'runtime.json' + p=runtime.prepare('initialize',manifest,instance=data/'instance.json',repos=repos,scientist=scientist,brain=brain) + assert 'configuration valid' in p['validation'] + assert runtime.apply(p) + before=runtime.fingerprints([data,repos],[manifest]) + _,html=runtime.observe(runtime.load(manifest),manifest,p['data']['current'],render=True) + assert 'Mira Dashboard' in html + assert runtime.fingerprints([data,repos],[manifest])==before + # A second revision retains the real owner validator. Create it locally so + # this test also works on CI's shallow checkout without fetching history. + older=tmp_path/'older-scientist' + command('git','clone','--quiet','--shared',str(source),str(older)) + (older/'runtime-fixture-revision.txt').write_text('second local framework revision') + commit(older) + assert runtime.apply(runtime.prepare('upgrade',manifest,scientist=older,brain=brain)) + assert runtime.apply(runtime.prepare('rollback',manifest)) + assert runtime.fingerprints([data,repos],[manifest])==before + + +def test_changed_recovery_pair_blocks_upgrade_save(pilot): + manifest, _, _, _, brain, old, new, prepared=pilot + runtime.apply(prepared) + upgrade=runtime.prepare('upgrade',manifest,scientist=new,brain=brain) + (old/'new.txt').write_text('changed after preview') + before=manifest.read_bytes() + with pytest.raises(ValueError,match='dirty'): runtime.apply(upgrade) + assert manifest.read_bytes()==before + + +def test_manifest_mutation_during_check_is_detected(pilot): + manifest, _, _, _, brain, old, _, prepared=pilot + runtime.apply(prepared) + script=old/'scripts/organism_board.py' + script.write_text(f"from pathlib import Path\nPath({str(manifest)!r}).write_text('{{}}')\n") + commit(old) + data=runtime.load(manifest) + data['current']['scientist']=runtime.pin(old,'scientist') + manifest.write_text(json.dumps(data)) + with pytest.raises(ValueError,match='manifest changed'): + runtime.observe(data,manifest,data['current']) + + +def test_identical_selection_keeps_previous_even_if_it_is_no_longer_available(pilot): + manifest, _, _, _, brain, old, new, prepared=pilot + runtime.apply(prepared) + runtime.apply(runtime.prepare('upgrade',manifest,scientist=new,brain=brain)) + (old/'scripts/organism_board.py').unlink() + before=manifest.read_bytes() + assert not runtime.apply(runtime.prepare('upgrade',manifest,scientist=new,brain=brain)) + assert manifest.read_bytes()==before