bub-walkman 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.
@@ -0,0 +1,8 @@
1
+ """Render Bub tapes as an agent timeline in the browser."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("bub-walkman")
7
+ except PackageNotFoundError: # running from a source tree that is not installed
8
+ __version__ = "0.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
bub_walkman/cli.py ADDED
@@ -0,0 +1,122 @@
1
+ """bub-walkman <tape>: open a Bub tape as an agent timeline in the browser.
2
+
3
+ The same command is available as `bub walkman <tape>` when Bub is installed (see plugin.py).
4
+ """
5
+
6
+ import os
7
+ import socket
8
+ import threading
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from types import FrameType
12
+ from typing import Annotated, override
13
+ from urllib.parse import quote
14
+
15
+ import typer
16
+
17
+ from . import __version__
18
+
19
+
20
+ def tapes_dir() -> Path:
21
+ """Bub's tape directory: ``bub.home / "tapes"`` when Bub is installed, else ``$BUB_HOME/tapes`` (default ~/.bub)."""
22
+ try:
23
+ import bub
24
+
25
+ return Path(bub.home) / "tapes"
26
+ except Exception:
27
+ return Path(os.environ.get("BUB_HOME", "~/.bub")).expanduser() / "tapes"
28
+
29
+
30
+ def resolve_tape(tape: Path) -> Path | None:
31
+ """A tape file path, or the name of a tape in Bub's tape directory (with or without `.jsonl`)."""
32
+ candidates = [tape.expanduser()]
33
+ if not tape.is_absolute() and len(tape.parts) == 1:
34
+ directory = tapes_dir()
35
+ candidates += [directory / tape.name, directory / f"{tape.name}.jsonl"]
36
+ return next((c.resolve() for c in candidates if c.is_file()), None)
37
+
38
+
39
+ def _bind(host: str, port: int) -> socket.socket:
40
+ family = socket.AF_INET6 if ":" in host else socket.AF_INET
41
+ for candidate in (port, 0):
42
+ sock = socket.socket(family, socket.SOCK_STREAM)
43
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
44
+ try:
45
+ sock.bind((host, candidate))
46
+ return sock
47
+ except OSError:
48
+ sock.close()
49
+ if candidate == 0:
50
+ raise
51
+ raise AssertionError("unreachable")
52
+
53
+
54
+ def _version(value: bool) -> None:
55
+ if value:
56
+ typer.echo(f"bub-walkman {__version__}")
57
+ raise typer.Exit()
58
+
59
+
60
+ def walkman(
61
+ tape: Annotated[
62
+ Path | None,
63
+ typer.Argument(
64
+ help="Tape .jsonl file, or the name of a tape in Bub's tape directory. Without one, the page lists the tapes "
65
+ "(running ones first) to pick from.",
66
+ show_default=False,
67
+ ),
68
+ ] = None,
69
+ host: Annotated[str, typer.Option(help="Bind address.")] = "127.0.0.1",
70
+ port: Annotated[int, typer.Option(help="Port; falls back to a free one when taken.")] = 7357,
71
+ open_browser: Annotated[bool, typer.Option("--open/--no-open", help="Open the page in a browser.")] = True,
72
+ version: Annotated[
73
+ bool | None, typer.Option("--version", callback=_version, is_eager=True, help="Show the version and exit.")
74
+ ] = None,
75
+ ) -> None:
76
+ """Render a Bub tape as an agent timeline in the browser. Live following and replay are controlled from the web page."""
77
+ import uvicorn
78
+
79
+ from .server import create_app
80
+
81
+ directory = tapes_dir()
82
+ path = None
83
+ if tape is not None:
84
+ path = resolve_tape(tape)
85
+ if path is None:
86
+ typer.echo(f"bub-walkman: tape not found: {tape} (also looked in {directory})", err=True)
87
+ raise typer.Exit(2)
88
+
89
+ sock = _bind(host, port)
90
+ bound = sock.getsockname()[1]
91
+ shown = "localhost" if host in ("127.0.0.1", "0.0.0.0", "::") else host
92
+ url = f"http://{shown}:{bound}/" + (f"?tape={quote(path.stem)}" if path else "")
93
+ typer.echo(f"bub-walkman {path.name if path else directory}\n {url}\n Ctrl+C to stop")
94
+
95
+ app = create_app(directory, path)
96
+
97
+ class Server(uvicorn.Server):
98
+ @override
99
+ def handle_exit(self, sig: int, frame: FrameType | None) -> None:
100
+ # End open live streams first; otherwise graceful shutdown waits on them until it times out.
101
+ app.state.close_streams()
102
+ super().handle_exit(sig, frame)
103
+
104
+ server = Server(uvicorn.Config(app, log_level="warning", timeout_graceful_shutdown=2))
105
+ if open_browser:
106
+ threading.Timer(0.4, webbrowser.open, args=(url,)).start()
107
+ try:
108
+ server.run(sockets=[sock])
109
+ except KeyboardInterrupt:
110
+ pass
111
+
112
+
113
+ app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
114
+ app.command()(walkman)
115
+
116
+
117
+ def main() -> None:
118
+ app(prog_name="bub-walkman")
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
bub_walkman/plugin.py ADDED
@@ -0,0 +1,14 @@
1
+ """Bub plugin: adds `bub walkman <tape>` when bub-walkman is installed next to Bub.
2
+
3
+ Registered through the `bub` entry-point group; Bub only imports this module, so the `bub` import is safe.
4
+ """
5
+
6
+ import typer
7
+ from bub import hookimpl
8
+
9
+
10
+ @hookimpl
11
+ def register_cli_commands(app: typer.Typer) -> None:
12
+ from .cli import walkman
13
+
14
+ app.command("walkman")(walkman)
bub_walkman/server.py ADDED
@@ -0,0 +1,195 @@
1
+ """Starlette app serving the tapes of a directory: static UI, tape list, JSON snapshots and SSE tail streams."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from starlette.applications import Starlette
10
+ from starlette.requests import Request
11
+ from starlette.responses import (
12
+ FileResponse,
13
+ JSONResponse,
14
+ PlainTextResponse,
15
+ Response,
16
+ StreamingResponse,
17
+ )
18
+ from starlette.routing import Mount, Route
19
+ from starlette.staticfiles import StaticFiles
20
+ from watchfiles import awatch
21
+
22
+ from .tapes import TapeDirectory, TapeReader, read_all, read_spill, sidecar_path
23
+
24
+ STATIC_DIR = Path(__file__).parent / "static"
25
+ HEARTBEAT_INTERVAL = 15.0
26
+ FALLBACK_POLL = 2.0
27
+
28
+
29
+ def tape_info(path: Path) -> dict[str, Any]:
30
+ try:
31
+ st = path.stat()
32
+ size, mtime = st.st_size, st.st_mtime
33
+ except OSError:
34
+ size, mtime = 0, 0.0
35
+ return {"name": path.stem, "path": str(path), "size": size, "mtime": mtime}
36
+
37
+
38
+ def sse(event: str, data: Any) -> str:
39
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
40
+
41
+
42
+ async def _changes(path: Path, stop: asyncio.Event) -> AsyncIterator[None]:
43
+ """Yield whenever ``path`` may have changed: on filesystem events, or every few seconds."""
44
+ watcher = awatch(
45
+ path.parent,
46
+ stop_event=stop,
47
+ debounce=50,
48
+ step=50,
49
+ rust_timeout=int(FALLBACK_POLL * 1000),
50
+ yield_on_timeout=True,
51
+ watch_filter=lambda _, p: Path(p).name == path.name,
52
+ )
53
+ async for _ in watcher:
54
+ yield None
55
+
56
+
57
+ def create_app(tapes_dir: Path, tape: Path | None = None) -> Starlette:
58
+ """Serve the tapes in ``tapes_dir`` (plus ``tape``, which may live elsewhere). API calls pick one with ``?tape=<name>``."""
59
+ directory = TapeDirectory(tapes_dir, tape)
60
+ home = str(Path.home())
61
+ shown_dir = "~" + str(tapes_dir)[len(home) :] if str(tapes_dir).startswith(home + "/") else str(tapes_dir)
62
+
63
+ def pick(request: Request) -> Path | Response:
64
+ name = request.query_params.get("tape")
65
+ if not name:
66
+ return JSONResponse({"error": "missing ?tape=<name>"}, 400)
67
+ path = directory.resolve(name)
68
+ return path if path is not None else JSONResponse({"error": f"tape not found: {name}"}, 404)
69
+
70
+ # Stop events of open SSE streams; set them all on shutdown so streams end and the server exits promptly.
71
+ streams: set[asyncio.Event] = set()
72
+
73
+ async def index(request: Request) -> Response:
74
+ page = STATIC_DIR / "index.html"
75
+ if not page.is_file():
76
+ return PlainTextResponse("web UI is not built: run `pnpm -C web build`", 500)
77
+ return FileResponse(page, headers={"Cache-Control": "no-store"})
78
+
79
+ async def favicon(request: Request) -> Response:
80
+ icon = STATIC_DIR / "favicon.svg"
81
+ if not icon.is_file():
82
+ return PlainTextResponse("not found", 404)
83
+ return FileResponse(icon, media_type="image/svg+xml", headers={"Cache-Control": "max-age=86400"})
84
+
85
+ async def tapes(request: Request) -> Response:
86
+ items = await asyncio.to_thread(directory.summaries)
87
+ return JSONResponse({"directory": shown_dir, "tapes": items}, headers={"Cache-Control": "no-store"})
88
+
89
+ async def delete(request: Request) -> Response:
90
+ # Deleting files is only for the person at this machine: a loopback client, our own page (not a page from
91
+ # another site, nor a DNS-rebound host name), and a custom header that a plain cross-site form cannot send.
92
+ client = request.client.host if request.client else ""
93
+ origin = request.headers.get("origin")
94
+ host = request.headers.get("host", "")
95
+ if (
96
+ client not in ("127.0.0.1", "::1")
97
+ or request.headers.get("x-walkman") != "1"
98
+ or (origin is not None and origin.split("://", 1)[-1] != host)
99
+ or host.rsplit(":", 1)[0].strip("[]") not in ("localhost", "127.0.0.1", "::1")
100
+ ):
101
+ return JSONResponse({"error": "forbidden"}, 403)
102
+ try:
103
+ body = await request.json()
104
+ names = [str(n) for n in body["names"]]
105
+ except (ValueError, KeyError, TypeError):
106
+ return JSONResponse({"error": 'expected {"names": [...], "force": false}'}, 400)
107
+ deleted, errors = await asyncio.to_thread(directory.delete, names, bool(body.get("force")))
108
+ return JSONResponse({"deleted": deleted, "errors": errors}, 200 if not errors else 409)
109
+
110
+ async def snapshot(request: Request) -> Response:
111
+ tape = pick(request)
112
+ if isinstance(tape, Response):
113
+ return tape
114
+ entries, offset = await asyncio.to_thread(read_all, tape)
115
+ return JSONResponse({"tape": tape_info(tape), "entries": entries, "offset": offset})
116
+
117
+ async def spill(request: Request) -> Response:
118
+ tape = pick(request)
119
+ if isinstance(tape, Response):
120
+ return tape
121
+ handle = request.path_params["handle"]
122
+ if not handle.isalnum():
123
+ return JSONResponse({"error": "bad handle"}, 400)
124
+ path = sidecar_path(tape, "spill")
125
+ if not path.is_file():
126
+ return JSONResponse({"error": "spill sidecar not found"}, 404)
127
+ data = await asyncio.to_thread(read_spill, path, handle)
128
+ if not data["chunks"]:
129
+ return JSONResponse({"error": "spill handle not found (sidecar may have been reset)"}, 404)
130
+ return JSONResponse(data)
131
+
132
+ async def stream(request: Request) -> Response:
133
+ picked = pick(request)
134
+ if isinstance(picked, Response):
135
+ return picked
136
+ tape = picked
137
+ offset = int(request.query_params.get("offset") or 0)
138
+
139
+ async def events() -> AsyncIterator[str]:
140
+ reader = TapeReader(tape)
141
+ try:
142
+ st = tape.stat()
143
+ except OSError:
144
+ st = None
145
+ if st is not None and 0 < offset <= st.st_size:
146
+ reader.resume(offset, st) # continue right after the client's snapshot
147
+ elif offset:
148
+ yield sse("reset", {"offset": 0})
149
+ yield sse("hello", {"tape": tape_info(tape), "offset": reader.offset})
150
+
151
+ stop = asyncio.Event()
152
+ streams.add(stop)
153
+ loop = asyncio.get_running_loop()
154
+ last_beat = loop.time()
155
+ try:
156
+ async for _ in _changes(tape, stop):
157
+ if await request.is_disconnected():
158
+ break
159
+ reset, entries = await asyncio.to_thread(reader.poll)
160
+ if reset:
161
+ yield sse("reset", {"offset": 0})
162
+ if entries:
163
+ yield sse("entries", {"entries": entries, "offset": reader.offset})
164
+ last_beat = loop.time()
165
+ elif loop.time() - last_beat > HEARTBEAT_INTERVAL:
166
+ yield ": beat\n\n"
167
+ last_beat = loop.time()
168
+ finally:
169
+ stop.set()
170
+ streams.discard(stop)
171
+
172
+ return StreamingResponse(
173
+ events(),
174
+ media_type="text/event-stream",
175
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
176
+ )
177
+
178
+ app = Starlette(
179
+ routes=[
180
+ Route("/", index),
181
+ Route("/favicon.svg", favicon),
182
+ Route("/api/tapes", tapes),
183
+ Route("/api/tapes/delete", delete, methods=["POST"]),
184
+ Route("/api/tape", snapshot),
185
+ Route("/api/stream", stream),
186
+ Route("/api/spill/{handle}", spill),
187
+ Mount("/assets", StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), name="assets"),
188
+ ]
189
+ )
190
+ def close_streams() -> None:
191
+ for stop in list(streams):
192
+ stop.set()
193
+
194
+ app.state.close_streams = close_streams
195
+ return app