scrollback 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.
- scrollback/__init__.py +8 -0
- scrollback/assets/icon-256.png +0 -0
- scrollback/assets/icon.icns +0 -0
- scrollback/cli.py +1139 -0
- scrollback/clipboard.py +34 -0
- scrollback/export.py +293 -0
- scrollback/fts.py +307 -0
- scrollback/highlight.py +128 -0
- scrollback/katexbundle.py +81 -0
- scrollback/launcher_install.py +209 -0
- scrollback/launchers/scrollback.bat +19 -0
- scrollback/launchers/scrollback.command +19 -0
- scrollback/launchers/scrollback.desktop +10 -0
- scrollback/launchers/scrollback.sh +12 -0
- scrollback/mathspan.py +180 -0
- scrollback/minimd.py +205 -0
- scrollback/models.py +135 -0
- scrollback/serialize.py +83 -0
- scrollback/serverconfig.py +66 -0
- scrollback/sources/__init__.py +6 -0
- scrollback/sources/aider.py +244 -0
- scrollback/sources/base.py +117 -0
- scrollback/sources/claudecode.py +631 -0
- scrollback/sources/codex.py +281 -0
- scrollback/sources/opencode.py +357 -0
- scrollback/sources/registry.py +39 -0
- scrollback/store.py +384 -0
- scrollback/termrender.py +170 -0
- scrollback/web/__init__.py +1 -0
- scrollback/web/app.py +359 -0
- scrollback/web/static/app.js +1245 -0
- scrollback/web/static/apple-touch-icon.png +0 -0
- scrollback/web/static/favicon.png +0 -0
- scrollback/web/static/favicon.svg +41 -0
- scrollback/web/static/index.html +75 -0
- scrollback/web/static/style.css +628 -0
- scrollback/web/static/vendor/highlight.min.js +1213 -0
- scrollback/web/static/vendor/hljs-dark.min.css +10 -0
- scrollback/web/static/vendor/hljs-light.min.css +10 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/katex.min.css +1 -0
- scrollback/web/static/vendor/katex/katex.min.js +1 -0
- scrollback/web/static/vendor/marked.min.js +6 -0
- scrollback/web/static/vendor/purify.min.js +3 -0
- scrollback/webopen.py +96 -0
- scrollback-0.1.0.dist-info/METADATA +391 -0
- scrollback-0.1.0.dist-info/RECORD +69 -0
- scrollback-0.1.0.dist-info/WHEEL +4 -0
- scrollback-0.1.0.dist-info/entry_points.txt +4 -0
- scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
scrollback/web/app.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""FastAPI application exposing a read-only JSON API over the Store.
|
|
2
|
+
|
|
3
|
+
Design notes
|
|
4
|
+
------------
|
|
5
|
+
* Strictly read-only: there are no mutating endpoints. The Store and its
|
|
6
|
+
adapters never write to the agents' data.
|
|
7
|
+
* Intended to bind to 127.0.0.1 only (enforced by the `web` CLI command).
|
|
8
|
+
* The frontend is static (HTML/CSS/JS) served from `web/static/`; the
|
|
9
|
+
browser talks to the JSON endpoints below.
|
|
10
|
+
|
|
11
|
+
Endpoints
|
|
12
|
+
---------
|
|
13
|
+
GET /api/sources -> available source adapters
|
|
14
|
+
GET /api/sessions?source&dir&q&limit -> session summaries (newest first)
|
|
15
|
+
GET /api/sessions/{source}/{id} -> full session with messages/parts
|
|
16
|
+
GET /api/search?q&dir&limit -> search hits across sessions
|
|
17
|
+
GET /api/export/{source}/{id}?format&reasoning&tools -> rendered document
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from fastapi import FastAPI, HTTPException, Query, Response
|
|
26
|
+
from fastapi.staticfiles import StaticFiles
|
|
27
|
+
except ModuleNotFoundError as exc: # pragma: no cover - guidance path
|
|
28
|
+
raise SystemExit(
|
|
29
|
+
"The web app needs FastAPI/uvicorn. Install with:\n"
|
|
30
|
+
' pip install "scrollback[web]"\n'
|
|
31
|
+
"or:\n"
|
|
32
|
+
" pip install fastapi uvicorn"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
|
|
39
|
+
from .. import __version__, export
|
|
40
|
+
from ..serialize import message_dict, search_hit, session_detail, session_summary
|
|
41
|
+
from ..store import Store
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_dt(s: str | None) -> datetime | None:
|
|
45
|
+
"""Parse an ISO date/datetime from a query param; None if blank/invalid."""
|
|
46
|
+
if not s:
|
|
47
|
+
return None
|
|
48
|
+
raw = s.strip()
|
|
49
|
+
try:
|
|
50
|
+
if len(raw) == 10:
|
|
51
|
+
return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
52
|
+
iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
|
53
|
+
dt = datetime.fromisoformat(iso)
|
|
54
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
_STATIC_DIR = Path(__file__).parent / "static"
|
|
59
|
+
|
|
60
|
+
# Content types for the export endpoint.
|
|
61
|
+
_MEDIA = {
|
|
62
|
+
"markdown": "text/markdown; charset=utf-8",
|
|
63
|
+
"md": "text/markdown; charset=utf-8",
|
|
64
|
+
"json": "application/json; charset=utf-8",
|
|
65
|
+
"html": "text/html; charset=utf-8",
|
|
66
|
+
"text": "text/plain; charset=utf-8",
|
|
67
|
+
"txt": "text/plain; charset=utf-8",
|
|
68
|
+
}
|
|
69
|
+
_EXT = {"markdown": "md", "md": "md", "json": "json", "html": "html",
|
|
70
|
+
"text": "txt", "txt": "txt"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_app(
|
|
74
|
+
store: Store | None = None,
|
|
75
|
+
*,
|
|
76
|
+
on_idle=None,
|
|
77
|
+
idle_timeout: float = 0.0,
|
|
78
|
+
allowed_hosts: list[str] | None = None,
|
|
79
|
+
):
|
|
80
|
+
"""Build the FastAPI app. A custom Store can be injected for tests.
|
|
81
|
+
|
|
82
|
+
If `idle_timeout` > 0 and `on_idle` is provided, the app runs a watchdog:
|
|
83
|
+
the frontend pings `/api/heartbeat` periodically, and if no ping arrives
|
|
84
|
+
for `idle_timeout` seconds (i.e. the window was closed), `on_idle()` is
|
|
85
|
+
called -- used to auto-stop the server and free the port.
|
|
86
|
+
|
|
87
|
+
`allowed_hosts` guards against DNS-rebinding: requests whose Host header's
|
|
88
|
+
hostname is not in the allowlist are rejected. Defaults to loopback names
|
|
89
|
+
(localhost / 127.0.0.1 / ::1). Pass an explicit list when binding to a
|
|
90
|
+
non-loopback address. `None` => loopback-only; an empty list disables the
|
|
91
|
+
check (not recommended).
|
|
92
|
+
"""
|
|
93
|
+
app = FastAPI(title="scrollback", version=__version__)
|
|
94
|
+
_store = store if store is not None else Store()
|
|
95
|
+
_install_host_guard(app, allowed_hosts)
|
|
96
|
+
|
|
97
|
+
# Translate unexpected source/IO failures (locked/corrupt DB, unreadable
|
|
98
|
+
# files) into a clean 503 instead of a leaked 500 + traceback.
|
|
99
|
+
import sqlite3
|
|
100
|
+
|
|
101
|
+
from fastapi.responses import JSONResponse
|
|
102
|
+
|
|
103
|
+
@app.exception_handler(sqlite3.Error)
|
|
104
|
+
async def _sqlite_error(_request, exc): # pragma: no cover - error path
|
|
105
|
+
return JSONResponse(status_code=503, content={"detail": "data source unavailable"})
|
|
106
|
+
|
|
107
|
+
@app.exception_handler(OSError)
|
|
108
|
+
async def _os_error(_request, exc): # pragma: no cover - error path
|
|
109
|
+
return JSONResponse(status_code=503, content={"detail": "data source unavailable"})
|
|
110
|
+
|
|
111
|
+
watchdog_on = idle_timeout > 0 and on_idle is not None
|
|
112
|
+
if watchdog_on:
|
|
113
|
+
_install_heartbeat_watchdog(app, on_idle, idle_timeout)
|
|
114
|
+
else:
|
|
115
|
+
# Always expose the config endpoint so the frontend can ask once and
|
|
116
|
+
# skip heartbeats when auto-shutdown is not in effect.
|
|
117
|
+
@app.get("/api/heartbeat-config")
|
|
118
|
+
def heartbeat_config_off() -> dict[str, float]:
|
|
119
|
+
return {"interval": 0.0, "enabled": 0.0}
|
|
120
|
+
|
|
121
|
+
# -- API ---------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
@app.get("/api/sources")
|
|
124
|
+
def api_sources() -> list[dict[str, Any]]:
|
|
125
|
+
# Report every KNOWN adapter, marking which have data on this machine.
|
|
126
|
+
# The store holds the available ones; we additionally surface any
|
|
127
|
+
# registered-but-unavailable adapters so the UI can show them greyed.
|
|
128
|
+
from ..sources import registry
|
|
129
|
+
|
|
130
|
+
out: list[dict[str, Any]] = []
|
|
131
|
+
available_names = set()
|
|
132
|
+
for s in _store.sources:
|
|
133
|
+
available_names.add(s.name)
|
|
134
|
+
out.append({
|
|
135
|
+
"name": s.name,
|
|
136
|
+
"label": s.label,
|
|
137
|
+
"available": True,
|
|
138
|
+
"location": str(s.location()) if s.location() else None,
|
|
139
|
+
})
|
|
140
|
+
for s in registry.all_sources():
|
|
141
|
+
if s.name in available_names:
|
|
142
|
+
continue
|
|
143
|
+
out.append({
|
|
144
|
+
"name": s.name,
|
|
145
|
+
"label": s.label,
|
|
146
|
+
"available": False,
|
|
147
|
+
"location": None,
|
|
148
|
+
})
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
@app.get("/api/sessions")
|
|
152
|
+
def api_sessions(
|
|
153
|
+
source: str | None = None,
|
|
154
|
+
dir: str | None = None,
|
|
155
|
+
q: str | None = None,
|
|
156
|
+
since: str | None = None,
|
|
157
|
+
until: str | None = None,
|
|
158
|
+
fold: bool = True,
|
|
159
|
+
offset: int = Query(default=0, ge=0),
|
|
160
|
+
limit: int = Query(default=60, ge=1, le=2000),
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
if source and source not in {s.name for s in _store.sources}:
|
|
163
|
+
raise HTTPException(status_code=400, detail=f"unknown source: {source}")
|
|
164
|
+
st = _store.with_sources([source]) if source else _store
|
|
165
|
+
# Fetch one extra to tell the client whether more pages exist.
|
|
166
|
+
rows = st.list_sessions(
|
|
167
|
+
directory=dir, query=q,
|
|
168
|
+
since=_parse_dt(since), until=_parse_dt(until),
|
|
169
|
+
offset=offset, limit=limit + 1, fold_subagents=fold,
|
|
170
|
+
)
|
|
171
|
+
has_more = len(rows) > limit
|
|
172
|
+
rows = rows[:limit]
|
|
173
|
+
return {
|
|
174
|
+
"sessions": [session_summary(s) for s in rows],
|
|
175
|
+
"offset": offset,
|
|
176
|
+
"limit": limit,
|
|
177
|
+
"has_more": has_more,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@app.get("/api/sessions/{source}/{session_id}")
|
|
181
|
+
def api_session_detail(source: str, session_id: str) -> dict[str, Any]:
|
|
182
|
+
"""Full session including all messages. For very large sessions the
|
|
183
|
+
frontend should prefer the meta + windowed messages endpoints."""
|
|
184
|
+
sess = _store.load_session(session_id, source=source)
|
|
185
|
+
if sess is None:
|
|
186
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
187
|
+
return session_detail(sess)
|
|
188
|
+
|
|
189
|
+
@app.get("/api/sessions/{source}/{session_id}/meta")
|
|
190
|
+
def api_session_meta(source: str, session_id: str) -> dict[str, Any]:
|
|
191
|
+
sess = _store.load_session_meta(session_id, source=source)
|
|
192
|
+
if sess is None:
|
|
193
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
194
|
+
return session_summary(sess)
|
|
195
|
+
|
|
196
|
+
@app.get("/api/sessions/{source}/{session_id}/messages")
|
|
197
|
+
def api_session_messages(
|
|
198
|
+
source: str,
|
|
199
|
+
session_id: str,
|
|
200
|
+
offset: int = Query(default=0, ge=0),
|
|
201
|
+
limit: int = Query(default=40, ge=1, le=500),
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
msgs = _store.load_messages(
|
|
204
|
+
session_id, source=source, offset=offset, limit=limit + 1
|
|
205
|
+
)
|
|
206
|
+
has_more = len(msgs) > limit
|
|
207
|
+
msgs = msgs[:limit]
|
|
208
|
+
return {
|
|
209
|
+
"messages": [message_dict(m) for m in msgs],
|
|
210
|
+
"offset": offset,
|
|
211
|
+
"limit": limit,
|
|
212
|
+
"has_more": has_more,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@app.get("/api/search")
|
|
216
|
+
def api_search(
|
|
217
|
+
q: str,
|
|
218
|
+
dir: str | None = None,
|
|
219
|
+
since: str | None = None,
|
|
220
|
+
until: str | None = None,
|
|
221
|
+
limit: int = Query(default=100, ge=1, le=2000),
|
|
222
|
+
) -> list[dict[str, Any]]:
|
|
223
|
+
if not q.strip():
|
|
224
|
+
return []
|
|
225
|
+
hits = _store.search(
|
|
226
|
+
q, directory=dir, since=_parse_dt(since), until=_parse_dt(until), limit=limit
|
|
227
|
+
)
|
|
228
|
+
return [search_hit(h) for h in hits]
|
|
229
|
+
|
|
230
|
+
@app.get("/api/export/{source}/{session_id}")
|
|
231
|
+
def api_export(
|
|
232
|
+
source: str,
|
|
233
|
+
session_id: str,
|
|
234
|
+
format: str = "markdown",
|
|
235
|
+
reasoning: bool = True,
|
|
236
|
+
tools: bool = True,
|
|
237
|
+
math: str = "raw",
|
|
238
|
+
download: bool = False,
|
|
239
|
+
) -> "Response":
|
|
240
|
+
if format not in export.FORMATS:
|
|
241
|
+
raise HTTPException(status_code=400, detail=f"bad format: {format}")
|
|
242
|
+
if math not in export.MATH_MODES:
|
|
243
|
+
raise HTTPException(status_code=400, detail=f"bad math mode: {math}")
|
|
244
|
+
sess = _store.load_session(session_id, source=source)
|
|
245
|
+
if sess is None:
|
|
246
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
247
|
+
kwargs: dict[str, Any] = {}
|
|
248
|
+
if format != "json":
|
|
249
|
+
kwargs = {"include_reasoning": reasoning, "include_tools": tools, "math": math}
|
|
250
|
+
body = export.render(sess, format, **kwargs)
|
|
251
|
+
headers = {}
|
|
252
|
+
if download:
|
|
253
|
+
ext = _EXT.get(format, "txt")
|
|
254
|
+
fname = f"{sess.source}_{sess.short_id}.{ext}"
|
|
255
|
+
headers["Content-Disposition"] = f'attachment; filename="{fname}"'
|
|
256
|
+
return Response(content=body, media_type=_MEDIA.get(format, "text/plain"),
|
|
257
|
+
headers=headers)
|
|
258
|
+
|
|
259
|
+
@app.get("/print/{source}/{session_id}")
|
|
260
|
+
def print_view(
|
|
261
|
+
source: str, session_id: str, reasoning: bool = True, tools: bool = True,
|
|
262
|
+
math: str = "raw",
|
|
263
|
+
) -> "Response":
|
|
264
|
+
"""A print-friendly HTML page that auto-opens the print dialog.
|
|
265
|
+
|
|
266
|
+
Used by the native-window 'print' action, which opens this URL in the
|
|
267
|
+
user's real browser (where window.print() works)."""
|
|
268
|
+
if math not in export.MATH_MODES:
|
|
269
|
+
raise HTTPException(status_code=400, detail=f"bad math mode: {math}")
|
|
270
|
+
sess = _store.load_session(session_id, source=source)
|
|
271
|
+
if sess is None:
|
|
272
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
273
|
+
html = export.to_html(sess, include_reasoning=reasoning, include_tools=tools, math=math)
|
|
274
|
+
# Inject an auto-print trigger before </body>.
|
|
275
|
+
auto = "<script>window.addEventListener('load',()=>setTimeout(()=>window.print(),300));</script>"
|
|
276
|
+
if "</body>" in html:
|
|
277
|
+
html = html.replace("</body>", auto + "</body>", 1)
|
|
278
|
+
else:
|
|
279
|
+
html += auto
|
|
280
|
+
return Response(content=html, media_type="text/html; charset=utf-8")
|
|
281
|
+
|
|
282
|
+
@app.get("/api/health")
|
|
283
|
+
def api_health() -> dict[str, Any]:
|
|
284
|
+
return {"status": "ok", "version": __version__,
|
|
285
|
+
"sources": [s.name for s in _store.sources]}
|
|
286
|
+
|
|
287
|
+
# -- static frontend ---------------------------------------------------
|
|
288
|
+
|
|
289
|
+
if _STATIC_DIR.is_dir():
|
|
290
|
+
app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
|
|
291
|
+
|
|
292
|
+
return app
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", ""}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _install_host_guard(app: "FastAPI", allowed_hosts: list[str] | None) -> None:
|
|
299
|
+
"""Reject requests whose Host header isn't an allowed hostname.
|
|
300
|
+
|
|
301
|
+
Defends against DNS-rebinding: a malicious page can't point its own
|
|
302
|
+
hostname at 127.0.0.1 and read local data, because the Host header would
|
|
303
|
+
be that hostname, not a loopback name. The port portion is ignored (it
|
|
304
|
+
can auto-change); only the hostname is checked.
|
|
305
|
+
"""
|
|
306
|
+
if allowed_hosts is not None and not allowed_hosts:
|
|
307
|
+
return # explicitly disabled
|
|
308
|
+
allow = set(_LOOPBACK_HOSTS)
|
|
309
|
+
if allowed_hosts:
|
|
310
|
+
allow.update(h.lower() for h in allowed_hosts)
|
|
311
|
+
|
|
312
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
313
|
+
from starlette.responses import PlainTextResponse
|
|
314
|
+
|
|
315
|
+
class _HostGuard(BaseHTTPMiddleware):
|
|
316
|
+
async def dispatch(self, request, call_next):
|
|
317
|
+
host = request.headers.get("host", "")
|
|
318
|
+
# Strip the port; handle IPv6 [::1]:port form.
|
|
319
|
+
hostname = host.rsplit(":", 1)[0] if ":" in host and not host.endswith("]") else host
|
|
320
|
+
hostname = hostname.strip("[]").lower()
|
|
321
|
+
if hostname not in allow:
|
|
322
|
+
return PlainTextResponse("Forbidden: unexpected Host header", status_code=403)
|
|
323
|
+
return await call_next(request)
|
|
324
|
+
|
|
325
|
+
app.add_middleware(_HostGuard)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _install_heartbeat_watchdog(app: "FastAPI", on_idle, idle_timeout: float) -> None:
|
|
329
|
+
"""Auto-stop when the page stops sending heartbeats (window closed).
|
|
330
|
+
|
|
331
|
+
The frontend POSTs /api/heartbeat on an interval. A background thread
|
|
332
|
+
checks the last-seen time; if it exceeds `idle_timeout`, it calls
|
|
333
|
+
`on_idle()` once. A grace period before the first heartbeat avoids
|
|
334
|
+
shutting down during initial page load.
|
|
335
|
+
"""
|
|
336
|
+
import threading
|
|
337
|
+
import time
|
|
338
|
+
|
|
339
|
+
state = {"last": time.monotonic() + max(idle_timeout, 10.0), "fired": False}
|
|
340
|
+
|
|
341
|
+
@app.post("/api/heartbeat")
|
|
342
|
+
def heartbeat() -> dict[str, str]:
|
|
343
|
+
state["last"] = time.monotonic()
|
|
344
|
+
return {"status": "ok"}
|
|
345
|
+
|
|
346
|
+
@app.get("/api/heartbeat-config")
|
|
347
|
+
def heartbeat_config() -> dict[str, float]:
|
|
348
|
+
# Tell the client how often to ping (a third of the timeout).
|
|
349
|
+
return {"interval": max(idle_timeout / 3.0, 2.0), "enabled": 1.0}
|
|
350
|
+
|
|
351
|
+
def watch() -> None:
|
|
352
|
+
while not state["fired"]:
|
|
353
|
+
time.sleep(1.0)
|
|
354
|
+
if time.monotonic() - state["last"] > idle_timeout:
|
|
355
|
+
state["fired"] = True
|
|
356
|
+
on_idle()
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
threading.Thread(target=watch, daemon=True).start()
|