tracefork 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tracefork/report.py ADDED
@@ -0,0 +1,131 @@
1
+ """Report generator: produces a self-contained HTML file from a Tape.
2
+
3
+ In static mode, the entire tape data is serialized as JSON and injected
4
+ into the HTML template as `window.__TRACEFORK_DATA__ = {...}`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ _INJECT_MARKER = "</head>"
13
+
14
+
15
+ def _template_path() -> Path:
16
+ """Locate ``web/report.html`` in both an installed wheel and a source checkout.
17
+
18
+ A built wheel force-includes the file at ``tracefork/web/report.html`` (next to
19
+ this module); an editable/source checkout keeps it at the repo root. Resolved at
20
+ call time so importing this module never depends on the file's location.
21
+ """
22
+ here = Path(__file__).parent
23
+ for cand in (
24
+ here / "web" / "report.html", # installed wheel (force-included)
25
+ here.parent.parent / "web" / "report.html", # repo root (src/tracefork -> repo)
26
+ ):
27
+ if cand.exists():
28
+ return cand
29
+ raise FileNotFoundError("web/report.html not found (looked in the package and the repo root)")
30
+
31
+
32
+ def _tape_to_data(tape, blame: dict | None = None) -> dict:
33
+ """Convert a Tape to the JSON shape expected by the web UI."""
34
+ exchanges = []
35
+ for req_bytes, resp_bytes in tape.exchanges:
36
+ try:
37
+ req_json = json.loads(req_bytes.decode())
38
+ except Exception:
39
+ req_json = {"_raw": req_bytes.hex()}
40
+
41
+ try:
42
+ resp_json = json.loads(resp_bytes.decode())
43
+ except Exception:
44
+ # SSE stream — extract first data line
45
+ lines = resp_bytes.decode(errors="replace").splitlines()
46
+ data_lines = [
47
+ line[6:] for line in lines if line.startswith("data: ") and line != "data: [DONE]"
48
+ ]
49
+ try:
50
+ resp_json = json.loads(data_lines[0]) if data_lines else {"_raw": "sse"}
51
+ except Exception:
52
+ resp_json = {"_raw": "sse"}
53
+
54
+ # Determine role from response
55
+ role = "unknown"
56
+ if isinstance(resp_json, dict):
57
+ if resp_json.get("type") == "message":
58
+ role = "assistant"
59
+ elif resp_json.get("role") == "user":
60
+ role = "user"
61
+ if "messages" in req_json:
62
+ msgs = req_json["messages"]
63
+ if msgs:
64
+ role = msgs[-1].get("role", role)
65
+
66
+ # Preview: first 80 chars of last user message or response text
67
+ preview = ""
68
+ try:
69
+ if isinstance(resp_json, dict) and resp_json.get("content"):
70
+ for block in resp_json["content"]:
71
+ if block.get("type") == "text":
72
+ preview = block["text"][:80]
73
+ break
74
+ if block.get("type") == "tool_use":
75
+ tool_input_preview = json.dumps(block.get("input", {}))[:60]
76
+ preview = f"→ {block.get('name', 'tool')}({tool_input_preview})"
77
+ break
78
+ except Exception:
79
+ pass
80
+
81
+ exchanges.append(
82
+ {
83
+ "role": role,
84
+ "preview": preview,
85
+ "request": req_json,
86
+ "response_preview": resp_json,
87
+ }
88
+ )
89
+
90
+ return {
91
+ "agent_name": tape.agent_name,
92
+ "exchanges": exchanges,
93
+ "blame": blame or {},
94
+ "created_at": "",
95
+ "fingerprint": tape.digest()[:16],
96
+ }
97
+
98
+
99
+ def _safe_json(data: dict) -> str:
100
+ """Serialize `data` and escape HTML-significant chars so recorded agent I/O
101
+ (which can contain ``</script>``) cannot break out of the inline <script>.
102
+
103
+ Replacing ``< > &`` with their ``\\uXXXX`` forms yields valid JSON string
104
+ escapes, so the loader's parse still works. The JS line separators
105
+ U+2028/U+2029 are already emitted as ``\\u`` escapes by ``ensure_ascii=True``.
106
+ """
107
+ return (
108
+ json.dumps(data, indent=2)
109
+ .replace("<", "\\u003c")
110
+ .replace(">", "\\u003e")
111
+ .replace("&", "\\u0026")
112
+ )
113
+
114
+
115
+ def generate_report(
116
+ tape,
117
+ output_path: Path,
118
+ *,
119
+ blame: dict | None = None,
120
+ ) -> None:
121
+ """Write a self-contained HTML report to `output_path`.
122
+
123
+ The tape data is injected before </head> so the UI loads it synchronously.
124
+ """
125
+ html = _template_path().read_text()
126
+ data = _tape_to_data(tape, blame)
127
+ inject = f"\n<script>\nwindow.__TRACEFORK_DATA__ = {_safe_json(data)};\n</script>\n"
128
+ html = html.replace(_INJECT_MARKER, inject + _INJECT_MARKER, 1)
129
+ output_path = Path(output_path)
130
+ output_path.parent.mkdir(parents=True, exist_ok=True)
131
+ output_path.write_text(html)
tracefork/server.py ADDED
@@ -0,0 +1,73 @@
1
+ """FastAPI server for tracefork live mode.
2
+
3
+ Serves the report HTML at / and JSON endpoints at /api/run/{run_id}
4
+ and /api/branch/{branch_id}. Single-threaded (uvicorn --workers 1).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.responses import HTMLResponse, JSONResponse
11
+
12
+ from .report import _tape_to_data, _template_path
13
+ from .store import TapeStore
14
+
15
+ # No CORS middleware: the UI is served same-origin by this app and uvicorn
16
+ # binds to 127.0.0.1 (see the `serve` CLI command), so cross-origin access is
17
+ # neither needed nor desirable.
18
+ app = FastAPI(title="tracefork", docs_url=None, redoc_url=None)
19
+
20
+ _store: TapeStore | None = None
21
+
22
+
23
+ def get_store() -> TapeStore:
24
+ if _store is None:
25
+ raise RuntimeError("Store not initialized — call init_store() first")
26
+ return _store
27
+
28
+
29
+ def init_store(db_path: str = "store.db") -> None:
30
+ global _store
31
+ _store = TapeStore(db_path)
32
+
33
+
34
+ @app.get("/", response_class=HTMLResponse)
35
+ async def serve_ui() -> HTMLResponse:
36
+ html = _template_path().read_text()
37
+ # Empty server URL → the UI fetches same-origin (works on any --port).
38
+ inject = "\n<script>\nwindow.__TRACEFORK_SERVER_URL__ = '';\n</script>\n"
39
+ html = html.replace("</head>", inject + "</head>", 1)
40
+ return HTMLResponse(html)
41
+
42
+
43
+ @app.get("/api/runs")
44
+ async def list_runs() -> JSONResponse:
45
+ store = get_store()
46
+ return JSONResponse(store.list_runs())
47
+
48
+
49
+ @app.get("/api/run/{run_id}")
50
+ async def get_run(run_id: str) -> JSONResponse:
51
+ store = get_store()
52
+ try:
53
+ tape = store.load_tape(run_id)
54
+ except KeyError:
55
+ raise HTTPException(status_code=404, detail=f"run {run_id!r} not found") from None
56
+ data = _tape_to_data(tape)
57
+ data["run_id"] = run_id
58
+ return JSONResponse(data)
59
+
60
+
61
+ @app.get("/api/branch/{branch_id}")
62
+ async def get_branch(branch_id: str) -> JSONResponse:
63
+ store = get_store()
64
+ try:
65
+ branch = store.load_branch(branch_id)
66
+ except KeyError:
67
+ raise HTTPException(status_code=404, detail=f"branch {branch_id!r} not found") from None
68
+ data = _tape_to_data(branch["delta_tape"])
69
+ data["branch_id"] = branch_id
70
+ data["parent_run_id"] = branch["parent_run_id"]
71
+ data["divergence_step"] = branch["divergence_step"]
72
+ data["mutation_desc"] = branch["mutation_desc"]
73
+ return JSONResponse(data)
tracefork/store.py ADDED
@@ -0,0 +1,123 @@
1
+ """TapeStore — SQLite-backed persistence for tapes and branch metadata.
2
+
3
+ Schema:
4
+ tapes (run_id TEXT PK, agent_name TEXT, tape_bytes BLOB, created_at TEXT)
5
+ branches(branch_id TEXT PK, parent_run_id TEXT, divergence_step INT,
6
+ delta_tape_bytes BLOB, mutation_desc TEXT, created_at TEXT)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ import uuid
13
+
14
+ from .tape import Tape
15
+
16
+ _DDL = """
17
+ CREATE TABLE IF NOT EXISTS tapes (
18
+ run_id TEXT PRIMARY KEY,
19
+ agent_name TEXT NOT NULL,
20
+ tape_bytes BLOB NOT NULL,
21
+ created_at TEXT NOT NULL
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS branches (
25
+ branch_id TEXT PRIMARY KEY,
26
+ parent_run_id TEXT NOT NULL,
27
+ divergence_step INTEGER NOT NULL,
28
+ delta_tape_bytes BLOB NOT NULL,
29
+ mutation_desc TEXT NOT NULL DEFAULT '',
30
+ created_at TEXT NOT NULL,
31
+ FOREIGN KEY(parent_run_id) REFERENCES tapes(run_id)
32
+ );
33
+ """
34
+
35
+
36
+ class TapeStore:
37
+ """SQLite-backed store for tapes and branches."""
38
+
39
+ def __init__(self, db_path: str = "store.db") -> None:
40
+ self._path = db_path
41
+ self._con = sqlite3.connect(db_path, check_same_thread=False)
42
+ self._con.executescript(_DDL)
43
+ self._con.commit()
44
+
45
+ # ── tapes ──────────────────────────────────────────────────────────────
46
+
47
+ def save_tape(self, tape: Tape, *, run_id: str | None = None, created_at: str = "") -> str:
48
+ rid = run_id or uuid.uuid4().hex[:12]
49
+ blob = tape.to_bytes()
50
+ self._con.execute(
51
+ "INSERT OR REPLACE INTO tapes(run_id, agent_name, tape_bytes, created_at) "
52
+ "VALUES(?,?,?,?)",
53
+ (rid, tape.agent_name, blob, created_at),
54
+ )
55
+ self._con.commit()
56
+ return rid
57
+
58
+ def load_tape(self, run_id: str) -> Tape:
59
+ row = self._con.execute("SELECT tape_bytes FROM tapes WHERE run_id=?", (run_id,)).fetchone()
60
+ if row is None:
61
+ raise KeyError(f"run_id {run_id!r} not found")
62
+ return Tape.from_bytes(bytes(row[0]))
63
+
64
+ def list_runs(self) -> list[dict]:
65
+ rows = self._con.execute(
66
+ "SELECT run_id, agent_name, created_at FROM tapes ORDER BY created_at DESC"
67
+ ).fetchall()
68
+ return [{"run_id": r[0], "agent_name": r[1], "created_at": r[2]} for r in rows]
69
+
70
+ # ── branches ────────────────────────────────────────────────────────────
71
+
72
+ def save_branch(
73
+ self,
74
+ *,
75
+ parent_run_id: str,
76
+ divergence_step: int,
77
+ delta_tape: Tape,
78
+ mutation_desc: str = "",
79
+ created_at: str = "",
80
+ ) -> str:
81
+ bid = uuid.uuid4().hex[:12]
82
+ blob = delta_tape.to_bytes()
83
+ self._con.execute(
84
+ """INSERT INTO branches
85
+ (branch_id, parent_run_id, divergence_step, delta_tape_bytes,
86
+ mutation_desc, created_at)
87
+ VALUES(?,?,?,?,?,?)""",
88
+ (bid, parent_run_id, divergence_step, blob, mutation_desc, created_at),
89
+ )
90
+ self._con.commit()
91
+ return bid
92
+
93
+ def load_branch(self, branch_id: str) -> dict:
94
+ row = self._con.execute(
95
+ """SELECT branch_id, parent_run_id, divergence_step,
96
+ delta_tape_bytes, mutation_desc, created_at
97
+ FROM branches WHERE branch_id=?""",
98
+ (branch_id,),
99
+ ).fetchone()
100
+ if row is None:
101
+ raise KeyError(f"branch_id {branch_id!r} not found")
102
+ return {
103
+ "branch_id": row[0],
104
+ "parent_run_id": row[1],
105
+ "divergence_step": row[2],
106
+ "delta_tape": Tape.from_bytes(bytes(row[3])),
107
+ "mutation_desc": row[4],
108
+ "created_at": row[5],
109
+ }
110
+
111
+ def list_branches(self, parent_run_id: str) -> list[dict]:
112
+ rows = self._con.execute(
113
+ """SELECT branch_id, divergence_step, mutation_desc, created_at
114
+ FROM branches WHERE parent_run_id=? ORDER BY created_at DESC""",
115
+ (parent_run_id,),
116
+ ).fetchall()
117
+ return [
118
+ {"branch_id": r[0], "divergence_step": r[1], "mutation_desc": r[2], "created_at": r[3]}
119
+ for r in rows
120
+ ]
121
+
122
+ def close(self) -> None:
123
+ self._con.close()
tracefork/synthetic.py ADDED
@@ -0,0 +1,104 @@
1
+ """Synthetic Anthropic transports — offline, deterministic stand-ins for the API.
2
+
3
+ These serve real Anthropic wire-format bytes (built by `tracefork.wire`) so the
4
+ genuine SDK parses them, but never touch the network. They are production
5
+ components: the self-validation suite (`tracefork validate`) and the test suite
6
+ both drive the recorder/fork/blame machinery through them at $0.
7
+
8
+ - `ScriptedFakeLLM` — returns a fixed list of responses in order.
9
+ - `AsyncScriptedFakeLLM`— async variant.
10
+ - `FaultAwareFakeLLM` — returns a *failure* script when a fault marker
11
+ appears in the request body, else a *normal* script; this is how an
12
+ injected fault propagates into a flipped outcome during validation.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import httpx
18
+
19
+
20
+ class ScriptedFakeLLM(httpx.BaseTransport):
21
+ """Returns scripted Anthropic wire-format responses in sequence.
22
+
23
+ Pass a list of response bytes (from make_text_response / make_tool_use_response).
24
+ Raises ScriptExhausted if more requests arrive than the script has responses.
25
+ """
26
+
27
+ class ScriptExhausted(RuntimeError):
28
+ pass
29
+
30
+ def __init__(self, responses: list[bytes]) -> None:
31
+ self._responses = list(responses)
32
+ self._i = 0
33
+ self.requests_received: list[bytes] = []
34
+
35
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
36
+ self.requests_received.append(request.content)
37
+ if self._i >= len(self._responses):
38
+ raise self.ScriptExhausted(
39
+ f"ScriptedFakeLLM exhausted after {len(self._responses)} responses"
40
+ )
41
+ resp = self._responses[self._i]
42
+ self._i += 1
43
+ return httpx.Response(
44
+ 200,
45
+ headers={"content-type": "application/json"},
46
+ content=resp,
47
+ )
48
+
49
+
50
+ class AsyncScriptedFakeLLM(httpx.AsyncBaseTransport):
51
+ """Async variant of ScriptedFakeLLM."""
52
+
53
+ class ScriptExhausted(RuntimeError):
54
+ pass
55
+
56
+ def __init__(self, responses: list[bytes]) -> None:
57
+ self._responses = list(responses)
58
+ self._i = 0
59
+ self.requests_received: list[bytes] = []
60
+
61
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
62
+ self.requests_received.append(request.content)
63
+ if self._i >= len(self._responses):
64
+ raise self.ScriptExhausted(
65
+ f"AsyncScriptedFakeLLM exhausted after {len(self._responses)} responses"
66
+ )
67
+ resp = self._responses[self._i]
68
+ self._i += 1
69
+ return httpx.Response(
70
+ 200,
71
+ headers={"content-type": "application/json"},
72
+ content=resp,
73
+ )
74
+
75
+
76
+ class FaultAwareFakeLLM(httpx.BaseTransport):
77
+ """Returns different response scripts based on a fault marker in the request.
78
+
79
+ If `fault_marker` (bytes) appears anywhere in the request body, serve
80
+ `fault_responses`; otherwise serve `normal_responses`. Each script cycles
81
+ independently. This lets an injected fault — which the agent echoes into a
82
+ later request — deterministically flip the run's outcome.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ normal_responses: list[bytes],
88
+ fault_responses: list[bytes],
89
+ fault_marker: bytes,
90
+ ) -> None:
91
+ self._normal = list(normal_responses)
92
+ self._fault = list(fault_responses)
93
+ self._marker = fault_marker
94
+ self._normal_i = 0
95
+ self._fault_i = 0
96
+
97
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
98
+ if self._marker in request.content:
99
+ resp = self._fault[self._fault_i % len(self._fault)]
100
+ self._fault_i += 1
101
+ else:
102
+ resp = self._normal[self._normal_i % len(self._normal)]
103
+ self._normal_i += 1
104
+ return httpx.Response(200, headers={"content-type": "application/json"}, content=resp)
tracefork/tape.py ADDED
@@ -0,0 +1,135 @@
1
+ """Content-addressed, zstd-compressed, persistable tape.
2
+
3
+ A tape is the recorded artifact of one agent run: ordered HTTP exchanges
4
+ (request body + response body) and nondeterminism draws. Blobs are stored
5
+ content-addressed (keyed by sha256) and zstd-compressed so identical bytes
6
+ are stored once. `digest()` is a hash chain over all draws + exchanges.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import sqlite3
13
+ from dataclasses import dataclass, field
14
+
15
+ import zstandard as zstd
16
+
17
+ from .constants import BOUNDARY_V1
18
+
19
+ _ZCTX = zstd.ZstdCompressor(level=3)
20
+ _DCTX = zstd.ZstdDecompressor()
21
+
22
+
23
+ def sha256_hex(data: bytes) -> str:
24
+ return hashlib.sha256(data).hexdigest()
25
+
26
+
27
+ @dataclass
28
+ class Tape:
29
+ exchanges: list[tuple[bytes, bytes]] = field(default_factory=list)
30
+ draws: list[tuple[str, str]] = field(default_factory=list)
31
+ boundary: str = BOUNDARY_V1
32
+ agent_name: str = ""
33
+
34
+ def append_exchange(self, request_body: bytes, response_body: bytes) -> None:
35
+ self.exchanges.append((request_body, response_body))
36
+
37
+ def exchange(self, i: int) -> tuple[bytes, bytes]:
38
+ return self.exchanges[i]
39
+
40
+ def digest(self) -> str:
41
+ """sha256 hash chain over draws then exchanges — the tape fingerprint."""
42
+ h = hashlib.sha256()
43
+ for kind, value in self.draws:
44
+ h.update(b"D:" + kind.encode() + b":" + value.encode() + b"\n")
45
+ for req, resp in self.exchanges:
46
+ h.update(b"X:" + sha256_hex(req).encode() + b":" + sha256_hex(resp).encode() + b"\n")
47
+ return h.hexdigest()
48
+
49
+ def to_bytes(self) -> bytes:
50
+ """Serialize tape to JSON bytes (base64-encoded for binary exchange fields)."""
51
+ import base64
52
+ import json
53
+
54
+ return json.dumps(
55
+ {
56
+ "exchanges": [
57
+ [base64.b64encode(req).decode(), base64.b64encode(resp).decode()]
58
+ for req, resp in self.exchanges
59
+ ],
60
+ "draws": self.draws,
61
+ "boundary": self.boundary,
62
+ "agent_name": self.agent_name,
63
+ }
64
+ ).encode()
65
+
66
+ @classmethod
67
+ def from_bytes(cls, data: bytes) -> Tape:
68
+ """Deserialize tape from JSON bytes produced by to_bytes()."""
69
+ import base64
70
+ import json
71
+
72
+ d = json.loads(data)
73
+ tape = cls(
74
+ boundary=d["boundary"],
75
+ agent_name=d["agent_name"],
76
+ )
77
+ tape.draws = [tuple(pair) for pair in d["draws"]]
78
+ tape.exchanges = [
79
+ (base64.b64decode(req), base64.b64decode(resp)) for req, resp in d["exchanges"]
80
+ ]
81
+ return tape
82
+
83
+ def save(self, path: str) -> None:
84
+ con = sqlite3.connect(path)
85
+ try:
86
+ con.executescript("""
87
+ DROP TABLE IF EXISTS blobs;
88
+ DROP TABLE IF EXISTS events;
89
+ DROP TABLE IF EXISTS meta;
90
+ CREATE TABLE blobs (hash TEXT PRIMARY KEY, data BLOB NOT NULL);
91
+ CREATE TABLE events (
92
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ kind TEXT NOT NULL, a TEXT NOT NULL, b TEXT NOT NULL
94
+ );
95
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
96
+ """)
97
+ for kind, value in self.draws:
98
+ con.execute("INSERT INTO events (kind, a, b) VALUES ('draw', ?, ?)", (kind, value))
99
+ for req, resp in self.exchanges:
100
+ rh, sh = sha256_hex(req), sha256_hex(resp)
101
+ con.execute(
102
+ "INSERT OR IGNORE INTO blobs VALUES (?, ?)",
103
+ (rh, _ZCTX.compress(req)),
104
+ )
105
+ con.execute(
106
+ "INSERT OR IGNORE INTO blobs VALUES (?, ?)",
107
+ (sh, _ZCTX.compress(resp)),
108
+ )
109
+ con.execute("INSERT INTO events (kind, a, b) VALUES ('exchange', ?, ?)", (rh, sh))
110
+ con.execute("INSERT INTO meta VALUES ('boundary', ?)", (self.boundary,))
111
+ con.execute("INSERT INTO meta VALUES ('agent_name', ?)", (self.agent_name,))
112
+ con.execute("INSERT INTO meta VALUES ('schema_version', '1')")
113
+ con.commit()
114
+ finally:
115
+ con.close()
116
+
117
+ @classmethod
118
+ def load(cls, path: str) -> Tape:
119
+ con = sqlite3.connect(path)
120
+ try:
121
+ raw_blobs = dict(con.execute("SELECT hash, data FROM blobs").fetchall())
122
+ blobs = {k: _DCTX.decompress(bytes(v)) for k, v in raw_blobs.items()}
123
+ meta = dict(con.execute("SELECT key, value FROM meta").fetchall())
124
+ tape = cls(
125
+ boundary=meta.get("boundary", BOUNDARY_V1),
126
+ agent_name=meta.get("agent_name", ""),
127
+ )
128
+ for kind, a, b in con.execute("SELECT kind, a, b FROM events ORDER BY seq").fetchall():
129
+ if kind == "draw":
130
+ tape.draws.append((a, b))
131
+ elif kind == "exchange":
132
+ tape.exchanges.append((blobs[a], blobs[b]))
133
+ return tape
134
+ finally:
135
+ con.close()