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.
Files changed (44) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_cache.py +62 -13
  4. package/bin/_cctally_core.py +5 -1
  5. package/bin/_cctally_dashboard.py +362 -41
  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/_lib_codex_conversation_query.py +286 -63
  22. package/bin/_lib_codex_metadata.py +207 -0
  23. package/bin/_lib_conversation_query.py +254 -37
  24. package/bin/_lib_doctor.py +16 -2
  25. package/bin/_lib_pricing.py +69 -14
  26. package/bin/_lib_rederive.py +903 -2
  27. package/bin/_lib_view_models.py +5 -0
  28. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  29. package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
  30. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  31. package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
  32. package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
  33. package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
  34. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  35. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  36. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  37. package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
  38. package/dashboard/static/dashboard.html +3 -3
  39. package/package.json +2 -1
  40. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
  41. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
  42. package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
  43. package/dashboard/static/assets/index-DLVwACUM.js +0 -13
  44. 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"]
@@ -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
- self._respond_json(200, {"alert": payload, "dispatch": status})
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(self.headers.get("If-None-Match"), etag):
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 _prepare_diagnosis():
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})