cctally 1.104.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 +38 -0
- package/README.md +6 -6
- package/bin/_cctally_alerts.py +46 -13
- package/bin/_cctally_cache.py +193 -71
- package/bin/_cctally_dashboard.py +683 -70
- package/bin/_cctally_dashboard_conversation.py +400 -6
- package/bin/_cctally_dashboard_envelope.py +17 -0
- package/bin/_cctally_dashboard_perf.py +93 -0
- 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 +22 -8
- package/bin/_cctally_forecast.py +17 -9
- package/bin/_cctally_journal.py +298 -40
- package/bin/_cctally_quota.py +15 -0
- package/bin/_cctally_quota_model.py +584 -58
- package/bin/_cctally_record.py +133 -67
- package/bin/_cctally_tui.py +317 -70
- 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_diagnosis.py +21 -2
- package/bin/_lib_doctor.py +64 -8
- package/bin/_lib_ingest_frontier.py +889 -0
- package/bin/_lib_meter_rate_change.py +95 -29
- package/bin/_lib_perf.py +22 -0
- package/bin/_lib_pricing.py +30 -3
- package/bin/_lib_quota_copy.py +30 -4
- package/bin/_lib_quota_model.py +215 -28
- package/bin/_lib_rate_change_delivery.py +119 -0
- package/bin/_lib_record.py +50 -0
- package/bin/_lib_retained_size.py +187 -0
- package/bin/_lib_snapshot_cache.py +504 -17
- package/bin/_lib_statusline.py +3 -29
- package/bin/_lib_tick_stats.py +28 -4
- 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-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 +2 -1
- package/package.json +4 -1
- package/dashboard/static/assets/index-D19TO7Mg.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"]
|
|
@@ -589,6 +670,7 @@ _ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
|
589
670
|
from _cctally_dashboard_sources import (
|
|
590
671
|
SourceCapabilityUnavailable,
|
|
591
672
|
SourceResourceNotFound,
|
|
673
|
+
shutdown_codex_source_memory_worker,
|
|
592
674
|
source_detail_lookup,
|
|
593
675
|
)
|
|
594
676
|
from _lib_dashboard_sources import dashboard_resource_key
|
|
@@ -1463,7 +1545,7 @@ from _cctally_dashboard_conversation import (
|
|
|
1463
1545
|
_CONV_FIND_KINDS,
|
|
1464
1546
|
_BadConversationFilter,
|
|
1465
1547
|
_cached_file_sigs,
|
|
1466
|
-
# query plumbing +
|
|
1548
|
+
# query plumbing + handler impls (the class delegators call these)
|
|
1467
1549
|
_conversation_query_impl,
|
|
1468
1550
|
_parse_search_kind_impl,
|
|
1469
1551
|
_run_conversation_query_impl,
|
|
@@ -1475,6 +1557,8 @@ from _cctally_dashboard_conversation import (
|
|
|
1475
1557
|
_handle_get_conversation_search_impl,
|
|
1476
1558
|
_handle_get_conversation_payload_impl,
|
|
1477
1559
|
_handle_get_conversation_outline_impl,
|
|
1560
|
+
_handle_get_conversation_outline_transfer_impl,
|
|
1561
|
+
_handle_delete_conversation_outline_transfer_impl,
|
|
1478
1562
|
_handle_get_conversation_prompts_impl,
|
|
1479
1563
|
_handle_get_conversation_export_impl,
|
|
1480
1564
|
_handle_get_conversation_anon_map_impl,
|
|
@@ -1796,6 +1880,111 @@ def _dashboard_maybe_prune_retention() -> None:
|
|
|
1796
1880
|
pass
|
|
1797
1881
|
|
|
1798
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
|
+
|
|
1799
1988
|
def _conversation_sync_pass() -> str:
|
|
1800
1989
|
"""One WHOLE transcript-ingest pass (#583 S4 / F5).
|
|
1801
1990
|
|
|
@@ -1830,11 +2019,65 @@ def _conversation_sync_pass() -> str:
|
|
|
1830
2019
|
conn = open_conversations_db()
|
|
1831
2020
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
1832
2021
|
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
1833
|
-
return "store_unavailable"
|
|
2022
|
+
return _ConversationPassStatus("store_unavailable")
|
|
1834
2023
|
status = "ok"
|
|
2024
|
+
modes = {"claude": "not_observed", "codex": "not_observed"}
|
|
2025
|
+
files = {"claude": 0, "codex": 0}
|
|
1835
2026
|
try:
|
|
1836
|
-
|
|
1837
|
-
|
|
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)
|
|
1838
2081
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
1839
2082
|
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
1840
2083
|
status = "store_unavailable"
|
|
@@ -1854,7 +2097,11 @@ def _conversation_sync_pass() -> str:
|
|
|
1854
2097
|
except Exception: # noqa: BLE001
|
|
1855
2098
|
pass
|
|
1856
2099
|
_dashboard_maybe_prune_retention()
|
|
1857
|
-
|
|
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)
|
|
1858
2105
|
|
|
1859
2106
|
|
|
1860
2107
|
def _conversation_sync_loop(
|
|
@@ -1907,6 +2154,12 @@ def _conversation_sync_loop(
|
|
|
1907
2154
|
# interval that preceded it would shift the denominator by one
|
|
1908
2155
|
# pass and publish a share with no upper bound.
|
|
1909
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),
|
|
1910
2163
|
)
|
|
1911
2164
|
deadline = _conversation_next_deadline(t0, interval, work)
|
|
1912
2165
|
remaining = deadline - monotonic()
|
|
@@ -2584,6 +2837,71 @@ class _SnapshotRef:
|
|
|
2584
2837
|
self._restamp_locked()
|
|
2585
2838
|
|
|
2586
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
|
+
|
|
2587
2905
|
class _SSEDelivery:
|
|
2588
2906
|
"""One publication, projected and encoded at most once per variant.
|
|
2589
2907
|
|
|
@@ -2612,14 +2930,19 @@ class _SSEDelivery:
|
|
|
2612
2930
|
"""
|
|
2613
2931
|
|
|
2614
2932
|
__slots__ = ("snapshot", "pinned_now_utc", "pinned_monotonic",
|
|
2615
|
-
"
|
|
2933
|
+
"_frame_cache", "_cache_token")
|
|
2616
2934
|
|
|
2617
|
-
def __init__(
|
|
2935
|
+
def __init__(
|
|
2936
|
+
self, snapshot, pinned_now_utc, pinned_monotonic, *, frame_cache=None,
|
|
2937
|
+
) -> None:
|
|
2618
2938
|
self.snapshot = snapshot
|
|
2619
2939
|
self.pinned_now_utc = pinned_now_utc
|
|
2620
2940
|
self.pinned_monotonic = pinned_monotonic
|
|
2621
|
-
self.
|
|
2622
|
-
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()
|
|
2623
2946
|
|
|
2624
2947
|
def encoded(self, variant_key, project_fn) -> bytes:
|
|
2625
2948
|
"""Return complete SSE frame bytes for ``variant_key``, building once.
|
|
@@ -2633,16 +2956,8 @@ class _SSEDelivery:
|
|
|
2633
2956
|
before the key is built. Those are different situations and conflating
|
|
2634
2957
|
them either leaks or breaks the gate.
|
|
2635
2958
|
"""
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
return hit
|
|
2639
|
-
with self._lock:
|
|
2640
|
-
hit = self._cache.get(variant_key)
|
|
2641
|
-
if hit is not None:
|
|
2642
|
-
return hit
|
|
2643
|
-
built = project_fn(variant_key)
|
|
2644
|
-
self._cache[variant_key] = built
|
|
2645
|
-
return built
|
|
2959
|
+
return self._frame_cache.encoded(
|
|
2960
|
+
(self._cache_token, variant_key), project_fn)
|
|
2646
2961
|
|
|
2647
2962
|
|
|
2648
2963
|
# #583 S3 §5. A distinct slot for "no oauth_usage configuration at all", so it
|
|
@@ -2745,17 +3060,11 @@ def _delivery_is_shareable(snapshot) -> bool:
|
|
|
2745
3060
|
def _drain_to_newest(q, first):
|
|
2746
3061
|
"""Return the newest delivery queued on ``q``, discarding older ones.
|
|
2747
3062
|
|
|
2748
|
-
#583 S3 §5
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
consumption time and its ages are therefore current.
|
|
2754
|
-
|
|
2755
|
-
The fix is on the CONSUMER side deliberately: ``SSEHub.publish`` is
|
|
2756
|
-
governed by Preserve 4 and the A2 publication tests depend on its
|
|
2757
|
-
behaviour, so it is not modified. Draining here is the latest-wins
|
|
2758
|
-
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.
|
|
2759
3068
|
|
|
2760
3069
|
``first`` is the item the caller already took off the queue with its own
|
|
2761
3070
|
blocking ``get``, so the ``queue.Empty`` keep-alive path stays where it is.
|
|
@@ -2780,16 +3089,19 @@ class SSEHub:
|
|
|
2780
3089
|
#583 S3 §5: what the queues carry is a `_SSEDelivery` wrapping the
|
|
2781
3090
|
published snapshot, not the snapshot itself, so one tick projects and
|
|
2782
3091
|
encodes once per variant instead of once per connected client. The
|
|
2783
|
-
queueing behaviour below
|
|
2784
|
-
|
|
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.
|
|
2785
3095
|
"""
|
|
2786
3096
|
|
|
2787
|
-
def __init__(self, maxsize: int =
|
|
3097
|
+
def __init__(self, maxsize: int = 1) -> None:
|
|
2788
3098
|
import threading
|
|
2789
3099
|
import queue as _queue
|
|
2790
3100
|
self._lock = threading.Lock()
|
|
2791
3101
|
self._queues: list[_queue.Queue] = []
|
|
2792
3102
|
self._maxsize = maxsize
|
|
3103
|
+
self._closed = False
|
|
3104
|
+
self._frame_cache = _SSEFrameCache()
|
|
2793
3105
|
# Held so we can send the current state to a newly-subscribed
|
|
2794
3106
|
# client without waiting for the next sync tick.
|
|
2795
3107
|
self._last: object | None = None
|
|
@@ -2798,6 +3110,8 @@ class SSEHub:
|
|
|
2798
3110
|
import queue as _queue
|
|
2799
3111
|
q = _queue.Queue(maxsize=self._maxsize)
|
|
2800
3112
|
with self._lock:
|
|
3113
|
+
if self._closed:
|
|
3114
|
+
return q
|
|
2801
3115
|
self._queues.append(q)
|
|
2802
3116
|
if self._last is not None:
|
|
2803
3117
|
# Seed the new subscriber so it renders immediately.
|
|
@@ -2809,6 +3123,7 @@ class SSEHub:
|
|
|
2809
3123
|
snapshot=self._last.snapshot,
|
|
2810
3124
|
pinned_now_utc=dt.datetime.now(dt.timezone.utc),
|
|
2811
3125
|
pinned_monotonic=time.monotonic(),
|
|
3126
|
+
frame_cache=self._frame_cache,
|
|
2812
3127
|
)
|
|
2813
3128
|
try:
|
|
2814
3129
|
q.put_nowait(seed)
|
|
@@ -2834,8 +3149,40 @@ class SSEHub:
|
|
|
2834
3149
|
except ValueError:
|
|
2835
3150
|
pass
|
|
2836
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
|
+
|
|
2837
3181
|
def publish(self, snapshot) -> None:
|
|
2838
3182
|
import queue as _queue
|
|
3183
|
+
with self._lock:
|
|
3184
|
+
if self._closed:
|
|
3185
|
+
return
|
|
2839
3186
|
# #583 S3 §5: wrap ONCE, outside the hub lock, so every queue and
|
|
2840
3187
|
# `_last` share one projection cache for this tick. Built before the
|
|
2841
3188
|
# lock because construction must not run under it.
|
|
@@ -2843,16 +3190,18 @@ class SSEHub:
|
|
|
2843
3190
|
snapshot=snapshot,
|
|
2844
3191
|
pinned_now_utc=dt.datetime.now(dt.timezone.utc),
|
|
2845
3192
|
pinned_monotonic=time.monotonic(),
|
|
3193
|
+
frame_cache=self._frame_cache,
|
|
2846
3194
|
)
|
|
2847
3195
|
with self._lock:
|
|
3196
|
+
if self._closed:
|
|
3197
|
+
return
|
|
2848
3198
|
self._last = delivery
|
|
2849
|
-
# Latest-wins coalescing (#278 §2.6): every
|
|
2850
|
-
# COMPLETE state replacement, so a client
|
|
2851
|
-
#
|
|
2852
|
-
# the
|
|
2853
|
-
#
|
|
2854
|
-
#
|
|
2855
|
-
# 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.
|
|
2856
3205
|
#
|
|
2857
3206
|
# Held under the hub lock so concurrent producers (the sync tick +
|
|
2858
3207
|
# the update-check thread) can't interleave a get/put on the same
|
|
@@ -2861,20 +3210,17 @@ class SSEHub:
|
|
|
2861
3210
|
# consumer only ever get()s (never puts), so after we make room the
|
|
2862
3211
|
# re-put cannot lose to it.
|
|
2863
3212
|
for q in self._queues:
|
|
3213
|
+
while True:
|
|
3214
|
+
try:
|
|
3215
|
+
q.get_nowait()
|
|
3216
|
+
except _queue.Empty:
|
|
3217
|
+
break
|
|
2864
3218
|
try:
|
|
2865
3219
|
q.put_nowait(delivery)
|
|
2866
3220
|
except _queue.Full:
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
pass
|
|
2871
|
-
try:
|
|
2872
|
-
q.put_nowait(delivery)
|
|
2873
|
-
except _queue.Full:
|
|
2874
|
-
# Defensive: a consumer racing between our get and put
|
|
2875
|
-
# could only have removed items, so this is unreachable
|
|
2876
|
-
# under the lock — but never raise out of publish().
|
|
2877
|
-
pass
|
|
3221
|
+
# Defensive: only this publisher writes while holding the
|
|
3222
|
+
# hub lock; a consumer can remove but never add an item.
|
|
3223
|
+
pass
|
|
2878
3224
|
|
|
2879
3225
|
|
|
2880
3226
|
def _format_url(host: str, port: int) -> str:
|
|
@@ -7053,6 +7399,119 @@ def _debug_tool_version() -> str:
|
|
|
7053
7399
|
return "unknown"
|
|
7054
7400
|
|
|
7055
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
|
+
|
|
7056
7515
|
# === Table-driven route dispatch (#279 S5 F5, spec §7) =====================
|
|
7057
7516
|
# Ordered, first-match-wins tables — evaluated top-to-bottom so semantics are
|
|
7058
7517
|
# if/elif-identical to the pre-S5 chains. Each entry is
|
|
@@ -7099,6 +7558,9 @@ _GET_ROUTES = (
|
|
|
7099
7558
|
("scope", "endpoint.conversations"), False),
|
|
7100
7559
|
("exact", "/api/conversation/search", "_handle_get_conversation_search",
|
|
7101
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),
|
|
7102
7564
|
("prefix+suffix", ("/api/conversation/", "/payload"),
|
|
7103
7565
|
"_handle_get_conversation_payload",
|
|
7104
7566
|
("phase", "endpoint.conversation_payload"), True),
|
|
@@ -7145,6 +7607,8 @@ _POST_ROUTES = (
|
|
|
7145
7607
|
)
|
|
7146
7608
|
|
|
7147
7609
|
_DELETE_ROUTES = (
|
|
7610
|
+
("prefix", "/api/conversation/outline-transfer/",
|
|
7611
|
+
"_handle_delete_conversation_outline_transfer", None, True),
|
|
7148
7612
|
("prefix", "/api/share/presets/", "_handle_share_presets_delete", None, False),
|
|
7149
7613
|
("exact", "/api/share/history", "_handle_share_history_delete", None, False),
|
|
7150
7614
|
)
|
|
@@ -7735,6 +8199,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7735
8199
|
if not self._require_debug_backend_allowed():
|
|
7736
8200
|
return
|
|
7737
8201
|
last = self._perf_gate().last_backend_perf()
|
|
8202
|
+
last_ingest = self._perf_gate().last_ingest_perf()
|
|
7738
8203
|
dataset: dict = {}
|
|
7739
8204
|
cache_state: dict = {}
|
|
7740
8205
|
try:
|
|
@@ -7758,12 +8223,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7758
8223
|
perf = self._perf_gate()
|
|
7759
8224
|
tick_state = _lib_tick_stats.snapshot()
|
|
7760
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
|
+
)
|
|
7761
8231
|
body = {
|
|
7762
8232
|
"schemaVersion": 1,
|
|
7763
8233
|
"version": _debug_tool_version(),
|
|
7764
8234
|
"generated_at": (last or {}).get("generated_at"),
|
|
7765
8235
|
"dataset": dataset,
|
|
7766
8236
|
"phases": (last or {}).get("phases"),
|
|
8237
|
+
"ingest_phases": (last_ingest or {}).get("phases"),
|
|
8238
|
+
"ingest_generated_at": (last_ingest or {}).get("generated_at"),
|
|
7767
8239
|
# #583 S1 §3.1 asked for the stored tree's instant beside
|
|
7768
8240
|
# `phases`, so a GET after `--trace off` cannot present an old tree
|
|
7769
8241
|
# as current — disabling tracing does not clear the stored tree.
|
|
@@ -7795,6 +8267,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7795
8267
|
},
|
|
7796
8268
|
"cache_state": cache_state,
|
|
7797
8269
|
"sources": sources,
|
|
8270
|
+
"memory": memory,
|
|
8271
|
+
"activity": self.snapshot_ref.activity(),
|
|
7798
8272
|
# Additive, and named rather than folded into `cache_state`: a
|
|
7799
8273
|
# stats fault is not cache state, and #496 S3 §8 exists because a
|
|
7800
8274
|
# stats failure reported as a cache one sends the user to
|
|
@@ -8876,6 +9350,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8876
9350
|
".json": "application/json; charset=utf-8",
|
|
8877
9351
|
}.get(p.suffix.lower(), "application/octet-stream")
|
|
8878
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
|
+
|
|
8879
9385
|
def _serve_static_file(self, path: pathlib.Path, ctype: str) -> None:
|
|
8880
9386
|
try:
|
|
8881
9387
|
body = path.read_bytes()
|
|
@@ -8885,12 +9391,45 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8885
9391
|
except IsADirectoryError:
|
|
8886
9392
|
self.send_error(404, "not found")
|
|
8887
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
|
+
|
|
8888
9422
|
self.send_response(200)
|
|
8889
9423
|
self.send_header("Content-Type", ctype)
|
|
8890
|
-
self.send_header("Content-Length", str(len(
|
|
8891
|
-
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")
|
|
8892
9431
|
self.end_headers()
|
|
8893
|
-
self.wfile.write(
|
|
9432
|
+
self.wfile.write(encoded)
|
|
8894
9433
|
|
|
8895
9434
|
def _serve_api_data(self) -> None:
|
|
8896
9435
|
# #583 S3 §6/§7. TWO phases. Preparation may answer a JSON 500 because
|
|
@@ -9229,9 +9768,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
9229
9768
|
# describes what actually ran rather than what was intended.
|
|
9230
9769
|
# The predicate itself is untouched (D-E) — this composes it,
|
|
9231
9770
|
# it does not change it.
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
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,
|
|
9235
9803
|
)
|
|
9236
9804
|
except _DiagnosisSelectorError as exc:
|
|
9237
9805
|
self._send_diagnosis_json(
|
|
@@ -9262,12 +9830,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
9262
9830
|
status, {"error": exc.message, "code": exc.code})
|
|
9263
9831
|
return
|
|
9264
9832
|
|
|
9265
|
-
scopes = {result.source: diagnosis._scope_for(sources, scope,
|
|
9266
|
-
result.source)
|
|
9267
|
-
for result in report.results}
|
|
9268
|
-
body = diagnosis.diagnosis_to_wire(report, scopes=scopes,
|
|
9269
|
-
reveal_projects=reveal)
|
|
9270
|
-
status = 503 if kernel.unreadable_store_is_terminal(report) else 200
|
|
9271
9833
|
except Exception as exc: # noqa: BLE001
|
|
9272
9834
|
self.log_error("/api/diagnosis failed before commit: %r", exc)
|
|
9273
9835
|
self._send_diagnosis_json(500, {"error": "internal error"})
|
|
@@ -9480,6 +10042,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
9480
10042
|
def _handle_get_conversation_outline(self, path: str) -> None:
|
|
9481
10043
|
return _handle_get_conversation_outline_impl(self, path)
|
|
9482
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
|
+
|
|
9483
10051
|
def _handle_get_conversation_prompts(self, path: str) -> None:
|
|
9484
10052
|
return _handle_get_conversation_prompts_impl(self, path)
|
|
9485
10053
|
|
|
@@ -10405,11 +10973,18 @@ def _dashboard_initial_snapshot_once(
|
|
|
10405
10973
|
# Route _tui_build_snapshot through the cctally module (its re-export)
|
|
10406
10974
|
# so ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)`` in tests
|
|
10407
10975
|
# propagates — identical to the pre-change call form.
|
|
10408
|
-
|
|
10976
|
+
snapshot = c._tui_build_snapshot(
|
|
10409
10977
|
now_utc=pinned_now, skip_sync=True,
|
|
10410
10978
|
display_tz_pref_override=display_tz_pref_override,
|
|
10411
10979
|
precompute_envelope=True, runtime_bind=getattr(args, "host", None),
|
|
10412
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
|
|
10413
10988
|
|
|
10414
10989
|
import time as _time
|
|
10415
10990
|
now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
|
|
@@ -10702,10 +11277,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10702
11277
|
print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
|
|
10703
11278
|
f"from removed sessions on startup", flush=True)
|
|
10704
11279
|
|
|
10705
|
-
|
|
10706
|
-
|
|
10707
|
-
|
|
10708
|
-
)
|
|
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
|
+
)
|
|
10709
11294
|
if args.no_sync:
|
|
10710
11295
|
# No background refresher will run, so surfacing a ticking
|
|
10711
11296
|
# "synced Ns ago" chip would be misleading. Clear the monotonic
|
|
@@ -10733,8 +11318,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10733
11318
|
# where nothing would drain that queue, so a manual request there waits on
|
|
10734
11319
|
# a blocking acquire instead. The lock inside _run_sync_now is what
|
|
10735
11320
|
# actually prevents overlap.
|
|
10736
|
-
sync_lock = threading.Lock()
|
|
10737
|
-
|
|
10738
11321
|
# Build the two variants up front. The locked variant is exposed on the
|
|
10739
11322
|
# handler so /api/sync paths that already hold sync_lock (e.g. for
|
|
10740
11323
|
# multi-step refresh-then-rebuild) can reuse the snapshot-publish body
|
|
@@ -10778,6 +11361,18 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10778
11361
|
DashboardHTTPHandler.run_sync_now_locked = staticmethod(
|
|
10779
11362
|
lambda: _run_sync_now_locked(skip_sync=args.no_sync)
|
|
10780
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)
|
|
10781
11376
|
|
|
10782
11377
|
# Background rebuilder — reuses the TUI's proven sync thread with a
|
|
10783
11378
|
# small shim that delegates the sync body to _run_sync_now (so POST
|
|
@@ -10940,7 +11535,25 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
10940
11535
|
if conversation_sync_thread is not None:
|
|
10941
11536
|
conversation_sync_thread.join(timeout=2)
|
|
10942
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)
|
|
10943
11550
|
srv.shutdown()
|
|
10944
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
|
+
)
|
|
10945
11558
|
print("dashboard: stopped", flush=True)
|
|
10946
11559
|
return 0
|