cctally 1.110.0 → 1.112.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 +23 -0
- package/README.md +5 -6
- package/bin/_cctally_cache.py +14 -7
- package/bin/_cctally_core.py +12 -1
- package/bin/_cctally_dashboard.py +60 -154
- package/bin/_cctally_dashboard_envelope.py +7 -6
- package/bin/_cctally_dashboard_sources.py +90 -55
- package/bin/_cctally_doctor.py +73 -27
- package/bin/_cctally_forecast.py +2 -1
- package/bin/_cctally_journal.py +147 -6
- package/bin/_cctally_milestones.py +29 -3
- package/bin/_cctally_quota.py +10 -1
- package/bin/_cctally_record.py +136 -4
- package/bin/_cctally_rederive.py +311 -8
- package/bin/_cctally_source_analytics.py +20 -8
- package/bin/_cctally_tui.py +546 -45
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_codex_conversation_query.py +40 -4
- package/bin/_lib_codex_metadata.py +57 -11
- package/bin/_lib_cost_provenance.py +303 -0
- package/bin/_lib_dashboard_sources.py +263 -28
- package/bin/_lib_doctor.py +2 -2
- package/bin/_lib_ingest_frontier.py +137 -38
- package/bin/_lib_pricing.py +24 -263
- package/bin/_lib_rederive.py +704 -36
- package/bin/_lib_snapshot_cache.py +332 -9
- package/bin/cctally +18 -5
- package/dashboard/static/assets/{ConversationsView-BB9vUiL6.js → ConversationsView-CEZ-BUyi.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFY2iPDf.js → DoctorModal-CE1oH_Zy.js} +1 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DAv39uSo.js → ProjectsDrillPanel-B3DZoCeD.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-DSAWo2Fp.js → SourceDetailModal-CHp9EgLP.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-R_9dOVnh.js → UpdateModal-5iKST6jo.js} +1 -1
- package/dashboard/static/assets/{index-DdU62sov.js → index-Cm0VTxJC.js} +2 -2
- package/dashboard/static/assets/index-Dt4tqf43.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-BOCLcRa6.js → outlineNavigation-DkXGL8J6.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +0 -1
- package/dashboard/static/assets/index-COhxe3vU.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.112.0] - 2026-09-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Claude cost reports price `claude-sonnet-5-5` at its published input, output and cache rates instead of showing $0 for new usage.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `cctally dashboard` uses less CPU while idle on a large history.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- The Conversation Viewer keeps project-filtered browse working with any number of unreadable Codex project paths, avoids repeated metadata schema probes, and preserves SQLite alias ordering.
|
|
18
|
+
- The TUI and dashboard clear a Codex metadata retry as soon as its transient partial generation is rejected, so the first healthy refresh recovers immediately instead of waiting through a stale retry delay.
|
|
19
|
+
- `cctally db rederive` corrects costs recorded while a model had no rate card, including provably unpriced $0 five-hour blocks. A Codex budget alert that fallback pricing overstated is retired and can fire again when real spend crosses.
|
|
20
|
+
- After `cctally db rederive --yes`, a still-crossed Claude budget, projected or project-budget alert no longer breaks every later stats ingest until the period ends, and fired five-hour alerts stay in Recent Alerts.
|
|
21
|
+
- On phones, the dashboard's `Current Usage` dialog shows its full title and hides the onboarding hint while the dialog is open.
|
|
22
|
+
- An idle `cctally dashboard` no longer rebuilds its Codex view on every refresh after a Codex weekly reset with no newer Codex activity, which cost steady CPU while nothing changed.
|
|
23
|
+
- `cctally db rederive` no longer reprices the unchanged five-hour milestones of a closed block whose cost is frozen when a rate card changes: each keeps its recorded cumulative cost; its marginal changes only to follow the prior milestone.
|
|
24
|
+
|
|
25
|
+
## [1.111.0] - 2026-09-24
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- After `cctally update` or a Homebrew upgrade installs a newer version, a running `dashboard` restarts itself so new prices and code apply. Restart it yourself once after installing this release, and after any manual `npm install -g`.
|
|
29
|
+
- A `dashboard` restart after an update no longer leaves an extra Node process running on npm installs.
|
|
30
|
+
|
|
8
31
|
## [1.110.0] - 2026-09-22
|
|
9
32
|
|
|
10
33
|
### Added
|
package/README.md
CHANGED
|
@@ -30,15 +30,14 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
|
|
|
30
30
|
</p>
|
|
31
31
|
|
|
32
32
|
<!-- cctally:latest-stable:begin -->
|
|
33
|
-
**Latest stable: v1.
|
|
33
|
+
**Latest stable: v1.111.0** (2026-09-24)
|
|
34
34
|
|
|
35
|
-
Highlights from the `v1.
|
|
35
|
+
Highlights from the `v1.109.0` to `v1.111.0` stable upgrade:
|
|
36
36
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
- The dashboard's Projects panel gives every billing cycle of a credited week its own percentage. Both cycles shared one calendar date, and only the later reading survived, so the second cycle could never carry one.
|
|
37
|
+
- After `cctally update` or a Homebrew upgrade installs a newer version, a running `dashboard` restarts itself so new prices and code apply. Restart it yourself once after installing this release, and after any manual `npm install -g`.
|
|
38
|
+
- A `dashboard` restart after an update no longer leaves an extra Node process running on npm installs.
|
|
40
39
|
|
|
41
|
-
[See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.
|
|
40
|
+
[See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.111.0)
|
|
42
41
|
<!-- cctally:latest-stable:end -->
|
|
43
42
|
|
|
44
43
|
## Quick start
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -9478,6 +9478,7 @@ def _sum_codex_cost_for_range(
|
|
|
9478
9478
|
speed: str = "auto",
|
|
9479
9479
|
skip_sync: bool = False,
|
|
9480
9480
|
account_key: "str | None" = None,
|
|
9481
|
+
pricing_provenance: "dict | None" = None,
|
|
9481
9482
|
) -> float:
|
|
9482
9483
|
"""Sum USD Codex cost of all `codex_session_entries` in ``[start, end)``.
|
|
9483
9484
|
|
|
@@ -9511,17 +9512,23 @@ def _sum_codex_cost_for_range(
|
|
|
9511
9512
|
"""
|
|
9512
9513
|
c = _cctally()
|
|
9513
9514
|
eff_speed = c._resolve_codex_speed(speed)
|
|
9515
|
+
entries = c.get_codex_entries(
|
|
9516
|
+
start, end, skip_sync=skip_sync, account_key=account_key,
|
|
9517
|
+
)
|
|
9518
|
+
if pricing_provenance is not None:
|
|
9519
|
+
import _lib_cost_provenance
|
|
9520
|
+
total, evidence = _lib_cost_provenance.codex_cost_with_provenance(
|
|
9521
|
+
entries, end=end, speed=eff_speed,
|
|
9522
|
+
)
|
|
9523
|
+
pricing_provenance.update(evidence)
|
|
9524
|
+
return total
|
|
9514
9525
|
total = 0.0
|
|
9515
|
-
for entry in
|
|
9516
|
-
account_key=account_key):
|
|
9526
|
+
for entry in entries:
|
|
9517
9527
|
if entry.timestamp >= end:
|
|
9518
9528
|
continue
|
|
9519
9529
|
total += c._calculate_codex_entry_cost(
|
|
9520
|
-
entry.model,
|
|
9521
|
-
entry.
|
|
9522
|
-
entry.cached_input_tokens,
|
|
9523
|
-
entry.output_tokens,
|
|
9524
|
-
entry.reasoning_output_tokens,
|
|
9530
|
+
entry.model, entry.input_tokens, entry.cached_input_tokens,
|
|
9531
|
+
entry.output_tokens, entry.reasoning_output_tokens,
|
|
9525
9532
|
speed=eff_speed,
|
|
9526
9533
|
)
|
|
9527
9534
|
return total
|
package/bin/_cctally_core.py
CHANGED
|
@@ -80,6 +80,7 @@ def _init_paths_from_env() -> None:
|
|
|
80
80
|
global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
|
|
81
81
|
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
82
82
|
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
83
|
+
global INSTALL_SUCCESS_RECORD_PATH
|
|
83
84
|
global CLAUDE_PROJECTS_DIR, CLAUDE_JSON_PATH
|
|
84
85
|
global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
|
|
85
86
|
global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
|
|
@@ -195,6 +196,8 @@ def _init_paths_from_env() -> None:
|
|
|
195
196
|
UPDATE_LOG_PATH = APP_DIR / "update.log"
|
|
196
197
|
UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
|
|
197
198
|
UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
|
|
199
|
+
# Written only by the install paths (#868), never by an update check.
|
|
200
|
+
INSTALL_SUCCESS_RECORD_PATH = APP_DIR / "install-success.json"
|
|
198
201
|
|
|
199
202
|
# Anonymous install-count telemetry markers (see spec 2026-07-07).
|
|
200
203
|
# All four derive from APP_DIR and are re-bound here so a redirected
|
|
@@ -465,7 +468,12 @@ _init_paths_from_env()
|
|
|
465
468
|
# a migration is unchanged: the registry is frozen at 13, and an epoch-current
|
|
466
469
|
# open returns before any schema work, so a handler or an
|
|
467
470
|
# `add_column_if_missing` would never run on an upgraded install.
|
|
468
|
-
|
|
471
|
+
# 1015 -> 1016 (#869): an open five-hour block records the pricing evidence
|
|
472
|
+
# used to compute its current cost. Closing a different block on a successor
|
|
473
|
+
# tick must carry that earlier evidence into the journal, even after this
|
|
474
|
+
# binary learns a new model card. A pre-existing open projection that was
|
|
475
|
+
# rebuilt without the marker remains unproven until a new tick recomputes it.
|
|
476
|
+
STATS_INDEX_EPOCH = 1016
|
|
469
477
|
LEGACY_STATS_HEAD = 13
|
|
470
478
|
|
|
471
479
|
#: #496 S1 F1. A NEW branch, for a state that cannot occur before the
|
|
@@ -2503,6 +2511,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2503
2511
|
total_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2504
2512
|
total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2505
2513
|
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
2514
|
+
pricing_provenance_json TEXT,
|
|
2506
2515
|
is_closed INTEGER NOT NULL DEFAULT 0,
|
|
2507
2516
|
created_at_utc TEXT NOT NULL,
|
|
2508
2517
|
last_updated_at_utc TEXT NOT NULL,
|
|
@@ -2514,6 +2523,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2514
2523
|
add_column_if_missing(
|
|
2515
2524
|
conn, "five_hour_blocks", "account_key",
|
|
2516
2525
|
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
2526
|
+
add_column_if_missing(
|
|
2527
|
+
conn, "five_hour_blocks", "pricing_provenance_json", "TEXT")
|
|
2517
2528
|
conn.execute(
|
|
2518
2529
|
"""
|
|
2519
2530
|
CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
|
|
@@ -712,10 +712,6 @@ from _lib_aggregators import (
|
|
|
712
712
|
_finalize_bucket,
|
|
713
713
|
)
|
|
714
714
|
from _lib_fmt import stable_sum
|
|
715
|
-
# The MODULE, not just the names: #714's `pricing_snapshot_context` has to be
|
|
716
|
-
# reached through the live module object so a test that swaps the snapshot is
|
|
717
|
-
# seen by the request, build and sync entries below.
|
|
718
|
-
import _lib_pricing
|
|
719
715
|
from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
|
|
720
716
|
_short_model_name, claude_usage_dict)
|
|
721
717
|
from _lib_five_hour import _canonical_5h_window_key, _round_to_ten_minutes
|
|
@@ -2272,13 +2268,6 @@ def _make_dashboard_run_iteration(
|
|
|
2272
2268
|
|
|
2273
2269
|
def run_iteration(batch=None) -> dict:
|
|
2274
2270
|
warnings: list = []
|
|
2275
|
-
# #714: the whole snapshot build runs on ONE pricing revision. A swap
|
|
2276
|
-
# landing part-way through would publish a snapshot whose panels were
|
|
2277
|
-
# priced from two different tables.
|
|
2278
|
-
with _lib_pricing.pricing_snapshot_context():
|
|
2279
|
-
return _run_iteration_body(batch, warnings)
|
|
2280
|
-
|
|
2281
|
-
def _run_iteration_body(batch, warnings: list) -> dict:
|
|
2282
2271
|
if batch is not None and batch[1] and not skip_sync:
|
|
2283
2272
|
with sync_lock:
|
|
2284
2273
|
result = _refresh_usage_inproc()
|
|
@@ -2556,103 +2545,38 @@ def _wake_schema_writer() -> None:
|
|
|
2556
2545
|
_SCHEMA_WAKE_THREAD = None
|
|
2557
2546
|
|
|
2558
2547
|
|
|
2559
|
-
|
|
2560
|
-
#: cadence, because a wall-clock one would re-poll or stall across a system
|
|
2561
|
-
#: clock change.
|
|
2562
|
-
_PRICING_WATCH_INTERVAL_S = 60.0
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
class _PricingWatch:
|
|
2566
|
-
"""Detect a replaced `_lib_pricing.py` and adopt it in process (#714).
|
|
2548
|
+
_INSTALL_WATCH = None
|
|
2567
2549
|
|
|
2568
|
-
An installer overwrites the deployed file under a running dashboard.
|
|
2569
|
-
Nothing about that reaches this process: every CLI invocation re-imports
|
|
2570
|
-
and picks the new tables up, and only a long-lived process holds the old
|
|
2571
|
-
ones.
|
|
2572
2550
|
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
minute rather than an `ast.parse` of a 1,400-line file.
|
|
2551
|
+
def _start_install_watch(boot):
|
|
2552
|
+
"""Own the install watch on its own lifecycle thread, in every mode (#868).
|
|
2576
2553
|
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
rather than an exceptional one; a raising poll would end the thread and
|
|
2580
|
-
leave the process permanently stale, which is the very condition this
|
|
2581
|
-
exists to clear.
|
|
2554
|
+
Not started from a git checkout, where file changes are development work,
|
|
2555
|
+
not installs.
|
|
2582
2556
|
"""
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
self.interval_s = (_PRICING_WATCH_INTERVAL_S if interval_s is None
|
|
2589
|
-
else interval_s)
|
|
2590
|
-
self._signature = None
|
|
2591
|
-
self._stop = threading.Event()
|
|
2592
|
-
|
|
2593
|
-
def _current_signature(self):
|
|
2594
|
-
try:
|
|
2595
|
-
st = self.source_path.stat()
|
|
2596
|
-
except OSError:
|
|
2597
|
-
return None
|
|
2598
|
-
return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
|
|
2599
|
-
|
|
2600
|
-
def poll_once(self) -> bool:
|
|
2601
|
-
"""One check. Returns True iff a new snapshot was adopted."""
|
|
2602
|
-
try:
|
|
2603
|
-
signature = self._current_signature()
|
|
2604
|
-
if signature is None or signature == self._signature:
|
|
2605
|
-
return False
|
|
2606
|
-
self._signature = signature
|
|
2607
|
-
pricing = sys.modules["_lib_pricing"]
|
|
2608
|
-
candidate = pricing.read_pricing_snapshot_from_source(
|
|
2609
|
-
self.source_path)
|
|
2610
|
-
if not pricing.adopt_pricing_candidate(candidate):
|
|
2611
|
-
return False
|
|
2612
|
-
eprint("[pricing] adopted pricing revision "
|
|
2613
|
-
f"{candidate.snapshot_date} without restarting")
|
|
2614
|
-
return True
|
|
2615
|
-
except Exception: # noqa: BLE001
|
|
2616
|
-
# Including `PricingCandidateRejected`: a rejected candidate is a
|
|
2617
|
-
# normal outcome, and the signature is already recorded so the
|
|
2618
|
-
# same bad file is not re-parsed every minute.
|
|
2619
|
-
return False
|
|
2620
|
-
|
|
2621
|
-
def run(self) -> None:
|
|
2622
|
-
while not self._stop.wait(self.interval_s):
|
|
2623
|
-
self.poll_once()
|
|
2624
|
-
|
|
2625
|
-
def stop(self) -> None:
|
|
2626
|
-
self._stop.set()
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
_PRICING_WATCH: "_PricingWatch | None" = None
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
def _start_pricing_watch() -> "_PricingWatch | None":
|
|
2633
|
-
"""Own the pricing detector on a lifecycle thread that runs in BOTH modes.
|
|
2634
|
-
|
|
2635
|
-
Deliberately not `_DashboardSyncThread` and not the conversation sync
|
|
2636
|
-
thread: `cmd_dashboard` disables both under `--no-sync`, so a detector in
|
|
2637
|
-
either would silently have no owner exactly where a long-lived process is
|
|
2638
|
-
most likely to run.
|
|
2639
|
-
"""
|
|
2640
|
-
global _PRICING_WATCH
|
|
2641
|
-
if _PRICING_WATCH is not None:
|
|
2642
|
-
return _PRICING_WATCH
|
|
2557
|
+
global _INSTALL_WATCH
|
|
2558
|
+
if _cctally_core._is_dev_checkout():
|
|
2559
|
+
return None
|
|
2560
|
+
if _INSTALL_WATCH is not None:
|
|
2561
|
+
return _INSTALL_WATCH
|
|
2643
2562
|
try:
|
|
2644
|
-
watch =
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
target=watch.run, name="cctally-pricing-watch", daemon=True)
|
|
2648
|
-
thread.start()
|
|
2563
|
+
watch = InstallWatch(
|
|
2564
|
+
boot, worker_fn=lambda: getattr(_cctally(), "_UPDATE_WORKER", None))
|
|
2565
|
+
watch.start()
|
|
2649
2566
|
except Exception as exc: # noqa: BLE001
|
|
2650
|
-
eprint(f"[
|
|
2567
|
+
eprint(f"[update] install watch not started ({exc})")
|
|
2651
2568
|
return None
|
|
2652
|
-
|
|
2569
|
+
_INSTALL_WATCH = watch
|
|
2653
2570
|
return watch
|
|
2654
2571
|
|
|
2655
2572
|
|
|
2573
|
+
def _stop_install_watch() -> None:
|
|
2574
|
+
global _INSTALL_WATCH
|
|
2575
|
+
watch, _INSTALL_WATCH = _INSTALL_WATCH, None
|
|
2576
|
+
if watch is not None:
|
|
2577
|
+
watch.stop()
|
|
2578
|
+
|
|
2579
|
+
|
|
2656
2580
|
def _dashboard_maybe_prune_retention() -> None:
|
|
2657
2581
|
"""#313 P3 (F7): throttled transcript retention prune driven from the
|
|
2658
2582
|
independent conversation sync thread. Opens a dedicated transcript
|
|
@@ -2812,18 +2736,7 @@ def _conversation_sync_pass() -> str:
|
|
|
2812
2736
|
therefore NO SECOND OPEN ATTEMPT AFTER A FAILED OPEN — stated that narrowly
|
|
2813
2737
|
because a successful pass opens the store twice by design, once here and
|
|
2814
2738
|
once inside the prune.
|
|
2815
|
-
|
|
2816
|
-
#714: the whole pass runs on ONE pricing revision. This pass writes
|
|
2817
|
-
materialized cost, so a swap landing between the Claude leg and the Codex
|
|
2818
|
-
leg would derive one store's rollup from two revisions — the ordered-write
|
|
2819
|
-
guard would not even see it, because both halves are the same process.
|
|
2820
2739
|
"""
|
|
2821
|
-
with _lib_pricing.pricing_snapshot_context():
|
|
2822
|
-
return _conversation_sync_pass_body()
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
def _conversation_sync_pass_body() -> str:
|
|
2826
|
-
"""The body of `_conversation_sync_pass`, run under its pricing context."""
|
|
2827
2740
|
try:
|
|
2828
2741
|
conn = open_conversations_db()
|
|
2829
2742
|
except (OSError, sqlite3.DatabaseError) as exc:
|
|
@@ -3375,6 +3288,14 @@ def _DashboardUpdateCheckThread(*args, **kwargs):
|
|
|
3375
3288
|
return sys.modules["cctally"]._DashboardUpdateCheckThread(*args, **kwargs)
|
|
3376
3289
|
|
|
3377
3290
|
|
|
3291
|
+
def capture_install_boot_state(*args, **kwargs):
|
|
3292
|
+
return sys.modules["cctally"].capture_install_boot_state(*args, **kwargs)
|
|
3293
|
+
|
|
3294
|
+
|
|
3295
|
+
def InstallWatch(*args, **kwargs):
|
|
3296
|
+
return sys.modules["cctally"].InstallWatch(*args, **kwargs)
|
|
3297
|
+
|
|
3298
|
+
|
|
3378
3299
|
# Module-level __getattr__ — lazy-resolves a handful of cctally globals at
|
|
3379
3300
|
# attribute-access time (used for the ``except cctally.SomeError`` sites
|
|
3380
3301
|
# that need real class identity, and for non-callable constants that the
|
|
@@ -8610,19 +8531,6 @@ _GET_ROUTES = (
|
|
|
8610
8531
|
("scope", "endpoint.conversation_detail"), True),
|
|
8611
8532
|
)
|
|
8612
8533
|
|
|
8613
|
-
#: Handlers that block for the life of a client connection rather than for one
|
|
8614
|
-
#: response. `_dispatch` must NOT pin a pricing revision around these: the pin
|
|
8615
|
-
#: would last as long as the stream, so a viewer who left a tab open would keep
|
|
8616
|
-
#: pricing from whichever revision was live when the tab connected — the exact
|
|
8617
|
-
#: staleness #714 removes. Both are streams that carry no cost of their own;
|
|
8618
|
-
#: the envelopes they forward are priced by the build that produced them, which
|
|
8619
|
-
#: has its own context.
|
|
8620
|
-
_STREAMING_ROUTE_HANDLERS = frozenset({
|
|
8621
|
-
"_serve_api_events",
|
|
8622
|
-
"_handle_get_update_stream",
|
|
8623
|
-
"_handle_get_conversation_events",
|
|
8624
|
-
})
|
|
8625
|
-
|
|
8626
8534
|
_POST_ROUTES = (
|
|
8627
8535
|
("exact", "/api/auth", "_handle_post_auth", None, False),
|
|
8628
8536
|
("exact", "/api/sync", "_handle_post_sync", None, False),
|
|
@@ -8758,13 +8666,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8758
8666
|
eleven path-taking handlers. (NOTE: returning a bool, not ``fn()`` —
|
|
8759
8667
|
the void handlers all return None, so a None-return contract could not
|
|
8760
8668
|
tell "handled" from "unmatched".)
|
|
8761
|
-
|
|
8762
|
-
#714: every matched route runs inside a pricing-snapshot context, so a
|
|
8763
|
-
response is never assembled from two pricing revisions when an
|
|
8764
|
-
installer replaces `_lib_pricing.py` mid-request. The two long-lived
|
|
8765
|
-
streaming routes are excluded — a context entered there would pin one
|
|
8766
|
-
revision for the whole life of the stream, which is minutes to hours
|
|
8767
|
-
and the opposite of what the reload exists to achieve.
|
|
8768
8669
|
"""
|
|
8769
8670
|
path = self.path.split("?", 1)[0]
|
|
8770
8671
|
for kind, pattern, name, perf, wants_path in table:
|
|
@@ -8778,19 +8679,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8778
8679
|
continue
|
|
8779
8680
|
fn = getattr(self, name)
|
|
8780
8681
|
args = (path,) if wants_path else ()
|
|
8781
|
-
if
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8682
|
+
if perf is None:
|
|
8683
|
+
fn(*args)
|
|
8684
|
+
elif perf[0] == "scope":
|
|
8685
|
+
with self._perf_scope(perf[1]):
|
|
8686
|
+
fn(*args)
|
|
8687
|
+
else: # "phase"
|
|
8688
|
+
with self._perf_gate().phase(perf[1]):
|
|
8787
8689
|
fn(*args)
|
|
8788
|
-
elif perf[0] == "scope":
|
|
8789
|
-
with self._perf_scope(perf[1]):
|
|
8790
|
-
fn(*args)
|
|
8791
|
-
else: # "phase"
|
|
8792
|
-
with self._perf_gate().phase(perf[1]):
|
|
8793
|
-
fn(*args)
|
|
8794
8690
|
return True
|
|
8795
8691
|
return False
|
|
8796
8692
|
|
|
@@ -11731,7 +11627,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11731
11627
|
as an explicit user pin; the worker resolves the selected channel
|
|
11732
11628
|
afresh. CSRF-gated. Returns
|
|
11733
11629
|
202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
|
|
11734
|
-
when another run is already in progress
|
|
11630
|
+
when another run is already in progress; 503 + ``{"error": ...}`` while
|
|
11631
|
+
the install watcher holds the automatic-restart claim (#868), which
|
|
11632
|
+
the unchanged client shows as a failed start.
|
|
11735
11633
|
"""
|
|
11736
11634
|
if not self._check_origin_csrf():
|
|
11737
11635
|
return
|
|
@@ -11753,7 +11651,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
11753
11651
|
# The dashboard has no explicit-version input. Older clients sent the
|
|
11754
11652
|
# cached beta target here; trusting it as a pin recreates #342 whenever
|
|
11755
11653
|
# the registry advances while the modal is open.
|
|
11756
|
-
|
|
11654
|
+
try:
|
|
11655
|
+
accepted, run_id = worker.start(None)
|
|
11656
|
+
except sys.modules["cctally"].AutomaticRestartInProgress as exc:
|
|
11657
|
+
self._respond_json(503, {"error": str(exc)})
|
|
11658
|
+
return
|
|
11757
11659
|
if accepted:
|
|
11758
11660
|
self._respond_json(202, {"run_id": run_id})
|
|
11759
11661
|
else:
|
|
@@ -12339,16 +12241,20 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12339
12241
|
_cctally_core.enable_quota_projection_reconciliation()
|
|
12340
12242
|
|
|
12341
12243
|
# Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
|
|
12342
|
-
# at boot
|
|
12343
|
-
#
|
|
12344
|
-
#
|
|
12345
|
-
#
|
|
12346
|
-
# them via the module-proxy so
|
|
12347
|
-
#
|
|
12244
|
+
# at boot. ``_resolve_execvp_target`` derives the in-place restart
|
|
12245
|
+
# from them: an npm install execs Python directly on its own
|
|
12246
|
+
# ``bin/cctally`` (no added Node process, #868), and a brew install
|
|
12247
|
+
# re-enters the symlink into the newly linked keg. Module-level
|
|
12248
|
+
# globals live in cctally; we write them via the module-proxy so
|
|
12249
|
+
# ``UpdateWorker`` and the install watch (in _cctally_update) read the
|
|
12250
|
+
# right values via ``cctally.X`` at call time.
|
|
12348
12251
|
_c_boot = _cctally()
|
|
12349
12252
|
_c_boot.ORIGINAL_SYS_ARGV = list(sys.argv)
|
|
12350
12253
|
_c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
|
|
12351
12254
|
_c_boot._UPDATE_WORKER = UpdateWorker()
|
|
12255
|
+
# #868: captured before any thread starts; the install watch compares every
|
|
12256
|
+
# later poll against what this process booted from.
|
|
12257
|
+
_install_boot = capture_install_boot_state()
|
|
12352
12258
|
|
|
12353
12259
|
# Load config for the bind-host + expose-transcripts resolution below. (#217
|
|
12354
12260
|
# S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
|
|
@@ -12433,11 +12339,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12433
12339
|
_cctally_cache_module.set_conversation_derivations_suppressed(
|
|
12434
12340
|
bool(args.no_sync))
|
|
12435
12341
|
_dashboard_startup_schema_migration(run_derivations=not bool(args.no_sync))
|
|
12436
|
-
# #
|
|
12437
|
-
#
|
|
12438
|
-
|
|
12439
|
-
# the one mode a long-lived read-only dashboard runs in.
|
|
12440
|
-
_start_pricing_watch()
|
|
12342
|
+
# #868: in EVERY mode, because `--no-sync` disables both sync threads and a
|
|
12343
|
+
# long-lived read-only dashboard is still left behind by an install.
|
|
12344
|
+
_start_install_watch(_install_boot)
|
|
12441
12345
|
_heal = _dashboard_self_heal_orphans(skip_sync=bool(args.no_sync))
|
|
12442
12346
|
if _heal is not None and _heal.pruned_files:
|
|
12443
12347
|
print(f"dashboard: pruned {_heal.pruned_files} orphaned cache file(s) "
|
|
@@ -12703,6 +12607,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12703
12607
|
try:
|
|
12704
12608
|
_dashboard_wait_for_signal((_signal.SIGINT, _signal.SIGTERM))
|
|
12705
12609
|
finally:
|
|
12610
|
+
# First, so a shutdown can never be followed by an automatic restart.
|
|
12611
|
+
_stop_install_watch()
|
|
12706
12612
|
if sync_thread is not None:
|
|
12707
12613
|
sync_thread.stop()
|
|
12708
12614
|
conversation_sync_stop.set()
|
|
@@ -56,8 +56,8 @@ from _cctally_core import (
|
|
|
56
56
|
)
|
|
57
57
|
from _lib_dashboard_sources import (
|
|
58
58
|
SOURCE_SCHEMA_VERSION,
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
alerted_at_order_key as _alerted_at_order_key,
|
|
60
|
+
alerted_at_order_key_sql as _alerted_at_order_key_sql,
|
|
61
61
|
dashboard_resource_key as _dashboard_resource_key,
|
|
62
62
|
)
|
|
63
63
|
from _lib_display_tz import _compute_display_block, format_display_dt
|
|
@@ -475,9 +475,10 @@ def _alert_account_resolver(conn: sqlite3.Connection):
|
|
|
475
475
|
# and a row excluded there is unrecoverable. Two spellings of one instant
|
|
476
476
|
# compare equal here; a nonzero offset — which the ``all-combined`` fixture
|
|
477
477
|
# stores deliberately — orders by its true instant rather than by its
|
|
478
|
-
# local-time text.
|
|
479
|
-
|
|
480
|
-
|
|
478
|
+
# local-time text. #869 F14: the key carries the full sub-second instant, so a
|
|
479
|
+
# Codex crossing fired later in the same second as a Claude alert sorts newer.
|
|
480
|
+
_CANON_ALERTED_AT = _alerted_at_order_key_sql()
|
|
481
|
+
_CANON_ALERTED_AT_M = _alerted_at_order_key_sql("m.alerted_at")
|
|
481
482
|
|
|
482
483
|
|
|
483
484
|
def _envelope_rows_weekly(
|
|
@@ -1169,7 +1170,7 @@ def _build_alerts_envelope_array(
|
|
|
1169
1170
|
# `_combined_alert_rows` does — otherwise the operator sees an empty
|
|
1170
1171
|
# panel and a message that identifies nothing.
|
|
1171
1172
|
try:
|
|
1172
|
-
return
|
|
1173
|
+
return _alerted_at_order_key(alert["alerted_at"])
|
|
1173
1174
|
except (KeyError, ValueError) as exc:
|
|
1174
1175
|
identity = alert.get("key") or alert.get("id")
|
|
1175
1176
|
raise ValueError(
|