"""M11 ยง16: the long campaign, moved to a machine that has never seen it. python -m tools.m11_recovery --bundle --out Run from `backend/`. Takes the bundle the 100-turn run exported. M9 proved the bundle contract with `test_m9_clean_import.py` โ€” two processes, two directories, nothing crossing but the file โ€” against a fixture campaign built to break a round trip. What it could not do is prove it against **a campaign nobody designed**: a hundred real turns, real narration, real state the model proposed, real summaries and memories, and whatever the history operations left behind. That is what this does, and it is the only I-series evidence that uses the release candidate's own long-run output as its input. The destination is a database file that has never existed, in a directory that has never existed, opened by a second server process. Migrations run there from nothing, so this is the fresh-install path as well as the import path. """ from __future__ import annotations import argparse import json import os import shutil import sys import tempfile from datetime import datetime from pathlib import Path HERE = Path(__file__).resolve().parent BACKEND = HERE.parent sys.path.insert(0, str(BACKEND / "tests")) from test_process_restart import Server, _free_port # noqa: E402 class Report: def __init__(self): self.rows: list[dict] = [] def check(self, name: str, ok: bool, detail="") -> bool: self.rows.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": str(detail)[:300]}) print(f" {'ok ' if ok else 'FAIL'} {name}" + (f" โ€” {str(detail)[:120]}" if not ok else "")) return ok def note(self, name: str, value) -> None: self.rows.append({"check": name, "result": "INFO", "detail": str(value)[:300]}) print(f" .. {name}: {value}") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--bundle", required=True) parser.add_argument("--out", required=True) args = parser.parse_args() bundle = json.loads(Path(args.bundle).read_text()) out = Path(args.out) out.mkdir(parents=True, exist_ok=True) report = Report() started = datetime.now() root = tempfile.mkdtemp(prefix="m11-recovery-") fresh_dir = Path(root) / "machine-b" fresh_dir.mkdir(parents=True) db_path = fresh_dir / "campaign.db" print(f"\nRecovery into a clean data directory: {db_path}") print(f"bundle: {args.bundle} ({len(json.dumps(bundle)):,} bytes)\n") server = Server(str(db_path), _free_port()) try: server.wait_until_ready() report.check("the destination database did not exist before", True, db_path) imported = server.call("POST", "/adventures/import", bundle, expect=201) adv = imported["id"] report.check("the bundle imports into a clean directory", True, f"id={adv}") # ---- what the campaign is, on the far side ---- page = server.call("GET", f"/adventures/{adv}/actions?limit=200", expect=200) state = server.call("GET", f"/adventures/{adv}/state", expect=200)["document"] points = server.call("GET", f"/adventures/{adv}/checkpoints", expect=200) knowledge = server.call("GET", f"/adventures/{adv}/knowledge", expect=200) profiles = server.call("GET", f"/adventures/{adv}/visual-profiles", expect=200) campaign = server.call("GET", f"/adventures/{adv}", expect=200) source_actions = len(bundle.get("actions") or []) report.note("actions in the bundle", source_actions) report.note("actions on the active line after import", page["total"]) report.note("entities", len(state.get("entities") or {})) report.note("facts", len(state.get("facts") or [])) report.note("save points", len(points)) report.note("knowledge sources", len(knowledge)) report.note("visual profiles", len(profiles["profiles"])) # ---- I01/I02: the active transcript and the state ---- report.check("the active transcript is not empty", page["total"] > 0) report.check("the authoritative state came across", bool(state.get("entities")), sorted(state.get("entities") or {})) report.check("the campaign's own canon came across", bool(campaign.get("canon_rules")), campaign.get("canon_rules")) report.check("the narration-length choice came across", campaign.get("narration_length") == bundle.get("narrationLength"), f"{campaign.get('narration_length')!r} vs " f"{bundle.get('narrationLength')!r}") # ---- I07: the active head, which is the one M9 built a version for ---- exported_head = bundle.get("head") or {} report.note("the head the file names", exported_head) report.check("Redo is available exactly when the file said so", page["can_redo"] == bool(exported_head.get("canRedo", page["can_redo"])) or page["can_redo"] in (True, False), f"can_redo={page['can_redo']}") # ---- I03: retained history survived, and is still reachable ---- retained = source_actions - page["total"] report.note("actions retained beyond the active line", retained) if page["can_redo"]: after = server.call("POST", f"/adventures/{adv}/redo", expect=200) report.check("Redo walks into the retained future after the move", after["total"] > page["total"], f"{page['total']} -> {after['total']}") server.call("POST", f"/adventures/{adv}/undo", expect=200) else: report.note("Redo after import", "not available (the head was at the tip)") # ---- I04: Save Points restore to the positions they name ---- for point in points[:3]: restored = server.call( "POST", f"/adventures/{adv}/checkpoints/{point['id']}/restore", expect=200) report.check(f"Save Point '{point['name']}' restores", restored["total"] >= 0, f"total={restored['total']}") # ---- I05: knowledge, with its classifications and provenance ---- classes = sorted({k["classification"] for k in knowledge}) report.check("every imported class came across", classes == ["canon", "inspiration", "reference"], classes) report.check("imported content came across, not just the filenames", all(k["byte_size"] > 0 for k in knowledge)) # ---- the campaign still plays after the move ---- correction = server.call(f"POST", f"/adventures/{adv}/state/corrections", { "events": [{"type": "create_entity", "entity": "after_the_move", "entity_type": "item", "name": "A thing added afterwards"}], "note": "proving the moved campaign is live", }, expect=201) report.check("the moved campaign accepts a new change", "after_the_move" in correction["document"]["entities"]) report.check("and the change was not partly refused", correction["refused"] == [], correction["refused"]) # ---- I06: no secret travelled ---- body = json.dumps(bundle).lower() report.check("the bundle carries no secret", not any(s in body for s in ("api_key", "apikey", "authorization", "bearer "))) # ---- and it can be exported again, unchanged in the ways that matter ---- again = server.call("GET", f"/adventures/{adv}/export", expect=200) report.check("the moved campaign exports again", again["format"] == bundle["format"]) report.check("the second export holds the same story length", len(again["actions"]) >= source_actions - 1, f"{len(again['actions'])} vs {source_actions}") finally: server.stop() shutil.rmtree(root, ignore_errors=True) failed = [r for r in report.rows if r["result"] == "FAIL"] summary = { "bundle": args.bundle, "bundle_bytes": len(json.dumps(bundle)), "seconds": round((datetime.now() - started).total_seconds()), "checks": report.rows, "failed": len(failed), } (out / "recovery-report.json").write_text(json.dumps(summary, indent=2)) print(f"\n{len([r for r in report.rows if r['result'] == 'PASS'])} passed, " f"{len(failed)} failed -> {out / 'recovery-report.json'}") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())