tokenol 0.3.0__tar.gz → 0.3.2__tar.gz
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.
- {tokenol-0.3.0 → tokenol-0.3.2}/CHANGELOG.md +38 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/PKG-INFO +9 -3
- {tokenol-0.3.0 → tokenol-0.3.2}/README.md +8 -2
- {tokenol-0.3.0 → tokenol-0.3.2}/pyproject.toml +1 -1
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/__init__.py +1 -1
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/app.py +33 -16
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/app.js +58 -6
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/streaming.py +11 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_app.py +88 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/uv.lock +1 -1
- {tokenol-0.3.0 → tokenol-0.3.2}/.claude/settings.local.json +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/.claudesignore +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/.github/workflows/ci.yml +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/.gitignore +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/ASSUMPTIONS.md +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/METRICS.md +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/breakdown_bottom.jpg +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/breakdown_top.jpg +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/main.jpg +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/project.jpg +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/session_bottom.jpg +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/screenshots/session_top.jpg +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr1.md +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr2.md +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr3.md +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/docs/superpowers/specs/2026-04-24-breakdown-tab-design.md +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/assumptions.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/cli.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/enums.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/__init__.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/builder.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/discovery.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/parser.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/ingest/schema.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/__init__.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/context.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/cost.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/history.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/patterns.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/rollups.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/thresholds.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/verdicts.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/metrics/windows.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/__init__.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/events.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/pricing.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/model/registry.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/report/__init__.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/report/text.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/__init__.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/prefs.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/session_detail.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/state.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/chart.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/components.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/day.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/day.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/index.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/model.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/model.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/project.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/project.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/session.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/session.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/styles.css +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/tool.html +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/src/tokenol/serve/static/tool.js +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/conftest.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/basic.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/dedup.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/gemini.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/interrupted.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/missing_ids.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/multi.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/fixtures/sidechain.jsonl +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_cli.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_context_metrics.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_dedup.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_history_and_thresholds.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_metrics.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_parser.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_patterns.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_prefs.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_state.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_serve_streaming.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_session_detail.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_verdicts.py +0 -0
- {tokenol-0.3.0 → tokenol-0.3.2}/tests/test_windows.py +0 -0
|
@@ -4,6 +4,44 @@ All notable changes to tokenol are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.2] — 2026-04-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Dashboard fallback endpoints no longer freeze on stale turn counts.**
|
|
11
|
+
The 0.3.1 `/api/snapshot` fast-path stopped refreshing
|
|
12
|
+
`app.state.snapshot_result`, so every endpoint that fell back to it
|
|
13
|
+
(`/api/hourly`, `/api/daily`, `/api/models`, `/api/recent`, `/api/session/*`,
|
|
14
|
+
`/api/project/*`, `/api/breakdown/*`, `/api/search`, `/api/model/*`,
|
|
15
|
+
`/api/tool/*`) silently froze at first-page-load turn counts. Symptom: the
|
|
16
|
+
HIT% panel showed live turns but switching to $/KW, CTX, Cache Reuse, Output,
|
|
17
|
+
or Cost rendered stale numbers. Fix: `SnapshotBroadcaster` now exposes its
|
|
18
|
+
freshest `SnapshotResult`, and fallback endpoints prefer it over the
|
|
19
|
+
app-level cache.
|
|
20
|
+
|
|
21
|
+
## [0.3.1] — 2026-04-27
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Dashboard auto-update self-heals from SSE drift.** The live dashboard
|
|
25
|
+
could intermittently freeze on stale tile/chart values while the SSE
|
|
26
|
+
connection appeared healthy (dot green, messages arriving), requiring a
|
|
27
|
+
hard reload. Reproduced reliably in long-lived browser tabs; root cause
|
|
28
|
+
is browser-environmental (extension hooks, accumulated tab state, or
|
|
29
|
+
long-lived `EventSource` quirks). Fix is a layered, root-cause-agnostic
|
|
30
|
+
resilience set in the static client:
|
|
31
|
+
- `/api/snapshot` polling backstop every 30 s while the tab is visible —
|
|
32
|
+
state self-heals within 30 s even if SSE delivery silently breaks.
|
|
33
|
+
- Force-reconnect on `visibilitychange → visible` when the last message
|
|
34
|
+
is older than 15 s (browsers throttle background-tab timers/SSE).
|
|
35
|
+
- 90 s SSE staleness watchdog (silent stalls don't always fire `onerror`).
|
|
36
|
+
- Live "last update Ns ago" tooltip on the SSE dot for at-a-glance
|
|
37
|
+
freshness.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- `/api/snapshot` reuses `SnapshotBroadcaster.cached_payload(period)` when
|
|
41
|
+
an SSE group is live for the requested period, avoiding a redundant full
|
|
42
|
+
rebuild. Cuts the snapshot fetch from ~175 ms to ~100 ms (mostly JSON
|
|
43
|
+
serialization), making the new poll backstop essentially free.
|
|
44
|
+
|
|
7
45
|
## [0.3.0] — 2026-04-27
|
|
8
46
|
|
|
9
47
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenol
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure.
|
|
5
5
|
Project-URL: Homepage, https://github.com/farhanferoz/tokenol
|
|
6
6
|
Project-URL: Issues, https://github.com/farhanferoz/tokenol/issues
|
|
@@ -148,7 +148,11 @@ tokenol serve --all-projects --tick 2s --reference 25
|
|
|
148
148
|
tokenol serve --open
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
The dashboard updates via SSE as Claude Code writes events to disk.
|
|
151
|
+
The dashboard updates via SSE as Claude Code writes events to disk. The server gates rebuilds on JSONL file changes — when no files have changed, it idles at near-zero CPU and forces a refresh at most once a minute (so time-windowed panels like Recent Activity don't drift more than ~60 s from wall clock). Multiple browser tabs share a single producer, so opening more tabs does not multiply server cost.
|
|
152
|
+
|
|
153
|
+
If SSE delivery silently stalls (browser tab throttling, extension hooks, long-lived `EventSource` quirks), the client self-heals: it polls `/api/snapshot` every 30 s as a backstop, force-reconnects on tab-visibility return, and runs a 90 s staleness watchdog. `/api/snapshot` reuses the broadcaster's cached payload while an SSE group is live, so the backstop costs only a JSON serialize. Hover the live-status dot for a "last update Ns ago" indicator.
|
|
154
|
+
|
|
155
|
+
Main page layout (top to bottom):
|
|
152
156
|
|
|
153
157
|
| Panel | What it shows |
|
|
154
158
|
|---|---|
|
|
@@ -173,7 +177,7 @@ Keyboard shortcuts: `?` Glossary · `/` Find · `,` Settings · `Esc` close/back
|
|
|
173
177
|
|
|
174
178
|
### Preferences
|
|
175
179
|
|
|
176
|
-
User preferences (
|
|
180
|
+
User preferences (gate-poll cadence and threshold overrides) are saved to:
|
|
177
181
|
|
|
178
182
|
```
|
|
179
183
|
$XDG_CONFIG_HOME/tokenol/prefs.json # default: ~/.config/tokenol/prefs.json
|
|
@@ -197,6 +201,8 @@ Shape:
|
|
|
197
201
|
}
|
|
198
202
|
```
|
|
199
203
|
|
|
204
|
+
`tick_seconds` is how often the server stat-checks the JSONL files for changes (cheap). The full snapshot only rebuilds on a detected change or once per ~60 s heartbeat — so a long `tick_seconds` mainly reduces stat-syscall noise, not rebuild cost.
|
|
205
|
+
|
|
200
206
|
Reset to defaults via the Settings modal (`POST /api/prefs {"thresholds": "reset"}`).
|
|
201
207
|
|
|
202
208
|
### Session drill-down
|
|
@@ -115,7 +115,11 @@ tokenol serve --all-projects --tick 2s --reference 25
|
|
|
115
115
|
tokenol serve --open
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
The dashboard updates via SSE as Claude Code writes events to disk.
|
|
118
|
+
The dashboard updates via SSE as Claude Code writes events to disk. The server gates rebuilds on JSONL file changes — when no files have changed, it idles at near-zero CPU and forces a refresh at most once a minute (so time-windowed panels like Recent Activity don't drift more than ~60 s from wall clock). Multiple browser tabs share a single producer, so opening more tabs does not multiply server cost.
|
|
119
|
+
|
|
120
|
+
If SSE delivery silently stalls (browser tab throttling, extension hooks, long-lived `EventSource` quirks), the client self-heals: it polls `/api/snapshot` every 30 s as a backstop, force-reconnects on tab-visibility return, and runs a 90 s staleness watchdog. `/api/snapshot` reuses the broadcaster's cached payload while an SSE group is live, so the backstop costs only a JSON serialize. Hover the live-status dot for a "last update Ns ago" indicator.
|
|
121
|
+
|
|
122
|
+
Main page layout (top to bottom):
|
|
119
123
|
|
|
120
124
|
| Panel | What it shows |
|
|
121
125
|
|---|---|
|
|
@@ -140,7 +144,7 @@ Keyboard shortcuts: `?` Glossary · `/` Find · `,` Settings · `Esc` close/back
|
|
|
140
144
|
|
|
141
145
|
### Preferences
|
|
142
146
|
|
|
143
|
-
User preferences (
|
|
147
|
+
User preferences (gate-poll cadence and threshold overrides) are saved to:
|
|
144
148
|
|
|
145
149
|
```
|
|
146
150
|
$XDG_CONFIG_HOME/tokenol/prefs.json # default: ~/.config/tokenol/prefs.json
|
|
@@ -164,6 +168,8 @@ Shape:
|
|
|
164
168
|
}
|
|
165
169
|
```
|
|
166
170
|
|
|
171
|
+
`tick_seconds` is how often the server stat-checks the JSONL files for changes (cheap). The full snapshot only rebuilds on a detected change or once per ~60 s heartbeat — so a long `tick_seconds` mainly reduces stat-syscall noise, not rebuild cost.
|
|
172
|
+
|
|
167
173
|
Reset to defaults via the Settings modal (`POST /api/prefs {"thresholds": "reset"}`).
|
|
168
174
|
|
|
169
175
|
### Session drill-down
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tokenol"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Audit Claude Code JSONL session logs: track cost, cache health, context blow-ups, and 5h-window pressure."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -113,6 +113,16 @@ def _build_and_cache_snapshot(request: Request, period: str = "today") -> Snapsh
|
|
|
113
113
|
return result
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
def _current_snapshot_result(request: Request) -> SnapshotResult:
|
|
117
|
+
"""Prefer the broadcaster's latest build; fall back to the app cache or a fresh build."""
|
|
118
|
+
broadcaster = getattr(request.app.state, "broadcaster", None)
|
|
119
|
+
if broadcaster is not None:
|
|
120
|
+
latest = broadcaster.latest_result()
|
|
121
|
+
if latest is not None:
|
|
122
|
+
return latest
|
|
123
|
+
return request.app.state.snapshot_result or _build_and_cache_snapshot(request)
|
|
124
|
+
|
|
125
|
+
|
|
116
126
|
def create_app(
|
|
117
127
|
config: ServerConfig | None = None,
|
|
118
128
|
prefs_path: Path | None = None,
|
|
@@ -182,11 +192,18 @@ def create_app(
|
|
|
182
192
|
|
|
183
193
|
@app.get("/api/snapshot")
|
|
184
194
|
async def api_snapshot(request: Request, period: str = "today"):
|
|
195
|
+
# Reuse the broadcaster's cached payload when a SSE group is live for
|
|
196
|
+
# this period — avoids a second full rebuild for polling backstops.
|
|
197
|
+
broadcaster = getattr(request.app.state, "broadcaster", None)
|
|
198
|
+
if broadcaster is not None:
|
|
199
|
+
cached = broadcaster.cached_payload(period)
|
|
200
|
+
if cached is not None:
|
|
201
|
+
return JSONResponse(cached)
|
|
185
202
|
return JSONResponse(_build_and_cache_snapshot(request, period=period).payload)
|
|
186
203
|
|
|
187
204
|
@app.get("/api/session/{session_id}")
|
|
188
205
|
async def api_session_detail(session_id: str, request: Request):
|
|
189
|
-
result =
|
|
206
|
+
result = _current_snapshot_result(request)
|
|
190
207
|
session = next((s for s in result.sessions if s.session_id == session_id), None)
|
|
191
208
|
if session is None:
|
|
192
209
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
@@ -194,7 +211,7 @@ def create_app(
|
|
|
194
211
|
|
|
195
212
|
@app.get("/api/session/{session_id}/turn/{turn_idx}")
|
|
196
213
|
async def api_turn_detail(session_id: str, turn_idx: int, request: Request):
|
|
197
|
-
result =
|
|
214
|
+
result = _current_snapshot_result(request)
|
|
198
215
|
session = next((s for s in result.sessions if s.session_id == session_id), None)
|
|
199
216
|
if session is None:
|
|
200
217
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
@@ -211,7 +228,7 @@ def create_app(
|
|
|
211
228
|
raise HTTPException(status_code=400, detail="Invalid cwd encoding") from None
|
|
212
229
|
if range not in ("1d", "7d", "14d", "30d", "all"):
|
|
213
230
|
raise HTTPException(status_code=400, detail="Invalid range — use 1d, 7d, 14d, 30d, or all")
|
|
214
|
-
result =
|
|
231
|
+
result = _current_snapshot_result(request)
|
|
215
232
|
detail = await asyncio.get_running_loop().run_in_executor(
|
|
216
233
|
None,
|
|
217
234
|
lambda: build_project_detail(cwd, result.sessions, range_key=range),
|
|
@@ -228,7 +245,7 @@ def create_app(
|
|
|
228
245
|
raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD") from None
|
|
229
246
|
if parsed_date > date.today():
|
|
230
247
|
raise HTTPException(status_code=400, detail="Future dates not supported")
|
|
231
|
-
result =
|
|
248
|
+
result = _current_snapshot_result(request)
|
|
232
249
|
detail = await asyncio.get_running_loop().run_in_executor(
|
|
233
250
|
None,
|
|
234
251
|
lambda: build_day_detail(parsed_date, result.turns, result.sessions),
|
|
@@ -265,7 +282,7 @@ def create_app(
|
|
|
265
282
|
parsed = date.fromisoformat(target_date)
|
|
266
283
|
except ValueError:
|
|
267
284
|
raise HTTPException(status_code=400, detail="Invalid date — use YYYY-MM-DD") from None
|
|
268
|
-
result =
|
|
285
|
+
result = _current_snapshot_result(request)
|
|
269
286
|
return JSONResponse(build_hourly_panel(parsed, result.turns, result.sessions, metric, project, model))
|
|
270
287
|
|
|
271
288
|
@app.get("/api/daily")
|
|
@@ -282,7 +299,7 @@ def create_app(
|
|
|
282
299
|
raise HTTPException(status_code=400, detail=f"Unknown metric — valid: {sorted(VALID_METRICS)}")
|
|
283
300
|
if range not in ("7d", "30d", "90d", "all"):
|
|
284
301
|
raise HTTPException(status_code=400, detail="range must be 7d, 30d, 90d, or all")
|
|
285
|
-
result =
|
|
302
|
+
result = _current_snapshot_result(request)
|
|
286
303
|
# Fall back silently to the longest available window when the requested range
|
|
287
304
|
# exceeds the data we have — return 200 with a `note` so the UI can caption it.
|
|
288
305
|
# Returning 400 here forced clients to special-case "policy" failures even though
|
|
@@ -311,7 +328,7 @@ def create_app(
|
|
|
311
328
|
|
|
312
329
|
@app.get("/api/models")
|
|
313
330
|
async def api_models(request: Request, range: str = "today"):
|
|
314
|
-
result =
|
|
331
|
+
result = _current_snapshot_result(request)
|
|
315
332
|
since = range_since(range, date.today())
|
|
316
333
|
turns = [t for t in result.turns if t.timestamp.date() >= since] if since else result.turns
|
|
317
334
|
return JSONResponse(build_models_panel(turns, range))
|
|
@@ -320,7 +337,7 @@ def create_app(
|
|
|
320
337
|
async def api_recent(request: Request, window: str = "60m"):
|
|
321
338
|
if window not in _WINDOW_MINUTES:
|
|
322
339
|
raise HTTPException(status_code=400, detail=f"window must be one of: {list(_WINDOW_MINUTES)}")
|
|
323
|
-
result =
|
|
340
|
+
result = _current_snapshot_result(request)
|
|
324
341
|
now = datetime.now(tz=timezone.utc)
|
|
325
342
|
return JSONResponse(build_recent_activity_panel(
|
|
326
343
|
result.turns, result.sessions, now, _WINDOW_MINUTES[window]
|
|
@@ -328,7 +345,7 @@ def create_app(
|
|
|
328
345
|
|
|
329
346
|
@app.get("/api/model/{name}")
|
|
330
347
|
async def api_model_detail(name: str, request: Request):
|
|
331
|
-
result =
|
|
348
|
+
result = _current_snapshot_result(request)
|
|
332
349
|
detail = build_model_detail(name, result.turns, result.sessions)
|
|
333
350
|
if detail is None:
|
|
334
351
|
raise HTTPException(status_code=404, detail="Model not found")
|
|
@@ -336,7 +353,7 @@ def create_app(
|
|
|
336
353
|
|
|
337
354
|
@app.get("/api/tool/{name}")
|
|
338
355
|
async def api_tool_detail(name: str, request: Request):
|
|
339
|
-
result =
|
|
356
|
+
result = _current_snapshot_result(request)
|
|
340
357
|
detail = build_tool_detail(name, result.turns, result.sessions)
|
|
341
358
|
if detail is None:
|
|
342
359
|
raise HTTPException(status_code=404, detail="Tool not found")
|
|
@@ -346,7 +363,7 @@ def create_app(
|
|
|
346
363
|
async def api_search(request: Request, q: str = ""):
|
|
347
364
|
if not q.strip():
|
|
348
365
|
return JSONResponse({"hits": [], "query": q})
|
|
349
|
-
result =
|
|
366
|
+
result = _current_snapshot_result(request)
|
|
350
367
|
return JSONResponse(build_search_results(q, result.turns, result.sessions))
|
|
351
368
|
|
|
352
369
|
@app.get("/api/prefs")
|
|
@@ -395,7 +412,7 @@ def create_app(
|
|
|
395
412
|
@app.get("/api/breakdown/summary")
|
|
396
413
|
async def api_breakdown_summary(request: Request, range: str = "30d"):
|
|
397
414
|
_validate_breakdown_range(range)
|
|
398
|
-
result =
|
|
415
|
+
result = _current_snapshot_result(request)
|
|
399
416
|
since = range_since(range, date.today()) if range != "all" else None
|
|
400
417
|
if since is None:
|
|
401
418
|
turns = list(result.turns)
|
|
@@ -422,7 +439,7 @@ def create_app(
|
|
|
422
439
|
@app.get("/api/breakdown/daily-tokens")
|
|
423
440
|
async def api_breakdown_daily_tokens(request: Request, range: str = "30d"):
|
|
424
441
|
_validate_breakdown_range(range)
|
|
425
|
-
result =
|
|
442
|
+
result = _current_snapshot_result(request)
|
|
426
443
|
since = range_since(range, date.today()) if range != "all" else None
|
|
427
444
|
if since is None:
|
|
428
445
|
turns = list(result.turns)
|
|
@@ -449,7 +466,7 @@ def create_app(
|
|
|
449
466
|
@app.get("/api/breakdown/by-project")
|
|
450
467
|
async def api_breakdown_by_project(request: Request, range: str = "30d"):
|
|
451
468
|
_validate_breakdown_range(range)
|
|
452
|
-
result =
|
|
469
|
+
result = _current_snapshot_result(request)
|
|
453
470
|
since = range_since(range, date.today()) if range != "all" else None
|
|
454
471
|
|
|
455
472
|
cwd_by_sid = _grouped_cwd_by_sid(result.sessions)
|
|
@@ -477,7 +494,7 @@ def create_app(
|
|
|
477
494
|
@app.get("/api/breakdown/by-model")
|
|
478
495
|
async def api_breakdown_by_model(request: Request, range: str = "30d"):
|
|
479
496
|
_validate_breakdown_range(range)
|
|
480
|
-
result =
|
|
497
|
+
result = _current_snapshot_result(request)
|
|
481
498
|
since = range_since(range, date.today()) if range != "all" else None
|
|
482
499
|
|
|
483
500
|
buckets = _bucket_turns(
|
|
@@ -501,7 +518,7 @@ def create_app(
|
|
|
501
518
|
@app.get("/api/breakdown/tools")
|
|
502
519
|
async def api_breakdown_tools(request: Request, range: str = "30d"):
|
|
503
520
|
_validate_breakdown_range(range)
|
|
504
|
-
result =
|
|
521
|
+
result = _current_snapshot_result(request)
|
|
505
522
|
since = range_since(range, date.today()) if range != "all" else None
|
|
506
523
|
|
|
507
524
|
total: Counter[str] = Counter()
|
|
@@ -50,18 +50,44 @@ function _setPeriod(p) { localStorage.setItem(_LS_PERIOD, p); }
|
|
|
50
50
|
|
|
51
51
|
// ---- SSE dot ----
|
|
52
52
|
const _dot = $('sse-dot');
|
|
53
|
+
let _dotBaseTitle = '';
|
|
53
54
|
function _dotState(cls, title) {
|
|
54
55
|
_dot.className = 'sse-dot' + (cls ? ' ' + cls : '');
|
|
56
|
+
_dotBaseTitle = title;
|
|
55
57
|
_dot.title = title;
|
|
56
58
|
}
|
|
59
|
+
// Live tooltip: "Live — connected · last update 5s ago" — updated every second
|
|
60
|
+
// so you can hover-and-tell whether the page is genuinely stale.
|
|
61
|
+
setInterval(() => {
|
|
62
|
+
if (!_dot) return;
|
|
63
|
+
if (_lastMsgAt) {
|
|
64
|
+
const ageS = Math.round((Date.now() - _lastMsgAt) / 1000);
|
|
65
|
+
_dot.title = `${_dotBaseTitle} · last update ${ageS}s ago`;
|
|
66
|
+
} else {
|
|
67
|
+
_dot.title = _dotBaseTitle;
|
|
68
|
+
}
|
|
69
|
+
}, 1_000);
|
|
57
70
|
|
|
58
71
|
// ---- SSE connection ----
|
|
59
72
|
let _es = null;
|
|
60
73
|
let _reconnectDelay = 1_000;
|
|
61
74
|
let _fiveXXSince = null;
|
|
75
|
+
let _lastMsgAt = 0;
|
|
76
|
+
// Server heartbeat is ≤60s; >90s without a message means the connection
|
|
77
|
+
// silently stalled (system sleep, NAT drop, transparent proxy).
|
|
78
|
+
const _STALE_MS = 90_000;
|
|
79
|
+
|
|
80
|
+
function _scheduleReconnect(reason) {
|
|
81
|
+
if (_es) { _es.close(); _es = null; }
|
|
82
|
+
_lastMsgAt = 0;
|
|
83
|
+
_dotState('amber', `SSE ${reason} — reconnecting…`);
|
|
84
|
+
setTimeout(() => _connect(_getPeriod()), _reconnectDelay);
|
|
85
|
+
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
|
86
|
+
}
|
|
62
87
|
|
|
63
88
|
function _connect(period) {
|
|
64
89
|
if (_es) { _es.close(); _es = null; }
|
|
90
|
+
_lastMsgAt = 0;
|
|
65
91
|
_dotState('', 'SSE connecting…');
|
|
66
92
|
_es = new EventSource(`/api/stream?period=${period}`);
|
|
67
93
|
|
|
@@ -69,22 +95,48 @@ function _connect(period) {
|
|
|
69
95
|
_dotState('connected', 'Live — connected');
|
|
70
96
|
_reconnectDelay = 1_000;
|
|
71
97
|
_fiveXXSince = null;
|
|
98
|
+
_lastMsgAt = Date.now();
|
|
72
99
|
_resetIdleTimer();
|
|
73
100
|
};
|
|
74
101
|
|
|
75
102
|
_es.onmessage = ev => {
|
|
103
|
+
_lastMsgAt = Date.now();
|
|
76
104
|
try { _applyPayload(JSON.parse(ev.data)); }
|
|
77
105
|
catch (e) { console.error('SSE parse', e); }
|
|
78
106
|
};
|
|
79
107
|
|
|
80
|
-
_es.onerror = () =>
|
|
81
|
-
_dotState('amber', 'SSE disconnected — reconnecting…');
|
|
82
|
-
_es.close(); _es = null;
|
|
83
|
-
setTimeout(() => _connect(_getPeriod()), _reconnectDelay);
|
|
84
|
-
_reconnectDelay = Math.min(_reconnectDelay * 2, 30_000);
|
|
85
|
-
};
|
|
108
|
+
_es.onerror = () => _scheduleReconnect('disconnected');
|
|
86
109
|
}
|
|
87
110
|
|
|
111
|
+
// Watchdog: EventSource doesn't reliably fire onerror on silent stalls.
|
|
112
|
+
setInterval(() => {
|
|
113
|
+
if (!_es || !_lastMsgAt) return;
|
|
114
|
+
if (Date.now() - _lastMsgAt > _STALE_MS) _scheduleReconnect('stalled');
|
|
115
|
+
}, 10_000);
|
|
116
|
+
|
|
117
|
+
// Browsers throttle background tabs — timers slow, SSE may pause. On tab
|
|
118
|
+
// return, if our last message is older than one tick + slack, force a fresh
|
|
119
|
+
// reconnect so the user never sees stale data.
|
|
120
|
+
document.addEventListener('visibilitychange', () => {
|
|
121
|
+
if (document.visibilityState !== 'visible') return;
|
|
122
|
+
if (!_lastMsgAt || Date.now() - _lastMsgAt > 15_000) {
|
|
123
|
+
_reconnectDelay = 1_000;
|
|
124
|
+
_scheduleReconnect('tab visible');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Backstop: re-fetch the authoritative snapshot periodically. SSE is the
|
|
129
|
+
// fast path; this catches any case where SSE flows but the merged client
|
|
130
|
+
// state drifts (browser extension hooks, long-lived tab quirks, etc.).
|
|
131
|
+
// Skipped while hidden — visibilitychange handler covers the wakeup case.
|
|
132
|
+
setInterval(() => {
|
|
133
|
+
if (document.visibilityState !== 'visible') return;
|
|
134
|
+
_fetchSnapshot(_getPeriod());
|
|
135
|
+
}, 30_000);
|
|
136
|
+
|
|
137
|
+
// Debug: window.__tokenolForceReconnect() simulates a silent stall.
|
|
138
|
+
window.__tokenolForceReconnect = () => _scheduleReconnect('forced (debug)');
|
|
139
|
+
|
|
88
140
|
// ---- initial fetch ----
|
|
89
141
|
async function _fetchSnapshot(period) {
|
|
90
142
|
try {
|
|
@@ -177,6 +177,8 @@ class SnapshotBroadcaster:
|
|
|
177
177
|
self._heartbeat_s = heartbeat_s
|
|
178
178
|
self._groups: dict[str, _Group] = {}
|
|
179
179
|
self._lock = asyncio.Lock()
|
|
180
|
+
# turns/sessions are period-agnostic, so any producer's freshest result serves all readers.
|
|
181
|
+
self._latest_result: SnapshotResult | None = None
|
|
180
182
|
|
|
181
183
|
def _compute_active_keys(self) -> frozenset[tuple[str, int, int]]:
|
|
182
184
|
return compute_active_keys(self._all_projects)
|
|
@@ -190,8 +192,12 @@ class SnapshotBroadcaster:
|
|
|
190
192
|
period=period,
|
|
191
193
|
thresholds=self._get_thresholds(),
|
|
192
194
|
)
|
|
195
|
+
self._latest_result = result
|
|
193
196
|
return result.payload
|
|
194
197
|
|
|
198
|
+
def latest_result(self) -> SnapshotResult | None:
|
|
199
|
+
return self._latest_result
|
|
200
|
+
|
|
195
201
|
async def subscribe(self, period: str) -> AsyncGenerator[str, None]:
|
|
196
202
|
sub = _Subscriber()
|
|
197
203
|
async with self._lock:
|
|
@@ -243,3 +249,8 @@ class SnapshotBroadcaster:
|
|
|
243
249
|
def group_count(self) -> int:
|
|
244
250
|
"""Number of active producer groups (testing aid)."""
|
|
245
251
|
return len(self._groups)
|
|
252
|
+
|
|
253
|
+
def cached_payload(self, period: str) -> dict | None:
|
|
254
|
+
"""Latest broadcast payload for *period*, or None if no group is live."""
|
|
255
|
+
grp = self._groups.get(period)
|
|
256
|
+
return grp.last_payload if grp is not None else None
|
|
@@ -1052,3 +1052,91 @@ async def test_api_tool_detail_404_on_unknown(tmp_path: Path) -> None:
|
|
|
1052
1052
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
1053
1053
|
resp = await client.get("/api/tool/NoSuchTool")
|
|
1054
1054
|
assert resp.status_code == 404
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
@pytest.mark.asyncio
|
|
1058
|
+
async def test_api_hourly_reflects_broadcaster_freshness(tmp_path: Path) -> None:
|
|
1059
|
+
"""Regression: /api/hourly must track the broadcaster, not stale app.state.snapshot_result."""
|
|
1060
|
+
import asyncio
|
|
1061
|
+
from datetime import datetime, timezone
|
|
1062
|
+
|
|
1063
|
+
from httpx import ASGITransport, AsyncClient
|
|
1064
|
+
|
|
1065
|
+
today = datetime.now(tz=timezone.utc).date()
|
|
1066
|
+
target_date = today.isoformat()
|
|
1067
|
+
|
|
1068
|
+
def _turn_line(uuid: str, hour: int, minute: int) -> bytes:
|
|
1069
|
+
ts = f"{target_date}T{hour:02d}:{minute:02d}:00Z"
|
|
1070
|
+
return (
|
|
1071
|
+
b'{"type":"assistant","timestamp":"' + ts.encode() + b'",'
|
|
1072
|
+
b'"sessionId":"sess-001","requestId":"req-' + uuid.encode() + b'",'
|
|
1073
|
+
b'"uuid":"' + uuid.encode() + b'","isSidechain":false,'
|
|
1074
|
+
b'"model":"claude-opus-4-7",'
|
|
1075
|
+
b'"message":{"id":"msg-' + uuid.encode() + b'","role":"assistant",'
|
|
1076
|
+
b'"stop_reason":"end_turn",'
|
|
1077
|
+
b'"usage":{"input_tokens":10,"output_tokens":20,'
|
|
1078
|
+
b'"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}\n'
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
sess_path = tmp_path / "projects" / "sess-001.jsonl"
|
|
1082
|
+
sess_path.parent.mkdir(parents=True)
|
|
1083
|
+
sess_path.write_bytes(_turn_line("evt-001", 10, 0) + _turn_line("evt-002", 10, 5))
|
|
1084
|
+
|
|
1085
|
+
with _mock_dirs(tmp_path):
|
|
1086
|
+
app = create_app(ServerConfig())
|
|
1087
|
+
bc = app.state.broadcaster
|
|
1088
|
+
# Force the producer to rebuild on every mtime change with no heartbeat delay.
|
|
1089
|
+
bc._get_tick_seconds = lambda: 0
|
|
1090
|
+
bc._heartbeat_s = 0.0
|
|
1091
|
+
|
|
1092
|
+
agen = bc.subscribe("today").__aiter__()
|
|
1093
|
+
try:
|
|
1094
|
+
await asyncio.wait_for(agen.__anext__(), timeout=5.0)
|
|
1095
|
+
|
|
1096
|
+
async with AsyncClient(
|
|
1097
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
1098
|
+
) as client:
|
|
1099
|
+
resp = await client.get(
|
|
1100
|
+
f"/api/hourly/{target_date}?metric=cost_per_kw"
|
|
1101
|
+
)
|
|
1102
|
+
turns_before = sum(
|
|
1103
|
+
p["turns"] for s in resp.json()["series"] for p in s["points"]
|
|
1104
|
+
)
|
|
1105
|
+
assert turns_before == 2, (
|
|
1106
|
+
f"fixture has 2 assistant turns, /api/hourly saw {turns_before}"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
sess_path.write_bytes(sess_path.read_bytes() + _turn_line("evt-003", 10, 30))
|
|
1110
|
+
|
|
1111
|
+
# Poll /api/snapshot as the sync barrier — it exercises the same fast-path
|
|
1112
|
+
# the fix targets, without reaching into broadcaster internals.
|
|
1113
|
+
async with AsyncClient(
|
|
1114
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
1115
|
+
) as client:
|
|
1116
|
+
snapshot_turns = 0
|
|
1117
|
+
for _ in range(200):
|
|
1118
|
+
resp = await client.get("/api/snapshot?period=today")
|
|
1119
|
+
snapshot_turns = sum(
|
|
1120
|
+
s["turns"]
|
|
1121
|
+
for s in resp.json()["hourly_today"]["series"]
|
|
1122
|
+
)
|
|
1123
|
+
if snapshot_turns >= 3:
|
|
1124
|
+
break
|
|
1125
|
+
await asyncio.sleep(0.02)
|
|
1126
|
+
assert snapshot_turns == 3, (
|
|
1127
|
+
"broadcaster did not pick up appended turn within 4 s "
|
|
1128
|
+
f"(snapshot still reports {snapshot_turns} turns)"
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
resp = await client.get(
|
|
1132
|
+
f"/api/hourly/{target_date}?metric=cost_per_kw"
|
|
1133
|
+
)
|
|
1134
|
+
turns_after = sum(
|
|
1135
|
+
p["turns"] for s in resp.json()["series"] for p in s["points"]
|
|
1136
|
+
)
|
|
1137
|
+
assert turns_after == 3, (
|
|
1138
|
+
"/api/hourly returned stale turns "
|
|
1139
|
+
f"({turns_after}); expected 3 after appending one turn"
|
|
1140
|
+
)
|
|
1141
|
+
finally:
|
|
1142
|
+
await agen.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|