tokenol 0.3.1__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.
Files changed (90) hide show
  1. {tokenol-0.3.1 → tokenol-0.3.2}/CHANGELOG.md +14 -0
  2. {tokenol-0.3.1 → tokenol-0.3.2}/PKG-INFO +3 -1
  3. {tokenol-0.3.1 → tokenol-0.3.2}/README.md +2 -0
  4. {tokenol-0.3.1 → tokenol-0.3.2}/pyproject.toml +1 -1
  5. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/app.py +26 -16
  6. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/streaming.py +6 -0
  7. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_app.py +88 -0
  8. {tokenol-0.3.1 → tokenol-0.3.2}/.claude/settings.local.json +0 -0
  9. {tokenol-0.3.1 → tokenol-0.3.2}/.claudesignore +0 -0
  10. {tokenol-0.3.1 → tokenol-0.3.2}/.github/workflows/ci.yml +0 -0
  11. {tokenol-0.3.1 → tokenol-0.3.2}/.gitignore +0 -0
  12. {tokenol-0.3.1 → tokenol-0.3.2}/docs/ASSUMPTIONS.md +0 -0
  13. {tokenol-0.3.1 → tokenol-0.3.2}/docs/METRICS.md +0 -0
  14. {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/breakdown_bottom.jpg +0 -0
  15. {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/breakdown_top.jpg +0 -0
  16. {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/main.jpg +0 -0
  17. {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/project.jpg +0 -0
  18. {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/session_bottom.jpg +0 -0
  19. {tokenol-0.3.1 → tokenol-0.3.2}/docs/screenshots/session_top.jpg +0 -0
  20. {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr1.md +0 -0
  21. {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr2.md +0 -0
  22. {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/plans/2026-04-24-breakdown-pr3.md +0 -0
  23. {tokenol-0.3.1 → tokenol-0.3.2}/docs/superpowers/specs/2026-04-24-breakdown-tab-design.md +0 -0
  24. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/__init__.py +0 -0
  25. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/assumptions.py +0 -0
  26. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/cli.py +0 -0
  27. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/enums.py +0 -0
  28. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/__init__.py +0 -0
  29. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/builder.py +0 -0
  30. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/discovery.py +0 -0
  31. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/parser.py +0 -0
  32. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/ingest/schema.py +0 -0
  33. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/__init__.py +0 -0
  34. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/context.py +0 -0
  35. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/cost.py +0 -0
  36. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/history.py +0 -0
  37. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/patterns.py +0 -0
  38. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/rollups.py +0 -0
  39. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/thresholds.py +0 -0
  40. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/verdicts.py +0 -0
  41. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/metrics/windows.py +0 -0
  42. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/__init__.py +0 -0
  43. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/events.py +0 -0
  44. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/pricing.py +0 -0
  45. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/model/registry.py +0 -0
  46. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/report/__init__.py +0 -0
  47. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/report/text.py +0 -0
  48. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/__init__.py +0 -0
  49. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/prefs.py +0 -0
  50. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/session_detail.py +0 -0
  51. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/state.py +0 -0
  52. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/app.js +0 -0
  53. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.html +0 -0
  54. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/breakdown.js +0 -0
  55. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/chart.js +0 -0
  56. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/components.js +0 -0
  57. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/day.html +0 -0
  58. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/day.js +0 -0
  59. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/index.html +0 -0
  60. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/model.html +0 -0
  61. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/model.js +0 -0
  62. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/project.html +0 -0
  63. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/project.js +0 -0
  64. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/session.html +0 -0
  65. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/session.js +0 -0
  66. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/styles.css +0 -0
  67. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/tool.html +0 -0
  68. {tokenol-0.3.1 → tokenol-0.3.2}/src/tokenol/serve/static/tool.js +0 -0
  69. {tokenol-0.3.1 → tokenol-0.3.2}/tests/conftest.py +0 -0
  70. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/basic.jsonl +0 -0
  71. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/dedup.jsonl +0 -0
  72. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/gemini.jsonl +0 -0
  73. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/interrupted.jsonl +0 -0
  74. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/missing_ids.jsonl +0 -0
  75. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/multi.jsonl +0 -0
  76. {tokenol-0.3.1 → tokenol-0.3.2}/tests/fixtures/sidechain.jsonl +0 -0
  77. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_cli.py +0 -0
  78. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_context_metrics.py +0 -0
  79. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_dedup.py +0 -0
  80. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_history_and_thresholds.py +0 -0
  81. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_metrics.py +0 -0
  82. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_parser.py +0 -0
  83. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_patterns.py +0 -0
  84. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_prefs.py +0 -0
  85. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_state.py +0 -0
  86. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_serve_streaming.py +0 -0
  87. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_session_detail.py +0 -0
  88. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_verdicts.py +0 -0
  89. {tokenol-0.3.1 → tokenol-0.3.2}/tests/test_windows.py +0 -0
  90. {tokenol-0.3.1 → tokenol-0.3.2}/uv.lock +0 -0
@@ -4,6 +4,20 @@ 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
+
7
21
  ## [0.3.1] — 2026-04-27
8
22
 
9
23
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenol
3
- Version: 0.3.1
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
@@ -150,6 +150,8 @@ tokenol serve --open
150
150
 
151
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
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
+
153
155
  Main page layout (top to bottom):
154
156
 
155
157
  | Panel | What it shows |
@@ -117,6 +117,8 @@ tokenol serve --open
117
117
 
118
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
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
+
120
122
  Main page layout (top to bottom):
121
123
 
122
124
  | Panel | What it shows |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tokenol"
7
- version = "0.3.1"
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,
@@ -193,7 +203,7 @@ def create_app(
193
203
 
194
204
  @app.get("/api/session/{session_id}")
195
205
  async def api_session_detail(session_id: str, request: Request):
196
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
206
+ result = _current_snapshot_result(request)
197
207
  session = next((s for s in result.sessions if s.session_id == session_id), None)
198
208
  if session is None:
199
209
  raise HTTPException(status_code=404, detail="Session not found")
@@ -201,7 +211,7 @@ def create_app(
201
211
 
202
212
  @app.get("/api/session/{session_id}/turn/{turn_idx}")
203
213
  async def api_turn_detail(session_id: str, turn_idx: int, request: Request):
204
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
214
+ result = _current_snapshot_result(request)
205
215
  session = next((s for s in result.sessions if s.session_id == session_id), None)
206
216
  if session is None:
207
217
  raise HTTPException(status_code=404, detail="Session not found")
@@ -218,7 +228,7 @@ def create_app(
218
228
  raise HTTPException(status_code=400, detail="Invalid cwd encoding") from None
219
229
  if range not in ("1d", "7d", "14d", "30d", "all"):
220
230
  raise HTTPException(status_code=400, detail="Invalid range — use 1d, 7d, 14d, 30d, or all")
221
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
231
+ result = _current_snapshot_result(request)
222
232
  detail = await asyncio.get_running_loop().run_in_executor(
223
233
  None,
224
234
  lambda: build_project_detail(cwd, result.sessions, range_key=range),
@@ -235,7 +245,7 @@ def create_app(
235
245
  raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD") from None
236
246
  if parsed_date > date.today():
237
247
  raise HTTPException(status_code=400, detail="Future dates not supported")
238
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
248
+ result = _current_snapshot_result(request)
239
249
  detail = await asyncio.get_running_loop().run_in_executor(
240
250
  None,
241
251
  lambda: build_day_detail(parsed_date, result.turns, result.sessions),
@@ -272,7 +282,7 @@ def create_app(
272
282
  parsed = date.fromisoformat(target_date)
273
283
  except ValueError:
274
284
  raise HTTPException(status_code=400, detail="Invalid date — use YYYY-MM-DD") from None
275
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
285
+ result = _current_snapshot_result(request)
276
286
  return JSONResponse(build_hourly_panel(parsed, result.turns, result.sessions, metric, project, model))
277
287
 
278
288
  @app.get("/api/daily")
@@ -289,7 +299,7 @@ def create_app(
289
299
  raise HTTPException(status_code=400, detail=f"Unknown metric — valid: {sorted(VALID_METRICS)}")
290
300
  if range not in ("7d", "30d", "90d", "all"):
291
301
  raise HTTPException(status_code=400, detail="range must be 7d, 30d, 90d, or all")
292
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
302
+ result = _current_snapshot_result(request)
293
303
  # Fall back silently to the longest available window when the requested range
294
304
  # exceeds the data we have — return 200 with a `note` so the UI can caption it.
295
305
  # Returning 400 here forced clients to special-case "policy" failures even though
@@ -318,7 +328,7 @@ def create_app(
318
328
 
319
329
  @app.get("/api/models")
320
330
  async def api_models(request: Request, range: str = "today"):
321
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
331
+ result = _current_snapshot_result(request)
322
332
  since = range_since(range, date.today())
323
333
  turns = [t for t in result.turns if t.timestamp.date() >= since] if since else result.turns
324
334
  return JSONResponse(build_models_panel(turns, range))
@@ -327,7 +337,7 @@ def create_app(
327
337
  async def api_recent(request: Request, window: str = "60m"):
328
338
  if window not in _WINDOW_MINUTES:
329
339
  raise HTTPException(status_code=400, detail=f"window must be one of: {list(_WINDOW_MINUTES)}")
330
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
340
+ result = _current_snapshot_result(request)
331
341
  now = datetime.now(tz=timezone.utc)
332
342
  return JSONResponse(build_recent_activity_panel(
333
343
  result.turns, result.sessions, now, _WINDOW_MINUTES[window]
@@ -335,7 +345,7 @@ def create_app(
335
345
 
336
346
  @app.get("/api/model/{name}")
337
347
  async def api_model_detail(name: str, request: Request):
338
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
348
+ result = _current_snapshot_result(request)
339
349
  detail = build_model_detail(name, result.turns, result.sessions)
340
350
  if detail is None:
341
351
  raise HTTPException(status_code=404, detail="Model not found")
@@ -343,7 +353,7 @@ def create_app(
343
353
 
344
354
  @app.get("/api/tool/{name}")
345
355
  async def api_tool_detail(name: str, request: Request):
346
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
356
+ result = _current_snapshot_result(request)
347
357
  detail = build_tool_detail(name, result.turns, result.sessions)
348
358
  if detail is None:
349
359
  raise HTTPException(status_code=404, detail="Tool not found")
@@ -353,7 +363,7 @@ def create_app(
353
363
  async def api_search(request: Request, q: str = ""):
354
364
  if not q.strip():
355
365
  return JSONResponse({"hits": [], "query": q})
356
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
366
+ result = _current_snapshot_result(request)
357
367
  return JSONResponse(build_search_results(q, result.turns, result.sessions))
358
368
 
359
369
  @app.get("/api/prefs")
@@ -402,7 +412,7 @@ def create_app(
402
412
  @app.get("/api/breakdown/summary")
403
413
  async def api_breakdown_summary(request: Request, range: str = "30d"):
404
414
  _validate_breakdown_range(range)
405
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
415
+ result = _current_snapshot_result(request)
406
416
  since = range_since(range, date.today()) if range != "all" else None
407
417
  if since is None:
408
418
  turns = list(result.turns)
@@ -429,7 +439,7 @@ def create_app(
429
439
  @app.get("/api/breakdown/daily-tokens")
430
440
  async def api_breakdown_daily_tokens(request: Request, range: str = "30d"):
431
441
  _validate_breakdown_range(range)
432
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
442
+ result = _current_snapshot_result(request)
433
443
  since = range_since(range, date.today()) if range != "all" else None
434
444
  if since is None:
435
445
  turns = list(result.turns)
@@ -456,7 +466,7 @@ def create_app(
456
466
  @app.get("/api/breakdown/by-project")
457
467
  async def api_breakdown_by_project(request: Request, range: str = "30d"):
458
468
  _validate_breakdown_range(range)
459
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
469
+ result = _current_snapshot_result(request)
460
470
  since = range_since(range, date.today()) if range != "all" else None
461
471
 
462
472
  cwd_by_sid = _grouped_cwd_by_sid(result.sessions)
@@ -484,7 +494,7 @@ def create_app(
484
494
  @app.get("/api/breakdown/by-model")
485
495
  async def api_breakdown_by_model(request: Request, range: str = "30d"):
486
496
  _validate_breakdown_range(range)
487
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
497
+ result = _current_snapshot_result(request)
488
498
  since = range_since(range, date.today()) if range != "all" else None
489
499
 
490
500
  buckets = _bucket_turns(
@@ -508,7 +518,7 @@ def create_app(
508
518
  @app.get("/api/breakdown/tools")
509
519
  async def api_breakdown_tools(request: Request, range: str = "30d"):
510
520
  _validate_breakdown_range(range)
511
- result = request.app.state.snapshot_result or _build_and_cache_snapshot(request)
521
+ result = _current_snapshot_result(request)
512
522
  since = range_since(range, date.today()) if range != "all" else None
513
523
 
514
524
  total: Counter[str] = Counter()
@@ -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:
@@ -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