cctally 1.65.0 → 1.67.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 +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -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_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +13 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
|
@@ -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
|
|
@@ -240,7 +245,14 @@ def _render_title_banner(title: str, *, unicode_ok: bool, color: bool) -> str:
|
|
|
240
245
|
|
|
241
246
|
|
|
242
247
|
def _fmt_block_time_local(ts: dt.datetime, tz: "ZoneInfo | None") -> str:
|
|
243
|
-
"""Block-start timestamp in display tz, ccusage `toLocaleString`-style.
|
|
248
|
+
"""Block-start timestamp in display tz, ccusage `toLocaleString`-style.
|
|
249
|
+
|
|
250
|
+
``ts`` is always a 5h-block boundary (start_time / end_time — every
|
|
251
|
+
caller passes one), so it is rounded to the nearest 10-minute boundary
|
|
252
|
+
to normalize Anthropic's reset-capture jitter (e.g. 4:39:59 → 4:40:00)
|
|
253
|
+
before rendering.
|
|
254
|
+
"""
|
|
255
|
+
ts = _cctally()._round_to_ten_minutes(ts)
|
|
244
256
|
local = ts.astimezone(tz)
|
|
245
257
|
hour_12 = local.hour % 12 or 12
|
|
246
258
|
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
@@ -812,7 +824,8 @@ def _bucket_to_json(
|
|
|
812
824
|
"""
|
|
813
825
|
bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
|
|
814
826
|
totals = _bucket_totals_dict(buckets)
|
|
815
|
-
|
|
827
|
+
payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
|
|
828
|
+
return json.dumps(payload, indent=2)
|
|
816
829
|
|
|
817
830
|
|
|
818
831
|
def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
|
|
@@ -825,10 +838,9 @@ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str
|
|
|
825
838
|
for label, buckets in project_groups:
|
|
826
839
|
projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
|
|
827
840
|
all_buckets.extend(buckets)
|
|
828
|
-
|
|
829
|
-
{"projects": projects, "totals": _bucket_totals_dict(all_buckets)}
|
|
830
|
-
|
|
831
|
-
)
|
|
841
|
+
payload = stamp_schema_version(
|
|
842
|
+
{"projects": projects, "totals": _bucket_totals_dict(all_buckets)})
|
|
843
|
+
return json.dumps(payload, indent=2)
|
|
832
844
|
|
|
833
845
|
|
|
834
846
|
def _weekly_to_json(
|
|
@@ -912,7 +924,7 @@ def _weekly_to_json(
|
|
|
912
924
|
"totalCost": tot_cost,
|
|
913
925
|
},
|
|
914
926
|
}
|
|
915
|
-
return json.dumps(payload, indent=2)
|
|
927
|
+
return json.dumps(stamp_schema_version(payload), indent=2)
|
|
916
928
|
|
|
917
929
|
|
|
918
930
|
def _daily_compact_split(bucket: str) -> str:
|
|
@@ -976,7 +988,8 @@ def _emit_codex_no_data(args: argparse.Namespace, list_key: str) -> None:
|
|
|
976
988
|
filter_applied = bool(getattr(args, "since", None) or getattr(args, "until", None))
|
|
977
989
|
if getattr(args, "json", False):
|
|
978
990
|
# Compact separators to match Node's `JSON.stringify(obj)` output exactly.
|
|
979
|
-
print(json.dumps({list_key: [], "totals": None},
|
|
991
|
+
print(json.dumps(stamp_schema_version({list_key: [], "totals": None}),
|
|
992
|
+
separators=(",", ":")))
|
|
980
993
|
else:
|
|
981
994
|
if filter_applied:
|
|
982
995
|
print("No Codex usage data found for provided filters.")
|
|
@@ -1056,7 +1069,8 @@ def _codex_bucket_to_json(
|
|
|
1056
1069
|
"totalTokens": tot_tokens,
|
|
1057
1070
|
"costUSD": tot_cost,
|
|
1058
1071
|
}
|
|
1059
|
-
|
|
1072
|
+
payload = stamp_schema_version({list_key: bucket_list, "totals": totals})
|
|
1073
|
+
return json.dumps(payload, indent=2)
|
|
1060
1074
|
|
|
1061
1075
|
|
|
1062
1076
|
def _codex_root_short_labels(roots: list[str]) -> list[str]:
|
|
@@ -1161,7 +1175,8 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
1161
1175
|
"totalTokens": tot_tokens,
|
|
1162
1176
|
"costUSD": tot_cost,
|
|
1163
1177
|
}
|
|
1164
|
-
|
|
1178
|
+
payload = stamp_schema_version({"sessions": session_list, "totals": totals})
|
|
1179
|
+
return json.dumps(payload, indent=2)
|
|
1165
1180
|
|
|
1166
1181
|
|
|
1167
1182
|
def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
@@ -1227,7 +1242,7 @@ def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
|
1227
1242
|
"totalCost": tot_cost,
|
|
1228
1243
|
},
|
|
1229
1244
|
}
|
|
1230
|
-
return json.dumps(payload, indent=2)
|
|
1245
|
+
return json.dumps(stamp_schema_version(payload), indent=2)
|
|
1231
1246
|
|
|
1232
1247
|
|
|
1233
1248
|
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(
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -1329,8 +1329,14 @@ def build_blocks_view(
|
|
|
1329
1329
|
per_model.items(), key=lambda kv: -kv[1],
|
|
1330
1330
|
)
|
|
1331
1331
|
]
|
|
1332
|
+
# Round the DISPLAYED start to the nearest 10-min boundary
|
|
1333
|
+
# (reset-jitter normalization); ``start_at`` / ``end_at``
|
|
1334
|
+
# below stay exact — they key the React row and the
|
|
1335
|
+
# ``/api/block/:start_at`` lookup, which matches by exact
|
|
1336
|
+
# equality (issue #76).
|
|
1332
1337
|
local_label = c.format_display_dt(
|
|
1333
|
-
b.start_time,
|
|
1338
|
+
c._round_to_ten_minutes(b.start_time),
|
|
1339
|
+
display_tz, fmt="%H:%M %b %d", suffix=True,
|
|
1334
1340
|
)
|
|
1335
1341
|
rows.append(BlocksPanelRow(
|
|
1336
1342
|
start_at=b.start_time.astimezone(dt.timezone.utc).isoformat(),
|
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",
|
|
@@ -150,6 +151,7 @@ SETUP_SYMLINK_NAMES = (
|
|
|
150
151
|
"cctally-refresh-usage",
|
|
151
152
|
"cctally-statusline",
|
|
152
153
|
"cctally-sync-week",
|
|
154
|
+
"cctally-transcript",
|
|
153
155
|
"cctally-tui",
|
|
154
156
|
"cctally-update",
|
|
155
157
|
)
|
|
@@ -213,6 +215,7 @@ _canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
|
|
|
213
215
|
make_week_ref = _cctally_core.make_week_ref
|
|
214
216
|
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
|
|
215
217
|
_reset_aware_floor = _cctally_core._reset_aware_floor
|
|
218
|
+
_floored_week_max = _cctally_core._floored_week_max
|
|
216
219
|
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
|
|
217
220
|
# Re-exported so the telemetry kernel's ``c._is_dev_checkout()`` accessor
|
|
218
221
|
# call resolves on the ``cctally`` module (and tests can monkeypatch it
|
|
@@ -292,6 +295,7 @@ classify_coverage = _lib_pricing_check.classify_coverage
|
|
|
292
295
|
scope_litellm = _lib_pricing_check.scope_litellm
|
|
293
296
|
diff_pricing = _lib_pricing_check.diff_pricing
|
|
294
297
|
stale_allowlist_entries = _lib_pricing_check.stale_allowlist_entries
|
|
298
|
+
expired_allowlist_entries = _lib_pricing_check.expired_allowlist_entries
|
|
295
299
|
check_table_shapes = _lib_pricing_check.check_table_shapes
|
|
296
300
|
pricing_issue_action = _lib_pricing_check.pricing_issue_action
|
|
297
301
|
CoverageGap = _lib_pricing_check.CoverageGap
|
|
@@ -402,6 +406,7 @@ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
|
|
|
402
406
|
_lib_five_hour = _load_sibling("_lib_five_hour")
|
|
403
407
|
_FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
404
408
|
_floor_to_ten_minutes = _lib_five_hour._floor_to_ten_minutes
|
|
409
|
+
_round_to_ten_minutes = _lib_five_hour._round_to_ten_minutes
|
|
405
410
|
_canonical_5h_window_key = _lib_five_hour._canonical_5h_window_key
|
|
406
411
|
|
|
407
412
|
_lib_subscription_weeks = _load_sibling("_lib_subscription_weeks")
|
|
@@ -435,6 +440,17 @@ _parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
|
|
|
435
440
|
_lib_statusline = _load_sibling("_lib_statusline")
|
|
436
441
|
STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS
|
|
437
442
|
|
|
443
|
+
# #279 S2 F2: stdlib-logging chokepoint (CCTALLY_DEBUG tracebacks on the
|
|
444
|
+
# top-level catch-all; adopted by the dashboard log_error surface).
|
|
445
|
+
_lib_log = _load_sibling("_lib_log")
|
|
446
|
+
|
|
447
|
+
# #279 S6 W1: pure JSON wire-format kernel — the schemaVersion stamp helper
|
|
448
|
+
# (single chokepoint for the additive camelCase envelope across reporting
|
|
449
|
+
# --json surfaces) + the canonical _iso_z (bound in W4/Task 9, not here).
|
|
450
|
+
# Convention: docs/cli-contract.md.
|
|
451
|
+
_lib_json_envelope = _load_sibling("_lib_json_envelope")
|
|
452
|
+
stamp_schema_version = _lib_json_envelope.stamp_schema_version
|
|
453
|
+
|
|
438
454
|
_lib_changelog = _load_sibling("_lib_changelog")
|
|
439
455
|
# Backward-compat re-export: callers and tests reach the helper through
|
|
440
456
|
# ``cctally._release_read_latest_release_version`` (incl. monkeypatch
|
|
@@ -790,6 +806,7 @@ _MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
|
|
|
790
806
|
Migration = _cctally_db.Migration
|
|
791
807
|
DowngradeDetected = _cctally_db.DowngradeDetected
|
|
792
808
|
ProdMigrationRefused = _cctally_db.ProdMigrationRefused
|
|
809
|
+
StatsDbCorruptError = _cctally_db.StatsDbCorruptError
|
|
793
810
|
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
|
|
794
811
|
_CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
|
|
795
812
|
_make_migration_decorator = _cctally_db._make_migration_decorator
|
|
@@ -1107,7 +1124,8 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
|
|
|
1107
1124
|
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
1108
1125
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
1109
1126
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
1110
|
-
_iso_z
|
|
1127
|
+
# _iso_z is NOT bound here anymore — the former dashboard-then-forecast
|
|
1128
|
+
# double-bind collapses to a single canonical bind below (#279 S6 W4).
|
|
1111
1129
|
# Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
|
|
1112
1130
|
# Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
|
|
1113
1131
|
# can reach the dashboard sibling's aggregator via `c = _cctally()`.
|
|
@@ -1201,9 +1219,7 @@ cmd_range_cost = _cctally_reporting.cmd_range_cost # parser: c.cmd_range_cost
|
|
|
1201
1219
|
# are the parser's set_defaults(func=c.cmd_*) targets; the forecast cluster is
|
|
1202
1220
|
# reached by TUI/dashboard/refresh/view_models via shim/accessor, and tests
|
|
1203
1221
|
# 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.
|
|
1222
|
+
# here.
|
|
1207
1223
|
_cctally_forecast = _load_sibling("_cctally_forecast")
|
|
1208
1224
|
cmd_report = _cctally_forecast.cmd_report
|
|
1209
1225
|
cmd_forecast = _cctally_forecast.cmd_forecast
|
|
@@ -1252,8 +1268,12 @@ _budget_emit_json = _cctally_forecast._budget_emit_json
|
|
|
1252
1268
|
_build_budget_snapshot = _cctally_forecast._build_budget_snapshot
|
|
1253
1269
|
_build_budget_no_data_snapshot = _cctally_forecast._build_budget_no_data_snapshot
|
|
1254
1270
|
_build_budget_no_budget_snapshot = _cctally_forecast._build_budget_no_budget_snapshot
|
|
1255
|
-
#
|
|
1256
|
-
|
|
1271
|
+
# #279 S6 W4: single canonical _iso_z bind (None-safe seconds-precision UTC-Z),
|
|
1272
|
+
# placed where the old forecast-wins override sat so the _lib_diff_kernel /
|
|
1273
|
+
# _cctally_five_hour shim lookups resolve to the same object. forecast and the
|
|
1274
|
+
# dashboard envelope now alias this same canonical; doctor keeps its divergent
|
|
1275
|
+
# name-shared copy.
|
|
1276
|
+
_iso_z = _lib_json_envelope._iso_z
|
|
1257
1277
|
|
|
1258
1278
|
|
|
1259
1279
|
RELEASE_HEADER_RE = re.compile(
|
|
@@ -2548,6 +2568,11 @@ _cctally_diff = _load_sibling("_cctally_diff")
|
|
|
2548
2568
|
cmd_diff = _cctally_diff.cmd_diff # parser: c.cmd_diff (_cctally_parser.py:2032)
|
|
2549
2569
|
_emit_diff_debug_samples = _cctally_diff._emit_diff_debug_samples # test_debug_sample_emission mod._emit_diff_debug_samples
|
|
2550
2570
|
|
|
2571
|
+
# #281 S4: the `transcript export|search` command module. Eager re-export so the
|
|
2572
|
+
# parser's set_defaults(func=c.cmd_transcript) resolves at build_parser time.
|
|
2573
|
+
_cctally_transcript = _load_sibling("_cctally_transcript")
|
|
2574
|
+
cmd_transcript = _cctally_transcript.cmd_transcript # parser: c.cmd_transcript
|
|
2575
|
+
|
|
2551
2576
|
|
|
2552
2577
|
_cctally_weekrefs = _load_sibling("_cctally_weekrefs")
|
|
2553
2578
|
_get_canonical_boundary_for_date = _cctally_weekrefs._get_canonical_boundary_for_date
|
|
@@ -2801,15 +2826,42 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2801
2826
|
_print_migration_error_banner_if_needed(args)
|
|
2802
2827
|
try:
|
|
2803
2828
|
rc = int(args.func(args))
|
|
2829
|
+
# Flush now, inside the guard, so a buffered broken pipe (small output
|
|
2830
|
+
# that never filled the pipe buffer, e.g. `cctally daily | head`)
|
|
2831
|
+
# surfaces HERE and is handled below — not as an "Exception ignored in
|
|
2832
|
+
# sys.stdout" at interpreter shutdown (CPython exit code 120). #279 S1 F5.
|
|
2833
|
+
sys.stdout.flush()
|
|
2804
2834
|
except KeyboardInterrupt:
|
|
2805
2835
|
return 130
|
|
2836
|
+
except BrokenPipeError:
|
|
2837
|
+
# Reader went away (`cctally daily | head`). Not an error: redirect
|
|
2838
|
+
# stdout to /dev/null so the interpreter's shutdown-flush doesn't emit
|
|
2839
|
+
# an "Exception ignored" line, then exit 0 immediately — skipping the
|
|
2840
|
+
# post-command update hooks (stdout is dead, banners are pointless).
|
|
2841
|
+
# Mirrors the KeyboardInterrupt immediate-return precedent. #279 S1 F5.
|
|
2842
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
2843
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
2844
|
+
return 0
|
|
2806
2845
|
except DowngradeDetected as exc:
|
|
2807
2846
|
eprint(f"cctally: {exc}")
|
|
2808
2847
|
return 2
|
|
2809
2848
|
except ProdMigrationRefused as exc:
|
|
2810
2849
|
eprint(f"{exc}") # message already carries the 'cctally:' prefix
|
|
2811
2850
|
return 2
|
|
2851
|
+
except StatsDbCorruptError as exc:
|
|
2852
|
+
# #279 S1 F4: corrupt stats.db (non-re-derivable) — one-line diagnosis
|
|
2853
|
+
# + recovery guidance, exit 2. Caught before the generic handler so it
|
|
2854
|
+
# never renders as a raw traceback.
|
|
2855
|
+
eprint(f"cctally: {exc}")
|
|
2856
|
+
return 2
|
|
2812
2857
|
except Exception as exc: # pragma: no cover
|
|
2858
|
+
# #279 S2 F2: CCTALLY_DEBUG=1 surfaces the full traceback on stderr
|
|
2859
|
+
# via the _lib_log chokepoint; default mode stays byte-identical.
|
|
2860
|
+
if _lib_log.debug_enabled():
|
|
2861
|
+
_lib_log.get_logger("cli").debug(
|
|
2862
|
+
"unhandled exception in %r",
|
|
2863
|
+
getattr(args, "command", None), exc_info=True,
|
|
2864
|
+
)
|
|
2813
2865
|
eprint(f"Error: {exc}")
|
|
2814
2866
|
rc = 1
|
|
2815
2867
|
# Post-command update hooks (spec §4.2). Best-effort: any exception
|
|
@@ -2868,7 +2920,7 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
2868
2920
|
otherwise races that detached writer re-creating the dir after the
|
|
2869
2921
|
command returns. Setting this env var makes such harnesses
|
|
2870
2922
|
deterministic without disabling the feature for real users."""
|
|
2871
|
-
if
|
|
2923
|
+
if _cctally_core._truthy_env("CCTALLY_DISABLE_UPDATE_CHECK"):
|
|
2872
2924
|
return
|
|
2873
2925
|
if command == "setup" and getattr(args, "uninstall", False):
|
|
2874
2926
|
return
|
package/bin/cctally-dashboard
CHANGED
package/bin/cctally-statusline
CHANGED