cctally 1.64.0 → 1.66.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 +50 -0
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +262 -40
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +49 -14
- package/bin/_cctally_dashboard.py +827 -4911
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +771 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +127 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +2 -1
- package/bin/_cctally_forecast.py +78 -135
- package/bin/_cctally_parser.py +919 -784
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +8 -5
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_tui.py +487 -254
- package/bin/_cctally_update.py +5 -0
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +34 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +18 -10
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +51 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +11 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
|
@@ -162,6 +162,23 @@ def stale_allowlist_entries(allowlist, claude_tbl, codex_tbl, litellm_scoped) ->
|
|
|
162
162
|
return stale
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
def expired_allowlist_entries(allowlist, as_of_date) -> list:
|
|
166
|
+
"""Return allowlist entries whose optional ``expires`` (``YYYY-MM-DD``) date
|
|
167
|
+
has PASSED as of ``as_of_date`` (a date/datetime or ISO string).
|
|
168
|
+
|
|
169
|
+
Date-derived, so it needs no network (runs offline too — #279 S7 W7): an
|
|
170
|
+
expired suppression is a signal to remove the entry / re-sync the embedded
|
|
171
|
+
snapshot even before LiteLLM has reverted, so the deliberate divergence
|
|
172
|
+
doesn't silently ossify past its stated cutover. An entry with no
|
|
173
|
+
``expires`` field never expires. Comparison is strict — an entry expiring ON
|
|
174
|
+
a date is still valid THROUGH that date and only expired the day AFTER."""
|
|
175
|
+
as_of = str(as_of_date)[:10]
|
|
176
|
+
return [
|
|
177
|
+
e for e in (allowlist or [])
|
|
178
|
+
if e.get("expires") and str(e["expires"])[:10] < as_of
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
165
182
|
_CLAUDE_REQUIRED = ("input_cost_per_token", "output_cost_per_token",
|
|
166
183
|
"cache_creation_input_token_cost", "cache_read_input_token_cost")
|
|
167
184
|
_CODEX_REQUIRED = ("input_cost_per_token", "cache_read_input_token_cost",
|
|
@@ -194,8 +211,13 @@ def check_table_shapes(claude_tbl, codex_tbl, zero_sentinels) -> list:
|
|
|
194
211
|
return problems
|
|
195
212
|
|
|
196
213
|
|
|
197
|
-
def pricing_issue_action(
|
|
198
|
-
"""Decide the cron's GitHub-issue action. Pure; the YAML executes it.
|
|
199
|
-
|
|
214
|
+
def pricing_issue_action(findings_present: bool, existing_open: bool) -> str:
|
|
215
|
+
"""Decide the cron's GitHub-issue action. Pure; the YAML executes it.
|
|
216
|
+
|
|
217
|
+
``findings_present`` is ANY actionable finding the cron tracks — value
|
|
218
|
+
drift, missing-from-us, OR (as of #279 S7 W7) a stale/expired allowlist
|
|
219
|
+
suppression. The name generalizes the former ``drift_present``; the
|
|
220
|
+
two-state machine is unchanged."""
|
|
221
|
+
if findings_present:
|
|
200
222
|
return "update" if existing_open else "create"
|
|
201
223
|
return "close" if existing_open else "noop"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Record write-path decision kernels for cctally.
|
|
2
|
+
|
|
3
|
+
Pure-fn leaf (stdlib only, no I/O at import time): values in, decisions
|
|
4
|
+
out. Every DB read, config load, accessor reach (``c.<constant>``,
|
|
5
|
+
``c._is_reset_drop``, ``c._floor_to_ten_minutes``), SQL statement, file
|
|
6
|
+
write, and ``eprint`` stays in the I/O glue in ``bin/_cctally_record.py``
|
|
7
|
+
— this module never touches the ``cctally`` namespace. That single rule
|
|
8
|
+
is what preserves the entire ns-patch surface of ``cmd_record_usage`` /
|
|
9
|
+
``maybe_record_projected_alert`` while their decision cores move here.
|
|
10
|
+
|
|
11
|
+
Each kernel mirrors the exact comparison operators of the fragment it was
|
|
12
|
+
lifted from (``round(x, 1)`` on the HWM clamp, ``+ 1e-9`` snap on every
|
|
13
|
+
percent/threshold crossing, inclusive band bounds) so behavior is
|
|
14
|
+
byte-identical. Glue call site is named in each kernel's docstring.
|
|
15
|
+
|
|
16
|
+
Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Fragment 1: --resets-at / --five-hour-resets-at plausibility band ──────
|
|
24
|
+
def check_resets_at_plausibility(
|
|
25
|
+
epoch: int, now_epoch: int, *, past_slack_s: int, future_band_s: int
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Return True when ``epoch`` sits inside the inclusive plausibility band
|
|
28
|
+
``[now_epoch - past_slack_s, now_epoch + future_band_s]``.
|
|
29
|
+
|
|
30
|
+
Glue call sites (``cmd_record_usage``): the 7d leg (day-scale slack;
|
|
31
|
+
out-of-band → eprint + exit 2) and the 5h leg (10-min-past / 6h-future;
|
|
32
|
+
out-of-band → drop the 5h fields and continue). The two leg-specific
|
|
33
|
+
eprint literals and the differing consequences stay in glue; only the
|
|
34
|
+
raw second-granularity band check moves here.
|
|
35
|
+
"""
|
|
36
|
+
return now_epoch - past_slack_s <= epoch <= now_epoch + future_band_s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Fragment 2: weekly in-place-credit / reset-to-zero debounce ────────────
|
|
40
|
+
FIRE_IMMEDIATE = "fire_immediate"
|
|
41
|
+
CONFIRM_RESET = "confirm_reset"
|
|
42
|
+
CLEAR_MARKER = "clear_marker"
|
|
43
|
+
ARM_MARKER = "arm_marker"
|
|
44
|
+
NO_ACTION = "none"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class WeeklyDebounceDecision:
|
|
49
|
+
"""Which weekly credit/reset-to-zero action ``cmd_record_usage``'s
|
|
50
|
+
same-week (``prior_end == cur_end``) branch should take. ``action`` is one
|
|
51
|
+
of the module constants FIRE_IMMEDIATE / CONFIRM_RESET / CLEAR_MARKER /
|
|
52
|
+
ARM_MARKER / NO_ACTION — mirroring the branch outcomes at the glue site."""
|
|
53
|
+
action: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plan_weekly_credit_debounce(
|
|
57
|
+
prev_pct, new_pct, *, drop_threshold, zero_floor_pct, zero_min_drop_pct,
|
|
58
|
+
marker_armed, marker_baseline,
|
|
59
|
+
):
|
|
60
|
+
"""Classify the same-window weekly-credit debounce decision (glue call
|
|
61
|
+
site: ``cmd_record_usage`` under ``prior_end_canon == cur_end_canon`` and
|
|
62
|
+
``prior_end_dt > now_utc and prior_pct is not None``).
|
|
63
|
+
|
|
64
|
+
Mirrors the branch structure exactly:
|
|
65
|
+
- ``big_drop`` (drop >= drop_threshold) → FIRE_IMMEDIATE (>=25pp goodwill
|
|
66
|
+
credit; fires now, never debounced; glue also clears any pending arm).
|
|
67
|
+
- else, marker armed for this window:
|
|
68
|
+
- ``new_pct <= marker_baseline / 2.0`` → CONFIRM_RESET (stayed low).
|
|
69
|
+
- else → CLEAR_MARKER (recovered toward baseline → transient zero).
|
|
70
|
+
- else, ``zero_only`` (not big_drop AND new_pct <= zero_floor_pct AND
|
|
71
|
+
drop >= zero_min_drop_pct) → ARM_MARKER (first ~0).
|
|
72
|
+
- else → NO_ACTION.
|
|
73
|
+
|
|
74
|
+
Glue reads the ``c._RESET_*`` constants + the marker file, computes
|
|
75
|
+
``marker_armed`` (window-key match) and passes ``marker_baseline``
|
|
76
|
+
(marker[2] when armed), then executes the decided I/O.
|
|
77
|
+
"""
|
|
78
|
+
drop = float(prev_pct) - float(new_pct)
|
|
79
|
+
big_drop = drop >= drop_threshold
|
|
80
|
+
zero_only = (
|
|
81
|
+
(not big_drop)
|
|
82
|
+
and float(new_pct) <= zero_floor_pct
|
|
83
|
+
and drop >= zero_min_drop_pct
|
|
84
|
+
)
|
|
85
|
+
if big_drop:
|
|
86
|
+
return WeeklyDebounceDecision(FIRE_IMMEDIATE)
|
|
87
|
+
if marker_armed:
|
|
88
|
+
if float(new_pct) <= marker_baseline / 2.0:
|
|
89
|
+
return WeeklyDebounceDecision(CONFIRM_RESET)
|
|
90
|
+
return WeeklyDebounceDecision(CLEAR_MARKER)
|
|
91
|
+
if zero_only:
|
|
92
|
+
return WeeklyDebounceDecision(ARM_MARKER)
|
|
93
|
+
return WeeklyDebounceDecision(NO_ACTION)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Fragment 3: 5h in-place-credit detection guard ─────────────────────────
|
|
97
|
+
def plan_five_hour_credit(
|
|
98
|
+
prior_pct: float, new_pct: float, *, drop_threshold: float,
|
|
99
|
+
prior_resets_in_future: bool,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""Return True when a 5h in-place credit is detected (glue call site:
|
|
102
|
+
``cmd_record_usage``'s 5h-detection block).
|
|
103
|
+
|
|
104
|
+
Mirrors the one-line guard ``prior_5h_resets_dt > now_utc and
|
|
105
|
+
(prior_5h_pct - five_hour_percent) >= threshold``. ``is_dup`` is NOT an
|
|
106
|
+
input (gate P3-7): it gates only the glue INSERT, while the pivots
|
|
107
|
+
(hwm-5h force-write, stale-replica DELETE) fire unconditionally once a
|
|
108
|
+
credit is detected — all of that stays in glue.
|
|
109
|
+
"""
|
|
110
|
+
return prior_resets_in_future and (prior_pct - new_pct) >= drop_threshold
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Fragment 4: reset-aware HWM clamp comparison ───────────────────────────
|
|
114
|
+
def hwm_clamp_applies(incoming_pct: float, recorded_max_pct) -> bool:
|
|
115
|
+
"""Return True when ``incoming_pct`` is below the reset-aware recorded MAX
|
|
116
|
+
at tenths granularity (``round(x, 1)`` on both sides), i.e. the clamp fires.
|
|
117
|
+
|
|
118
|
+
Glue call sites (``cmd_record_usage``), each with a DISTINCT consequence
|
|
119
|
+
the glue keeps: the 7d leg sets ``should_insert = False`` (suppresses the
|
|
120
|
+
row); the 5h leg — NESTED inside the 7d ``else:`` — clamps the value up
|
|
121
|
+
(``five_hour_percent = float(max_5h_row["v"])``) and never touches
|
|
122
|
+
``should_insert``. ``recorded_max_pct`` is the MAX cell (may be ``None``
|
|
123
|
+
when there is no in-window row); the SELECTs (including
|
|
124
|
+
``_reset_aware_floor``) stay in glue verbatim.
|
|
125
|
+
"""
|
|
126
|
+
if recorded_max_pct is None:
|
|
127
|
+
return False
|
|
128
|
+
return round(incoming_pct, 1) < round(float(recorded_max_pct), 1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Fragment 5 (residue): self-heal milestone-coverage predicate ───────────
|
|
132
|
+
def milestone_coverage_owes(existing_max_threshold, floor: int) -> bool:
|
|
133
|
+
"""Return True when the milestone ledger for the ACTIVE segment owes a
|
|
134
|
+
heal — no rows yet (``existing_max_threshold is None``) or the highest
|
|
135
|
+
recorded threshold sits below the latest floor.
|
|
136
|
+
|
|
137
|
+
The load-bearing decision repeated at BOTH self-heal milestone-coverage
|
|
138
|
+
probes in ``cmd_record_usage``'s dedup self-heal block (weekly Probe 1 and
|
|
139
|
+
the 5h probe's milestone-coverage else-leg). Glue runs the DB probes,
|
|
140
|
+
reduces each to ``existing_max_threshold`` (int or None), and OR-s the
|
|
141
|
+
result into ``need_milestone_heal`` / ``need_5h_heal``. The broader
|
|
142
|
+
``assess_self_heal`` aggregate stays glue-only (gate P3-5): its need-flags
|
|
143
|
+
are thin residues interleaved with four nesting levels of DB probes and
|
|
144
|
+
the staleness checks (``block_row is None`` / ``last_observed <
|
|
145
|
+
captured``), which are not cleanly separable without moving I/O.
|
|
146
|
+
"""
|
|
147
|
+
return existing_max_threshold is None or existing_max_threshold < floor
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── Fragment 6: hwm-7d / hwm-5h monotonic file step ────────────────────────
|
|
151
|
+
def hwm_file_next(existing, incoming: float):
|
|
152
|
+
"""Return the value to write to the HWM file, or ``None`` when the write
|
|
153
|
+
should be skipped (glue call sites: ``cmd_record_usage``'s hwm-7d and
|
|
154
|
+
hwm-5h writers).
|
|
155
|
+
|
|
156
|
+
Mirrors the real ``>=`` operator: write when ``incoming >= existing``
|
|
157
|
+
(equality rewrites the same bytes — the code writes, so this returns the
|
|
158
|
+
value, not None). ``existing is None`` (no prior value) always writes. The
|
|
159
|
+
file read/parse and the actual ``write_text`` stay in glue.
|
|
160
|
+
"""
|
|
161
|
+
if existing is None or incoming >= existing:
|
|
162
|
+
return incoming
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Fragment 7: projected-pace alert threshold crossings ───────────────────
|
|
167
|
+
def projected_crossings(value: float, levels) -> list:
|
|
168
|
+
"""Return the threshold labels crossed by ``value`` at the ``+ 1e-9`` snap.
|
|
169
|
+
|
|
170
|
+
``levels`` is a list of ``(threshold_label, comparand)`` pairs — glue
|
|
171
|
+
pre-scales each comparand per leg (weekly_pct: ``(t, float(t))``; the two
|
|
172
|
+
budget legs: ``(t, (t / 100.0) * float(target))``), so this kernel never
|
|
173
|
+
rescales. A label crosses when ``value + 1e-9 >= comparand``. Glue maps
|
|
174
|
+
the returned labels back into the per-leg ``pending.append(dict(...))``
|
|
175
|
+
bodies; the leg-level ``_projected_levels_already_latched`` pre-gate stays
|
|
176
|
+
in glue (gate P2-2 — it is a per-leg gate BEFORE the loop, not a
|
|
177
|
+
per-threshold filter).
|
|
178
|
+
"""
|
|
179
|
+
return [t for (t, comparand) in levels if value + 1e-9 >= comparand]
|
package/bin/_lib_render.py
CHANGED
|
@@ -100,6 +100,11 @@ _short_model_name = _lib_pricing._short_model_name
|
|
|
100
100
|
_lib_display_tz = _load_lib("_lib_display_tz")
|
|
101
101
|
_resolve_tz = _lib_display_tz._resolve_tz
|
|
102
102
|
|
|
103
|
+
# JSON wire-format kernel — the additive camelCase schemaVersion stamp
|
|
104
|
+
# (#279 S6 W1; convention docs/cli-contract.md).
|
|
105
|
+
_lib_json_envelope = _load_lib("_lib_json_envelope")
|
|
106
|
+
stamp_schema_version = _lib_json_envelope.stamp_schema_version
|
|
107
|
+
|
|
103
108
|
# fmt/color/table primitives honest-imported from _lib_fmt (#126 C11)
|
|
104
109
|
_lib_fmt = _load_lib("_lib_fmt")
|
|
105
110
|
_supports_color_stdout = _lib_fmt._supports_color_stdout
|
|
@@ -812,7 +817,8 @@ def _bucket_to_json(
|
|
|
812
817
|
"""
|
|
813
818
|
bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
|
|
814
819
|
totals = _bucket_totals_dict(buckets)
|
|
815
|
-
|
|
820
|
+
payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
|
|
821
|
+
return json.dumps(payload, indent=2)
|
|
816
822
|
|
|
817
823
|
|
|
818
824
|
def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
|
|
@@ -825,10 +831,9 @@ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str
|
|
|
825
831
|
for label, buckets in project_groups:
|
|
826
832
|
projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
|
|
827
833
|
all_buckets.extend(buckets)
|
|
828
|
-
|
|
829
|
-
{"projects": projects, "totals": _bucket_totals_dict(all_buckets)}
|
|
830
|
-
|
|
831
|
-
)
|
|
834
|
+
payload = stamp_schema_version(
|
|
835
|
+
{"projects": projects, "totals": _bucket_totals_dict(all_buckets)})
|
|
836
|
+
return json.dumps(payload, indent=2)
|
|
832
837
|
|
|
833
838
|
|
|
834
839
|
def _weekly_to_json(
|
|
@@ -912,7 +917,7 @@ def _weekly_to_json(
|
|
|
912
917
|
"totalCost": tot_cost,
|
|
913
918
|
},
|
|
914
919
|
}
|
|
915
|
-
return json.dumps(payload, indent=2)
|
|
920
|
+
return json.dumps(stamp_schema_version(payload), indent=2)
|
|
916
921
|
|
|
917
922
|
|
|
918
923
|
def _daily_compact_split(bucket: str) -> str:
|
|
@@ -976,7 +981,8 @@ def _emit_codex_no_data(args: argparse.Namespace, list_key: str) -> None:
|
|
|
976
981
|
filter_applied = bool(getattr(args, "since", None) or getattr(args, "until", None))
|
|
977
982
|
if getattr(args, "json", False):
|
|
978
983
|
# Compact separators to match Node's `JSON.stringify(obj)` output exactly.
|
|
979
|
-
print(json.dumps({list_key: [], "totals": None},
|
|
984
|
+
print(json.dumps(stamp_schema_version({list_key: [], "totals": None}),
|
|
985
|
+
separators=(",", ":")))
|
|
980
986
|
else:
|
|
981
987
|
if filter_applied:
|
|
982
988
|
print("No Codex usage data found for provided filters.")
|
|
@@ -1056,7 +1062,8 @@ def _codex_bucket_to_json(
|
|
|
1056
1062
|
"totalTokens": tot_tokens,
|
|
1057
1063
|
"costUSD": tot_cost,
|
|
1058
1064
|
}
|
|
1059
|
-
|
|
1065
|
+
payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
|
|
1066
|
+
return json.dumps(payload, indent=2)
|
|
1060
1067
|
|
|
1061
1068
|
|
|
1062
1069
|
def _codex_root_short_labels(roots: list[str]) -> list[str]:
|
|
@@ -1161,7 +1168,8 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
1161
1168
|
"totalTokens": tot_tokens,
|
|
1162
1169
|
"costUSD": tot_cost,
|
|
1163
1170
|
}
|
|
1164
|
-
|
|
1171
|
+
payload = stamp_schema_version({"sessions": session_list, "totals": totals})
|
|
1172
|
+
return json.dumps(payload, indent=2)
|
|
1165
1173
|
|
|
1166
1174
|
|
|
1167
1175
|
def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
@@ -1227,7 +1235,7 @@ def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
|
1227
1235
|
"totalCost": tot_cost,
|
|
1228
1236
|
},
|
|
1229
1237
|
}
|
|
1230
|
-
return json.dumps(payload, indent=2)
|
|
1238
|
+
return json.dumps(stamp_schema_version(payload), indent=2)
|
|
1231
1239
|
|
|
1232
1240
|
|
|
1233
1241
|
def _render_bucket_table(
|
|
@@ -290,6 +290,47 @@ def current_generation() -> int:
|
|
|
290
290
|
return _GENERATION
|
|
291
291
|
|
|
292
292
|
|
|
293
|
+
# === Owner-thread tripwire (#279 S5 F6.3, spec §8) =========================
|
|
294
|
+
# The snapshot caches are single-writer-AT-A-TIME, under ``sync_lock`` — NOT
|
|
295
|
+
# "the periodic sync thread only": /api/sync and /api/settings legitimately
|
|
296
|
+
# rebuild on REQUEST threads while holding the lock (gate P1-1). So ownership
|
|
297
|
+
# means "the thread currently holding sync_lock for this rebuild", marked
|
|
298
|
+
# overwrite-on-call at the entry of the locked rebuild body
|
|
299
|
+
# (bin/_cctally_tui.py::_make_run_sync_now_locked._locked). Unarmed (never
|
|
300
|
+
# marked) => _assert_owner is a NO-OP, so every existing test and the TUI
|
|
301
|
+
# (which never arm) are untouched; a raise (not a log) on an armed foreign-
|
|
302
|
+
# thread mutation is deliberate — silent cache corruption is the failure mode
|
|
303
|
+
# being priced out.
|
|
304
|
+
_OWNER_THREAD_IDENT: int | None = None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def mark_owner_thread() -> None:
|
|
308
|
+
"""Record the calling thread as the cache owner (overwrite-on-call).
|
|
309
|
+
|
|
310
|
+
Called at the entry of the locked rebuild body
|
|
311
|
+
(``_make_run_sync_now_locked._locked``) — ownership means "the thread
|
|
312
|
+
currently holding ``sync_lock``", NOT "the periodic sync thread":
|
|
313
|
+
/api/sync and /api/settings legitimately rebuild on request threads
|
|
314
|
+
(#279 S5 gate P1-1). Unarmed (never marked) => _assert_owner is a
|
|
315
|
+
no-op, so tests and the TUI need no changes.
|
|
316
|
+
"""
|
|
317
|
+
global _OWNER_THREAD_IDENT
|
|
318
|
+
_OWNER_THREAD_IDENT = threading.get_ident()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def reset_owner_thread() -> None:
|
|
322
|
+
global _OWNER_THREAD_IDENT
|
|
323
|
+
_OWNER_THREAD_IDENT = None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _assert_owner() -> None:
|
|
327
|
+
if _OWNER_THREAD_IDENT is not None and threading.get_ident() != _OWNER_THREAD_IDENT:
|
|
328
|
+
raise RuntimeError(
|
|
329
|
+
"snapshot-cache mutation from non-owner thread; rebuilds must "
|
|
330
|
+
"run under sync_lock (see mark_owner_thread)"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
293
334
|
# === Task 0.4 — Group A bucket cache holder ================================
|
|
294
335
|
|
|
295
336
|
|
|
@@ -431,6 +472,7 @@ def reset_group_a_state() -> None:
|
|
|
431
472
|
cached bucket and reset the watermarks so the next rebuild recomputes
|
|
432
473
|
from scratch (spec §7 / Codex F4).
|
|
433
474
|
"""
|
|
475
|
+
_assert_owner()
|
|
434
476
|
_GROUP_A_CACHE.clear()
|
|
435
477
|
_GROUP_A_LAST_SEEN.clear()
|
|
436
478
|
reset_group_a_current_state()
|
|
@@ -470,6 +512,7 @@ _GROUP_A_CURRENT: "dict[str, CurrentBucketAccumulator]" = {}
|
|
|
470
512
|
|
|
471
513
|
def reset_group_a_current_state() -> None:
|
|
472
514
|
"""Drop every builder's current-bucket accumulator (prune-site + full-invalidate)."""
|
|
515
|
+
_assert_owner()
|
|
473
516
|
_GROUP_A_CURRENT.clear()
|
|
474
517
|
|
|
475
518
|
|
|
@@ -671,6 +714,7 @@ def build_cached_group_a(
|
|
|
671
714
|
labels, fresh recompute for current + dirty), in ``all_bucket_labels``
|
|
672
715
|
order, and updates this builder's last-seen state.
|
|
673
716
|
"""
|
|
717
|
+
_assert_owner()
|
|
674
718
|
cur_max_id = _max_id(cache_conn, "session_entries")
|
|
675
719
|
# #270 §7c: split max_id's two duties — the incremental watermark + "any
|
|
676
720
|
# new work?" gate move to the O(1) `cache_meta` mutation counter (an
|
|
@@ -829,6 +873,7 @@ def reset_session_cache_state() -> None:
|
|
|
829
873
|
cached session and reset the watermark so the next rebuild re-aggregates
|
|
830
874
|
the whole window from scratch (spec §7 / Codex F4).
|
|
831
875
|
"""
|
|
876
|
+
_assert_owner()
|
|
832
877
|
_SESSION_CACHE.clear()
|
|
833
878
|
_SESSION_LAST_SEEN.clear()
|
|
834
879
|
|
|
@@ -863,6 +908,7 @@ def build_cached_sessions(
|
|
|
863
908
|
its resolved ``session_id`` (which the aggregator sets to the stem for
|
|
864
909
|
fallback sessions), so the cache keys match ``affected_session_keys``.
|
|
865
910
|
"""
|
|
911
|
+
_assert_owner()
|
|
866
912
|
cur_max_id = _max_id(cache_conn, "session_entries")
|
|
867
913
|
# #270 §7c/§7d: the warm "any new work?" gate + the affected-set query key
|
|
868
914
|
# on the `mutation_seq` counter (an id-stable in-place finalization of an
|
|
@@ -997,6 +1043,7 @@ def store_dispatch_state(dispatch_key: object, snapshot: object) -> None:
|
|
|
997
1043
|
Called once per rebuild (idle or full) so the next tick compares against the
|
|
998
1044
|
key the just-published snapshot was built from.
|
|
999
1045
|
"""
|
|
1046
|
+
_assert_owner()
|
|
1000
1047
|
global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
|
|
1001
1048
|
_LAST_DISPATCH_KEY = dispatch_key
|
|
1002
1049
|
_LAST_PUBLISHED_SNAPSHOT = snapshot
|
|
@@ -1010,6 +1057,7 @@ def reset_dispatch_state() -> None:
|
|
|
1010
1057
|
part of the M5.2 prune invalidation — the generation bump (a signature leg)
|
|
1011
1058
|
already forces the next rebuild off the idle path.
|
|
1012
1059
|
"""
|
|
1060
|
+
_assert_owner()
|
|
1013
1061
|
global _LAST_DISPATCH_KEY, _LAST_PUBLISHED_SNAPSHOT
|
|
1014
1062
|
_LAST_DISPATCH_KEY = None
|
|
1015
1063
|
_LAST_PUBLISHED_SNAPSHOT = None
|
|
@@ -1056,6 +1104,7 @@ def reset_weekref_cost_state():
|
|
|
1056
1104
|
possibly WITHOUT lowering ``MAX(id)``, so the reconcile's max-id-regression
|
|
1057
1105
|
check cannot catch it — the explicit clear must) and as a test hook.
|
|
1058
1106
|
"""
|
|
1107
|
+
_assert_owner()
|
|
1059
1108
|
_WEEKREF_COST_CACHE.clear()
|
|
1060
1109
|
_WEEKREF_COST_LAST_SEEN.clear()
|
|
1061
1110
|
|
|
@@ -1116,6 +1165,7 @@ def reconcile_weekref_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset
|
|
|
1116
1165
|
``max_mutation_seq > last_seen_seq`` branch; the caller passes a short-lived
|
|
1117
1166
|
cache connection, opened for that query.
|
|
1118
1167
|
"""
|
|
1168
|
+
_assert_owner()
|
|
1119
1169
|
ls = _WEEKREF_COST_LAST_SEEN
|
|
1120
1170
|
if not ls: # cold
|
|
1121
1171
|
ls["max_id"] = max_entry_id
|
|
@@ -1207,6 +1257,7 @@ def reset_projects_env_state():
|
|
|
1207
1257
|
current-week slot rides this same clear (spec §20): a prune can re-key a
|
|
1208
1258
|
current-week bucket_path, so it must cold-refold next tick.
|
|
1209
1259
|
"""
|
|
1260
|
+
_assert_owner()
|
|
1210
1261
|
_PROJECTS_ENV_WEEK_CACHE.clear()
|
|
1211
1262
|
_PROJECTS_ENV_WEEK_TOTALS.clear()
|
|
1212
1263
|
_PROJECTS_ENV_LAST_SEEN.clear()
|
|
@@ -1222,6 +1273,7 @@ def reset_projects_env_current_state():
|
|
|
1222
1273
|
/ prune / cache.db rebuild cold-refolds the current week the same tick the
|
|
1223
1274
|
closed-week cache clears.
|
|
1224
1275
|
"""
|
|
1276
|
+
_assert_owner()
|
|
1225
1277
|
_PROJECTS_ENV_CURRENT["state"] = None
|
|
1226
1278
|
|
|
1227
1279
|
|
|
@@ -1283,6 +1335,7 @@ def projects_env_week_put(week_iso, buckets_by_bp, total) -> None:
|
|
|
1283
1335
|
computed-empty week is a HIT (not a perpetual miss). Values are stored
|
|
1284
1336
|
as-is (immutable); this never mutates a stored aggregate in place.
|
|
1285
1337
|
"""
|
|
1338
|
+
_assert_owner()
|
|
1286
1339
|
_PROJECTS_ENV_WEEK_TOTALS[week_iso] = total
|
|
1287
1340
|
for bp, agg in buckets_by_bp.items():
|
|
1288
1341
|
_PROJECTS_ENV_WEEK_CACHE[(bp, week_iso)] = agg
|
|
@@ -1354,6 +1407,7 @@ def accumulate_projects_current_week(*, week_key, cur_max_id, cur_max_seq,
|
|
|
1354
1407
|
snapshot — ``finalize`` builds a FRESH bucket map each tick, so mutating
|
|
1355
1408
|
``mut`` next tick cannot tear a prior snapshot.
|
|
1356
1409
|
"""
|
|
1410
|
+
_assert_owner()
|
|
1357
1411
|
prior = _PROJECTS_ENV_CURRENT["state"]
|
|
1358
1412
|
cold = (
|
|
1359
1413
|
prior is None
|
|
@@ -1439,6 +1493,7 @@ def reconcile_projects_env_cache(cache_conn, *, max_entry_id, max_mutation_seq,
|
|
|
1439
1493
|
watermark query). The short-lived ``cache_conn`` is used only for the
|
|
1440
1494
|
watermark query on the ``max_mutation_seq > last_seen_seq`` branch (Codex-4).
|
|
1441
1495
|
"""
|
|
1496
|
+
_assert_owner()
|
|
1442
1497
|
ls = _PROJECTS_ENV_LAST_SEEN
|
|
1443
1498
|
if not ls: # cold
|
|
1444
1499
|
ls["max_id"] = max_entry_id
|
|
@@ -1574,6 +1629,7 @@ def reset_bugk_segment_state():
|
|
|
1574
1629
|
possibly WITHOUT lowering ``MAX(id)``, so the reconcile's max-id-regression
|
|
1575
1630
|
check cannot catch it — the explicit clear must) and as a test hook.
|
|
1576
1631
|
"""
|
|
1632
|
+
_assert_owner()
|
|
1577
1633
|
_BUGK_SEGMENT_CACHE.clear()
|
|
1578
1634
|
_BUGK_SEGMENT_LAST_SEEN.clear()
|
|
1579
1635
|
|
|
@@ -1630,6 +1686,7 @@ def reconcile_bugk_cache(cache_conn, *, max_entry_id, max_mutation_seq, reset_si
|
|
|
1630
1686
|
``changed_min_timestamp`` query on the ``max_mutation_seq > last_seen_seq``
|
|
1631
1687
|
branch (Codex-4 lifecycle from §4).
|
|
1632
1688
|
"""
|
|
1689
|
+
_assert_owner()
|
|
1633
1690
|
ls = _BUGK_SEGMENT_LAST_SEEN
|
|
1634
1691
|
if not ls: # cold
|
|
1635
1692
|
ls["max_id"] = max_entry_id
|
|
@@ -1682,6 +1739,7 @@ def reset_cache_report_state():
|
|
|
1682
1739
|
as a test hook. Mirrors ``reset_bugk_segment_state`` /
|
|
1683
1740
|
``reset_projects_env_state``.
|
|
1684
1741
|
"""
|
|
1742
|
+
_assert_owner()
|
|
1685
1743
|
_CACHE_REPORT_DAY_CACHE.clear()
|
|
1686
1744
|
_CACHE_REPORT_LAST_SEEN.clear()
|
|
1687
1745
|
|
|
@@ -1693,6 +1751,7 @@ def cache_report_day_get(date_key):
|
|
|
1693
1751
|
|
|
1694
1752
|
def cache_report_day_store(date_key, value) -> None:
|
|
1695
1753
|
"""Store one CLOSED day's immutable ``CachedCacheReportDay`` (never mutated)."""
|
|
1754
|
+
_assert_owner()
|
|
1696
1755
|
_CACHE_REPORT_DAY_CACHE[date_key] = value
|
|
1697
1756
|
|
|
1698
1757
|
|
|
@@ -1711,6 +1770,7 @@ def cache_report_day_evict_before(oldest_key) -> None:
|
|
|
1711
1770
|
byte-safe (a re-needed day just re-populates on the next cold tick), so the
|
|
1712
1771
|
predicate is a plain lexical ``<`` on the ``YYYY-MM-DD`` keys.
|
|
1713
1772
|
"""
|
|
1773
|
+
_assert_owner()
|
|
1714
1774
|
for date_key in [d for d in _CACHE_REPORT_DAY_CACHE if d < oldest_key]:
|
|
1715
1775
|
del _CACHE_REPORT_DAY_CACHE[date_key]
|
|
1716
1776
|
|
|
@@ -1762,6 +1822,7 @@ def reconcile_cache_report_cache(
|
|
|
1762
1822
|
same-signature second call sees ``max_mutation_seq == last_seen_seq`` and
|
|
1763
1823
|
no sig delta → no watermark re-query, no eviction.
|
|
1764
1824
|
"""
|
|
1825
|
+
_assert_owner()
|
|
1765
1826
|
ls = _CACHE_REPORT_LAST_SEEN
|
|
1766
1827
|
if not ls: # cold
|
|
1767
1828
|
ls.update(
|
|
@@ -55,6 +55,29 @@ def is_loopback(host: str) -> bool:
|
|
|
55
55
|
return False
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def debug_backend_allowed(peer_ip, host) -> bool:
|
|
59
|
+
"""Gate for the loopback-only ``/api/debug/backend`` diagnostic endpoint
|
|
60
|
+
(issue #276, Session A).
|
|
61
|
+
|
|
62
|
+
STRICTER than the transcript gate — it never opens to the LAN. Two checks:
|
|
63
|
+
|
|
64
|
+
* PRIMARY: the TCP peer (``client_address[0]``) must be a loopback
|
|
65
|
+
literal. This is the unspoofable signal — the dashboard can bind
|
|
66
|
+
``0.0.0.0`` (``dashboard.bind = lan``), and a ``Host``-header-only
|
|
67
|
+
check would let a LAN client connect to the LAN socket while sending
|
|
68
|
+
``Host: 127.0.0.1``. The peer address cannot be spoofed that way.
|
|
69
|
+
* DEFENSE-IN-DEPTH: the ``Host`` authority must ALSO be an IP-literal
|
|
70
|
+
loopback (anti-DNS-rebinding) — a hostname ``Host`` (a rebinding
|
|
71
|
+
vector) is rejected even from a loopback peer.
|
|
72
|
+
|
|
73
|
+
``dashboard.expose_transcripts`` is NEVER consulted — this surface is
|
|
74
|
+
loopback-only, ALWAYS. Fail closed on a missing/empty peer or Host.
|
|
75
|
+
"""
|
|
76
|
+
if not is_loopback(peer_ip):
|
|
77
|
+
return False
|
|
78
|
+
return is_loopback(authority_host(host))
|
|
79
|
+
|
|
80
|
+
|
|
58
81
|
def transcripts_allowed(bind_host, expose: bool) -> bool:
|
|
59
82
|
"""Are transcripts served AT ALL on this bind? Loopback bind always; a
|
|
60
83
|
non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
|
package/bin/cctally
CHANGED
|
@@ -141,6 +141,7 @@ HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0
|
|
|
141
141
|
SETUP_SYMLINK_NAMES = (
|
|
142
142
|
"cctally",
|
|
143
143
|
"cctally-alerts",
|
|
144
|
+
"cctally-budget",
|
|
144
145
|
"cctally-dashboard",
|
|
145
146
|
"cctally-dollar-per-percent",
|
|
146
147
|
"cctally-five-hour-blocks",
|
|
@@ -292,6 +293,7 @@ classify_coverage = _lib_pricing_check.classify_coverage
|
|
|
292
293
|
scope_litellm = _lib_pricing_check.scope_litellm
|
|
293
294
|
diff_pricing = _lib_pricing_check.diff_pricing
|
|
294
295
|
stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
|
|
296
|
+
expired_allowlist_entries = _lib_pricing_check.expired_allowlist_entries
|
|
295
297
|
check_table_shapes = _lib_pricing_check.check_table_shapes
|
|
296
298
|
pricing_issue_action = _lib_pricing_check.pricing_issue_action
|
|
297
299
|
CoverageGap = _lib_pricing_check.CoverageGap
|
|
@@ -435,6 +437,17 @@ _parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
|
|
|
435
437
|
_lib_statusline = _load_sibling("_lib_statusline")
|
|
436
438
|
STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS
|
|
437
439
|
|
|
440
|
+
# #279 S2 F2: stdlib-logging chokepoint (CCTALLY_DEBUG tracebacks on the
|
|
441
|
+
# top-level catch-all; adopted by the dashboard log_error surface).
|
|
442
|
+
_lib_log = _load_sibling("_lib_log")
|
|
443
|
+
|
|
444
|
+
# #279 S6 W1: pure JSON wire-format kernel — the schemaVersion stamp helper
|
|
445
|
+
# (single chokepoint for the additive camelCase envelope across reporting
|
|
446
|
+
# --json surfaces) + the canonical _iso_z (bound in W4/Task 9, not here).
|
|
447
|
+
# Convention: docs/cli-contract.md.
|
|
448
|
+
_lib_json_envelope = _load_sibling("_lib_json_envelope")
|
|
449
|
+
stamp_schema_version = _lib_json_envelope.stamp_schema_version
|
|
450
|
+
|
|
438
451
|
_lib_changelog = _load_sibling("_lib_changelog")
|
|
439
452
|
# Backward-compat re-export: callers and tests reach the helper through
|
|
440
453
|
# ``cctally._release_read_latest_release_version`` (incl. monkeypatch
|
|
@@ -790,6 +803,7 @@ _MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
|
|
|
790
803
|
Migration = _cctally_db.Migration
|
|
791
804
|
DowngradeDetected = _cctally_db.DowngradeDetected
|
|
792
805
|
ProdMigrationRefused = _cctally_db.ProdMigrationRefused
|
|
806
|
+
StatsDbCorruptError = _cctally_db.StatsDbCorruptError
|
|
793
807
|
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
|
|
794
808
|
_CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
|
|
795
809
|
_make_migration_decorator = _cctally_db._make_migration_decorator
|
|
@@ -1107,7 +1121,8 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
|
|
|
1107
1121
|
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
1108
1122
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
1109
1123
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
1110
|
-
_iso_z
|
|
1124
|
+
# _iso_z is NOT bound here anymore — the former dashboard-then-forecast
|
|
1125
|
+
# double-bind collapses to a single canonical bind below (#279 S6 W4).
|
|
1111
1126
|
# Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
|
|
1112
1127
|
# Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
|
|
1113
1128
|
# can reach the dashboard sibling's aggregator via `c = _cctally()`.
|
|
@@ -1201,9 +1216,7 @@ cmd_range_cost = _cctally_reporting.cmd_range_cost # parser: c.cmd_range_cost
|
|
|
1201
1216
|
# are the parser's set_defaults(func=c.cmd_*) targets; the forecast cluster is
|
|
1202
1217
|
# reached by TUI/dashboard/refresh/view_models via shim/accessor, and tests
|
|
1203
1218
|
# retrieve any of these off the cctally namespace. So EVERY moved symbol lives
|
|
1204
|
-
# here.
|
|
1205
|
-
# binding so cctally._iso_z stays the FORECAST version (the dt-only variant
|
|
1206
|
-
# _lib_diff_kernel + _cctally_five_hour depend on). This block MUST be after L943.
|
|
1219
|
+
# here.
|
|
1207
1220
|
_cctally_forecast = _load_sibling("_cctally_forecast")
|
|
1208
1221
|
cmd_report = _cctally_forecast.cmd_report
|
|
1209
1222
|
cmd_forecast = _cctally_forecast.cmd_forecast
|
|
@@ -1252,8 +1265,12 @@ _budget_emit_json = _cctally_forecast._budget_emit_json
|
|
|
1252
1265
|
_build_budget_snapshot = _cctally_forecast._build_budget_snapshot
|
|
1253
1266
|
_build_budget_no_data_snapshot = _cctally_forecast._build_budget_no_data_snapshot
|
|
1254
1267
|
_build_budget_no_budget_snapshot = _cctally_forecast._build_budget_no_budget_snapshot
|
|
1255
|
-
#
|
|
1256
|
-
|
|
1268
|
+
# #279 S6 W4: single canonical _iso_z bind (None-safe seconds-precision UTC-Z),
|
|
1269
|
+
# placed where the old forecast-wins override sat so the _lib_diff_kernel /
|
|
1270
|
+
# _cctally_five_hour shim lookups resolve to the same object. forecast and the
|
|
1271
|
+
# dashboard envelope now alias this same canonical; doctor keeps its divergent
|
|
1272
|
+
# name-shared copy.
|
|
1273
|
+
_iso_z = _lib_json_envelope._iso_z
|
|
1257
1274
|
|
|
1258
1275
|
|
|
1259
1276
|
RELEASE_HEADER_RE = re.compile(
|
|
@@ -2801,15 +2818,42 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2801
2818
|
_print_migration_error_banner_if_needed(args)
|
|
2802
2819
|
try:
|
|
2803
2820
|
rc = int(args.func(args))
|
|
2821
|
+
# Flush now, inside the guard, so a buffered broken pipe (small output
|
|
2822
|
+
# that never filled the pipe buffer, e.g. `cctally daily | head`)
|
|
2823
|
+
# surfaces HERE and is handled below — not as an "Exception ignored in
|
|
2824
|
+
# sys.stdout" at interpreter shutdown (CPython exit code 120). #279 S1 F5.
|
|
2825
|
+
sys.stdout.flush()
|
|
2804
2826
|
except KeyboardInterrupt:
|
|
2805
2827
|
return 130
|
|
2828
|
+
except BrokenPipeError:
|
|
2829
|
+
# Reader went away (`cctally daily | head`). Not an error: redirect
|
|
2830
|
+
# stdout to /dev/null so the interpreter's shutdown-flush doesn't emit
|
|
2831
|
+
# an "Exception ignored" line, then exit 0 immediately — skipping the
|
|
2832
|
+
# post-command update hooks (stdout is dead, banners are pointless).
|
|
2833
|
+
# Mirrors the KeyboardInterrupt immediate-return precedent. #279 S1 F5.
|
|
2834
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
2835
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
2836
|
+
return 0
|
|
2806
2837
|
except DowngradeDetected as exc:
|
|
2807
2838
|
eprint(f"cctally: {exc}")
|
|
2808
2839
|
return 2
|
|
2809
2840
|
except ProdMigrationRefused as exc:
|
|
2810
2841
|
eprint(f"{exc}") # message already carries the 'cctally:' prefix
|
|
2811
2842
|
return 2
|
|
2843
|
+
except StatsDbCorruptError as exc:
|
|
2844
|
+
# #279 S1 F4: corrupt stats.db (non-re-derivable) — one-line diagnosis
|
|
2845
|
+
# + recovery guidance, exit 2. Caught before the generic handler so it
|
|
2846
|
+
# never renders as a raw traceback.
|
|
2847
|
+
eprint(f"cctally: {exc}")
|
|
2848
|
+
return 2
|
|
2812
2849
|
except Exception as exc: # pragma: no cover
|
|
2850
|
+
# #279 S2 F2: CCTALLY_DEBUG=1 surfaces the full traceback on stderr
|
|
2851
|
+
# via the _lib_log chokepoint; default mode stays byte-identical.
|
|
2852
|
+
if _lib_log.debug_enabled():
|
|
2853
|
+
_lib_log.get_logger("cli").debug(
|
|
2854
|
+
"unhandled exception in %r",
|
|
2855
|
+
getattr(args, "command", None), exc_info=True,
|
|
2856
|
+
)
|
|
2813
2857
|
eprint(f"Error: {exc}")
|
|
2814
2858
|
rc = 1
|
|
2815
2859
|
# Post-command update hooks (spec §4.2). Best-effort: any exception
|
|
@@ -2868,7 +2912,7 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
2868
2912
|
otherwise races that detached writer re-creating the dir after the
|
|
2869
2913
|
command returns. Setting this env var makes such harnesses
|
|
2870
2914
|
deterministic without disabling the feature for real users."""
|
|
2871
|
-
if
|
|
2915
|
+
if _cctally_core._truthy_env("CCTALLY_DISABLE_UPDATE_CHECK"):
|
|
2872
2916
|
return
|
|
2873
2917
|
if command == "setup" and getattr(args, "uninstall", False):
|
|
2874
2918
|
return
|
package/bin/cctally-dashboard
CHANGED