cctally 1.109.0 → 1.110.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.
- package/CHANGELOG.md +46 -0
- package/README.md +6 -6
- package/bin/_cctally_cache.py +62 -13
- package/bin/_cctally_core.py +5 -1
- package/bin/_cctally_dashboard.py +362 -41
- package/bin/_cctally_dashboard_conversation.py +231 -40
- package/bin/_cctally_dashboard_envelope.py +48 -32
- package/bin/_cctally_dashboard_sources.py +503 -109
- package/bin/_cctally_db.py +44 -0
- package/bin/_cctally_diagnosis_sources.py +181 -15
- package/bin/_cctally_doctor.py +15 -3
- package/bin/_cctally_forecast.py +25 -12
- package/bin/_cctally_journal.py +59 -32
- package/bin/_cctally_milestone_history.py +65 -9
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_record.py +50 -12
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_source_analytics.py +567 -133
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +39 -6
- package/bin/_lib_codex_conversation_query.py +286 -63
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation_query.py +254 -37
- package/bin/_lib_doctor.py +16 -2
- package/bin/_lib_pricing.py +69 -14
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_view_models.py +5 -0
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +2 -1
- package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
- package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
- package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
- package/dashboard/static/assets/index-DLVwACUM.js +0 -13
- 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
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
393
|
-
|
|
594
|
+
if flight.error is not None:
|
|
595
|
+
raise flight.error
|
|
596
|
+
return flight.result
|
|
394
597
|
finally:
|
|
395
598
|
with self._changed:
|
|
396
|
-
|
|
397
|
-
|
|
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"]
|
|
@@ -669,6 +906,7 @@ from _cctally_dashboard_envelope import (
|
|
|
669
906
|
_envelope_rows_projected,
|
|
670
907
|
_envelope_rows_project_budget,
|
|
671
908
|
_ENVELOPE_AXIS_MAPPERS,
|
|
909
|
+
_alert_account_resolver,
|
|
672
910
|
_build_alerts_envelope_array,
|
|
673
911
|
_build_meter_rate_change_array,
|
|
674
912
|
_model_breakdowns_to_models,
|
|
@@ -4807,6 +5045,7 @@ def _blocks_view_with_retained_facts(conn, view):
|
|
|
4807
5045
|
models=_model_breakdowns_to_models(
|
|
4808
5046
|
facts["model_breakdowns"], facts["cost_usd"],
|
|
4809
5047
|
),
|
|
5048
|
+
facts_source="retained",
|
|
4810
5049
|
)
|
|
4811
5050
|
return dataclasses.replace(
|
|
4812
5051
|
view,
|
|
@@ -10103,7 +10342,31 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10103
10342
|
primary_model="claude-sonnet-4-6",
|
|
10104
10343
|
)
|
|
10105
10344
|
status = _dispatch_alert_notification(payload, mode="test")
|
|
10106
|
-
|
|
10345
|
+
# Dispatch and alerts.log keep the builder's internal snake_case key.
|
|
10346
|
+
# The response is an AlertEntry-shaped toast preview, so its public
|
|
10347
|
+
# account fields must follow the same R8 gate as real envelope rows.
|
|
10348
|
+
# Work on a copy: removing the key from `payload` would also change
|
|
10349
|
+
# what the native notifier and test log receive.
|
|
10350
|
+
response_alert = {k: v for k, v in payload.items() if k != "account_key"}
|
|
10351
|
+
provider = (
|
|
10352
|
+
"codex" if axis == "codex_budget" or (
|
|
10353
|
+
axis == "projected" and metric == "codex_budget_usd"
|
|
10354
|
+
) else "claude"
|
|
10355
|
+
)
|
|
10356
|
+
if _cctally_core.DB_PATH.exists():
|
|
10357
|
+
try:
|
|
10358
|
+
conn = _stats_ro_guarded()
|
|
10359
|
+
try:
|
|
10360
|
+
response_alert.update(_alert_account_resolver(conn)(
|
|
10361
|
+
provider, payload.get("account_key"),
|
|
10362
|
+
))
|
|
10363
|
+
finally:
|
|
10364
|
+
conn.close()
|
|
10365
|
+
except (OSError, sqlite3.Error):
|
|
10366
|
+
# A rehearsal still works without a readable account registry;
|
|
10367
|
+
# no writable DB open or synthetic milestone is needed.
|
|
10368
|
+
pass
|
|
10369
|
+
self._respond_json(200, {"alert": response_alert, "dispatch": status})
|
|
10107
10370
|
|
|
10108
10371
|
|
|
10109
10372
|
# ---- share endpoints (spec §5.1) — thin delegators to the F1 sibling ----
|
|
@@ -10198,15 +10461,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10198
10461
|
return False
|
|
10199
10462
|
|
|
10200
10463
|
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
10464
|
compressible = (
|
|
10211
10465
|
ctype.startswith("text/")
|
|
10212
10466
|
or ctype.startswith("application/javascript")
|
|
@@ -10215,15 +10469,54 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10215
10469
|
gzip_on = compressible and _accepts_gzip(
|
|
10216
10470
|
self.headers.get("Accept-Encoding")
|
|
10217
10471
|
)
|
|
10218
|
-
encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
|
|
10219
|
-
etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
|
|
10220
10472
|
cache_control = (
|
|
10221
10473
|
"public, max-age=31536000, immutable"
|
|
10222
10474
|
if self._is_hashed_static_asset(path)
|
|
10223
10475
|
else "no-cache"
|
|
10224
10476
|
)
|
|
10477
|
+
validator = self.headers.get("If-None-Match")
|
|
10478
|
+
key = (path, gzip_on)
|
|
10479
|
+
try:
|
|
10480
|
+
before = _STATIC_VALIDATORS.fingerprint(path.stat())
|
|
10481
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
10482
|
+
self.send_error(404, "not found")
|
|
10483
|
+
return
|
|
10484
|
+
|
|
10485
|
+
# Only a validator derived from bytes previously served for this exact
|
|
10486
|
+
# encoding and unchanged file identity can answer before I/O. In
|
|
10487
|
+
# particular, a mutable dashboard.html replacement invalidates this.
|
|
10488
|
+
if validator:
|
|
10489
|
+
warm_etag = _STATIC_VALIDATORS.get(key, before)
|
|
10490
|
+
if warm_etag and self._etag_matches(validator, warm_etag):
|
|
10491
|
+
self.send_response(304)
|
|
10492
|
+
self.send_header("ETag", warm_etag)
|
|
10493
|
+
self.send_header("Cache-Control", cache_control)
|
|
10494
|
+
if compressible:
|
|
10495
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
10496
|
+
if gzip_on:
|
|
10497
|
+
self.send_header("Content-Encoding", "gzip")
|
|
10498
|
+
self.end_headers()
|
|
10499
|
+
return
|
|
10500
|
+
|
|
10501
|
+
try:
|
|
10502
|
+
body = path.read_bytes()
|
|
10503
|
+
except FileNotFoundError:
|
|
10504
|
+
self.send_error(404, "not found")
|
|
10505
|
+
return
|
|
10506
|
+
except IsADirectoryError:
|
|
10507
|
+
self.send_error(404, "not found")
|
|
10508
|
+
return
|
|
10509
|
+
|
|
10510
|
+
encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
|
|
10511
|
+
etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
|
|
10512
|
+
try:
|
|
10513
|
+
after = _STATIC_VALIDATORS.fingerprint(path.stat())
|
|
10514
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
10515
|
+
after = None
|
|
10516
|
+
if after == before:
|
|
10517
|
+
_STATIC_VALIDATORS.put(key, before, etag)
|
|
10225
10518
|
|
|
10226
|
-
if self._etag_matches(
|
|
10519
|
+
if after == before and self._etag_matches(validator, etag):
|
|
10227
10520
|
self.send_response(304)
|
|
10228
10521
|
self.send_header("ETag", etag)
|
|
10229
10522
|
self.send_header("Cache-Control", cache_control)
|
|
@@ -10588,12 +10881,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10588
10881
|
scope, transcripts_visible, reveal,
|
|
10589
10882
|
)
|
|
10590
10883
|
|
|
10591
|
-
def
|
|
10592
|
-
report = sources.build_diagnosis(
|
|
10593
|
-
scope,
|
|
10594
|
-
measured_at=now_utc,
|
|
10595
|
-
transcripts_visible=transcripts_visible,
|
|
10596
|
-
)
|
|
10884
|
+
def _project_diagnosis(report):
|
|
10597
10885
|
scopes = {
|
|
10598
10886
|
result.source: diagnosis._scope_for(
|
|
10599
10887
|
sources, scope, result.source,
|
|
@@ -10612,10 +10900,43 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
10612
10900
|
)
|
|
10613
10901
|
return status, body
|
|
10614
10902
|
|
|
10903
|
+
def _prepare_diagnosis():
|
|
10904
|
+
return _project_diagnosis(sources.build_diagnosis(
|
|
10905
|
+
scope,
|
|
10906
|
+
measured_at=now_utc,
|
|
10907
|
+
transcripts_visible=transcripts_visible,
|
|
10908
|
+
))
|
|
10909
|
+
|
|
10910
|
+
def _prepare_bounded_diagnosis(remaining):
|
|
10911
|
+
return _project_diagnosis(
|
|
10912
|
+
sources.build_diagnosis_bounded(
|
|
10913
|
+
scope,
|
|
10914
|
+
measured_at=now_utc,
|
|
10915
|
+
transcripts_visible=transcripts_visible,
|
|
10916
|
+
timeout_seconds=remaining,
|
|
10917
|
+
)
|
|
10918
|
+
)
|
|
10919
|
+
|
|
10615
10920
|
status, body = _DIAGNOSIS_ADMISSION.run(
|
|
10616
10921
|
flight_key,
|
|
10617
10922
|
_prepare_diagnosis,
|
|
10923
|
+
peer_gone=lambda: _diagnosis_peer_gone(self.connection),
|
|
10924
|
+
bounded_build=_prepare_bounded_diagnosis,
|
|
10618
10925
|
)
|
|
10926
|
+
except _DiagnosisClientGone:
|
|
10927
|
+
return
|
|
10928
|
+
except _DiagnosisOverloaded:
|
|
10929
|
+
self._send_diagnosis_json(503, {
|
|
10930
|
+
"error": "diagnosis busy; retry shortly",
|
|
10931
|
+
"code": "diagnosis_overloaded",
|
|
10932
|
+
})
|
|
10933
|
+
return
|
|
10934
|
+
except _DiagnosisTimedOut:
|
|
10935
|
+
self._send_diagnosis_json(503, {
|
|
10936
|
+
"error": "diagnosis timed out; retry shortly",
|
|
10937
|
+
"code": "diagnosis_timeout",
|
|
10938
|
+
})
|
|
10939
|
+
return
|
|
10619
10940
|
except _DiagnosisSelectorError as exc:
|
|
10620
10941
|
self._send_diagnosis_json(
|
|
10621
10942
|
400, {"error": exc.message, "code": exc.code})
|