cctally 1.109.0 → 1.111.0

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 (47) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_cache.py +62 -13
  4. package/bin/_cctally_core.py +8 -1
  5. package/bin/_cctally_dashboard.py +422 -195
  6. package/bin/_cctally_dashboard_conversation.py +231 -40
  7. package/bin/_cctally_dashboard_envelope.py +48 -32
  8. package/bin/_cctally_dashboard_sources.py +503 -109
  9. package/bin/_cctally_db.py +44 -0
  10. package/bin/_cctally_diagnosis_sources.py +181 -15
  11. package/bin/_cctally_doctor.py +15 -3
  12. package/bin/_cctally_forecast.py +25 -12
  13. package/bin/_cctally_journal.py +59 -32
  14. package/bin/_cctally_milestone_history.py +65 -9
  15. package/bin/_cctally_parser.py +5 -0
  16. package/bin/_cctally_record.py +50 -12
  17. package/bin/_cctally_rederive.py +899 -30
  18. package/bin/_cctally_source_analytics.py +567 -133
  19. package/bin/_cctally_transcript.py +27 -2
  20. package/bin/_cctally_tui.py +39 -6
  21. package/bin/_cctally_update.py +420 -14
  22. package/bin/_lib_cache_report.py +7 -10
  23. package/bin/_lib_codex_conversation_query.py +286 -63
  24. package/bin/_lib_codex_metadata.py +207 -0
  25. package/bin/_lib_conversation_query.py +254 -37
  26. package/bin/_lib_doctor.py +16 -2
  27. package/bin/_lib_pricing.py +80 -276
  28. package/bin/_lib_rederive.py +903 -2
  29. package/bin/_lib_view_models.py +5 -0
  30. package/bin/cctally +18 -5
  31. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  32. package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
  33. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  34. package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
  35. package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
  36. package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
  37. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  38. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  39. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  40. package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
  41. package/dashboard/static/dashboard.html +3 -3
  42. package/package.json +2 -1
  43. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
  44. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
  45. package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
  46. package/dashboard/static/assets/index-DLVwACUM.js +0 -13
  47. package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
@@ -278,6 +278,7 @@ import secrets
278
278
  import shutil
279
279
  import signal as _signal
280
280
  import socket
281
+ import select
281
282
  import sqlite3
282
283
  import sys
283
284
  import threading
@@ -297,7 +298,7 @@ from _lib_retained_size import retained_size_bytes
297
298
 
298
299
 
299
300
  class _QuietThreadingHTTPServer(ThreadingHTTPServer):
300
- """`ThreadingHTTPServer` that swallows client-disconnect tracebacks.
301
+ """Bounded HTTP threads with quiet client-disconnect handling.
301
302
 
302
303
  A backgrounded/closed/reloaded dashboard tab hangs up mid-response, so the
303
304
  per-request thread's socket write raises one of the "peer went away"
