cctally 1.117.0 → 1.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +7 -3
- package/bin/_cctally_cache.py +2783 -175
- package/bin/_cctally_completion.py +106 -33
- package/bin/_cctally_core.py +69 -4
- package/bin/_cctally_dashboard.py +227 -23
- package/bin/_cctally_dashboard_conversation.py +5 -1
- package/bin/_cctally_dashboard_envelope.py +60 -19
- package/bin/_cctally_dashboard_sources.py +23 -1
- package/bin/_cctally_db.py +193 -153
- package/bin/_cctally_diagnosis_sources.py +63 -7
- package/bin/_cctally_doctor.py +865 -93
- package/bin/_cctally_five_hour.py +124 -54
- package/bin/_cctally_forecast.py +301 -7
- package/bin/_cctally_journal.py +186 -18
- package/bin/_cctally_milestone_history.py +1193 -108
- package/bin/_cctally_oauth_samples.py +286 -15
- package/bin/_cctally_parser.py +2 -2
- package/bin/_cctally_quota.py +104 -12
- package/bin/_cctally_record.py +153 -29
- package/bin/_cctally_rederive.py +608 -68
- package/bin/_cctally_statusline.py +108 -81
- package/bin/_cctally_store.py +179 -38
- package/bin/_cctally_tui.py +1210 -170
- package/bin/_cctally_update.py +32 -13
- package/bin/_lib_codex_conversation_query.py +52 -14
- package/bin/_lib_codex_drain_admission.py +224 -6
- package/bin/_lib_codex_metadata.py +39 -0
- package/bin/_lib_conversation_anon.py +15 -8
- package/bin/_lib_conversation_dispatch.py +7 -0
- package/bin/_lib_conversation_query.py +289 -70
- package/bin/_lib_dashboard_sources.py +131 -9
- package/bin/_lib_detector_access_registry.py +497 -26
- package/bin/_lib_doctor.py +85 -20
- package/bin/_lib_doctor_scan_index.py +321 -0
- package/bin/_lib_ingest_frontier.py +20 -1
- package/bin/_lib_oauth_samples.py +23 -7
- package/bin/_lib_pricing.py +146 -16
- package/bin/_lib_quota.py +19 -0
- package/bin/_lib_quota_model.py +29 -11
- package/bin/_lib_rederive.py +11 -6
- package/bin/_lib_retained_records.py +516 -0
- package/bin/_lib_snapshot_cache.py +18 -0
- package/bin/_lib_stale_proof.py +272 -43
- package/bin/_lib_stats_content_revision.py +831 -0
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +12 -5
- package/bin/cctally +8 -1
- package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
- package/dashboard/static/assets/{DoctorModal-BqmXbnhn.js → DoctorModal-D4QSq6uz.js} +1 -1
- package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B-HZkDnT.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-QhJkHB7i.js → SourceDetailModal-NyyEfXmc.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CkXEIQcK.js → UpdateModal-CFLq9pT1.js} +1 -1
- package/dashboard/static/assets/{index-HKS1s56o.js → index-BKTWH9JE.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-BNGaNPCp.js → outlineNavigation-BnGUwCS3.js} +3 -3
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-Dcfn8T6f.js +0 -72
- package/dashboard/static/assets/ModalRoot-Ck3WJK2V.js +0 -1
|
@@ -2450,6 +2450,108 @@ def _dashboard_sync_loop(
|
|
|
2450
2450
|
sleep(min(0.1, max(0.0, deadline - now)))
|
|
2451
2451
|
|
|
2452
2452
|
|
|
2453
|
+
def _frozen_deferral_settle_loop(
|
|
2454
|
+
*,
|
|
2455
|
+
stop,
|
|
2456
|
+
interval: float,
|
|
2457
|
+
ref,
|
|
2458
|
+
sync_lock,
|
|
2459
|
+
run_locked,
|
|
2460
|
+
mark_rebuilding,
|
|
2461
|
+
is_deferred,
|
|
2462
|
+
monotonic=time.monotonic,
|
|
2463
|
+
wait=None,
|
|
2464
|
+
) -> None:
|
|
2465
|
+
"""Retry a frozen dashboard's no-ingest build until recovery permits it (#932).
|
|
2466
|
+
|
|
2467
|
+
`--no-sync` starts no sync thread, so a frozen dashboard whose first build
|
|
2468
|
+
DEFERRED (an older-epoch index, or a corruption heal running detached) would
|
|
2469
|
+
otherwise hold that data-less deferral frame for its whole life. This loop
|
|
2470
|
+
retries the dashboard's existing locked publisher with ``skip_sync=True``
|
|
2471
|
+
(the same no-ingest build a manual ``POST /api/sync`` runs: no ingest, no
|
|
2472
|
+
OAuth refresh, no derivations) only while the held frame is an EXPLICIT
|
|
2473
|
+
stats deferral (``is_deferred``, the `SyncFailureAttribution.deferred`
|
|
2474
|
+
predicate), and exits for good on any other outcome — a populated or empty
|
|
2475
|
+
success, a crash, an exhausted corruption retry.
|
|
2476
|
+
|
|
2477
|
+
Cadence: one ``interval`` before the first attempt, then
|
|
2478
|
+
``_next_deadline(t0, interval, work)``, the #313 cooldown that bounds duty
|
|
2479
|
+
to 50 %. The ``sync_lock`` acquire is NON-blocking; contention skips that
|
|
2480
|
+
cadence. Every attempt does all of its work under the lock and rechecks
|
|
2481
|
+
``stop`` right after acquiring it, which is what makes
|
|
2482
|
+
`_retire_frozen_deferral_settle`'s lock fence exact. ``wait(seconds)``
|
|
2483
|
+
returns True when stopped (``stop.wait`` in production).
|
|
2484
|
+
"""
|
|
2485
|
+
if wait is None:
|
|
2486
|
+
wait = stop.wait
|
|
2487
|
+
deadline = monotonic() + interval
|
|
2488
|
+
while True:
|
|
2489
|
+
if wait(max(0.0, deadline - monotonic())) or stop.is_set():
|
|
2490
|
+
return
|
|
2491
|
+
if not is_deferred(ref.get()):
|
|
2492
|
+
return # settled elsewhere (a manual sync): no extra build
|
|
2493
|
+
if not sync_lock.acquire(blocking=False):
|
|
2494
|
+
deadline = monotonic() + interval
|
|
2495
|
+
continue
|
|
2496
|
+
try:
|
|
2497
|
+
if stop.is_set() or not is_deferred(ref.get()):
|
|
2498
|
+
return
|
|
2499
|
+
t0 = monotonic()
|
|
2500
|
+
try:
|
|
2501
|
+
with _rebuilding_claim(mark_rebuilding):
|
|
2502
|
+
run_locked(skip_sync=True)
|
|
2503
|
+
except Exception: # noqa: BLE001 — logged; the loop is done
|
|
2504
|
+
_log_sync_iteration_failure()
|
|
2505
|
+
return
|
|
2506
|
+
work = monotonic() - t0
|
|
2507
|
+
still_deferred = is_deferred(ref.get())
|
|
2508
|
+
finally:
|
|
2509
|
+
sync_lock.release()
|
|
2510
|
+
if not still_deferred:
|
|
2511
|
+
return
|
|
2512
|
+
deadline = _next_deadline(t0, interval, work)
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
def _make_frozen_deferral_settle_thread(
|
|
2516
|
+
*, stop, interval, ref, hub, sync_lock, run_sync_now_locked,
|
|
2517
|
+
) -> threading.Thread:
|
|
2518
|
+
"""The daemon thread running `_frozen_deferral_settle_loop` (#932)."""
|
|
2519
|
+
collaborators = _make_sync_loop_collaborators(ref=ref, hub=hub)
|
|
2520
|
+
tui = _cctally()._cctally_tui
|
|
2521
|
+
return threading.Thread(
|
|
2522
|
+
target=lambda: _frozen_deferral_settle_loop(
|
|
2523
|
+
stop=stop,
|
|
2524
|
+
interval=float(interval),
|
|
2525
|
+
ref=ref,
|
|
2526
|
+
sync_lock=sync_lock,
|
|
2527
|
+
run_locked=run_sync_now_locked,
|
|
2528
|
+
mark_rebuilding=collaborators["mark_rebuilding"],
|
|
2529
|
+
is_deferred=tui._tui_snapshot_is_stats_deferral,
|
|
2530
|
+
),
|
|
2531
|
+
daemon=True,
|
|
2532
|
+
name="dashboard-frozen-deferral-settle",
|
|
2533
|
+
)
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
def _retire_frozen_deferral_settle(*, stop, sync_lock, thread) -> None:
|
|
2537
|
+
"""Retire the settle thread before the resources its attempts use (#932).
|
|
2538
|
+
|
|
2539
|
+
A lock fence, not a timed join: set ``stop``, then acquire and release
|
|
2540
|
+
``sync_lock`` (blocking, no timeout), which waits out any attempt already
|
|
2541
|
+
in flight — its build, verifier submission, ``set_final`` and publish all
|
|
2542
|
+
run under the lock — and no attempt can start after it, because each
|
|
2543
|
+
rechecks ``stop`` right after acquiring. The join that follows only
|
|
2544
|
+
collects a thread that is waiting on ``stop`` or returning. The lock is
|
|
2545
|
+
released before the caller retires the memory verifiers, whose unsafe
|
|
2546
|
+
completion takes the same lock. Intentionally unbounded: it drains
|
|
2547
|
+
whichever holder has the lock.
|
|
2548
|
+
"""
|
|
2549
|
+
stop.set()
|
|
2550
|
+
sync_lock.acquire()
|
|
2551
|
+
sync_lock.release()
|
|
2552
|
+
thread.join()
|
|
2553
|
+
|
|
2554
|
+
|
|
2453
2555
|
def _dashboard_startup_schema_migration(*, run_derivations: bool = True) -> None:
|
|
2454
2556
|
"""Advance both stores to head once, at startup, in EVERY mode (#780).
|
|
2455
2557
|
|
|
@@ -7135,6 +7237,25 @@ def _projects_week_usage_rows(
|
|
|
7135
7237
|
return rows
|
|
7136
7238
|
|
|
7137
7239
|
|
|
7240
|
+
def _projects_content_revision_leg(conn: "sqlite3.Connection") -> str:
|
|
7241
|
+
"""The Claude content revision memo leg read on ``conn`` (#1050) for a
|
|
7242
|
+
call without a dispatch leg (tests, the drill-down fallback).
|
|
7243
|
+
|
|
7244
|
+
A ``current`` store's leg is its token. Any other state names itself, so a
|
|
7245
|
+
store with no revision tracking (a bare fixture database) memoizes exactly
|
|
7246
|
+
as it did before #1050 instead of missing on every call; the tick never
|
|
7247
|
+
takes this path, and its own dispatch leg on such a store is a fresh nonce.
|
|
7248
|
+
"""
|
|
7249
|
+
import _lib_stats_content_revision as k
|
|
7250
|
+
try:
|
|
7251
|
+
revision = k.read_claude_content_revision(conn)
|
|
7252
|
+
except k.StatsContentRevisionUnreadable:
|
|
7253
|
+
return "?unreadable"
|
|
7254
|
+
if revision.state == k.STATE_CURRENT:
|
|
7255
|
+
return revision.token
|
|
7256
|
+
return f"?{revision.state}"
|
|
7257
|
+
|
|
7258
|
+
|
|
7138
7259
|
def _build_projects_envelope(
|
|
7139
7260
|
conn: "sqlite3.Connection",
|
|
7140
7261
|
*,
|
|
@@ -7142,6 +7263,7 @@ def _build_projects_envelope(
|
|
|
7142
7263
|
current_week: "Any | None" = None,
|
|
7143
7264
|
weeks_back: int = 12,
|
|
7144
7265
|
use_projects_env_cache: bool = False,
|
|
7266
|
+
content_revision_leg: "str | None" = None,
|
|
7145
7267
|
) -> dict:
|
|
7146
7268
|
"""Build the ``projects.{current_week, trend}`` envelope block.
|
|
7147
7269
|
|
|
@@ -7172,6 +7294,12 @@ def _build_projects_envelope(
|
|
|
7172
7294
|
``(max(session_entries.id), max_wus_id, max(mutation_seq), cw_week_start,
|
|
7173
7295
|
weeks_back)`` — the ``mutation_seq`` leg (#270 §7d) busts the memo on an
|
|
7174
7296
|
id-stable in-place finalization.
|
|
7297
|
+
|
|
7298
|
+
``content_revision_leg`` (#1050 spec 4.5 item 7) is the dashboard tick's
|
|
7299
|
+
dispatch leg of the stats.db Claude content revision, read before this
|
|
7300
|
+
lookup; the memo key carries it, so an in-place ``weekly_percent``
|
|
7301
|
+
correction (which moves no ``MAX(id)``) rebuilds the envelope. ``None``
|
|
7302
|
+
reads it here, on ``conn``.
|
|
7175
7303
|
"""
|
|
7176
7304
|
c = _cctally()
|
|
7177
7305
|
|
|
@@ -7184,6 +7312,7 @@ def _build_projects_envelope(
|
|
|
7184
7312
|
# SELECT uses below).
|
|
7185
7313
|
cur = conn.execute("SELECT COALESCE(MAX(id), 0) FROM session_entries")
|
|
7186
7314
|
max_id = cur.fetchone()[0]
|
|
7315
|
+
stats_readable = True
|
|
7187
7316
|
try:
|
|
7188
7317
|
cur = conn.execute(
|
|
7189
7318
|
"SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
|
|
@@ -7191,6 +7320,15 @@ def _build_projects_envelope(
|
|
|
7191
7320
|
max_wus_id = cur.fetchone()[0]
|
|
7192
7321
|
except sqlite3.OperationalError:
|
|
7193
7322
|
max_wus_id = 0
|
|
7323
|
+
stats_readable = False
|
|
7324
|
+
# #1050 spec 4.5 item 7: the attributed percentages are functions of the
|
|
7325
|
+
# stored `weekly_percent`, which a correction rewrites in place. The leg is
|
|
7326
|
+
# the dispatch's; a caller without one reads it here, before the lookup.
|
|
7327
|
+
# A connection with no stats tables publishes no stats-derived value, so
|
|
7328
|
+
# it keys on no leg; an untrusted or unreadable identity is a fresh nonce.
|
|
7329
|
+
if content_revision_leg is None:
|
|
7330
|
+
content_revision_leg = (
|
|
7331
|
+
_projects_content_revision_leg(conn) if stats_readable else "")
|
|
7194
7332
|
# #270 (§7d, Codex-2b): this OUTER whole-envelope memo returns BEFORE the
|
|
7195
7333
|
# inner per-(project, week) reconcile cache, so an id-stable in-place
|
|
7196
7334
|
# finalization — which leaves MAX(id)/MAX(wus id) flat — would serve the
|
|
@@ -7214,7 +7352,8 @@ def _build_projects_envelope(
|
|
|
7214
7352
|
cw_key: "dt.datetime | None" = None
|
|
7215
7353
|
if current_week is not None:
|
|
7216
7354
|
cw_key = getattr(current_week, "week_start_at", None)
|
|
7217
|
-
memo_key = (max_id, max_wus_id, entry_mutation_seq, cw_key, weeks_back
|
|
7355
|
+
memo_key = (max_id, max_wus_id, entry_mutation_seq, cw_key, weeks_back,
|
|
7356
|
+
content_revision_leg)
|
|
7218
7357
|
cached = _PROJECTS_ENV_MEMO.get("value")
|
|
7219
7358
|
if cached is not None and _PROJECTS_ENV_MEMO.get("key") == memo_key:
|
|
7220
7359
|
return cached
|
|
@@ -11544,12 +11683,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11544
11683
|
assembly runs inside one transaction per DB. 400 malformed key/source;
|
|
11545
11684
|
404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
|
|
11546
11685
|
|
|
11547
|
-
``?account=<account_key|unattributed>`` (#416
|
|
11548
|
-
cycle on one account: its own crossings, its own spend.
|
|
11549
|
-
shipped merged response and is byte-identical — the
|
|
11550
|
-
qualifier only under account focus
|
|
11686
|
+
``?account=<account_key|unattributed>`` (#416 Codex; #836 Claude)
|
|
11687
|
+
focuses the cycle on one account: its own crossings, its own spend.
|
|
11688
|
+
ABSENT is the shipped merged response and is byte-identical — the
|
|
11689
|
+
client sends the qualifier only under account focus (Codex) or for the
|
|
11690
|
+
displayed account (Claude), exactly as `/api/source/` does. The
|
|
11551
11691
|
vendor-wide ``*`` sentinel is rejected here (unlike `/api/source/`,
|
|
11552
11692
|
where it labels an alert row): it names no cycle to render.
|
|
11693
|
+
|
|
11694
|
+
Claude (#836 spec 5.6) accepts the qualifier only while Claude is
|
|
11695
|
+
decorated (more than one REAL account), checked in the same read
|
|
11696
|
+
transaction as the assembly: 400 on an undecorated store, 404
|
|
11697
|
+
``unknown_key`` for an unknown or non-Claude account or a key outside
|
|
11698
|
+
that account's scoped index, and never a retry against the merged
|
|
11699
|
+
index.
|
|
11553
11700
|
"""
|
|
11554
11701
|
import re as _re
|
|
11555
11702
|
import urllib.parse as _urlparse
|
|
@@ -11576,11 +11723,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11576
11723
|
):
|
|
11577
11724
|
self._send_milestones_json(400, {"error": "invalid account"})
|
|
11578
11725
|
return
|
|
11579
|
-
if source != "codex":
|
|
11580
|
-
# Claude weeks are not account-partitioned on this route yet;
|
|
11581
|
-
# accepting the qualifier silently would be a privacy lie.
|
|
11582
|
-
self._send_milestones_json(400, {"error": "invalid account"})
|
|
11583
|
-
return
|
|
11584
11726
|
account_key = raw_account[0]
|
|
11585
11727
|
c = sys.modules["cctally"]
|
|
11586
11728
|
try:
|
|
@@ -11592,12 +11734,38 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11592
11734
|
self._send_milestones_json(400, {"error": "invalid cycle key"})
|
|
11593
11735
|
return
|
|
11594
11736
|
conn = open_db()
|
|
11737
|
+
rejected = False
|
|
11738
|
+
detail = None
|
|
11595
11739
|
try:
|
|
11596
11740
|
conn.execute("BEGIN")
|
|
11597
|
-
|
|
11741
|
+
if account_key is None:
|
|
11742
|
+
detail = c.build_claude_week_detail(conn, key)
|
|
11743
|
+
else:
|
|
11744
|
+
# #836 spec 5.6. Only a DECORATED store partitions
|
|
11745
|
+
# Claude history by account. On an undecorated one the
|
|
11746
|
+
# qualifier is refused, never ignored: answering it with
|
|
11747
|
+
# the merged week would claim an account scope the
|
|
11748
|
+
# payload does not have, which is the privacy lie the
|
|
11749
|
+
# earlier blanket rejection guarded against. Checked
|
|
11750
|
+
# inside this transaction so the gate and the assembly
|
|
11751
|
+
# read one state.
|
|
11752
|
+
import _cctally_account
|
|
11753
|
+
if not _cctally_account.provider_is_decorated(
|
|
11754
|
+
conn, "claude"):
|
|
11755
|
+
rejected = True
|
|
11756
|
+
elif account_key == "unattributed" or conn.execute(
|
|
11757
|
+
"SELECT 1 FROM accounts WHERE account_key = ? "
|
|
11758
|
+
"AND provider = 'claude'",
|
|
11759
|
+
(account_key,)).fetchone() is not None:
|
|
11760
|
+
detail = c.build_claude_week_detail(
|
|
11761
|
+
conn, key, account_key=account_key,
|
|
11762
|
+
now_utc=_command_as_of())
|
|
11598
11763
|
conn.commit()
|
|
11599
11764
|
finally:
|
|
11600
11765
|
conn.close()
|
|
11766
|
+
if rejected:
|
|
11767
|
+
self._send_milestones_json(400, {"error": "invalid account"})
|
|
11768
|
+
return
|
|
11601
11769
|
if detail is None:
|
|
11602
11770
|
self._send_milestones_json(404, {
|
|
11603
11771
|
"error": "unknown week", "code": "unknown_key",
|
|
@@ -12197,7 +12365,6 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
12197
12365
|
c = _cctally()
|
|
12198
12366
|
tui = c._cctally_tui
|
|
12199
12367
|
now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
|
|
12200
|
-
hydrating = not getattr(args, "no_sync", False)
|
|
12201
12368
|
errors = [f"stats-open: {exc}"]
|
|
12202
12369
|
envelope_precompute = None
|
|
12203
12370
|
try:
|
|
@@ -12220,19 +12387,16 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
12220
12387
|
# a fresh success. There is no earlier success to preserve either.
|
|
12221
12388
|
"last_sync_at": None,
|
|
12222
12389
|
"last_sync_error": "; ".join(errors),
|
|
12223
|
-
"sync_failures": (
|
|
12224
|
-
tui.SyncFailureAttribution(
|
|
12225
|
-
leg="stats-open",
|
|
12226
|
-
database="stats",
|
|
12227
|
-
corruption=tui._stats_open_failure_is_corruption(exc),
|
|
12228
|
-
),
|
|
12229
|
-
),
|
|
12390
|
+
"sync_failures": (tui._tui_deferral_attribution(exc),),
|
|
12230
12391
|
"doctor_payload": doctor_payload,
|
|
12231
12392
|
"envelope_precompute": envelope_precompute,
|
|
12232
|
-
|
|
12393
|
+
# #932 §4.2: hydrating in BOTH modes. A frozen dashboard's settle loop
|
|
12394
|
+
# (`_frozen_deferral_settle_loop`) replaces this frame once recovery
|
|
12395
|
+
# permits a build, so it must read as loading, never as "restart the
|
|
12396
|
+
# dashboard".
|
|
12397
|
+
"hydrating": True,
|
|
12398
|
+
"source_bundle": tui._tui_hydrating_source_bundle(),
|
|
12233
12399
|
}
|
|
12234
|
-
if hydrating:
|
|
12235
|
-
replacements["source_bundle"] = tui._tui_hydrating_source_bundle()
|
|
12236
12400
|
return dataclasses.replace(tui._tui_empty_snapshot(now_utc), **replacements)
|
|
12237
12401
|
|
|
12238
12402
|
|
|
@@ -12322,8 +12486,11 @@ def _dashboard_initial_snapshot_once(
|
|
|
12322
12486
|
)
|
|
12323
12487
|
if conn is not None:
|
|
12324
12488
|
try:
|
|
12489
|
+
reading_errors: list[Exception] = []
|
|
12325
12490
|
try:
|
|
12326
|
-
cw = tui._tui_build_current_week(
|
|
12491
|
+
cw = tui._tui_build_current_week(
|
|
12492
|
+
conn, now_utc, skip_sync=True,
|
|
12493
|
+
reading_errors=reading_errors)
|
|
12327
12494
|
except Exception as exc: # noqa: BLE001 — never block the bind
|
|
12328
12495
|
tui._tui_capture_sync_failure(
|
|
12329
12496
|
conn,
|
|
@@ -12334,6 +12501,18 @@ def _dashboard_initial_snapshot_once(
|
|
|
12334
12501
|
exc=exc,
|
|
12335
12502
|
stats_heal_attempted=stats_heal_attempted,
|
|
12336
12503
|
)
|
|
12504
|
+
# #1018 twin review F1: a failed confirming-reading lookup keeps
|
|
12505
|
+
# the card and is recorded like its other reads.
|
|
12506
|
+
for exc in reading_errors:
|
|
12507
|
+
tui._tui_capture_sync_failure(
|
|
12508
|
+
conn,
|
|
12509
|
+
errors,
|
|
12510
|
+
sync_failures,
|
|
12511
|
+
leg="current-week-reading",
|
|
12512
|
+
database="stats_or_cache",
|
|
12513
|
+
exc=exc,
|
|
12514
|
+
stats_heal_attempted=stats_heal_attempted,
|
|
12515
|
+
)
|
|
12337
12516
|
try:
|
|
12338
12517
|
fc_view = tui._tui_build_forecast_view(
|
|
12339
12518
|
conn, now_utc, skip_sync=True
|
|
@@ -12781,6 +12960,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12781
12960
|
update_check_stop = threading.Event()
|
|
12782
12961
|
update_check_thread = _DashboardUpdateCheckThread(
|
|
12783
12962
|
update_check_stop, hub=hub, snapshot_ref=ref, runtime_bind=args.host,
|
|
12963
|
+
# #932 §4.2: serialize its republish with every snapshot publisher.
|
|
12964
|
+
sync_lock=sync_lock,
|
|
12784
12965
|
)
|
|
12785
12966
|
update_check_thread.start()
|
|
12786
12967
|
|
|
@@ -12880,6 +13061,23 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12880
13061
|
file=sys.stderr,
|
|
12881
13062
|
)
|
|
12882
13063
|
|
|
13064
|
+
# #932: a frozen dashboard whose first build DEFERRED (an older-epoch
|
|
13065
|
+
# index or a detached heal) has no sync thread to replace that frame, so a
|
|
13066
|
+
# one-shot settle loop retries the no-ingest build until recovery permits
|
|
13067
|
+
# it. Started after the server objects exist and immediately before the
|
|
13068
|
+
# shutdown-managed region below.
|
|
13069
|
+
frozen_settle_stop = threading.Event()
|
|
13070
|
+
frozen_settle_thread = None
|
|
13071
|
+
if args.no_sync and _cctally()._cctally_tui._tui_snapshot_is_stats_deferral(
|
|
13072
|
+
initial):
|
|
13073
|
+
frozen_settle_thread = _make_frozen_deferral_settle_thread(
|
|
13074
|
+
stop=frozen_settle_stop,
|
|
13075
|
+
interval=float(args.sync_interval),
|
|
13076
|
+
ref=ref, hub=hub, sync_lock=sync_lock,
|
|
13077
|
+
run_sync_now_locked=_run_sync_now_locked,
|
|
13078
|
+
)
|
|
13079
|
+
frozen_settle_thread.start()
|
|
13080
|
+
|
|
12883
13081
|
# Block the main thread until SIGINT/SIGTERM. The wait is driven by a
|
|
12884
13082
|
# self-pipe wakeup fd (signal.set_wakeup_fd) rather than a
|
|
12885
13083
|
# threading.Event, so a single signal unblocks it unconditionally — the
|
|
@@ -12900,6 +13098,12 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12900
13098
|
_lib_write_io.clear_local_instance()
|
|
12901
13099
|
if sync_thread is not None:
|
|
12902
13100
|
sync_thread.stop()
|
|
13101
|
+
# #932: fence the settle thread through `sync_lock` BEFORE the memory
|
|
13102
|
+
# verifiers, their completion locks, the server and the hub retire.
|
|
13103
|
+
if frozen_settle_thread is not None:
|
|
13104
|
+
_retire_frozen_deferral_settle(
|
|
13105
|
+
stop=frozen_settle_stop, sync_lock=sync_lock,
|
|
13106
|
+
thread=frozen_settle_thread)
|
|
12903
13107
|
conversation_sync_stop.set()
|
|
12904
13108
|
if conversation_sync_thread is not None:
|
|
12905
13109
|
conversation_sync_thread.join(timeout=2)
|
|
@@ -639,7 +639,11 @@ def _codex_observation_hold_only(stats, *, executor_owned=False):
|
|
|
639
639
|
return False
|
|
640
640
|
if getattr(stats, "barrier_retry", False) and not executor_owned:
|
|
641
641
|
return False
|
|
642
|
-
|
|
642
|
+
# #1052: every skip the pass saw, its unit phase's included, when the
|
|
643
|
+
# stats object splits them; otherwise its single census dict.
|
|
644
|
+
every_skip = getattr(stats, "all_container_skipped", None)
|
|
645
|
+
skipped = (every_skip() if callable(every_skip)
|
|
646
|
+
else getattr(stats, "container_skipped", {}))
|
|
643
647
|
return (bool(getattr(stats, "container_pending", 0) or any(skipped.values()))
|
|
644
648
|
and set(skipped) <= {
|
|
645
649
|
"decoder-unavailable", "decode-error", "read-error",
|
|
@@ -1274,6 +1274,25 @@ def _sync_failure_envelope(
|
|
|
1274
1274
|
"action": "cctally db repair --db stats --yes",
|
|
1275
1275
|
}
|
|
1276
1276
|
|
|
1277
|
+
# #932 §4.1: an epoch deferral whose admission FAILED has no rebuild
|
|
1278
|
+
# running (no marker, no worker), so "rebuilding" would be false. It is the
|
|
1279
|
+
# existing server-sync error until a later admission succeeds.
|
|
1280
|
+
def field(item, key, default=None):
|
|
1281
|
+
return (item.get(key, default) if isinstance(item, dict)
|
|
1282
|
+
else getattr(item, key, default))
|
|
1283
|
+
|
|
1284
|
+
for item in attributions or ():
|
|
1285
|
+
if (field(item, "database") == "stats"
|
|
1286
|
+
and not bool(field(item, "corruption", False))
|
|
1287
|
+
and bool(field(item, "deferred", False))
|
|
1288
|
+
and field(item, "deferral_outcome") == "failed"):
|
|
1289
|
+
return {
|
|
1290
|
+
"kind": "server_sync",
|
|
1291
|
+
"label": "⚠ server sync error",
|
|
1292
|
+
"detail": "The server could not complete its background sync.",
|
|
1293
|
+
"action": None,
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1277
1296
|
if attributed("stats"):
|
|
1278
1297
|
return {
|
|
1279
1298
|
"kind": "maintenance_active",
|
|
@@ -1676,6 +1695,29 @@ def _header_rate_and_delta(current_rate, current_week_start, trend):
|
|
|
1676
1695
|
return current_rate, None
|
|
1677
1696
|
|
|
1678
1697
|
|
|
1698
|
+
def current_week_freshness(captured, *, now_utc: "dt.datetime",
|
|
1699
|
+
oauth_usage_cfg: "dict | None" = None
|
|
1700
|
+
) -> "dict | None":
|
|
1701
|
+
"""`current_week.freshness` (``{label, captured_at, age_seconds}``) of a
|
|
1702
|
+
confirming-reading instant, or None when there is none. The one
|
|
1703
|
+
derivation the envelope and the source payload's idle refresh share
|
|
1704
|
+
(#1018 twin spec 5.4/5.5)."""
|
|
1705
|
+
if not isinstance(captured, dt.datetime):
|
|
1706
|
+
return None
|
|
1707
|
+
if captured.tzinfo is None:
|
|
1708
|
+
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
1709
|
+
age_s = max(0.0, (now_utc - captured).total_seconds())
|
|
1710
|
+
_fresh_cfg = (
|
|
1711
|
+
oauth_usage_cfg if oauth_usage_cfg is not None
|
|
1712
|
+
else sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS
|
|
1713
|
+
)
|
|
1714
|
+
return {
|
|
1715
|
+
"label": sys.modules["cctally"]._freshness_label(age_s, _fresh_cfg),
|
|
1716
|
+
"captured_at": _iso_z(captured),
|
|
1717
|
+
"age_seconds": int(age_s),
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
|
|
1679
1721
|
def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
1680
1722
|
now_utc: "dt.datetime",
|
|
1681
1723
|
monotonic_now: "float | None" = None,
|
|
@@ -1850,25 +1892,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1850
1892
|
elif tp == 90:
|
|
1851
1893
|
budget_90 = dpd
|
|
1852
1894
|
|
|
1853
|
-
# Freshness envelope for current_week — derived from
|
|
1854
|
-
# cw.
|
|
1855
|
-
# cw is absent or has no
|
|
1856
|
-
#
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
1862
|
-
age_s = max(0.0, (now_utc - captured).total_seconds())
|
|
1863
|
-
_fresh_cfg = (
|
|
1864
|
-
oauth_usage_cfg if oauth_usage_cfg is not None
|
|
1865
|
-
else sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS
|
|
1866
|
-
)
|
|
1867
|
-
cw_freshness = {
|
|
1868
|
-
"label": sys.modules["cctally"]._freshness_label(age_s, _fresh_cfg),
|
|
1869
|
-
"captured_at": _iso_z(captured),
|
|
1870
|
-
"age_seconds": int(age_s),
|
|
1871
|
-
}
|
|
1895
|
+
# Freshness envelope for current_week (spec §3.4) — derived from
|
|
1896
|
+
# cw.latest_weekly_reading_at, the newest genuine reading confirming the
|
|
1897
|
+
# displayed value (#1018 twin spec 5.4). None when cw is absent or has no
|
|
1898
|
+
# resolvable confirmation; the React client renders a placeholder then.
|
|
1899
|
+
# `last_snapshot_age_sec` below keeps the stored row's capture.
|
|
1900
|
+
cw_freshness = current_week_freshness(
|
|
1901
|
+
None if cw is None else getattr(cw, "latest_weekly_reading_at", None),
|
|
1902
|
+
now_utc=now_utc, oauth_usage_cfg=oauth_usage_cfg)
|
|
1872
1903
|
|
|
1873
1904
|
week_lbl: "str | None" = None
|
|
1874
1905
|
reset_at_utc: "dt.datetime | None" = None
|
|
@@ -2557,6 +2588,16 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2557
2588
|
# PREVIEW badge when set.
|
|
2558
2589
|
**sys.modules["_cctally_dashboard"]._channel_env_fragment(),
|
|
2559
2590
|
}
|
|
2591
|
+
# #836 (spec 5.1): the displayed account, decorated builds ONLY. Below the
|
|
2592
|
+
# R8 threshold both keys are omitted (never null), so an undecorated
|
|
2593
|
+
# `current_week` keeps its bytes. Appended after the literal so the key
|
|
2594
|
+
# order of every existing field is unchanged. This identity — never
|
|
2595
|
+
# `five_hour_block.account_key`, absent with no live block — is what the
|
|
2596
|
+
# client labels and scopes its history navigation by.
|
|
2597
|
+
if (cw is not None and getattr(cw, "account_decorated", False)
|
|
2598
|
+
and isinstance(envelope.get("current_week"), dict)):
|
|
2599
|
+
envelope["current_week"]["account_key"] = cw.account_key
|
|
2600
|
+
envelope["current_week"]["account_label"] = cw.account_label
|
|
2560
2601
|
envelope.update(_source_bundle_to_envelope(getattr(snap, "source_bundle", None)))
|
|
2561
2602
|
_overlay_claude_source_session_titles(envelope, snap, transcripts_visible)
|
|
2562
2603
|
_overlay_codex_source_session_labels(envelope, snap, transcripts_visible)
|
|
@@ -1490,6 +1490,7 @@ def resolve_dashboard_source_semantics(
|
|
|
1490
1490
|
config: Mapping[str, object] | None,
|
|
1491
1491
|
*,
|
|
1492
1492
|
display_tz_name: str | None,
|
|
1493
|
+
speed: str | None = None,
|
|
1493
1494
|
) -> DashboardSourceSemantics:
|
|
1494
1495
|
"""Resolve dashboard semantics through the shipped CLI kernels.
|
|
1495
1496
|
|
|
@@ -1497,12 +1498,17 @@ def resolve_dashboard_source_semantics(
|
|
|
1497
1498
|
it preserves the CLI's all-$CODEX_HOME fast-service-tier behavior. The
|
|
1498
1499
|
weekly index comes from the same ``get_week_start_name``/``WEEKDAY_MAP``
|
|
1499
1500
|
pair used by the report and budget command surfaces.
|
|
1501
|
+
|
|
1502
|
+
``speed`` (#1050 spec 4.7) is a tier that resolver already returned this
|
|
1503
|
+
tick — the dashboard captures it beside its dispatch read and keys the idle
|
|
1504
|
+
gate on it — so the build uses that one value; ``None`` resolves here.
|
|
1500
1505
|
"""
|
|
1501
1506
|
c = sys.modules["cctally"]
|
|
1502
1507
|
raw_config = dict(config or {})
|
|
1503
1508
|
week_start_name = get_week_start_name(raw_config)
|
|
1504
1509
|
week_start_idx = c.WEEKDAY_MAP[week_start_name]
|
|
1505
|
-
speed
|
|
1510
|
+
if speed is None:
|
|
1511
|
+
speed = c._resolve_codex_speed("auto")
|
|
1506
1512
|
budget_config = c._get_budget_config(raw_config)
|
|
1507
1513
|
quota_alerts = c._get_quota_alerts_config(raw_config)
|
|
1508
1514
|
raw_cache_report = raw_config.get("cache_report")
|
|
@@ -5929,6 +5935,20 @@ def reset_codex_account_scope_cache(*, keep_counters: bool = False) -> None:
|
|
|
5929
5935
|
_CODEX_DERIVED_COHERENCE.clear()
|
|
5930
5936
|
|
|
5931
5937
|
|
|
5938
|
+
def _codex_cycle_index_cost_inputs(context: "DashboardReadContext") -> dict:
|
|
5939
|
+
"""The cost inputs every Codex cycle stamp names (#1050 spec 4.7): this
|
|
5940
|
+
read's cache handle and resolved speed, and — when the build computed it
|
|
5941
|
+
for its own signature — the `codex_stats_digest` that admits the index's
|
|
5942
|
+
process memo."""
|
|
5943
|
+
stats_identity = context.stats_identity
|
|
5944
|
+
return {
|
|
5945
|
+
"cache_conn": context.cache_conn,
|
|
5946
|
+
"speed": context.speed,
|
|
5947
|
+
"stats_digest": (
|
|
5948
|
+
stats_identity[0] if stats_identity is not None else None),
|
|
5949
|
+
}
|
|
5950
|
+
|
|
5951
|
+
|
|
5932
5952
|
def _codex_account_scopes_wire(
|
|
5933
5953
|
context: DashboardReadContext,
|
|
5934
5954
|
*,
|
|
@@ -6060,6 +6080,7 @@ def _codex_account_scopes_wire(
|
|
|
6060
6080
|
sys.modules["cctally"].build_codex_cycle_index(
|
|
6061
6081
|
context.stats_conn, identity=cycle,
|
|
6062
6082
|
now_utc=context.now_utc, account_key=key,
|
|
6083
|
+
**_codex_cycle_index_cost_inputs(context),
|
|
6063
6084
|
)
|
|
6064
6085
|
)
|
|
6065
6086
|
except sqlite3.Error:
|
|
@@ -8704,6 +8725,7 @@ def _build_codex_source_state(
|
|
|
8704
8725
|
cycle_index = tuple(
|
|
8705
8726
|
sys.modules["cctally"].build_codex_cycle_index(
|
|
8706
8727
|
context.stats_conn, identity=cycle, now_utc=context.now_utc,
|
|
8728
|
+
**_codex_cycle_index_cost_inputs(context),
|
|
8707
8729
|
)
|
|
8708
8730
|
)
|
|
8709
8731
|
except sqlite3.Error:
|