cctally 1.110.0 → 1.111.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 +6 -0
- package/bin/_cctally_core.py +3 -0
- package/bin/_cctally_dashboard.py +60 -154
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_pricing.py +11 -262
- package/bin/cctally +18 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.111.0] - 2026-09-24
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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`.
|
|
12
|
+
- A `dashboard` restart after an update no longer leaves an extra Node process running on npm installs.
|
|
13
|
+
|
|
8
14
|
## [1.110.0] - 2026-09-22
|
|
9
15
|
|
|
10
16
|
### Added
|
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
|
|
@@ -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()
|
package/bin/_cctally_update.py
CHANGED
|
@@ -75,7 +75,12 @@ worker / polling thread:
|
|
|
75
75
|
(two-thread pump → callbacks + log lines),
|
|
76
76
|
``_do_update_install`` (acquire lock → run steps → release →
|
|
77
77
|
rotate log; dry-run path skips lock + subprocesses),
|
|
78
|
-
``_resolve_execvp_target`` (
|
|
78
|
+
``_resolve_execvp_target`` (spec §5.7; npm execs Python directly
|
|
79
|
+
since #868) and ``_execvp_target_problem`` (target validity).
|
|
80
|
+
- Install evidence (#868): ``install-success.json`` via
|
|
81
|
+
``_write_install_success_record`` / ``_read_install_success_record``,
|
|
82
|
+
``_update_lock_is_held``, ``_install_root_for_entrypoint``,
|
|
83
|
+
``_read_changelog_version_at``, ``_install_layout``.
|
|
79
84
|
- Dashboard surface: ``UpdateWorker`` (single-slot orchestrator,
|
|
80
85
|
spec §5.6; idempotent-release contract per §5.6.1),
|
|
81
86
|
``_DashboardUpdateCheckThread`` (poll cadence ≠ network-call
|
|
@@ -184,12 +189,14 @@ import argparse
|
|
|
184
189
|
import datetime as dt
|
|
185
190
|
import fcntl
|
|
186
191
|
import json
|
|
192
|
+
import math
|
|
187
193
|
import os
|
|
188
194
|
import pathlib
|
|
189
195
|
import queue
|
|
190
196
|
import re
|
|
191
197
|
import secrets
|
|
192
198
|
import shlex
|
|
199
|
+
import shutil
|
|
193
200
|
import subprocess
|
|
194
201
|
import sys
|
|
195
202
|
import threading
|
|
@@ -677,6 +684,101 @@ def _release_update_lock(fd: int) -> None:
|
|
|
677
684
|
pass
|
|
678
685
|
|
|
679
686
|
|
|
687
|
+
_INSTALL_SUCCESS_RECORD_SCHEMA = 1
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@dataclass(frozen=True)
|
|
691
|
+
class InstallSuccessRecord:
|
|
692
|
+
"""One install event from ``install-success.json`` (#868)."""
|
|
693
|
+
|
|
694
|
+
version: str
|
|
695
|
+
at_utc: str
|
|
696
|
+
token: str
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _write_install_success_record(version: str) -> None:
|
|
700
|
+
"""Atomically replace the install-only success record (#868).
|
|
701
|
+
|
|
702
|
+
``_do_update_check`` loads ``update-state.json`` before its network fetch
|
|
703
|
+
and saves that object afterward, so an install that stamps success during
|
|
704
|
+
the fetch loses its fields there. Only the install paths write this file,
|
|
705
|
+
so no update check can erase an install event. Every write mints a fresh
|
|
706
|
+
random token: a running dashboard compares tokens, never times, so a clock
|
|
707
|
+
step can neither hide a new install nor revive an old one.
|
|
708
|
+
"""
|
|
709
|
+
path = _cctally_core.INSTALL_SUCCESS_RECORD_PATH
|
|
710
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
711
|
+
payload = (json.dumps({
|
|
712
|
+
"_schema": _INSTALL_SUCCESS_RECORD_SCHEMA,
|
|
713
|
+
"version": version,
|
|
714
|
+
"at_utc": _now_utc().isoformat(),
|
|
715
|
+
"token": secrets.token_hex(16),
|
|
716
|
+
}, sort_keys=True) + "\n").encode("utf-8")
|
|
717
|
+
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
|
718
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
719
|
+
try:
|
|
720
|
+
try:
|
|
721
|
+
view = memoryview(payload)
|
|
722
|
+
while view:
|
|
723
|
+
view = view[os.write(fd, view):]
|
|
724
|
+
os.fsync(fd)
|
|
725
|
+
finally:
|
|
726
|
+
os.close(fd)
|
|
727
|
+
os.replace(str(tmp), str(path))
|
|
728
|
+
except BaseException:
|
|
729
|
+
try:
|
|
730
|
+
os.unlink(str(tmp))
|
|
731
|
+
except OSError:
|
|
732
|
+
pass
|
|
733
|
+
raise
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _read_install_success_record() -> "InstallSuccessRecord | None":
|
|
737
|
+
"""The current install-success record, or None when absent or malformed."""
|
|
738
|
+
try:
|
|
739
|
+
raw = json.loads(
|
|
740
|
+
_cctally_core.INSTALL_SUCCESS_RECORD_PATH.read_text(encoding="utf-8"))
|
|
741
|
+
except (OSError, ValueError):
|
|
742
|
+
return None
|
|
743
|
+
if not isinstance(raw, dict) or raw.get("_schema") != _INSTALL_SUCCESS_RECORD_SCHEMA:
|
|
744
|
+
return None
|
|
745
|
+
fields = (raw.get("version"), raw.get("at_utc"), raw.get("token"))
|
|
746
|
+
if not all(isinstance(v, str) and v for v in fields):
|
|
747
|
+
return None
|
|
748
|
+
try:
|
|
749
|
+
_release_parse_semver(fields[0])
|
|
750
|
+
except ValueError:
|
|
751
|
+
return None
|
|
752
|
+
return InstallSuccessRecord(*fields)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _update_lock_is_held() -> bool:
|
|
756
|
+
"""True unless ``update.lock`` is provably free; never creates the file.
|
|
757
|
+
|
|
758
|
+
A shared non-blocking probe conflicts with the updater's exclusive lock. A
|
|
759
|
+
lock file left behind by a finished or crashed updater carries no lock, so
|
|
760
|
+
it reads as free. The probe holds its shared lock for an instant, so an
|
|
761
|
+
updater whose own non-blocking attempt lands in that instant takes its
|
|
762
|
+
contention path. Any other error proves nothing about the updater and
|
|
763
|
+
reads as held.
|
|
764
|
+
"""
|
|
765
|
+
try:
|
|
766
|
+
fd = os.open(str(_cctally_core.UPDATE_LOCK_PATH), os.O_RDONLY)
|
|
767
|
+
except FileNotFoundError:
|
|
768
|
+
return False
|
|
769
|
+
except OSError:
|
|
770
|
+
return True
|
|
771
|
+
try:
|
|
772
|
+
try:
|
|
773
|
+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
774
|
+
except OSError:
|
|
775
|
+
return True
|
|
776
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
777
|
+
return False
|
|
778
|
+
finally:
|
|
779
|
+
os.close(fd)
|
|
780
|
+
|
|
781
|
+
|
|
680
782
|
def _rotate_update_log_if_needed() -> None:
|
|
681
783
|
"""Rotate ``update.log`` → ``update.log.1`` when the live log
|
|
682
784
|
crosses :data:`UPDATE_LOG_ROTATE_BYTES` (1 MB, spec §1.5).
|
|
@@ -905,6 +1007,9 @@ def _stamp_install_success_to_state(
|
|
|
905
1007
|
installed version. Skipped on brew for the reason above.
|
|
906
1008
|
4. ``state.latest_version`` — last resort, also covers the npm
|
|
907
1009
|
path when CHANGELOG is unreadable.
|
|
1010
|
+
|
|
1011
|
+
The same version is also written to ``install-success.json`` (#868),
|
|
1012
|
+
which a running dashboard reads to decide whether to restart itself.
|
|
908
1013
|
"""
|
|
909
1014
|
c = _cctally()
|
|
910
1015
|
state = c._load_update_state() or {"_schema": 1}
|
|
@@ -923,6 +1028,15 @@ def _stamp_install_success_to_state(
|
|
|
923
1028
|
state["current_version"] = cur
|
|
924
1029
|
state["last_install_success_at_utc"] = _now_utc().isoformat()
|
|
925
1030
|
c._save_update_state(state)
|
|
1031
|
+
try:
|
|
1032
|
+
c._write_install_success_record(cur)
|
|
1033
|
+
except OSError as exc:
|
|
1034
|
+
# The install itself succeeded; only a running dashboard's
|
|
1035
|
+
# automatic restart loses its evidence.
|
|
1036
|
+
eprint(f"cctally: could not write "
|
|
1037
|
+
f"{_cctally_core.INSTALL_SUCCESS_RECORD_PATH.name} ({exc}); "
|
|
1038
|
+
"a running dashboard will not restart itself for this "
|
|
1039
|
+
"install")
|
|
926
1040
|
|
|
927
1041
|
|
|
928
1042
|
def _self_heal_current_version() -> None:
|
|
@@ -2106,7 +2220,7 @@ def _do_update_install(
|
|
|
2106
2220
|
c._rotate_update_log_if_needed()
|
|
2107
2221
|
|
|
2108
2222
|
|
|
2109
|
-
# === Dashboard execvp re-entry (spec §5.7) ===
|
|
2223
|
+
# === Dashboard execvp re-entry (spec §5.7; #868) ===
|
|
2110
2224
|
# ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
|
|
2111
2225
|
# server boot in cmd_dashboard (in bin/cctally, written via
|
|
2112
2226
|
# ``global ORIGINAL_SYS_ARGV, ORIGINAL_ENTRYPOINT`` so the running
|
|
@@ -2117,26 +2231,81 @@ def _do_update_install(
|
|
|
2117
2231
|
#
|
|
2118
2232
|
# _resolve_execvp_target uses them to return (entrypoint, exec_argv)
|
|
2119
2233
|
# for os.execvp:
|
|
2120
|
-
# - npm:
|
|
2121
|
-
#
|
|
2122
|
-
#
|
|
2234
|
+
# - npm: the interpreter the Node shim would use (CCTALLY_PYTHON, else
|
|
2235
|
+
# python3 on PATH) on the install's bin/cctally. Re-entering the shim
|
|
2236
|
+
# would add a Node process per restart, because the shim starts Python
|
|
2237
|
+
# with spawnSync and waits for it (#868).
|
|
2123
2238
|
# - brew: entrypoint = <brew>/bin/cctally → symlink into the
|
|
2124
2239
|
# post-upgrade Python script with its rewritten shebang.
|
|
2125
2240
|
# - Fallback when shutil.which("cctally") returned None: use
|
|
2126
|
-
# sys.argv[0] directly.
|
|
2127
|
-
#
|
|
2241
|
+
# sys.argv[0] directly.
|
|
2242
|
+
# The install watcher (#868) and UpdateWorker both refuse a target that
|
|
2243
|
+
# _execvp_target_problem rejects.
|
|
2128
2244
|
|
|
2129
2245
|
|
|
2130
|
-
def
|
|
2131
|
-
"""
|
|
2246
|
+
def _install_root_for_entrypoint(entrypoint: "str | None") -> "pathlib.Path | None":
|
|
2247
|
+
"""Install root behind a user-facing entrypoint, resolved afresh (#868).
|
|
2132
2248
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
``
|
|
2136
|
-
|
|
2249
|
+
npm: ``<prefix>/bin/cctally`` → ``…/node_modules/cctally/bin/cctally-npm-shim.js``
|
|
2250
|
+
→ ``…/node_modules/cctally``. Homebrew: ``<prefix>/bin/cctally`` →
|
|
2251
|
+
``Cellar/cctally/<v>/libexec/bin/cctally`` → ``…/libexec``, where the
|
|
2252
|
+
formula installs ``CHANGELOG.md``. The process's own ``CHANGELOG_PATH`` is
|
|
2253
|
+
never used for this: it stays inside the install the process booted from.
|
|
2254
|
+
"""
|
|
2255
|
+
if not entrypoint:
|
|
2256
|
+
return None
|
|
2257
|
+
try:
|
|
2258
|
+
real = pathlib.Path(os.path.realpath(entrypoint))
|
|
2259
|
+
except (OSError, ValueError):
|
|
2260
|
+
return None
|
|
2261
|
+
if real.parent.name != "bin":
|
|
2262
|
+
return None
|
|
2263
|
+
return real.parent.parent
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def _read_changelog_version_at(root) -> "str | None":
|
|
2267
|
+
"""Latest stamped version in ``<root>/CHANGELOG.md``, or None."""
|
|
2268
|
+
try:
|
|
2269
|
+
text = (pathlib.Path(root) / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
2270
|
+
except (OSError, UnicodeDecodeError):
|
|
2271
|
+
return None
|
|
2272
|
+
m = _cctally().RELEASE_HEADER_RE.search(text)
|
|
2273
|
+
return m.group(1) if m else None
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
def _install_layout(root) -> str:
|
|
2277
|
+
"""``"npm"``, ``"brew"`` or ``"other"`` for an install root."""
|
|
2278
|
+
root = pathlib.Path(root)
|
|
2279
|
+
if ((root / "bin" / "cctally-npm-shim.js").is_file()
|
|
2280
|
+
and (root / "package.json").is_file()):
|
|
2281
|
+
return "npm"
|
|
2282
|
+
parts = root.parts
|
|
2283
|
+
if (root.name == "libexec" and len(parts) >= 4
|
|
2284
|
+
and parts[-4] == "Cellar" and parts[-3] == "cctally"):
|
|
2285
|
+
return "brew"
|
|
2286
|
+
return "other"
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def _resolve_execvp_target() -> tuple[str, list[str]]:
|
|
2290
|
+
"""Return (entrypoint, exec_argv) for the in-place restart.
|
|
2291
|
+
|
|
2292
|
+
npm installs exec the interpreter the shim would use (``CCTALLY_PYTHON``,
|
|
2293
|
+
else ``python3`` on ``PATH``) on the install's ``bin/cctally`` directly, so
|
|
2294
|
+
the serving Python process keeps its PID and the original shim stays its
|
|
2295
|
+
only Node parent (#868). Homebrew re-enters the captured entrypoint, which
|
|
2296
|
+
resolves into the newly linked keg. Falls back to ``sys.argv[0]`` only when
|
|
2297
|
+
``shutil.which`` returned ``None`` at dashboard boot.
|
|
2137
2298
|
"""
|
|
2138
2299
|
c = _cctally()
|
|
2139
2300
|
if c.ORIGINAL_ENTRYPOINT is not None:
|
|
2301
|
+
root = _install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT)
|
|
2302
|
+
if root is not None and _install_layout(root) == "npm":
|
|
2303
|
+
python = os.environ.get("CCTALLY_PYTHON") or "python3"
|
|
2304
|
+
if os.path.sep not in python:
|
|
2305
|
+
python = shutil.which(python) or python
|
|
2306
|
+
return (python,
|
|
2307
|
+
[python, str(root / "bin" / "cctally"),
|
|
2308
|
+
*c.ORIGINAL_SYS_ARGV[1:]])
|
|
2140
2309
|
return (
|
|
2141
2310
|
c.ORIGINAL_ENTRYPOINT,
|
|
2142
2311
|
[c.ORIGINAL_ENTRYPOINT, *c.ORIGINAL_SYS_ARGV[1:]],
|
|
@@ -2144,6 +2313,34 @@ def _resolve_execvp_target() -> tuple[str, list[str]]:
|
|
|
2144
2313
|
return (c.ORIGINAL_SYS_ARGV[0], list(c.ORIGINAL_SYS_ARGV))
|
|
2145
2314
|
|
|
2146
2315
|
|
|
2316
|
+
def _execvp_target_problem(entrypoint: str, argv: "list[str]") -> "str | None":
|
|
2317
|
+
"""Why ``os.execvp(entrypoint, argv)`` cannot start cctally, or None.
|
|
2318
|
+
|
|
2319
|
+
The executable must resolve to an executable file. When ``argv[1]`` is an
|
|
2320
|
+
install's ``bin/cctally`` (the npm target), that script must exist and be
|
|
2321
|
+
readable, the same file the npm shim checks before it starts Python.
|
|
2322
|
+
"""
|
|
2323
|
+
path = entrypoint if os.path.sep in entrypoint else shutil.which(entrypoint)
|
|
2324
|
+
if not path or not os.path.isfile(path) or not os.access(path, os.X_OK):
|
|
2325
|
+
return f"restart target {entrypoint!r} is not an executable file"
|
|
2326
|
+
if (len(argv) >= 2 and os.path.isabs(argv[1])
|
|
2327
|
+
and pathlib.Path(argv[1]).parts[-2:] == ("bin", "cctally")):
|
|
2328
|
+
if not (os.path.isfile(argv[1]) and os.access(argv[1], os.R_OK)):
|
|
2329
|
+
return f"restart script {argv[1]!r} is missing or unreadable"
|
|
2330
|
+
return None
|
|
2331
|
+
|
|
2332
|
+
|
|
2333
|
+
class AutomaticRestartInProgress(Exception):
|
|
2334
|
+
"""``UpdateWorker.start`` refused: the install watcher holds the restart
|
|
2335
|
+
claim (#868, maintainer decision 6)."""
|
|
2336
|
+
|
|
2337
|
+
def __init__(self, version: str) -> None:
|
|
2338
|
+
self.version = version
|
|
2339
|
+
super().__init__(
|
|
2340
|
+
f"The dashboard is restarting to load v{version}. "
|
|
2341
|
+
"Try again in a moment.")
|
|
2342
|
+
|
|
2343
|
+
|
|
2147
2344
|
class UpdateWorker:
|
|
2148
2345
|
"""Single-slot dashboard-side update orchestrator (spec §5.6).
|
|
2149
2346
|
|
|
@@ -2175,15 +2372,23 @@ class UpdateWorker:
|
|
|
2175
2372
|
# consumer ever subscribes, ``start()`` reaps stale entries on
|
|
2176
2373
|
# the next run.
|
|
2177
2374
|
self._streams: dict[str, "queue.Queue"] = {}
|
|
2375
|
+
# The install watcher's restart claim (#868): the candidate version
|
|
2376
|
+
# while held, else None. Guarded by ``_lock`` together with
|
|
2377
|
+
# ``_current_id``, so a claim and an admitted run exclude each other.
|
|
2378
|
+
self._auto_restart_version: "str | None" = None
|
|
2178
2379
|
|
|
2179
2380
|
def start(self, version: "str | None") -> tuple[bool, str]:
|
|
2180
2381
|
"""Begin a run. Returns (accepted, run_id).
|
|
2181
2382
|
|
|
2182
2383
|
``accepted=False`` when another run is in progress; the
|
|
2183
2384
|
returned ``run_id`` is the in-progress one (so the caller can
|
|
2184
|
-
surface it as ``run_id_in_progress`` to the client).
|
|
2385
|
+
surface it as ``run_id_in_progress`` to the client). Raises
|
|
2386
|
+
:class:`AutomaticRestartInProgress` while the install watcher holds
|
|
2387
|
+
the restart claim (#868).
|
|
2185
2388
|
"""
|
|
2186
2389
|
with self._lock:
|
|
2390
|
+
if self._auto_restart_version is not None:
|
|
2391
|
+
raise AutomaticRestartInProgress(self._auto_restart_version)
|
|
2187
2392
|
if self._current_id is not None:
|
|
2188
2393
|
return (False, self._current_id)
|
|
2189
2394
|
# Reap any stale entries from prior no-consumer runs. Safe
|
|
@@ -2206,6 +2411,26 @@ class UpdateWorker:
|
|
|
2206
2411
|
with self._lock:
|
|
2207
2412
|
return {"current_run_id": self._current_id}
|
|
2208
2413
|
|
|
2414
|
+
def claim_automatic_restart(self, version: str) -> bool:
|
|
2415
|
+
"""Claim the in-place restart for the install watcher (#868).
|
|
2416
|
+
|
|
2417
|
+
Fails while an update run is admitted, because that run owns its own
|
|
2418
|
+
stamp, flush, lock release and exec. The claim is not an update run:
|
|
2419
|
+
it has no run id, stream or event, so ``status()`` still reports no
|
|
2420
|
+
run and a client that clicks Update meanwhile gets
|
|
2421
|
+
:class:`AutomaticRestartInProgress` from ``start``.
|
|
2422
|
+
"""
|
|
2423
|
+
with self._lock:
|
|
2424
|
+
if (self._current_id is not None
|
|
2425
|
+
or self._auto_restart_version is not None):
|
|
2426
|
+
return False
|
|
2427
|
+
self._auto_restart_version = version
|
|
2428
|
+
return True
|
|
2429
|
+
|
|
2430
|
+
def release_automatic_restart(self) -> None:
|
|
2431
|
+
with self._lock:
|
|
2432
|
+
self._auto_restart_version = None
|
|
2433
|
+
|
|
2209
2434
|
def _emit(self, run_id: str, event: dict) -> None:
|
|
2210
2435
|
q = self._streams.get(run_id)
|
|
2211
2436
|
if q is not None:
|
|
@@ -2310,6 +2535,9 @@ class UpdateWorker:
|
|
|
2310
2535
|
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
2311
2536
|
c._stamp_install_success_to_state(resolved_version, method)
|
|
2312
2537
|
entrypoint, exec_argv = c._resolve_execvp_target()
|
|
2538
|
+
problem = c._execvp_target_problem(entrypoint, exec_argv)
|
|
2539
|
+
if problem is not None:
|
|
2540
|
+
raise UpdateError(problem)
|
|
2313
2541
|
self._emit(run_id, {"type": "execvp", "argv": exec_argv})
|
|
2314
2542
|
try:
|
|
2315
2543
|
log_fd.close()
|
|
@@ -2347,6 +2575,184 @@ class UpdateWorker:
|
|
|
2347
2575
|
# start() sweeps stale entries on the next run.
|
|
2348
2576
|
|
|
2349
2577
|
|
|
2578
|
+
# === Install watch (#868) =================================================
|
|
2579
|
+
# A running dashboard restarts itself in place when a proven install puts a
|
|
2580
|
+
# strictly newer cctally under it. Spec:
|
|
2581
|
+
# docs/superpowers/specs/2026-09-23-868-dashboard-restart-on-install-design.md
|
|
2582
|
+
|
|
2583
|
+
_INSTALL_WATCH_INTERVAL_S = 60.0
|
|
2584
|
+
_INSTALL_RESTART_RETRY_DELAYS_S = (60.0, 300.0, 900.0)
|
|
2585
|
+
_INSTALL_RESTART_FLUSH_S = 0.5
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
@dataclass(frozen=True)
|
|
2589
|
+
class InstallBootState:
|
|
2590
|
+
"""What the dashboard process booted from. Later polls compare against it,
|
|
2591
|
+
never against wall-clock time, so a restarted process cannot qualify on the
|
|
2592
|
+
install it booted from."""
|
|
2593
|
+
|
|
2594
|
+
version: "str | None"
|
|
2595
|
+
root: "pathlib.Path | None"
|
|
2596
|
+
record_token: "str | None"
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def capture_install_boot_state() -> InstallBootState:
|
|
2600
|
+
c = _cctally()
|
|
2601
|
+
latest = c._release_read_latest_release_version()
|
|
2602
|
+
record = _read_install_success_record()
|
|
2603
|
+
return InstallBootState(
|
|
2604
|
+
version=latest[0] if latest else None,
|
|
2605
|
+
root=_install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT),
|
|
2606
|
+
record_token=record.token if record else None,
|
|
2607
|
+
)
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
@dataclass(frozen=True)
|
|
2611
|
+
class RestartCandidate:
|
|
2612
|
+
"""A proven newer install. ``identity`` keys the retry budget: the version
|
|
2613
|
+
with the record token (updater) or the resolved install root (Homebrew)."""
|
|
2614
|
+
|
|
2615
|
+
version: str
|
|
2616
|
+
evidence: str
|
|
2617
|
+
identity: tuple
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
class InstallWatch:
|
|
2621
|
+
"""Restart the dashboard in place after a proven newer install (#868).
|
|
2622
|
+
|
|
2623
|
+
Proven means cctally's updater wrote a new install-success record, or
|
|
2624
|
+
Homebrew switched the entrypoint into a newer keg. A manual npm install
|
|
2625
|
+
rewrites files in place with no completion signal, so it never qualifies.
|
|
2626
|
+
|
|
2627
|
+
The restart meets the Update-button safety bar and no more: nothing is
|
|
2628
|
+
stopped before the exec, and an in-flight write is interrupted as in a
|
|
2629
|
+
crash. The claim on ``UpdateWorker`` keeps an admitted update run as the
|
|
2630
|
+
sole owner of its own restart.
|
|
2631
|
+
"""
|
|
2632
|
+
|
|
2633
|
+
def __init__(self, boot, *, worker_fn, exec_fn=os.execvp,
|
|
2634
|
+
sleep_fn=time.sleep, monotonic_fn=time.monotonic,
|
|
2635
|
+
log_fn=None, interval_s=_INSTALL_WATCH_INTERVAL_S,
|
|
2636
|
+
retry_delays=_INSTALL_RESTART_RETRY_DELAYS_S,
|
|
2637
|
+
flush_s=_INSTALL_RESTART_FLUSH_S):
|
|
2638
|
+
self._boot = boot
|
|
2639
|
+
self._worker_fn = worker_fn
|
|
2640
|
+
self._exec = exec_fn
|
|
2641
|
+
self._sleep = sleep_fn
|
|
2642
|
+
self._monotonic = monotonic_fn
|
|
2643
|
+
self._log = log_fn or eprint
|
|
2644
|
+
self.interval_s = interval_s
|
|
2645
|
+
self._retry_delays = tuple(retry_delays)
|
|
2646
|
+
self._flush_s = flush_s
|
|
2647
|
+
# identity -> (failed attempts, monotonic time of the next attempt)
|
|
2648
|
+
self._attempts: dict = {}
|
|
2649
|
+
self._stop = threading.Event()
|
|
2650
|
+
self._thread: "threading.Thread | None" = None
|
|
2651
|
+
|
|
2652
|
+
def _say(self, msg: str) -> None:
|
|
2653
|
+
# A lost log line, e.g. stderr on a closed pipe, must not count as a
|
|
2654
|
+
# failed restart attempt or end the watch thread.
|
|
2655
|
+
try:
|
|
2656
|
+
self._log(msg)
|
|
2657
|
+
except Exception: # noqa: BLE001
|
|
2658
|
+
pass
|
|
2659
|
+
|
|
2660
|
+
def find_candidate(self) -> "RestartCandidate | None":
|
|
2661
|
+
boot = self._boot
|
|
2662
|
+
if boot.version is None:
|
|
2663
|
+
return None
|
|
2664
|
+
root = _install_root_for_entrypoint(_cctally().ORIGINAL_ENTRYPOINT)
|
|
2665
|
+
if root is None:
|
|
2666
|
+
return None
|
|
2667
|
+
record = _read_install_success_record()
|
|
2668
|
+
if (record is not None and record.token != boot.record_token
|
|
2669
|
+
and _semver_gt(record.version, boot.version)
|
|
2670
|
+
and not _update_lock_is_held()
|
|
2671
|
+
and _read_changelog_version_at(root) == record.version):
|
|
2672
|
+
return RestartCandidate(record.version, "updater",
|
|
2673
|
+
(record.version, "updater", record.token))
|
|
2674
|
+
# Homebrew links the new keg only after installing it completely, so
|
|
2675
|
+
# the switch itself is the completion event.
|
|
2676
|
+
if root != boot.root and _install_layout(root) == "brew":
|
|
2677
|
+
version = _read_changelog_version_at(root)
|
|
2678
|
+
if version and _semver_gt(version, boot.version):
|
|
2679
|
+
return RestartCandidate(version, "brew",
|
|
2680
|
+
(version, "brew", str(root)))
|
|
2681
|
+
return None
|
|
2682
|
+
|
|
2683
|
+
def poll_once(self) -> str:
|
|
2684
|
+
"""One check. Returns ``"idle"``, ``"backoff"``, ``"exhausted"``,
|
|
2685
|
+
``"deferred"``, ``"stopped"``, ``"failed"`` or ``"exec"``."""
|
|
2686
|
+
candidate = self.find_candidate()
|
|
2687
|
+
if candidate is None:
|
|
2688
|
+
return "idle"
|
|
2689
|
+
now = self._monotonic()
|
|
2690
|
+
prior = self._attempts.get(candidate.identity)
|
|
2691
|
+
if prior is not None:
|
|
2692
|
+
failures, next_at = prior
|
|
2693
|
+
if failures > len(self._retry_delays):
|
|
2694
|
+
return "exhausted"
|
|
2695
|
+
if now < next_at:
|
|
2696
|
+
return "backoff"
|
|
2697
|
+
worker = self._worker_fn()
|
|
2698
|
+
if worker is None:
|
|
2699
|
+
return "deferred"
|
|
2700
|
+
c = _cctally()
|
|
2701
|
+
claimed = False
|
|
2702
|
+
try:
|
|
2703
|
+
entrypoint, argv = c._resolve_execvp_target()
|
|
2704
|
+
problem = c._execvp_target_problem(entrypoint, argv)
|
|
2705
|
+
if problem is None:
|
|
2706
|
+
again = self.find_candidate()
|
|
2707
|
+
if again is None or again.identity != candidate.identity:
|
|
2708
|
+
problem = "the install changed before the restart"
|
|
2709
|
+
if problem is not None:
|
|
2710
|
+
raise UpdateError(problem)
|
|
2711
|
+
if not worker.claim_automatic_restart(candidate.version):
|
|
2712
|
+
return "deferred"
|
|
2713
|
+
claimed = True
|
|
2714
|
+
self._say(f"[update] restarting dashboard: v{self._boot.version} "
|
|
2715
|
+
f"-> v{candidate.version} ({candidate.evidence} install)")
|
|
2716
|
+
# Lets a POST /api/update already refused under the claim finish
|
|
2717
|
+
# writing its 503 before the process image is replaced.
|
|
2718
|
+
self._sleep(self._flush_s)
|
|
2719
|
+
if self._stop.is_set():
|
|
2720
|
+
worker.release_automatic_restart()
|
|
2721
|
+
return "stopped"
|
|
2722
|
+
self._exec(entrypoint, argv)
|
|
2723
|
+
except Exception as exc: # noqa: BLE001 — a failed attempt keeps serving
|
|
2724
|
+
if claimed:
|
|
2725
|
+
worker.release_automatic_restart()
|
|
2726
|
+
failures = (prior[0] if prior else 0) + 1
|
|
2727
|
+
delay = (self._retry_delays[failures - 1]
|
|
2728
|
+
if failures <= len(self._retry_delays) else math.inf)
|
|
2729
|
+
self._attempts[candidate.identity] = (failures, now + delay)
|
|
2730
|
+
self._say(f"[update] automatic restart to v{candidate.version} "
|
|
2731
|
+
f"failed: {exc}")
|
|
2732
|
+
return "failed"
|
|
2733
|
+
return "exec"
|
|
2734
|
+
|
|
2735
|
+
def run(self) -> None:
|
|
2736
|
+
while not self._stop.wait(self.interval_s):
|
|
2737
|
+
try:
|
|
2738
|
+
self.poll_once()
|
|
2739
|
+
except Exception as exc: # noqa: BLE001 — the watch must not die
|
|
2740
|
+
self._say(f"[update] install watch poll failed: {exc!r}")
|
|
2741
|
+
|
|
2742
|
+
def start(self) -> "threading.Thread":
|
|
2743
|
+
thread = threading.Thread(target=self.run, name="cctally-install-watch",
|
|
2744
|
+
daemon=True)
|
|
2745
|
+
thread.start()
|
|
2746
|
+
self._thread = thread
|
|
2747
|
+
return thread
|
|
2748
|
+
|
|
2749
|
+
def stop(self, timeout: float = 2.0) -> None:
|
|
2750
|
+
self._stop.set()
|
|
2751
|
+
thread = self._thread
|
|
2752
|
+
if thread is not None and thread is not threading.current_thread():
|
|
2753
|
+
thread.join(timeout)
|
|
2754
|
+
|
|
2755
|
+
|
|
2350
2756
|
class _DashboardUpdateCheckThread(threading.Thread):
|
|
2351
2757
|
"""Dedicated update-check polling thread (spec §3.5).
|
|
2352
2758
|
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -107,13 +107,10 @@ def _import_pricing_kernel():
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
# ``DEFAULT_TIERED_THRESHOLD`` above is Anthropic's per-call >200K-tokens tier,
|
|
110
|
-
# READ FROM ``_lib_pricing`` rather than duplicated here
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
# swap, and ``_compute_entry_cache_dollars`` resolves it at CALL time — a
|
|
115
|
-
# default argument would bind it once at function-definition time, so the
|
|
116
|
-
# refresh would reach the name and never the calls.
|
|
110
|
+
# READ FROM ``_lib_pricing.TIERED_THRESHOLD`` rather than duplicated here, so
|
|
111
|
+
# the two cannot drift. ``_compute_entry_cache_dollars`` resolves it at CALL
|
|
112
|
+
# time instead of binding it as a default argument, so this module global is
|
|
113
|
+
# the one place the value is taken from.
|
|
117
114
|
# Callers may still override via the ``tiered_threshold`` kwarg.
|
|
118
115
|
|
|
119
116
|
|
|
@@ -384,9 +381,9 @@ def _compute_entry_cache_dollars(
|
|
|
384
381
|
warning for unknown models elsewhere.
|
|
385
382
|
|
|
386
383
|
``tiered_threshold=None`` resolves ``DEFAULT_TIERED_THRESHOLD`` HERE rather
|
|
387
|
-
than in the signature
|
|
388
|
-
function-definition time, so
|
|
389
|
-
|
|
384
|
+
than in the signature. A default argument is evaluated once, at
|
|
385
|
+
function-definition time, so a change to the module global would never
|
|
386
|
+
reach the calls.
|
|
390
387
|
"""
|
|
391
388
|
if tiered_threshold is None:
|
|
392
389
|
tiered_threshold = DEFAULT_TIERED_THRESHOLD
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -17,17 +17,12 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
17
17
|
"""
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
-
import contextlib as _contextlib
|
|
21
|
-
import contextvars as _contextvars
|
|
22
20
|
import dataclasses
|
|
23
21
|
import datetime as dt
|
|
24
22
|
import re
|
|
25
23
|
import sys
|
|
26
|
-
import threading as _threading
|
|
27
24
|
from typing import Any
|
|
28
25
|
|
|
29
|
-
_sys = sys
|
|
30
|
-
|
|
31
26
|
|
|
32
27
|
def _eprint(*args: Any) -> None:
|
|
33
28
|
print(*args, file=sys.stderr)
|
|
@@ -1448,36 +1443,20 @@ def _short_model_name(model: str) -> str:
|
|
|
1448
1443
|
|
|
1449
1444
|
|
|
1450
1445
|
# ---------------------------------------------------------------------------
|
|
1451
|
-
#
|
|
1452
|
-
#
|
|
1453
|
-
# Every CLI invocation re-imports this module, so only a long-lived process —
|
|
1454
|
-
# the dashboard — can hold pricing an installer has already replaced on disk.
|
|
1455
|
-
# Two rules shape everything below.
|
|
1456
|
-
#
|
|
1457
|
-
# A PARTIAL SWAP IS WORSE THAN NO SWAP. `PRICING_SNAPSHOT_DATE` is what gates
|
|
1458
|
-
# the materialized-cost write refusal in `_cctally_cache`, so replacing the
|
|
1459
|
-
# date alone clears the refusal and resumes writing cost computed from the old
|
|
1460
|
-
# tables. A candidate is therefore validated whole and adopted whole, or it is
|
|
1461
|
-
# rejected and the live snapshot is retained unchanged.
|
|
1446
|
+
# The pricing snapshot every cost kernel reads.
|
|
1462
1447
|
#
|
|
1463
|
-
#
|
|
1464
|
-
#
|
|
1465
|
-
#
|
|
1466
|
-
#
|
|
1467
|
-
# in `PRICING_EXPORT_BINDINGS`. `ast.literal_eval` cannot execute code.
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
class PricingCandidateRejected(Exception):
|
|
1471
|
-
"""A candidate pricing file was not adopted. Carries the reason."""
|
|
1448
|
+
# Built once at import from the module globals above and never replaced. A
|
|
1449
|
+
# long-lived dashboard does not reload pricing in process: after a proven
|
|
1450
|
+
# install of a newer version it restarts itself (#868), and after a manual
|
|
1451
|
+
# install it keeps these tables until the user restarts it.
|
|
1472
1452
|
|
|
1473
1453
|
|
|
1474
1454
|
class PricingSnapshot:
|
|
1475
1455
|
"""One complete, immutable pricing revision.
|
|
1476
1456
|
|
|
1477
|
-
Every pricing-affecting value lives here — not only the two tables
|
|
1478
|
-
tier
|
|
1479
|
-
|
|
1480
|
-
revision, which is the same defect as a stale table and harder to see.
|
|
1457
|
+
Every pricing-affecting value lives here — not only the two tables: both
|
|
1458
|
+
tier thresholds, the Codex fallback model, the aliases, the cache-write
|
|
1459
|
+
multiplier and the fast multipliers.
|
|
1481
1460
|
|
|
1482
1461
|
Hand-written frozen rather than ``@dataclasses.dataclass(frozen=True)``,
|
|
1483
1462
|
for the reason `PricingFingerprintObservation` above states at length:
|
|
@@ -1522,81 +1501,6 @@ class PricingSnapshot:
|
|
|
1522
1501
|
f"codex_models={len(self.codex_pricing)})")
|
|
1523
1502
|
|
|
1524
1503
|
|
|
1525
|
-
#: The complete inventory of import-time copies, made explicit so it can be
|
|
1526
|
-
#: tested rather than asserted. `(module name, attribute, field accessor)`.
|
|
1527
|
-
#: `bin/cctally` copies the tables at import and both dashboard source modules
|
|
1528
|
-
#: read those copies, so a swap that missed one would price from a stale table
|
|
1529
|
-
#: with the refusal latch already cleared.
|
|
1530
|
-
PRICING_EXPORT_BINDINGS = (
|
|
1531
|
-
("_lib_pricing", "PRICING_SNAPSHOT_DATE", lambda s: s.snapshot_date),
|
|
1532
|
-
("_lib_pricing", "CLAUDE_MODEL_PRICING", lambda s: s.claude_pricing),
|
|
1533
|
-
("_lib_pricing", "CODEX_MODEL_PRICING", lambda s: s.codex_pricing),
|
|
1534
|
-
("_lib_pricing", "CODEX_MODEL_ALIASES", lambda s: s.aliases),
|
|
1535
|
-
("_lib_pricing", "TIERED_THRESHOLD",
|
|
1536
|
-
lambda s: s.tier_thresholds["claude"]),
|
|
1537
|
-
("_lib_pricing", "CODEX_TIERED_THRESHOLD",
|
|
1538
|
-
lambda s: s.tier_thresholds["codex"]),
|
|
1539
|
-
("_lib_pricing", "CODEX_LEGACY_FALLBACK_MODEL", lambda s: s.fallback_model),
|
|
1540
|
-
("_lib_pricing", "CACHE_WRITE_1H_MULTIPLIER",
|
|
1541
|
-
lambda s: s.cache_write_1h_multiplier),
|
|
1542
|
-
("_lib_pricing", "CLAUDE_FAST_MULTIPLIER_OVERRIDES",
|
|
1543
|
-
lambda s: s.fast_multipliers["claude"]),
|
|
1544
|
-
("_lib_pricing", "CODEX_FAST_MULTIPLIER_OVERRIDES",
|
|
1545
|
-
lambda s: s.fast_multipliers["codex"]),
|
|
1546
|
-
("_lib_pricing", "CODEX_FAST_MULTIPLIER_FALLBACK",
|
|
1547
|
-
lambda s: s.fast_multipliers["codex_fallback"]),
|
|
1548
|
-
("cctally", "PRICING_SNAPSHOT_DATE", lambda s: s.snapshot_date),
|
|
1549
|
-
("cctally", "CLAUDE_MODEL_PRICING", lambda s: s.claude_pricing),
|
|
1550
|
-
("cctally", "CODEX_MODEL_PRICING", lambda s: s.codex_pricing),
|
|
1551
|
-
("cctally", "TIERED_THRESHOLD", lambda s: s.tier_thresholds["claude"]),
|
|
1552
|
-
("cctally", "CODEX_TIERED_THRESHOLD",
|
|
1553
|
-
lambda s: s.tier_thresholds["codex"]),
|
|
1554
|
-
("cctally", "CODEX_LEGACY_FALLBACK_MODEL", lambda s: s.fallback_model),
|
|
1555
|
-
("cctally", "CACHE_WRITE_1H_MULTIPLIER",
|
|
1556
|
-
lambda s: s.cache_write_1h_multiplier),
|
|
1557
|
-
("cctally", "CLAUDE_FAST_MULTIPLIER_OVERRIDES",
|
|
1558
|
-
lambda s: s.fast_multipliers["claude"]),
|
|
1559
|
-
("cctally", "CODEX_FAST_MULTIPLIER_OVERRIDES",
|
|
1560
|
-
lambda s: s.fast_multipliers["codex"]),
|
|
1561
|
-
("cctally", "CODEX_FAST_MULTIPLIER_FALLBACK",
|
|
1562
|
-
lambda s: s.fast_multipliers["codex_fallback"]),
|
|
1563
|
-
# `_cctally_cache` binds the date as a module global on purpose, so a test
|
|
1564
|
-
# can monkeypatch it; that affordance is preserved and the binding is
|
|
1565
|
-
# simply refreshed here too.
|
|
1566
|
-
("_cctally_cache", "PRICING_SNAPSHOT_DATE", lambda s: s.snapshot_date),
|
|
1567
|
-
# `_lib_cache_report` unpacks the multiplier and the Claude tier threshold
|
|
1568
|
-
# at import. The threshold used to be a literal `200_000` in that file with
|
|
1569
|
-
# nothing keeping it equal to `TIERED_THRESHOLD` here, which made it a
|
|
1570
|
-
# pricing value no reload could replace; it is read from this module now
|
|
1571
|
-
# and refreshed through this entry.
|
|
1572
|
-
("_lib_cache_report", "CACHE_WRITE_1H_MULTIPLIER",
|
|
1573
|
-
lambda s: s.cache_write_1h_multiplier),
|
|
1574
|
-
("_lib_cache_report", "DEFAULT_TIERED_THRESHOLD",
|
|
1575
|
-
lambda s: s.tier_thresholds["claude"]),
|
|
1576
|
-
)
|
|
1577
|
-
|
|
1578
|
-
#: The names `read_pricing_snapshot_from_source` requires, each mapped to the
|
|
1579
|
-
#: `PricingSnapshot` field it fills. A missing OR non-literal name rejects the
|
|
1580
|
-
#: whole candidate.
|
|
1581
|
-
_CANDIDATE_ASSIGNMENTS = (
|
|
1582
|
-
"PRICING_SNAPSHOT_DATE",
|
|
1583
|
-
"CLAUDE_MODEL_PRICING",
|
|
1584
|
-
"CODEX_MODEL_PRICING",
|
|
1585
|
-
"CODEX_MODEL_ALIASES",
|
|
1586
|
-
"TIERED_THRESHOLD",
|
|
1587
|
-
"CODEX_TIERED_THRESHOLD",
|
|
1588
|
-
"CODEX_LEGACY_FALLBACK_MODEL",
|
|
1589
|
-
"CACHE_WRITE_1H_MULTIPLIER",
|
|
1590
|
-
"CLAUDE_FAST_MULTIPLIER_OVERRIDES",
|
|
1591
|
-
"CODEX_FAST_MULTIPLIER_OVERRIDES",
|
|
1592
|
-
"CODEX_FAST_MULTIPLIER_FALLBACK",
|
|
1593
|
-
)
|
|
1594
|
-
|
|
1595
|
-
_PRICING_PUBLISH_LOCK = _threading.Lock()
|
|
1596
|
-
_PRICING_CONTEXT = _contextvars.ContextVar("cctally_pricing_snapshot",
|
|
1597
|
-
default=None)
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
1504
|
def _snapshot_from_module_globals() -> PricingSnapshot:
|
|
1601
1505
|
return PricingSnapshot(
|
|
1602
1506
|
snapshot_date=PRICING_SNAPSHOT_DATE,
|
|
@@ -1619,161 +1523,6 @@ _LIVE_PRICING_SNAPSHOT = _snapshot_from_module_globals()
|
|
|
1619
1523
|
|
|
1620
1524
|
|
|
1621
1525
|
def current_pricing_snapshot() -> PricingSnapshot:
|
|
1622
|
-
"""The snapshot
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
with, so a request, a dashboard snapshot build or a sync pass already
|
|
1626
|
-
under way is never assembled from two revisions. Everyone else reads the
|
|
1627
|
-
live pointer, which is one immutable object replaced by one assignment.
|
|
1628
|
-
"""
|
|
1629
|
-
captured = _PRICING_CONTEXT.get()
|
|
1630
|
-
return _LIVE_PRICING_SNAPSHOT if captured is None else captured
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
@_contextlib.contextmanager
|
|
1634
|
-
def pricing_snapshot_context(snapshot: "PricingSnapshot | None" = None):
|
|
1635
|
-
"""Pin one revision for the duration of a request, build or sync pass."""
|
|
1636
|
-
pinned = current_pricing_snapshot() if snapshot is None else snapshot
|
|
1637
|
-
token = _PRICING_CONTEXT.set(pinned)
|
|
1638
|
-
try:
|
|
1639
|
-
yield pinned
|
|
1640
|
-
finally:
|
|
1641
|
-
_PRICING_CONTEXT.reset(token)
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
def publish_pricing_snapshot(snapshot: PricingSnapshot) -> None:
|
|
1645
|
-
"""Swap the live pointer and refresh every import-time copy.
|
|
1646
|
-
|
|
1647
|
-
The pointer swap is one assignment of one frozen object, so a concurrent
|
|
1648
|
-
reader observes the old revision whole or the new one whole. The binding
|
|
1649
|
-
refresh that follows exists for call sites that still read a copied name;
|
|
1650
|
-
every cost kernel in this module reads `current_pricing_snapshot()`
|
|
1651
|
-
instead, so correctness does not depend on the refresh reaching a module
|
|
1652
|
-
that has not been imported.
|
|
1653
|
-
"""
|
|
1654
|
-
global _LIVE_PRICING_SNAPSHOT
|
|
1655
|
-
with _PRICING_PUBLISH_LOCK:
|
|
1656
|
-
_LIVE_PRICING_SNAPSHOT = snapshot
|
|
1657
|
-
for module_name, attr, accessor in PRICING_EXPORT_BINDINGS:
|
|
1658
|
-
module = _sys.modules.get(module_name)
|
|
1659
|
-
if module is None:
|
|
1660
|
-
continue
|
|
1661
|
-
try:
|
|
1662
|
-
setattr(module, attr, accessor(snapshot))
|
|
1663
|
-
except Exception: # noqa: BLE001
|
|
1664
|
-
# A module that refuses an attribute set must not stop the
|
|
1665
|
-
# rest of the inventory being refreshed.
|
|
1666
|
-
_eprint(f"[pricing] could not refresh {module_name}.{attr}")
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
def adopt_pricing_candidate(candidate: PricingSnapshot, *,
|
|
1670
|
-
force: bool = False) -> bool:
|
|
1671
|
-
"""Publish `candidate` iff it is strictly newer than the live snapshot.
|
|
1672
|
-
|
|
1673
|
-
`_lib_pricing` documents that a pricing revision always ADVANCES
|
|
1674
|
-
`PRICING_SNAPSHOT_DATE`, so a candidate that does not is a rollback, a
|
|
1675
|
-
corrupt read or the same revision read twice; each of those is a reason to
|
|
1676
|
-
keep what is running. `force` exists for tests restoring a captured
|
|
1677
|
-
snapshot and for nothing else.
|
|
1678
|
-
"""
|
|
1679
|
-
if force:
|
|
1680
|
-
publish_pricing_snapshot(candidate)
|
|
1681
|
-
return True
|
|
1682
|
-
live = _LIVE_PRICING_SNAPSHOT
|
|
1683
|
-
new_date = parse_pricing_fingerprint(candidate.snapshot_date)
|
|
1684
|
-
live_date = parse_pricing_fingerprint(live.snapshot_date)
|
|
1685
|
-
if new_date is None:
|
|
1686
|
-
return False
|
|
1687
|
-
if live_date is not None and new_date <= live_date:
|
|
1688
|
-
return False
|
|
1689
|
-
publish_pricing_snapshot(candidate)
|
|
1690
|
-
return True
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
def _file_signature(path) -> tuple:
|
|
1694
|
-
st = path.stat()
|
|
1695
|
-
return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
def read_pricing_snapshot_from_source(path) -> PricingSnapshot:
|
|
1699
|
-
"""Build a candidate from the literal assignments in a pricing file.
|
|
1700
|
-
|
|
1701
|
-
`ast.parse` plus `ast.literal_eval`, never `importlib.reload` and never
|
|
1702
|
-
`exec`: the file is one an installer has just written and this process
|
|
1703
|
-
must not run it.
|
|
1704
|
-
|
|
1705
|
-
Raises `PricingCandidateRejected` — never returns a partial snapshot — on
|
|
1706
|
-
a missing name, a name whose value is not a literal, a date that does not
|
|
1707
|
-
parse, or a file whose inode/size/mtime signature changes while it is
|
|
1708
|
-
being read, which is an installer replacing it mid-parse.
|
|
1709
|
-
"""
|
|
1710
|
-
import ast
|
|
1711
|
-
import pathlib as _pathlib
|
|
1712
|
-
|
|
1713
|
-
path = _pathlib.Path(path)
|
|
1714
|
-
try:
|
|
1715
|
-
before = _file_signature(path)
|
|
1716
|
-
source = path.read_bytes()
|
|
1717
|
-
after = _file_signature(path)
|
|
1718
|
-
except OSError as exc:
|
|
1719
|
-
raise PricingCandidateRejected(
|
|
1720
|
-
f"pricing candidate could not be read: {exc}") from exc
|
|
1721
|
-
if before != after:
|
|
1722
|
-
raise PricingCandidateRejected(
|
|
1723
|
-
"pricing candidate signature changed while it was being read")
|
|
1724
|
-
|
|
1725
|
-
try:
|
|
1726
|
-
tree = ast.parse(source.decode("utf-8"), filename=str(path))
|
|
1727
|
-
except (SyntaxError, UnicodeDecodeError) as exc:
|
|
1728
|
-
raise PricingCandidateRejected(
|
|
1729
|
-
f"pricing candidate does not parse: {exc}") from exc
|
|
1730
|
-
|
|
1731
|
-
found: "dict[str, Any]" = {}
|
|
1732
|
-
for node in tree.body:
|
|
1733
|
-
targets = []
|
|
1734
|
-
if isinstance(node, ast.Assign):
|
|
1735
|
-
targets = [t for t in node.targets if isinstance(t, ast.Name)]
|
|
1736
|
-
value = node.value
|
|
1737
|
-
elif isinstance(node, ast.AnnAssign) and isinstance(node.target,
|
|
1738
|
-
ast.Name):
|
|
1739
|
-
targets = [node.target]
|
|
1740
|
-
value = node.value
|
|
1741
|
-
else:
|
|
1742
|
-
continue
|
|
1743
|
-
for target in targets:
|
|
1744
|
-
if target.id not in _CANDIDATE_ASSIGNMENTS or value is None:
|
|
1745
|
-
continue
|
|
1746
|
-
try:
|
|
1747
|
-
found[target.id] = ast.literal_eval(value)
|
|
1748
|
-
except (ValueError, SyntaxError, TypeError) as exc:
|
|
1749
|
-
raise PricingCandidateRejected(
|
|
1750
|
-
f"{target.id} is no longer a literal, so the in-process "
|
|
1751
|
-
f"pricing reload cannot read it: {exc}") from exc
|
|
1752
|
-
|
|
1753
|
-
missing = [name for name in _CANDIDATE_ASSIGNMENTS if name not in found]
|
|
1754
|
-
if missing:
|
|
1755
|
-
raise PricingCandidateRejected(
|
|
1756
|
-
"pricing candidate is incomplete; adopting a partial revision "
|
|
1757
|
-
"would clear the write refusal and price from the old tables. "
|
|
1758
|
-
f"Missing: {', '.join(missing)}")
|
|
1759
|
-
|
|
1760
|
-
if parse_pricing_fingerprint(found["PRICING_SNAPSHOT_DATE"]) is None:
|
|
1761
|
-
raise PricingCandidateRejected(
|
|
1762
|
-
"pricing candidate has no orderable PRICING_SNAPSHOT_DATE: "
|
|
1763
|
-
f"{found['PRICING_SNAPSHOT_DATE']!r}")
|
|
1764
|
-
|
|
1765
|
-
return PricingSnapshot(
|
|
1766
|
-
snapshot_date=found["PRICING_SNAPSHOT_DATE"],
|
|
1767
|
-
claude_pricing=found["CLAUDE_MODEL_PRICING"],
|
|
1768
|
-
codex_pricing=found["CODEX_MODEL_PRICING"],
|
|
1769
|
-
aliases=found["CODEX_MODEL_ALIASES"],
|
|
1770
|
-
tier_thresholds={"claude": found["TIERED_THRESHOLD"],
|
|
1771
|
-
"codex": found["CODEX_TIERED_THRESHOLD"]},
|
|
1772
|
-
fallback_model=found["CODEX_LEGACY_FALLBACK_MODEL"],
|
|
1773
|
-
cache_write_1h_multiplier=found["CACHE_WRITE_1H_MULTIPLIER"],
|
|
1774
|
-
fast_multipliers={
|
|
1775
|
-
"claude": found["CLAUDE_FAST_MULTIPLIER_OVERRIDES"],
|
|
1776
|
-
"codex": found["CODEX_FAST_MULTIPLIER_OVERRIDES"],
|
|
1777
|
-
"codex_fallback": found["CODEX_FAST_MULTIPLIER_FALLBACK"],
|
|
1778
|
-
},
|
|
1779
|
-
)
|
|
1526
|
+
"""The snapshot every cost kernel prices from; it never changes after
|
|
1527
|
+
import."""
|
|
1528
|
+
return _LIVE_PRICING_SNAPSHOT
|
package/bin/cctally
CHANGED
|
@@ -351,6 +351,7 @@ UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
|
|
|
351
351
|
UPDATE_LOG_PATH = _cctally_core.UPDATE_LOG_PATH
|
|
352
352
|
UPDATE_LOG_ROTATED_PATH = _cctally_core.UPDATE_LOG_ROTATED_PATH
|
|
353
353
|
UPDATE_CHECK_LAST_FETCH_PATH = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH
|
|
354
|
+
INSTALL_SUCCESS_RECORD_PATH = _cctally_core.INSTALL_SUCCESS_RECORD_PATH
|
|
354
355
|
|
|
355
356
|
CLAUDE_SETTINGS_PATH = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
356
357
|
|
|
@@ -1356,6 +1357,10 @@ _persist_npm_prefix_to_state = _cctally_update._persist_npm_prefix_to_state
|
|
|
1356
1357
|
_detect_install_method = _cctally_update._detect_install_method
|
|
1357
1358
|
_persist_install_method_to_state = _cctally_update._persist_install_method_to_state
|
|
1358
1359
|
_stamp_install_success_to_state = _cctally_update._stamp_install_success_to_state
|
|
1360
|
+
InstallSuccessRecord = _cctally_update.InstallSuccessRecord
|
|
1361
|
+
_write_install_success_record = _cctally_update._write_install_success_record
|
|
1362
|
+
_read_install_success_record = _cctally_update._read_install_success_record
|
|
1363
|
+
_update_lock_is_held = _cctally_update._update_lock_is_held
|
|
1359
1364
|
_self_heal_current_version = _cctally_update._self_heal_current_version
|
|
1360
1365
|
_BREW_VERSION_RE_LIST = _cctally_update._BREW_VERSION_RE_LIST
|
|
1361
1366
|
_update_user_agent = _cctally_update._update_user_agent
|
|
@@ -1388,8 +1393,17 @@ _preflight_install = _cctally_update._preflight_install
|
|
|
1388
1393
|
_build_update_steps = _cctally_update._build_update_steps
|
|
1389
1394
|
_run_streaming = _cctally_update._run_streaming
|
|
1390
1395
|
_do_update_install = _cctally_update._do_update_install
|
|
1396
|
+
_install_root_for_entrypoint = _cctally_update._install_root_for_entrypoint
|
|
1397
|
+
_read_changelog_version_at = _cctally_update._read_changelog_version_at
|
|
1398
|
+
_install_layout = _cctally_update._install_layout
|
|
1391
1399
|
_resolve_execvp_target = _cctally_update._resolve_execvp_target
|
|
1400
|
+
_execvp_target_problem = _cctally_update._execvp_target_problem
|
|
1401
|
+
AutomaticRestartInProgress = _cctally_update.AutomaticRestartInProgress
|
|
1392
1402
|
UpdateWorker = _cctally_update.UpdateWorker
|
|
1403
|
+
InstallBootState = _cctally_update.InstallBootState
|
|
1404
|
+
capture_install_boot_state = _cctally_update.capture_install_boot_state
|
|
1405
|
+
RestartCandidate = _cctally_update.RestartCandidate
|
|
1406
|
+
InstallWatch = _cctally_update.InstallWatch
|
|
1393
1407
|
_DashboardUpdateCheckThread = _cctally_update._DashboardUpdateCheckThread
|
|
1394
1408
|
cmd_update = _cctally_update.cmd_update
|
|
1395
1409
|
_args_emit_json = _cctally_update._args_emit_json
|
|
@@ -2661,14 +2675,13 @@ def _warn_budget_bad_config_once(exc: Exception) -> None:
|
|
|
2661
2675
|
# server boot in cmd_dashboard. The moved _resolve_execvp_target
|
|
2662
2676
|
# uses them to return (entrypoint, exec_argv) for os.execvp:
|
|
2663
2677
|
#
|
|
2664
|
-
# - npm:
|
|
2665
|
-
#
|
|
2666
|
-
#
|
|
2678
|
+
# - npm: the interpreter the Node shim would use (CCTALLY_PYTHON,
|
|
2679
|
+
# else python3 on PATH) on the install's bin/cctally, so a restart
|
|
2680
|
+
# adds no Node process (#868).
|
|
2667
2681
|
# - brew: entrypoint = <brew>/bin/cctally → symlink into the
|
|
2668
2682
|
# post-upgrade Python script with its rewritten shebang.
|
|
2669
2683
|
# - Fallback when shutil.which("cctally") returned None: use
|
|
2670
|
-
# sys.argv[0] directly.
|
|
2671
|
-
# degraded edge case rather than guess.
|
|
2684
|
+
# sys.argv[0] directly.
|
|
2672
2685
|
ORIGINAL_SYS_ARGV: list[str] = []
|
|
2673
2686
|
ORIGINAL_ENTRYPOINT: "str | None" = None
|
|
2674
2687
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.111.0",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|