Full port of the Store (17 tables, FTS5, per-profile slices, sessions, turn state, bots, settings, MCP registry), DebugLog, SETTINGS_CATALOG and boot_settings, ShellSession (as ShellContext), Conversation persistence, Repl/Boot, and the PlexerController family with FlowValidator/PlexerFlow. Verified by replaying 643 operations through tai.py and the C++ code (results, tables, FTS, schema, files identical) and by restarting the REPL under per-pane shells and checking the vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
543 lines
26 KiB
Python
543 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Vault parity: replay one scenario through tai.py and through the C++ Store
|
|
(build/foundation_probe) against two throwaway TAI_HOMEs, then diff the
|
|
operation results, every table, the FTS index, the schema and every file under
|
|
profiles/. Timestamps and random record ids are masked; everything else must
|
|
be identical.
|
|
|
|
Usage: python3 tests/foundation_parity.py [--probe PATH] [--keep]
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import foundation_cases as cases # noqa: E402
|
|
import foundation_fixtures as fixtures # noqa: E402
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.dirname(os.path.dirname(HERE))
|
|
SESSION = "pane-parity-ctx"
|
|
BIG = ("Zażółć gęślą jaźń 😀 line %d fox cats\n" * 300) % tuple(range(300))
|
|
BIG = "".join("Zażółć gęślą jaźń 😀 line %d fox cats\n" % n for n in range(300))
|
|
|
|
SCENARIO = [
|
|
["add_record", ["note", "Alpha plan", "The quick brown fox jumps over the lazy dogs. cats and mice", ["Animals", "fox"]]], # 0
|
|
["add_record", ["note", "Second note", "foxes and cats share tags with animals", ["animal"]]], # 1
|
|
["add_record", ["file", "/tmp/x.py", "print('héllo wörld 😀')", ["python"]]], # 2
|
|
["add_record", ["output", "Big output", BIG, []]], # 3
|
|
["add_chunks", ["record", "$R3", ["chunk a fox", "chunk b"]]], # 4
|
|
["get_record", ["$R0"]], # 5
|
|
["read_record", ["$R3", 4, 25]], # 6
|
|
["search_records", ["fox"]], # 7
|
|
["search_records", ["fox animals"]], # 8
|
|
["search_records", ["", "note"]], # 9
|
|
["search_records", ["zzzz"]], # 10
|
|
["search_records", ["", None, ["animals"]]], # 11
|
|
["tag_counts", []], # 12
|
|
["add_edge", ["$R0", "$R1", "Relates To!"]], # 13
|
|
["add_edge", ["$R0", "mem:deadbeefdeadbeef"]], # 14
|
|
["edges_for", ["$R0"]], # 15
|
|
["traverse", ["$R0"]], # 16
|
|
["save_secret", ["api_key", "sekrit-value-123", {"k": 1, "z": "ü"}, ["svc"]]], # 17
|
|
["secret_meta", ["api_key"]], # 18
|
|
["list_secret_infos", []], # 19
|
|
["load_secret", ["api_key"]], # 20
|
|
["redact", ["my token sekrit-value-123 here"]], # 21
|
|
["add_edge", ["$R0", "secret:api_key", "uses"]], # 22
|
|
["add_schedule", ["daily", "do the thing", "default", 3600, "2026-01-01T00:00:00+00:00", 60]], # 23
|
|
["list_schedules", []], # 24
|
|
["add_edge", ["$R0", "sched:1"]], # 25
|
|
["traverse", ["$R0", 3]], # 26
|
|
["audit_event", ["main", "write", "/tmp/x.py", "msg about fox", "old text", "new text", ["Edit", "py"]]], # 27
|
|
["audit_history", ["/tmp/x.py"]], # 28
|
|
["audit_get", ["$R27"]], # 29
|
|
["audit_search", ["fox"]], # 30
|
|
["upsert_file_record", ["/tmp/y.md", "# Title\nfox content"]], # 31
|
|
["file_record_id", ["/tmp/y.md"]], # 32
|
|
["upsert_file_record", ["/tmp/y.md", "# Title 2\nupdated fox", None]], # 33
|
|
["delete_file_record", ["/tmp/y.md"]], # 34
|
|
["log_event", ["default", "user", "say", "hello fox world", ["a"]]], # 35
|
|
["log_event", ["default", "assistant", "say", "bye"]], # 36
|
|
["search_events", ["default", "fox"]], # 37
|
|
["events_like", ["default", "bye"]], # 38
|
|
["history_add", ["default", "main", "first input"]], # 39
|
|
["history_add", ["default", "main", "first input"]], # 40
|
|
["history_add", ["default", "main", " padded ünï "]], # 41
|
|
["history_load", ["default"]], # 42
|
|
["fts_search", ["fox"]], # 43
|
|
["fts_search", ["fox", ["record"]]], # 44
|
|
["save_system", ["default", "You are a test."]], # 45
|
|
["load_system", ["default"]], # 46
|
|
["save_session", ["default", [{"role": "system", "content": "S"}, {"role": "user", "content": "hi ü 😀"},
|
|
{"role": "assistant", "content": "yo", "tool_calls": [{"id": "1", "function": {"name": "shell", "arguments": "{}"}}]},
|
|
{"role": "tool", "content": "x", "tool_call_id": "1"}], "main", SESSION]], # 47
|
|
["load_session", ["default", "main", SESSION]], # 48
|
|
["load_session", ["default", "main", "other-ctx"]], # 49
|
|
["save_bots", ["default", {"helper": {"nicknames": ["h"]}, "zed": {}}]], # 50
|
|
["list_bots", ["default"]], # 51
|
|
["resolve_bot", ["default", "H"]], # 52
|
|
["resolve_bot", ["default", "main"]], # 53
|
|
["resolve_bot", ["default", "nope"]], # 54
|
|
["save_bot_system", ["default", "helper", "I am helper"]], # 55
|
|
["load_bot_system", ["default", "helper"]], # 56
|
|
["save_session", ["default", [{"role": "user", "content": "to helper"}], "helper", SESSION]], # 57
|
|
["load_session", ["default", "helper", SESSION]], # 58
|
|
["save_turn_state", ["default", "main", {"open": True, "goal": "ship it", "steps": 3, "tools": 2, "updated": "2026-01-01T00:00:00+00:00"}, SESSION]], # 59
|
|
["load_turn_state", ["default", "main", SESSION]], # 60
|
|
["get_or_create_settings", []], # 61
|
|
["upsert_settings", [{"model": "foo", "voice": "v", "retry_seconds": 2.5, "zen_enabled": "yes", "theme": "nord", "openrouter_key": "abc", "gemini_plan": True}]], # 62
|
|
["upsert_settings", [{"bogus": 1}]], # 63
|
|
["upsert_settings", [{"theme": "neon"}]], # 64
|
|
["upsert_settings", [{"model": None}]], # 65
|
|
["upsert_settings", [{"retry_seconds": -1}]], # 66
|
|
["upsert_settings", [{"zen_enabled": "maybe"}]], # 67
|
|
["upsert_settings", [{"muse_bin": None, "opencode_models": "a,b"}], {"scope": "work"}], # 68
|
|
["get_all_settings", [], {"scope": "work"}], # 69
|
|
["get_or_create_settings", [["model", "voice"]], {"scope": "bad scope!"}], # 70
|
|
["mcp_save_server", [{"name": "srv", "transport": "stdio", "command": "x", "args": ["a", "b"], "env": {"K": "v"}, "headers": {"Authorization": "Bearer t"}, "allow": ["t1"], "source": "test"}]], # 71
|
|
["mcp_save_server", [{"name": "bad name!", "transport": "stdio"}]], # 72
|
|
["mcp_get_server", ["srv"]], # 73
|
|
["mcp_store_tools", ["srv", [{"name": "t1", "description": "d1", "inputSchema": {"type": "object", "b": 1, "a": 2}}, {"name": "t2"}, {"nope": 1}]]], # 74
|
|
["mcp_cached_tools", ["srv"]], # 75
|
|
["mcp_tool_pins", ["srv"]], # 76
|
|
["mcp_set_enabled", ["srv", "default", True]], # 77
|
|
["mcp_enabled_servers", ["default"]], # 78
|
|
["mcp_list_servers", []], # 79
|
|
["save_dir_defaults", ["/tmp/dd", "prov", "mod", "bot1"]], # 80
|
|
["load_dir_defaults", ["/tmp/dd"]], # 81
|
|
["save_dir_defaults", ["/tmp/dd", None, None, None, ["model"]]], # 82
|
|
["load_dir_defaults", ["/tmp/dd"]], # 83
|
|
["spill_or_truncate", ["output", "spilled", BIG]], # 84
|
|
["spill_or_truncate", ["output", "small", "short text"]], # 85
|
|
["known_tags", []], # 86
|
|
["canonical_tags", [["Animals", "new tag", "cats"]]], # 87
|
|
["tagged_items", [["record", "fox"]]], # 88
|
|
["item_tags", ["$R0"]], # 89
|
|
["delete_record", ["$R1"]], # 90
|
|
["delete_secret", ["api_key"]], # 91
|
|
["remove_schedule", [1]], # 92
|
|
["search_records", ["fox"]], # 93
|
|
["add_record", ["note", "Work note", "work profile fox", ["w"], "work"]], # 94
|
|
["search_records", ["fox", None, [], 8, "work"]], # 95
|
|
["mcp_remove_server", ["srv"]], # 96
|
|
["list_profiles", []], # 97
|
|
["get_chunks_by_ids", [[1, 2, 999]]], # 98
|
|
]
|
|
|
|
UNORDERED = {"tagged_items"}
|
|
TS = re.compile(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?\+00:00")
|
|
MEM = re.compile(r"mem:[0-9a-f]{16}")
|
|
EPHEMERAL = re.compile(r"(\d{8}-\d{6}-e\d+)-[0-9a-f]{4}")
|
|
|
|
|
|
PURE_OPS = {"prompt", "capability_block", "orientation_block", "settings_catalog", "json_dumps", "json_roundtrip", "float_repr", "normalize_tag", "normalize_tags",
|
|
"singular_noun", "tag_variants", "parse_node_id", "fts_match_variants", "fts_rank", "profile_re", "bot_re", "secret_re", "mcp_server_re",
|
|
"normalize_setting_value", "parse_setting_env", "parse_setting_stored", "parse_backends", "split_models", "chunk_lines", "truncate", "sanitize_sid",
|
|
"shell_current", "validate_flow_spec", "run_flow", "sleep", "native_ctl", "tmux_ctl",
|
|
"statusbar", "strip_ansi", "truncate_ansi", "glow_prompt", "glow_title"}
|
|
|
|
|
|
def special(tai, op, pos):
|
|
"""Module-level functions of tai.py that have no Store: the python side of the pure cases."""
|
|
if op == "prompt":
|
|
return getattr(tai, pos[0])
|
|
if op == "capability_block":
|
|
return tai.capability_block(*pos)
|
|
if op == "orientation_block":
|
|
return tai.orientation_block(*pos)
|
|
if op == "settings_catalog":
|
|
return {key: {"key": spec["key"], "env": spec["env"], "type": spec["type"], "default": spec["default"], "desc": spec["desc"], "required": spec["required"],
|
|
"nullable": spec["nullable"], "encrypted": spec["encrypted"], "options": spec["options"], "preferred": spec["preferred"],
|
|
"true_words": spec["true_words"], "false_words": spec["false_words"]} for key, spec in tai.SETTINGS_CATALOG.items()}
|
|
if op == "json_dumps":
|
|
return [json.dumps(pos[0]), json.dumps(pos[0], sort_keys=True)]
|
|
if op == "json_roundtrip":
|
|
try:
|
|
return json.dumps(json.loads(pos[0]), sort_keys=True)
|
|
except ValueError:
|
|
return {"ValueError": "x"}
|
|
if op == "float_repr":
|
|
return repr(float(pos[0]))
|
|
if op == "normalize_tag":
|
|
return tai.normalize_tag(pos[0])
|
|
if op == "normalize_tags":
|
|
return tai.normalize_tags(pos[0])
|
|
if op == "singular_noun":
|
|
return tai.singular_noun(pos[0])
|
|
if op == "tag_variants":
|
|
return tai.tag_variants(pos[0])
|
|
if op == "parse_node_id":
|
|
return tai.parse_node_id(pos[0])
|
|
if op == "fts_match_variants":
|
|
terms = tai.fts_escape(pos[0])
|
|
return None if terms is None else tai.fts_match_variants(terms)
|
|
if op == "fts_rank":
|
|
return tai.fts_rank(pos[0])
|
|
if op in ("profile_re", "bot_re", "secret_re", "mcp_server_re"):
|
|
regex = {"profile_re": tai.PROFILE_RE, "bot_re": tai.BOT_RE, "secret_re": tai.SECRET_RE, "mcp_server_re": tai.MCP_SERVER_RE}[op]
|
|
return regex.match(pos[0]) is not None
|
|
if op == "normalize_setting_value":
|
|
try:
|
|
return tai.normalize_setting_value(pos[0], tai.SETTINGS_CATALOG[pos[0]], pos[1])
|
|
except ValueError as exc:
|
|
return {"ValueError": str(exc)}
|
|
if op == "parse_setting_env":
|
|
try:
|
|
return tai.parse_setting_env(tai.SETTINGS_CATALOG[pos[0]], pos[1].strip())
|
|
except ValueError as exc:
|
|
return {"ValueError": str(exc)}
|
|
if op == "parse_setting_stored":
|
|
try:
|
|
return tai.parse_setting_stored(tai.SETTINGS_CATALOG[pos[0]], pos[1])
|
|
except ValueError:
|
|
return {"ValueError": "could not convert"}
|
|
if op == "parse_backends":
|
|
return tai.parse_backends(pos[0])
|
|
if op == "split_models":
|
|
return tai.split_models(pos[0])
|
|
if op == "chunk_lines":
|
|
return tai.chunk_lines(*pos)
|
|
if op == "truncate":
|
|
return tai.truncate(*pos)
|
|
if op == "sanitize_sid":
|
|
return tai.ShellSession.sanitize(pos[0])
|
|
if op == "shell_current":
|
|
env, pid, root, now, tick = pos
|
|
c = tai.ShellSession.current(env=env, pid=pid, proc_root=root, now=now, clk_tck=tick)
|
|
return [c.sid, c.shell, c.pid, c.started, c.parent, c.chain]
|
|
if op == "statusbar":
|
|
return tai.Glow.statusbar(pos[0], pos[1], pos[2], yolo=pos[3], agents=pos[4], width=pos[5])
|
|
if op == "strip_ansi":
|
|
return tai.strip_ansi(pos[0])
|
|
if op == "truncate_ansi":
|
|
return tai.truncate_ansi(pos[0], pos[1])
|
|
if op == "glow_prompt":
|
|
return tai.Glow.prompt_text()
|
|
if op == "glow_title":
|
|
A, version, profile = tai.Ansi, pos[0], pos[1]
|
|
return "%s◆%s %stai%s %s%s%s %s· profile %s%s" % (A.BOLD, A.RESET, A.BOLD, A.RESET, tai.FG_FILE, version, A.RESET, A.DIM, profile, A.RESET)
|
|
if op == "sleep":
|
|
import time
|
|
time.sleep(pos[0])
|
|
return None
|
|
if op in ("native_ctl", "tmux_ctl"):
|
|
if op == "native_ctl":
|
|
sock, pane, method, args = pos
|
|
ctl = tai.NativePlexerController(sock, pane or None)
|
|
else:
|
|
pane, method, args = pos
|
|
ctl = tai.TmuxPlexerController(pane)
|
|
try:
|
|
if method == "type_text":
|
|
return ctl.type_text(args[0], 0.0, args[1])
|
|
if method == "call":
|
|
return ctl._call(args[0])
|
|
return getattr(ctl, method)(*args)
|
|
except tai.PlexerError as exc:
|
|
return {"PlexerError": str(exc)}
|
|
if op == "validate_flow_spec":
|
|
app, error = tai.validate_flow_spec(pos[0])
|
|
return [app if not error else None, error]
|
|
if op == "run_flow":
|
|
app, error = tai.validate_flow_spec(pos[0])
|
|
if error:
|
|
return [None, error]
|
|
|
|
class Fake(tai.PlexerController):
|
|
def __init__(self, replies):
|
|
self.replies = list(replies)
|
|
self.asked = []
|
|
|
|
def ask(self, screen, timeout):
|
|
self.asked.append(screen)
|
|
return self.replies.pop(0)
|
|
fake = Fake(pos[1])
|
|
outcome = tai.PlexerFlow(app).run(fake)
|
|
return [outcome, fake.asked]
|
|
raise KeyError(op)
|
|
|
|
|
|
STORE_OPS = {"debug_log", "repl_script", "log_query"}
|
|
|
|
|
|
def special_store(tai, op, pos, store, config):
|
|
"""tai.py behaviour that needs the live store: DebugLog sinks, REPL commands, log queries."""
|
|
import contextlib
|
|
import io
|
|
if op == "log_query":
|
|
return store.log_query(**pos[0])
|
|
if op == "debug_log":
|
|
sink, fmt, events = pos
|
|
tai.LOG.configure(sink, fmt, config.home, store=store)
|
|
for level, component, event, message, kwargs in events:
|
|
tai.LOG.event(level, component, event, message, **kwargs)
|
|
run = tai.LOG.run
|
|
tai.LOG.close()
|
|
tai.LOG.reset()
|
|
with open(os.path.join(config.home, "debug.log"), "r", encoding="utf-8") as handle:
|
|
return [run, handle.read()]
|
|
if op == "repl_script":
|
|
agent = tai.Agent(config, store, ctx=os.environ.get("TAI_SESSION"))
|
|
outputs = []
|
|
for text in pos[0]:
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
try:
|
|
cont = tai.handle_command(agent, text)
|
|
except IndexError:
|
|
print("unknown command, try /help") # python crashes on a bare "/"; the C++ port reports it as unknown
|
|
cont = True
|
|
outputs.append([buf.getvalue(), cont])
|
|
return [outputs, [agent.profile, agent.bot, agent.messages[1:]]]
|
|
raise KeyError(op)
|
|
|
|
|
|
def run_python(home, scenario_path):
|
|
"""Executed in a child interpreter: replay through tai.py."""
|
|
sys.path.insert(0, ROOT)
|
|
import tai
|
|
import argparse as ap
|
|
args = ap.Namespace(profile="default", yes=False, no_opencode=False)
|
|
config = tai.Config(args)
|
|
seal = tai.Seal(config.home, "")
|
|
store = tai.Store(config, seal)
|
|
with open(scenario_path, "r", encoding="utf-8") as handle:
|
|
scenario = json.load(handle)
|
|
results = []
|
|
|
|
def sub(value):
|
|
if isinstance(value, str) and value.startswith("$R") and value[2:].isdigit():
|
|
return results[int(value[2:])]
|
|
if isinstance(value, list):
|
|
return [sub(item) for item in value]
|
|
if isinstance(value, dict):
|
|
return {key: sub(item) for key, item in value.items()}
|
|
return value
|
|
|
|
def plain(value):
|
|
if isinstance(value, tuple):
|
|
return [plain(item) for item in value]
|
|
if isinstance(value, list):
|
|
return [plain(item) for item in value]
|
|
if isinstance(value, set):
|
|
return sorted(value)
|
|
if isinstance(value, dict):
|
|
return {str(key): plain(item) for key, item in value.items()}
|
|
return value
|
|
|
|
for step in scenario:
|
|
op, pos = step[0], sub(step[1]) if len(step) > 1 else []
|
|
kw = sub(step[2]) if len(step) > 2 else {}
|
|
try:
|
|
if op in STORE_OPS:
|
|
out = special_store(tai, op, pos, store, config)
|
|
elif op in PURE_OPS:
|
|
out = special(tai, op, pos)
|
|
elif op == "spill_or_truncate":
|
|
out = tai.spill_or_truncate(store, *pos, **kw)
|
|
elif op == "mcp_set_enabled":
|
|
out = store.mcp_set_enabled(*pos, **kw)
|
|
else:
|
|
out = getattr(store, op)(*pos, **kw)
|
|
except ValueError as exc:
|
|
out = {"ValueError": str(exc)}
|
|
results.append(plain(out))
|
|
store.close()
|
|
print(json.dumps(results, sort_keys=True))
|
|
|
|
|
|
def dump_vault(home):
|
|
out = {"tables": {}, "schema": [], "files": {}}
|
|
db = sqlite3.connect(os.path.join(home, "memory.db"))
|
|
for name, sql in db.execute("SELECT name, sql FROM sqlite_master WHERE name NOT LIKE 'fts_docs_%' AND name NOT LIKE 'sqlite_%' ORDER BY type, name").fetchall():
|
|
out["schema"].append([name, " ".join((sql or "").split())])
|
|
tables = [row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'fts_docs_%' AND name NOT LIKE 'sqlite_%' ORDER BY name").fetchall()]
|
|
for table in tables:
|
|
cols = [row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()]
|
|
if table == "fts_docs":
|
|
rows = db.execute("SELECT item, kind, sub, title, body, profile FROM fts_docs").fetchall()
|
|
cols = ["item", "kind", "sub", "title", "body", "profile"]
|
|
else:
|
|
rows = db.execute("SELECT * FROM %s" % table).fetchall()
|
|
out["tables"][table] = {"columns": cols, "rows": [list(row) for row in rows]}
|
|
db.close()
|
|
prof = os.path.join(home, "profiles")
|
|
for entry in sorted(os.listdir(prof)):
|
|
with open(os.path.join(prof, entry), "r", encoding="utf-8") as handle:
|
|
out["files"][entry] = handle.read()
|
|
return out
|
|
|
|
|
|
def normalizer(results, scenario):
|
|
runs = [results[pos][0] for pos, step in enumerate(scenario) if step[0] == "debug_log"]
|
|
order = []
|
|
for blob in (json.dumps(results, sort_keys=True),):
|
|
for match in MEM.finditer(blob):
|
|
if match.group(0) not in order:
|
|
order.append(match.group(0))
|
|
names = {mem: "<MEM%d>" % pos for pos, mem in enumerate(order)}
|
|
|
|
def norm(value):
|
|
if isinstance(value, str):
|
|
for run in runs:
|
|
value = value.replace(run, "<RUN>")
|
|
value = TS.sub("<TS>", value)
|
|
value = EPHEMERAL.sub(r"\1-<RAND>", value)
|
|
return MEM.sub(lambda m: names.get(m.group(0), "<MEM?>"), value)
|
|
if isinstance(value, float):
|
|
return float("%.9g" % value)
|
|
if isinstance(value, list):
|
|
return [norm(item) for item in value]
|
|
if isinstance(value, dict):
|
|
return {norm(key): norm(item) for key, item in value.items()}
|
|
return value
|
|
return norm
|
|
|
|
|
|
def settle(dump, norm):
|
|
dump = norm(dump)
|
|
for table in dump["tables"].values():
|
|
table["rows"].sort(key=lambda row: json.dumps(row, sort_keys=True, default=str))
|
|
return dump
|
|
|
|
|
|
def first_diff(a, b, path="$"):
|
|
if type(a) is not type(b):
|
|
return "%s: type %s vs %s: %r vs %r" % (path, type(a).__name__, type(b).__name__, str(a)[:200], str(b)[:200])
|
|
if isinstance(a, dict):
|
|
for key in sorted(set(a) | set(b)):
|
|
if key not in a or key not in b:
|
|
return "%s.%s: only in %s" % (path, key, "python" if key in a else "cpp")
|
|
found = first_diff(a[key], b[key], "%s.%s" % (path, key))
|
|
if found:
|
|
return found
|
|
return None
|
|
if isinstance(a, list):
|
|
if len(a) != len(b):
|
|
return "%s: length %d vs %d" % (path, len(a), len(b))
|
|
for pos, (x, y) in enumerate(zip(a, b)):
|
|
found = first_diff(x, y, "%s[%d]" % (path, pos))
|
|
if found:
|
|
return found
|
|
return None
|
|
if a != b:
|
|
return "%s: %r vs %r" % (path, str(a)[:300], str(b)[:300])
|
|
return None
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--probe", default=os.path.join(os.path.dirname(HERE), "build", "foundation_probe"))
|
|
parser.add_argument("--keep", action="store_true")
|
|
parser.add_argument("--show", nargs="*", default=[], metavar="OP", help="print the C++ result of every step with this op")
|
|
parser.add_argument("--py-runner", nargs=2, metavar=("HOME", "SCENARIO"))
|
|
opts = parser.parse_args()
|
|
if opts.py_runner:
|
|
run_python(*opts.py_runner)
|
|
return 0
|
|
work = tempfile.mkdtemp(prefix="detz-parity-")
|
|
proc = os.path.join(work, "proc")
|
|
cases.build_fake_proc(proc)
|
|
cases.build_fake_proc(proc + "_noboot", boot_id=False)
|
|
cases.build_fake_proc(proc + "_nobtime", btime=False)
|
|
cases.build_fake_proc(proc + "_bare", boot_id=False, btime=False)
|
|
scenario = SCENARIO + cases.debug_cases() + cases.repl_cases() + cases.pure_cases() + cases.flow_cases() + cases.proc_cases(proc) + cases.style_cases() + cases.prune_cases() + cases.plexer_cases()
|
|
runs = {}
|
|
for label in ("python", "cpp"):
|
|
home = os.path.join(work, label)
|
|
os.makedirs(home)
|
|
backend = fixtures.FakeBackend(os.path.join(work, label + ".sock"))
|
|
tmux_server = fixtures.TmuxServer(os.path.join(work, label + "-tmux-root"))
|
|
scenario_path = os.path.join(work, label + "-scenario.json")
|
|
with open(scenario_path, "w", encoding="utf-8") as handle:
|
|
json.dump(json.loads(json.dumps(scenario).replace("$SOCK", backend.path).replace("@@HOME@@", work)), handle)
|
|
env = {key: value for key, value in os.environ.items() if not key.startswith(("TAI_", "TMUX"))}
|
|
env.update({"TAI_HOME": home, "TAI_SESSION": SESSION, "TAI_PASSPHRASE": "", "HOME": work, "TMUX_TMPDIR": tmux_server.tmpdir})
|
|
if label == "python":
|
|
cmd = [sys.executable, "-I", os.path.abspath(__file__), "--py-runner", home, scenario_path]
|
|
else:
|
|
cmd = [opts.probe, scenario_path]
|
|
try:
|
|
done = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=300)
|
|
finally:
|
|
backend.close()
|
|
tmux_server.close()
|
|
if done.returncode != 0:
|
|
print("%s run failed (%d):\n%s" % (label, done.returncode, done.stderr[-2000:]))
|
|
return 1
|
|
runs[label] = {"results": json.loads(done.stdout), "dump": dump_vault(home), "requests": backend.log}
|
|
# vault portability: each implementation reads the vault the other one wrote
|
|
read_path = os.path.join(work, "readback.json")
|
|
with open(read_path, "w", encoding="utf-8") as handle:
|
|
json.dump(cases.readback_cases(SESSION), handle)
|
|
|
|
def read_vault(impl, home):
|
|
env = {key: value for key, value in os.environ.items() if not key.startswith(("TAI_", "TMUX"))}
|
|
env.update({"TAI_HOME": home, "TAI_SESSION": SESSION, "TAI_PASSPHRASE": "", "HOME": work})
|
|
cmd = [sys.executable, "-I", os.path.abspath(__file__), "--py-runner", home, read_path] if impl == "python" else [opts.probe, read_path]
|
|
done = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120)
|
|
if done.returncode != 0:
|
|
raise RuntimeError("%s readback failed:\n%s" % (impl, done.stderr[-1500:]))
|
|
return json.loads(done.stdout)
|
|
|
|
portability = 0
|
|
for home_label in ("python", "cpp"):
|
|
home = os.path.join(work, home_label)
|
|
by_python, by_cpp = read_vault("python", home), read_vault("cpp", home)
|
|
for pos, (a, b) in enumerate(zip(by_python, by_cpp)):
|
|
diff = first_diff(a, b)
|
|
if diff:
|
|
portability += 1
|
|
print("PORTABILITY %s vault, step %d (%s): %s" % (home_label, pos, cases.readback_cases(SESSION)[pos][0], diff))
|
|
py_norm, cpp_norm = normalizer(runs["python"]["results"], scenario), normalizer(runs["cpp"]["results"], scenario)
|
|
py_res, cpp_res = py_norm(runs["python"]["results"]), cpp_norm(runs["cpp"]["results"])
|
|
failures = portability
|
|
for pos, step in enumerate(scenario):
|
|
if step[0] in opts.show:
|
|
print("%d %s -> %s" % (pos, step[0], json.dumps(cpp_res[pos], ensure_ascii=False)[:300]))
|
|
for pos, step in enumerate(scenario):
|
|
if step[0] in UNORDERED: # SQL group order follows the random record ids
|
|
py_res[pos] = sorted(py_res[pos], key=str)
|
|
cpp_res[pos] = sorted(cpp_res[pos], key=str)
|
|
for pos, (a, b) in enumerate(zip(py_res, cpp_res)):
|
|
diff = first_diff(a, b)
|
|
if diff:
|
|
failures += 1
|
|
print("RESULT %d (%s) differs: %s" % (pos, scenario[pos][0], diff))
|
|
if len(py_res) != len(cpp_res):
|
|
failures += 1
|
|
print("result count %d vs %d" % (len(py_res), len(cpp_res)))
|
|
py_dump, cpp_dump = settle(runs["python"]["dump"], py_norm), settle(runs["cpp"]["dump"], cpp_norm)
|
|
diff = first_diff(py_dump["schema"], cpp_dump["schema"])
|
|
if diff:
|
|
failures += 1
|
|
print("SCHEMA differs: %s" % diff)
|
|
for table in sorted(set(py_dump["tables"]) | set(cpp_dump["tables"])):
|
|
diff = first_diff(py_dump["tables"].get(table), cpp_dump["tables"].get(table))
|
|
if diff:
|
|
failures += 1
|
|
print("TABLE %s differs: %s" % (table, diff))
|
|
diff = first_diff(py_dump["files"], cpp_dump["files"])
|
|
if diff:
|
|
failures += 1
|
|
print("FILES differ: %s" % diff)
|
|
diff = first_diff(runs["python"]["requests"], runs["cpp"]["requests"])
|
|
if diff:
|
|
failures += 1
|
|
print("NATIVE BACKEND REQUESTS differ: %s" % diff)
|
|
total_rows = sum(len(t["rows"]) for t in py_dump["tables"].values())
|
|
print("%d operations, %d tables, %d rows, %d profile files compared, %d differences" % (len(py_res), len(py_dump["tables"]), total_rows, len(py_dump["files"]), failures))
|
|
if opts.keep:
|
|
print("kept %s" % work)
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|