backspin 0.5.1__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.
backspin/replay.py ADDED
@@ -0,0 +1,327 @@
1
+ """Deterministic replay: run your agent again with recorded LLM responses.
2
+
3
+ A :class:`Cassette` indexes the LLM calls of a recorded run. A stub client
4
+ answers new calls from the cassette, matching by request fingerprint
5
+ (model + messages) and falling back to call order — so you can re-run the
6
+ agent offline, in tests, or against a fix, with the LLM held constant.
7
+
8
+ What-if branching: :meth:`Cassette.mutate` alters one recorded answer and
9
+ :func:`branch` records the mutated replay as a new run, so you can diff
10
+ "what would have happened if the model had said X".
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import json
16
+ import warnings
17
+ from contextlib import contextmanager
18
+ from types import SimpleNamespace
19
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
20
+
21
+ from .fakes import FakeResponse, stream_chunks
22
+ from .recorder import Recorder
23
+ from .runfile import Run, fingerprint_request, load_run
24
+
25
+
26
+ class ReplayMismatchWarning(UserWarning):
27
+ """A replayed request did not exactly match its recording."""
28
+
29
+
30
+ class ReplayMismatch(Exception):
31
+ """The cassette ran out of recordings before the replay finished."""
32
+
33
+
34
+ class Cassette:
35
+ """Indexed recordings of LLM calls from one run."""
36
+
37
+ def __init__(self, entries: List[Dict[str, Any]]):
38
+ self.entries = entries
39
+ self._cursor = 0
40
+
41
+ @classmethod
42
+ def from_run(cls, run: Run) -> "Cassette":
43
+ return cls([e for e in run.llm_calls() if e.get("response") is not None])
44
+
45
+ def __len__(self) -> int:
46
+ return len(self.entries)
47
+
48
+ def match(self, fingerprint: str) -> Tuple[Optional[Dict[str, Any]], bool]:
49
+ """Best-effort match against the remaining tape: exact fingerprint
50
+ first, then the next recording in call order.
51
+
52
+ Returns ``(entry, exact)``. ``entry`` is ``None`` when the cassette
53
+ is exhausted. Matching is sequential — each recording is consumed
54
+ at most once, so looping agents surface as exhaustion instead of
55
+ silently looping the same response forever.
56
+ """
57
+ for i in range(self._cursor, len(self.entries)):
58
+ if self.entries[i].get("fingerprint") == fingerprint:
59
+ self._cursor = i + 1
60
+ return self.entries[i], True
61
+ if self._cursor < len(self.entries):
62
+ entry = self.entries[self._cursor]
63
+ self._cursor += 1
64
+ return entry, False
65
+ return None, False
66
+
67
+ def take(self, fingerprint: str) -> Dict[str, Any]:
68
+ """Like :meth:`match` but raises on exhaustion and warns on fallback."""
69
+ entry, exact = self.match(fingerprint)
70
+ if entry is None:
71
+ raise ReplayMismatch(
72
+ f"no recording left in cassette (wanted fingerprint {fingerprint})"
73
+ )
74
+ if not exact:
75
+ warnings.warn(
76
+ f"request fingerprint {fingerprint} not in cassette; "
77
+ f"falling back to recording #{self._cursor} by call order",
78
+ ReplayMismatchWarning,
79
+ stacklevel=3,
80
+ )
81
+ return entry
82
+
83
+ def mutate(
84
+ self,
85
+ index: int,
86
+ *,
87
+ content: Optional[str] = None,
88
+ tool_arguments: Optional[Dict[str, Any]] = None,
89
+ ) -> "Cassette":
90
+ """What-if: return a copy with recording ``#index``'s answer altered.
91
+
92
+ ``content`` replaces the assistant message text; ``tool_arguments``
93
+ (a dict) replaces the first tool call's arguments, stored as JSON.
94
+ Requests are untouched, so replay matching still works — only the
95
+ answer the agent sees changes. Combine with :func:`branch` or
96
+ ``stub_client`` to measure the downstream effect.
97
+ """
98
+ if not 0 <= index < len(self.entries):
99
+ raise IndexError(
100
+ f"recording #{index} out of range (cassette has {len(self.entries)})"
101
+ )
102
+ entries = copy.deepcopy(self.entries)
103
+ resp = entries[index]["response"]
104
+ if "choices" in resp:
105
+ message = (resp["choices"] or [{}])[0].setdefault(
106
+ "message", {"role": "assistant"}
107
+ )
108
+ if content is not None:
109
+ message["content"] = content
110
+ if tool_arguments is not None:
111
+ calls = message.get("tool_calls")
112
+ if not calls:
113
+ raise ValueError("that recording has no tool calls to mutate")
114
+ calls[0].setdefault("function", {})["arguments"] = json.dumps(tool_arguments)
115
+ elif "content" in resp: # anthropic message: mutate first text block
116
+ if tool_arguments is not None:
117
+ raise ValueError(
118
+ "tool_arguments mutation is not supported for anthropic tool_use blocks yet"
119
+ )
120
+ for block in resp["content"] or []:
121
+ if isinstance(block, dict) and block.get("type") == "text":
122
+ block["text"] = content
123
+ break
124
+ else:
125
+ if content is not None:
126
+ resp.setdefault("content", []).append({"type": "text", "text": content})
127
+ else:
128
+ raise ValueError("unrecognized response shape; cannot mutate")
129
+ return Cassette(entries)
130
+
131
+
132
+ class _StubCompletions:
133
+ """Duck-typed ``chat.completions`` answered from a cassette."""
134
+
135
+ def __init__(self, cassette: Cassette, *, async_mode: bool = False):
136
+ self.cassette = cassette
137
+ self.state = SimpleNamespace(calls=0, mismatches=[])
138
+ self._async = async_mode
139
+
140
+ def _respond(self, **kwargs: Any):
141
+ self.state.calls += 1
142
+ fp = fingerprint_request(kwargs.get("model"), kwargs.get("messages"))
143
+ entry, exact = self.cassette.match(fp)
144
+ if entry is None:
145
+ raise ReplayMismatch(
146
+ f"LLM call #{self.state.calls}: cassette exhausted "
147
+ f"(fingerprint={fp})"
148
+ )
149
+ if not exact:
150
+ self.state.mismatches.append(
151
+ {"call": self.state.calls, "requested": fp,
152
+ "replayed": entry.get("fingerprint")}
153
+ )
154
+ warnings.warn(
155
+ f"LLM call #{self.state.calls}: fingerprint mismatch, "
156
+ f"replaying recording #{self.cassette._cursor} by call order",
157
+ ReplayMismatchWarning,
158
+ stacklevel=2,
159
+ )
160
+ data = dict(entry["response"])
161
+ if kwargs.get("stream"):
162
+ return iter(stream_chunks(data))
163
+ return FakeResponse(data)
164
+
165
+ def create(self, **kwargs: Any):
166
+ if self._async:
167
+ return self._respond_async(**kwargs)
168
+ return self._respond(**kwargs)
169
+
170
+ async def _respond_async(self, **kwargs: Any):
171
+ return self._respond(**kwargs)
172
+
173
+
174
+ def stub_client(cassette: Cassette, *, async_mode: bool = False) -> SimpleNamespace:
175
+ """A client shaped like ``openai.OpenAI`` (the slice agents touch)."""
176
+ completions = _StubCompletions(cassette, async_mode=async_mode)
177
+ return SimpleNamespace(
178
+ chat=SimpleNamespace(completions=completions), state=completions.state
179
+ )
180
+
181
+
182
+ @contextmanager
183
+ def patch_openai(cassette: Cassette):
184
+ """Patch ``openai.OpenAI`` / ``openai.AsyncOpenAI`` so constructors
185
+ yield stub clients backed by ``cassette``. Restores on exit.
186
+
187
+ Lets unmodified agent code (``client = OpenAI()``) replay offline.
188
+ """
189
+ try:
190
+ import openai
191
+ except ImportError as exc: # pragma: no cover
192
+ raise ImportError(
193
+ "patch_openai() requires the openai package: pip install openai"
194
+ ) from exc
195
+ saved = (openai.OpenAI, openai.AsyncOpenAI)
196
+ sync_client = stub_client(cassette)
197
+ async_client = stub_client(cassette, async_mode=True)
198
+ # monkeypatching the constructors is the whole point of this helper
199
+ openai.OpenAI = lambda *a, **k: sync_client # type: ignore[assignment,misc]
200
+ openai.AsyncOpenAI = lambda *a, **k: async_client # type: ignore[assignment,misc]
201
+ try:
202
+ yield SimpleNamespace(sync=sync_client, async_=async_client)
203
+ finally:
204
+ openai.OpenAI, openai.AsyncOpenAI = saved # type: ignore[misc]
205
+
206
+
207
+ def branch(
208
+ run: Union[Run, str],
209
+ mutations: Dict[int, Dict[str, Any]],
210
+ *,
211
+ dir: str = "runs",
212
+ agent: Optional[str] = None,
213
+ ) -> str:
214
+ """What-if: record a replayed run with selected answers mutated.
215
+
216
+ ``mutations`` maps a 0-based LLM-call index to
217
+ :meth:`Cassette.mutate` kwargs, e.g. ``{1: {"content": "No."}}``. Every
218
+ recorded LLM request is re-issued in order against the mutated cassette
219
+ and captured into a new run file marked ``branch_of`` in its metadata.
220
+
221
+ Returns the new run's path. Diff it against the original with
222
+ ``diff_runs(original, branch, llm_only=True)`` — requests match until
223
+ the mutated answer flows back into a later request, which is exactly
224
+ where the two timelines split.
225
+ """
226
+ run_obj = load_run(run) if isinstance(run, str) else run
227
+ if not mutations:
228
+ raise ValueError("branch(): pass at least one mutation, e.g. {0: {'content': 'No.'}}")
229
+ cassette = Cassette.from_run(run_obj)
230
+ calls = run_obj.llm_calls()
231
+
232
+ # Text substitutions that carry mutations forward: when a mutated answer
233
+ # would have been fed back into a later request, rewrite that request's
234
+ # assistant messages to the mutated text. (Content-level rewriting; tool
235
+ # call arguments in follow-up requests are left as recorded.)
236
+ substitutions: Dict[str, str] = {}
237
+ for index in sorted(mutations):
238
+ if not 0 <= index < len(calls):
239
+ raise IndexError(
240
+ f"mutation step {index} out of range (run has {len(calls)} LLM calls)"
241
+ )
242
+ before = (
243
+ (calls[index].get("response") or {})
244
+ .get("choices", [{}])[0].get("message", {}).get("content")
245
+ )
246
+ cassette = cassette.mutate(index, **mutations[index])
247
+ after = (cassette.entries[index]["response"]["choices"][0]["message"].get("content"))
248
+ if before is not None and after != before:
249
+ substitutions[before] = after
250
+
251
+ rec = Recorder(
252
+ dir=dir,
253
+ agent=agent or run_obj.agent,
254
+ metadata={
255
+ "branch_of": run_obj.run_id,
256
+ "mutations": {str(k): v for k, v in sorted(mutations.items())},
257
+ },
258
+ )
259
+ with rec:
260
+ stub = rec.capture_openai(stub_client(cassette))
261
+ with warnings.catch_warnings():
262
+ # fingerprint fallbacks are the point of a branch: mutated
263
+ # answers change later requests by design
264
+ warnings.simplefilter("ignore", ReplayMismatchWarning)
265
+ for entry in calls:
266
+ if entry.get("response") is None:
267
+ continue
268
+ request = dict(entry.get("request") or {})
269
+ messages = [
270
+ dict(m, content=substitutions[m["content"]])
271
+ if m.get("role") == "assistant" and m.get("content") in substitutions
272
+ else m
273
+ for m in (request.get("messages") or [])
274
+ ]
275
+ stub.chat.completions.create(
276
+ model=request.get("model"), messages=messages
277
+ )
278
+ return rec.path
279
+
280
+
281
+ def branch_agent(
282
+ fn: Callable,
283
+ run: Union[Run, str],
284
+ mutations: Dict[int, Dict[str, Any]],
285
+ *,
286
+ dir: str = "runs",
287
+ agent: Optional[str] = None,
288
+ extra_args: Optional[Tuple] = None,
289
+ ) -> str:
290
+ """Agent-level what-if: re-run the *actual agent function* against a
291
+ mutated cassette and record it as a branch run.
292
+
293
+ ``fn(client, rec, *extra_args)`` is your real agent function: ``client``
294
+ is a stub answering from the (mutated) cassette, and ``rec`` is the
295
+ branch run's recorder, so logs, spans, tool calls and downstream
296
+ requests all happen for real and preserve the run's full shape (unlike
297
+ :func:`branch`, which replays the request sequence only).
298
+
299
+ Returns the new run's path; metadata carries ``branch_of`` +
300
+ ``mutations``. Diff against the original with plain ``diff_runs`` —
301
+ shapes align, and the first divergence is where the mutated answer
302
+ changed the conversation.
303
+ """
304
+ run_obj = load_run(run) if isinstance(run, str) else run
305
+ if not callable(fn):
306
+ raise TypeError("branch_agent(): fn must be callable, taking (client, *extra_args)")
307
+ if not mutations:
308
+ raise ValueError("branch_agent(): pass at least one mutation, e.g. {0: {'content': 'No.'}}")
309
+ cassette = Cassette.from_run(run_obj)
310
+ for index in sorted(mutations):
311
+ cassette = cassette.mutate(index, **mutations[index])
312
+
313
+ rec = Recorder(
314
+ dir=dir,
315
+ agent=agent or run_obj.agent,
316
+ metadata={
317
+ "branch_of": run_obj.run_id,
318
+ "mutations": {str(k): v for k, v in sorted(mutations.items())},
319
+ "branch_level": "agent",
320
+ },
321
+ )
322
+ with rec:
323
+ stub = rec.capture_openai(stub_client(cassette))
324
+ with warnings.catch_warnings():
325
+ warnings.simplefilter("ignore", ReplayMismatchWarning)
326
+ fn(stub, rec, *(extra_args or ()))
327
+ return rec.path
backspin/runfile.py ADDED
@@ -0,0 +1,182 @@
1
+ """On-disk run format.
2
+
3
+ A run is a single self-contained ``*.backspin.jsonl`` file. The first line
4
+ is a header event; every following line is one recorded step — an LLM call,
5
+ a tool call, a log line, an error, or any custom event. Because a run is
6
+ one file, "please attach the failing run" becomes a practical bug-report
7
+ ritual instead of a database dump.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import time
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ SCHEMA_VERSION = 1
20
+ FILE_SUFFIX = ".backspin.jsonl"
21
+
22
+ HEADER = "header"
23
+ LLM = "llm"
24
+ TOOL = "tool"
25
+ LOG = "log"
26
+ ERROR = "error"
27
+ SPAN = "span"
28
+
29
+
30
+ def new_run_id() -> str:
31
+ return uuid.uuid4().hex[:12]
32
+
33
+
34
+ def backspin_version() -> str:
35
+ try:
36
+ from importlib.metadata import version
37
+
38
+ return version("backspin")
39
+ except Exception:
40
+ return "0.0.0+unknown"
41
+
42
+
43
+ def canonical_json(obj: Any) -> str:
44
+ """Stable serialization, used for fingerprints."""
45
+ return json.dumps(
46
+ obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str
47
+ )
48
+
49
+
50
+ def jsonable(obj: Any, max_len: int = 800) -> Any:
51
+ """Return obj if JSON-serializable, else a truncated repr.
52
+
53
+ A recorder must never crash the agent it is watching, so anything
54
+ exotic degrades to a repr instead of raising.
55
+ """
56
+ try:
57
+ json.dumps(obj)
58
+ return obj
59
+ except (TypeError, ValueError):
60
+ return repr(obj)[:max_len]
61
+
62
+
63
+ def fingerprint_request(model: Optional[str], messages: Any) -> str:
64
+ """Short stable fingerprint of an LLM request (model + messages).
65
+
66
+ Sampling params (temperature, seed, ...) are deliberately excluded:
67
+ replay matching should survive them changing.
68
+ """
69
+ payload = canonical_json({"model": model, "messages": messages})
70
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
71
+
72
+
73
+ def make_header(
74
+ run_id: str, agent: str, metadata: Optional[Dict[str, Any]] = None
75
+ ) -> Dict[str, Any]:
76
+ return {
77
+ "kind": HEADER,
78
+ "schema": SCHEMA_VERSION,
79
+ "run_id": run_id,
80
+ "agent": agent,
81
+ "created_at": time.time(),
82
+ "backspin_version": backspin_version(),
83
+ "metadata": dict(metadata or {}),
84
+ }
85
+
86
+
87
+ def _token_sums(events: List[Dict[str, Any]]) -> Dict[str, int]:
88
+ prompt = completion = 0
89
+ for e in events:
90
+ usage = e.get("usage") or {}
91
+ prompt += usage.get("prompt_tokens") or 0
92
+ completion += usage.get("completion_tokens") or 0
93
+ return {"prompt_tokens": prompt, "completion_tokens": completion}
94
+
95
+
96
+ @dataclass
97
+ class Run:
98
+ """A loaded run file: header + ordered step events."""
99
+
100
+ header: Dict[str, Any]
101
+ events: List[Dict[str, Any]] = field(default_factory=list)
102
+ path: Optional[str] = None
103
+
104
+ @property
105
+ def run_id(self) -> str:
106
+ return str(self.header.get("run_id", ""))
107
+
108
+ @property
109
+ def agent(self) -> str:
110
+ return str(self.header.get("agent", ""))
111
+
112
+ @property
113
+ def metadata(self) -> Dict[str, Any]:
114
+ return dict(self.header.get("metadata") or {})
115
+
116
+ def by_kind(self, kind: str) -> List[Dict[str, Any]]:
117
+ return [e for e in self.events if e.get("kind") == kind]
118
+
119
+ def llm_calls(self) -> List[Dict[str, Any]]:
120
+ return self.by_kind(LLM)
121
+
122
+ def tool_calls(self) -> List[Dict[str, Any]]:
123
+ return self.by_kind(TOOL)
124
+
125
+ def totals(self) -> Dict[str, Any]:
126
+ tokens = _token_sums(self.events)
127
+ duration = sum(
128
+ e.get("duration_ms") or 0.0
129
+ for e in self.events
130
+ if e.get("kind") != "span" # nested spans would double-count
131
+ )
132
+ from .cost import cost_report # lazy: keeps this module dependency-light
133
+
134
+ cost = cost_report(self)
135
+ return {
136
+ "steps": len(self.events),
137
+ "llm_calls": len(self.llm_calls()),
138
+ "tool_calls": len(self.tool_calls()),
139
+ **tokens,
140
+ "total_tokens": tokens["prompt_tokens"] + tokens["completion_tokens"],
141
+ "duration_ms": round(duration, 1),
142
+ "cost_usd": cost["total_usd"],
143
+ "cost_complete": cost["complete"],
144
+ }
145
+
146
+ def summary(self) -> Dict[str, Any]:
147
+ """Everything list views (CLI / viewer) need about a run."""
148
+ return {
149
+ "name": os.path.basename(self.path) if self.path else self.run_id,
150
+ "run_id": self.run_id,
151
+ "agent": self.agent,
152
+ "created_at": self.header.get("created_at"),
153
+ "backspin_version": self.header.get("backspin_version"),
154
+ "metadata": self.metadata,
155
+ "totals": self.totals(),
156
+ }
157
+
158
+
159
+ def load_run(path: str) -> Run:
160
+ """Load and validate a ``*.backspin.jsonl`` run file."""
161
+ header: Optional[Dict[str, Any]] = None
162
+ events: List[Dict[str, Any]] = []
163
+ with open(path, encoding="utf-8") as f:
164
+ for lineno, line in enumerate(f, 1):
165
+ line = line.strip()
166
+ if not line:
167
+ continue
168
+ try:
169
+ ev = json.loads(line)
170
+ except json.JSONDecodeError as exc:
171
+ raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
172
+ if not isinstance(ev, dict) or "kind" not in ev:
173
+ raise ValueError(f"{path}:{lineno}: event must be an object with a 'kind'")
174
+ if header is None:
175
+ if ev["kind"] != HEADER:
176
+ raise ValueError(f"{path}: first event must be a header")
177
+ header = ev
178
+ else:
179
+ events.append(ev)
180
+ if header is None:
181
+ raise ValueError(f"{path}: empty run file")
182
+ return Run(header=header, events=events, path=os.path.abspath(path))
backspin/server.py ADDED
@@ -0,0 +1,91 @@
1
+ """Local viewer: a small FastAPI app that serves the timeline UI.
2
+
3
+ Read-only over the runs directory: the API only ever exposes files that
4
+ are plain names directly inside the resolved runs dir, and never serves
5
+ anything outside it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.responses import JSONResponse
16
+
17
+ from .diff import diff_runs
18
+ from .runfile import FILE_SUFFIX, load_run
19
+
20
+ UI_DIR = Path(__file__).resolve().parent / "ui"
21
+
22
+
23
+ def _resolve_runs_dir(runs_dir: str) -> Path:
24
+ root = Path(os.path.realpath(os.path.expanduser(runs_dir)))
25
+ if not root.is_dir():
26
+ raise NotADirectoryError(f"runs directory not found: {runs_dir}")
27
+ return root
28
+
29
+
30
+ def _safe_run_path(root: Path, name: str) -> Path:
31
+ """Only bare file names directly inside ``root`` are ever readable."""
32
+ if (
33
+ not name.endswith(FILE_SUFFIX)
34
+ or "/" in name
35
+ or "\\" in name
36
+ or name in (".", "..")
37
+ ):
38
+ raise HTTPException(status_code=404, detail="no such run")
39
+ path = Path(os.path.realpath(root / name))
40
+ if os.path.dirname(str(path)) != str(root):
41
+ raise HTTPException(status_code=404, detail="no such run")
42
+ if not path.is_file():
43
+ raise HTTPException(status_code=404, detail="no such run")
44
+ return path
45
+
46
+
47
+ def create_app(runs_dir: str = "runs") -> FastAPI:
48
+ app = FastAPI(title="backspin viewer", docs_url=None, redoc_url=None)
49
+ root = _resolve_runs_dir(runs_dir)
50
+
51
+ @app.get("/api/runs")
52
+ def list_runs() -> List[Dict[str, Any]]:
53
+ runs = []
54
+ for name in sorted(os.listdir(root), reverse=True):
55
+ if not name.endswith(FILE_SUFFIX):
56
+ continue
57
+ try:
58
+ runs.append(load_run(str(root / name)).summary())
59
+ except ValueError:
60
+ runs.append({"name": name, "agent": "?", "invalid": True})
61
+ return runs
62
+
63
+ @app.get("/api/run/{name}")
64
+ def get_run(name: str) -> Dict[str, Any]:
65
+ path = _safe_run_path(root, name)
66
+ try:
67
+ run = load_run(str(path))
68
+ except ValueError as exc:
69
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
70
+ data = run.summary()
71
+ data["events"] = run.events
72
+ return data
73
+
74
+ @app.get("/api/diff")
75
+ def diff(a: str, b: str) -> Dict[str, Any]:
76
+ run_a = load_run(str(_safe_run_path(root, a)))
77
+ run_b = load_run(str(_safe_run_path(root, b)))
78
+ return diff_runs(run_a, run_b).to_dict()
79
+
80
+ @app.exception_handler(NotADirectoryError)
81
+ def bad_dir(_req, exc: NotADirectoryError):
82
+ return JSONResponse(status_code=500, content={"detail": str(exc)})
83
+
84
+ app.mount("/", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
85
+ return app
86
+
87
+
88
+ def serve(runs_dir: str = "runs", host: str = "127.0.0.1", port: int = 8787) -> None:
89
+ import uvicorn
90
+
91
+ uvicorn.run(create_app(runs_dir), host=host, port=port, log_level="warning")
backspin/share.py ADDED
@@ -0,0 +1,58 @@
1
+ """Single-file HTML sharing: one run + the whole viewer in one .html file.
2
+
3
+ ``build_share_html`` inlines the viewer's CSS/JS and the run's data, so a
4
+ teammate can open the file in any browser and step through the run — no
5
+ server, no install, no data leaves the file.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ from .runfile import load_run
14
+
15
+ _UI_DIR = Path(__file__).resolve().parent / "ui"
16
+
17
+
18
+ def _safe_json(obj: Any) -> str:
19
+ """JSON that is safe to embed inside a <script> block."""
20
+ return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
21
+
22
+
23
+ def build_share_html(run_path: str, title: Optional[str] = None) -> str:
24
+ run = load_run(run_path)
25
+ data = run.summary()
26
+ data["events"] = run.events
27
+
28
+ html = (_UI_DIR / "index.html").read_text(encoding="utf-8")
29
+ css = (_UI_DIR / "style.css").read_text(encoding="utf-8")
30
+ i18n = (_UI_DIR / "i18n.js").read_text(encoding="utf-8")
31
+ js = (_UI_DIR / "app.js").read_text(encoding="utf-8")
32
+
33
+ html = html.replace(
34
+ '<link rel="stylesheet" href="style.css">',
35
+ "<style>\n" + css + "\n</style>",
36
+ )
37
+ # i18n.js must be inlined before app.js (app.js calls its applyI18n/t).
38
+ embed = (
39
+ "<script>\n" + i18n + "\n</script>\n"
40
+ "<script>window.__BACKSPIN_EMBED__ = " + _safe_json(data) + ";</script>\n"
41
+ "<script>\n" + js + "\n</script>"
42
+ )
43
+ html = html.replace('<script src="i18n.js"></script>', "")
44
+ html = html.replace('<script src="app.js"></script>', embed)
45
+ if title:
46
+ html = html.replace("<title>backspin — agent 运行查看器</title>",
47
+ "<title>" + title + "</title>")
48
+ return html
49
+
50
+
51
+ def write_share_html(run_path: str, out_path: Optional[str] = None) -> str:
52
+ """Build the share file; returns the written path."""
53
+ run = load_run(run_path)
54
+ if out_path is None:
55
+ out_path = str(Path(run_path).with_suffix(".share.html"))
56
+ html = build_share_html(run_path, title=f"backspin — {run.agent}")
57
+ Path(out_path).write_text(html, encoding="utf-8")
58
+ return out_path