@@ -313,6 +314,111 @@ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
313
314
  """
314
315
 
315
316
  daemon_threads = True
317
+ request_queue_size = 256
318
+ max_request_threads = 96
319
+ _reject_workers_count = 4
320
+ _reject_queue_size = 256
321
+ _busy_body = b'{"error":"server busy"}'
322
+ _busy_response = (
323
+ b"HTTP/1.1 503 Service Unavailable\r\n"
324
+ b"Content-Type: application/json\r\n"
325
+ b"Cache-Control: no-store\r\n"
326
+ b"Connection: close\r\n"
327
+ + f"Content-Length: {len(_busy_body)}\r\n\r\n".encode("ascii")
328
+ + _busy_body
329
+ )
330
+
331
+ def __init__(self, *args, **kwargs):
332
+ self._request_slots = threading.BoundedSemaphore(
333
+ self.max_request_threads)
334
+ self._reject_queue: queue.Queue[socket.socket] = queue.Queue(
335
+ maxsize=self._reject_queue_size)
336
+ self._reject_stop = threading.Event()
337
+ self._reject_start_lock = threading.Lock()
338
+ self._reject_workers: list[threading.Thread] = []
339
+ super().__init__(*args, **kwargs)
340
+
341
+ def _reject_request(self, request: socket.socket) -> None:
342
+ try:
343
+ # Read only the bounded request headers so a close with unread
344
+ # input does not reset a client before it sees the 503. Slow peers
345
+ # are confined to a fixed reject worker, never the accept loop.
346
+ request.settimeout(0.25)
347
+ received = bytearray()
348
+ while b"\r\n\r\n" not in received and len(received) < 8192:
349
+ chunk = request.recv(min(8192 - len(received), 4096))
350
+ if not chunk:
351
+ break
352
+ received.extend(chunk)
353
+ request.sendall(self._busy_response)
354
+ except OSError:
355
+ pass # A peer that disconnected cannot receive the overload reply.
356
+ finally:
357
+ self.shutdown_request(request)
358
+
359
+ def _reject_loop(self) -> None:
360
+ while not self._reject_stop.is_set():
361
+ try:
362
+ request = self._reject_queue.get(timeout=0.1)
363
+ except queue.Empty:
364
+ continue
365
+ try:
366
+ self._reject_request(request)
367
+ finally:
368
+ self._reject_queue.task_done()
369
+
370
+ def _queue_rejection(self, request: socket.socket) -> None:
371
+ with self._reject_start_lock:
372
+ if not self._reject_workers:
373
+ for index in range(self._reject_workers_count):
374
+ worker = threading.Thread(
375
+ target=self._reject_loop,
376
+ name=f"cctally-http-reject-{index}", daemon=True,
377
+ )
378
+ worker.start()
379
+ self._reject_workers.append(worker)
380
+ try:
381
+ self._reject_queue.put_nowait(request)
382
+ except queue.Full:
383
+ # This last-resort reply is nonblocking. It keeps the accept loop
384
+ # responsive even if the bounded reject queue also saturates.
385
+ try:
386
+ request.setblocking(False)
387
+ request.send(self._busy_response)
388
+ except OSError:
389
+ pass
390
+ finally:
391
+ self.shutdown_request(request)
392
+
393
+ def process_request(self, request, client_address):
394
+ if not self._request_slots.acquire(blocking=False):
395
+ self._queue_rejection(request)
396
+ return
397
+ try:
398
+ super().process_request(request, client_address)
399
+ except BaseException:
400
+ self._request_slots.release()
401
+ self.shutdown_request(request)
402
+ raise
403
+
404
+ def process_request_thread(self, request, client_address):
405
+ try:
406
+ super().process_request_thread(request, client_address)
407
+ finally:
408
+ self._request_slots.release()
409
+
410
+ def server_close(self):
411
+ self._reject_stop.set()
412
+ while True:
413
+ try:
414
+ request = self._reject_queue.get_nowait()
415
+ except queue.Empty:
416
+ break
417
+ self.shutdown_request(request)
418
+ self._reject_queue.task_done()
419
+ for worker in self._reject_workers:
420
+ worker.join(timeout=0.5)
421
+ super().server_close()
316
422
 
317
423
  def handle_error(self, request, client_address):
318
424
  exc = sys.exc_info()[1]
@@ -332,6 +438,30 @@ class _DiagnosisFlight:
332
438
  result: Any = None
333
439
  error: BaseException | None = None
334
440
  callers: int = 0
441
+ deadline: float = 0.0
442
+
443
+
444
+ class _DiagnosisOverloaded(Exception):
445
+ """The bounded diagnosis admission has no room for another caller."""
446
+
447
+
448
+ class _DiagnosisTimedOut(Exception):
449
+ """A diagnosis did not finish within its flight's deadline."""
450
+
451
+
452
+ class _DiagnosisClientGone(Exception):
453
+ """The requesting peer disconnected while its diagnosis was pending."""
454
+
455
+
456
+ def _diagnosis_peer_gone(connection: socket.socket) -> bool:
457
+ """Check a waiting GET socket without consuming any request bytes."""
458
+ try:
459
+ if not select.select([connection], [], [], 0)[0]:
460
+ return False
461
+ return not connection.recv(
462
+ 1, socket.MSG_PEEK | getattr(socket, "MSG_DONTWAIT", 0))
463
+ except (OSError, ValueError):
464
+ return True
335
465
 
336
466
 
