"""M9: the campaign moves to a machine that has never seen it. This is the milestone's Definition of Done, and it is the one claim the rest of the M9 suite cannot make. `test_m9_portability.py` imports beside the original, in one process, against one database — which is the right place to check the *contract* and the wrong place to check *portability*. A shared id space, a warm cache, a row the exporter forgot to scope, a session still holding the original: every one of those would pass there and fail here. So each test below: 1. starts a real server process against database A, and plays a campaign; 2. exports it over HTTP and stops that process; 3. starts a **second** server process against database B, **a file that has never existed before**, in a different directory; 4. imports the file over HTTP, and asks the second process what it has. Nothing crosses between them but the bundle. Migrations run on B from nothing, because it is a new file — so this is also the fresh-install path, and the "clean data directory" in the Definition of Done is a directory, not a metaphor. The final test restarts the *importing* server, which is L03 after a move: a Save Point restored in the third process must reach the same position and the same state as it did in the second. python -m pytest tests/test_m9_clean_import.py -v """ import json import os import shutil import sqlite3 import subprocess import sys import tempfile import urllib.error import urllib.request from pathlib import Path import pytest from fakes import TALLY_PER_TURN, tally_of from test_process_restart import Server, _free_port HERE = Path(__file__).resolve().parent @pytest.fixture() def machines(): """Two directories, each with its own database, and the servers on them. Two directories rather than two filenames, because the backup directory and anything else the application derives from the database's location must land in the importing machine's own space rather than beside the exporter's. """ root = tempfile.mkdtemp(prefix="m9-clean-") started: list[Server] = [] def start(name: str) -> Server: directory = os.path.join(root, name) os.makedirs(directory, exist_ok=True) server = Server(os.path.join(directory, "campaign.db"), _free_port()) started.append(server) server.wait_until_ready() return server def path_of(name: str) -> str: return os.path.join(root, name, "campaign.db") try: yield start, path_of finally: for server in started: server.stop() shutil.rmtree(root, ignore_errors=True) # ------------------------------------------------------------------ building def _campaign(server: Server) -> int: """A campaign with everything a move has to carry, played over HTTP. Deliberately not `m9_fixture`: that builds through a `TestClient` and this file exists to avoid one. What it reproduces is the same shape — a retry, a Save Point, an imported source that a turn actually used, an undone head and a retained future. """ adventure = server.call("POST", "/adventures", { "title": "Moved between machines", "canon_rules": ["The dead do not return."], "opening": "Aldric sits in the Crooked Lantern with Mara.", }, expect=201) adv_id = adventure["id"] _upload(server, adv_id, "canon.md", "canon", ( "# Westhaven\n\n## The Old Abbey\n\nThe abbey above Westhaven has stood " "since the founding. Its crypt is sealed, its door is oak, and the seal " "on it has never been broken.\n" )) _upload(server, adv_id, "secret.md", "canon", ( "# The seal\n\nIt was broken once, sixty years ago.\n" ), visibility="hidden") disabled = _upload(server, adv_id, "draft.md", "reference", ( "# Discarded draft\n\nAn earlier version, switched off.\n" )) server.call("PATCH", f"/adventures/{adv_id}/knowledge/{disabled}", {"enabled": False}, expect=200) # The spawned narrator writes "Beat N." and nothing else, so every term the # retrieval has to work with comes from the player's own words. They are # written to name things the Canon file names. server.play(adv_id, "ask Mara about the abbey crypt in Westhaven") server.play(adv_id, "walk up the hill to the abbey") server.play(adv_id, "try the sealed crypt door of the abbey") _retry(server, adv_id) server.call("POST", f"/adventures/{adv_id}/checkpoints", {"name": "At the door", "note": "Before deciding."}, expect=201) server.play(adv_id, "force the door") server.play(adv_id, "go down the stair") server.call("POST", f"/adventures/{adv_id}/state/corrections", { "events": [{"type": "add_fact", "predicate": "keeper", "value": "Mara", "fact_id": "keeper"}], "note": "Established in play before the state system saw it.", }, expect=201) # Two Undos, so the export is taken behind the retained tip. server.call("POST", f"/adventures/{adv_id}/undo", expect=200) server.call("POST", f"/adventures/{adv_id}/undo", expect=200) return adv_id def _retry(server: Server, adv_id: int) -> None: """Retries the newest turn, over the streaming endpoint it actually uses. `Server.call` parses JSON, and `/retry` answers with an SSE stream as `/actions` does — so calling it as JSON reads `data: {...}` as a document and fails on the first character. Draining the stream is what the browser does. """ request = urllib.request.Request( f"http://127.0.0.1:{server.port}/api/adventures/{adv_id}/retry", data=b"{}", method="POST", headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(request, timeout=120) as response: body = response.read() assert b'"type": "error"' not in body, body[:300] def _upload(server: Server, adv_id: int, name: str, classification: str, body: str, **fields) -> int: """A multipart knowledge upload over real HTTP, without a client library.""" boundary = "----m9cleanimport" parts = [] for key, value in {"classification": classification, **fields}.items(): parts.append( f"--{boundary}\r\nContent-Disposition: form-data; name=\"{key}\"\r\n" f"\r\n{value}\r\n" ) parts.append( f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; " f"filename=\"{name}\"\r\nContent-Type: text/markdown\r\n\r\n{body}\r\n" ) payload = ("".join(parts) + f"--{boundary}--\r\n").encode() request = urllib.request.Request( f"http://127.0.0.1:{server.port}/api/adventures/{adv_id}/knowledge", data=payload, method="POST", headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, ) with urllib.request.urlopen(request, timeout=60) as response: return json.loads(response.read())["id"] def _snapshot(server: Server, adv_id: int) -> dict: """What a reader can see, read over HTTP through the API they read.""" page = server.call("GET", f"/adventures/{adv_id}", expect=200) return { "title": page["title"], "canon_rules": page["canon_rules"], "transcript": [(a["type"], a["text"]) for a in page["actions"]], "can_undo": page["can_undo"], "can_redo": page["can_redo"], "state": server.call("GET", f"/adventures/{adv_id}/state", expect=200)["document"], "checkpoints": sorted( (c["name"], c["note"], c["depth"]) for c in server.call("GET", f"/adventures/{adv_id}/checkpoints", expect=200) ), "knowledge": sorted( (k["title"], k["classification"], k["enabled"], k["visibility"], k["content_hash"], k["index_state"], k["chunk_count"] > 0) for k in server.call("GET", f"/adventures/{adv_id}/knowledge", expect=200) ), "events": sorted( (e["event_type"], e["source"], json.dumps(e["payload"], sort_keys=True)) for e in server.call("GET", f"/adventures/{adv_id}/state/events?limit=500", expect=200) ), "rows": server.total_rows(adv_id), } # ------------------------------------------------------------------- the move @pytest.fixture() def moved(machines): """The campaign, exported from machine A and imported into a clean B.""" start, path_of = machines source = start("a") adv_id = _campaign(source) before = _snapshot(source, adv_id) # What the source machine retrieves at this position, recorded while it is # still running. It is the only thing the copy can honestly be compared to. retrieved = { record["filename"] for record in source.call("GET", f"/adventures/{adv_id}/context", expect=200) ["knowledge"]["used"] } bundle = source.call("GET", f"/adventures/{adv_id}/export", expect=200) source.stop() assert not os.path.exists(path_of("b")), "machine B must not exist yet" target = start("b") assert target.call("GET", "/adventures", expect=200) == [], \ "machine B is not empty" imported = target.call("POST", "/adventures/import", bundle, expect=201) return { "bundle": bundle, "before": before, "target": target, "retrieved": retrieved, "copy_id": imported["id"], "imported": imported, "path": path_of, "start": start, } def test_the_campaign_arrives_whole_on_a_machine_that_never_had_it(moved): """The Definition of Done, in one assertion per family.""" after = _snapshot(moved["target"], moved["copy_id"]) before = moved["before"] assert after["transcript"] == before["transcript"] assert after["state"] == before["state"] assert after["canon_rules"] == before["canon_rules"] assert after["checkpoints"] == before["checkpoints"] assert after["knowledge"] == before["knowledge"] assert after["events"] == before["events"] assert after["rows"] == before["rows"], "the retained tree is a different size" def test_it_opens_at_the_exact_head_it_was_exported_at(moved): """I07, across the boundary the acceptance test names. The export was taken two Undos behind the tip, so a machine that opened the campaign at its newest retained turn would show a story two turns longer than the one that was saved. """ after = _snapshot(moved["target"], moved["copy_id"]) assert after["transcript"] == moved["before"]["transcript"] assert after["can_redo"] is True, "the retained future is not reachable" assert moved["imported"]["can_redo"] is True, ( "the response that opens the campaign says Redo is unavailable" ) assert after["rows"] > len(after["transcript"]), ( "the retained future is not in the database" ) def test_the_state_audit_arrives_and_still_names_its_author(moved): """The manual correction is still a manual correction on the new machine.""" events = moved["target"].call( f"GET", f"/adventures/{moved['copy_id']}/state/events?limit=500", expect=200 ) manual = [e for e in events if e["source"] == "manual_correction"] assert len(manual) == 1 assert manual[0]["payload"]["predicate"] == "keeper" assert any(e["source"] == "accepted_story" for e in events), ( "and the story's own events are there beside it" ) def test_the_knowledge_works_with_no_access_to_the_original_machine(moved): """§11. The exporting machine is stopped; nothing may reach back to it. Its process is dead and its directory holds a database this server has never opened. If retrieval works here, it works from the content the file carried. The comparison is against what the *source* retrieved, recorded before that process was killed, and the source's own result is asserted first. A test that only checked the copy retrieved something would pass by accident on a day the fixture happened to match, and — worse — would report a portability failure when what had actually happened is that neither side retrieved anything. That is M8's finding 10: assert your own precondition. """ assert moved["retrieved"], ( "the source campaign retrieved nothing, so this proves nothing about " "the copy" ) report = moved["target"].call( "GET", f"/adventures/{moved['copy_id']}/context", expect=200 ) used = {record["filename"] for record in report["knowledge"]["used"]} assert used == moved["retrieved"], ( f"the copy retrieved {used} where the source retrieved {moved['retrieved']}" ) assert "draft.md" not in used, "the disabled source was re-enabled by the move" assert "canon.md" in used def test_a_historical_turn_still_shows_what_it_was_given(moved): """The M8 handoff, across the boundary that made it a handoff. Inspect Context on an old narrator turn works on a machine that never assembled that prompt and could not reassemble it — the sources are here but the state, the head and the canon have all moved on since. """ target, copy_id = moved["target"], moved["copy_id"] page = target.call("GET", f"/adventures/{copy_id}/actions?limit=200", expect=200) narrator = [a for a in page["actions"] if a["type"] == "ai"] assert narrator, "the imported campaign has no narrator turn" inspected = 0 for action in narrator: response = target.call( "GET", f"/adventures/{copy_id}/actions/{action['id']}/context" ) if response is None: continue assert response["prompt"]["system"], "a restored prompt is empty" assert response["sections"], "a restored prompt has no sections" inspected += 1 assert inspected, "no turn on the new machine can say what it was told" def test_no_secret_and_no_path_from_the_old_machine_travelled(moved): """I06, and the private-detail half of it. The bundle is checked as text, because that is what actually left the machine — a field added to a model the exporter walks would reach the file without any test of a column noticing. """ text = json.dumps(moved["bundle"]) assert "api_key" not in text assert "11434" not in text, "an inference endpoint travelled with the campaign" assert "/tmp/" not in text and "campaign.db" not in text, ( "a filesystem path from the exporting machine travelled" ) def test_the_importing_machine_keeps_its_own_settings(moved): """§15. A campaign is not a way to reconfigure the destination. The bundle carries per-turn model provenance, which is a record of what happened. It does not carry the endpoint, the model or the context budget, because those describe the machine rather than the campaign — and importing a campaign must not silently repoint the destination's inference at the source's. """ settings = moved["target"].call("GET", "/settings", expect=200) assert settings["endpoint_url"] == "http://localhost:11434/v1", ( "the import changed the destination's inference endpoint" ) assert settings["context_token_budget"] == 16384 def test_a_missing_model_does_not_stop_the_campaign_arriving(moved): """§15. The campaign and its data are portable independently of a model. The importing server has no model configured at all — nothing has ever written a `model` into its settings — and the import still succeeds, opens, and shows its state. Play would fail; recovery does not. """ settings = moved["target"].call("GET", "/settings", expect=200) assert settings["model"] == "", "this test needs an unconfigured destination" after = _snapshot(moved["target"], moved["copy_id"]) assert after["transcript"] == moved["before"]["transcript"] # ------------------------------------------------- L03, after the campaign moved def test_l03_a_save_point_restored_on_the_new_machine_survives_its_restart(moved): """L03, with the move in front of it. Restore a Save Point in the second process, record the position and the state, kill the process, start a **third** against the same file, and ask again. What crosses is bytes on disk. """ target, copy_id = moved["target"], moved["copy_id"] points = target.call("GET", f"/adventures/{copy_id}/checkpoints", expect=200) assert points, "the Save Point did not survive the move" point = points[0] assert point["resolved"] is True target.call("POST", f"/adventures/{copy_id}/checkpoints/{point['id']}/restore", expect=200) restored = _snapshot(target, copy_id) rows_before = restored["rows"] target.stop() assert not target.is_listening() third = moved["start"]("b") again = _snapshot(third, copy_id) assert again["transcript"] == restored["transcript"] assert again["state"] == restored["state"] assert again["rows"] == rows_before, "restoring deleted later history" # ------------------------------------------------------ the database it wrote def test_the_importing_machines_database_passes_its_own_integrity_check(moved): """A campaign written by an import is a database SQLite is happy with.""" moved["target"].stop() connection = sqlite3.connect(moved["path"]("b")) try: assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok" assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] finally: connection.close() def test_the_import_left_no_orphan_behind(moved): """§17's list, checked against the database rather than against the API. Every one of these would be invisible from the outside until the moment it mattered: a Save Point pointing at a turn that is not there, knowledge owned by a campaign that does not exist, an action on a branch belonging to something else. """ moved["target"].stop() connection = sqlite3.connect(moved["path"]("b")) try: def one(sql): return connection.execute(sql).fetchone()[0] assert one(""" SELECT COUNT(*) FROM checkpoints c LEFT JOIN actions a ON a.branch_id = c.branch_id AND a.depth = c.depth AND a.adventure_id = c.adventure_id WHERE a.id IS NULL """) == 0, "a Save Point names a position with no turn at it" assert one(""" SELECT COUNT(*) FROM actions a LEFT JOIN branches b ON b.id = a.branch_id WHERE a.branch_id IS NOT NULL AND (b.id IS NULL OR b.adventure_id <> a.adventure_id) """) == 0, "an action sits on another campaign's branch" assert one(""" SELECT COUNT(*) FROM knowledge_sources k LEFT JOIN adventures adv ON adv.id = k.adventure_id WHERE adv.id IS NULL """) == 0, "knowledge owned by no campaign" assert one(""" SELECT COUNT(*) FROM state_events e LEFT JOIN actions a ON a.id = e.action_id WHERE e.action_id IS NOT NULL AND (a.id IS NULL OR a.adventure_id <> e.adventure_id) """) == 0, "a state event names a turn in another campaign" assert one(""" SELECT COUNT(*) FROM adventures adv LEFT JOIN actions a ON a.branch_id = adv.head_branch_id AND a.depth = adv.head_depth AND a.adventure_id = adv.id WHERE adv.head_depth >= 0 AND a.id IS NULL """) == 0, "the head points outside the retained story" finally: connection.close()