A campaign could already be exported and imported. What could not survive the trip was everything that explains it: the state events behind the authoritative document, the prompt each turn was actually given, the passages it was shown, the summaries that carry long-story continuity, and which take belonged to which turn. An imported campaign could be read and could no longer say why it was what it was — and a manual correction, the one state change no narration explains, was indistinguishable from something the story had established. The bundle is now `ai-dnd-adventure-v3`, and the version is the design rather than a side effect. Everything added here could have been another optional key, the way persona, Save Points, narrative state and imported knowledge each were. That mechanism stops working at exactly this addition: a v2 file with no prompt provenance is ambiguous between "written before M9" and "written by M9 from a campaign that has none", and those are different facts about a campaign. A version number is how a recovery file states what it was capable of recording. v1 and v2 still import, and every seam from pre-active-head onward is tested for the rule that an older file is never reinterpreted under a newer assumption. Two categories became three. "Chosen travels, derived is recomputed" was enough until stored prompts had to be decided: they are derived, and they must travel anyway. The test that separates evidence from cache is not "could this be recomputed" but "would a recomputation answer the same question" — a rebuilt search index answers the same question, a rebuilt prompt says what the turn would be told *now*, which is the opposite of what the inspector is for. Also here: a real SQLite backup, through the online backup API rather than a file copy, taken while the application is running and verified before it is kept; story cards settled as compatibility-only legacy data and taken out of the narrator's prompt, because they were the untracked path around knowledge authority that IMPORTED-KNOWLEDGE-DESIGN §73 already forbade; and no schema change at all, proved against a database M8's own code wrote. Three defects, found by running the milestone's own tests rather than by reading them. Deleting a campaign leaked its FTS index rows, and SQLite then handed the freed ids to the next source imported into any campaign, which failed with an integrity error that Reindex could not repair — both ends are closed, and a database already carrying the damage now repairs itself. An imported node with no state snapshot was being stamped with the campaign's head state, so an Undo to turn 2 showed what the story knew at turn 20. And the snapshot relink did not persist at all, because it mutated a dict in place on a column SQLAlchemy tracks by assignment: it looked correct in memory and wrote the wrong ids to disk. Carrying per-turn prompts looked like it would halve the length of campaign that can be restored. Measured — and after compressing them inside the file — everything M9 added costs 12% of it: the import ceiling moves from about 318 turns to about 279, against a 100-turn certification target. The dominant cost is not M9's at all. The per-position narrative state document is 74% of a bundle, and v2 already carried it. Backend 1,102 passed / 14 skipped / 0 failed. Frontend 145 passed. Lint, production build and Docker build clean. Verified across two server processes with two data directories, and in a real browser against a real narrator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
461 lines
19 KiB
Python
461 lines
19 KiB
Python
"""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()
|