| 1 | #!/usr/bin/env python3 |
| 2 | """Run each release's real Go writers in an isolated checkout (no user data). |
| 3 | |
| 4 | The generated histories, event sidecars and content objects are synthetic. |
| 5 | Unlike schema-shaped JSON fixtures, these exercise the actual tagged encoder, |
| 6 | checksums, framing and externalization. Requires the historical Go dependencies. |
| 7 | """ |
| 8 | import argparse |
| 9 | import hashlib |
| 10 | import json |
| 11 | import os |
| 12 | import pathlib |
| 13 | import re |
| 14 | import shutil |
| 15 | import subprocess |
| 16 | import tempfile |
| 17 | |
| 18 | ROOT = pathlib.Path(__file__).resolve().parents[1] |
| 19 | OUTPUT = ROOT / "desktop/testdata/session-history-upgrade" |
| 20 | TEMPLATE = ROOT / "scripts/fixtures/session-history.go.txt" |
| 21 | |
| 22 | |
| 23 | def redact_writer_hosts(output): |
| 24 | # Writer announcements are environment metadata, not conversation content. |
| 25 | # Preserve byte lengths so tagged event-index offsets stay valid. |
| 26 | for path in output.rglob("*.events.jsonl"): |
| 27 | lines = [] |
| 28 | for line in path.read_bytes().splitlines(keepends=True): |
| 29 | record = json.loads(line) |
| 30 | if record.get("type") == "writer": |
| 31 | line = re.sub(rb'("hostname":")([^"\\]*)(")', |
| 32 | lambda match: match[1] + b"fixture-host".ljust(len(match[2]), b"-")[:len(match[2])] + match[3], line) |
| 33 | lines.append(line) |
| 34 | path.write_bytes(b"".join(lines)) |
| 35 | |
| 36 | |
| 37 | def generate(version, writers_dir, build_only=False): |
| 38 | tag = "desktop-v" + version |
| 39 | commit = subprocess.check_output(["git", "rev-parse", tag + "^{commit}"], cwd=ROOT, text=True).strip() |
| 40 | with tempfile.TemporaryDirectory(prefix="reasonix-tagged-history-") as temporary: |
| 41 | checkout = pathlib.Path(temporary) / "source" |
| 42 | checkout.mkdir() |
| 43 | archive = pathlib.Path(temporary) / "source.tar" |
| 44 | with archive.open("wb") as destination: |
| 45 | subprocess.run(["git", "archive", tag, "go.mod", "go.sum", "internal", "sdk"], cwd=ROOT, stdout=destination, check=True) |
| 46 | subprocess.run(["tar", "-xf", str(archive), "-C", str(checkout)], check=True) |
| 47 | # The encoder remains the tagged implementation. Supply a synthetic |
| 48 | # process identity before its first write so fixtures contain no host name. |
| 49 | (checkout / "internal/agent/rollback_fixture_identity.go").write_text( |
| 50 | 'package agent\nfunc init() { sessionWriterID = "historical-fixture-writer" }\n') |
| 51 | program = checkout / "cmd/rollback-history-fixture" |
| 52 | program.mkdir(parents=True) |
| 53 | source = TEMPLATE.read_text() |
| 54 | canonical = int(version.rsplit(".", 1)[1]) >= 8 |
| 55 | source = source.replace("// CANONICAL_IMPORT", '"context"; "errors"; "reasonix/internal/session"' if canonical else "") |
| 56 | start, end = source.index("// CANONICAL_START"), source.index("// CANONICAL_END") |
| 57 | if not canonical: |
| 58 | source = source[:start] + source[end:] |
| 59 | source = source.replace("// CANONICAL_CALL", "writeCanonical(output, messages)" if canonical else "") |
| 60 | source = source.replace("// CONTINUE_CALL", 'if len(os.Args)>2 { continueCanonical(os.Args[1],os.Args[2]); return }' if canonical else "") |
| 61 | source = source.replace("// FORMAL_CALL", ''' |
| 62 | service,err:=session.NewService("desktop",session.NewFilesystemPersistence(filepath.Join(output,"formal")));must(err) |
| 63 | runtime,err:=service.Create(ctx,session.CreateOptions{SessionID:"formal-session",CWD:"/synthetic/workspace",Origin:session.SessionOriginNew});must(err) |
| 64 | var events []session.Event |
| 65 | for _,message:=range messages {events=append(events,session.Event{Kind:"message/complete",Payload:body(map[string]any{"message":message})})} |
| 66 | _,err=runtime.Session().Append(ctx,session.Batch{OperationID:"formal-history",Events:events});must(err) |
| 67 | must(service.Close(ctx,runtime.Ref())) |
| 68 | ''' if int(version.rsplit(".", 1)[1]) >= 9 else "") |
| 69 | (program / "main.go").write_text(source) |
| 70 | output = OUTPUT / version |
| 71 | # Old writers can consult config paths. Keep every such path disposable. |
| 72 | isolated_home = pathlib.Path(temporary) / "home" |
| 73 | env = dict(os.environ, REASONIX_HOME=str(isolated_home), REASONIX_STATE_HOME=str(isolated_home), REASONIX_CACHE_HOME=str(isolated_home / "cache")) |
| 74 | executable = pathlib.Path(writers_dir).resolve() / version if writers_dir else pathlib.Path(temporary) / "writer" |
| 75 | executable.parent.mkdir(parents=True, exist_ok=True) |
| 76 | subprocess.run(["go", "build", "-o", str(executable), "./cmd/rollback-history-fixture"], cwd=checkout, env=env, check=True) |
| 77 | if build_only: |
| 78 | print(f"PASS {tag}: built historical reader/writer {executable}", flush=True) |
| 79 | return |
| 80 | if output.exists(): |
| 81 | shutil.rmtree(output) |
| 82 | output.mkdir(parents=True) |
| 83 | subprocess.run([str(executable), str(output)], cwd=checkout, env=env, check=True) |
| 84 | redact_writer_hosts(output) |
| 85 | # Locks/caches are rebuildable, not part of the durable fixture. |
| 86 | for path in list(output.rglob("*")): |
| 87 | if path.is_file() and (path.name.endswith((".lock", ".lease")) or ".query-cache" in path.parts): |
| 88 | path.unlink() |
| 89 | files = {str(path.relative_to(output)): hashlib.sha256(path.read_bytes()).hexdigest() |
| 90 | for path in sorted(output.rglob("*")) if path.is_file()} |
| 91 | (output / "SOURCE.json").write_text(json.dumps({"tag": tag, "commit": commit, |
| 92 | "normalizations": ["synthetic writer identity", "length-preserving writer hostname"], "files": files}, indent=2) + "\n") |
| 93 | print(f"PASS {tag} ({commit[:12]}): historical writer, {len(files)} durable files", flush=True) |
| 94 | |
| 95 | |
| 96 | if __name__ == "__main__": |
| 97 | parser = argparse.ArgumentParser(description=__doc__) |
| 98 | parser.add_argument("--writers-dir", help="retain isolated writer executables for previous-reader roundtrip tests") |
| 99 | parser.add_argument("--build-only", action="store_true", help="build old readers without changing existing fixture files") |
| 100 | parser.add_argument("versions", nargs="*", default=[f"1.38.{n}" for n in range(3, 12)]) |
| 101 | args = parser.parse_args() |
| 102 | if args.build_only and not args.writers_dir: |
| 103 | parser.error("--build-only requires --writers-dir") |
| 104 | for version in args.versions: |
| 105 | generate(version, args.writers_dir, args.build_only) |
| 106 |