cctally 1.103.0 → 1.105.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 +94 -0
- package/README.md +6 -6
- package/bin/_cctally_alerts.py +104 -10
- package/bin/_cctally_cache.py +193 -71
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +694 -70
- package/bin/_cctally_dashboard_conversation.py +400 -6
- package/bin/_cctally_dashboard_envelope.py +336 -14
- package/bin/_cctally_dashboard_perf.py +93 -0
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_dashboard_sources.py +988 -94
- package/bin/_cctally_db.py +30 -3
- package/bin/_cctally_diagnosis_sources.py +699 -186
- package/bin/_cctally_doctor.py +77 -0
- package/bin/_cctally_forecast.py +932 -51
- package/bin/_cctally_journal.py +430 -24
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +28 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +2142 -0
- package/bin/_cctally_record.py +204 -30
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +531 -115
- package/bin/_lib_codex_conversation_query.py +258 -12
- package/bin/_lib_codex_hooks.py +26 -0
- package/bin/_lib_conversation_query.py +145 -15
- package/bin/_lib_dashboard_json.py +105 -0
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_diagnosis.py +21 -2
- package/bin/_lib_doctor.py +215 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_ingest_frontier.py +889 -0
- package/bin/_lib_meter_rate_change.py +360 -0
- package/bin/_lib_perf.py +22 -0
- package/bin/_lib_pricing.py +30 -3
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +157 -0
- package/bin/_lib_quota_model.py +2520 -0
- package/bin/_lib_rate_change_delivery.py +119 -0
- package/bin/_lib_record.py +50 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_retained_size.py +187 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_snapshot_cache.py +504 -17
- package/bin/_lib_statusline.py +200 -2
- package/bin/_lib_tick_stats.py +28 -4
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
- package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
- package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
- package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
- package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
- package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
- package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
- package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
- package/dashboard/static/dashboard.html +3 -2
- package/package.json +10 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
|
@@ -266,6 +266,7 @@ import contextlib
|
|
|
266
266
|
import dataclasses
|
|
267
267
|
import datetime as dt
|
|
268
268
|
import gzip
|
|
269
|
+
import hashlib
|
|
269
270
|
import hmac
|
|
270
271
|
import io
|
|
271
272
|
import json
|
|
@@ -286,11 +287,13 @@ import urllib.parse
|
|
|
286
287
|
import urllib.request
|
|
287
288
|
import webbrowser as _wb
|
|
288
289
|
import zlib
|
|
290
|
+
from collections import OrderedDict
|
|
289
291
|
from dataclasses import dataclass, field, replace
|
|
290
292
|
from collections.abc import Mapping
|
|
291
293
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
292
294
|
from typing import Any, NamedTuple
|
|
293
295
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
296
|
+
from _lib_retained_size import retained_size_bytes
|
|
294
297
|
|
|
295
298
|
|
|
296
299
|
class _QuietThreadingHTTPServer(ThreadingHTTPServer):
|
|
@@ -321,6 +324,84 @@ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
|
|
|
321
324
|
super().handle_error(request, client_address)
|
|
322
325
|
|
|
323
326
|
|
|
327
|
+
@dataclass
|
|
328
|
+
class _DiagnosisFlight:
|
|
329
|
+
"""One exact-scope diagnosis shared by concurrent request threads."""
|
|
330
|
+
|
|
331
|
+
done: threading.Event = field(default_factory=threading.Event)
|
|
332
|
+
result: Any = None
|
|
333
|
+
error: BaseException | None = None
|
|
334
|
+
callers: int = 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _diagnosis_flight_key(
|
|
338
|
+
scope, transcripts_visible: bool, reveal_projects: bool,
|
|
339
|
+
) -> tuple[Any, bool, bool]:
|
|
340
|
+
"""The complete build-authorization identity for one diagnosis request.
|
|
341
|
+
|
|
342
|
+
``DiagnosisScope`` is frozen and includes source, account, half-open range,
|
|
343
|
+
effective speed, display timezone and label. Transcript visibility is a
|
|
344
|
+
separate authorization input to plan stage 1, so it must be part of the
|
|
345
|
+
identity even when every other selector matches. Project reveal changes
|
|
346
|
+
the response shaping and is included so the admission covers both the
|
|
347
|
+
transient fact populations and their complete wire projection.
|
|
348
|
+
"""
|
|
349
|
+
return scope, bool(transcripts_visible), bool(reveal_projects)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class _DiagnosisAdmission:
|
|
353
|
+
"""One process-wide diagnosis build with exact-scope single-flight.
|
|
354
|
+
|
|
355
|
+
``ThreadingHTTPServer`` admits one thread per tab/client. The diagnosis's
|
|
356
|
+
All-provider path can in turn launch an isolated Codex worker and retain
|
|
357
|
+
both providers' largest transient populations. A process-wide admission
|
|
358
|
+
slot bounds that process tree; the flight table prevents identical queued
|
|
359
|
+
callers from repeating the same read once admitted. Completed reports are
|
|
360
|
+
never retained, so this adds no stale report cache or periodic work.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
def __init__(self) -> None:
|
|
364
|
+
self._changed = threading.Condition()
|
|
365
|
+
self._admission = threading.BoundedSemaphore(1)
|
|
366
|
+
self._flights: dict[tuple[Any, bool, bool], _DiagnosisFlight] = {}
|
|
367
|
+
|
|
368
|
+
def run(self, key: tuple[Any, bool, bool], build):
|
|
369
|
+
with self._changed:
|
|
370
|
+
flight = self._flights.get(key)
|
|
371
|
+
owner = flight is None
|
|
372
|
+
if owner:
|
|
373
|
+
flight = _DiagnosisFlight()
|
|
374
|
+
self._flights[key] = flight
|
|
375
|
+
flight.callers += 1
|
|
376
|
+
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
|
+
try:
|
|
385
|
+
with self._admission:
|
|
386
|
+
result = build()
|
|
387
|
+
with self._changed:
|
|
388
|
+
flight.result = result
|
|
389
|
+
return result
|
|
390
|
+
except BaseException as exc:
|
|
391
|
+
with self._changed:
|
|
392
|
+
flight.error = exc
|
|
393
|
+
raise
|
|
394
|
+
finally:
|
|
395
|
+
with self._changed:
|
|
396
|
+
if self._flights.get(key) is flight:
|
|
397
|
+
del self._flights[key]
|
|
398
|
+
flight.done.set()
|
|
399
|
+
self._changed.notify_all()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
_DIAGNOSIS_ADMISSION = _DiagnosisAdmission()
|
|
403
|
+
|
|
404
|
+
|
|
324
405
|
def _cctally():
|
|
325
406
|
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
326
407
|
return sys.modules["cctally"]
|
|
@@ -581,6 +662,7 @@ from _cctally_dashboard_envelope import (
|
|
|
581
662
|
_envelope_rows_project_budget,
|
|
582
663
|
_ENVELOPE_AXIS_MAPPERS,
|
|
583
664
|
_build_alerts_envelope_array,
|
|
665
|
+
_build_meter_rate_change_array,
|
|
584
666
|
_model_breakdowns_to_models,
|
|
585
667
|
)
|
|
586
668
|
|
|
@@ -588,6 +670,7 @@ _ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
|
588
670
|
from _cctally_dashboard_sources import (
|
|
589
671
|
SourceCapabilityUnavailable,
|
|
590
672
|
SourceResourceNotFound,
|
|
673
|
+
shutdown_codex_source_memory_worker,
|
|
591
674
|
source_detail_lookup,
|
|
592
675
|
)
|
|
593
676
|
from _lib_dashboard_sources import dashboard_resource_key
|
|
@@ -1462,7 +1545,7 @@ from _cctally_dashboard_conversation import (
|
|
|
1462
1545
|
_CONV_FIND_KINDS,
|
|
1463
1546
|
_BadConversationFilter,
|
|
1464
1547
|
_cached_file_sigs,
|
|
1465
|
-
# query plumbing +
|
|
1548
|
+
# query plumbing + handler impls (the class delegators call these)
|
|
1466
1549
|
_conversation_query_impl,
|
|
1467
1550
|
_parse_search_kind_impl,
|
|
1468
1551
|
_run_conversation_query_impl,
|
|
@@ -1474,6 +1557,8 @@ from _cctally_dashboard_conversation import (
|
|
|
1474
1557
|
_handle_get_conversation_search_impl,
|
|
1475
1558
|
_handle_get_conversation_payload_impl,
|
|
1476
1559
|
_handle_get_conversation_outline_impl,
|
|
1560
|
+
_handle_get_conversation_outline_transfer_impl,
|
|
1561
|
+
_handle_delete_conversation_outline_transfer_impl,
|
|
1477
1562
|
_handle_get_conversation_prompts_impl,
|
|
1478
1563
|
_handle_get_conversation_export_impl,
|
|
1479
1564
|
_handle_get_conversation_anon_map_impl,
|
|
@@ -1795,6 +1880,111 @@ def _dashboard_maybe_prune_retention() -> None:
|
|
|
1795
1880
|
pass
|
|
1796
1881
|
|
|
1797
1882
|
|
|
1883
|
+
def _conversation_frontier_context():
|
|
1884
|
+
"""Roots, hook guards and trust decisions for transcript fast-negatives.
|
|
1885
|
+
|
|
1886
|
+
This mirrors the main ingest frontier's evidence contract. It is kept at
|
|
1887
|
+
call time because tests and dev instances redirect every path after module
|
|
1888
|
+
import, and because Codex hook roots are provider-root dependent.
|
|
1889
|
+
"""
|
|
1890
|
+
frontier_mod = _cctally()._load_sibling("_lib_ingest_frontier")
|
|
1891
|
+
claude_roots = tuple(_cctally_core._resolve_claude_projects_dirs())
|
|
1892
|
+
codex_homes = tuple(_cctally()._codex_home_roots())
|
|
1893
|
+
codex_roots = tuple(root / "sessions" for root in codex_homes)
|
|
1894
|
+
claude_guards = (_cctally_core.CLAUDE_SETTINGS_PATH,)
|
|
1895
|
+
|
|
1896
|
+
try:
|
|
1897
|
+
codex_hooks_mod = _cctally()._load_sibling("_lib_codex_hooks")
|
|
1898
|
+
hook_roots = codex_hooks_mod.codex_hook_roots(codex_homes)
|
|
1899
|
+
codex_guards = tuple(root.hooks_path for root in hook_roots)
|
|
1900
|
+
except Exception:
|
|
1901
|
+
codex_hooks_mod = None
|
|
1902
|
+
hook_roots = ()
|
|
1903
|
+
codex_guards = ()
|
|
1904
|
+
|
|
1905
|
+
def claude_trusted():
|
|
1906
|
+
try:
|
|
1907
|
+
setup_mod = _cctally()._load_sibling("_cctally_setup")
|
|
1908
|
+
settings = setup_mod._load_claude_settings()
|
|
1909
|
+
hooks = settings.get("hooks", {})
|
|
1910
|
+
return all(
|
|
1911
|
+
any(
|
|
1912
|
+
frontier_mod.is_dashboard_activity_claude_hook_handler(handler)
|
|
1913
|
+
for group in hooks.get(event, ())
|
|
1914
|
+
if isinstance(group, dict)
|
|
1915
|
+
for handler in group.get("hooks", ())
|
|
1916
|
+
)
|
|
1917
|
+
for event in _cctally().SETUP_HOOK_EVENTS
|
|
1918
|
+
)
|
|
1919
|
+
except Exception:
|
|
1920
|
+
return False
|
|
1921
|
+
|
|
1922
|
+
def codex_trusted():
|
|
1923
|
+
if codex_hooks_mod is None:
|
|
1924
|
+
return False
|
|
1925
|
+
try:
|
|
1926
|
+
trusted = bool(hook_roots)
|
|
1927
|
+
for hook_root in hook_roots:
|
|
1928
|
+
document = codex_hooks_mod._read_hooks_document(
|
|
1929
|
+
hook_root.hooks_path)
|
|
1930
|
+
hooks = document.get("hooks", {})
|
|
1931
|
+
for event in codex_hooks_mod.CODEX_HOOK_EVENTS:
|
|
1932
|
+
owned = sum(
|
|
1933
|
+
1
|
|
1934
|
+
for group in hooks.get(event, ())
|
|
1935
|
+
if isinstance(group, dict)
|
|
1936
|
+
for handler in group.get("hooks", ())
|
|
1937
|
+
if codex_hooks_mod.is_dashboard_activity_codex_hook_handler(
|
|
1938
|
+
handler)
|
|
1939
|
+
)
|
|
1940
|
+
trusted = trusted and owned >= 1
|
|
1941
|
+
return trusted
|
|
1942
|
+
except Exception:
|
|
1943
|
+
return False
|
|
1944
|
+
|
|
1945
|
+
return frontier_mod, {
|
|
1946
|
+
"claude": (claude_roots, claude_guards, claude_trusted()),
|
|
1947
|
+
"codex": (codex_roots, codex_guards, codex_trusted()),
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
def _conversation_frontier_plans(conn):
|
|
1952
|
+
"""Return `(frontier, cutoff, context, plans)` or None for safe full work."""
|
|
1953
|
+
try:
|
|
1954
|
+
# A lightweight fake connection used by the scheduling algebra tests
|
|
1955
|
+
# has no `execute`; production connections always do. The fallback is
|
|
1956
|
+
# the pre-#682 full pass, never a skipped sync.
|
|
1957
|
+
if not callable(getattr(conn, "execute", None)):
|
|
1958
|
+
return None
|
|
1959
|
+
frontier_mod, context = _conversation_frontier_context()
|
|
1960
|
+
app_dir = _cctally_core.APP_DIR
|
|
1961
|
+
frontier = getattr(_conversation_sync_pass, "_frontier", None)
|
|
1962
|
+
if frontier is None or frontier.app_dir != app_dir:
|
|
1963
|
+
frontier = frontier_mod.ConversationSyncFrontier(app_dir)
|
|
1964
|
+
_conversation_sync_pass._frontier = frontier
|
|
1965
|
+
cutoff = frontier.capture_cutoff()
|
|
1966
|
+
plans = {
|
|
1967
|
+
provider: frontier.plan_provider(
|
|
1968
|
+
provider, conn, roots=roots, guard_paths=guards)
|
|
1969
|
+
for provider, (roots, guards, _trusted) in context.items()
|
|
1970
|
+
}
|
|
1971
|
+
return frontier_mod, frontier, cutoff, context, plans
|
|
1972
|
+
except Exception:
|
|
1973
|
+
# Missing/malformed evidence is not a background-worker failure. It is
|
|
1974
|
+
# authorization for the original exhaustive pass.
|
|
1975
|
+
return None
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
class _ConversationPassStatus(str):
|
|
1979
|
+
"""Validated status string carrying fixed-size pass diagnostics."""
|
|
1980
|
+
|
|
1981
|
+
def __new__(cls, status, *, modes=None, files=None):
|
|
1982
|
+
obj = str.__new__(cls, status)
|
|
1983
|
+
obj.modes = dict(modes or {})
|
|
1984
|
+
obj.files = dict(files or {})
|
|
1985
|
+
return obj
|
|
1986
|
+
|
|
1987
|
+
|
|
1798
1988
|
def _conversation_sync_pass() -> str:
|
|
1799
1989
|
"""One WHOLE transcript-ingest pass (#583 S4 / F5).
|
|
1800
1990
|
|
|
@@ -1829,11 +2019,65 @@ def _conversation_sync_pass() -> str:
|
|
|
1829
2019
|
conn = open_conversations_db()
|
|
1830
2020
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
1831
2021
|
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
1832
|
-
return "store_unavailable"
|
|
2022
|
+
return _ConversationPassStatus("store_unavailable")
|
|
1833
2023
|
status = "ok"
|
|
2024
|
+
modes = {"claude": "not_observed", "codex": "not_observed"}
|
|
2025
|
+
files = {"claude": 0, "codex": 0}
|
|
1834
2026
|
try:
|
|
1835
|
-
|
|
1836
|
-
|
|
2027
|
+
planned = _conversation_frontier_plans(conn)
|
|
2028
|
+
results = {}
|
|
2029
|
+
if planned is None:
|
|
2030
|
+
modes = {"claude": "full", "codex": "full"}
|
|
2031
|
+
results["claude"] = sync_claude_conversations(conn)
|
|
2032
|
+
results["codex"] = sync_codex_conversations(conn)
|
|
2033
|
+
else:
|
|
2034
|
+
frontier_mod, frontier, cutoff, context, plans = planned
|
|
2035
|
+
modes = {
|
|
2036
|
+
provider: plans[provider].mode
|
|
2037
|
+
for provider in ("claude", "codex")
|
|
2038
|
+
}
|
|
2039
|
+
for provider, sync_fn in (
|
|
2040
|
+
("claude", sync_claude_conversations),
|
|
2041
|
+
("codex", sync_codex_conversations),
|
|
2042
|
+
):
|
|
2043
|
+
plan = plans[provider]
|
|
2044
|
+
if plan.mode == "caught_up":
|
|
2045
|
+
results[provider] = None
|
|
2046
|
+
continue
|
|
2047
|
+
sync_kwargs = {}
|
|
2048
|
+
if plan.mode == "targeted":
|
|
2049
|
+
sync_kwargs["only_paths"] = set(plan.paths)
|
|
2050
|
+
elif plan.reason == "source_replaced":
|
|
2051
|
+
# Conversation ingesters deliberately size-skip ordinary
|
|
2052
|
+
# same-length files. A ticketed mtime change at the stored
|
|
2053
|
+
# size is therefore replacement evidence and needs the
|
|
2054
|
+
# provider's from-zero replay before it can be certified.
|
|
2055
|
+
sync_kwargs["rebuild"] = True
|
|
2056
|
+
results[provider] = sync_fn(conn, **sync_kwargs)
|
|
2057
|
+
|
|
2058
|
+
files = {
|
|
2059
|
+
provider: int(getattr(results.get(provider), "files_total", 0) or 0)
|
|
2060
|
+
for provider in ("claude", "codex")
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
certifiable = all(
|
|
2064
|
+
frontier_mod.conversation_sync_certifiable(
|
|
2065
|
+
plans[provider].mode, results.get(provider),
|
|
2066
|
+
expected_paths=len(plans[provider].paths))
|
|
2067
|
+
for provider in ("claude", "codex")
|
|
2068
|
+
)
|
|
2069
|
+
if certifiable:
|
|
2070
|
+
for provider in ("claude", "codex"):
|
|
2071
|
+
roots, guards, trusted = context[provider]
|
|
2072
|
+
plan = plans[provider]
|
|
2073
|
+
if plan.mode == "full":
|
|
2074
|
+
frontier.seed_provider(
|
|
2075
|
+
provider, conn, roots=roots,
|
|
2076
|
+
guard_paths=guards, trusted=trusted, cutoff=cutoff)
|
|
2077
|
+
else:
|
|
2078
|
+
frontier.commit_provider(
|
|
2079
|
+
plan, conn, roots=roots, guard_paths=guards,
|
|
2080
|
+
trusted=trusted, cutoff=cutoff)
|
|
1837
2081
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
1838
2082
|
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
1839
2083
|
status = "store_unavailable"
|
|
@@ -1853,7 +2097,11 @@ def _conversation_sync_pass() -> str:
|
|
|
1853
2097
|
except Exception: # noqa: BLE001
|
|
1854
2098
|
pass
|
|
1855
2099
|
_dashboard_maybe_prune_retention()
|
|
1856
|
-
|
|
2100
|
+
for provider in ("claude", "codex"):
|
|
2101
|
+
if provider in results:
|
|
2102
|
+
files[provider] = int(
|
|
2103
|
+
getattr(results.get(provider), "files_total", 0) or 0)
|
|
2104
|
+
return _ConversationPassStatus(status, modes=modes, files=files)
|
|
1857
2105
|
|
|
1858
2106
|
|
|
1859
2107
|
def _conversation_sync_loop(
|
|
@@ -1906,6 +2154,12 @@ def _conversation_sync_loop(
|
|
|
1906
2154
|
# interval that preceded it would shift the denominator by one
|
|
1907
2155
|
# pass and publish a share with no upper bound.
|
|
1908
2156
|
status=status,
|
|
2157
|
+
claude_mode=getattr(status, "modes", {}).get(
|
|
2158
|
+
"claude", "not_observed"),
|
|
2159
|
+
codex_mode=getattr(status, "modes", {}).get(
|
|
2160
|
+
"codex", "not_observed"),
|
|
2161
|
+
claude_files=getattr(status, "files", {}).get("claude", 0),
|
|
2162
|
+
codex_files=getattr(status, "files", {}).get("codex", 0),
|
|
1909
2163
|
)
|
|
1910
2164
|
deadline = _conversation_next_deadline(t0, interval, work)
|
|
1911
2165
|
remaining = deadline - monotonic()
|
|
@@ -2583,6 +2837,71 @@ class _SnapshotRef:
|
|
|
2583
2837
|
self._restamp_locked()
|
|
2584
2838
|
|
|
2585
2839
|
|
|
2840
|
+
_SSE_DELIVERY_MAX_ENTRIES = 4
|
|
2841
|
+
_SSE_DELIVERY_MAX_BYTES = 32 * 1024 * 1024
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
class _SSEFrameCache:
|
|
2845
|
+
"""One aggregate retained-frame owner for every delivery in an SSE hub.
|
|
2846
|
+
|
|
2847
|
+
Publications and per-subscriber fresh-clock seeds are distinct delivery
|
|
2848
|
+
objects, but their encoded frames share this ONE admission budget. Thus a
|
|
2849
|
+
reconnect storm can evict older variants, never multiply the 32 MiB cap by
|
|
2850
|
+
the number of subscribers.
|
|
2851
|
+
"""
|
|
2852
|
+
|
|
2853
|
+
def __init__(self) -> None:
|
|
2854
|
+
self._cache: "OrderedDict[object, bytes]" = OrderedDict()
|
|
2855
|
+
self._sizes: dict[object, int] = {}
|
|
2856
|
+
self._bytes = 0
|
|
2857
|
+
self._evictions = 0
|
|
2858
|
+
self._fallbacks = 0
|
|
2859
|
+
self._lock = threading.Lock()
|
|
2860
|
+
|
|
2861
|
+
def stats(self) -> Mapping[str, int]:
|
|
2862
|
+
with self._lock:
|
|
2863
|
+
return {
|
|
2864
|
+
"estimatedBytes": int(self._bytes),
|
|
2865
|
+
"maxBytes": int(_SSE_DELIVERY_MAX_BYTES),
|
|
2866
|
+
"entryCount": len(self._cache),
|
|
2867
|
+
"maxEntries": int(_SSE_DELIVERY_MAX_ENTRIES),
|
|
2868
|
+
"evictionCount": int(self._evictions),
|
|
2869
|
+
"fallbackCount": int(self._fallbacks),
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
def clear(self) -> None:
|
|
2873
|
+
with self._lock:
|
|
2874
|
+
self._cache.clear()
|
|
2875
|
+
self._sizes.clear()
|
|
2876
|
+
self._bytes = 0
|
|
2877
|
+
|
|
2878
|
+
def encoded(self, cache_key, project_fn) -> bytes:
|
|
2879
|
+
hit = self._cache.get(cache_key)
|
|
2880
|
+
if hit is not None:
|
|
2881
|
+
return hit
|
|
2882
|
+
with self._lock:
|
|
2883
|
+
hit = self._cache.get(cache_key)
|
|
2884
|
+
if hit is not None:
|
|
2885
|
+
return hit
|
|
2886
|
+
built = project_fn(cache_key[1])
|
|
2887
|
+
entry_bytes = retained_size_bytes(
|
|
2888
|
+
(cache_key, built), stop_after=_SSE_DELIVERY_MAX_BYTES)
|
|
2889
|
+
if entry_bytes > _SSE_DELIVERY_MAX_BYTES:
|
|
2890
|
+
self._fallbacks += 1
|
|
2891
|
+
return built
|
|
2892
|
+
while self._cache and (
|
|
2893
|
+
len(self._cache) >= _SSE_DELIVERY_MAX_ENTRIES
|
|
2894
|
+
or self._bytes + entry_bytes > _SSE_DELIVERY_MAX_BYTES
|
|
2895
|
+
):
|
|
2896
|
+
old_key, _old_value = self._cache.popitem(last=False)
|
|
2897
|
+
self._bytes -= self._sizes.pop(old_key)
|
|
2898
|
+
self._evictions += 1
|
|
2899
|
+
self._cache[cache_key] = built
|
|
2900
|
+
self._sizes[cache_key] = entry_bytes
|
|
2901
|
+
self._bytes += entry_bytes
|
|
2902
|
+
return built
|
|
2903
|
+
|
|
2904
|
+
|
|
2586
2905
|
class _SSEDelivery:
|
|
2587
2906
|
"""One publication, projected and encoded at most once per variant.
|
|
2588
2907
|
|
|
@@ -2611,14 +2930,19 @@ class _SSEDelivery:
|
|
|
2611
2930
|
"""
|
|
2612
2931
|
|
|
2613
2932
|
__slots__ = ("snapshot", "pinned_now_utc", "pinned_monotonic",
|
|
2614
|
-
"
|
|
2933
|
+
"_frame_cache", "_cache_token")
|
|
2615
2934
|
|
|
2616
|
-
def __init__(
|
|
2935
|
+
def __init__(
|
|
2936
|
+
self, snapshot, pinned_now_utc, pinned_monotonic, *, frame_cache=None,
|
|
2937
|
+
) -> None:
|
|
2617
2938
|
self.snapshot = snapshot
|
|
2618
2939
|
self.pinned_now_utc = pinned_now_utc
|
|
2619
2940
|
self.pinned_monotonic = pinned_monotonic
|
|
2620
|
-
self.
|
|
2621
|
-
self.
|
|
2941
|
+
self._frame_cache = frame_cache or _SSEFrameCache()
|
|
2942
|
+
self._cache_token = object()
|
|
2943
|
+
|
|
2944
|
+
def cache_stats(self) -> Mapping[str, int]:
|
|
2945
|
+
return self._frame_cache.stats()
|
|
2622
2946
|
|
|
2623
2947
|
def encoded(self, variant_key, project_fn) -> bytes:
|
|
2624
2948
|
"""Return complete SSE frame bytes for ``variant_key``, building once.
|
|
@@ -2632,16 +2956,8 @@ class _SSEDelivery:
|
|
|
2632
2956
|
before the key is built. Those are different situations and conflating
|
|
2633
2957
|
them either leaks or breaks the gate.
|
|
2634
2958
|
"""
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
return hit
|
|
2638
|
-
with self._lock:
|
|
2639
|
-
hit = self._cache.get(variant_key)
|
|
2640
|
-
if hit is not None:
|
|
2641
|
-
return hit
|
|
2642
|
-
built = project_fn(variant_key)
|
|
2643
|
-
self._cache[variant_key] = built
|
|
2644
|
-
return built
|
|
2959
|
+
return self._frame_cache.encoded(
|
|
2960
|
+
(self._cache_token, variant_key), project_fn)
|
|
2645
2961
|
|
|
2646
2962
|
|
|
2647
2963
|
# #583 S3 §5. A distinct slot for "no oauth_usage configuration at all", so it
|
|
@@ -2744,17 +3060,11 @@ def _delivery_is_shareable(snapshot) -> bool:
|
|
|
2744
3060
|
def _drain_to_newest(q, first):
|
|
2745
3061
|
"""Return the newest delivery queued on ``q``, discarding older ones.
|
|
2746
3062
|
|
|
2747
|
-
#583 S3 §5
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
consumption time and its ages are therefore current.
|
|
2753
|
-
|
|
2754
|
-
The fix is on the CONSUMER side deliberately: ``SSEHub.publish`` is
|
|
2755
|
-
governed by Preserve 4 and the A2 publication tests depend on its
|
|
2756
|
-
behaviour, so it is not modified. Draining here is the latest-wins
|
|
2757
|
-
behaviour the hub's own docstring already describes.
|
|
3063
|
+
#583 S3 §5 originally allowed a four-delivery backlog. #684 reduces the
|
|
3064
|
+
default queue to one shared delivery and also drains here, so an explicitly
|
|
3065
|
+
larger test/integration queue still preserves the same latest-wins rule.
|
|
3066
|
+
Each delivery pins its clock at publication; replaying any backlog would
|
|
3067
|
+
render ages several publish periods stale.
|
|
2758
3068
|
|
|
2759
3069
|
``first`` is the item the caller already took off the queue with its own
|
|
2760
3070
|
blocking ``get``, so the ``queue.Empty`` keep-alive path stays where it is.
|
|
@@ -2779,16 +3089,19 @@ class SSEHub:
|
|
|
2779
3089
|
#583 S3 §5: what the queues carry is a `_SSEDelivery` wrapping the
|
|
2780
3090
|
published snapshot, not the snapshot itself, so one tick projects and
|
|
2781
3091
|
encodes once per variant instead of once per connected client. The
|
|
2782
|
-
queueing behaviour below
|
|
2783
|
-
|
|
3092
|
+
queueing behaviour below is latest-wins and non-blocking. #684 caps the
|
|
3093
|
+
default at one queued delivery per subscriber and releases every retained
|
|
3094
|
+
delivery during shutdown.
|
|
2784
3095
|
"""
|
|
2785
3096
|
|
|
2786
|
-
def __init__(self, maxsize: int =
|
|
3097
|
+
def __init__(self, maxsize: int = 1) -> None:
|
|
2787
3098
|
import threading
|
|
2788
3099
|
import queue as _queue
|
|
2789
3100
|
self._lock = threading.Lock()
|
|
2790
3101
|
self._queues: list[_queue.Queue] = []
|
|
2791
3102
|
self._maxsize = maxsize
|
|
3103
|
+
self._closed = False
|
|
3104
|
+
self._frame_cache = _SSEFrameCache()
|
|
2792
3105
|
# Held so we can send the current state to a newly-subscribed
|
|
2793
3106
|
# client without waiting for the next sync tick.
|
|
2794
3107
|
self._last: object | None = None
|
|
@@ -2797,6 +3110,8 @@ class SSEHub:
|
|
|
2797
3110
|
import queue as _queue
|
|
2798
3111
|
q = _queue.Queue(maxsize=self._maxsize)
|
|
2799
3112
|
with self._lock:
|
|
3113
|
+
if self._closed:
|
|
3114
|
+
return q
|
|
2800
3115
|
self._queues.append(q)
|
|
2801
3116
|
if self._last is not None:
|
|
2802
3117
|
# Seed the new subscriber so it renders immediately.
|
|
@@ -2808,6 +3123,7 @@ class SSEHub:
|
|
|
2808
3123
|
snapshot=self._last.snapshot,
|
|
2809
3124
|
pinned_now_utc=dt.datetime.now(dt.timezone.utc),
|
|
2810
3125
|
pinned_monotonic=time.monotonic(),
|
|
3126
|
+
frame_cache=self._frame_cache,
|
|
2811
3127
|
)
|
|
2812
3128
|
try:
|
|
2813
3129
|
q.put_nowait(seed)
|
|
@@ -2833,8 +3149,40 @@ class SSEHub:
|
|
|
2833
3149
|
except ValueError:
|
|
2834
3150
|
pass
|
|
2835
3151
|
|
|
3152
|
+
def close(self) -> None:
|
|
3153
|
+
"""Release the last snapshot and every queued delivery at shutdown."""
|
|
3154
|
+
import queue as _queue
|
|
3155
|
+
with self._lock:
|
|
3156
|
+
self._closed = True
|
|
3157
|
+
self._last = None
|
|
3158
|
+
self._frame_cache.clear()
|
|
3159
|
+
for q in self._queues:
|
|
3160
|
+
while True:
|
|
3161
|
+
try:
|
|
3162
|
+
q.get_nowait()
|
|
3163
|
+
except _queue.Empty:
|
|
3164
|
+
break
|
|
3165
|
+
self._queues.clear()
|
|
3166
|
+
|
|
3167
|
+
def memory_stats(self) -> Mapping[str, int]:
|
|
3168
|
+
"""Bounded delivery ownership; snapshots are shared across queues."""
|
|
3169
|
+
with self._lock:
|
|
3170
|
+
latest = self._last
|
|
3171
|
+
subscribers = len(self._queues)
|
|
3172
|
+
queued = sum(q.qsize() for q in self._queues)
|
|
3173
|
+
delivery = self._frame_cache.stats()
|
|
3174
|
+
return {
|
|
3175
|
+
**delivery,
|
|
3176
|
+
"subscriberCount": subscribers,
|
|
3177
|
+
"queuedDeliveryCount": queued,
|
|
3178
|
+
"maxQueuedPerSubscriber": self._maxsize,
|
|
3179
|
+
}
|
|
3180
|
+
|
|
2836
3181
|
def publish(self, snapshot) -> None:
|
|
2837
3182
|
import queue as _queue
|
|
3183
|
+
with self._lock:
|
|
3184
|
+
if self._closed:
|
|
3185
|
+
return
|
|
2838
3186
|
# #583 S3 §5: wrap ONCE, outside the hub lock, so every queue and
|
|
2839
3187
|
# `_last` share one projection cache for this tick. Built before the
|
|
2840
3188
|
# lock because construction must not run under it.
|
|
@@ -2842,16 +3190,18 @@ class SSEHub:
|
|
|
2842
3190
|
snapshot=snapshot,
|
|
2843
3191
|
pinned_now_utc=dt.datetime.now(dt.timezone.utc),
|
|
2844
3192
|
pinned_monotonic=time.monotonic(),
|
|
3193
|
+
frame_cache=self._frame_cache,
|
|
2845
3194
|
)
|
|
2846
3195
|
with self._lock:
|
|
3196
|
+
if self._closed:
|
|
3197
|
+
return
|
|
2847
3198
|
self._last = delivery
|
|
2848
|
-
# Latest-wins coalescing (#278 §2.6): every
|
|
2849
|
-
# COMPLETE state replacement, so a client
|
|
2850
|
-
#
|
|
2851
|
-
# the
|
|
2852
|
-
#
|
|
2853
|
-
#
|
|
2854
|
-
# current state rather than replaying stale frames.
|
|
3199
|
+
# Latest-wins coalescing (#278 §2.6, tightened by #684): every
|
|
3200
|
+
# published snapshot is a COMPLETE state replacement, so a client
|
|
3201
|
+
# only ever needs the newest. Discard every queued delivery before
|
|
3202
|
+
# enqueuing the replacement. Doing this only after a four-slot queue
|
|
3203
|
+
# became full let each stalled client retain four full snapshots;
|
|
3204
|
+
# consumer-side draining fixed freshness but not retained memory.
|
|
2855
3205
|
#
|
|
2856
3206
|
# Held under the hub lock so concurrent producers (the sync tick +
|
|
2857
3207
|
# the update-check thread) can't interleave a get/put on the same
|
|
@@ -2860,20 +3210,17 @@ class SSEHub:
|
|
|
2860
3210
|
# consumer only ever get()s (never puts), so after we make room the
|
|
2861
3211
|
# re-put cannot lose to it.
|
|
2862
3212
|
for q in self._queues:
|
|
3213
|
+
while True:
|
|
3214
|
+
try:
|
|
3215
|
+
q.get_nowait()
|
|
3216
|
+
except _queue.Empty:
|
|
3217
|
+
break
|
|
2863
3218
|
try:
|
|
2864
3219
|
q.put_nowait(delivery)
|
|
2865
3220
|
except _queue.Full:
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
pass
|
|
2870
|
-
try:
|
|
2871
|
-
q.put_nowait(delivery)
|
|
2872
|
-
except _queue.Full:
|
|
2873
|
-
# Defensive: a consumer racing between our get and put
|
|
2874
|
-
# could only have removed items, so this is unreachable
|
|
2875
|
-
# under the lock — but never raise out of publish().
|
|
2876
|
-
pass
|
|
3221
|
+
# Defensive: only this publisher writes while holding the
|
|
3222
|
+
# hub lock; a consumer can remove but never add an item.
|
|
3223
|
+
pass
|
|
2877
3224
|
|
|
2878
3225
|
|
|
2879
3226
|
def _format_url(host: str, port: int) -> str:
|
|
@@ -7052,6 +7399,119 @@ def _debug_tool_version() -> str:
|
|
|
7052
7399
|
return "unknown"
|
|
7053
7400
|
|
|
7054
7401
|
|
|
7402
|
+
_DASHBOARD_PROCESS_MEMORY_CEILING_BYTES = 1536 * 1024 * 1024
|
|
7403
|
+
|
|
7404
|
+
|
|
7405
|
+
def _debug_memory_owner(stats, *, prefix: str = "") -> dict:
|
|
7406
|
+
"""Normalize one owner's safe numeric counters for the debug endpoint."""
|
|
7407
|
+
source = dict(stats or {})
|
|
7408
|
+
def field(name, default=0):
|
|
7409
|
+
key = f"{prefix}{name}" if prefix else name[0].lower() + name[1:]
|
|
7410
|
+
return max(0, int(source.get(key, default) or 0))
|
|
7411
|
+
return {
|
|
7412
|
+
"estimatedBytes": field("EstimatedBytes"),
|
|
7413
|
+
"maxBytes": field("MaxBytes"),
|
|
7414
|
+
"entryCount": field("EntryCount"),
|
|
7415
|
+
"maxEntries": field("MaxEntries"),
|
|
7416
|
+
"evictionCount": field("EvictionCount"),
|
|
7417
|
+
"fallbackCount": field("FallbackCount"),
|
|
7418
|
+
}
|
|
7419
|
+
|
|
7420
|
+
|
|
7421
|
+
def _debug_retained_memory(hub, *, main_frontier_stats=None) -> dict:
|
|
7422
|
+
"""Numeric retained-owner ceilings; never values, keys or source paths."""
|
|
7423
|
+
owners: dict[str, dict] = {}
|
|
7424
|
+
sources = sys.modules.get("_cctally_dashboard_sources")
|
|
7425
|
+
if sources is not None:
|
|
7426
|
+
source_stats = dict(
|
|
7427
|
+
sources.codex_source_accelerator_memory_stats())
|
|
7428
|
+
owners["codexSourceAccelerators"] = _debug_memory_owner(source_stats)
|
|
7429
|
+
owners["codexSourceAccelerators"].update({
|
|
7430
|
+
"measuredGeneration": int(
|
|
7431
|
+
source_stats.get("measuredGeneration", -1)),
|
|
7432
|
+
"currentGeneration": int(
|
|
7433
|
+
source_stats.get("currentGeneration", 0)),
|
|
7434
|
+
"measurementPending": int(
|
|
7435
|
+
source_stats.get("measurementPending", 1)),
|
|
7436
|
+
"measurementErrorCount": int(
|
|
7437
|
+
source_stats.get("measurementErrorCount", 0)),
|
|
7438
|
+
"workerAlive": int(source_stats.get("workerAlive", 0)),
|
|
7439
|
+
})
|
|
7440
|
+
|
|
7441
|
+
cctally = sys.modules.get("cctally")
|
|
7442
|
+
loader = getattr(cctally, "_load_sibling", None)
|
|
7443
|
+
if callable(loader):
|
|
7444
|
+
try:
|
|
7445
|
+
snapshot_stats = dict(
|
|
7446
|
+
loader("_lib_snapshot_cache").snapshot_accelerator_memory_stats())
|
|
7447
|
+
owners["snapshotAccelerators"] = _debug_memory_owner(snapshot_stats)
|
|
7448
|
+
owners["snapshotAccelerators"].update({
|
|
7449
|
+
"measuredGeneration": int(
|
|
7450
|
+
snapshot_stats.get("measuredGeneration", -1)),
|
|
7451
|
+
"currentGeneration": int(
|
|
7452
|
+
snapshot_stats.get("currentGeneration", 0)),
|
|
7453
|
+
"measurementPending": int(
|
|
7454
|
+
snapshot_stats.get("measurementPending", 1)),
|
|
7455
|
+
"measurementErrorCount": int(
|
|
7456
|
+
snapshot_stats.get("measurementErrorCount", 0)),
|
|
7457
|
+
"workerAlive": int(snapshot_stats.get("workerAlive", 0)),
|
|
7458
|
+
})
|
|
7459
|
+
except Exception:
|
|
7460
|
+
pass
|
|
7461
|
+
try:
|
|
7462
|
+
owners["claudeAssembly"] = _debug_memory_owner(
|
|
7463
|
+
loader("_lib_conversation_query").conversation_assembly_cache_stats())
|
|
7464
|
+
except Exception:
|
|
7465
|
+
pass
|
|
7466
|
+
try:
|
|
7467
|
+
codex_stats = loader(
|
|
7468
|
+
"_lib_codex_conversation_query").codex_conversation_cache_stats()
|
|
7469
|
+
owners["codexOutline"] = _debug_memory_owner(
|
|
7470
|
+
codex_stats, prefix="outline")
|
|
7471
|
+
owners["codexOutlineDerivation"] = _debug_memory_owner(
|
|
7472
|
+
codex_stats, prefix="derivation")
|
|
7473
|
+
except Exception:
|
|
7474
|
+
pass
|
|
7475
|
+
try:
|
|
7476
|
+
owners["outlineTransfers"] = _debug_memory_owner(
|
|
7477
|
+
loader("_cctally_dashboard_conversation").outline_transfer_cache_stats())
|
|
7478
|
+
except Exception:
|
|
7479
|
+
pass
|
|
7480
|
+
|
|
7481
|
+
sse_stats = hub.memory_stats() if hub is not None else {
|
|
7482
|
+
"estimatedBytes": 0, "maxBytes": _SSE_DELIVERY_MAX_BYTES,
|
|
7483
|
+
}
|
|
7484
|
+
owners["sseDelivery"] = _debug_memory_owner(sse_stats)
|
|
7485
|
+
owners["sseDelivery"].update({
|
|
7486
|
+
"subscriberCount": int(sse_stats.get("subscriberCount", 0) or 0),
|
|
7487
|
+
"queuedDeliveryCount": int(sse_stats.get("queuedDeliveryCount", 0) or 0),
|
|
7488
|
+
"maxQueuedPerSubscriber": int(
|
|
7489
|
+
sse_stats.get("maxQueuedPerSubscriber", 1) or 1),
|
|
7490
|
+
})
|
|
7491
|
+
|
|
7492
|
+
if callable(main_frontier_stats):
|
|
7493
|
+
try:
|
|
7494
|
+
stats = main_frontier_stats()
|
|
7495
|
+
if stats is not None:
|
|
7496
|
+
owners["mainIngestFrontier"] = _debug_memory_owner(stats)
|
|
7497
|
+
except Exception:
|
|
7498
|
+
pass
|
|
7499
|
+
conversation_frontier = getattr(_conversation_sync_pass, "_frontier", None)
|
|
7500
|
+
if conversation_frontier is not None:
|
|
7501
|
+
owners["conversationIngestFrontier"] = _debug_memory_owner(
|
|
7502
|
+
conversation_frontier.memory_stats())
|
|
7503
|
+
|
|
7504
|
+
return {
|
|
7505
|
+
"ownerEstimatedBytes": sum(
|
|
7506
|
+
row["estimatedBytes"] for row in owners.values()),
|
|
7507
|
+
"ownerCeilingBytes": sum(row["maxBytes"] for row in owners.values()),
|
|
7508
|
+
"processCeilingBytes": _DASHBOARD_PROCESS_MEMORY_CEILING_BYTES,
|
|
7509
|
+
"threadCount": threading.active_count(),
|
|
7510
|
+
"maxThreadCount": 64,
|
|
7511
|
+
"owners": owners,
|
|
7512
|
+
}
|
|
7513
|
+
|
|
7514
|
+
|
|
7055
7515
|
# === Table-driven route dispatch (#279 S5 F5, spec §7) =====================
|
|
7056
7516
|
# Ordered, first-match-wins tables — evaluated top-to-bottom so semantics are
|
|
7057
7517
|
# if/elif-identical to the pre-S5 chains. Each entry is
|
|
@@ -7098,6 +7558,9 @@ _GET_ROUTES = (
|
|
|
7098
7558
|
("scope", "endpoint.conversations"), False),
|
|
7099
7559
|
("exact", "/api/conversation/search", "_handle_get_conversation_search",
|
|
7100
7560
|
("scope", "endpoint.conversation_search"), False),
|
|
7561
|
+
("prefix", "/api/conversation/outline-transfer/",
|
|
7562
|
+
"_handle_get_conversation_outline_transfer",
|
|
7563
|
+
("scope", "endpoint.conversation_outline_transfer"), True),
|
|
7101
7564
|
("prefix+suffix", ("/api/conversation/", "/payload"),
|
|
7102
7565
|
"_handle_get_conversation_payload",
|
|
7103
7566
|
("phase", "endpoint.conversation_payload"), True),
|
|
@@ -7144,6 +7607,8 @@ _POST_ROUTES = (
|
|
|
7144
7607
|
)
|
|
7145
7608
|
|
|
7146
7609
|
_DELETE_ROUTES = (
|
|
7610
|
+
("prefix", "/api/conversation/outline-transfer/",
|
|
7611
|
+
"_handle_delete_conversation_outline_transfer", None, True),
|
|
7147
7612
|
("prefix", "/api/share/presets/", "_handle_share_presets_delete", None, False),
|
|
7148
7613
|
("exact", "/api/share/history", "_handle_share_history_delete", None, False),
|
|
7149
7614
|
)
|
|
@@ -7734,6 +8199,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7734
8199
|
if not self._require_debug_backend_allowed():
|
|
7735
8200
|
return
|
|
7736
8201
|
last = self._perf_gate().last_backend_perf()
|
|
8202
|
+
last_ingest = self._perf_gate().last_ingest_perf()
|
|
7737
8203
|
dataset: dict = {}
|
|
7738
8204
|
cache_state: dict = {}
|
|
7739
8205
|
try:
|
|
@@ -7757,12 +8223,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7757
8223
|
perf = self._perf_gate()
|
|
7758
8224
|
tick_state = _lib_tick_stats.snapshot()
|
|
7759
8225
|
requested, applied = perf.pending_state()
|
|
8226
|
+
memory = _debug_retained_memory(
|
|
8227
|
+
type(self).hub,
|
|
8228
|
+
main_frontier_stats=getattr(
|
|
8229
|
+
type(self), "ingest_frontier_stats", None),
|
|
8230
|
+
)
|
|
7760
8231
|
body = {
|
|
7761
8232
|
"schemaVersion": 1,
|
|
7762
8233
|
"version": _debug_tool_version(),
|
|
7763
8234
|
"generated_at": (last or {}).get("generated_at"),
|
|
7764
8235
|
"dataset": dataset,
|
|
7765
8236
|
"phases": (last or {}).get("phases"),
|
|
8237
|
+
"ingest_phases": (last_ingest or {}).get("phases"),
|
|
8238
|
+
"ingest_generated_at": (last_ingest or {}).get("generated_at"),
|
|
7766
8239
|
# #583 S1 §3.1 asked for the stored tree's instant beside
|
|
7767
8240
|
# `phases`, so a GET after `--trace off` cannot present an old tree
|
|
7768
8241
|
# as current — disabling tracing does not clear the stored tree.
|
|
@@ -7794,6 +8267,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7794
8267
|
},
|
|
7795
8268
|
"cache_state": cache_state,
|
|
7796
8269
|
"sources": sources,
|
|
8270
|
+
"memory": memory,
|
|
8271
|
+
"activity": self.snapshot_ref.activity(),
|
|
7797
8272
|
# Additive, and named rather than folded into `cache_state`: a
|
|
7798
8273
|
# stats fault is not cache state, and #496 S3 §8 exists because a
|
|
7799
8274
|
# stats failure reported as a cache one sends the user to
|
|
@@ -8282,6 +8757,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8282
8757
|
merged_alerts["projected_enabled"] = (
|
|
8283
8758
|
alerts_in["projected_enabled"]
|
|
8284
8759
|
)
|
|
8760
|
+
# #661 S2 section 6.2's PUSH gate. Assigned VERBATIM, like
|
|
8761
|
+
# every sibling here: the boolean rule lives in
|
|
8762
|
+
# `_get_alerts_config`, which runs against the merged block
|
|
8763
|
+
# below, so a `bool()` here would coerce "yes" to True and
|
|
8764
|
+
# destroy the evidence before the canonical validator could
|
|
8765
|
+
# refuse it.
|
|
8766
|
+
if "rate_change_enabled" in alerts_in:
|
|
8767
|
+
merged_alerts["rate_change_enabled"] = (
|
|
8768
|
+
alerts_in["rate_change_enabled"]
|
|
8769
|
+
)
|
|
8285
8770
|
if "notifier" in alerts_in:
|
|
8286
8771
|
merged_alerts["notifier"] = alerts_in["notifier"]
|
|
8287
8772
|
merged["alerts"] = merged_alerts
|
|
@@ -8865,6 +9350,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8865
9350
|
".json": "application/json; charset=utf-8",
|
|
8866
9351
|
}.get(p.suffix.lower(), "application/octet-stream")
|
|
8867
9352
|
|
|
9353
|
+
@staticmethod
|
|
9354
|
+
def _is_hashed_static_asset(path: pathlib.Path) -> bool:
|
|
9355
|
+
"""Whether Vite made ``path`` content-addressed and immutable.
|
|
9356
|
+
|
|
9357
|
+
Only files beneath the build's ``assets`` directory qualify. Root
|
|
9358
|
+
resources such as ``dashboard.html``, ``icons.svg`` and the favicon
|
|
9359
|
+
keep their mutable-name revalidation contract even if a future name
|
|
9360
|
+
happens to contain a dash.
|
|
9361
|
+
"""
|
|
9362
|
+
return (
|
|
9363
|
+
path.parent.name == "assets"
|
|
9364
|
+
and re.fullmatch(
|
|
9365
|
+
r".+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+", path.name
|
|
9366
|
+
)
|
|
9367
|
+
is not None
|
|
9368
|
+
)
|
|
9369
|
+
|
|
9370
|
+
@staticmethod
|
|
9371
|
+
def _etag_matches(if_none_match: str | None, etag: str) -> bool:
|
|
9372
|
+
"""Apply GET/HEAD weak comparison to an ``If-None-Match`` list."""
|
|
9373
|
+
if not if_none_match:
|
|
9374
|
+
return False
|
|
9375
|
+
for candidate in if_none_match.split(","):
|
|
9376
|
+
candidate = candidate.strip()
|
|
9377
|
+
if candidate == "*":
|
|
9378
|
+
return True
|
|
9379
|
+
if candidate.startswith("W/"):
|
|
9380
|
+
candidate = candidate[2:].lstrip()
|
|
9381
|
+
if candidate == etag:
|
|
9382
|
+
return True
|
|
9383
|
+
return False
|
|
9384
|
+
|
|
8868
9385
|
def _serve_static_file(self, path: pathlib.Path, ctype: str) -> None:
|
|
8869
9386
|
try:
|
|
8870
9387
|
body = path.read_bytes()
|
|
@@ -8874,12 +9391,45 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8874
9391
|
except IsADirectoryError:
|
|
8875
9392
|
self.send_error(404, "not found")
|
|
8876
9393
|
return
|
|
9394
|
+
|
|
9395
|
+
compressible = (
|
|
9396
|
+
ctype.startswith("text/")
|
|
9397
|
+
or ctype.startswith("application/javascript")
|
|
9398
|
+
or ctype.startswith("image/svg+xml")
|
|
9399
|
+
)
|
|
9400
|
+
gzip_on = compressible and _accepts_gzip(
|
|
9401
|
+
self.headers.get("Accept-Encoding")
|
|
9402
|
+
)
|
|
9403
|
+
encoded = gzip.compress(body, compresslevel=6, mtime=0) if gzip_on else body
|
|
9404
|
+
etag = f'"{hashlib.sha256(encoded).hexdigest()}"'
|
|
9405
|
+
cache_control = (
|
|
9406
|
+
"public, max-age=31536000, immutable"
|
|
9407
|
+
if self._is_hashed_static_asset(path)
|
|
9408
|
+
else "no-cache"
|
|
9409
|
+
)
|
|
9410
|
+
|
|
9411
|
+
if self._etag_matches(self.headers.get("If-None-Match"), etag):
|
|
9412
|
+
self.send_response(304)
|
|
9413
|
+
self.send_header("ETag", etag)
|
|
9414
|
+
self.send_header("Cache-Control", cache_control)
|
|
9415
|
+
if compressible:
|
|
9416
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
9417
|
+
if gzip_on:
|
|
9418
|
+
self.send_header("Content-Encoding", "gzip")
|
|
9419
|
+
self.end_headers()
|
|
9420
|
+
return
|
|
9421
|
+
|
|
8877
9422
|
self.send_response(200)
|
|
8878
9423
|
self.send_header("Content-Type", ctype)
|
|
8879
|
-
self.send_header("Content-Length", str(len(
|
|
8880
|
-
self.send_header("Cache-Control",
|
|
9424
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
9425
|
+
self.send_header("Cache-Control", cache_control)
|
|
9426
|
+
self.send_header("ETag", etag)
|
|
9427
|
+
if compressible:
|
|
9428
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
9429
|
+
if gzip_on:
|
|
9430
|
+
self.send_header("Content-Encoding", "gzip")
|
|
8881
9431
|
self.end_headers()
|
|
8882
|
-
self.wfile.write(
|
|
9432
|
+
self.wfile.write(encoded)
|
|
8883
9433
|
|
|
8884
9434
|
def _serve_api_data(self) -> None:
|
|
8885
9435
|
# #583 S3 §6/§7. TWO phases. Preparation may answer a JSON 500 because
|
|
@@ -9218,9 +9768,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
9218
9768
|
# describes what actually ran rather than what was intended.
|
|
9219
9769
|
# The predicate itself is untouched (D-E) — this composes it,
|
|
9220
9770
|
# it does not change it.
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
transcripts_visible
|
|
9771
|
+
transcripts_visible = self._transcripts_visible_to_request()
|
|
9772
|
+
flight_key = _diagnosis_flight_key(
|
|
9773
|
+
scope, transcripts_visible, reveal,
|
|
9774
|
+
)
|
|
9775
|
+
|
|
9776
|
+
def _prepare_diagnosis():
|
|
9777
|
+
report = sources.build_diagnosis(
|
|
9778
|
+
scope,
|
|
9779
|
+
measured_at=now_utc,
|
|
9780
|
+
transcripts_visible=transcripts_visible,
|
|
9781
|
+
)
|
|
9782
|
+
scopes = {
|
|
9783
|
+
result.source: diagnosis._scope_for(
|
|
9784
|
+
sources, scope, result.source,
|
|
9785
|
+
)
|
|
9786
|
+
for result in report.results
|
|
9787
|
+
}
|
|
9788
|
+
body = diagnosis.diagnosis_to_wire(
|
|
9789
|
+
report,
|
|
9790
|
+
scopes=scopes,
|
|
9791
|
+
reveal_projects=reveal,
|
|
9792
|
+
)
|
|
9793
|
+
status = (
|
|
9794
|
+
503
|
|
9795
|
+
if kernel.unreadable_store_is_terminal(report)
|
|
9796
|
+
else 200
|
|
9797
|
+
)
|
|
9798
|
+
return status, body
|
|
9799
|
+
|
|
9800
|
+
status, body = _DIAGNOSIS_ADMISSION.run(
|
|
9801
|
+
flight_key,
|
|
9802
|
+
_prepare_diagnosis,
|
|
9224
9803
|
)
|
|
9225
9804
|
except _DiagnosisSelectorError as exc:
|
|
9226
9805
|
self._send_diagnosis_json(
|
|
@@ -9251,12 +9830,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
9251
9830
|
status, {"error": exc.message, "code": exc.code})
|
|
9252
9831
|
return
|
|
9253
9832
|
|
|
9254
|
-
scopes = {result.source: diagnosis._scope_for(sources, scope,
|
|
9255
|
-
result.source)
|
|
9256
|
-
for result in report.results}
|
|
9257
|
-
body = diagnosis.diagnosis_to_wire(report, scopes=scopes,
|
|
9258
|
-
reveal_projects=reveal)
|
|
9259
|
-
status = 503 if kernel.unreadable_store_is_terminal(report) else 200
|
|
9260
9833
|
except Exception as exc: # noqa: BLE001
|
|
9261
9834
|
self.log_error("/api/diagnosis failed before commit: %r", exc)
|
|
9262
9835
|
self._send_diagnosis_json(500, {"error": "internal error"})
|
|
@@ -9469,6 +10042,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
9469
10042
|
def _handle_get_conversation_outline(self, path: str) -> None:
|
|
9470
10043
|
return _handle_get_conversation_outline_impl(self, path)
|
|
9471
10044
|
|
|
10045
|
+
def _handle_get_conversation_outline_transfer(self, path: str) -> None:
|
|
10046
|
+
return _handle_get_conversation_outline_transfer_impl(self, path)
|
|
10047
|
+
|
|
10048
|
+
def _handle_delete_conversation_outline_transfer(self, path: str) -> None:
|
|
10049
|
+
return _handle_delete_conversation_outline_transfer_impl(self, path)
|
|
10050
|
+
|
|
9472
10051
|
def _handle_get_conversation_prompts(self, path: str) -> None:
|
|
9473
10052
|
return _handle_get_conversation_prompts_impl(self, path)
|
|
9474
10053
|
|
|
@@ -10394,11 +10973,18 @@ def _dashboard_initial_snapshot_once(
|
|
|
10394
10973
|
# Route _tui_build_snapshot through the cctally module (its re-export)
|
|
10395
10974
|
# so ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)`` in tests
|
|
10396
10975
|
# propagates — identical to the pre-change call form.
|
|
10397
|
-
|
|
10976
|
+
snapshot = c._tui_build_snapshot(
|
|
10398
10977
|
now_utc=pinned_now, skip_sync=True,
|
|
10399
10978
|
display_tz_pref_override=display_tz_pref_override,
|
|
10400
10979
|
precompute_envelope=True, runtime_bind=getattr(args, "host", None),
|
|
10401
10980
|
)
|
|
10981
|
+
# Frozen mode has no background publisher, so this initial full build
|
|
10982
|
+
# is also its only retained snapshot. Submit it explicitly to the same
|
|
10983
|
+
# asynchronous owner verifier used by ordinary publisher ticks.
|
|
10984
|
+
c._load_sibling(
|
|
10985
|
+
"_lib_snapshot_cache").enforce_snapshot_accelerator_bounds(
|
|
10986
|
+
data_version="dashboard-no-sync-initial")
|
|
10987
|
+
return snapshot
|
|
10402
10988
|
|
|
10403
10989
|
import time as _time
|
|
10404
10990
|
now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
|
|
@@ -10691,10 +11277,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10691
11277
|
print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
|
|
10692
11278
|
f"from removed sessions on startup", flush=True)
|
|
10693
11279
|
|
|
10694
|
-
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
)
|
|
11280
|
+
# #709: background retained-size verifiers own fail-closed completion.
|
|
11281
|
+
# Bind both owners to the same publisher lock before the initial build so
|
|
11282
|
+
# --no-sync cannot strand an over-cap/error result waiting for a next tick.
|
|
11283
|
+
sync_lock = threading.Lock()
|
|
11284
|
+
source_memory_module = _cctally()._load_sibling(
|
|
11285
|
+
"_cctally_dashboard_sources")
|
|
11286
|
+
snapshot_memory_module = _cctally()._load_sibling("_lib_snapshot_cache")
|
|
11287
|
+
source_memory_module.set_codex_source_memory_completion_lock(sync_lock)
|
|
11288
|
+
snapshot_memory_module.set_snapshot_memory_completion_lock(sync_lock)
|
|
11289
|
+
with sync_lock:
|
|
11290
|
+
initial = _dashboard_initial_snapshot(
|
|
11291
|
+
args, pinned_now=pinned_now,
|
|
11292
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
11293
|
+
)
|
|
10698
11294
|
if args.no_sync:
|
|
10699
11295
|
# No background refresher will run, so surfacing a ticking
|
|
10700
11296
|
# "synced Ns ago" chip would be misleading. Clear the monotonic
|
|
@@ -10722,8 +11318,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10722
11318
|
# where nothing would drain that queue, so a manual request there waits on
|
|
10723
11319
|
# a blocking acquire instead. The lock inside _run_sync_now is what
|
|
10724
11320
|
# actually prevents overlap.
|
|
10725
|
-
sync_lock = threading.Lock()
|
|
10726
|
-
|
|
10727
11321
|
# Build the two variants up front. The locked variant is exposed on the
|
|
10728
11322
|
# handler so /api/sync paths that already hold sync_lock (e.g. for
|
|
10729
11323
|
# multi-step refresh-then-rebuild) can reuse the snapshot-publish body
|
|
@@ -10767,6 +11361,18 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10767
11361
|
DashboardHTTPHandler.run_sync_now_locked = staticmethod(
|
|
10768
11362
|
lambda: _run_sync_now_locked(skip_sync=args.no_sync)
|
|
10769
11363
|
)
|
|
11364
|
+
def _main_frontier_stats():
|
|
11365
|
+
periodic_owner = getattr(_run_sync_now, "_locked_owner", None)
|
|
11366
|
+
frontier = getattr(periodic_owner, "_ingest_frontier", None)
|
|
11367
|
+
if frontier is None:
|
|
11368
|
+
frontier = getattr(_run_sync_now_locked, "_ingest_frontier", None)
|
|
11369
|
+
return None if frontier is None else frontier.memory_stats()
|
|
11370
|
+
|
|
11371
|
+
# Static callback preserves the exact closure that owns the frontier. A
|
|
11372
|
+
# plain function assigned to the handler class is a descriptor and may be
|
|
11373
|
+
# rebound when the debug route reads it from a request instance.
|
|
11374
|
+
DashboardHTTPHandler.ingest_frontier_stats = staticmethod(
|
|
11375
|
+
_main_frontier_stats)
|
|
10770
11376
|
|
|
10771
11377
|
# Background rebuilder — reuses the TUI's proven sync thread with a
|
|
10772
11378
|
# small shim that delegates the sync body to _run_sync_now (so POST
|
|
@@ -10929,7 +11535,25 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10929
11535
|
if conversation_sync_thread is not None:
|
|
10930
11536
|
conversation_sync_thread.join(timeout=2)
|
|
10931
11537
|
update_check_stop.set()
|
|
11538
|
+
memory_shutdown_errors = []
|
|
11539
|
+
try:
|
|
11540
|
+
shutdown_codex_source_memory_worker()
|
|
11541
|
+
except Exception as exc: # noqa: BLE001 - finish server cleanup first
|
|
11542
|
+
memory_shutdown_errors.append(exc)
|
|
11543
|
+
try:
|
|
11544
|
+
_cctally()._load_sibling(
|
|
11545
|
+
"_lib_snapshot_cache").shutdown_snapshot_memory_worker()
|
|
11546
|
+
except Exception as exc: # noqa: BLE001 - finish server cleanup first
|
|
11547
|
+
memory_shutdown_errors.append(exc)
|
|
11548
|
+
source_memory_module.set_codex_source_memory_completion_lock(None)
|
|
11549
|
+
snapshot_memory_module.set_snapshot_memory_completion_lock(None)
|
|
10932
11550
|
srv.shutdown()
|
|
10933
11551
|
http_thread.join(timeout=2)
|
|
11552
|
+
hub.close()
|
|
11553
|
+
if memory_shutdown_errors:
|
|
11554
|
+
raise RuntimeError(
|
|
11555
|
+
"dashboard memory verifier shutdown failed: "
|
|
11556
|
+
+ "; ".join(str(exc) for exc in memory_shutdown_errors)
|
|
11557
|
+
)
|
|
10934
11558
|
print("dashboard: stopped", flush=True)
|
|
10935
11559
|
return 0
|