cctally 1.99.0 → 1.100.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 +52 -0
- package/bin/_cctally_dashboard.py +1370 -190
- package/bin/_cctally_dashboard_envelope.py +98 -4
- package/bin/_cctally_dashboard_perf.py +433 -0
- package/bin/_cctally_dashboard_sources.py +450 -56
- package/bin/_cctally_db.py +30 -14
- package/bin/_cctally_doctor.py +1331 -1148
- package/bin/_cctally_parser.py +86 -1
- package/bin/_cctally_quota.py +40 -21
- package/bin/_cctally_record.py +37 -2
- package/bin/_cctally_refresh.py +60 -10
- package/bin/_cctally_statusline.py +53 -7
- package/bin/_cctally_tui.py +335 -30
- package/bin/_cctally_update.py +28 -22
- package/bin/_lib_dashboard_sources.py +26 -7
- package/bin/_lib_doctor.py +37 -0
- package/bin/_lib_jsonl.py +4 -2
- package/bin/_lib_perf.py +132 -3
- package/bin/_lib_source_analytics.py +2 -2
- package/bin/_lib_tick_stats.py +538 -0
- package/bin/cctally +19 -7
- package/dashboard/static/assets/index-B5YfQEtn.css +1 -0
- package/dashboard/static/assets/index-Bt59nMMO.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-Bcbm-DNP.js +0 -97
- package/dashboard/static/assets/index-hJP4wlIO.css +0 -1
|
@@ -191,11 +191,10 @@ What stays in bin/cctally:
|
|
|
191
191
|
``ns["_build_current_week_share_panel_data"]``,
|
|
192
192
|
``ns["_build_daily_share_panel_data"]``,
|
|
193
193
|
``ns["_build_monthly_share_panel_data"]``,
|
|
194
|
-
``ns["_build_blocks_share_panel_data"]``, ``ns["STATIC_DIR"]``,
|
|
195
|
-
``ns["_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS"]``, plus
|
|
194
|
+
``ns["_build_blocks_share_panel_data"]``, ``ns["STATIC_DIR"]``, plus
|
|
196
195
|
``monkeypatch.setitem`` mutations on
|
|
197
|
-
``_dashboard_build_weekly_periods
|
|
198
|
-
|
|
196
|
+
``_dashboard_build_weekly_periods`` and
|
|
197
|
+
``_dashboard_build_blocks_panel``). Forces the **eager
|
|
199
198
|
re-export** carve-out per spec §4.8 (same precedent as Phase E
|
|
200
199
|
#19/#20 + Phase F #21):
|
|
201
200
|
|
|
@@ -266,6 +265,7 @@ import copy
|
|
|
266
265
|
import contextlib
|
|
267
266
|
import dataclasses
|
|
268
267
|
import datetime as dt
|
|
268
|
+
import gzip
|
|
269
269
|
import hmac
|
|
270
270
|
import io
|
|
271
271
|
import json
|
|
@@ -285,6 +285,7 @@ import urllib.error
|
|
|
285
285
|
import urllib.parse
|
|
286
286
|
import urllib.request
|
|
287
287
|
import webbrowser as _wb
|
|
288
|
+
import zlib
|
|
288
289
|
from dataclasses import dataclass, field, replace
|
|
289
290
|
from collections.abc import Mapping
|
|
290
291
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
@@ -419,7 +420,8 @@ from _lib_dashboard_settings_contract import (
|
|
|
419
420
|
from _cctally_config import save_config, _load_config_unlocked
|
|
420
421
|
from _cctally_db import _render_migration_error_banner
|
|
421
422
|
from _cctally_cache import (
|
|
422
|
-
get_entries, iter_entries, iter_entries_with_id,
|
|
423
|
+
get_entries, iter_entries, iter_entries_with_id,
|
|
424
|
+
open_cache_db as _raw_open_cache_db,
|
|
423
425
|
open_conversations_db, sync_cache, sync_claude_conversations,
|
|
424
426
|
sync_codex_conversations,
|
|
425
427
|
_prune_orphaned_cache_entries,
|
|
@@ -439,6 +441,65 @@ from _lib_snapshot_cache import (
|
|
|
439
441
|
_max_id as _snapshot_max_id,
|
|
440
442
|
_reset_sig as _snapshot_reset_sig,
|
|
441
443
|
)
|
|
444
|
+
import _lib_tick_stats
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# === F22a: count the silent Group A cache-open failures (#583 S1 §1.6) =====
|
|
448
|
+
# `_group_a_daily_buckets`, `_group_a_weekly_buckets` and
|
|
449
|
+
# `_group_a_monthly_buckets` each wrap their `open_cache_db()` in
|
|
450
|
+
# `try: … except Exception: return None`, where `None` means "fall back to the
|
|
451
|
+
# wide from-scratch fetch". Output stays byte-identical, so no golden moves and
|
|
452
|
+
# nothing surfaces — the failure is invisible even to `doctor`.
|
|
453
|
+
#
|
|
454
|
+
# The count is taken from OUTSIDE those three functions, for two reasons. From
|
|
455
|
+
# outside them a `None` return cannot distinguish a disabled cache, an open
|
|
456
|
+
# failure and a later fallback, so this wrapper is the only place the open
|
|
457
|
+
# failure is observable as itself. And session S5 owns those three helpers, so
|
|
458
|
+
# counting here leaves all three byte-for-byte unchanged and leaves no
|
|
459
|
+
# overlapping hunk to merge.
|
|
460
|
+
|
|
461
|
+
_GROUP_A_CACHE_OPENERS = (
|
|
462
|
+
("_group_a_daily_buckets", "daily"),
|
|
463
|
+
("_group_a_weekly_buckets", "weekly"),
|
|
464
|
+
("_group_a_monthly_buckets", "monthly"),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _group_a_cache_failure_kind(code):
|
|
469
|
+
"""Which Group A bucket builder owns this code object, or None.
|
|
470
|
+
|
|
471
|
+
Matched by ``__code__`` IDENTITY, never by ``co_name``: a name match can be
|
|
472
|
+
satisfied by an unrelated function of the same name, and a diagnostic that
|
|
473
|
+
credits the wrong counter is worse than one that credits none. Resolved
|
|
474
|
+
against the live module globals rather than a memo so it cannot go stale.
|
|
475
|
+
"""
|
|
476
|
+
for name, kind in _GROUP_A_CACHE_OPENERS:
|
|
477
|
+
if getattr(globals().get(name), "__code__", None) is code:
|
|
478
|
+
return kind
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def open_cache_db(*args, **kwargs):
|
|
483
|
+
"""``_cctally_cache.open_cache_db``, plus the Group A failure count.
|
|
484
|
+
|
|
485
|
+
Behaviour-preserving. On an exception it identifies the caller, increments
|
|
486
|
+
the matching fixed counter, and re-raises the ORIGINAL exception unchanged.
|
|
487
|
+
|
|
488
|
+
It fails open in both directions: on no caller match, or on any failure of
|
|
489
|
+
the frame introspection itself, it increments nothing and still re-raises.
|
|
490
|
+
A diagnostic must never replace the error it was observing. Introspection
|
|
491
|
+
runs only on the already-exceptional path, so the steady-state cost is nil.
|
|
492
|
+
"""
|
|
493
|
+
try:
|
|
494
|
+
return _raw_open_cache_db(*args, **kwargs)
|
|
495
|
+
except Exception:
|
|
496
|
+
try:
|
|
497
|
+
kind = _group_a_cache_failure_kind(sys._getframe(1).f_code)
|
|
498
|
+
if kind is not None:
|
|
499
|
+
_lib_tick_stats.note_cache_open_failure(kind)
|
|
500
|
+
except Exception: # noqa: BLE001 — never mask the original failure
|
|
501
|
+
pass
|
|
502
|
+
raise
|
|
442
503
|
|
|
443
504
|
|
|
444
505
|
# === #279 S5: consumer-only dashboard siblings ============================
|
|
@@ -1477,33 +1538,222 @@ def _next_deadline(t0: float, interval: float, work: float) -> float:
|
|
|
1477
1538
|
return (t0 + work) + max(interval, work)
|
|
1478
1539
|
|
|
1479
1540
|
|
|
1541
|
+
def _conversation_next_deadline(
|
|
1542
|
+
t0: float, interval: float, work: float
|
|
1543
|
+
) -> float:
|
|
1544
|
+
"""Monotonic deadline for the next conversation sync pass (#583 S4 / F5).
|
|
1545
|
+
|
|
1546
|
+
Same algebra as `_next_deadline`, and deliberately a SEPARATE function. The
|
|
1547
|
+
two loops' bounds are independent regressions: sharing one helper would let
|
|
1548
|
+
a later change to the main loop's scheduling silently remove this thread's
|
|
1549
|
+
duty bound, which is the defect F5 exists to fix. A test asserts the two
|
|
1550
|
+
currently agree, so a divergence has to be a deliberate act.
|
|
1551
|
+
|
|
1552
|
+
work >= interval -> period = 2*work -> duty capped at 50% of one core,
|
|
1553
|
+
scale-independently. work < interval -> period = work + interval, which is
|
|
1554
|
+
the fixed-sleep cadence this replaced, so a small install sees no
|
|
1555
|
+
behavioural change.
|
|
1556
|
+
"""
|
|
1557
|
+
return (t0 + work) + max(interval, work)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _log_sync_iteration_failure() -> None:
|
|
1561
|
+
"""Route an escaped sync-iteration exception through the log chokepoint.
|
|
1562
|
+
|
|
1563
|
+
The traceback is the operator signal; the loop deliberately continues, so
|
|
1564
|
+
without this the failure would be entirely silent.
|
|
1565
|
+
"""
|
|
1566
|
+
import traceback
|
|
1567
|
+
try:
|
|
1568
|
+
_lib_log.get_logger("dashboard").error(
|
|
1569
|
+
"sync iteration failed:\n%s", traceback.format_exc(),
|
|
1570
|
+
)
|
|
1571
|
+
except Exception: # noqa: BLE001 — logging must never kill the drainer
|
|
1572
|
+
pass
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _make_dashboard_run_iteration(
|
|
1576
|
+
*, sync_lock, run_sync_now, run_sync_now_locked, skip_sync,
|
|
1577
|
+
monotonic=time.monotonic, heal_interval_seconds=60.0,
|
|
1578
|
+
):
|
|
1579
|
+
"""Return the dashboard sync thread's whole-iteration callable.
|
|
1580
|
+
|
|
1581
|
+
Module-level for the same reason ``_make_run_sync_now_locked`` is: the
|
|
1582
|
+
body is the only place the #583 S2 §4 contract "one OAuth refresh
|
|
1583
|
+
immediately before one rebuild, holding ``sync_lock`` across both" exists,
|
|
1584
|
+
and as a closure inside ``cmd_dashboard`` no test could reach it.
|
|
1585
|
+
|
|
1586
|
+
``run_iteration`` performs one WHOLE iteration — the rebuild plus the
|
|
1587
|
+
orphan self-heal maintenance — so the loop's measured duration drives the
|
|
1588
|
+
cooldown deadline (#313 P2 / F10), not just the rebuild.
|
|
1589
|
+
|
|
1590
|
+
The refresh leg runs only for a batch carrying the refresh bit, and never
|
|
1591
|
+
under ``skip_sync``: ``--no-sync`` freezes the data and performs no network
|
|
1592
|
+
calls. Preserve 9's rule applies here rather than in the handler, so the
|
|
1593
|
+
periodic path cannot fire a redundant rebuild between the two steps, and
|
|
1594
|
+
many queued ``refresh=1`` requests collapse to one OAuth call because
|
|
1595
|
+
``capture_batch`` ORs their intents.
|
|
1596
|
+
|
|
1597
|
+
``_refresh_usage_inproc`` and ``_dashboard_self_heal_orphans`` are called
|
|
1598
|
+
by bare name on purpose, so a test patching either on this module (or on
|
|
1599
|
+
the ``cctally`` namespace the shim delegates to) reaches this body.
|
|
1600
|
+
"""
|
|
1601
|
+
last_heal = [monotonic()]
|
|
1602
|
+
|
|
1603
|
+
def run_iteration(batch=None) -> dict:
|
|
1604
|
+
warnings: list = []
|
|
1605
|
+
if batch is not None and batch[1] and not skip_sync:
|
|
1606
|
+
with sync_lock:
|
|
1607
|
+
result = _refresh_usage_inproc()
|
|
1608
|
+
if result.status != "ok":
|
|
1609
|
+
warnings.append({"code": result.status})
|
|
1610
|
+
run_sync_now_locked(skip_sync=skip_sync)
|
|
1611
|
+
else:
|
|
1612
|
+
run_sync_now(skip_sync=skip_sync)
|
|
1613
|
+
# Self-heal removed-worktree orphans on a ~60s cadence (far rarer than
|
|
1614
|
+
# the sync tick — a deleted worktree is not urgent). Non-blocking on
|
|
1615
|
+
# the flock, so a contended tick just retries next cadence; gated off
|
|
1616
|
+
# under --no-sync.
|
|
1617
|
+
if (not skip_sync
|
|
1618
|
+
and monotonic() - last_heal[0] >= heal_interval_seconds):
|
|
1619
|
+
last_heal[0] = monotonic()
|
|
1620
|
+
_dashboard_self_heal_orphans(skip_sync=skip_sync)
|
|
1621
|
+
# A queued request has no HTTP response, so a deferred refresh's
|
|
1622
|
+
# warnings ride the settlement frame instead.
|
|
1623
|
+
return {"warnings": warnings}
|
|
1624
|
+
|
|
1625
|
+
return run_iteration
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _make_sync_loop_collaborators(*, ref, hub) -> dict:
|
|
1629
|
+
"""Bind a `_SnapshotRef` to an `SSEHub` for `_dashboard_sync_loop`.
|
|
1630
|
+
|
|
1631
|
+
Returns the loop's collaborator keyword arguments, which are the three
|
|
1632
|
+
publication points of #583 S2 spec §6.2 that the loop owns: point 2
|
|
1633
|
+
(``rebuilding=true`` with ``started_id`` advanced, immediately before work
|
|
1634
|
+
begins), point 4 (``rebuilding=false`` plus the settled fields), and the
|
|
1635
|
+
batchless equivalent of both. The reference re-stamps its held snapshot on
|
|
1636
|
+
every mutation, so ``ref.get()`` already carries the new counters.
|
|
1637
|
+
|
|
1638
|
+
Extracted to module level so a test can drive the loop through the SAME
|
|
1639
|
+
wiring the dashboard's sync thread uses. A test that rebuilt these three
|
|
1640
|
+
closures itself would assert only that its own copy publishes.
|
|
1641
|
+
"""
|
|
1642
|
+
def capture_batch():
|
|
1643
|
+
batch = ref.capture_batch()
|
|
1644
|
+
hub.publish(ref.get())
|
|
1645
|
+
return batch
|
|
1646
|
+
|
|
1647
|
+
def settle(batch_id, status, warnings=()) -> None:
|
|
1648
|
+
ref.settle(batch_id, status, warnings)
|
|
1649
|
+
hub.publish(ref.get())
|
|
1650
|
+
|
|
1651
|
+
def mark_rebuilding(value) -> None:
|
|
1652
|
+
# Publish only on a real transition. A requested tick has already
|
|
1653
|
+
# published through capture_batch/settle, which set the same flag.
|
|
1654
|
+
if ref.mark_rebuilding(value):
|
|
1655
|
+
hub.publish(ref.get())
|
|
1656
|
+
|
|
1657
|
+
return {
|
|
1658
|
+
"pending_request": ref.pending_request,
|
|
1659
|
+
"capture_batch": capture_batch,
|
|
1660
|
+
"settle": settle,
|
|
1661
|
+
"mark_rebuilding": mark_rebuilding,
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
|
|
1480
1665
|
def _dashboard_sync_loop(
|
|
1481
1666
|
*,
|
|
1482
1667
|
stop,
|
|
1483
1668
|
interval: float,
|
|
1484
1669
|
run_iteration,
|
|
1485
|
-
take_sync_request,
|
|
1670
|
+
take_sync_request=None,
|
|
1486
1671
|
monotonic=time.monotonic,
|
|
1487
1672
|
sleep=time.sleep,
|
|
1673
|
+
pending_request=None,
|
|
1674
|
+
capture_batch=None,
|
|
1675
|
+
settle=None,
|
|
1676
|
+
mark_rebuilding=None,
|
|
1488
1677
|
) -> None:
|
|
1489
|
-
"""
|
|
1678
|
+
"""Periodic rebuild loop with a #313-preserving request floor (#583 S2).
|
|
1490
1679
|
|
|
1491
1680
|
``run_iteration`` performs one whole automatic iteration (rebuild plus any
|
|
1492
1681
|
orphan self-heal / retention maintenance the thread does), so its measured
|
|
1493
|
-
duration — not just the rebuild — drives the deadline (F10).
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1682
|
+
duration — not just the rebuild — drives the deadline (F10).
|
|
1683
|
+
|
|
1684
|
+
Automatic cadence is unchanged: ``_next_deadline(t0, interval, work)``. A
|
|
1685
|
+
queued request may start a batch earlier, but never before ``t0 + 2*work``,
|
|
1686
|
+
which is exactly the ``period >= 2*work`` that caps CPU duty at 50% of one
|
|
1687
|
+
core, scale-independently. When ``work >= interval`` the automatic deadline
|
|
1688
|
+
already equals ``t0 + 2*work``, so the floor coincides with it and a request
|
|
1689
|
+
changes nothing; the floor binds only when ``work < interval``, where it
|
|
1690
|
+
turns a 5.5 s wait into a 1.0 s one for a 0.5 s rebuild.
|
|
1691
|
+
|
|
1692
|
+
The floor is never later than the deadline, so a pending request is always
|
|
1693
|
+
serviced at or before the automatic tick it would otherwise wait for.
|
|
1694
|
+
|
|
1695
|
+
``pending_request`` PEEKS and never consumes: a poll firing before the floor
|
|
1696
|
+
is met must not discard the request (spec §5.2). ``capture_batch`` claims
|
|
1697
|
+
every outstanding request atomically at the moment work starts, and
|
|
1698
|
+
``settle`` records the batch's terminal state. ``take_sync_request`` is the
|
|
1699
|
+
legacy test-and-clear flag and is left injectable for callers that still
|
|
1700
|
+
drive the loop that way.
|
|
1701
|
+
|
|
1702
|
+
``mark_rebuilding`` publishes the in-flight flag for EVERY iteration,
|
|
1703
|
+
requested or automatic, and clears it on every exit path. It is separate
|
|
1704
|
+
from ``capture_batch``/``settle`` on purpose: those two also move the
|
|
1705
|
+
settlement counters, which must not advance for a batch that never existed.
|
|
1497
1706
|
"""
|
|
1498
1707
|
while not stop.is_set():
|
|
1708
|
+
batch = None
|
|
1709
|
+
if (pending_request is not None and capture_batch is not None
|
|
1710
|
+
and pending_request()):
|
|
1711
|
+
batch = capture_batch()
|
|
1712
|
+
if mark_rebuilding is not None:
|
|
1713
|
+
# BEFORE t0: the publish is a non-blocking queue put, and keeping
|
|
1714
|
+
# it outside the measured span leaves the #313 bound's algebra
|
|
1715
|
+
# exactly as it is. An automatic tick reaches this with no batch
|
|
1716
|
+
# captured, which is the whole point — `rebuilding` describes the
|
|
1717
|
+
# iteration, not the request that may or may not have started it.
|
|
1718
|
+
mark_rebuilding(True)
|
|
1499
1719
|
t0 = monotonic()
|
|
1500
|
-
|
|
1501
|
-
|
|
1720
|
+
status, warnings = "ok", ()
|
|
1721
|
+
try:
|
|
1722
|
+
result = (run_iteration(batch=batch) if batch is not None
|
|
1723
|
+
else run_iteration())
|
|
1724
|
+
if isinstance(result, dict):
|
|
1725
|
+
warnings = tuple(result.get("warnings") or ())
|
|
1726
|
+
except Exception: # noqa: BLE001 — see below
|
|
1727
|
+
# An escaped exception must not kill the only drainer: an accepted
|
|
1728
|
+
# 202 would then never reach a terminal state, and the client would
|
|
1729
|
+
# hold `queued…` forever waiting for a settlement no surviving
|
|
1730
|
+
# thread can publish.
|
|
1731
|
+
status = "failed"
|
|
1732
|
+
_log_sync_iteration_failure()
|
|
1733
|
+
finally:
|
|
1734
|
+
# Failure time is charged to the cooldown exactly like success
|
|
1735
|
+
# time, so a crash loop cannot busy-spin.
|
|
1736
|
+
work = monotonic() - t0
|
|
1737
|
+
if batch is not None and settle is not None:
|
|
1738
|
+
settle(batch[0], status, warnings)
|
|
1739
|
+
if mark_rebuilding is not None:
|
|
1740
|
+
# Every exit path, including an escaped exception: a flag left
|
|
1741
|
+
# set would pin the client's chip at `syncing…` for the life of
|
|
1742
|
+
# the process. `settle` has already cleared it on a requested
|
|
1743
|
+
# tick, so this publishes nothing extra there.
|
|
1744
|
+
mark_rebuilding(False)
|
|
1745
|
+
|
|
1502
1746
|
deadline = _next_deadline(t0, interval, work)
|
|
1503
|
-
|
|
1504
|
-
|
|
1747
|
+
floor = t0 + 2.0 * work
|
|
1748
|
+
while not stop.is_set():
|
|
1749
|
+
now = monotonic()
|
|
1750
|
+
ready = (pending_request is not None and capture_batch is not None
|
|
1751
|
+
and now >= floor and pending_request())
|
|
1752
|
+
if ready or now >= deadline:
|
|
1505
1753
|
break
|
|
1506
|
-
|
|
1754
|
+
if take_sync_request is not None and take_sync_request():
|
|
1755
|
+
break # legacy test-and-clear path, unchanged
|
|
1756
|
+
sleep(min(0.1, max(0.0, deadline - now)))
|
|
1507
1757
|
|
|
1508
1758
|
|
|
1509
1759
|
def _dashboard_maybe_prune_retention() -> None:
|
|
@@ -1532,6 +1782,147 @@ def _dashboard_maybe_prune_retention() -> None:
|
|
|
1532
1782
|
pass
|
|
1533
1783
|
|
|
1534
1784
|
|
|
1785
|
+
def _conversation_sync_pass() -> str:
|
|
1786
|
+
"""One WHOLE transcript-ingest pass (#583 S4 / F5).
|
|
1787
|
+
|
|
1788
|
+
Store open, both provider syncs, the retention prune and the close. The
|
|
1789
|
+
loop measures this callable's duration as `work`, so anything left outside
|
|
1790
|
+
it would be work outside the duty denominator — exactly the F5 defect.
|
|
1791
|
+
|
|
1792
|
+
Returns a status from `_lib_tick_stats.CONVERSATION_STATUSES`. The prune is
|
|
1793
|
+
attempted whenever the store OPENED, including after a failing sync: the
|
|
1794
|
+
documented contract is a throttled prune driven by this thread, not a prune
|
|
1795
|
+
conditional on a successful ingest.
|
|
1796
|
+
|
|
1797
|
+
The returned status describes the OPEN and the two SYNCS, and nothing else.
|
|
1798
|
+
`_dashboard_maybe_prune_retention`'s outcome is discarded here and that
|
|
1799
|
+
function ends in `except Exception: pass`, so a pass that ingested cleanly
|
|
1800
|
+
and then failed only in the prune reports `ok`. Widening the status to
|
|
1801
|
+
cover the prune would mean widening `CONVERSATION_STATUSES`, which is a
|
|
1802
|
+
closed set by design.
|
|
1803
|
+
|
|
1804
|
+
When the primary open FAILS the pass must not prune, and this is a
|
|
1805
|
+
constraint rather than a detail. `_dashboard_maybe_prune_retention` opens
|
|
1806
|
+
its own conversations connection and reaches the retention due/throttle
|
|
1807
|
+
check only after that open, so an unconditional prune would attempt a
|
|
1808
|
+
second open of the same unopenable store on every pass, swallow the
|
|
1809
|
+
failure, and repeat. The duty bound would cap CPU share while doing nothing
|
|
1810
|
+
about the duplicated migration, recovery and I/O pressure. The invariant is
|
|
1811
|
+
therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
|
|
1812
|
+
because a successful pass opens the store twice by design, once here and
|
|
1813
|
+
once inside the prune.
|
|
1814
|
+
"""
|
|
1815
|
+
try:
|
|
1816
|
+
conn = open_conversations_db()
|
|
1817
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
1818
|
+
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
1819
|
+
return "store_unavailable"
|
|
1820
|
+
status = "ok"
|
|
1821
|
+
try:
|
|
1822
|
+
sync_claude_conversations(conn)
|
|
1823
|
+
sync_codex_conversations(conn)
|
|
1824
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
1825
|
+
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
1826
|
+
status = "store_unavailable"
|
|
1827
|
+
except Exception as exc: # noqa: BLE001
|
|
1828
|
+
# Transcript parsing/normalization is deliberately outside the core
|
|
1829
|
+
# freshness loop. Keep this worker alive so a later clean tick can
|
|
1830
|
+
# self-heal instead of permanently stopping after one malformed
|
|
1831
|
+
# provider record.
|
|
1832
|
+
eprint(
|
|
1833
|
+
"[conversations] background sync failed: "
|
|
1834
|
+
f"{type(exc).__name__}: {exc}"
|
|
1835
|
+
)
|
|
1836
|
+
status = "error"
|
|
1837
|
+
finally:
|
|
1838
|
+
try:
|
|
1839
|
+
conn.close()
|
|
1840
|
+
except Exception: # noqa: BLE001
|
|
1841
|
+
pass
|
|
1842
|
+
_dashboard_maybe_prune_retention()
|
|
1843
|
+
return status
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _conversation_sync_loop(
|
|
1847
|
+
*,
|
|
1848
|
+
stop,
|
|
1849
|
+
interval: float,
|
|
1850
|
+
run_iteration,
|
|
1851
|
+
monotonic=time.monotonic,
|
|
1852
|
+
thread_time_ns=None,
|
|
1853
|
+
wait=None,
|
|
1854
|
+
record=None,
|
|
1855
|
+
) -> None:
|
|
1856
|
+
"""Transcript ingest loop, bounded like the main one (#583 S4 / F5).
|
|
1857
|
+
|
|
1858
|
+
Extracted from a closure inside `cmd_dashboard` so the duty property can be
|
|
1859
|
+
driven by a virtual clock rather than merely asserted. `run_iteration`
|
|
1860
|
+
performs one WHOLE pass, so its measured duration is what drives the
|
|
1861
|
+
deadline.
|
|
1862
|
+
|
|
1863
|
+
The previous fixed `wait(interval)` prevented literal 100% duty for finite
|
|
1864
|
+
work but provided no scale-independent ceiling below it: a 30 s pass ran
|
|
1865
|
+
30-on/5-off, about 86% duty, and nothing bounded that as the store grew.
|
|
1866
|
+
"""
|
|
1867
|
+
if thread_time_ns is None:
|
|
1868
|
+
thread_time_ns = time.thread_time_ns
|
|
1869
|
+
if wait is None:
|
|
1870
|
+
wait = stop.wait
|
|
1871
|
+
seq = 0
|
|
1872
|
+
while not stop.is_set():
|
|
1873
|
+
t0 = monotonic()
|
|
1874
|
+
cpu0 = thread_time_ns()
|
|
1875
|
+
try:
|
|
1876
|
+
status = run_iteration() or "ok"
|
|
1877
|
+
except Exception: # noqa: BLE001 — the worker must outlive one bad pass
|
|
1878
|
+
_log_sync_iteration_failure()
|
|
1879
|
+
status = "error"
|
|
1880
|
+
work = max(0.0, monotonic() - t0)
|
|
1881
|
+
cpu_ns = max(0, thread_time_ns() - cpu0)
|
|
1882
|
+
seq += 1
|
|
1883
|
+
if record is not None:
|
|
1884
|
+
record(
|
|
1885
|
+
seq=seq,
|
|
1886
|
+
started_ns=int(t0 * 1e9),
|
|
1887
|
+
ended_ns=int((t0 + work) * 1e9),
|
|
1888
|
+
duration_ns=int(work * 1e9),
|
|
1889
|
+
cpu_ns=cpu_ns,
|
|
1890
|
+
# No period is passed: `period_ns` is the FORWARD interval, so
|
|
1891
|
+
# the recorder stamps it onto the PREVIOUS record when this
|
|
1892
|
+
# pass's start closes it. Pairing a pass's CPU with the
|
|
1893
|
+
# interval that preceded it would shift the denominator by one
|
|
1894
|
+
# pass and publish a share with no upper bound.
|
|
1895
|
+
status=status,
|
|
1896
|
+
)
|
|
1897
|
+
deadline = _conversation_next_deadline(t0, interval, work)
|
|
1898
|
+
remaining = deadline - monotonic()
|
|
1899
|
+
if remaining > 0:
|
|
1900
|
+
wait(remaining)
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
def _make_conversation_sync_thread(*, stop, sync_interval, no_sync):
|
|
1904
|
+
"""Build the bounded conversation-sync thread, or None under --no-sync.
|
|
1905
|
+
|
|
1906
|
+
#320: transcript/search ingestion runs on its own thread and SQLite file, so
|
|
1907
|
+
a multi-GB first rebuild or a contended conversations.db cannot delay
|
|
1908
|
+
`_run_sync_now`, its `last_sync_at` stamp, or core SSE publication. #583 S4
|
|
1909
|
+
gives that thread the main loop's 50%-duty bound and publishes each pass
|
|
1910
|
+
into the second `_lib_tick_stats` ring.
|
|
1911
|
+
"""
|
|
1912
|
+
if no_sync:
|
|
1913
|
+
return None
|
|
1914
|
+
return threading.Thread(
|
|
1915
|
+
target=lambda: _conversation_sync_loop(
|
|
1916
|
+
stop=stop,
|
|
1917
|
+
interval=max(5.0, float(sync_interval)),
|
|
1918
|
+
run_iteration=_conversation_sync_pass,
|
|
1919
|
+
record=_lib_tick_stats.record_conversation_pass,
|
|
1920
|
+
),
|
|
1921
|
+
daemon=True,
|
|
1922
|
+
name="dashboard-conversations-sync",
|
|
1923
|
+
)
|
|
1924
|
+
|
|
1925
|
+
|
|
1535
1926
|
def _make_run_sync_now(*args, **kwargs):
|
|
1536
1927
|
return sys.modules["cctally"]._make_run_sync_now(*args, **kwargs)
|
|
1537
1928
|
|
|
@@ -1897,10 +2288,9 @@ def __getattr__(name): # pylint: disable=invalid-name
|
|
|
1897
2288
|
# are pure constants / read-only objects whose identity is stable across
|
|
1898
2289
|
# the process lifetime; binding them once at load time keeps bare-name
|
|
1899
2290
|
# reads in moved bodies working without per-call attribute lookups.
|
|
1900
|
-
# Path constants and tunables that tests monkeypatch (STATIC_DIR
|
|
1901
|
-
#
|
|
1902
|
-
#
|
|
1903
|
-
# things that are NEVER patched at runtime.
|
|
2291
|
+
# Path constants and tunables that tests monkeypatch (STATIC_DIR) are
|
|
2292
|
+
# eager-re-exported FROM the sibling at bin/cctally so monkeypatches
|
|
2293
|
+
# propagate; this block carries things that are NEVER patched at runtime.
|
|
1904
2294
|
BLOCK_DURATION = sys.modules["cctally"].BLOCK_DURATION
|
|
1905
2295
|
|
|
1906
2296
|
|
|
@@ -1958,25 +2348,177 @@ def _resolve_dashboard_bind_for_runtime(stored: str) -> str:
|
|
|
1958
2348
|
# Pre-extract location: bin/cctally L16265.
|
|
1959
2349
|
|
|
1960
2350
|
class _SnapshotRef:
|
|
1961
|
-
"""Thread-safe holder for the current DataSnapshot.
|
|
2351
|
+
"""Thread-safe holder for the current DataSnapshot and the sync queue.
|
|
2352
|
+
|
|
2353
|
+
#583 S2. This object is the SINGLE authority for queue and activity
|
|
2354
|
+
state. Builders must never construct activity counters themselves: A2
|
|
2355
|
+
and the final publish both replace the snapshot wholesale, so a request
|
|
2356
|
+
accepted mid-build would otherwise have its counter overwritten by the
|
|
2357
|
+
older snapshot the builder had already assembled. Every mutator below
|
|
2358
|
+
re-stamps the held snapshot, and ``set()`` merges the authoritative
|
|
2359
|
+
activity in and RETURNS the merged object so a publish site can send
|
|
2360
|
+
exactly what the reference now holds.
|
|
2361
|
+
|
|
2362
|
+
The legacy ``_sync_requested`` flag and ``take_sync_request()`` are the
|
|
2363
|
+
TUI's mechanism (bin/_cctally_tui.py:5581 and :5832) and keep their
|
|
2364
|
+
exact test-and-clear semantics. The dashboard uses the counters.
|
|
2365
|
+
"""
|
|
1962
2366
|
|
|
1963
2367
|
def __init__(self, initial: DataSnapshot) -> None:
|
|
1964
2368
|
import threading
|
|
2369
|
+
import uuid
|
|
1965
2370
|
self._lock = threading.Lock()
|
|
1966
|
-
self._snap = initial
|
|
1967
2371
|
self._sync_requested = False
|
|
2372
|
+
# Fixed 16 chars: the envelope's byte count must stay deterministic
|
|
2373
|
+
# for bench/baselines/envelope-oracle.json to remain comparable.
|
|
2374
|
+
self.server_epoch = uuid.uuid4().hex[:16]
|
|
2375
|
+
self._requested_id = 0
|
|
2376
|
+
self._requested_refresh = False
|
|
2377
|
+
self._started_id = 0
|
|
2378
|
+
self._settled_id = 0
|
|
2379
|
+
self._settled_status = None
|
|
2380
|
+
self._settled_warnings = ()
|
|
2381
|
+
# OWNER-SCOPED, not a single process-wide boolean. Two independent
|
|
2382
|
+
# rebuilders write this state — the periodic sync loop and any HTTP
|
|
2383
|
+
# handler thread that wins the non-blocking `sync_lock` acquire — and a
|
|
2384
|
+
# boolean gives neither of them a way to know who set it. A clear must
|
|
2385
|
+
# therefore remove only the clearing thread's own claim; `rebuilding`
|
|
2386
|
+
# is then true exactly while at least one rebuilder holds one.
|
|
2387
|
+
#
|
|
2388
|
+
# Owner scoping made a LEAKED CLAIM strictly worse than the boolean it
|
|
2389
|
+
# replaced, so do not record the opposite. Under the boolean a leaked
|
|
2390
|
+
# `True` was cleared by whichever rebuilder next reached `set_final`, so
|
|
2391
|
+
# it self-healed on the following rebuild. Every clear site here discards
|
|
2392
|
+
# `threading.get_ident()`, so a claim left behind by a thread that has
|
|
2393
|
+
# exited can be discarded by NO other thread, and `rebuilding` would stay
|
|
2394
|
+
# true — pinning every client's chip at `syncing…` for the life of the
|
|
2395
|
+
# process.
|
|
2396
|
+
#
|
|
2397
|
+
# No leak is reachable today, but the argument splits by CALLER, not by
|
|
2398
|
+
# add site. `mark_rebuilding` below is reached from both routes — the
|
|
2399
|
+
# sync loop through `_make_sync_loop_collaborators` and an HTTP handler
|
|
2400
|
+
# thread through `DashboardHTTPHandler.mark_rebuilding` — so reading one
|
|
2401
|
+
# add site answers for neither. Four callers add a claim.
|
|
2402
|
+
#
|
|
2403
|
+
# Two of the four are bracketed: `_handle_post_sync` and
|
|
2404
|
+
# `_handle_post_settings` each mark inside a `try` whose `finally`
|
|
2405
|
+
# clears, so nothing between the two can leak the claim.
|
|
2406
|
+
#
|
|
2407
|
+
# The other two are the sync loop's `capture_batch()` and its
|
|
2408
|
+
# `mark_rebuilding(True)`, and they are NOT bracketed. Both run before
|
|
2409
|
+
# `t0` and therefore before the `try:` whose `finally` clears them; the
|
|
2410
|
+
# loop's own comment at `mark_rebuilding(True)` gives the #313
|
|
2411
|
+
# duty-algebra reason for that one's placement. `_dashboard_sync_loop`'s
|
|
2412
|
+
# `while` has no outer handler, so a raise in that gap would kill the
|
|
2413
|
+
# drainer and leak the claim together. The gap is safe because nothing
|
|
2414
|
+
# in it raises: the `_restamp_locked()` each add performs is a
|
|
2415
|
+
# `dataclasses.replace` over `DataSnapshot`, a plain dataclass with no
|
|
2416
|
+
# `__post_init__`, no `init=False` field and no `InitVar`;
|
|
2417
|
+
# `SSEHub.publish` holds its own lock and swallows
|
|
2418
|
+
# `queue.Full`/`queue.Empty`; `ref.get()` is a lock-and-return; and what
|
|
2419
|
+
# remains is a clock read and two local assignments.
|
|
2420
|
+
#
|
|
2421
|
+
# A new `add` must therefore satisfy one of the two: a same-thread
|
|
2422
|
+
# `finally` that clears it, or a proven non-raising path to one. There
|
|
2423
|
+
# is no self-healing path behind either.
|
|
2424
|
+
self._rebuilding_owners: set[int] = set()
|
|
2425
|
+
self._snap = self._stamped_locked(initial)
|
|
2426
|
+
|
|
2427
|
+
def _activity_locked(self) -> dict:
|
|
2428
|
+
return {
|
|
2429
|
+
"server_epoch": self.server_epoch,
|
|
2430
|
+
"rebuilding": bool(self._rebuilding_owners),
|
|
2431
|
+
"requested_id": self._requested_id,
|
|
2432
|
+
"started_id": self._started_id,
|
|
2433
|
+
"settled_id": self._settled_id,
|
|
2434
|
+
"settled_status": self._settled_status,
|
|
2435
|
+
"settled_warnings": self._settled_warnings,
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
def _stamped_locked(self, snap: DataSnapshot) -> DataSnapshot:
|
|
2439
|
+
import dataclasses
|
|
2440
|
+
return dataclasses.replace(snap, sync_activity=self._activity_locked())
|
|
2441
|
+
|
|
2442
|
+
def _restamp_locked(self) -> None:
|
|
2443
|
+
self._snap = self._stamped_locked(self._snap)
|
|
2444
|
+
|
|
2445
|
+
def activity(self) -> dict:
|
|
2446
|
+
with self._lock:
|
|
2447
|
+
return self._activity_locked()
|
|
1968
2448
|
|
|
1969
2449
|
def get(self) -> DataSnapshot:
|
|
1970
2450
|
with self._lock:
|
|
1971
2451
|
return self._snap
|
|
1972
2452
|
|
|
1973
|
-
def set(self, snap: DataSnapshot) ->
|
|
2453
|
+
def set(self, snap: DataSnapshot) -> DataSnapshot:
|
|
2454
|
+
"""Store ``snap`` with the authoritative activity merged in.
|
|
2455
|
+
|
|
2456
|
+
Returns the merged object. Publish sites must send the RETURN value,
|
|
2457
|
+
not their local build result, or a request accepted mid-build has its
|
|
2458
|
+
counter erased by the older snapshot the builder assembled.
|
|
2459
|
+
"""
|
|
2460
|
+
with self._lock:
|
|
2461
|
+
self._snap = self._stamped_locked(snap)
|
|
2462
|
+
return self._snap
|
|
2463
|
+
|
|
2464
|
+
def replace_fields(self, **changes) -> DataSnapshot:
|
|
2465
|
+
"""Atomically apply narrow field changes to the latest snapshot.
|
|
2466
|
+
|
|
2467
|
+
Out-of-band publishers compute small derived fields independently of
|
|
2468
|
+
the main snapshot builder. They must not read a whole snapshot, do
|
|
2469
|
+
that work, then call ``set()``: a sync can publish in between and the
|
|
2470
|
+
stale whole-object write would erase its newer data. This method keeps
|
|
2471
|
+
the read/replace/write sequence under the reference lock and re-stamps
|
|
2472
|
+
the authoritative activity state before returning the publishable
|
|
2473
|
+
object.
|
|
2474
|
+
"""
|
|
2475
|
+
import dataclasses
|
|
2476
|
+
with self._lock:
|
|
2477
|
+
self._snap = self._stamped_locked(
|
|
2478
|
+
dataclasses.replace(self._snap, **changes)
|
|
2479
|
+
)
|
|
2480
|
+
return self._snap
|
|
2481
|
+
|
|
2482
|
+
def set_final(self, snap: DataSnapshot) -> DataSnapshot:
|
|
2483
|
+
"""Store ``snap`` as the iteration's TERMINAL state: the merge of
|
|
2484
|
+
``set()`` plus dropping THIS thread's claim on ``_rebuilding_owners``,
|
|
2485
|
+
under one lock acquisition.
|
|
2486
|
+
|
|
2487
|
+
Without this a rebuild costs three published frames — the flag going
|
|
2488
|
+
up, the build's own final publish, and the flag coming back down — and
|
|
2489
|
+
the third exists only because the loop clears the flag after the
|
|
2490
|
+
rebuild has already published. Each extra frame is one shared
|
|
2491
|
+
``snapshot_to_envelope`` plus one byte-ready JSON frame per variant,
|
|
2492
|
+
and one whole-store replacement in each connected browser, which is
|
|
2493
|
+
the opposite of what a dashboard-performance session is for.
|
|
2494
|
+
|
|
2495
|
+
Storing and clearing through two calls would not help: the frame
|
|
2496
|
+
published between them would carry ``rebuilding: true`` and the third
|
|
2497
|
+
frame would come back. The clear has to be part of the same store.
|
|
2498
|
+
|
|
2499
|
+
The clear drops THIS thread's claim only. Clearing outright would end
|
|
2500
|
+
one rebuilder's build by declaring every rebuilder idle: a handler
|
|
2501
|
+
thread that finished first would publish ``rebuilding: false`` over a
|
|
2502
|
+
periodic rebuild that had already marked itself and was still blocked on
|
|
2503
|
+
``sync_lock``, and nothing would correct that until the next tick.
|
|
2504
|
+
"""
|
|
1974
2505
|
with self._lock:
|
|
1975
|
-
self.
|
|
2506
|
+
self._rebuilding_owners.discard(threading.get_ident())
|
|
2507
|
+
self._snap = self._stamped_locked(snap)
|
|
2508
|
+
return self._snap
|
|
2509
|
+
|
|
2510
|
+
def request_sync(self, refresh: bool = False) -> int:
|
|
2511
|
+
"""Enqueue a coalescing sync request; returns its identifier.
|
|
1976
2512
|
|
|
1977
|
-
|
|
2513
|
+
``refresh`` defaults False so the TUI's argument-less call site is
|
|
2514
|
+
unchanged.
|
|
2515
|
+
"""
|
|
1978
2516
|
with self._lock:
|
|
1979
2517
|
self._sync_requested = True
|
|
2518
|
+
self._requested_id += 1
|
|
2519
|
+
self._requested_refresh = self._requested_refresh or bool(refresh)
|
|
2520
|
+
self._restamp_locked()
|
|
2521
|
+
return self._requested_id
|
|
1980
2522
|
|
|
1981
2523
|
def take_sync_request(self) -> bool:
|
|
1982
2524
|
# Atomic test-and-clear — threading.Event's is_set()/clear() pair
|
|
@@ -1985,6 +2527,261 @@ class _SnapshotRef:
|
|
|
1985
2527
|
taken, self._sync_requested = self._sync_requested, False
|
|
1986
2528
|
return taken
|
|
1987
2529
|
|
|
2530
|
+
def pending_request(self) -> bool:
|
|
2531
|
+
"""Peek, never consume. A poll firing before the service floor must
|
|
2532
|
+
not clear the request (#583 S2 spec 5.2)."""
|
|
2533
|
+
with self._lock:
|
|
2534
|
+
return self._requested_id > self._started_id
|
|
2535
|
+
|
|
2536
|
+
def capture_batch(self) -> tuple:
|
|
2537
|
+
"""Atomically claim every outstanding request as one batch."""
|
|
2538
|
+
with self._lock:
|
|
2539
|
+
self._started_id = self._requested_id
|
|
2540
|
+
refresh = self._requested_refresh
|
|
2541
|
+
self._requested_refresh = False
|
|
2542
|
+
self._rebuilding_owners.add(threading.get_ident())
|
|
2543
|
+
self._restamp_locked()
|
|
2544
|
+
return (self._started_id, refresh)
|
|
2545
|
+
|
|
2546
|
+
def mark_rebuilding(self, value: bool) -> bool:
|
|
2547
|
+
"""Set the in-flight flag alone; return True iff it CHANGED.
|
|
2548
|
+
|
|
2549
|
+
Deliberately narrow. ``rebuilding`` must be true for the duration of
|
|
2550
|
+
every sync iteration, but ``capture_batch``/``settle`` run only when a
|
|
2551
|
+
request is pending, so an automatic tick — the normal case on a
|
|
2552
|
+
dashboard nobody is clicking — left the flag permanently false.
|
|
2553
|
+
Widening those two to batchless ticks is the wrong fix: ``settle``
|
|
2554
|
+
would advance ``settled_id``/``settled_status``/``settled_warnings``
|
|
2555
|
+
for a batch that never existed, and spec §6.2 says those three
|
|
2556
|
+
describe the most recently SETTLED batch.
|
|
2557
|
+
|
|
2558
|
+
The changed/unchanged return lets the loop's collaborator publish
|
|
2559
|
+
exactly once per transition: on a requested tick ``capture_batch`` has
|
|
2560
|
+
already set the flag and published, so this reports no change and adds
|
|
2561
|
+
no duplicate frame.
|
|
2562
|
+
|
|
2563
|
+
A mark adds or drops the CALLING thread's claim, and the transition is
|
|
2564
|
+
the emptiness of the owner set changing. Publish-on-transition is
|
|
2565
|
+
preserved exactly, because a transition is now empty-to-nonempty or
|
|
2566
|
+
nonempty-to-empty. The set is not a counter, so a claim is idempotent
|
|
2567
|
+
per thread and one thread cannot hold two nested claims.
|
|
2568
|
+
"""
|
|
2569
|
+
with self._lock:
|
|
2570
|
+
ident = threading.get_ident()
|
|
2571
|
+
before = bool(self._rebuilding_owners)
|
|
2572
|
+
if value:
|
|
2573
|
+
self._rebuilding_owners.add(ident)
|
|
2574
|
+
else:
|
|
2575
|
+
self._rebuilding_owners.discard(ident)
|
|
2576
|
+
after = bool(self._rebuilding_owners)
|
|
2577
|
+
if before == after:
|
|
2578
|
+
return False
|
|
2579
|
+
self._restamp_locked()
|
|
2580
|
+
return True
|
|
2581
|
+
|
|
2582
|
+
def settle(self, batch_id: int, status: str, warnings=()) -> None:
|
|
2583
|
+
"""Record a batch's terminal state.
|
|
2584
|
+
|
|
2585
|
+
``settled_status`` and ``settled_warnings`` describe the most recently
|
|
2586
|
+
settled batch and are retained across subsequent automatic frames:
|
|
2587
|
+
clearing them on the next ordinary tick would tell a client its request
|
|
2588
|
+
settled while destroying the warnings explaining how, and the queued
|
|
2589
|
+
contract removed the HTTP response that used to carry them.
|
|
2590
|
+
"""
|
|
2591
|
+
with self._lock:
|
|
2592
|
+
self._settled_id = max(self._settled_id, int(batch_id))
|
|
2593
|
+
self._settled_status = status
|
|
2594
|
+
self._settled_warnings = tuple(warnings)
|
|
2595
|
+
# This thread's claim only — `capture_batch` added it, and another
|
|
2596
|
+
# rebuilder's concurrent claim is not this batch's to end.
|
|
2597
|
+
self._rebuilding_owners.discard(threading.get_ident())
|
|
2598
|
+
self._restamp_locked()
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
class _SSEDelivery:
|
|
2602
|
+
"""One publication, projected and encoded at most once per variant.
|
|
2603
|
+
|
|
2604
|
+
#583 S3 §5. ``_serve_api_events`` used to call ``snapshot_to_envelope`` plus
|
|
2605
|
+
``encode_dashboard_json`` inside its per-connection loop, so N connected
|
|
2606
|
+
clients projected and encoded the same data N times per tick — about
|
|
2607
|
+
3.4 MB each on a production-scale store.
|
|
2608
|
+
|
|
2609
|
+
The clock is pinned HERE, once, so every client served from this delivery
|
|
2610
|
+
agrees on the age fields instead of differing by the fan-out latency.
|
|
2611
|
+
``SSEHub.subscribe`` deliberately builds a FRESH delivery for its seed
|
|
2612
|
+
rather than handing out a stored one, because a client connecting between
|
|
2613
|
+
ticks would otherwise render an age frozen at the previous publication.
|
|
2614
|
+
|
|
2615
|
+
The cache lock is PER DELIVERY. The hub's own lock must never be held
|
|
2616
|
+
across a multi-megabyte projection.
|
|
2617
|
+
|
|
2618
|
+
Sharing is only sound when the projection is a function of the snapshot
|
|
2619
|
+
plus the variant key. ``_serve_api_events`` therefore refuses to share a
|
|
2620
|
+
snapshot that carries no ``envelope_precompute``: ``snapshot_to_envelope``
|
|
2621
|
+
then reads configuration inline and runs the real doctor gather per call,
|
|
2622
|
+
neither of which is keyed. It also captures ``_channel_env_fragment``'s
|
|
2623
|
+
preview-channel process state, which is process-global rather than
|
|
2624
|
+
connection-specific — stated so the "function of its key" claim is
|
|
2625
|
+
complete.
|
|
2626
|
+
"""
|
|
2627
|
+
|
|
2628
|
+
__slots__ = ("snapshot", "pinned_now_utc", "pinned_monotonic",
|
|
2629
|
+
"_cache", "_lock")
|
|
2630
|
+
|
|
2631
|
+
def __init__(self, snapshot, pinned_now_utc, pinned_monotonic) -> None:
|
|
2632
|
+
self.snapshot = snapshot
|
|
2633
|
+
self.pinned_now_utc = pinned_now_utc
|
|
2634
|
+
self.pinned_monotonic = pinned_monotonic
|
|
2635
|
+
self._cache: dict = {}
|
|
2636
|
+
self._lock = threading.Lock()
|
|
2637
|
+
|
|
2638
|
+
def encoded(self, variant_key, project_fn) -> bytes:
|
|
2639
|
+
"""Return complete SSE frame bytes for ``variant_key``, building once.
|
|
2640
|
+
|
|
2641
|
+
Double-checked: the fast path is a lock-free dict read, and the slow
|
|
2642
|
+
path re-checks under the lock so two threads racing on the same missing
|
|
2643
|
+
variant produce one projection, JSON encoding and frame assembly. The
|
|
2644
|
+
cached value is byte-ready so fan-out never repeats UTF-8 encoding for
|
|
2645
|
+
each connection. A MISS for any valid variant computes that variant —
|
|
2646
|
+
normalizing an INVALID privacy input to False is the CALLER's job, done
|
|
2647
|
+
before the key is built. Those are different situations and conflating
|
|
2648
|
+
them either leaks or breaks the gate.
|
|
2649
|
+
"""
|
|
2650
|
+
hit = self._cache.get(variant_key)
|
|
2651
|
+
if hit is not None:
|
|
2652
|
+
return hit
|
|
2653
|
+
with self._lock:
|
|
2654
|
+
hit = self._cache.get(variant_key)
|
|
2655
|
+
if hit is not None:
|
|
2656
|
+
return hit
|
|
2657
|
+
built = project_fn(variant_key)
|
|
2658
|
+
self._cache[variant_key] = built
|
|
2659
|
+
return built
|
|
2660
|
+
|
|
2661
|
+
|
|
2662
|
+
# #583 S3 §5. A distinct slot for "no oauth_usage configuration at all", so it
|
|
2663
|
+
# cannot collide with an EMPTY configuration. Both used to canonicalize to `()`.
|
|
2664
|
+
_OAUTH_CFG_ABSENT = ("\x00cctally:oauth-usage-absent",)
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
def _canonical_oauth_key(cfg):
|
|
2668
|
+
"""A hashable canonical form of the oauth_usage config block.
|
|
2669
|
+
|
|
2670
|
+
#583 S3 §5. The delivery cache is keyed by a tuple, and the resolved
|
|
2671
|
+
``oauth_usage`` config is a dict, which is unhashable. Sorted items give a
|
|
2672
|
+
stable key for two connections that resolved the same configuration, which
|
|
2673
|
+
is the normal case — every connection reads the same file.
|
|
2674
|
+
|
|
2675
|
+
An ABSENT configuration and an EMPTY one are different configurations and
|
|
2676
|
+
get different keys. Collapsing them is unreachable today, because
|
|
2677
|
+
``_get_oauth_usage_config`` is defaults-filled and never returns an empty
|
|
2678
|
+
mapping — which is precisely why it would go unnoticed if a later change
|
|
2679
|
+
made it reachable, inside a key whose entire job is keeping two
|
|
2680
|
+
configurations apart. Callers pass a mapping or ``None``.
|
|
2681
|
+
"""
|
|
2682
|
+
if cfg is None:
|
|
2683
|
+
return _OAUTH_CFG_ABSENT
|
|
2684
|
+
return tuple(sorted((str(k), repr(v)) for k, v in cfg.items()))
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
# #583 S3 §6. The SSE keep-alive interval, as a module constant so a test can
|
|
2688
|
+
# drive the keep-alive path without waiting fifteen seconds. It matters that
|
|
2689
|
+
# the path is testable: under compression a raw `wfile.write` of the keep-alive
|
|
2690
|
+
# comment corrupts everything after it, and the failure is silent for one
|
|
2691
|
+
# interval and then permanent for that connection.
|
|
2692
|
+
_SSE_KEEPALIVE_SECONDS = 15
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def _accepts_gzip(header_value: "str | None") -> bool:
|
|
2696
|
+
"""Whether this client accepts gzip, parsed on token boundaries.
|
|
2697
|
+
|
|
2698
|
+
#583 S3 §6. A substring test for "gzip" compresses for a client sending
|
|
2699
|
+
``gzip;q=0``, which is an explicit refusal, and for an unrelated token such
|
|
2700
|
+
as ``notgzip`` or ``x-gzip``. A malformed quality value falls back to
|
|
2701
|
+
identity rather than raising, because this runs on the publish path and
|
|
2702
|
+
must never take a connection down.
|
|
2703
|
+
|
|
2704
|
+
``*`` is honoured as a wildcard, but an explicit ``gzip`` entry wins over
|
|
2705
|
+
it in either direction: ``gzip;q=0, *`` is a refusal even though the
|
|
2706
|
+
wildcard would otherwise accept.
|
|
2707
|
+
"""
|
|
2708
|
+
if not header_value:
|
|
2709
|
+
return False
|
|
2710
|
+
wildcard = None
|
|
2711
|
+
for part in header_value.split(","):
|
|
2712
|
+
token, _, params = part.strip().partition(";")
|
|
2713
|
+
token = token.strip().lower()
|
|
2714
|
+
if token not in ("gzip", "*"):
|
|
2715
|
+
continue
|
|
2716
|
+
q = 1.0
|
|
2717
|
+
malformed = False
|
|
2718
|
+
for param in params.split(";"):
|
|
2719
|
+
name, _, value = param.strip().partition("=")
|
|
2720
|
+
if name.strip().lower() != "q":
|
|
2721
|
+
continue
|
|
2722
|
+
try:
|
|
2723
|
+
q = float(value.strip())
|
|
2724
|
+
except ValueError:
|
|
2725
|
+
malformed = True
|
|
2726
|
+
break
|
|
2727
|
+
if malformed:
|
|
2728
|
+
return False
|
|
2729
|
+
if token == "gzip":
|
|
2730
|
+
return q > 0.0
|
|
2731
|
+
if wildcard is None:
|
|
2732
|
+
wildcard = q
|
|
2733
|
+
return bool(wildcard is not None and wildcard > 0.0)
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
def _delivery_is_shareable(snapshot) -> bool:
|
|
2737
|
+
"""Whether one projection of ``snapshot`` may be shared across clients.
|
|
2738
|
+
|
|
2739
|
+
#583 S3 §5. Sharing is sound only when the projection is a function of the
|
|
2740
|
+
snapshot plus the stated variant key. The gate keys on ONE field,
|
|
2741
|
+
``envelope_precompute``: without it ``snapshot_to_envelope`` reads
|
|
2742
|
+
``config.json`` inline (``bin/_cctally_dashboard_envelope.py:1194``), so two
|
|
2743
|
+
calls with the same key can differ and the result is not cacheable. Those
|
|
2744
|
+
snapshots are fixtures, the initial empty snapshot and positionally-
|
|
2745
|
+
constructed ones — never a live tick.
|
|
2746
|
+
|
|
2747
|
+
The doctor block is NOT part of this gate, and saying it was would misstate
|
|
2748
|
+
what the predicate reads. It is guarded separately by ``doctor_payload``
|
|
2749
|
+
(``:1627``), which is set independently of ``envelope_precompute`` because
|
|
2750
|
+
each catches its own failure, so a snapshot can carry the precompute and
|
|
2751
|
+
still run the real gather. That is sound to share anyway: the gather is
|
|
2752
|
+
process-global rather than connection-specific, and both of its inputs —
|
|
2753
|
+
``now_utc`` and ``runtime_bind`` — are already fixed by the delivery's pin
|
|
2754
|
+
and by the variant key.
|
|
2755
|
+
"""
|
|
2756
|
+
return getattr(snapshot, "envelope_precompute", None) is not None
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
def _drain_to_newest(q, first):
|
|
2760
|
+
"""Return the newest delivery queued on ``q``, discarding older ones.
|
|
2761
|
+
|
|
2762
|
+
#583 S3 §5. ``SSEHub`` uses a four-slot queue and ``publish`` discards only
|
|
2763
|
+
ONE oldest entry when full, so a client that falls behind holds a backlog
|
|
2764
|
+
of up to four deliveries. Each delivery pins its clock at publication, so
|
|
2765
|
+
replaying that backlog would render ages several publish periods stale — a
|
|
2766
|
+
regression against the present behaviour, where each frame is projected at
|
|
2767
|
+
consumption time and its ages are therefore current.
|
|
2768
|
+
|
|
2769
|
+
The fix is on the CONSUMER side deliberately: ``SSEHub.publish`` is
|
|
2770
|
+
governed by Preserve 4 and the A2 publication tests depend on its
|
|
2771
|
+
behaviour, so it is not modified. Draining here is the latest-wins
|
|
2772
|
+
behaviour the hub's own docstring already describes.
|
|
2773
|
+
|
|
2774
|
+
``first`` is the item the caller already took off the queue with its own
|
|
2775
|
+
blocking ``get``, so the ``queue.Empty`` keep-alive path stays where it is.
|
|
2776
|
+
"""
|
|
2777
|
+
import queue as _queue
|
|
2778
|
+
newest = first
|
|
2779
|
+
while True:
|
|
2780
|
+
try:
|
|
2781
|
+
newest = q.get_nowait()
|
|
2782
|
+
except _queue.Empty:
|
|
2783
|
+
return newest
|
|
2784
|
+
|
|
1988
2785
|
|
|
1989
2786
|
class SSEHub:
|
|
1990
2787
|
"""Thread-safe fan-out hub for SSE clients.
|
|
@@ -1993,6 +2790,12 @@ class SSEHub:
|
|
|
1993
2790
|
client queues so a slow browser cannot back-pressure the sync thread.
|
|
1994
2791
|
Consumers call `subscribe()` to obtain a `queue.Queue`, then read
|
|
1995
2792
|
with a timeout; call `unsubscribe()` on disconnect or at teardown.
|
|
2793
|
+
|
|
2794
|
+
#583 S3 §5: what the queues carry is a `_SSEDelivery` wrapping the
|
|
2795
|
+
published snapshot, not the snapshot itself, so one tick projects and
|
|
2796
|
+
encodes once per variant instead of once per connected client. The
|
|
2797
|
+
queueing behaviour below — size, latest-wins discard, lock discipline — is
|
|
2798
|
+
unchanged and is governed by Preserve 4.
|
|
1996
2799
|
"""
|
|
1997
2800
|
|
|
1998
2801
|
def __init__(self, maxsize: int = 4) -> None:
|
|
@@ -2012,12 +2815,32 @@ class SSEHub:
|
|
|
2012
2815
|
self._queues.append(q)
|
|
2013
2816
|
if self._last is not None:
|
|
2014
2817
|
# Seed the new subscriber so it renders immediately.
|
|
2818
|
+
# #583 S3 §5: a FRESH delivery over the same snapshot, with the
|
|
2819
|
+
# clock sampled NOW. Handing out `self._last` would render this
|
|
2820
|
+
# client's ages frozen at the previous publication, which for a
|
|
2821
|
+
# tab opened late in a publish period is visibly wrong.
|
|
2822
|
+
seed = _SSEDelivery(
|
|
2823
|
+
snapshot=self._last.snapshot,
|
|
2824
|
+
pinned_now_utc=dt.datetime.now(dt.timezone.utc),
|
|
2825
|
+
pinned_monotonic=time.monotonic(),
|
|
2826
|
+
)
|
|
2015
2827
|
try:
|
|
2016
|
-
q.put_nowait(
|
|
2828
|
+
q.put_nowait(seed)
|
|
2017
2829
|
except _queue.Full:
|
|
2018
2830
|
pass
|
|
2019
2831
|
return q
|
|
2020
2832
|
|
|
2833
|
+
def latest(self):
|
|
2834
|
+
"""The most recently published delivery, or None before the first.
|
|
2835
|
+
|
|
2836
|
+
#583 S3 §7: `/api/data` serves the most recently PUBLISHED state. The
|
|
2837
|
+
snapshot reference is mutated by four operations that publish
|
|
2838
|
+
separately, so reading the reference could report a `hydrating` flag no
|
|
2839
|
+
client was ever sent (#600).
|
|
2840
|
+
"""
|
|
2841
|
+
with self._lock:
|
|
2842
|
+
return self._last
|
|
2843
|
+
|
|
2021
2844
|
def unsubscribe(self, q) -> None:
|
|
2022
2845
|
with self._lock:
|
|
2023
2846
|
try:
|
|
@@ -2027,8 +2850,16 @@ class SSEHub:
|
|
|
2027
2850
|
|
|
2028
2851
|
def publish(self, snapshot) -> None:
|
|
2029
2852
|
import queue as _queue
|
|
2853
|
+
# #583 S3 §5: wrap ONCE, outside the hub lock, so every queue and
|
|
2854
|
+
# `_last` share one projection cache for this tick. Built before the
|
|
2855
|
+
# lock because construction must not run under it.
|
|
2856
|
+
delivery = _SSEDelivery(
|
|
2857
|
+
snapshot=snapshot,
|
|
2858
|
+
pinned_now_utc=dt.datetime.now(dt.timezone.utc),
|
|
2859
|
+
pinned_monotonic=time.monotonic(),
|
|
2860
|
+
)
|
|
2030
2861
|
with self._lock:
|
|
2031
|
-
self._last =
|
|
2862
|
+
self._last = delivery
|
|
2032
2863
|
# Latest-wins coalescing (#278 §2.6): every published snapshot is a
|
|
2033
2864
|
# COMPLETE state replacement, so a client only ever needs the
|
|
2034
2865
|
# newest. On a full queue drop the STALE queued frame and enqueue
|
|
@@ -2045,14 +2876,14 @@ class SSEHub:
|
|
|
2045
2876
|
# re-put cannot lose to it.
|
|
2046
2877
|
for q in self._queues:
|
|
2047
2878
|
try:
|
|
2048
|
-
q.put_nowait(
|
|
2879
|
+
q.put_nowait(delivery)
|
|
2049
2880
|
except _queue.Full:
|
|
2050
2881
|
try:
|
|
2051
2882
|
q.get_nowait() # discard the oldest, stale frame
|
|
2052
2883
|
except _queue.Empty:
|
|
2053
2884
|
pass
|
|
2054
2885
|
try:
|
|
2055
|
-
q.put_nowait(
|
|
2886
|
+
q.put_nowait(delivery)
|
|
2056
2887
|
except _queue.Full:
|
|
2057
2888
|
# Defensive: a consumer racing between our get and put
|
|
2058
2889
|
# could only have removed items, so this is unreachable
|
|
@@ -5301,14 +6132,31 @@ def _channel_env_fragment() -> dict:
|
|
|
5301
6132
|
return {}
|
|
5302
6133
|
|
|
5303
6134
|
|
|
5304
|
-
|
|
5305
|
-
#
|
|
5306
|
-
#
|
|
5307
|
-
#
|
|
5308
|
-
#
|
|
5309
|
-
#
|
|
5310
|
-
#
|
|
5311
|
-
|
|
6135
|
+
|
|
6136
|
+
# Upper bound on the ONE blocking `sync_lock` acquire left in the tree: a
|
|
6137
|
+
# manual `POST /api/sync` under `--no-sync`. Every other path either acquires
|
|
6138
|
+
# non-blocking or enqueues. #583 S2 removed the old bounded acquire along with
|
|
6139
|
+
# its 503, which left this one bare — and a bare acquire pins an HTTP handler
|
|
6140
|
+
# thread forever when a rebuild wedges (a `cache.db` read that never returns, a
|
|
6141
|
+
# hung builder), with no diagnostic at all.
|
|
6142
|
+
#
|
|
6143
|
+
# 30s rather than the old 2s because the holder in this mode is a whole
|
|
6144
|
+
# synchronous rebuild (measured 1.9-6.3 s), not a periodic tick, so the bound
|
|
6145
|
+
# has to be a wedge detector rather than a contention timeout. On expiry the
|
|
6146
|
+
# endpoint answers 200 with a `sync_busy` warning: that stays inside its
|
|
6147
|
+
# declared status vocabulary, does NOT reintroduce 503, and does not strand the
|
|
6148
|
+
# client the way a 202 would in a mode where nothing drains the queue.
|
|
6149
|
+
#
|
|
6150
|
+
# Recorded, not resolved: 30 s also outlives any plausible browser fetch
|
|
6151
|
+
# timeout, so on a real wedge the client aborts first and this handler writes
|
|
6152
|
+
# its 200 to a socket nobody is reading — broken-pipe noise in the dashboard's
|
|
6153
|
+
# terminal. That interaction is exactly why the earlier 2.0 s bound was chosen
|
|
6154
|
+
# against a 3.0 s client timeout
|
|
6155
|
+
# (docs/superpowers/specs/2026-06-13-refresh-usage-dashboard-nudge-design.md,
|
|
6156
|
+
# the "Timeout chosen above the server's lock-wait" bullet). Deciding between
|
|
6157
|
+
# the two needs the client-side timeout in view as well, which is out of scope
|
|
6158
|
+
# here; a later session should pick one with both numbers in front of it.
|
|
6159
|
+
_DASHBOARD_NO_SYNC_LOCK_TIMEOUT_SECONDS = 30.0
|
|
5312
6160
|
|
|
5313
6161
|
|
|
5314
6162
|
# === DashboardHTTPHandler (the /api/* + static surface) ===================
|
|
@@ -5671,6 +6519,8 @@ _POST_ROUTES = (
|
|
|
5671
6519
|
("exact", "/api/share/presets/rename", "_handle_share_presets_rename_post",
|
|
5672
6520
|
None, False),
|
|
5673
6521
|
("exact", "/api/share/history", "_handle_share_history_post", None, False),
|
|
6522
|
+
("exact", "/api/debug/backend/trace", "_handle_post_debug_backend_trace",
|
|
6523
|
+
None, False),
|
|
5674
6524
|
)
|
|
5675
6525
|
|
|
5676
6526
|
_DELETE_ROUTES = (
|
|
@@ -5950,15 +6800,54 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5950
6800
|
)
|
|
5951
6801
|
self.end_headers()
|
|
5952
6802
|
|
|
6803
|
+
@classmethod
|
|
6804
|
+
def publish_activity(cls) -> None:
|
|
6805
|
+
"""Republish the held snapshot so a new counter reaches clients now.
|
|
6806
|
+
|
|
6807
|
+
#583 S2 spec 6.2 publication point 1: acknowledge an accepted request
|
|
6808
|
+
without waiting for a rebuild. The reference re-stamps its held
|
|
6809
|
+
snapshot on every mutation, so ``get()`` already carries the new
|
|
6810
|
+
``requested_id``.
|
|
6811
|
+
"""
|
|
6812
|
+
ref = getattr(cls, "snapshot_ref", None)
|
|
6813
|
+
hub = getattr(cls, "hub", None)
|
|
6814
|
+
if ref is None or hub is None:
|
|
6815
|
+
return
|
|
6816
|
+
hub.publish(ref.get())
|
|
6817
|
+
|
|
6818
|
+
@classmethod
|
|
6819
|
+
def mark_rebuilding(cls, value: bool) -> None:
|
|
6820
|
+
"""Publish the in-flight flag for a HANDLER-driven rebuild.
|
|
6821
|
+
|
|
6822
|
+
#583 S2 §6.3. `rebuilding` describes the rebuild, not the thread that
|
|
6823
|
+
started it, and an UNCONTENDED manual refresh rebuilds synchronously
|
|
6824
|
+
right here — `202 queued` is only the contended branch. Without this
|
|
6825
|
+
pair a user clicking the sync chip ran a multi-second rebuild during
|
|
6826
|
+
which every other connected tab published `rebuilding: false` and could
|
|
6827
|
+
not tell a busy dashboard from a wedged one.
|
|
6828
|
+
|
|
6829
|
+
Publishes only on a real transition, exactly like the sync loop's
|
|
6830
|
+
collaborator, so the rebuild's own terminal publish (`set_final`) leaves
|
|
6831
|
+
the trailing mark with nothing to say.
|
|
6832
|
+
"""
|
|
6833
|
+
ref = getattr(cls, "snapshot_ref", None)
|
|
6834
|
+
hub = getattr(cls, "hub", None)
|
|
6835
|
+
if ref is None or hub is None:
|
|
6836
|
+
return
|
|
6837
|
+
if ref.mark_rebuilding(value):
|
|
6838
|
+
hub.publish(ref.get())
|
|
6839
|
+
|
|
5953
6840
|
def _handle_post_sync(self) -> None:
|
|
5954
6841
|
"""Trigger refresh-usage + snapshot rebuild on user demand.
|
|
5955
6842
|
|
|
5956
6843
|
Flow:
|
|
5957
6844
|
1. Origin/Host CSRF check.
|
|
5958
|
-
2. acquire
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
6845
|
+
2. Non-blocking acquire. A machine nudge, or a held lock, enqueues
|
|
6846
|
+
on ``_SnapshotRef`` and answers 202 with the request identifier
|
|
6847
|
+
and this process's server epoch.
|
|
6848
|
+
3. Lock free, human click: exactly the pre-#583 path — under
|
|
6849
|
+
--no-sync skip refresh; otherwise call ``_refresh_usage_inproc()``;
|
|
6850
|
+
always call run_sync_now_locked.
|
|
5962
6851
|
4. Return 204 on clean success, 200 + JSON warnings on
|
|
5963
6852
|
non-ok refresh status, 500 on unexpected exception.
|
|
5964
6853
|
|
|
@@ -5967,17 +6856,36 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5967
6856
|
run_sync_now_locked assumes the caller holds sync_lock - that's the
|
|
5968
6857
|
whole point of the Task 0 lock split.
|
|
5969
6858
|
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
6859
|
+
#583 S2. The bounded acquire and its 503 are gone. A click landing
|
|
6860
|
+
inside the periodic thread's lock-hold used to wait up to ~2s and then
|
|
6861
|
+
503 on stuck contention, which silently dropped the user's intent; it
|
|
6862
|
+
now queues, is serviced by the sync loop under the duty floor, and its
|
|
6863
|
+
settlement arrives on the published frame. This endpoint's status
|
|
6864
|
+
vocabulary is 403 / 202 / 200 / 204 / 500. The two other 503 sites
|
|
6865
|
+
(quota_projection_incomplete) are untouched.
|
|
6866
|
+
|
|
6867
|
+
A MACHINE NUDGE always queues, even when the lock is free. This is
|
|
6868
|
+
load-bearing: cmd_record_usage fires at Claude Code's status-line
|
|
6869
|
+
cadence, so a nudge taking the synchronous path would rebuild at that
|
|
6870
|
+
frequency and reopen the #313 peg, bypassing the loop's floor. The
|
|
6871
|
+
nudge is identified by an explicit ``queue=1``; an older refresh-usage
|
|
6872
|
+
binary posting without it takes the synchronous path, which is today's
|
|
6873
|
+
behaviour.
|
|
5977
6874
|
|
|
5978
6875
|
--no-sync mode: refresh skipped (frozen mode preserves "no network
|
|
5979
6876
|
calls"), rebuild still runs with skip_sync=True (the wired
|
|
5980
|
-
staticmethod closes over args.no_sync, so the no-arg call DTRT).
|
|
6877
|
+
staticmethod closes over args.no_sync, so the no-arg call DTRT). A
|
|
6878
|
+
manual request there acquires sync_lock BLOCKING rather than
|
|
6879
|
+
non-blocking, because nothing would drain a queue in that mode —
|
|
6880
|
+
bounded by ``_DASHBOARD_NO_SYNC_LOCK_TIMEOUT_SECONDS``, past which it
|
|
6881
|
+
answers 200 with a ``sync_busy`` warning rather than pinning the
|
|
6882
|
+
handler thread on a wedged rebuild. A
|
|
6883
|
+
machine nudge is REFUSED there with 204 and enqueues nothing: the
|
|
6884
|
+
documented contract freezes data to the startup snapshot, and a queued
|
|
6885
|
+
nudge serviced by a skip_sync rebuild would read newly persisted rows
|
|
6886
|
+
and unfreeze it. A manual refresh=1 states the skip with a
|
|
6887
|
+
``refresh_skipped_no_sync`` warning instead of performing no refresh
|
|
6888
|
+
silently.
|
|
5981
6889
|
|
|
5982
6890
|
Refresh failures DO NOT cause 500 - they surface as warnings in the
|
|
5983
6891
|
200 envelope and the rebuild still runs so the snapshot stays
|
|
@@ -5985,28 +6893,87 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5985
6893
|
"""
|
|
5986
6894
|
if not self._check_origin_csrf():
|
|
5987
6895
|
return
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
6896
|
+
cls = type(self)
|
|
6897
|
+
sync_lock = cls.sync_lock
|
|
6898
|
+
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
|
6899
|
+
do_refresh = query.get("refresh", ["1"])[0] != "0"
|
|
6900
|
+
is_machine_nudge = query.get("queue", ["0"])[0] == "1"
|
|
6901
|
+
|
|
6902
|
+
if is_machine_nudge and cls.no_sync:
|
|
6903
|
+
self.send_response(204)
|
|
6904
|
+
self.end_headers()
|
|
6905
|
+
return
|
|
6906
|
+
|
|
6907
|
+
# Under --no-sync a manual request WAITS for the lock rather than
|
|
6908
|
+
# queueing. Nothing drains a queue in that mode (`sync_thread` is None),
|
|
6909
|
+
# and the lock is not always free there: POST /api/settings calls
|
|
6910
|
+
# `run_sync_now()`, which takes it blocking precisely so a config change
|
|
6911
|
+
# propagates in a mode whose periodic thread never runs. A click landing
|
|
6912
|
+
# inside that hold used to answer 202 for a batch nobody could capture,
|
|
6913
|
+
# leaving `requested_id > started_id` true forever. The holder in that
|
|
6914
|
+
# mode is always another short synchronous rebuild, so the wait is
|
|
6915
|
+
# bounded. The machine-nudge refusal above is unaffected.
|
|
6916
|
+
if cls.no_sync and not is_machine_nudge:
|
|
6917
|
+
if not sync_lock.acquire(
|
|
6918
|
+
timeout=_DASHBOARD_NO_SYNC_LOCK_TIMEOUT_SECONDS):
|
|
6919
|
+
# A wedged rebuild, not ordinary contention: the holder in this
|
|
6920
|
+
# mode is one short synchronous rebuild. Say so instead of
|
|
6921
|
+
# pinning this thread for the life of the process.
|
|
6922
|
+
self._respond_json(200, {
|
|
6923
|
+
"status": "ok",
|
|
6924
|
+
"warnings": [{"code": "sync_busy"}],
|
|
6925
|
+
})
|
|
6926
|
+
return
|
|
6927
|
+
acquired = True
|
|
6928
|
+
else:
|
|
6929
|
+
acquired = not is_machine_nudge and sync_lock.acquire(blocking=False)
|
|
6930
|
+
if not acquired:
|
|
6931
|
+
# `cls.no_sync` is UNREACHABLE-false here today, so the guard never
|
|
6932
|
+
# subtracts anything: a manual request under --no-sync took the
|
|
6933
|
+
# bounded blocking acquire above and either holds the lock or has
|
|
6934
|
+
# already answered, and a machine nudge under --no-sync answered 204
|
|
6935
|
+
# before either branch. The guard is kept because it states the
|
|
6936
|
+
# invariant the queue depends on — nothing drains a queue under
|
|
6937
|
+
# --no-sync, so a queued batch there must never carry an OAuth
|
|
6938
|
+
# intent — and a future queueing path in that mode would silently
|
|
6939
|
+
# violate it if this were dropped as dead code.
|
|
6940
|
+
request_id = cls.snapshot_ref.request_sync(
|
|
6941
|
+
refresh=do_refresh and not cls.no_sync
|
|
6942
|
+
)
|
|
6943
|
+
cls.publish_activity() # acknowledge before responding
|
|
6944
|
+
self._respond_json(202, {
|
|
6945
|
+
"status": "queued",
|
|
6946
|
+
"request_id": request_id,
|
|
6947
|
+
"server_epoch": cls.snapshot_ref.server_epoch,
|
|
6948
|
+
})
|
|
5992
6949
|
return
|
|
5993
6950
|
try:
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
)
|
|
6951
|
+
# The locked section is a REBUILD, and this is the path an
|
|
6952
|
+
# uncontended manual refresh actually takes, so it reports itself
|
|
6953
|
+
# like every other rebuild does.
|
|
6954
|
+
cls.mark_rebuilding(True)
|
|
5998
6955
|
warnings: list = []
|
|
5999
|
-
if do_refresh
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6956
|
+
if do_refresh:
|
|
6957
|
+
if cls.no_sync:
|
|
6958
|
+
warnings.append({"code": "refresh_skipped_no_sync"})
|
|
6959
|
+
else:
|
|
6960
|
+
result = _refresh_usage_inproc()
|
|
6961
|
+
if result.status != "ok":
|
|
6962
|
+
warnings.append({"code": result.status})
|
|
6003
6963
|
try:
|
|
6004
|
-
|
|
6964
|
+
cls.run_sync_now_locked()
|
|
6005
6965
|
except Exception as exc:
|
|
6006
6966
|
self.log_error("/api/sync rebuild failed: %r", exc)
|
|
6007
6967
|
self.send_error(500, "sync failed")
|
|
6008
6968
|
return
|
|
6009
6969
|
finally:
|
|
6970
|
+
# Drops THIS thread's claim only, so the ordering against the lock
|
|
6971
|
+
# release is not what makes it safe: a concurrent rebuilder's claim
|
|
6972
|
+
# is a different set member and this call cannot touch it, whichever
|
|
6973
|
+
# side of the release it runs on. On the success path the rebuild's
|
|
6974
|
+
# terminal publish has already dropped this thread's claim and this
|
|
6975
|
+
# adds no frame; the exception path is what needs it.
|
|
6976
|
+
cls.mark_rebuilding(False)
|
|
6010
6977
|
sync_lock.release()
|
|
6011
6978
|
|
|
6012
6979
|
if warnings:
|
|
@@ -6177,12 +7144,44 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6177
7144
|
conn.close()
|
|
6178
7145
|
except Exception: # noqa: BLE001 -- a diagnostic must not expose raw errors.
|
|
6179
7146
|
cache_state = {"status": "unavailable"}
|
|
7147
|
+
perf = self._perf_gate()
|
|
7148
|
+
tick_state = _lib_tick_stats.snapshot()
|
|
7149
|
+
requested, applied = perf.pending_state()
|
|
6180
7150
|
body = {
|
|
6181
7151
|
"schemaVersion": 1,
|
|
6182
7152
|
"version": _debug_tool_version(),
|
|
6183
7153
|
"generated_at": (last or {}).get("generated_at"),
|
|
6184
7154
|
"dataset": dataset,
|
|
6185
7155
|
"phases": (last or {}).get("phases"),
|
|
7156
|
+
# #583 S1 §3.1 asked for the stored tree's instant beside
|
|
7157
|
+
# `phases`, so a GET after `--trace off` cannot present an old tree
|
|
7158
|
+
# as current — disabling tracing does not clear the stored tree.
|
|
7159
|
+
# It was already there: the top-level `generated_at` above IS the
|
|
7160
|
+
# tree's instant, read from the same slot. A `phases_generated_at`
|
|
7161
|
+
# key was added and measured byte-identical to it on a live
|
|
7162
|
+
# endpoint, so it is not repeated here.
|
|
7163
|
+
"tick": {
|
|
7164
|
+
"dispatch_counts": dict(tick_state.dispatch_counts),
|
|
7165
|
+
"cache_open_failures": dict(tick_state.cache_open_failures),
|
|
7166
|
+
"tick_seq": tick_state.tick_seq,
|
|
7167
|
+
"records": [r.as_wire() for r in tick_state.records],
|
|
7168
|
+
"standalone": (
|
|
7169
|
+
tick_state.standalone.as_wire()
|
|
7170
|
+
if tick_state.standalone is not None else None
|
|
7171
|
+
),
|
|
7172
|
+
# #583 S4: the SECOND work loop's ring, published under the
|
|
7173
|
+
# same object and behind the same loopback gate. An empty list
|
|
7174
|
+
# is a reachable steady state (`--no-sync` never starts the
|
|
7175
|
+
# thread), so the key is always present.
|
|
7176
|
+
"conversation_sync": [
|
|
7177
|
+
r.as_wire() for r in tick_state.conversation_records
|
|
7178
|
+
],
|
|
7179
|
+
},
|
|
7180
|
+
"tracing": {
|
|
7181
|
+
"requested": requested,
|
|
7182
|
+
"applied": applied,
|
|
7183
|
+
"applies_at": perf.applies_at(),
|
|
7184
|
+
},
|
|
6186
7185
|
"cache_state": cache_state,
|
|
6187
7186
|
"sources": sources,
|
|
6188
7187
|
# Additive, and named rather than folded into `cache_state`: a
|
|
@@ -6195,6 +7194,60 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6195
7194
|
body["note"] = "tracing_disabled"
|
|
6196
7195
|
self._respond_json(200, body)
|
|
6197
7196
|
|
|
7197
|
+
def _handle_post_debug_backend_trace(self) -> None:
|
|
7198
|
+
"""POST ``/api/debug/backend/trace`` — arm the deep phase trace (§3.2).
|
|
7199
|
+
|
|
7200
|
+
Body ``{"enabled": true|false}``; any other shape is 400.
|
|
7201
|
+
|
|
7202
|
+
Gated in three layers, in this order. ``_require_api_auth`` runs first,
|
|
7203
|
+
automatically for any ``/api/*`` path in ``do_POST``, and enforces the
|
|
7204
|
+
bearer whenever the dashboard minted a token. ``_require_debug_backend_
|
|
7205
|
+
allowed`` then applies the loopback TCP peer plus the IP-literal
|
|
7206
|
+
``Host``. ``_check_origin_csrf`` applies Origin/Host parity last.
|
|
7207
|
+
|
|
7208
|
+
Retaining the CSRF layer matters even though the peer is already known
|
|
7209
|
+
to be loopback: the loopback and anti-rebinding checks do not stop a
|
|
7210
|
+
malicious page aiming a simple form POST straight at
|
|
7211
|
+
``http://127.0.0.1:8789``. It is also why a command-line client must
|
|
7212
|
+
send an ``Origin`` matching the ``Host`` it calls — `_check_origin_csrf`
|
|
7213
|
+
rejects a request with none. That is not a weakening, because a
|
|
7214
|
+
non-browser client can set arbitrary headers regardless; the check
|
|
7215
|
+
exists to stop a page making the BROWSER issue the request.
|
|
7216
|
+
|
|
7217
|
+
The flip itself happens at the rebuild boundary in `_lib_perf.
|
|
7218
|
+
apply_pending`, not here, which is why the response reports `applied`
|
|
7219
|
+
as it is now and `applies_at` names when the request takes effect.
|
|
7220
|
+
"""
|
|
7221
|
+
if not self._require_debug_backend_allowed():
|
|
7222
|
+
return
|
|
7223
|
+
if not self._check_origin_csrf():
|
|
7224
|
+
return
|
|
7225
|
+
try:
|
|
7226
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
7227
|
+
except ValueError:
|
|
7228
|
+
length = 0
|
|
7229
|
+
if length <= 0 or length > 4096:
|
|
7230
|
+
self._respond_json(400, {"error": "body required (<=4 KB)"})
|
|
7231
|
+
return
|
|
7232
|
+
try:
|
|
7233
|
+
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
7234
|
+
except (ValueError, UnicodeDecodeError):
|
|
7235
|
+
self._respond_json(400, {"error": "malformed JSON body"})
|
|
7236
|
+
return
|
|
7237
|
+
if (not isinstance(body, dict) or set(body) != {"enabled"}
|
|
7238
|
+
or not isinstance(body.get("enabled"), bool)):
|
|
7239
|
+
self._respond_json(
|
|
7240
|
+
400, {"error": 'body must be {"enabled": true|false}'})
|
|
7241
|
+
return
|
|
7242
|
+
perf = self._perf_gate()
|
|
7243
|
+
perf.request_enabled(body["enabled"])
|
|
7244
|
+
requested, applied = perf.pending_state()
|
|
7245
|
+
self._respond_json(200, {
|
|
7246
|
+
"requested": requested,
|
|
7247
|
+
"applied": applied,
|
|
7248
|
+
"applies_at": perf.applies_at(),
|
|
7249
|
+
})
|
|
7250
|
+
|
|
6198
7251
|
def _handle_post_settings(self) -> None:
|
|
6199
7252
|
"""Persist a settings update and trigger an immediate SSE broadcast.
|
|
6200
7253
|
|
|
@@ -6933,10 +7986,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6933
7986
|
# under --no-sync). run_sync_now is the same path POST /api/sync
|
|
6934
7987
|
# uses; under skip_sync=True it still rebuilds + publishes via
|
|
6935
7988
|
# hub.publish, which is what each SSE listener pulls.
|
|
7989
|
+
#
|
|
7990
|
+
# #583 S2 §6.3: this rebuild is the same multi-second locked rebuild
|
|
7991
|
+
# POST /api/sync runs, so it reports itself the same way. The mark is
|
|
7992
|
+
# outside the acquire because `run_sync_now` takes `sync_lock` itself;
|
|
7993
|
+
# a wait for a rebuild already in flight is honestly in-flight too.
|
|
6936
7994
|
try:
|
|
7995
|
+
type(self).mark_rebuilding(True)
|
|
6937
7996
|
type(self).run_sync_now()
|
|
6938
7997
|
except Exception as exc:
|
|
6939
7998
|
eprint(f"warning: settings broadcast failed: {exc!r}")
|
|
7999
|
+
finally:
|
|
8000
|
+
type(self).mark_rebuilding(False)
|
|
6940
8001
|
|
|
6941
8002
|
self._respond_json(200, out)
|
|
6942
8003
|
|
|
@@ -7206,8 +8267,41 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7206
8267
|
self.wfile.write(body)
|
|
7207
8268
|
|
|
7208
8269
|
def _serve_api_data(self) -> None:
|
|
8270
|
+
# #583 S3 §6/§7. TWO phases. Preparation may answer a JSON 500 because
|
|
8271
|
+
# nothing has been sent yet. Commit may NOT: once `send_response` has
|
|
8272
|
+
# run, a second `_respond_json` writes another HTTP response onto an
|
|
8273
|
+
# already-committed stream. The old handler wrapped both in one `try`
|
|
8274
|
+
# and did exactly that on any partial write.
|
|
8275
|
+
# #600 / §7: the LAST PUBLISHED state, not the reference. The dashboard
|
|
8276
|
+
# mutates the reference through four operations — `set`,
|
|
8277
|
+
# `capture_batch`, `settle`, `mark_rebuilding` — and publication is a
|
|
8278
|
+
# separate call in every case, so reading the reference can report a
|
|
8279
|
+
# `hydrating` flag no client was ever sent. There is no atomic boundary
|
|
8280
|
+
# to read instead, and creating one would mean editing `_SnapshotRef`
|
|
8281
|
+
# and the sync loop, so this endpoint is DEFINED as serving the most
|
|
8282
|
+
# recently published state. Deliberately no silent fallback to the
|
|
8283
|
+
# reference: that fallback is the disagreement this fixes.
|
|
8284
|
+
#
|
|
8285
|
+
# #583 S3 §6: `hub.latest()` gets its own guard, and the 503 is answered
|
|
8286
|
+
# OUTSIDE the preparation `try` below. Writing the 503 inside that `try`
|
|
8287
|
+
# meant a failure part-way through it was caught by the same `except`
|
|
8288
|
+
# that answers a JSON 500, appending a SECOND HTTP response onto a
|
|
8289
|
+
# stream this handler had already committed — the very defect the
|
|
8290
|
+
# prepare/commit split exists to remove, on the one path it did not
|
|
8291
|
+
# cover. The duplicated 500 arm below is the price of that separation.
|
|
7209
8292
|
try:
|
|
7210
|
-
|
|
8293
|
+
delivery = self.hub.latest()
|
|
8294
|
+
except Exception as exc: # noqa: BLE001
|
|
8295
|
+
self.log_error("api/data failed before commit: %r", exc)
|
|
8296
|
+
self._respond_json(500, {"error": "internal error"})
|
|
8297
|
+
return
|
|
8298
|
+
if delivery is None:
|
|
8299
|
+
self._respond_json(503, {"error": "no snapshot published yet"})
|
|
8300
|
+
return
|
|
8301
|
+
|
|
8302
|
+
try:
|
|
8303
|
+
# ---- preparation ---------------------------------------------
|
|
8304
|
+
snap = delivery.snapshot
|
|
7211
8305
|
# Resolve oauth_usage cfg out here so snapshot_to_envelope stays
|
|
7212
8306
|
# pure (no per-request FS read on the dashboard hot path).
|
|
7213
8307
|
# Tolerate user config typos -- fall back to defaults rather than
|
|
@@ -7243,18 +8337,34 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7243
8337
|
# finding) — one predicate, two consumers, desync impossible.
|
|
7244
8338
|
env["transcriptsEnabled"] = visible
|
|
7245
8339
|
body = encode_dashboard_json_bytes(env, ensure_ascii=False)
|
|
8340
|
+
gzip_on = _accepts_gzip(self.headers.get("Accept-Encoding"))
|
|
8341
|
+
if gzip_on:
|
|
8342
|
+
body = gzip.compress(body, 6)
|
|
8343
|
+
except Exception as exc: # noqa: BLE001
|
|
8344
|
+
# #279 S5 F6.1 (spec §8): a snapshot/envelope/dumps failure used to
|
|
8345
|
+
# escape to _QuietThreadingHTTPServer.handle_error (stdlib traceback +
|
|
8346
|
+
# dropped socket, no 500). Mirror _handle_get_doctor: log + JSON 500.
|
|
8347
|
+
self.log_error("api/data failed before commit: %r", exc)
|
|
8348
|
+
self._respond_json(500, {"error": "internal error"})
|
|
8349
|
+
return
|
|
8350
|
+
|
|
8351
|
+
# ---- commit ------------------------------------------------------
|
|
8352
|
+
try:
|
|
7246
8353
|
self.send_response(200)
|
|
7247
8354
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
8355
|
+
if gzip_on:
|
|
8356
|
+
self.send_header("Content-Encoding", "gzip")
|
|
8357
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
7248
8358
|
self.send_header("Content-Length", str(len(body)))
|
|
7249
8359
|
self.send_header("Cache-Control", "no-cache")
|
|
7250
8360
|
self.end_headers()
|
|
7251
8361
|
self.wfile.write(body)
|
|
7252
8362
|
except Exception as exc: # noqa: BLE001
|
|
7253
|
-
#
|
|
7254
|
-
#
|
|
7255
|
-
#
|
|
7256
|
-
self.log_error("api/data failed: %r", exc)
|
|
7257
|
-
self.
|
|
8363
|
+
# Headers are committed, so no status code is available. Log and
|
|
8364
|
+
# close; NEVER `_respond_json` here — that appends a second HTTP
|
|
8365
|
+
# response onto a stream the client is already reading as one.
|
|
8366
|
+
self.log_error("api/data failed after commit: %r", exc)
|
|
8367
|
+
self.close_connection = True
|
|
7258
8368
|
|
|
7259
8369
|
def _handle_get_doctor(self) -> None:
|
|
7260
8370
|
"""`GET /api/doctor` — full kernel-serialized doctor report (spec §5.6).
|
|
@@ -7271,6 +8381,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7271
8381
|
dashboard's default bind; no CSRF gating here (mirrors
|
|
7272
8382
|
`/api/data`, `/api/session/:id`, `/api/block/:start_at`).
|
|
7273
8383
|
"""
|
|
8384
|
+
# Preparation and commit are separate. Before headers, a failure can
|
|
8385
|
+
# still become a JSON 500. After headers, another response would
|
|
8386
|
+
# corrupt the stream, so the only valid recovery is log + close.
|
|
7274
8387
|
try:
|
|
7275
8388
|
_ld = sys.modules["cctally"]._load_sibling("_lib_doctor")
|
|
7276
8389
|
state = doctor_gather_state(
|
|
@@ -7280,6 +8393,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7280
8393
|
body = encode_dashboard_json_bytes(
|
|
7281
8394
|
_ld.serialize_json(report), ensure_ascii=False,
|
|
7282
8395
|
)
|
|
8396
|
+
except Exception as exc: # noqa: BLE001
|
|
8397
|
+
self.log_error("/api/doctor failed before commit: %r", exc)
|
|
8398
|
+
self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
8399
|
+
return
|
|
8400
|
+
|
|
8401
|
+
try:
|
|
7283
8402
|
self.send_response(200)
|
|
7284
8403
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
7285
8404
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -7287,8 +8406,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7287
8406
|
self.end_headers()
|
|
7288
8407
|
self.wfile.write(body)
|
|
7289
8408
|
except Exception as exc: # noqa: BLE001
|
|
7290
|
-
self.log_error("/api/doctor failed: %r", exc)
|
|
7291
|
-
self.
|
|
8409
|
+
self.log_error("/api/doctor failed after commit: %r", exc)
|
|
8410
|
+
self.close_connection = True
|
|
7292
8411
|
|
|
7293
8412
|
def _handle_get_session_detail(self, path: str) -> None:
|
|
7294
8413
|
"""Return TuiSessionDetail JSON for the given session id (spec §3.2).
|
|
@@ -7621,43 +8740,46 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7621
8740
|
None,
|
|
7622
8741
|
)
|
|
7623
8742
|
if target is None:
|
|
8743
|
+
status = 404
|
|
7624
8744
|
body = encode_dashboard_json_bytes({"error": "block not found"})
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
# same zone the rest of the envelope speaks.
|
|
7642
|
-
_detail_tz = _resolve_display_tz_obj(
|
|
7643
|
-
_apply_display_tz_override(
|
|
7644
|
-
load_config(), type(self).display_tz_pref_override
|
|
8745
|
+
else:
|
|
8746
|
+
block_entries = [
|
|
8747
|
+
e for e in entries_in_window
|
|
8748
|
+
if target.start_time <= e.timestamp < target.end_time
|
|
8749
|
+
]
|
|
8750
|
+
# Resolve display tz once per request so the block detail's
|
|
8751
|
+
# `label` matches the snapshot envelope's blocks panel.
|
|
8752
|
+
# Shared resolver -- same warn-once semantics as
|
|
8753
|
+
# `_compute_display_block` and `_tui_build_snapshot`. F3:
|
|
8754
|
+
# honor the dashboard's `--tz` override (set as a class attr
|
|
8755
|
+
# by cmd_dashboard) so the block-detail label speaks the
|
|
8756
|
+
# same zone the rest of the envelope speaks.
|
|
8757
|
+
_detail_tz = _resolve_display_tz_obj(
|
|
8758
|
+
_apply_display_tz_override(
|
|
8759
|
+
load_config(), type(self).display_tz_pref_override
|
|
8760
|
+
)
|
|
7645
8761
|
)
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
8762
|
+
detail = _build_block_detail(
|
|
8763
|
+
target, block_entries, display_tz=_detail_tz,
|
|
8764
|
+
)
|
|
8765
|
+
status = 200
|
|
8766
|
+
body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
|
|
7650
8767
|
except Exception as exc:
|
|
7651
|
-
self.log_error("/api/block failed: %r", exc)
|
|
8768
|
+
self.log_error("/api/block failed before commit: %r", exc)
|
|
7652
8769
|
self.send_error(500, "block detail failed")
|
|
7653
8770
|
return
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
8771
|
+
|
|
8772
|
+
try:
|
|
8773
|
+
self.send_response(status)
|
|
8774
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
8775
|
+
self.send_header("Content-Length", str(len(body)))
|
|
8776
|
+
if status == 200:
|
|
8777
|
+
self.send_header("Cache-Control", "no-cache")
|
|
8778
|
+
self.end_headers()
|
|
8779
|
+
self.wfile.write(body)
|
|
8780
|
+
except Exception as exc: # noqa: BLE001
|
|
8781
|
+
self.log_error("/api/block failed after commit: %r", exc)
|
|
8782
|
+
self.close_connection = True
|
|
7661
8783
|
|
|
7662
8784
|
def _send_milestones_json(self, status: int, body: dict) -> None:
|
|
7663
8785
|
payload = encode_dashboard_json_bytes(body, ensure_ascii=False)
|
|
@@ -7786,7 +8908,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7786
8908
|
# reset and never resolves as current.
|
|
7787
8909
|
identity = resolve_codex_cycle_detail_identity(
|
|
7788
8910
|
cache_conn, source_root_keys=roots, now_utc=now_utc,
|
|
7789
|
-
account_key=account_key,
|
|
8911
|
+
account_key=account_key, stats_conn=stats_conn,
|
|
7790
8912
|
)
|
|
7791
8913
|
result = c.build_codex_cycle_detail(
|
|
7792
8914
|
stats_conn, cache_conn, identity=identity, key=key,
|
|
@@ -7827,14 +8949,46 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7827
8949
|
|
|
7828
8950
|
def _serve_api_events(self) -> None:
|
|
7829
8951
|
import queue as _queue
|
|
8952
|
+
gzip_on = _accepts_gzip(self.headers.get("Accept-Encoding"))
|
|
7830
8953
|
self.send_response(200)
|
|
7831
8954
|
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
7832
8955
|
self.send_header("Cache-Control", "no-cache")
|
|
7833
8956
|
self.send_header("Connection", "keep-alive")
|
|
7834
8957
|
# Nginx/proxies: disable buffering so events flow immediately.
|
|
8958
|
+
# #583 S3 §6: this STAYS under compression. It addresses an
|
|
8959
|
+
# intermediary proxy, not our own buffering, and is orthogonal.
|
|
7835
8960
|
self.send_header("X-Accel-Buffering", "no")
|
|
8961
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
8962
|
+
if gzip_on:
|
|
8963
|
+
self.send_header("Content-Encoding", "gzip")
|
|
7836
8964
|
self.end_headers()
|
|
7837
8965
|
|
|
8966
|
+
# #583 S3 §6. ONE stateful compressor per connection. EVERY byte after
|
|
8967
|
+
# the headers goes through it — updates AND the keep-alive comment. A
|
|
8968
|
+
# raw write of even two bytes corrupts the whole remainder of the
|
|
8969
|
+
# stream, and the failure is silent until the next frame.
|
|
8970
|
+
#
|
|
8971
|
+
# One compressor per CONNECTION rather than one per tick, deliberately:
|
|
8972
|
+
# sharing compressed bytes across clients requires each frame to be an
|
|
8973
|
+
# independent gzip member, and cross-browser support for incrementally
|
|
8974
|
+
# decoding a concatenated multi-member stream under `Content-Encoding`
|
|
8975
|
+
# is not established. The saving would be proportional to connected
|
|
8976
|
+
# clients minus one — exactly zero at one open tab. Filed as a residual.
|
|
8977
|
+
_comp = (zlib.compressobj(6, zlib.DEFLATED, 16 + zlib.MAX_WBITS)
|
|
8978
|
+
if gzip_on else None)
|
|
8979
|
+
|
|
8980
|
+
def _emit(raw: bytes) -> None:
|
|
8981
|
+
if _comp is None:
|
|
8982
|
+
self.wfile.write(raw)
|
|
8983
|
+
else:
|
|
8984
|
+
# Z_SYNC_FLUSH, not a bare compress(): zlib buffers a small
|
|
8985
|
+
# frame entirely, so without the flush the client receives
|
|
8986
|
+
# nothing at all until some later write happens to spill it.
|
|
8987
|
+
chunk = _comp.compress(raw) + _comp.flush(zlib.Z_SYNC_FLUSH)
|
|
8988
|
+
if chunk:
|
|
8989
|
+
self.wfile.write(chunk)
|
|
8990
|
+
self.wfile.flush()
|
|
8991
|
+
|
|
7838
8992
|
# Resolve oauth_usage cfg once per SSE connection so the per-tick
|
|
7839
8993
|
# envelope build stays free of FS reads. A config edit during the
|
|
7840
8994
|
# connection's lifetime won't take effect until the client
|
|
@@ -7854,37 +9008,74 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7854
9008
|
# ~15s after bootstrap). Mirrors `/api/data`'s per-request injection.
|
|
7855
9009
|
transcripts_enabled = self._transcripts_visible_to_request()
|
|
7856
9010
|
|
|
9011
|
+
# #583 S3 §5. Normalize the privacy input BEFORE it becomes a cache
|
|
9012
|
+
# key: an unresolvable value is the RESTRICTIVE one. An ordinary cache
|
|
9013
|
+
# MISS for the `true` variant still computes `true` — that is a
|
|
9014
|
+
# different situation, and conflating the two either leaks transcript
|
|
9015
|
+
# content or breaks the gate.
|
|
9016
|
+
visible = bool(transcripts_enabled)
|
|
9017
|
+
# Only `visible` and the oauth config can differ between two
|
|
9018
|
+
# connections in this process today. The two process constants stay in
|
|
9019
|
+
# the key so a later change making either per-connection cannot
|
|
9020
|
+
# silently serve one client another client's payload.
|
|
9021
|
+
variant = (
|
|
9022
|
+
visible,
|
|
9023
|
+
_canonical_oauth_key(cfg_oauth),
|
|
9024
|
+
type(self).display_tz_pref_override,
|
|
9025
|
+
type(self).cctally_host,
|
|
9026
|
+
)
|
|
9027
|
+
|
|
7857
9028
|
q = self.hub.subscribe()
|
|
7858
9029
|
try:
|
|
7859
9030
|
while True:
|
|
7860
9031
|
try:
|
|
7861
|
-
|
|
9032
|
+
delivery = q.get(timeout=_SSE_KEEPALIVE_SECONDS)
|
|
7862
9033
|
except _queue.Empty:
|
|
7863
9034
|
# Keep-alive. Comment lines are ignored by EventSource
|
|
7864
|
-
# but stop idle-proxy timeouts.
|
|
7865
|
-
|
|
7866
|
-
|
|
9035
|
+
# but stop idle-proxy timeouts. #583 S3 §6: through
|
|
9036
|
+
# `_emit`, never a raw `wfile.write` — under compression a
|
|
9037
|
+
# raw write here corrupts every byte after it.
|
|
9038
|
+
_emit(b": keep-alive\n\n")
|
|
7867
9039
|
continue
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
9040
|
+
# #583 S3 §5: skip to the newest queued delivery. The queue
|
|
9041
|
+
# holds four and `publish` discards only one oldest, so a
|
|
9042
|
+
# lagging client would otherwise replay a backlog whose clocks
|
|
9043
|
+
# were pinned several publish periods ago. The blocking `get`
|
|
9044
|
+
# above keeps its own `queue.Empty` keep-alive path.
|
|
9045
|
+
delivery = _drain_to_newest(q, delivery)
|
|
9046
|
+
|
|
9047
|
+
def _project(_key, _d=delivery, _v=visible):
|
|
9048
|
+
env = snapshot_to_envelope(
|
|
9049
|
+
_d.snapshot,
|
|
9050
|
+
now_utc=_d.pinned_now_utc,
|
|
9051
|
+
monotonic_now=_d.pinned_monotonic,
|
|
9052
|
+
oauth_usage_cfg=cfg_oauth,
|
|
9053
|
+
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
9054
|
+
runtime_bind=type(self).cctally_host,
|
|
9055
|
+
# #264 S3: gate the in-envelope session `title` on the
|
|
9056
|
+
# same connection-scoped predicate that drives
|
|
9057
|
+
# transcriptsEnabled.
|
|
9058
|
+
transcripts_visible=_v,
|
|
9059
|
+
)
|
|
9060
|
+
# Part of the CACHED variant, not a post-projection
|
|
9061
|
+
# mutation: the payload is shared across every client
|
|
9062
|
+
# holding this delivery, so mutating it here would apply
|
|
9063
|
+
# to all of them.
|
|
9064
|
+
env["transcriptsEnabled"] = _v
|
|
9065
|
+
payload = encode_dashboard_json_bytes(
|
|
9066
|
+
env, ensure_ascii=False,
|
|
9067
|
+
)
|
|
9068
|
+
return b"event: update\ndata: " + payload + b"\n\n"
|
|
9069
|
+
|
|
9070
|
+
if _delivery_is_shareable(delivery.snapshot):
|
|
9071
|
+
frame = delivery.encoded(variant, _project)
|
|
9072
|
+
else:
|
|
9073
|
+
# #583 S3 §5: this snapshot's projection is NOT a function
|
|
9074
|
+
# of the snapshot plus the key — `snapshot_to_envelope`
|
|
9075
|
+
# reads configuration inline and runs the real doctor
|
|
9076
|
+
# gather per call — so it must not be cached and shared.
|
|
9077
|
+
frame = _project(variant)
|
|
9078
|
+
_emit(frame)
|
|
7888
9079
|
except (BrokenPipeError, ConnectionResetError,
|
|
7889
9080
|
ConnectionAbortedError, socket.timeout):
|
|
7890
9081
|
# #279 S1 F3: a stalled send past the handler timeout raises
|
|
@@ -7898,7 +9089,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7898
9089
|
# traceback. Headers are already committed — no 500 is possible; the
|
|
7899
9090
|
# win is routing the operator signal through the _lib_log chokepoint
|
|
7900
9091
|
# (self.log_error) + a deliberate clean close via the finally below.
|
|
9092
|
+
#
|
|
9093
|
+
# #583 S3 §6: under compression this path must add NOTHING to the
|
|
9094
|
+
# stream. Do not append plaintext, another gzip member, or a
|
|
9095
|
+
# trailer to a truncated stream; do not call `Z_FINISH` here,
|
|
9096
|
+
# because the stateful compressor may have advanced even though its
|
|
9097
|
+
# output was not fully written; and do not skip the frame and carry
|
|
9098
|
+
# on, for the same reason. The browser's own EventSource reconnect
|
|
9099
|
+
# opens a fresh response with a fresh compressor, which is the
|
|
9100
|
+
# correct recovery.
|
|
7901
9101
|
self.log_error("api/events stream failed: %r", exc)
|
|
9102
|
+
self.close_connection = True
|
|
7902
9103
|
finally:
|
|
7903
9104
|
self.hub.unsubscribe(q)
|
|
7904
9105
|
|
|
@@ -8269,7 +9470,10 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
8269
9470
|
"fingerprint": "sha1:" + ("0" * 40),
|
|
8270
9471
|
}
|
|
8271
9472
|
replacements = {
|
|
8272
|
-
|
|
9473
|
+
# #583 S2 §6.1: this frame is degraded by construction — it carries a
|
|
9474
|
+
# `stats-open` error and no successful build ran, so it must not stamp
|
|
9475
|
+
# a fresh success. There is no earlier success to preserve either.
|
|
9476
|
+
"last_sync_at": None,
|
|
8273
9477
|
"last_sync_error": "; ".join(errors),
|
|
8274
9478
|
"sync_failures": (
|
|
8275
9479
|
tui.SyncFailureAttribution(
|
|
@@ -8412,7 +9616,10 @@ def _dashboard_initial_snapshot_once(
|
|
|
8412
9616
|
current_week=cw,
|
|
8413
9617
|
forecast=fc,
|
|
8414
9618
|
forecast_view=fc_view,
|
|
8415
|
-
|
|
9619
|
+
# #583 S2 §6.1: a build that failed does not stamp a success. This is
|
|
9620
|
+
# the FIRST build, so a failure retains None rather than becoming
|
|
9621
|
+
# freshly successful — there is no earlier success to preserve.
|
|
9622
|
+
last_sync_at=(None if errors else _time.monotonic()),
|
|
8416
9623
|
last_sync_error=("; ".join(errors) if errors else None),
|
|
8417
9624
|
sync_failures=tuple(sync_failures),
|
|
8418
9625
|
doctor_payload=doctor_payload,
|
|
@@ -8640,16 +9847,23 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
8640
9847
|
|
|
8641
9848
|
ref = _SnapshotRef(initial)
|
|
8642
9849
|
hub = SSEHub()
|
|
8643
|
-
|
|
9850
|
+
# #583 S2: seed for early subscribers, published from the reference so the
|
|
9851
|
+
# very first frame already carries the process's `server_epoch`. A seed
|
|
9852
|
+
# published without it would make the client discard its (empty) outstanding
|
|
9853
|
+
# set on the next frame's epoch change — harmless, but it would also mean
|
|
9854
|
+
# the first frame a client ever sees disagrees with every later one.
|
|
9855
|
+
hub.publish(ref.get())
|
|
8644
9856
|
|
|
8645
9857
|
# sync_lock serializes sync-work between the periodic sync thread and
|
|
8646
9858
|
# the POST /api/sync handler. Held only around _tui_build_snapshot +
|
|
8647
9859
|
# ref.set + hub.publish — NOT around the handler's response path.
|
|
8648
|
-
# The handler
|
|
8649
|
-
# the periodic thread's lock-hold
|
|
8650
|
-
#
|
|
8651
|
-
#
|
|
8652
|
-
#
|
|
9860
|
+
# The handler acquires it NON-BLOCKING (#583 S2): a click that lands
|
|
9861
|
+
# inside the periodic thread's lock-hold is queued on _SnapshotRef and
|
|
9862
|
+
# answered 202, and the sync loop services it under the duty floor. The
|
|
9863
|
+
# bounded acquire and its 503 are gone. The one exception is --no-sync,
|
|
9864
|
+
# where nothing would drain that queue, so a manual request there waits on
|
|
9865
|
+
# a blocking acquire instead. The lock inside _run_sync_now is what
|
|
9866
|
+
# actually prevents overlap.
|
|
8653
9867
|
sync_lock = threading.Lock()
|
|
8654
9868
|
|
|
8655
9869
|
# Build the two variants up front. The locked variant is exposed on the
|
|
@@ -8706,32 +9920,27 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
8706
9920
|
|
|
8707
9921
|
class _DashboardSyncThread(_c_for_subclass._TuiSyncThread):
|
|
8708
9922
|
def _run(self) -> None:
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
# next cadence; gated off under --no-sync. Runs INSIDE the
|
|
8717
|
-
# measured iteration so its cost counts toward the cooldown
|
|
8718
|
-
# deadline (#313 P2 / F10).
|
|
8719
|
-
if (not self._skip_sync
|
|
8720
|
-
and _time.monotonic() - last_heal[0] >= 60.0):
|
|
8721
|
-
last_heal[0] = _time.monotonic()
|
|
8722
|
-
_dashboard_self_heal_orphans(skip_sync=self._skip_sync)
|
|
9923
|
+
run_iteration = _make_dashboard_run_iteration(
|
|
9924
|
+
sync_lock=sync_lock,
|
|
9925
|
+
run_sync_now=_run_sync_now,
|
|
9926
|
+
run_sync_now_locked=_run_sync_now_locked,
|
|
9927
|
+
skip_sync=self._skip_sync,
|
|
9928
|
+
monotonic=_time.monotonic,
|
|
9929
|
+
)
|
|
8723
9930
|
|
|
8724
9931
|
# Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
|
|
8725
|
-
# so a slow rebuild cannot peg a full core.
|
|
8726
|
-
#
|
|
8727
|
-
#
|
|
9932
|
+
# so a slow rebuild cannot peg a full core. #583 S2 adds the
|
|
9933
|
+
# request-driven start, floored at t0 + 2*work so a queued refresh
|
|
9934
|
+
# cannot drive the duty above the same #313 bound. The dashboard no
|
|
9935
|
+
# longer passes the legacy test-and-clear flag: under a floor a poll
|
|
9936
|
+
# firing early would clear the request and discard it.
|
|
8728
9937
|
_dashboard_sync_loop(
|
|
8729
9938
|
stop=self._stop,
|
|
8730
9939
|
interval=self._interval,
|
|
8731
9940
|
run_iteration=run_iteration,
|
|
8732
|
-
take_sync_request=self._ref.take_sync_request,
|
|
8733
9941
|
monotonic=_time.monotonic,
|
|
8734
9942
|
sleep=_time.sleep,
|
|
9943
|
+
**_make_sync_loop_collaborators(ref=ref, hub=hub),
|
|
8735
9944
|
)
|
|
8736
9945
|
|
|
8737
9946
|
sync_thread = (
|
|
@@ -8743,43 +9952,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
8743
9952
|
if sync_thread is not None:
|
|
8744
9953
|
sync_thread.start()
|
|
8745
9954
|
|
|
8746
|
-
#
|
|
8747
|
-
#
|
|
8748
|
-
#
|
|
9955
|
+
# The loop, the pass and this thread's construction are module-level (see
|
|
9956
|
+
# `_conversation_sync_loop`): the extraction is what lets the duty bound be
|
|
9957
|
+
# proven on a virtual clock instead of asserted.
|
|
8749
9958
|
conversation_sync_stop = threading.Event()
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
conn = None
|
|
8755
|
-
try:
|
|
8756
|
-
conn = open_conversations_db()
|
|
8757
|
-
sync_claude_conversations(conn)
|
|
8758
|
-
sync_codex_conversations(conn)
|
|
8759
|
-
_dashboard_maybe_prune_retention()
|
|
8760
|
-
except (OSError, sqlite3.DatabaseError) as exc:
|
|
8761
|
-
eprint(f"[conversations] background sync unavailable: {exc}")
|
|
8762
|
-
except Exception as exc: # noqa: BLE001
|
|
8763
|
-
# Transcript parsing/normalization is deliberately outside the
|
|
8764
|
-
# core freshness loop. Keep this worker alive so a later clean
|
|
8765
|
-
# tick can self-heal instead of permanently stopping after one
|
|
8766
|
-
# malformed provider record.
|
|
8767
|
-
eprint(
|
|
8768
|
-
"[conversations] background sync failed: "
|
|
8769
|
-
f"{type(exc).__name__}: {exc}"
|
|
8770
|
-
)
|
|
8771
|
-
finally:
|
|
8772
|
-
if conn is not None:
|
|
8773
|
-
conn.close()
|
|
8774
|
-
conversation_sync_stop.wait(interval)
|
|
8775
|
-
|
|
8776
|
-
conversation_sync_thread = (
|
|
8777
|
-
None if args.no_sync
|
|
8778
|
-
else threading.Thread(
|
|
8779
|
-
target=_conversation_sync_loop,
|
|
8780
|
-
daemon=True,
|
|
8781
|
-
name="dashboard-conversations-sync",
|
|
8782
|
-
)
|
|
9959
|
+
conversation_sync_thread = _make_conversation_sync_thread(
|
|
9960
|
+
stop=conversation_sync_stop,
|
|
9961
|
+
sync_interval=args.sync_interval,
|
|
9962
|
+
no_sync=args.no_sync,
|
|
8783
9963
|
)
|
|
8784
9964
|
if conversation_sync_thread is not None:
|
|
8785
9965
|
conversation_sync_thread.start()
|
|
@@ -8790,7 +9970,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
8790
9970
|
# relying on the data-sync thread's lifecycle.
|
|
8791
9971
|
update_check_stop = threading.Event()
|
|
8792
9972
|
update_check_thread = _DashboardUpdateCheckThread(
|
|
8793
|
-
update_check_stop, hub=hub, snapshot_ref=ref,
|
|
9973
|
+
update_check_stop, hub=hub, snapshot_ref=ref, runtime_bind=args.host,
|
|
8794
9974
|
)
|
|
8795
9975
|
update_check_thread.start()
|
|
8796
9976
|
|