tracelink 0.2.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.
tracelink/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ TraceLink — Python core tracer.
3
+
4
+ Unified debug logging for FastAPI / Starlette apps. Writes to the same
5
+ NDJSON + readable file pair as the TypeScript core, so frontend and
6
+ backend events are correlated by traceId.
7
+
8
+ Quick start:
9
+ from tracelink import debug_tracer
10
+ debug_tracer.start_scope('delete-work')
11
+ debug_tracer.entry('router.py:delete', 'Delete work', {'id': 123}, scope='delete-work')
12
+ debug_tracer.end_scope('delete-work')
13
+
14
+ For automatic scope sync from frontend, also register the middleware:
15
+ from tracelink import TraceMiddleware
16
+ app.add_middleware(TraceMiddleware)
17
+
18
+ Layer system — see docs/LAYERS.md (English) / docs/LAYERS.zh-CN.md (Chinese)
19
+ for the rules on custom X-* layers.
20
+ """
21
+
22
+ from .tracer import DebugTracer, debug_tracer
23
+ from .types import TraceLayer
24
+ from .middleware import TraceMiddleware
25
+ from .types import (
26
+ BUILTIN_LAYERS,
27
+ is_builtin_layer,
28
+ is_custom_layer,
29
+ normalize_layer,
30
+ )
31
+
32
+ __version__ = "0.2.0"
33
+
34
+ __all__ = [
35
+ "DebugTracer",
36
+ "debug_tracer",
37
+ "TraceMiddleware",
38
+ "TraceLayer",
39
+ "BUILTIN_LAYERS",
40
+ "is_builtin_layer",
41
+ "is_custom_layer",
42
+ "normalize_layer",
43
+ "__version__",
44
+ ]
tracelink/_paths.py ADDED
@@ -0,0 +1,41 @@
1
+ """Resolve log file paths from env / project root."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def resolve_log_dir() -> Path:
8
+ """Resolve the project root where .cursor/ and .RandP/ live.
9
+
10
+ Priority:
11
+ 1. TRACELINK_DIR env (absolute path)
12
+ 2. Current working directory
13
+ 3. Walk up from cwd until we find a package.json or pyproject.toml
14
+ """
15
+ env_dir = os.getenv("TRACELINK_DIR")
16
+ if env_dir:
17
+ return Path(env_dir)
18
+
19
+ cwd = Path.cwd()
20
+ # Walk up looking for project markers
21
+ for parent in [cwd, *cwd.parents]:
22
+ if any((parent / m).exists() for m in ("package.json", "pyproject.toml", "Cargo.toml", "go.mod")):
23
+ return parent
24
+
25
+ return cwd
26
+
27
+
28
+ def cursor_dir() -> Path:
29
+ return resolve_log_dir() / ".cursor"
30
+
31
+
32
+ def readable_dir() -> Path:
33
+ return resolve_log_dir() / ".RandP"
34
+
35
+
36
+ def ndjson_path() -> Path:
37
+ return cursor_dir() / "debug.log"
38
+
39
+
40
+ def readable_path() -> Path:
41
+ return readable_dir() / "debug.log"
tracelink/_time.py ADDED
@@ -0,0 +1,27 @@
1
+ """Time and ID helpers."""
2
+
3
+ import os
4
+ import time
5
+ from datetime import datetime
6
+
7
+
8
+ def now_ms() -> int:
9
+ return int(time.time() * 1000)
10
+
11
+
12
+ def format_ts(ts: int | None = None) -> str:
13
+ if ts is None:
14
+ ts = now_ms()
15
+ dt = datetime.fromtimestamp(ts / 1000)
16
+ return f"[{dt.strftime('%H:%M:%S')}.{dt.microsecond // 1000:03d}]"
17
+
18
+
19
+ def make_trace_id(scope: str) -> str:
20
+ """Generate traceId with format `<scope>-<timestamp6>-<rand3>`."""
21
+ ts_suffix = str(now_ms())[-6:]
22
+ rand_suffix = os.urandom(2).hex()[:3]
23
+ return f"{scope}-{ts_suffix}-{rand_suffix}"
24
+
25
+
26
+ def make_span_id(counter: int) -> str:
27
+ return f"span-{counter}"
tracelink/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """TraceLink CLI — top-level `tracelink ...` entry point.
2
+
3
+ Subcommands (v0.3):
4
+ board Launch the dev-time 看板 (per docs/03-prd.md §10).
5
+
6
+ Usage:
7
+ tracelink board [--port 8766] [--host 127.0.0.1] [--open]
8
+ python -m tracelink board
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from typing import List, Optional
16
+
17
+
18
+ def _cmd_board(args: argparse.Namespace) -> int:
19
+ # Lazy import — keeps `tracelink --help` cheap and lets the dashboard
20
+ # module stay self-contained.
21
+ from .dashboard import serve_board
22
+
23
+ return serve_board(
24
+ host=args.host,
25
+ port=args.port,
26
+ auto_open=args.open,
27
+ )
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="tracelink",
33
+ description="TraceLink dev-time CLI (v0.3+)",
34
+ )
35
+ sub = parser.add_subparsers(dest="command", required=True)
36
+
37
+ p_board = sub.add_parser(
38
+ "board",
39
+ help="Launch the dev-time 看板 (per docs/03-prd.md §10).",
40
+ description=(
41
+ "Serve a local browser-based board that reads the project's "
42
+ ".cursor/debug.log and .RandP/debug.log files, lets you switch "
43
+ "scopes at runtime, and filter historical events by scope."
44
+ ),
45
+ )
46
+ p_board.add_argument(
47
+ "--port",
48
+ type=int,
49
+ default=8766,
50
+ help="Port to bind the board server (default: 8766)",
51
+ )
52
+ p_board.add_argument(
53
+ "--host",
54
+ default="127.0.0.1",
55
+ help="Host to bind (default: 127.0.0.1 — localhost only by design)",
56
+ )
57
+ p_board.add_argument(
58
+ "--open",
59
+ action="store_true",
60
+ help="Open the board in your default browser after starting",
61
+ )
62
+ p_board.set_defaults(func=_cmd_board)
63
+
64
+ return parser
65
+
66
+
67
+ def main(argv: Optional[List[str]] = None) -> int:
68
+ parser = build_parser()
69
+ args = parser.parse_args(argv)
70
+ return args.func(args)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())
@@ -0,0 +1,38 @@
1
+ """TraceLink dev-time dashboard (board).
2
+
3
+ Public surface:
4
+ serve_board(...) — start the stdlib HTTP server (CLI uses this)
5
+ fastapi_router — drop-in FastAPI router (embed in your app)
6
+
7
+ The CLI is `tracelink board` (see `tracelink.cli`). The package directory
8
+ is `dashboard/` (architecture name); the user-facing command is `board`
9
+ (product name). This keeps the package aligned with future dashboard
10
+ expansion (analysis, AI summary, etc.) while the CLI stays short.
11
+
12
+ Static UI is bundled at `dashboard/static/`. No build step required.
13
+ """
14
+
15
+ from .server import serve_board, main
16
+ from ._paths_resolve import (
17
+ scope_state_path,
18
+ load_scope_state,
19
+ save_scope_state,
20
+ is_scope_enabled,
21
+ scan_observed_scopes,
22
+ load_ndjson_events,
23
+ )
24
+
25
+ # Optional FastAPI router (None if FastAPI not installed)
26
+ from .api import fastapi_router
27
+
28
+ __all__ = [
29
+ "serve_board",
30
+ "main",
31
+ "fastapi_router",
32
+ "scope_state_path",
33
+ "load_scope_state",
34
+ "save_scope_state",
35
+ "is_scope_enabled",
36
+ "scan_observed_scopes",
37
+ "load_ndjson_events",
38
+ ]
@@ -0,0 +1,102 @@
1
+ """Internal path resolvers for the dashboard.
2
+
3
+ Kept separate from server/api so both can import without circular deps.
4
+ Scope state lives at `.RandP/.scope_state.json` (sibling to debug.log),
5
+ deliberately OUTSIDE the log file pair (per PRD §10.5 — does not modify
6
+ log file schema).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Set
15
+
16
+ from .._paths import ndjson_path, readable_dir
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ SCOPE_STATE_FILENAME = ".scope_state.json"
22
+
23
+
24
+ def scope_state_path() -> Path:
25
+ """Path to the persisted scope state JSON file."""
26
+ return readable_dir() / SCOPE_STATE_FILENAME
27
+
28
+
29
+ def load_scope_state() -> Dict[str, bool]:
30
+ """Read scope state JSON. Empty dict == 'all on' (matches tracer default)."""
31
+ p = scope_state_path()
32
+ if not p.exists():
33
+ return {}
34
+ try:
35
+ return json.loads(p.read_text(encoding="utf-8"))
36
+ except Exception:
37
+ return {}
38
+
39
+
40
+ def save_scope_state(state: Dict[str, bool]) -> None:
41
+ p = scope_state_path()
42
+ p.parent.mkdir(parents=True, exist_ok=True)
43
+ p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
44
+
45
+
46
+ def is_scope_enabled(state: Dict[str, bool], scope: str) -> bool:
47
+ """Mirror of DebugTracer._is_scope_enabled at the persisted layer.
48
+
49
+ Fallback order: explicit scope -> '*' default -> True (all on).
50
+ """
51
+ if scope in state:
52
+ return bool(state[scope])
53
+ if "*" in state:
54
+ return bool(state["*"])
55
+ return True
56
+
57
+
58
+ def scan_observed_scopes() -> List[str]:
59
+ """Walk the NDJSON file once, return unique scope names in encounter order."""
60
+ seen: List[str] = []
61
+ seen_set: Set[str] = set()
62
+ nd = ndjson_path()
63
+ if not nd.exists():
64
+ return seen
65
+ try:
66
+ with nd.open("r", encoding="utf-8") as f:
67
+ for line in f:
68
+ line = line.strip()
69
+ if not line:
70
+ continue
71
+ try:
72
+ obj = json.loads(line)
73
+ except Exception:
74
+ continue
75
+ sc = obj.get("scope")
76
+ if sc and sc not in seen_set:
77
+ seen_set.add(sc)
78
+ seen.append(sc)
79
+ except Exception as e:
80
+ logger.debug("scan_observed_scopes failed: %s", e)
81
+ return seen
82
+
83
+
84
+ def load_ndjson_events() -> List[Dict[str, Any]]:
85
+ """Load every parseable event from the NDJSON file (used for tests)."""
86
+ nd = ndjson_path()
87
+ if not nd.exists():
88
+ return []
89
+ out: List[Dict[str, Any]] = []
90
+ try:
91
+ with nd.open("r", encoding="utf-8") as f:
92
+ for line in f:
93
+ line = line.strip()
94
+ if not line:
95
+ continue
96
+ try:
97
+ out.append(json.loads(line))
98
+ except Exception:
99
+ continue
100
+ except Exception:
101
+ return []
102
+ return out
@@ -0,0 +1,107 @@
1
+ """FastAPI router for the TraceLink dashboard.
2
+
3
+ Same endpoints as `server.py` — drop into a FastAPI app for projects that
4
+ already have one running. The standalone `tracelink board` CLI uses the
5
+ stdlib server in `server.py`, not this router.
6
+
7
+ Usage:
8
+ from fastapi import FastAPI
9
+ from tracelink.dashboard import fastapi_router
10
+
11
+ app = FastAPI()
12
+ app.include_router(fastapi_router)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any
19
+
20
+ from .._paths import cursor_dir, ndjson_path, readable_dir, readable_path
21
+ from ._paths_resolve import (
22
+ is_scope_enabled,
23
+ load_scope_state,
24
+ save_scope_state,
25
+ scan_observed_scopes,
26
+ scope_state_path,
27
+ )
28
+
29
+
30
+ fastapi_router = None
31
+ try:
32
+ from fastapi import APIRouter, Request, Response
33
+
34
+ fastapi_router = APIRouter(prefix="/__board/api", tags=["tracelink-board"])
35
+
36
+ @fastapi_router.get("/logs")
37
+ async def _logs(request: Request) -> Response:
38
+ fmt = request.query_params.get("format", "ndjson").lower()
39
+ if fmt == "readable":
40
+ rd = readable_path()
41
+ content = rd.read_text(encoding="utf-8") if rd.exists() else ""
42
+ return Response(content, media_type="text/plain; charset=utf-8")
43
+ nd = ndjson_path()
44
+ content = nd.read_text(encoding="utf-8") if nd.exists() else ""
45
+ return Response(content, media_type="application/x-ndjson; charset=utf-8")
46
+
47
+ @fastapi_router.get("/scopes")
48
+ async def _scopes() -> Response:
49
+ observed = scan_observed_scopes()
50
+ state = load_scope_state()
51
+ projected = {sc: is_scope_enabled(state, sc) for sc in observed}
52
+ projected["*"] = is_scope_enabled(state, "*")
53
+ return Response(
54
+ json.dumps({"scopes": projected, "observed": observed}, ensure_ascii=False),
55
+ media_type="application/json; charset=utf-8",
56
+ )
57
+
58
+ @fastapi_router.get("/paths")
59
+ async def _paths() -> Response:
60
+ return Response(
61
+ json.dumps({
62
+ "cursor_dir": str(cursor_dir()),
63
+ "readable_dir": str(readable_dir()),
64
+ "ndjson_path": str(ndjson_path()),
65
+ "readable_path": str(readable_path()),
66
+ "scope_state": str(scope_state_path()),
67
+ }, ensure_ascii=False),
68
+ media_type="application/json; charset=utf-8",
69
+ )
70
+
71
+ @fastapi_router.post("/scopes")
72
+ async def _set_scope(request: Request) -> Response:
73
+ try:
74
+ payload: Any = await request.json()
75
+ except Exception:
76
+ return Response('{"error":"bad json"}', status_code=400,
77
+ media_type="application/json")
78
+ scope = payload.get("scope")
79
+ enabled = payload.get("enabled")
80
+ if not isinstance(scope, str) or not isinstance(enabled, bool):
81
+ return Response('{"error":"bad payload"}', status_code=400,
82
+ media_type="application/json")
83
+ state = load_scope_state()
84
+ state[scope] = enabled
85
+ save_scope_state(state)
86
+ try:
87
+ from ..tracer import debug_tracer
88
+ if enabled:
89
+ debug_tracer.enable_scope(scope)
90
+ else:
91
+ debug_tracer.disable_scope(scope)
92
+ except Exception:
93
+ pass
94
+ return Response('{"ok":true}', media_type="application/json")
95
+
96
+ @fastapi_router.delete("/logs")
97
+ async def _clear() -> Response:
98
+ for p in (ndjson_path(), readable_path()):
99
+ try:
100
+ if p.exists():
101
+ p.unlink()
102
+ except Exception:
103
+ pass
104
+ return Response(status_code=204)
105
+
106
+ except ImportError:
107
+ pass
@@ -0,0 +1,248 @@
1
+ """Stdlib HTTP server for the TraceLink dashboard.
2
+
3
+ Used by the `tracelink board` CLI. Endpoints:
4
+
5
+ GET /__board/api/logs?format=ndjson|readable
6
+ GET /__board/api/scopes
7
+ GET /__board/api/paths
8
+ POST /__board/api/scopes {scope, enabled}
9
+ DELETE /__board/api/logs
10
+
11
+ GET /__board/... static UI (index.html, app.js, style.css)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import logging
19
+ import sys
20
+ import threading
21
+ import webbrowser
22
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+ from urllib.parse import parse_qs, urlparse
26
+
27
+ from .._paths import cursor_dir, ndjson_path, readable_dir, readable_path
28
+ from ._paths_resolve import (
29
+ is_scope_enabled,
30
+ load_ndjson_events,
31
+ load_scope_state,
32
+ save_scope_state,
33
+ scan_observed_scopes,
34
+ scope_state_path,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ API_PREFIX = "/__board/api"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Static file serving (sandboxed to dashboard/static/)
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _static_root() -> Path:
47
+ return Path(__file__).parent / "static"
48
+
49
+
50
+ def _resolve_static_path(url_path: str) -> Optional[Path]:
51
+ """Map '/' -> static/index.html, '/app.js' -> static/app.js. Block traversal."""
52
+ rel = url_path.lstrip("/")
53
+ if rel in ("", "/"):
54
+ rel = "index.html"
55
+ root = _static_root().resolve()
56
+ target = (root / rel).resolve()
57
+ try:
58
+ target.relative_to(root)
59
+ except ValueError:
60
+ return None # path traversal attempt
61
+ return target if target.is_file() else None
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Handler
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def make_handler() -> type:
69
+ class BoardHandler(BaseHTTPRequestHandler):
70
+ # Quieter logs (override for full tracing if needed)
71
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
72
+ return
73
+
74
+ # ----- helpers -----
75
+ def _send_json(self, code: int, body: Any) -> None:
76
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
77
+ self.send_response(code)
78
+ self.send_header("Content-Type", "application/json; charset=utf-8")
79
+ self.send_header("Content-Length", str(len(data)))
80
+ self.send_header("Cache-Control", "no-store")
81
+ self.end_headers()
82
+ self.wfile.write(data)
83
+
84
+ def _send_bytes(self, code: int, body: bytes, ctype: str) -> None:
85
+ self.send_response(code)
86
+ self.send_header("Content-Type", ctype)
87
+ self.send_header("Content-Length", str(len(body)))
88
+ self.send_header("Cache-Control", "no-store")
89
+ self.end_headers()
90
+ self.wfile.write(body)
91
+
92
+ # ----- GET -----
93
+ def do_GET(self) -> None: # noqa: N802
94
+ if self.path.startswith(f"{API_PREFIX}/logs"):
95
+ qs = parse_qs(urlparse(self.path).query)
96
+ fmt = (qs.get("format", ["ndjson"])[0] or "ndjson").lower()
97
+ if fmt == "readable":
98
+ rd = readable_path()
99
+ content = rd.read_text(encoding="utf-8") if rd.exists() else ""
100
+ self._send_bytes(200, content.encode("utf-8"),
101
+ "text/plain; charset=utf-8")
102
+ else:
103
+ nd = ndjson_path()
104
+ content = nd.read_text(encoding="utf-8") if nd.exists() else ""
105
+ self._send_bytes(200, content.encode("utf-8"),
106
+ "application/x-ndjson; charset=utf-8")
107
+ return
108
+
109
+ if self.path == f"{API_PREFIX}/scopes":
110
+ observed = scan_observed_scopes()
111
+ state = load_scope_state()
112
+ projected: Dict[str, bool] = {sc: is_scope_enabled(state, sc) for sc in observed}
113
+ projected["*"] = is_scope_enabled(state, "*")
114
+ self._send_json(200, {"scopes": projected, "observed": observed})
115
+ return
116
+
117
+ if self.path == f"{API_PREFIX}/paths":
118
+ self._send_json(200, {
119
+ "cursor_dir": str(cursor_dir()),
120
+ "readable_dir": str(readable_dir()),
121
+ "ndjson_path": str(ndjson_path()),
122
+ "readable_path": str(readable_path()),
123
+ "scope_state": str(scope_state_path()),
124
+ })
125
+ return
126
+
127
+ # ----- static UI -----
128
+ static = _resolve_static_path(self.path)
129
+ if static is None:
130
+ self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
131
+ return
132
+ ctype = (
133
+ "text/html; charset=utf-8" if static.suffix == ".html" else
134
+ "application/javascript; charset=utf-8" if static.suffix == ".js" else
135
+ "text/css; charset=utf-8" if static.suffix == ".css" else
136
+ "application/octet-stream"
137
+ )
138
+ try:
139
+ self._send_bytes(200, static.read_bytes(), ctype)
140
+ except FileNotFoundError:
141
+ self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
142
+
143
+ # ----- POST -----
144
+ def do_POST(self) -> None: # noqa: N802
145
+ if self.path != f"{API_PREFIX}/scopes":
146
+ self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
147
+ return
148
+ length = int(self.headers.get("Content-Length", "0"))
149
+ raw = self.rfile.read(length).decode("utf-8") if length else ""
150
+ try:
151
+ payload = json.loads(raw) if raw else {}
152
+ except Exception:
153
+ self._send_json(400, {"error": "bad json"})
154
+ return
155
+ scope = payload.get("scope")
156
+ enabled = payload.get("enabled")
157
+ if not isinstance(scope, str) or not isinstance(enabled, bool):
158
+ self._send_json(400, {"error": "expected {scope: str, enabled: bool}"})
159
+ return
160
+ state = load_scope_state()
161
+ state[scope] = bool(enabled)
162
+ save_scope_state(state)
163
+ # Best-effort live push to in-process tracer
164
+ try:
165
+ from ..tracer import debug_tracer
166
+ if enabled:
167
+ debug_tracer.enable_scope(scope)
168
+ else:
169
+ debug_tracer.disable_scope(scope)
170
+ except Exception:
171
+ pass
172
+ self._send_json(200, {"ok": True, "scope": scope, "enabled": enabled})
173
+
174
+ # ----- DELETE -----
175
+ def do_DELETE(self) -> None: # noqa: N802
176
+ if self.path != f"{API_PREFIX}/logs":
177
+ self._send_bytes(404, b"not found", "text/plain; charset=utf-8")
178
+ return
179
+ for p in (ndjson_path(), readable_path()):
180
+ try:
181
+ if p.exists():
182
+ p.unlink()
183
+ except Exception as e:
184
+ logger.debug("delete failed: %s", e)
185
+ self.send_response(204)
186
+ self.end_headers()
187
+
188
+ return BoardHandler
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Standalone runner
193
+ # ---------------------------------------------------------------------------
194
+
195
+ def serve_board(
196
+ host: str = "127.0.0.1",
197
+ port: int = 8766,
198
+ auto_open: bool = False,
199
+ ) -> int:
200
+ """Start the dashboard server. Blocks until KeyboardInterrupt."""
201
+ if not _static_root().is_dir():
202
+ print(
203
+ f"[tracelink.dashboard] ERROR: static dir not found: {_static_root()}\n"
204
+ " → Did the dashboard/static/ folder ship with the package?",
205
+ file=sys.stderr,
206
+ )
207
+ return 2
208
+ handler = make_handler()
209
+ try:
210
+ server = ThreadingHTTPServer((host, port), handler)
211
+ except OSError as e:
212
+ print(f"[tracelink.dashboard] bind {host}:{port} failed: {e}", file=sys.stderr)
213
+ return 1
214
+
215
+ url = f"http://{host}:{port}/"
216
+ print(
217
+ f"[tracelink.dashboard] listening on {url}\n"
218
+ f" ndjson : {ndjson_path()}\n"
219
+ f" readable : {readable_path()}\n"
220
+ f" state : {scope_state_path()}\n"
221
+ " press Ctrl-C to stop.",
222
+ file=sys.stderr,
223
+ )
224
+ if auto_open:
225
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
226
+ try:
227
+ server.serve_forever()
228
+ except KeyboardInterrupt:
229
+ print("\n[tracelink.dashboard] shutting down.", file=sys.stderr)
230
+ finally:
231
+ server.server_close()
232
+ return 0
233
+
234
+
235
+ def main(argv: Optional[List[str]] = None) -> int:
236
+ parser = argparse.ArgumentParser(
237
+ prog="python -m tracelink.dashboard",
238
+ description="TraceLink dev-time dashboard (per docs/03-prd.md §10).",
239
+ )
240
+ parser.add_argument("--port", type=int, default=8766)
241
+ parser.add_argument("--host", default="127.0.0.1")
242
+ parser.add_argument("--open", action="store_true")
243
+ args = parser.parse_args(argv)
244
+ return serve_board(args.host, args.port, args.open)
245
+
246
+
247
+ if __name__ == "__main__":
248
+ sys.exit(main())