337
467
  def _diagnosis_flight_key(
@@ -360,48 +490,155 @@ class _DiagnosisAdmission:
360
490
  never retained, so this adds no stale report cache or periodic work.
361
491
  """
362
492
 
363
- def __init__(self) -> None:
493
+ def __init__(self, *, max_callers: int = 32, max_per_key: int = 16,
494
+ max_keys: int = 8, deadline_seconds: float = 15.0) -> None:
495
+ if min(max_callers, max_per_key, max_keys) < 1 or deadline_seconds <= 0:
496
+ raise ValueError("diagnosis admission limits must be positive")
364
497
  self._changed = threading.Condition()
365
498
  self._admission = threading.BoundedSemaphore(1)
366
499
  self._flights: dict[tuple[Any, bool, bool], _DiagnosisFlight] = {}
500
+ self._callers = 0
501
+ self._max_callers = max_callers
502
+ self._max_per_key = max_per_key
503
+ self._max_keys = max_keys
504
+ self._deadline_seconds = deadline_seconds
367
505
 
368
- def run(self, key: tuple[Any, bool, bool], build):
506
+ def _finish(self, key, flight, *, result=None, error=None) -> None:
507
+ with self._changed:
508
+ if flight.done.is_set():
509
+ return
510
+ flight.result = result
511
+ flight.error = error
512
+ flight.done.set()
513
+ if self._flights.get(key) is flight:
514
+ del self._flights[key]
515
+ self._changed.notify_all()
516
+
517
+ def _produce(self, key, flight, build, bounded_build) -> None:
518
+ remaining = max(0.0, flight.deadline - time.monotonic())
519
+ acquired = self._admission.acquire(timeout=remaining)
520
+ if not acquired:
521
+ self._finish(key, flight, error=_DiagnosisTimedOut())
522
+ return
523
+ try:
524
+ # A queued flight may have expired while a prior producer held the
525
+ # single process-wide slot. It must never start a late build.
526
+ if flight.done.is_set() or time.monotonic() >= flight.deadline:
527
+ self._finish(key, flight, error=_DiagnosisTimedOut())
528
+ return
529
+ try:
530
+ remaining = max(0.0, flight.deadline - time.monotonic())
531
+ result = (
532
+ bounded_build(remaining)
533
+ if bounded_build is not None else build()
534
+ )
535
+ except TimeoutError:
536
+ self._finish(key, flight, error=_DiagnosisTimedOut())
537
+ except BaseException as exc:
538
+ self._finish(
539
+ key, flight,
540
+ error=(_DiagnosisTimedOut()
541
+ if time.monotonic() >= flight.deadline else exc),
542
+ )
543
+ else:
544
+ if time.monotonic() >= flight.deadline:
545
+ self._finish(key, flight, error=_DiagnosisTimedOut())
546
+ else:
547
+ self._finish(key, flight, result=result)
548
+ finally:
549
+ # Production passes a killable process build, so reaching the
550
+ # deadline retires that process tree before this slot is released.
551
+ # Direct in-process builds remain available only to focused unit
552
+ # callers whose producer is known to terminate.
553
+ self._admission.release()
554
+
555
+ def run(self, key: tuple[Any, bool, bool], build, *, peer_gone=None,
556
+ bounded_build=None):
369
557
  with self._changed:
370
558
  flight = self._flights.get(key)
371
559
  owner = flight is None
560
+ if self._callers >= self._max_callers:
561
+ raise _DiagnosisOverloaded()
372
562
  if owner:
373
- flight = _DiagnosisFlight()
563
+ if len(self._flights) >= self._max_keys:
564
+ raise _DiagnosisOverloaded()
565
+ flight = _DiagnosisFlight(
566
+ deadline=time.monotonic() + self._deadline_seconds,
567
+ )
374
568
  self._flights[key] = flight
569
+ elif flight.callers >= self._max_per_key:
570
+ raise _DiagnosisOverloaded()
375
571
  flight.callers += 1
572
+ self._callers += 1
376
573
  self._changed.notify_all()
377
-
378
- if not owner:
379
- flight.done.wait()
380
- if flight.error is not None:
381
- raise flight.error
382
- return flight.result
383
-
384
574
  try:
385
- with self._admission:
386
- result = build()
387
- with self._changed:
388
- flight.result = result
389
- return result
390
- except BaseException as exc:
575
+ if owner:
576
+ try:
577
+ threading.Thread(
578
+ target=self._produce,
579
+ args=(key, flight, build, bounded_build),
580
+ name="cctally-diagnosis", daemon=True,
581
+ ).start()
582
+ except BaseException as exc:
583
+ self._finish(key, flight, error=exc)
584
+ while not flight.done.is_set():
585
+ if peer_gone is not None and peer_gone():
586
+ raise _DiagnosisClientGone()
587
+ remaining = flight.deadline - time.monotonic()
588
+ if remaining <= 0:
589
+ break
590
+ flight.done.wait(min(0.1, remaining))
591
+ if not flight.done.is_set():
592
+ self._finish(key, flight, error=_DiagnosisTimedOut())
391
593
  with self._changed:
392
- flight.error = exc
393
- raise
594
+ if flight.error is not None:
595
+ raise flight.error
596
+ return flight.result
394
597
  finally:
395
598
  with self._changed:
396
- if self._flights.get(key) is flight:
397
- del self._flights[key]
398
- flight.done.set()
599
+ flight.callers -= 1
600
+ self._callers -= 1
399
601
  self._changed.notify_all()
400
602
 
401
603
 
402
604
  _DIAGNOSIS_ADMISSION = _DiagnosisAdmission()
403
605
 
404
606
 
607
+ class _StaticValidatorCache:
608
+ """Bounded validators for bytes already served by this process."""
609
+
610
+ def __init__(self, max_entries: int = 128) -> None:
611
+ self._lock = threading.Lock()
612
+ self._entries: OrderedDict[tuple[pathlib.Path, bool],
613
+ tuple[tuple[int, ...], str]] = OrderedDict()
614
+ self._max_entries = max_entries
615
+
616
+ @staticmethod
617
+ def fingerprint(stat_result: os.stat_result) -> tuple[int, ...]:
618
+ # ctime catches a rewrite that restores size and mtime. Device/inode
619
+ # distinguishes replacement of a mutable-name shell during a build.
620
+ return (stat_result.st_dev, stat_result.st_ino, stat_result.st_size,
621
+ stat_result.st_mtime_ns, stat_result.st_ctime_ns)
622
+
623
+ def get(self, key, fingerprint) -> str | None:
624
+ with self._lock:
625
+ entry = self._entries.get(key)
626
+ if entry is None or entry[0] != fingerprint:
627
+ return None
628
+ self._entries.move_to_end(key)
629
+ return entry[1]
630
+
631
+ def put(self, key, fingerprint, etag: str) -> None:
632
+ with self._lock:
633
+ self._entries[key] = (fingerprint, etag)
634
+ self._entries.move_to_end(key)
635
+ if len(self._entries) > self._max_entries:
636
+ self._entries.popitem(last=False)
637
+
638
+
639
+ _STATIC_VALIDATORS = _StaticValidatorCache()
640
+
641
+
405
642
  def _cctally():
406
643
  """Resolve the current ``cctally`` module at call-time (spec §5.5)."""
407
644
  return sys.modules["cctally"]
@@ -475,10 +712,6 @@ from _lib_aggregators import (
475
712
  _finalize_bucket,
476
713
  )
477
714
  from _lib_fmt import stable_sum
478
- # The MODULE, not just the names: #714's `pricing_snapshot_context` has to be
479
- # reached through the live module object so a test that swaps the snapshot is
480
- # seen by the request, build and sync entries below.
481
- import _lib_pricing
482
715
  from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
483
716
  _short_model_name, claude_usage_dict)
484
717
  from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
@@ -669,6 +902,7 @@ from _cctally_dashboard_envelope import (
669
902
  _envelope_rows_projected,
670
903
  _envelope_rows_project_budget,
671
904
  _ENVELOPE_AXIS_MAPPERS,
905
+ _alert_account_resolver,
672
906
  _build_alerts_envelope_array,
673
907
  _build_meter_rate_change_array,
674
908
  _model_breakdowns_to_models,
@@ -2034,13 +2268,6 @@ def _make_dashboard_run_iteration(
2034
2268
 
2035
2269
  def run_iteration(batch=None) -> dict:
2036
2270
  warnings: list = []
2037
- # #714: the whole snapshot build runs on ONE pricing revision. A swap
2038
- # landing part-way through would publish a snapshot whose panels were
2039
- # priced from two different tables.
2040
- with _lib_pricing.pricing_snapshot_context():
2041
- return _run_iteration_body(batch, warnings)
2042
-
2043
- def _run_iteration_body(batch, warnings: list) -> dict:
2044
2271
  if batch is not None and batch[1] and not skip_sync:
2045
2272
  with sync_lock:
2046
2273
  result = _refresh_usage_inproc()
@@ -2318,103 +2545,38 @@ def _wake_schema_writer() -> None:
2318
2545
  _SCHEMA_WAKE_THREAD = None
2319
2546
 
2320
2547
 
2321
- #: How often the pricing watch stats the deployed pricing file. A monotonic
2322
- #: cadence, because a wall-clock one would re-poll or stall across a system
2323
- #: clock change.
2324
- _PRICING_WATCH_INTERVAL_S = 60.0
2548
+ _INSTALL_WATCH = None
2325
2549
 
2326
2550
 
2327
- class _PricingWatch:
2328
- """Detect a replaced `_lib_pricing.py` and adopt it in process (#714).
2551
+ def _start_install_watch(boot):
2552
+ """Own the install watch on its own lifecycle thread, in every mode (#868).
2329
2553
 
2330
- An installer overwrites the deployed file under a running dashboard.
2331
- Nothing about that reaches this process: every CLI invocation re-imports
2332
- and picks the new tables up, and only a long-lived process holds the old
2333
- ones.
2334
-
2335
- STAT FIRST, PARSE SECOND. The poll compares an inode/size/mtime signature
2336
- and parses only when it changed, so the steady state is one `stat` a
2337
- minute rather than an `ast.parse` of a 1,400-line file.
2338
-
2339
- NEVER RAISES OUT OF `poll_once`. The file it reads is one an installer may
2340
- be part-way through writing, so a half-written file is the ordinary case
2341
- rather than an exceptional one; a raising poll would end the thread and
2342
- leave the process permanently stale, which is the very condition this
2343
- exists to clear.
2554
+ Not started from a git checkout, where file changes are development work,
2555
+ not installs.
2344
2556
  """
2345
-
2346
- def __init__(self, source_path=None, interval_s=None):
2347
- self.source_path = pathlib.Path(
2348
- source_path if source_path is not None
2349
- else sys.modules["_lib_pricing"].__file__)
2350
- self.interval_s = (_PRICING_WATCH_INTERVAL_S if interval_s is None
2351
- else interval_s)
2352
- self._signature = None
2353
- self._stop = threading.Event()
2354
-
2355
- def _current_signature(self):
2356
- try:
2357
- st = self.source_path.stat()
2358
- except OSError:
2359
- return None
2360
- return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
2361
-
2362
- def poll_once(self) -> bool:
2363
- """One check. Returns True iff a new snapshot was adopted."""
2364
- try:
2365
- signature = self._current_signature()
2366
- if signature is None or signature == self._signature:
2367
- return False
2368
- self._signature = signature
2369
- pricing = sys.modules["_lib_pricing"]
2370
- candidate = pricing.read_pricing_snapshot_from_source(
2371
- self.source_path)
2372
- if not pricing.adopt_pricing_candidate(candidate):
2373
- return False
2374
- eprint("[pricing] adopted pricing revision "
2375
- f"{candidate.snapshot_date} without restarting")
2376
- return True
2377
- except Exception: # noqa: BLE001
2378
- # Including `PricingCandidateRejected`: a rejected candidate is a
2379
- # normal outcome, and the signature is already recorded so the
2380
- # same bad file is not re-parsed every minute.
2381
- return False
2382
-
2383
- def run(self) -> None:
2384
- while not self._stop.wait(self.interval_s):
2385
- self.poll_once()
2386
-
2387
- def stop(self) -> None:
2388
- self._stop.set()
2389
-
2390
-
2391
- _PRICING_WATCH: "_PricingWatch | None" = None
2392
-
2393
-
2394
- def _start_pricing_watch() -> "_PricingWatch | None":
2395
- """Own the pricing detector on a lifecycle thread that runs in BOTH modes.
2396
-
2397
- Deliberately not `_DashboardSyncThread` and not the conversation sync
2398
- thread: `cmd_dashboard` disables both under `--no-sync`, so a detector in
2399
- either would silently have no owner exactly where a long-lived process is
2400
- most likely to run.
2401
- """
2402
- global _PRICING_WATCH
2403
- if _PRICING_WATCH is not None:
2404
- return _PRICING_WATCH
2557
+ global _INSTALL_WATCH
2558
+ if _cctally_core._is_dev_checkout():
2559
+ return None
2560
+ if _INSTALL_WATCH is not None:
2561
+ return _INSTALL_WATCH
2405
2562
  try:
2406
- watch = _PricingWatch()
2407
- watch.poll_once() # establish the baseline signature; adopts nothing
2408
- thread = threading.Thread(
2409
- target=watch.run, name="cctally-pricing-watch", daemon=True)
2410
- thread.start()
2563
+ watch = InstallWatch(
2564
+ boot, worker_fn=lambda: getattr(_cctally(), "_UPDATE_WORKER", None))
2565
+ watch.start()
2411
2566
  except Exception as exc: # noqa: BLE001
2412
- eprint(f"[pricing] watch not started ({exc})")
2567
+ eprint(f"[update] install watch not started ({exc})")
2413
2568
  return None
2414
- _PRICING_WATCH = watch
2569
+ _INSTALL_WATCH = watch
2415
2570
  return watch
2416
2571
 
2417
2572
 
2573
+ def _stop_install_watch() -> None:
2574
+ global _INSTALL_WATCH
2575
+ watch, _INSTALL_WATCH = _INSTALL_WATCH, None
2576
+ if watch is not None:
2577
+ watch.stop()
2578
+
2579
+
2418
2580
  def _dashboard_maybe_prune_retention() -> None:
2419
2581
  """#313 P3 (F7): throttled transcript retention prune driven from the
2420
2582
  independent conversation sync thread. Opens a dedicated transcript
@@ -2574,18 +2736,7 @@ def _conversation_sync_pass() -> str:
2574
2736
  therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
2575
2737
  because a successful pass opens the store twice by design, once here and
2576
2738
  once inside the prune.
2577
-
2578
- #714: the whole pass runs on ONE pricing revision. This pass writes
2579
- materialized cost, so a swap landing between the Claude leg and the Codex
2580
- leg would derive one store's rollup from two revisions — the ordered-write
2581
- guard would not even see it, because both halves are the same process.
2582
2739
  """
2583
- with _lib_pricing.pricing_snapshot_context():
2584
- return _conversation_sync_pass_body()
2585
-
2586
-
2587
- def _conversation_sync_pass_body() -> str:
2588
- """The body of `_conversation_sync_pass`, run under its pricing context."""
2589
2740
  try:
2590
2741
  conn = open_conversations_db()
2591
2742
  except (OSError, sqlite3.DatabaseError) as exc:
@@ -3137,6 +3288,14 @@ def _DashboardUpdateCheckThread(*args, **kwargs):
3137
3288
  return sys.modules["cctally"]._DashboardUpdateCheckThread(*args, **kwargs)
3138
3289
 
3139
3290
 
3291
+ def capture_install_boot_state(*args, **kwargs):
3292
+ return sys.modules["cctally"].capture_install_boot_state(*args, **kwargs)
3293
+
3294
+
3295
+ def InstallWatch(*args, **kwargs):
3296
+ return sys.modules["cctally"].InstallWatch(*args, **kwargs)
3297
+
3298
+
3140
3299
  # Module-level __getattr__ — lazy-resolves a handful of cctally globals at
3141
3300
  # attribute-access time (used for the ``except cctally.SomeError`` sites
3142
3301
  # that need real class identity, and for non-callable constants that the
@@ -4807,6 +4966,7 @@ def _blocks_view_with_retained_facts(conn, view):
4807
4966
  models=_model_breakdowns_to_models(
4808
4967
  facts["model_breakdowns"], facts["cost_usd"],
4809
4968
  ),
4969
+ facts_source="retained",
4810
4970
  )
4811
4971
  return dataclasses.replace(
4812
4972
  view,
@@ -8371,19 +8531,6 @@ _GET_ROUTES = (
8371
8531
  ("scope", "endpoint.conversation_detail"), True),
8372
8532
  )
8373
8533
 
8374
- #: Handlers that block for the life of a client connection rather than for one
8375
- #: response. `_dispatch` must NOT pin a pricing revision around these: the pin
8376
- #: would last as long as the stream, so a viewer who left a tab open would keep
8377
- #: pricing from whichever revision was live when the tab connected — the exact
8378
- #: staleness #714 removes. Both are streams that carry no cost of their own;
8379
- #: the envelopes they forward are priced by the build that produced them, which
8380
- #: has its own context.
8381
- _STREAMING_ROUTE_HANDLERS = frozenset({
8382
- "_serve_api_events",
8383
- "_handle_get_update_stream",
8384
- "_handle_get_conversation_events",
8385
- })
8386
-
8387
8534
  _POST_ROUTES = (
8388
8535
  ("exact", "/api/auth", "_handle_post_auth", None, False),
8389
8536
  ("exact", "/api/sync", "_handle_post_sync", None, False),
@@ -8519,13 +8666,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8519
8666
  eleven path-taking handlers. (NOTE: returning a bool, not ``fn()`` —
8520
8667
  the void handlers all return None, so a None-return contract could not
8521
8668
  tell "handled" from "unmatched".)
8522
-
8523
- #714: every matched route runs inside a pricing-snapshot context, so a
8524
- response is never assembled from two pricing revisions when an
8525
- installer replaces `_lib_pricing.py` mid-request. The two long-lived
8526
- streaming routes are excluded — a context entered there would pin one
8527
- revision for the whole life of the stream, which is minutes to hours
8528
- and the opposite of what the reload exists to achieve.
8529
8669
  """
8530
8670
  path = self.path.split("?", 1)[0]
8531
8671
  for kind, pattern, name, perf, wants_path in table:
@@ -8539,19 +8679,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8539
8679
  continue
8540
8680
  fn = getattr(self, name)
8541
8681
  args = (path,) if wants_path else ()
8542
- if name in _STREAMING_ROUTE_HANDLERS:
8543
- pinned = contextlib.nullcontext()
8544
- else:
8545
- pinned = _lib_pricing.pricing_snapshot_context()
8546
- with pinned:
8547
- if perf is None:
8682
+ if perf is None:
8683
+ fn(*args)
8684
+ elif perf[0] == "scope":
8685
+ with self._perf_scope(perf[1]):
8686
+ fn(*args)
8687
+ else: # "phase"
8688
+ with self._perf_gate().phase(perf[1]):
8548
8689
  fn(*args)
8549
- elif perf[0] == "scope":
8550
- with self._perf_scope(perf[1]):
8551
- fn(*args)
8552
- else: # "phase"
8553
- with self._perf_gate().phase(perf[1]):
8554
- fn(*args)
8555
8690
  return True
8556
8691
  return False
8557
8692
 
@@ -10103,7 +10238,31 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
10103
10238
  primary_model="claude-sonnet-4-6",
10104
10239
  )
10105
10240
  status = _dispatch_alert_notification(payload, mode="test")
10106
- self._respond_json(200, {"alert": payload, "dispatch": status})
10241
+ # Dispatch and alerts.log keep the builder's internal snake_case key.
10242
+ # The response is an AlertEntry-shaped toast preview, so its public
10243
+ # account fields must follow the same R8 gate as real envelope rows.
10244
+ # Work on a copy: removing the key from `payload` would also change
10245
+ # what the native notifier and test log receive.
10246
+ response_alert = {k: v for k, v in payload.items() if k != "account_key"}
10247
+ provider = (
10248
+ "codex" if axis == "codex_budget" or (
10249
+ axis == "projected" and metric == "codex_budget_usd"
10250
+ ) else "claude"
10251
+ )
10252
+ if _cctally_core.DB_PATH.exists():
10253
+ try:
10254
+ conn = _stats_ro_guarded()
10255
+ try:
10256
+ response_alert.update(_alert_account_resolver(conn)(
10257
+ provider, payload.get("account_key"),
10258
+ ))
10259
+ finally:
10260
+ conn.close()
10261
+ except (OSError, sqlite3.Error):
10262
+ # A rehearsal still works without a readable account registry;
10263
+ # no writable DB open or synthetic milestone is needed.
10264
+ pass
10265
+ self._respond_json(200, {"alert": response_alert, "dispatch": status})
10107
10266
 
10108
10267
 
10109
10268
  # ---- share endpoints (spec §5.1) — thin delegators to the F1 sibling ----
@@ -10198,15 +10357,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
10198
10357
  return False
10199
10358
 
10200
10359
  def _serve_static_file(self, path: pathlib.Path, ctype: str) -> None:
10201
- try:
10202
- body = path.read_bytes()
10203
- except FileNotFoundError:
10204
- self.send_error(404, "not found")
10205
- return
10206
- except IsADirectoryError:
10207
- self.send_error(404, "not found")
10208
- return
10209
-
10210
10360
  compressible = (
10211
10361
  ctype.startswith("text/")
10212
10362
  or ctype.startswith("application/javascript")
@@ -10215,15 +10365,54 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
10215
10365
  gzip_on = compressible and _accepts_gzip(
10216
10366
  self.headers.get("Accept-Encoding")
10217
10367
  )
10218
- encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
10219
- etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
10220
10368
  cache_control = (
10221
10369
  "public, max-age=31536000, immutable"
10222
10370
  if self._is_hashed_static_asset(path)
10223
10371
  else "no-cache"
10224
10372
  )
10373
+ validator = self.headers.get("If-None-Match")
10374
+ key = (path, gzip_on)
10375
+ try:
10376
+ before = _STATIC_VALIDATORS.fingerprint(path.stat())
10377
+ except (FileNotFoundError, IsADirectoryError):
10378
+ self.send_error(404, "not found")
10379
+ return
10380
+
10381
+ # Only a validator derived from bytes previously served for this exact
10382
+ # encoding and unchanged file identity can answer before I/O. In
10383
+ # particular, a mutable dashboard.html replacement invalidates this.
10384
+ if validator:
10385
+ warm_etag = _STATIC_VALIDATORS.get(key, before)
10386
+ if warm_etag and self._etag_matches(validator, warm_etag):
10387
+ self.send_response(304)
10388
+ self.send_header("ETag", warm_etag)
10389
+ self.send_header("Cache-Control", cache_control)
10390
+ if compressible:
10391
+ self.send_header("Vary", "Accept-Encoding")
10392
+ if gzip_on:
10393
+ self.send_header("Content-Encoding", "gzip")
10394
+ self.end_headers()
10395
+ return
10225
10396
 
10226
- if self._etag_matches(self.headers.get("If-None-Match"), etag):
10397
+ try:
10398
+ body = path.read_bytes()
10399
+ except FileNotFoundError:
10400
+ self.send_error(404, "not found")
10401
+ return
10402
+ except IsADirectoryError:
10403
+ self.send_error(404, "not found")
10404
+ return
10405
+
10406
+ encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
10407
+ etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
10408
+ try:
10409
+ after = _STATIC_VALIDATORS.fingerprint(path.stat())
10410
+ except (FileNotFoundError, IsADirectoryError):
10411
+ after = None
10412
+ if after == before:
10413
+ _STATIC_VALIDATORS.put(key, before, etag)
10414
+
10415
+ if after == before and self._etag_matches(validator, etag):
10227
10416
  self.send_response(304)
10228
10417
  self.send_header("ETag", etag)
10229
10418
  self.send_header("Cache-Control", cache_control)
@@ -10588,12 +10777,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
10588
10777
  scope, transcripts_visible, reveal,
10589
10778
  )
10590
10779
 
10591
- def _prepare_diagnosis():
10592
- report = sources.build_diagnosis(
10593
- scope,
10594
- measured_at=now_utc,
10595
- transcripts_visible=transcripts_visible,
10596
- )
10780
+ def _project_diagnosis(report):
10597
10781
  scopes = {
10598
10782
  result.source: diagnosis._scope_for(
10599
10783
  sources, scope, result.source,
@@ -10612,10 +10796,43 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
10612
10796
  )
10613
10797
  return status, body
10614
10798
 
10799
+ def _prepare_diagnosis():
10800
+ return _project_diagnosis(sources.build_diagnosis(
10801
+ scope,
10802
+ measured_at=now_utc,
10803
+ transcripts_visible=transcripts_visible,
10804
+ ))
10805
+
10806
+ def _prepare_bounded_diagnosis(remaining):
10807
+ return _project_diagnosis(
10808
+ sources.build_diagnosis_bounded(
10809
+ scope,
10810
+ measured_at=now_utc,
10811
+ transcripts_visible=transcripts_visible,
10812
+ timeout_seconds=remaining,
10813
+ )
10814
+ )
10815
+
10615
10816
  status, body = _DIAGNOSIS_ADMISSION.run(
10616
10817
  flight_key,
10617
10818
  _prepare_diagnosis,
10819
+ peer_gone=lambda: _diagnosis_peer_gone(self.connection),
10820
+ bounded_build=_prepare_bounded_diagnosis,
10618
10821
  )
10822
+ except _DiagnosisClientGone:
10823
+ return
10824
+ except _DiagnosisOverloaded:
10825
+ self._send_diagnosis_json(503, {
10826
+ "error": "diagnosis busy; retry shortly",
10827
+ "code": "diagnosis_overloaded",
10828
+ })
10829
+ return
10830
+ except _DiagnosisTimedOut:
10831
+ self._send_diagnosis_json(503, {
10832
+ "error": "diagnosis timed out; retry shortly",
10833
+ "code": "diagnosis_timeout",
10834
+ })
10835
+ return
10619
10836
  except _DiagnosisSelectorError as exc:
10620
10837
  self._send_diagnosis_json(
10621
10838
  400, {"error": exc.message, "code": exc.code})
@@ -11410,7 +11627,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11410
11627
  as an explicit user pin; the worker resolves the selected channel
11411
11628
  afresh. CSRF-gated. Returns
11412
11629
  202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
11413
- when another run is already in progress.
11630
+ when another run is already in progress; 503 + ``{"error": ...}`` while
11631
+ the install watcher holds the automatic-restart claim (#868), which
11632
+ the unchanged client shows as a failed start.
11414
11633
  """
11415
11634
  if not self._check_origin_csrf():
11416
11635
  return
@@ -11432,7 +11651,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
11432
11651
  # The dashboard has no explicit-version input. Older clients sent the
11433
11652
  # cached beta target here; trusting it as a pin recreates #342 whenever
11434
11653
  # the registry advances while the modal is open.
11435
- accepted, run_id = worker.start(None)
11654
+ try:
11655
+ accepted, run_id = worker.start(None)
11656
+ except sys.modules["cctally"].AutomaticRestartInProgress as exc:
11657
+ self._respond_json(503, {"error": str(exc)})
11658
+ return
11436
11659
  if accepted:
11437
11660
  self._respond_json(202, {"run_id": run_id})
11438
11661
  else:
@@ -12018,16 +12241,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12018
12241
  _cctally_core.enable_quota_projection_reconciliation()
12019
12242
 
12020
12243
  # Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
12021
- # at boot so the in-place ``execvp`` after a successful update
12022
- # re-enters the user-facing wrapper (npm Node shim → CCTALLY_PYTHON
12023
- # honoured; brew symlink → post-upgrade Python script). Module-level
12024
- # globals live in cctally (declared at L23205 pre-extract); we write
12025
- # them via the module-proxy so ``UpdateWorker`` (in _cctally_update)
12026
- # reads the right values via ``cctally.X`` at call time.
12244
+ # at boot. ``_resolve_execvp_target`` derives the in-place restart
12245
+ # from them: an npm install execs Python directly on its own
12246
+ # ``bin/cctally`` (no added Node process, #868), and a brew install
12247
+ # re-enters the symlink into the newly linked keg. Module-level
12248
+ # globals live in cctally; we write them via the module-proxy so
12249
+ # ``UpdateWorker`` and the install watch (in _cctally_update) read the
12250
+ # right values via ``cctally.X`` at call time.
12027
12251
  _c_boot = _cctally()
12028
12252
  _c_boot.ORIGINAL_SYS_ARGV = list(sys.argv)
12029
12253
  _c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
12030
12254
  _c_boot._UPDATE_WORKER = UpdateWorker()
12255
+ # #868: captured before any thread starts; the install watch compares every
12256
+ # later poll against what this process booted from.
12257
+ _install_boot = capture_install_boot_state()
12031
12258
 
12032
12259
  # Load config for the bind-host + expose-transcripts resolution below. (#217
12033
12260
  # S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
@@ -12112,11 +12339,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12112
12339
  _cctally_cache_module.set_conversation_derivations_suppressed(
12113
12340
  bool(args.no_sync))
12114
12341
  _dashboard_startup_schema_migration(run_derivations=not bool(args.no_sync))
12115
- # #714: the pricing-file watch, in EVERY mode for the same reason. Both
12116
- # `_DashboardSyncThread` and `_make_conversation_sync_thread` are disabled
12117
- # under `--no-sync`, so a detector placed in either would have no owner in
12118
- # the one mode a long-lived read-only dashboard runs in.
12119
- _start_pricing_watch()
12342
+ # #868: in EVERY mode, because `--no-sync` disables both sync threads and a
12343
+ # long-lived read-only dashboard is still left behind by an install.
12344
+ _start_install_watch(_install_boot)
12120
12345
  _heal = _dashboard_self_heal_orphans(skip_sync=bool(args.no_sync))
12121
12346
  if _heal is not None and _heal.pruned_files:
12122
12347
  print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
@@ -12382,6 +12607,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12382
12607
  try:
12383
12608
  _dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
12384
12609
  finally:
12610
+ # First, so a shutdown can never be followed by an automatic restart.
12611
+ _stop_install_watch()
12385
12612
  if sync_thread is not None:
12386
12613
  sync_thread.stop()
12387
12614
  conversation_sync_stop.set()