cctally 1.7.2 → 1.7.4
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 +27 -0
- package/bin/_cctally_alerts.py +12 -5
- package/bin/_cctally_cache.py +12 -11
- package/bin/_cctally_config.py +34 -19
- package/bin/_cctally_core.py +890 -0
- package/bin/_cctally_dashboard.py +104 -113
- package/bin/_cctally_db.py +271 -41
- package/bin/_cctally_record.py +516 -116
- package/bin/_cctally_refresh.py +35 -20
- package/bin/_cctally_setup.py +26 -16
- package/bin/_cctally_sync_week.py +21 -6
- package/bin/_cctally_tui.py +128 -52
- package/bin/_cctally_update.py +11 -16
- package/bin/_lib_aggregators.py +7 -1
- package/bin/_lib_diff_kernel.py +19 -21
- package/bin/_lib_render.py +20 -0
- package/bin/_lib_subscription_weeks.py +17 -9
- package/bin/cctally +191 -779
- package/dashboard/static/assets/index-DhCnIFq9.js +18 -0
- package/dashboard/static/assets/index-Dv5Dzag5.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BgpoazlS.js +0 -18
- package/dashboard/static/assets/index-nJdUaGys.css +0 -1
|
@@ -251,121 +251,55 @@ def _cctally():
|
|
|
251
251
|
return sys.modules["cctally"]
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
# === Honest imports from extracted homes ===================================
|
|
255
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
256
|
+
# import from _cctally_core; already-decentralized buckets (X = _lib_*,
|
|
257
|
+
# Y = _cctally_*) import from their natural home. These bypass the
|
|
258
|
+
# legacy shim pattern entirely.
|
|
259
|
+
from _cctally_core import (
|
|
260
|
+
eprint,
|
|
261
|
+
now_utc_iso,
|
|
262
|
+
parse_iso_datetime,
|
|
263
|
+
_now_utc,
|
|
264
|
+
_command_as_of,
|
|
265
|
+
open_db,
|
|
266
|
+
get_latest_usage_for_week,
|
|
267
|
+
make_week_ref,
|
|
268
|
+
_get_alerts_config,
|
|
269
|
+
_AlertsConfigError,
|
|
270
|
+
)
|
|
271
|
+
from _lib_display_tz import (
|
|
272
|
+
format_display_dt,
|
|
273
|
+
resolve_display_tz,
|
|
274
|
+
normalize_display_tz_value,
|
|
275
|
+
_compute_display_block,
|
|
276
|
+
)
|
|
277
|
+
from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
|
|
278
|
+
from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
|
|
279
|
+
from _lib_five_hour import _canonical_5h_window_key
|
|
280
|
+
from _lib_subscription_weeks import _compute_subscription_weeks
|
|
281
|
+
from _lib_blocks import _group_entries_into_blocks
|
|
282
|
+
from _cctally_config import save_config, _load_config_unlocked
|
|
283
|
+
from _cctally_db import _render_migration_error_banner
|
|
284
|
+
from _cctally_cache import get_entries
|
|
285
|
+
|
|
286
|
+
|
|
254
287
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
255
288
|
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
256
289
|
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
257
|
-
# code unchanged.
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def now_utc_iso(*args, **kwargs):
|
|
266
|
-
return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
270
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _now_utc(*args, **kwargs):
|
|
274
|
-
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def _command_as_of(*args, **kwargs):
|
|
278
|
-
return sys.modules["cctally"]._command_as_of(*args, **kwargs)
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
# code unchanged. `load_config` and `get_claude_session_entries` STAY as
|
|
291
|
+
# shims even though their natural homes are decentralized (_cctally_config
|
|
292
|
+
# / _cctally_cache) — tests monkeypatch them via `ns["X"]` (21 sites total,
|
|
293
|
+
# audited 2026-05-17); direct imports would silently bypass the patches.
|
|
294
|
+
# See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
|
|
281
295
|
def load_config(*args, **kwargs):
|
|
282
296
|
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
283
297
|
|
|
284
298
|
|
|
285
|
-
def save_config(*args, **kwargs):
|
|
286
|
-
return sys.modules["cctally"].save_config(*args, **kwargs)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def open_db(*args, **kwargs):
|
|
290
|
-
return sys.modules["cctally"].open_db(*args, **kwargs)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def get_entries(*args, **kwargs):
|
|
294
|
-
return sys.modules["cctally"].get_entries(*args, **kwargs)
|
|
295
|
-
|
|
296
|
-
|
|
297
299
|
def get_claude_session_entries(*args, **kwargs):
|
|
298
300
|
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
299
301
|
|
|
300
302
|
|
|
301
|
-
def get_latest_usage_for_week(*args, **kwargs):
|
|
302
|
-
return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def make_week_ref(*args, **kwargs):
|
|
306
|
-
return sys.modules["cctally"].make_week_ref(*args, **kwargs)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def format_display_dt(*args, **kwargs):
|
|
310
|
-
return sys.modules["cctally"].format_display_dt(*args, **kwargs)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def resolve_display_tz(*args, **kwargs):
|
|
314
|
-
return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def normalize_display_tz_value(*args, **kwargs):
|
|
318
|
-
return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def _compute_display_block(*args, **kwargs):
|
|
322
|
-
return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def _render_migration_error_banner(*args, **kwargs):
|
|
326
|
-
return sys.modules["cctally"]._render_migration_error_banner(*args, **kwargs)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _aggregate_daily(*args, **kwargs):
|
|
330
|
-
return sys.modules["cctally"]._aggregate_daily(*args, **kwargs)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def _aggregate_monthly(*args, **kwargs):
|
|
334
|
-
return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _aggregate_weekly(*args, **kwargs):
|
|
338
|
-
return sys.modules["cctally"]._aggregate_weekly(*args, **kwargs)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _calculate_entry_cost(*args, **kwargs):
|
|
342
|
-
return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def _canonical_5h_window_key(*args, **kwargs):
|
|
346
|
-
return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _chip_for_model(*args, **kwargs):
|
|
350
|
-
return sys.modules["cctally"]._chip_for_model(*args, **kwargs)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def _short_model_name(*args, **kwargs):
|
|
354
|
-
return sys.modules["cctally"]._short_model_name(*args, **kwargs)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def _compute_subscription_weeks(*args, **kwargs):
|
|
358
|
-
return sys.modules["cctally"]._compute_subscription_weeks(*args, **kwargs)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def _group_entries_into_blocks(*args, **kwargs):
|
|
362
|
-
return sys.modules["cctally"]._group_entries_into_blocks(*args, **kwargs)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def _get_alerts_config(*args, **kwargs):
|
|
366
|
-
return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
|
|
367
|
-
|
|
368
|
-
|
|
369
303
|
def _warn_alerts_bad_config_once(*args, **kwargs):
|
|
370
304
|
return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
|
|
371
305
|
|
|
@@ -402,10 +336,6 @@ def doctor_gather_state(*args, **kwargs):
|
|
|
402
336
|
return sys.modules["cctally"].doctor_gather_state(*args, **kwargs)
|
|
403
337
|
|
|
404
338
|
|
|
405
|
-
def _load_config_unlocked(*args, **kwargs):
|
|
406
|
-
return sys.modules["cctally"]._load_config_unlocked(*args, **kwargs)
|
|
407
|
-
|
|
408
|
-
|
|
409
339
|
def _apply_display_tz_override(*args, **kwargs):
|
|
410
340
|
return sys.modules["cctally"]._apply_display_tz_override(*args, **kwargs)
|
|
411
341
|
|
|
@@ -2490,7 +2420,7 @@ def _select_current_block_for_envelope(
|
|
|
2490
2420
|
|
|
2491
2421
|
block = conn.execute(
|
|
2492
2422
|
"""
|
|
2493
|
-
SELECT block_start_at, last_observed_at_utc,
|
|
2423
|
+
SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
|
|
2494
2424
|
seven_day_pct_at_block_start,
|
|
2495
2425
|
crossed_seven_day_reset
|
|
2496
2426
|
FROM five_hour_blocks
|
|
@@ -2539,11 +2469,41 @@ def _select_current_block_for_envelope(
|
|
|
2539
2469
|
None if (p_anchor is None or current_used_pct is None)
|
|
2540
2470
|
else round(current_used_pct - p_anchor, 9)
|
|
2541
2471
|
)
|
|
2472
|
+
|
|
2473
|
+
# Spec §5.3 — in-place credit events for this 5h block's window,
|
|
2474
|
+
# ascending by ``effective_reset_at_utc``. Drives the
|
|
2475
|
+
# ``CurrentWeekPanel.tsx`` ``⚡ credited -Xpp`` chip and the
|
|
2476
|
+
# ``CurrentWeekModal.tsx`` merged-stream 5h milestones section.
|
|
2477
|
+
# Snake_case keys to match the envelope convention (see CLAUDE.md;
|
|
2478
|
+
# CLI ``--json`` uses camelCase, dashboard envelope is snake_case).
|
|
2479
|
+
cred_rows = conn.execute(
|
|
2480
|
+
"""
|
|
2481
|
+
SELECT effective_reset_at_utc, prior_percent, post_percent
|
|
2482
|
+
FROM five_hour_reset_events
|
|
2483
|
+
WHERE five_hour_window_key = ?
|
|
2484
|
+
ORDER BY effective_reset_at_utc ASC
|
|
2485
|
+
""",
|
|
2486
|
+
(int(block["five_hour_window_key"]),),
|
|
2487
|
+
).fetchall()
|
|
2488
|
+
credits = [
|
|
2489
|
+
{
|
|
2490
|
+
"effective_reset_at_utc": c["effective_reset_at_utc"],
|
|
2491
|
+
"prior_percent": float(c["prior_percent"]),
|
|
2492
|
+
"post_percent": float(c["post_percent"]),
|
|
2493
|
+
"delta_pp": round(
|
|
2494
|
+
float(c["post_percent"]) - float(c["prior_percent"]), 1
|
|
2495
|
+
),
|
|
2496
|
+
}
|
|
2497
|
+
for c in cred_rows
|
|
2498
|
+
]
|
|
2499
|
+
|
|
2542
2500
|
return {
|
|
2543
2501
|
"block_start_at": block["block_start_at"],
|
|
2502
|
+
"five_hour_window_key": int(block["five_hour_window_key"]),
|
|
2544
2503
|
"seven_day_pct_at_block_start": p_start,
|
|
2545
2504
|
"seven_day_pct_delta_pp": delta,
|
|
2546
2505
|
"crossed_seven_day_reset": crossed,
|
|
2506
|
+
"credits": credits,
|
|
2547
2507
|
}
|
|
2548
2508
|
|
|
2549
2509
|
|
|
@@ -2603,13 +2563,29 @@ def _build_alerts_envelope_array(
|
|
|
2603
2563
|
"week_start_date": r["week_start_date"],
|
|
2604
2564
|
"cumulative_cost_usd": cumulative,
|
|
2605
2565
|
"dollars_per_percent": dpp,
|
|
2566
|
+
# Round-3: parallel to the 5h context block below — both
|
|
2567
|
+
# axes now expose ``reset_event_id`` so downstream
|
|
2568
|
+
# clients (panel, modal, third-party consumers) can
|
|
2569
|
+
# discriminate pre- vs post-credit crossings of the
|
|
2570
|
+
# same (week, threshold) without scraping the
|
|
2571
|
+
# envelope ``id`` string. 0 = pre-credit / no-event;
|
|
2572
|
+
# event.id = post-credit segment.
|
|
2573
|
+
"reset_event_id": int(r["reset_event_id"]),
|
|
2606
2574
|
},
|
|
2607
2575
|
})
|
|
2608
2576
|
|
|
2577
|
+
# Site F (spec §3.2 bucket C / §3.3): widen the row identity to
|
|
2578
|
+
# include ``reset_event_id`` so post-credit (seg=event.id) crossings
|
|
2579
|
+
# of the same (window_key, threshold) don't collide with pre-credit
|
|
2580
|
+
# (seg=0) crossings on the React row key. Older clients tolerate
|
|
2581
|
+
# longer ids — the id is opaque to them; only the React key
|
|
2582
|
+
# uniqueness invariant matters. Mirrors the weekly precedent at
|
|
2583
|
+
# line ~2597.
|
|
2609
2584
|
fh_rows = conn.execute(
|
|
2610
2585
|
"""
|
|
2611
2586
|
SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
|
|
2612
|
-
m.alerted_at, m.block_cost_usd,
|
|
2587
|
+
m.alerted_at, m.block_cost_usd, m.reset_event_id,
|
|
2588
|
+
b.block_start_at
|
|
2613
2589
|
FROM five_hour_milestones m
|
|
2614
2590
|
LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
|
|
2615
2591
|
WHERE m.alerted_at IS NOT NULL
|
|
@@ -2621,7 +2597,10 @@ def _build_alerts_envelope_array(
|
|
|
2621
2597
|
for r in fh_rows:
|
|
2622
2598
|
threshold = int(r["percent_threshold"])
|
|
2623
2599
|
out.append({
|
|
2624
|
-
"id":
|
|
2600
|
+
"id": (
|
|
2601
|
+
f"five_hour:{int(r['five_hour_window_key'])}:"
|
|
2602
|
+
f"{threshold}:{int(r['reset_event_id'])}"
|
|
2603
|
+
),
|
|
2625
2604
|
"axis": "five_hour",
|
|
2626
2605
|
"threshold": threshold,
|
|
2627
2606
|
"crossed_at": r["captured_at_utc"],
|
|
@@ -2630,6 +2609,7 @@ def _build_alerts_envelope_array(
|
|
|
2630
2609
|
"five_hour_window_key": int(r["five_hour_window_key"]),
|
|
2631
2610
|
"block_start_at": r["block_start_at"] or "",
|
|
2632
2611
|
"block_cost_usd": float(r["block_cost_usd"] or 0.0),
|
|
2612
|
+
"reset_event_id": int(r["reset_event_id"]),
|
|
2633
2613
|
},
|
|
2634
2614
|
})
|
|
2635
2615
|
|
|
@@ -2937,7 +2917,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2937
2917
|
alerts_array = list(getattr(snap, "alerts", []) or [])
|
|
2938
2918
|
try:
|
|
2939
2919
|
_alerts_cfg = _get_alerts_config(load_config())
|
|
2940
|
-
except
|
|
2920
|
+
except _AlertsConfigError as exc:
|
|
2941
2921
|
_warn_alerts_bad_config_once(exc)
|
|
2942
2922
|
_alerts_cfg = {
|
|
2943
2923
|
"enabled": False,
|
|
@@ -3065,6 +3045,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
3065
3045
|
}
|
|
3066
3046
|
for m in (snap.percent_milestones or [])
|
|
3067
3047
|
],
|
|
3048
|
+
# Spec §5.3 (Codex r1 finding 3) — NEW envelope key
|
|
3049
|
+
# parallel to ``milestones`` (which carries the WEEKLY
|
|
3050
|
+
# timeline). 5h-block milestones for the active block,
|
|
3051
|
+
# in capture-time order, both pre- and post-credit
|
|
3052
|
+
# segments included (bucket B per §3.2 — no
|
|
3053
|
+
# ``reset_event_id`` filter; the React layer renders
|
|
3054
|
+
# repeated thresholds as distinct rows keyed on
|
|
3055
|
+
# ``reset_event_id``). Empty list when no 5h block is
|
|
3056
|
+
# bound or the data source crashed during sync
|
|
3057
|
+
# (recorded on ``last_sync_error``).
|
|
3058
|
+
"five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
|
|
3068
3059
|
},
|
|
3069
3060
|
|
|
3070
3061
|
"forecast":
|
|
@@ -3721,7 +3712,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
3721
3712
|
# save_config has not yet been called).
|
|
3722
3713
|
try:
|
|
3723
3714
|
_get_alerts_config(merged)
|
|
3724
|
-
except
|
|
3715
|
+
except _AlertsConfigError as exc:
|
|
3725
3716
|
self._respond_json(400, {"error": str(exc)})
|
|
3726
3717
|
return
|
|
3727
3718
|
|
package/bin/_cctally_db.py
CHANGED
|
@@ -71,35 +71,32 @@ def _cctally():
|
|
|
71
71
|
return sys.modules["cctally"]
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
# === Honest imports from extracted homes ===================================
|
|
75
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
76
|
+
# import from _cctally_core. The legacy shim functions for these names
|
|
77
|
+
# are deleted.
|
|
78
|
+
from _cctally_core import (
|
|
79
|
+
eprint,
|
|
80
|
+
now_utc_iso,
|
|
81
|
+
parse_iso_datetime,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Module-level back-ref shim for the one Z-high callable that STAYS in
|
|
86
|
+
# bin/cctally. Resolves `sys.modules['cctally'].X` at CALL TIME (not
|
|
87
|
+
# bind time), so monkeypatches on cctally's namespace propagate into the
|
|
88
|
+
# moved code unchanged. `_compute_block_totals` is Z-high (reaches into
|
|
89
|
+
# _cctally_cache via get_claude_session_entries) and is explicitly listed
|
|
90
|
+
# in spec §3.7's stays-on-shim allowlist.
|
|
82
91
|
#
|
|
83
92
|
# Path constants and rarer helpers (`MIGRATION_ERROR_LOG_PATH`,
|
|
84
93
|
# `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `format_local_iso`) are
|
|
85
94
|
# accessed via the standard `c = _cctally()` + `c.X` pattern instead
|
|
86
95
|
# (call-time lookup so fixture-HOME redirects propagate).
|
|
87
|
-
def now_utc_iso(*args, **kwargs):
|
|
88
|
-
return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
92
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
93
|
-
|
|
94
|
-
|
|
95
96
|
def _compute_block_totals(*args, **kwargs):
|
|
96
97
|
return sys.modules["cctally"]._compute_block_totals(*args, **kwargs)
|
|
97
98
|
|
|
98
99
|
|
|
99
|
-
def eprint(*args, **kwargs):
|
|
100
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
101
|
-
|
|
102
|
-
|
|
103
100
|
# === BEGIN MOVED REGIONS ===
|
|
104
101
|
# Regions below are inserted verbatim from bin/cctally. Bare-name
|
|
105
102
|
# references to `now_utc_iso(...)`, `parse_iso_datetime(...)`,
|
|
@@ -1009,36 +1006,65 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
|
|
|
1009
1006
|
|
|
1010
1007
|
# (c) Milestones: per-threshold dedup, keep earliest
|
|
1011
1008
|
# captured_at_utc, re-FK keepers to canonical.
|
|
1009
|
+
#
|
|
1010
|
+
# Defensive widening (Codex r2 finding 1, spec §3.4): if
|
|
1011
|
+
# migration 006 has already landed and added ``reset_event_id``,
|
|
1012
|
+
# key the dedup on ``(percent_threshold, reset_event_id)`` so
|
|
1013
|
+
# we don't silently collapse legitimately distinct pre/post-
|
|
1014
|
+
# credit rows at the same physical threshold. On the legacy
|
|
1015
|
+
# upgrade path (column doesn't exist yet because 003 runs
|
|
1016
|
+
# before 006 in migration order), ``has_seg`` is False and the
|
|
1017
|
+
# dedup key collapses to ``(threshold, 0)`` — byte-identical
|
|
1018
|
+
# to the original threshold-only shape. PRAGMA probe rather
|
|
1019
|
+
# than version-detect so the path also covers operator
|
|
1020
|
+
# re-runs (e.g. ``cctally db unskip 003_*``) post-006.
|
|
1021
|
+
ms_cols = {
|
|
1022
|
+
str(r[1])
|
|
1023
|
+
for r in conn.execute(
|
|
1024
|
+
"PRAGMA table_info(five_hour_milestones)"
|
|
1025
|
+
).fetchall()
|
|
1026
|
+
}
|
|
1027
|
+
has_seg = "reset_event_id" in ms_cols
|
|
1012
1028
|
ms_id_placeholders = ",".join(
|
|
1013
1029
|
"?" * (len(dropped_ids) + 1)
|
|
1014
1030
|
)
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1031
|
+
if has_seg:
|
|
1032
|
+
all_milestones = conn.execute(
|
|
1033
|
+
f"SELECT id, percent_threshold, captured_at_utc, "
|
|
1034
|
+
f" reset_event_id "
|
|
1035
|
+
f" FROM five_hour_milestones "
|
|
1036
|
+
f" WHERE block_id IN ({ms_id_placeholders})",
|
|
1037
|
+
[canonical["id"], *dropped_ids],
|
|
1038
|
+
).fetchall()
|
|
1039
|
+
else:
|
|
1040
|
+
all_milestones = conn.execute(
|
|
1041
|
+
f"SELECT id, percent_threshold, captured_at_utc "
|
|
1042
|
+
f" FROM five_hour_milestones "
|
|
1043
|
+
f" WHERE block_id IN ({ms_id_placeholders})",
|
|
1044
|
+
[canonical["id"], *dropped_ids],
|
|
1045
|
+
).fetchall()
|
|
1046
|
+
by_key: dict[tuple[int, int], dict] = {}
|
|
1022
1047
|
for m in all_milestones:
|
|
1023
|
-
|
|
1048
|
+
seg = int(m["reset_event_id"]) if has_seg else 0
|
|
1049
|
+
key = (int(m["percent_threshold"]), seg)
|
|
1024
1050
|
md = dict(m)
|
|
1025
1051
|
if (
|
|
1026
|
-
|
|
1052
|
+
key not in by_key
|
|
1027
1053
|
or md["captured_at_utc"]
|
|
1028
|
-
<
|
|
1054
|
+
< by_key[key]["captured_at_utc"]
|
|
1029
1055
|
):
|
|
1030
|
-
|
|
1031
|
-
keep_ids = {m["id"] for m in
|
|
1056
|
+
by_key[key] = md
|
|
1057
|
+
keep_ids = {m["id"] for m in by_key.values()}
|
|
1032
1058
|
# DELETE non-keepers BEFORE rekeying keepers. Otherwise, when
|
|
1033
1059
|
# both canonical and a dropped block hold a milestone for the
|
|
1034
|
-
# same
|
|
1035
|
-
#
|
|
1036
|
-
#
|
|
1037
|
-
#
|
|
1038
|
-
# back the migration. After this DELETE the only
|
|
1039
|
-
# referencing dropped_keys are the keepers
|
|
1040
|
-
# (one per
|
|
1041
|
-
# free.
|
|
1060
|
+
# same physical key and the dropped row's milestone is the
|
|
1061
|
+
# earlier keeper, UPDATEing it to the canonical key collides
|
|
1062
|
+
# with canonical's still-present non-keeper on UNIQUE
|
|
1063
|
+
# (either the 2-col legacy shape or the 3-col post-006 shape),
|
|
1064
|
+
# rolling back the migration. After this DELETE the only
|
|
1065
|
+
# milestones referencing dropped_keys are the keepers
|
|
1066
|
+
# themselves (one per dedup key), so the UPDATE loop below is
|
|
1067
|
+
# collision-free.
|
|
1042
1068
|
non_keep_ids = [
|
|
1043
1069
|
m["id"] for m in all_milestones if m["id"] not in keep_ids
|
|
1044
1070
|
]
|
|
@@ -1049,7 +1075,7 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
|
|
|
1049
1075
|
f" WHERE id IN ({nk_placeholders})",
|
|
1050
1076
|
non_keep_ids,
|
|
1051
1077
|
)
|
|
1052
|
-
for m in
|
|
1078
|
+
for m in by_key.values():
|
|
1053
1079
|
conn.execute(
|
|
1054
1080
|
"UPDATE five_hour_milestones "
|
|
1055
1081
|
" SET block_id = ?, "
|
|
@@ -1437,6 +1463,210 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
|
|
|
1437
1463
|
raise
|
|
1438
1464
|
|
|
1439
1465
|
|
|
1466
|
+
@stats_migration("006_five_hour_milestones_reset_event_id")
|
|
1467
|
+
def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) -> None:
|
|
1468
|
+
"""Add ``reset_event_id`` to ``five_hour_milestones`` so post-credit
|
|
1469
|
+
threshold crossings can coexist with pre-credit ones for the same
|
|
1470
|
+
``(five_hour_window_key, percent_threshold)``.
|
|
1471
|
+
|
|
1472
|
+
Sentinel: ``0`` = pre-credit / no event. Existing rows backfill to
|
|
1473
|
+
``0`` via the ``DEFAULT 0`` clause on the new column.
|
|
1474
|
+
|
|
1475
|
+
The new UNIQUE constraint is
|
|
1476
|
+
``UNIQUE(five_hour_window_key, percent_threshold, reset_event_id)`` so
|
|
1477
|
+
the same (window_key, threshold) pair can land twice if a goodwill
|
|
1478
|
+
credit re-opens the segment under a fresh ``five_hour_reset_events.id``.
|
|
1479
|
+
SQLite can't ALTER a UNIQUE constraint in place — we use the
|
|
1480
|
+
rename-recreate-copy idiom (same as migration 005 did for
|
|
1481
|
+
``percent_milestones``).
|
|
1482
|
+
|
|
1483
|
+
Companion live-path edits land at (Task 2 of issue #43):
|
|
1484
|
+
- bin/_cctally_record.py — 5h milestone INSERT + alert paths
|
|
1485
|
+
(Sites A-E in spec §3.3); grep ``active_reset_event_id`` to
|
|
1486
|
+
locate (line numbers drift per ``gotcha_cited_line_numbers_stale``)
|
|
1487
|
+
- bin/_cctally_dashboard.py — alerts list row-identity widening
|
|
1488
|
+
(Site F in spec §3.3 — bucket C per spec §3.2's three-bucket model);
|
|
1489
|
+
grep ``reset_event_id`` near the 5h alerts SELECT
|
|
1490
|
+
|
|
1491
|
+
Idempotent: a second invocation finds the column already present and
|
|
1492
|
+
returns. Empty-table fast path: when the column is already present
|
|
1493
|
+
(fresh-install fast-stamp from the dispatcher because the live
|
|
1494
|
+
``CREATE TABLE IF NOT EXISTS five_hour_milestones`` already carries
|
|
1495
|
+
the new shape — REQUIRED for fresh-install correctness per spec §3.2),
|
|
1496
|
+
the marker still gets stamped — no schema edit needed.
|
|
1497
|
+
"""
|
|
1498
|
+
# Fast-path probe: column already present means a prior run of this
|
|
1499
|
+
# migration (or a fresh-install fast-stamp from the dispatcher that
|
|
1500
|
+
# already picked up the new live-schema CREATE TABLE) has done the
|
|
1501
|
+
# work. Just stamp the marker and return. The marker INSERT runs in
|
|
1502
|
+
# SQLite's implicit transaction (auto-opened by the write, closed by
|
|
1503
|
+
# ``commit()`` — same shape as migration 005's fast path); no explicit
|
|
1504
|
+
# ``BEGIN`` is needed for a single-statement DML.
|
|
1505
|
+
cols = {
|
|
1506
|
+
str(r[1])
|
|
1507
|
+
for r in conn.execute("PRAGMA table_info(five_hour_milestones)").fetchall()
|
|
1508
|
+
}
|
|
1509
|
+
if "reset_event_id" in cols:
|
|
1510
|
+
conn.execute(
|
|
1511
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1512
|
+
"VALUES (?, ?)",
|
|
1513
|
+
("006_five_hour_milestones_reset_event_id", now_utc_iso()),
|
|
1514
|
+
)
|
|
1515
|
+
conn.commit()
|
|
1516
|
+
return
|
|
1517
|
+
|
|
1518
|
+
conn.execute("BEGIN")
|
|
1519
|
+
try:
|
|
1520
|
+
# Add the column with sentinel 0 default (covers existing rows).
|
|
1521
|
+
conn.execute(
|
|
1522
|
+
"ALTER TABLE five_hour_milestones "
|
|
1523
|
+
"ADD COLUMN reset_event_id INTEGER NOT NULL DEFAULT 0"
|
|
1524
|
+
)
|
|
1525
|
+
# SQLite can't ALTER a UNIQUE constraint in place; rename, recreate
|
|
1526
|
+
# with the new 3-column UNIQUE, copy, drop. Preserves ids and every
|
|
1527
|
+
# existing column (including those added by add_column_if_missing:
|
|
1528
|
+
# alerted_at).
|
|
1529
|
+
conn.execute(
|
|
1530
|
+
"ALTER TABLE five_hour_milestones "
|
|
1531
|
+
"RENAME TO five_hour_milestones_old_006"
|
|
1532
|
+
)
|
|
1533
|
+
conn.execute(
|
|
1534
|
+
"""
|
|
1535
|
+
CREATE TABLE five_hour_milestones (
|
|
1536
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1537
|
+
block_id INTEGER NOT NULL,
|
|
1538
|
+
five_hour_window_key INTEGER NOT NULL,
|
|
1539
|
+
percent_threshold INTEGER NOT NULL,
|
|
1540
|
+
captured_at_utc TEXT NOT NULL,
|
|
1541
|
+
usage_snapshot_id INTEGER NOT NULL,
|
|
1542
|
+
block_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1543
|
+
block_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1544
|
+
block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1545
|
+
block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1546
|
+
block_cost_usd REAL NOT NULL DEFAULT 0,
|
|
1547
|
+
marginal_cost_usd REAL,
|
|
1548
|
+
seven_day_pct_at_crossing REAL,
|
|
1549
|
+
alerted_at TEXT,
|
|
1550
|
+
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
1551
|
+
UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
|
|
1552
|
+
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
1553
|
+
)
|
|
1554
|
+
"""
|
|
1555
|
+
)
|
|
1556
|
+
conn.execute(
|
|
1557
|
+
"""
|
|
1558
|
+
INSERT INTO five_hour_milestones (
|
|
1559
|
+
id, block_id, five_hour_window_key, percent_threshold,
|
|
1560
|
+
captured_at_utc, usage_snapshot_id,
|
|
1561
|
+
block_input_tokens, block_output_tokens,
|
|
1562
|
+
block_cache_create_tokens, block_cache_read_tokens,
|
|
1563
|
+
block_cost_usd, marginal_cost_usd,
|
|
1564
|
+
seven_day_pct_at_crossing, alerted_at, reset_event_id
|
|
1565
|
+
)
|
|
1566
|
+
SELECT id, block_id, five_hour_window_key, percent_threshold,
|
|
1567
|
+
captured_at_utc, usage_snapshot_id,
|
|
1568
|
+
block_input_tokens, block_output_tokens,
|
|
1569
|
+
block_cache_create_tokens, block_cache_read_tokens,
|
|
1570
|
+
block_cost_usd, marginal_cost_usd,
|
|
1571
|
+
seven_day_pct_at_crossing, alerted_at, reset_event_id
|
|
1572
|
+
FROM five_hour_milestones_old_006
|
|
1573
|
+
"""
|
|
1574
|
+
)
|
|
1575
|
+
# Recreate the block_id index that was attached to the original
|
|
1576
|
+
# table; the rename carried index metadata with the table, but
|
|
1577
|
+
# the new table needs its own index entry. Safe under
|
|
1578
|
+
# IF NOT EXISTS if the rename preserved it (it does in practice,
|
|
1579
|
+
# but the explicit recreate is defensive).
|
|
1580
|
+
conn.execute(
|
|
1581
|
+
"""
|
|
1582
|
+
CREATE INDEX IF NOT EXISTS idx_five_hour_milestones_block
|
|
1583
|
+
ON five_hour_milestones(block_id)
|
|
1584
|
+
"""
|
|
1585
|
+
)
|
|
1586
|
+
conn.execute("DROP TABLE five_hour_milestones_old_006")
|
|
1587
|
+
conn.execute(
|
|
1588
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1589
|
+
"VALUES (?, ?)",
|
|
1590
|
+
("006_five_hour_milestones_reset_event_id", now_utc_iso()),
|
|
1591
|
+
)
|
|
1592
|
+
conn.commit()
|
|
1593
|
+
except Exception:
|
|
1594
|
+
conn.rollback()
|
|
1595
|
+
raise
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
@stats_migration("007_observed_pre_credit_pct")
|
|
1599
|
+
def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
|
|
1600
|
+
"""Add ``observed_pre_credit_pct`` to ``week_reset_events`` so the
|
|
1601
|
+
race-defensive cleanup DELETE in the in-place weekly credit branch
|
|
1602
|
+
has a durable record of the pre-credit baseline we observed at
|
|
1603
|
+
write time — independent of how the upstream claude-statusline
|
|
1604
|
+
tool rounds replays.
|
|
1605
|
+
|
|
1606
|
+
Today statusline replays cctally's ``hwm-7d`` value byte-identically,
|
|
1607
|
+
so the existing strict ``round(.,1)`` equality predicate is sound.
|
|
1608
|
+
Future-proofs against rounding drift: if Anthropic ever rounds the
|
|
1609
|
+
``--percent`` payload differently from the OAuth API used by
|
|
1610
|
+
record-usage, or if statusline grows its own coarser rounding, a
|
|
1611
|
+
replay at e.g. 67.5 against a stored prior_pct = 67.4 would slip
|
|
1612
|
+
past strict equality and then dominate the reset-aware clamp's
|
|
1613
|
+
MAX over the post-credit segment. With the value stamped on the
|
|
1614
|
+
event row, the cleanup predicate widens to a 1.0pp tolerance band
|
|
1615
|
+
(issue #45) — wide enough to absorb single-digit drift, narrow
|
|
1616
|
+
enough that legitimate post-credit observations (≥25pp away by
|
|
1617
|
+
the in-place credit detection threshold's hypothesis) stay.
|
|
1618
|
+
|
|
1619
|
+
Backfill: NULL on existing rows. NULL is legacy / never-stamped;
|
|
1620
|
+
the live cleanup's bind still uses the current tick's in-scope
|
|
1621
|
+
``prior_pct`` (the value we just observed and would have stamped),
|
|
1622
|
+
so the cleanup remains correct on the very tick that writes the
|
|
1623
|
+
row. The stored value matters for future tooling that may re-run
|
|
1624
|
+
cleanup against an already-written event row.
|
|
1625
|
+
|
|
1626
|
+
Companion live-path edits land in:
|
|
1627
|
+
- bin/cctally — CREATE TABLE adds the column for fresh installs.
|
|
1628
|
+
- bin/_cctally_record.py — in-place credit INSERT stamps
|
|
1629
|
+
``observed_pre_credit_pct = prior_pct``; race-defensive DELETE
|
|
1630
|
+
switches from ``round(weekly_percent,1) = round(?,1)`` to
|
|
1631
|
+
``ABS(weekly_percent - ?) < 1.0``.
|
|
1632
|
+
|
|
1633
|
+
Idempotent: a second invocation finds the column already present
|
|
1634
|
+
and returns. Empty-column fast path: when the live CREATE TABLE
|
|
1635
|
+
already carries the column (fresh install), stamp the marker and
|
|
1636
|
+
return without an ALTER. Simple ADD COLUMN — no UNIQUE constraint
|
|
1637
|
+
change, so no rename-recreate-copy needed (contrast migrations
|
|
1638
|
+
005 / 006).
|
|
1639
|
+
"""
|
|
1640
|
+
cols = {
|
|
1641
|
+
str(r[1])
|
|
1642
|
+
for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
|
|
1643
|
+
}
|
|
1644
|
+
if "observed_pre_credit_pct" in cols:
|
|
1645
|
+
conn.execute(
|
|
1646
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1647
|
+
"VALUES (?, ?)",
|
|
1648
|
+
("007_observed_pre_credit_pct", now_utc_iso()),
|
|
1649
|
+
)
|
|
1650
|
+
conn.commit()
|
|
1651
|
+
return
|
|
1652
|
+
|
|
1653
|
+
conn.execute("BEGIN")
|
|
1654
|
+
try:
|
|
1655
|
+
conn.execute(
|
|
1656
|
+
"ALTER TABLE week_reset_events "
|
|
1657
|
+
"ADD COLUMN observed_pre_credit_pct REAL"
|
|
1658
|
+
)
|
|
1659
|
+
conn.execute(
|
|
1660
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1661
|
+
"VALUES (?, ?)",
|
|
1662
|
+
("007_observed_pre_credit_pct", now_utc_iso()),
|
|
1663
|
+
)
|
|
1664
|
+
conn.commit()
|
|
1665
|
+
except Exception:
|
|
1666
|
+
conn.rollback()
|
|
1667
|
+
raise
|
|
1668
|
+
|
|
1669
|
+
|
|
1440
1670
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
1441
1671
|
|
|
1442
1672
|
# ──────────────────────────────────────────────────────────────────────
|