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.
- bub_walkman/__init__.py +8 -0
- bub_walkman/__main__.py +3 -0
- bub_walkman/cli.py +122 -0
- bub_walkman/plugin.py +14 -0
- bub_walkman/server.py +195 -0
- bub_walkman/static/assets/index-D3VvaBq6.js +46 -0
- bub_walkman/static/assets/index-GMnA6YXK.css +1 -0
- bub_walkman/static/favicon.svg +13 -0
- bub_walkman/static/index.html +14 -0
- bub_walkman/tapes.py +270 -0
- bub_walkman-0.1.0.dist-info/METADATA +120 -0
- bub_walkman-0.1.0.dist-info/RECORD +15 -0
- bub_walkman-0.1.0.dist-info/WHEEL +4 -0
- bub_walkman-0.1.0.dist-info/entry_points.txt +5 -0
- bub_walkman-0.1.0.dist-info/licenses/LICENSE +202 -0
bub_walkman/__init__.py
ADDED
|
@@ -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"
|
bub_walkman/__main__.py
ADDED
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
|