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
package/bin/cctally
CHANGED
|
@@ -210,6 +210,43 @@ PUBLIC_REPO = "omrikais/cctally"
|
|
|
210
210
|
#
|
|
211
211
|
# Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3-6.4
|
|
212
212
|
|
|
213
|
+
# === _cctally_core: leaf kernel (MUST load first; sibling imports
|
|
214
|
+
# `from _cctally_core import …` depend on this being in sys.modules
|
|
215
|
+
# before any other sibling's module body runs). Spec
|
|
216
|
+
# 2026-05-17-cctally-core-kernel-extraction.md §2.5. ===
|
|
217
|
+
_cctally_core = _load_sibling("_cctally_core")
|
|
218
|
+
|
|
219
|
+
# Eager re-exports for the 24 kernel symbols. Preserves
|
|
220
|
+
# `cctally.<name>` attribute access AND `ns["<name>"]` dict-subscript
|
|
221
|
+
# paths used heavily by tests (e.g. ns["open_db"]() in 125+ sites).
|
|
222
|
+
# Don't add to PEP 562 __getattr__ registry — eager binding is the
|
|
223
|
+
# whole point.
|
|
224
|
+
eprint = _cctally_core.eprint
|
|
225
|
+
now_utc_iso = _cctally_core.now_utc_iso
|
|
226
|
+
parse_iso_datetime = _cctally_core.parse_iso_datetime
|
|
227
|
+
parse_date_str = _cctally_core.parse_date_str
|
|
228
|
+
format_local_iso = _cctally_core.format_local_iso
|
|
229
|
+
_iso_to_epoch = _cctally_core._iso_to_epoch
|
|
230
|
+
_format_short_duration = _cctally_core._format_short_duration
|
|
231
|
+
_normalize_week_boundary_dt = _cctally_core._normalize_week_boundary_dt
|
|
232
|
+
_now_utc = _cctally_core._now_utc
|
|
233
|
+
_command_as_of = _cctally_core._command_as_of
|
|
234
|
+
get_week_start_name = _cctally_core.get_week_start_name
|
|
235
|
+
compute_week_bounds = _cctally_core.compute_week_bounds
|
|
236
|
+
WEEKDAY_MAP = _cctally_core.WEEKDAY_MAP
|
|
237
|
+
DEFAULT_WEEK_START = _cctally_core.DEFAULT_WEEK_START
|
|
238
|
+
ensure_dirs = _cctally_core.ensure_dirs
|
|
239
|
+
_AlertsConfigError = _cctally_core._AlertsConfigError
|
|
240
|
+
_ALERTS_CONFIG_VALID_KEYS = _cctally_core._ALERTS_CONFIG_VALID_KEYS
|
|
241
|
+
_validate_threshold_list = _cctally_core._validate_threshold_list
|
|
242
|
+
_get_alerts_config = _cctally_core._get_alerts_config
|
|
243
|
+
open_db = _cctally_core.open_db
|
|
244
|
+
WeekRef = _cctally_core.WeekRef
|
|
245
|
+
_canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
|
|
246
|
+
make_week_ref = _cctally_core.make_week_ref
|
|
247
|
+
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
|
|
248
|
+
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
|
|
249
|
+
|
|
213
250
|
_lib_semver = _load_sibling("_lib_semver")
|
|
214
251
|
_SEMVER_NUM = _lib_semver._SEMVER_NUM
|
|
215
252
|
_SEMVER_RE = _lib_semver._SEMVER_RE
|
|
@@ -2195,9 +2232,6 @@ def _migrate_legacy_data_dir() -> None:
|
|
|
2195
2232
|
)
|
|
2196
2233
|
|
|
2197
2234
|
|
|
2198
|
-
DEFAULT_WEEK_START = "monday"
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
2235
|
def _get_claude_data_dirs() -> list[pathlib.Path]:
|
|
2202
2236
|
"""Return Claude Code data directories containing a projects/ subdir."""
|
|
2203
2237
|
env_val = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
@@ -2352,79 +2386,6 @@ def _resolve_primary_model_for_block(
|
|
|
2352
2386
|
|
|
2353
2387
|
|
|
2354
2388
|
|
|
2355
|
-
WEEKDAY_MAP = {
|
|
2356
|
-
"monday": 0,
|
|
2357
|
-
"tuesday": 1,
|
|
2358
|
-
"wednesday": 2,
|
|
2359
|
-
"thursday": 3,
|
|
2360
|
-
"friday": 4,
|
|
2361
|
-
"saturday": 5,
|
|
2362
|
-
"sunday": 6,
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
def eprint(*args: Any) -> None:
|
|
2369
|
-
print(*args, file=sys.stderr)
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
def now_utc_iso(now_utc: dt.datetime | None = None) -> str:
|
|
2373
|
-
"""Return a UTC-ISO 'Z'-suffixed timestamp with seconds precision.
|
|
2374
|
-
|
|
2375
|
-
When ``now_utc`` is omitted (the default), reads wall-clock — existing
|
|
2376
|
-
behavior, preserved byte-for-byte for all existing callers. When a
|
|
2377
|
-
tz-aware UTC datetime is supplied (typically via ``_command_as_of()``),
|
|
2378
|
-
it is used verbatim so callers that honor ``CCTALLY_AS_OF`` get a
|
|
2379
|
-
stable, caller-pinned timestamp.
|
|
2380
|
-
"""
|
|
2381
|
-
value = now_utc if now_utc is not None else dt.datetime.now(dt.timezone.utc)
|
|
2382
|
-
return (
|
|
2383
|
-
value.astimezone(dt.timezone.utc)
|
|
2384
|
-
.replace(microsecond=0)
|
|
2385
|
-
.isoformat()
|
|
2386
|
-
.replace("+00:00", "Z")
|
|
2387
|
-
)
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
def _iso_to_epoch(s: str) -> int:
|
|
2391
|
-
"""Parse an ISO-8601 timestamp and return Unix epoch seconds.
|
|
2392
|
-
|
|
2393
|
-
Naive ISO strings (no timezone) are treated as UTC, matching the
|
|
2394
|
-
statusline-command.sh ``_iso_to_epoch`` helper. ``Z`` suffix is
|
|
2395
|
-
handled by mapping to ``+00:00`` since ``datetime.fromisoformat``
|
|
2396
|
-
accepts ``Z`` natively from Python 3.11.
|
|
2397
|
-
"""
|
|
2398
|
-
s = s.strip()
|
|
2399
|
-
if s.endswith("Z"):
|
|
2400
|
-
s = s[:-1] + "+00:00"
|
|
2401
|
-
parsed = dt.datetime.fromisoformat(s)
|
|
2402
|
-
if parsed.tzinfo is None:
|
|
2403
|
-
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
2404
|
-
return int(parsed.timestamp())
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
def _format_short_duration(seconds: int) -> str:
|
|
2408
|
-
"""Format a duration as a short top-two-units string.
|
|
2409
|
-
|
|
2410
|
-
Examples: ``6d 4h``, ``2h 15m``, ``2h``, ``45m``, ``30s``, ``0s``.
|
|
2411
|
-
Mirrors the shape used by ``~/.claude/statusline-command.sh``'s
|
|
2412
|
-
format_duration helper. Negative inputs clamp to ``0s``.
|
|
2413
|
-
"""
|
|
2414
|
-
s = max(0, int(seconds))
|
|
2415
|
-
if s >= 86400:
|
|
2416
|
-
days = s // 86400
|
|
2417
|
-
hours = (s % 86400) // 3600
|
|
2418
|
-
return f"{days}d {hours}h" if hours else f"{days}d"
|
|
2419
|
-
if s >= 3600:
|
|
2420
|
-
hours = s // 3600
|
|
2421
|
-
minutes = (s % 3600) // 60
|
|
2422
|
-
return f"{hours}h {minutes}m" if minutes else f"{hours}h"
|
|
2423
|
-
if s >= 60:
|
|
2424
|
-
return f"{s // 60}m"
|
|
2425
|
-
return f"{s}s"
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
2389
|
def _read_keychain_oauth_blob() -> str | None:
|
|
2429
2390
|
"""Read the Claude Code keychain entry on macOS via `security`.
|
|
2430
2391
|
|
|
@@ -2557,96 +2518,6 @@ def _select_last_known_snapshot() -> dict | None:
|
|
|
2557
2518
|
}
|
|
2558
2519
|
|
|
2559
2520
|
|
|
2560
|
-
def ensure_dirs() -> None:
|
|
2561
|
-
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
2562
|
-
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
class _AlertsConfigError(ValueError):
|
|
2568
|
-
"""Raised by _get_alerts_config on invalid alerts block."""
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
_ALERTS_CONFIG_VALID_KEYS = {"enabled", "weekly_thresholds", "five_hour_thresholds"}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
def _validate_threshold_list(name: str, value: object) -> "list[int]":
|
|
2575
|
-
"""Validate one of the alerts threshold lists.
|
|
2576
|
-
|
|
2577
|
-
Rules: non-empty list of plain ints (NOT bools — `bool` is an `int`
|
|
2578
|
-
subclass), each in [1, 100], strictly increasing (no duplicates).
|
|
2579
|
-
Error messages mention `alerts.<name>` so users can locate the
|
|
2580
|
-
offending key in their config.json.
|
|
2581
|
-
"""
|
|
2582
|
-
if not isinstance(value, list):
|
|
2583
|
-
raise _AlertsConfigError(f"alerts.{name} must be a list of integers")
|
|
2584
|
-
if len(value) == 0:
|
|
2585
|
-
raise _AlertsConfigError(
|
|
2586
|
-
f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)"
|
|
2587
|
-
)
|
|
2588
|
-
out: "list[int]" = []
|
|
2589
|
-
prev = -1
|
|
2590
|
-
seen: "set[int]" = set()
|
|
2591
|
-
for item in value:
|
|
2592
|
-
if not isinstance(item, int) or isinstance(item, bool):
|
|
2593
|
-
raise _AlertsConfigError(
|
|
2594
|
-
f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}"
|
|
2595
|
-
)
|
|
2596
|
-
if item < 1 or item > 100:
|
|
2597
|
-
raise _AlertsConfigError(
|
|
2598
|
-
f"alerts.{name} items must be in [1, 100], got {item}"
|
|
2599
|
-
)
|
|
2600
|
-
if item in seen:
|
|
2601
|
-
raise _AlertsConfigError(
|
|
2602
|
-
f"alerts.{name} contains duplicate value {item}"
|
|
2603
|
-
)
|
|
2604
|
-
if item <= prev:
|
|
2605
|
-
raise _AlertsConfigError(
|
|
2606
|
-
f"alerts.{name} must be strictly increasing, got {prev} then {item}"
|
|
2607
|
-
)
|
|
2608
|
-
seen.add(item)
|
|
2609
|
-
prev = item
|
|
2610
|
-
out.append(item)
|
|
2611
|
-
return out
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
2615
|
-
"""Return the validated alerts block. Raises _AlertsConfigError on failure.
|
|
2616
|
-
|
|
2617
|
-
Defaults applied at read time so future default-tuning takes effect
|
|
2618
|
-
for users who never customized. Unknown sub-keys under `alerts.*`
|
|
2619
|
-
emit a one-line warn-and-ignore (mirrors the `display.tz` posture
|
|
2620
|
-
for forward compatibility).
|
|
2621
|
-
"""
|
|
2622
|
-
block = (cfg or {}).get("alerts", {}) or {}
|
|
2623
|
-
if not isinstance(block, dict):
|
|
2624
|
-
raise _AlertsConfigError("alerts must be an object")
|
|
2625
|
-
# warn-and-ignore unknown keys (forward compat; matches display.tz posture)
|
|
2626
|
-
for k in block.keys():
|
|
2627
|
-
if k not in _ALERTS_CONFIG_VALID_KEYS:
|
|
2628
|
-
print(
|
|
2629
|
-
f"warning: ignoring unknown alerts config key: {k}",
|
|
2630
|
-
file=sys.stderr,
|
|
2631
|
-
)
|
|
2632
|
-
enabled = block.get("enabled", False)
|
|
2633
|
-
if not isinstance(enabled, bool):
|
|
2634
|
-
raise _AlertsConfigError(
|
|
2635
|
-
f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}"
|
|
2636
|
-
)
|
|
2637
|
-
weekly = _validate_threshold_list(
|
|
2638
|
-
"weekly_thresholds", block.get("weekly_thresholds", [90, 95])
|
|
2639
|
-
)
|
|
2640
|
-
five_hour = _validate_threshold_list(
|
|
2641
|
-
"five_hour_thresholds", block.get("five_hour_thresholds", [90, 95])
|
|
2642
|
-
)
|
|
2643
|
-
return {
|
|
2644
|
-
"enabled": enabled,
|
|
2645
|
-
"weekly_thresholds": weekly,
|
|
2646
|
-
"five_hour_thresholds": five_hour,
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
2521
|
def _normalize_alerts_enabled_value(raw: str) -> bool:
|
|
2651
2522
|
"""Normalize a CLI string value to a JSON bool. Raises ValueError on unknown.
|
|
2652
2523
|
|
|
@@ -2724,60 +2595,6 @@ ORIGINAL_ENTRYPOINT: "str | None" = None
|
|
|
2724
2595
|
_UPDATE_WORKER: "UpdateWorker | None" = None
|
|
2725
2596
|
|
|
2726
2597
|
|
|
2727
|
-
def get_week_start_name(config: dict[str, Any], override: str | None = None) -> str:
|
|
2728
|
-
if override:
|
|
2729
|
-
name = override.strip().lower()
|
|
2730
|
-
else:
|
|
2731
|
-
name = str(config.get("collector", {}).get("week_start", DEFAULT_WEEK_START)).strip().lower()
|
|
2732
|
-
if name not in WEEKDAY_MAP:
|
|
2733
|
-
raise ValueError(
|
|
2734
|
-
f"Invalid week start '{name}'. Allowed: {', '.join(WEEKDAY_MAP.keys())}"
|
|
2735
|
-
)
|
|
2736
|
-
return name
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[dt.date, dt.date]:
|
|
2740
|
-
start_idx = WEEKDAY_MAP[week_start_name]
|
|
2741
|
-
# internal fallback: host-local intentional
|
|
2742
|
-
local_anchor = anchor_dt.astimezone()
|
|
2743
|
-
local_date = local_anchor.date()
|
|
2744
|
-
diff = (local_date.weekday() - start_idx) % 7
|
|
2745
|
-
start = local_date - dt.timedelta(days=diff)
|
|
2746
|
-
end = start + dt.timedelta(days=6)
|
|
2747
|
-
return start, end
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
def parse_date_str(value: str, label: str) -> dt.date:
|
|
2751
|
-
s = value.strip()
|
|
2752
|
-
if not _DATE_RE.match(s):
|
|
2753
|
-
raise ValueError(f"{label} must be YYYY-MM-DD")
|
|
2754
|
-
return dt.date.fromisoformat(s)
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
def parse_iso_datetime(value: str, label: str) -> dt.datetime:
|
|
2758
|
-
s = value.strip()
|
|
2759
|
-
if not s:
|
|
2760
|
-
raise ValueError(f"{label} must be a non-empty ISO datetime")
|
|
2761
|
-
try:
|
|
2762
|
-
parsed = dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
2763
|
-
except ValueError as exc:
|
|
2764
|
-
raise ValueError(f"{label} must be ISO datetime") from exc
|
|
2765
|
-
|
|
2766
|
-
if parsed.tzinfo is None:
|
|
2767
|
-
# internal fallback: host-local intentional
|
|
2768
|
-
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
2769
|
-
parsed = parsed.replace(tzinfo=local_tz)
|
|
2770
|
-
# internal fallback: host-local intentional
|
|
2771
|
-
return parsed.astimezone()
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
def format_local_iso(d: dt.date, end_of_day: bool) -> str:
|
|
2775
|
-
t = dt.time(23, 59, 59) if end_of_day else dt.time(0, 0, 0)
|
|
2776
|
-
# internal fallback: host-local intentional
|
|
2777
|
-
local_dt = dt.datetime.combine(d, t).astimezone()
|
|
2778
|
-
return local_dt.isoformat(timespec="seconds")
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
2598
|
def _parse_iso_datetime_optional(value: Any) -> dt.datetime | None:
|
|
2782
2599
|
if not isinstance(value, str) or not value.strip():
|
|
2783
2600
|
return None
|
|
@@ -3652,441 +3469,6 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
|
|
|
3652
3469
|
return 0.0
|
|
3653
3470
|
|
|
3654
3471
|
|
|
3655
|
-
def _normalize_week_boundary_dt(value: dt.datetime) -> dt.datetime:
|
|
3656
|
-
"""
|
|
3657
|
-
Normalize known Anthropic boundary jitter.
|
|
3658
|
-
|
|
3659
|
-
Anthropic resets are always on hour boundaries. Relative reset text
|
|
3660
|
-
("in XX hr YY min") produces minute-level drift on every capture, and
|
|
3661
|
-
the UI occasionally alternates between HH:00 and HH-1:59 for the same
|
|
3662
|
-
logical reset.
|
|
3663
|
-
|
|
3664
|
-
Canonicalization: round to the nearest hour.
|
|
3665
|
-
- minutes 0..29 -> HH:00
|
|
3666
|
-
- minutes 30..59 -> (HH+1):00
|
|
3667
|
-
"""
|
|
3668
|
-
normalized = value.replace(second=0, microsecond=0)
|
|
3669
|
-
if normalized.minute >= 30:
|
|
3670
|
-
normalized = (normalized + dt.timedelta(hours=1)).replace(
|
|
3671
|
-
minute=0,
|
|
3672
|
-
second=0,
|
|
3673
|
-
microsecond=0,
|
|
3674
|
-
)
|
|
3675
|
-
elif normalized.minute > 0:
|
|
3676
|
-
normalized = normalized.replace(
|
|
3677
|
-
minute=0,
|
|
3678
|
-
second=0,
|
|
3679
|
-
microsecond=0,
|
|
3680
|
-
)
|
|
3681
|
-
return normalized
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
def open_db() -> sqlite3.Connection:
|
|
3685
|
-
ensure_dirs()
|
|
3686
|
-
conn = sqlite3.connect(DB_PATH)
|
|
3687
|
-
conn.row_factory = sqlite3.Row
|
|
3688
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
3689
|
-
conn.execute("PRAGMA synchronous=NORMAL")
|
|
3690
|
-
conn.execute(
|
|
3691
|
-
"""
|
|
3692
|
-
CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
|
|
3693
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3694
|
-
captured_at_utc TEXT NOT NULL,
|
|
3695
|
-
week_start_date TEXT NOT NULL,
|
|
3696
|
-
week_end_date TEXT NOT NULL,
|
|
3697
|
-
week_start_at TEXT,
|
|
3698
|
-
week_end_at TEXT,
|
|
3699
|
-
weekly_percent REAL NOT NULL,
|
|
3700
|
-
page_url TEXT,
|
|
3701
|
-
source TEXT NOT NULL DEFAULT 'userscript',
|
|
3702
|
-
payload_json TEXT NOT NULL
|
|
3703
|
-
)
|
|
3704
|
-
"""
|
|
3705
|
-
)
|
|
3706
|
-
conn.execute(
|
|
3707
|
-
"""
|
|
3708
|
-
CREATE INDEX IF NOT EXISTS idx_usage_week_time
|
|
3709
|
-
ON weekly_usage_snapshots(week_start_date, captured_at_utc DESC, id DESC)
|
|
3710
|
-
"""
|
|
3711
|
-
)
|
|
3712
|
-
conn.execute(
|
|
3713
|
-
"""
|
|
3714
|
-
CREATE TABLE IF NOT EXISTS weekly_cost_snapshots (
|
|
3715
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3716
|
-
captured_at_utc TEXT NOT NULL,
|
|
3717
|
-
week_start_date TEXT NOT NULL,
|
|
3718
|
-
week_end_date TEXT NOT NULL,
|
|
3719
|
-
week_start_at TEXT,
|
|
3720
|
-
week_end_at TEXT,
|
|
3721
|
-
range_start_iso TEXT,
|
|
3722
|
-
range_end_iso TEXT,
|
|
3723
|
-
cost_usd REAL NOT NULL,
|
|
3724
|
-
source TEXT NOT NULL DEFAULT 'cctally-range-cost',
|
|
3725
|
-
mode TEXT NOT NULL DEFAULT 'auto',
|
|
3726
|
-
project TEXT
|
|
3727
|
-
)
|
|
3728
|
-
"""
|
|
3729
|
-
)
|
|
3730
|
-
conn.execute(
|
|
3731
|
-
"""
|
|
3732
|
-
CREATE INDEX IF NOT EXISTS idx_cost_week_time
|
|
3733
|
-
ON weekly_cost_snapshots(week_start_date, captured_at_utc DESC, id DESC)
|
|
3734
|
-
"""
|
|
3735
|
-
)
|
|
3736
|
-
|
|
3737
|
-
add_column_if_missing(conn, "weekly_usage_snapshots", "week_start_at", "TEXT")
|
|
3738
|
-
add_column_if_missing(conn, "weekly_usage_snapshots", "week_end_at", "TEXT")
|
|
3739
|
-
add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_percent", "REAL")
|
|
3740
|
-
add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_resets_at", "TEXT")
|
|
3741
|
-
# five_hour_window_key — canonical (10-min-floored epoch) key for
|
|
3742
|
-
# jitter-tolerant equality. Anthropic's status-line API jitters
|
|
3743
|
-
# rate_limits.5h.resets_at by ~seconds within the same physical 5h
|
|
3744
|
-
# window; joining on the raw ISO string treats each jittered fetch as
|
|
3745
|
-
# a new window, escaping the monotonic clamp at cmd_record_usage.
|
|
3746
|
-
# Backfill is RESUMABLE: Python's sqlite3 auto-commits DDL,
|
|
3747
|
-
# so a process killed mid-loop would leave the column added with NULL
|
|
3748
|
-
# keys for unprocessed rows. The gating below detects that partial
|
|
3749
|
-
# state on the next open_db() call (`five_hour_resets_at IS NOT NULL
|
|
3750
|
-
# AND five_hour_window_key IS NULL`) and completes the backfill, so
|
|
3751
|
-
# the original Bug B can't silently re-emerge for half-migrated rows.
|
|
3752
|
-
needs_5h_key_backfill = add_column_if_missing(
|
|
3753
|
-
conn, "weekly_usage_snapshots", "five_hour_window_key", "INTEGER"
|
|
3754
|
-
)
|
|
3755
|
-
if not needs_5h_key_backfill and conn.execute(
|
|
3756
|
-
"SELECT 1 FROM weekly_usage_snapshots "
|
|
3757
|
-
"WHERE five_hour_resets_at IS NOT NULL "
|
|
3758
|
-
" AND five_hour_window_key IS NULL "
|
|
3759
|
-
"LIMIT 1"
|
|
3760
|
-
).fetchone() is not None:
|
|
3761
|
-
needs_5h_key_backfill = True
|
|
3762
|
-
|
|
3763
|
-
if needs_5h_key_backfill:
|
|
3764
|
-
backfill_rows = conn.execute(
|
|
3765
|
-
"SELECT id, five_hour_resets_at FROM weekly_usage_snapshots "
|
|
3766
|
-
"WHERE five_hour_resets_at IS NOT NULL "
|
|
3767
|
-
" AND five_hour_window_key IS NULL"
|
|
3768
|
-
).fetchall()
|
|
3769
|
-
for row in backfill_rows:
|
|
3770
|
-
try:
|
|
3771
|
-
iso = row[1]
|
|
3772
|
-
d = parse_iso_datetime(iso, "five_hour_resets_at backfill")
|
|
3773
|
-
epoch = int(d.timestamp())
|
|
3774
|
-
key = _canonical_5h_window_key(epoch)
|
|
3775
|
-
conn.execute(
|
|
3776
|
-
"UPDATE weekly_usage_snapshots "
|
|
3777
|
-
"SET five_hour_window_key = ? WHERE id = ?",
|
|
3778
|
-
(key, row[0]),
|
|
3779
|
-
)
|
|
3780
|
-
except (ValueError, TypeError) as exc:
|
|
3781
|
-
eprint(f"[migration] skipped row {row[0]}: {exc}")
|
|
3782
|
-
conn.execute(
|
|
3783
|
-
"CREATE INDEX IF NOT EXISTS idx_weekly_usage_snapshots_5h_window_key "
|
|
3784
|
-
"ON weekly_usage_snapshots(five_hour_window_key)"
|
|
3785
|
-
)
|
|
3786
|
-
conn.commit()
|
|
3787
|
-
|
|
3788
|
-
add_column_if_missing(conn, "weekly_cost_snapshots", "week_start_at", "TEXT")
|
|
3789
|
-
add_column_if_missing(conn, "weekly_cost_snapshots", "week_end_at", "TEXT")
|
|
3790
|
-
add_column_if_missing(conn, "weekly_cost_snapshots", "range_start_iso", "TEXT")
|
|
3791
|
-
add_column_if_missing(conn, "weekly_cost_snapshots", "range_end_iso", "TEXT")
|
|
3792
|
-
|
|
3793
|
-
conn.execute(
|
|
3794
|
-
"""
|
|
3795
|
-
CREATE INDEX IF NOT EXISTS idx_usage_week_start_at_time
|
|
3796
|
-
ON weekly_usage_snapshots(week_start_at, captured_at_utc DESC, id DESC)
|
|
3797
|
-
"""
|
|
3798
|
-
)
|
|
3799
|
-
conn.execute(
|
|
3800
|
-
"""
|
|
3801
|
-
CREATE INDEX IF NOT EXISTS idx_cost_week_start_at_time
|
|
3802
|
-
ON weekly_cost_snapshots(week_start_at, captured_at_utc DESC, id DESC)
|
|
3803
|
-
"""
|
|
3804
|
-
)
|
|
3805
|
-
|
|
3806
|
-
conn.execute(
|
|
3807
|
-
"""
|
|
3808
|
-
CREATE TABLE IF NOT EXISTS percent_milestones (
|
|
3809
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3810
|
-
captured_at_utc TEXT NOT NULL,
|
|
3811
|
-
week_start_date TEXT NOT NULL,
|
|
3812
|
-
week_end_date TEXT NOT NULL,
|
|
3813
|
-
week_start_at TEXT,
|
|
3814
|
-
week_end_at TEXT,
|
|
3815
|
-
percent_threshold INTEGER NOT NULL,
|
|
3816
|
-
cumulative_cost_usd REAL NOT NULL,
|
|
3817
|
-
marginal_cost_usd REAL,
|
|
3818
|
-
usage_snapshot_id INTEGER NOT NULL,
|
|
3819
|
-
cost_snapshot_id INTEGER NOT NULL,
|
|
3820
|
-
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
3821
|
-
UNIQUE(week_start_date, percent_threshold, reset_event_id)
|
|
3822
|
-
)
|
|
3823
|
-
"""
|
|
3824
|
-
)
|
|
3825
|
-
|
|
3826
|
-
add_column_if_missing(conn, "percent_milestones", "five_hour_percent_at_crossing", "REAL")
|
|
3827
|
-
# reset_event_id: segment column added by migration 005. Fresh-install
|
|
3828
|
-
# DBs get it via the live CREATE TABLE above + the dispatcher
|
|
3829
|
-
# fast-stamps the migration. Existing pre-005 DBs trip the migration's
|
|
3830
|
-
# rename-recreate-copy idiom (handler in _cctally_db.py); the handler's
|
|
3831
|
-
# fast-path probe stamps the marker when the column is already present
|
|
3832
|
-
# (covers the corner case where a partially-upgraded DB has the column
|
|
3833
|
-
# but not the new UNIQUE — re-run is safe).
|
|
3834
|
-
|
|
3835
|
-
# alerted_at: populated by the alert-dispatch path when a milestone-INSERT
|
|
3836
|
-
# row's threshold matches the user's configured alerts.weekly_thresholds /
|
|
3837
|
-
# alerts.five_hour_thresholds (and alerts.enabled is true). NULL means
|
|
3838
|
-
# "alerts were disabled at the moment of crossing OR the threshold wasn't
|
|
3839
|
-
# in the configured list" — never "alert delivery failed" (dispatch is
|
|
3840
|
-
# best-effort and write-once forward-only). The matching ALTER for
|
|
3841
|
-
# `five_hour_milestones` lives right after that table's CREATE block
|
|
3842
|
-
# below, since the table doesn't exist yet at this point in `open_db()`.
|
|
3843
|
-
add_column_if_missing(conn, "percent_milestones", "alerted_at", "TEXT")
|
|
3844
|
-
|
|
3845
|
-
# Mid-week reset events: when Anthropic advances `rate_limits.seven_day.
|
|
3846
|
-
# resets_at` before the previously-declared reset actually fires (i.e.,
|
|
3847
|
-
# gives the user a fresh weekly window before the old one naturally
|
|
3848
|
-
# expired), we record one row here so display + cost layers can treat
|
|
3849
|
-
# the effective reset moment as the old week's end AND the new week's
|
|
3850
|
-
# start — preventing the API's -7d-derived new week from overlapping
|
|
3851
|
-
# the old week. Inserted by cmd_record_usage on detection; read by
|
|
3852
|
-
# _apply_reset_events_to_weekrefs and the cost live-recompute path.
|
|
3853
|
-
conn.execute(
|
|
3854
|
-
"""
|
|
3855
|
-
CREATE TABLE IF NOT EXISTS week_reset_events (
|
|
3856
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3857
|
-
detected_at_utc TEXT NOT NULL,
|
|
3858
|
-
old_week_end_at TEXT NOT NULL,
|
|
3859
|
-
new_week_end_at TEXT NOT NULL,
|
|
3860
|
-
effective_reset_at_utc TEXT NOT NULL,
|
|
3861
|
-
UNIQUE(old_week_end_at, new_week_end_at)
|
|
3862
|
-
)
|
|
3863
|
-
"""
|
|
3864
|
-
)
|
|
3865
|
-
_backfill_week_reset_events(conn)
|
|
3866
|
-
|
|
3867
|
-
# ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
|
|
3868
|
-
conn.execute(
|
|
3869
|
-
"""
|
|
3870
|
-
CREATE TABLE IF NOT EXISTS five_hour_blocks (
|
|
3871
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3872
|
-
five_hour_window_key INTEGER NOT NULL UNIQUE,
|
|
3873
|
-
five_hour_resets_at TEXT NOT NULL,
|
|
3874
|
-
block_start_at TEXT NOT NULL,
|
|
3875
|
-
first_observed_at_utc TEXT NOT NULL,
|
|
3876
|
-
last_observed_at_utc TEXT NOT NULL,
|
|
3877
|
-
final_five_hour_percent REAL NOT NULL,
|
|
3878
|
-
seven_day_pct_at_block_start REAL,
|
|
3879
|
-
seven_day_pct_at_block_end REAL,
|
|
3880
|
-
crossed_seven_day_reset INTEGER NOT NULL DEFAULT 0,
|
|
3881
|
-
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3882
|
-
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3883
|
-
total_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3884
|
-
total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3885
|
-
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
3886
|
-
is_closed INTEGER NOT NULL DEFAULT 0,
|
|
3887
|
-
created_at_utc TEXT NOT NULL,
|
|
3888
|
-
last_updated_at_utc TEXT NOT NULL
|
|
3889
|
-
)
|
|
3890
|
-
"""
|
|
3891
|
-
)
|
|
3892
|
-
conn.execute(
|
|
3893
|
-
"""
|
|
3894
|
-
CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
|
|
3895
|
-
ON five_hour_blocks(block_start_at DESC)
|
|
3896
|
-
"""
|
|
3897
|
-
)
|
|
3898
|
-
|
|
3899
|
-
# ── five_hour_milestones (per-percent crossings inside a 5h block) ──
|
|
3900
|
-
conn.execute(
|
|
3901
|
-
"""
|
|
3902
|
-
CREATE TABLE IF NOT EXISTS five_hour_milestones (
|
|
3903
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3904
|
-
block_id INTEGER NOT NULL,
|
|
3905
|
-
five_hour_window_key INTEGER NOT NULL,
|
|
3906
|
-
percent_threshold INTEGER NOT NULL,
|
|
3907
|
-
captured_at_utc TEXT NOT NULL,
|
|
3908
|
-
usage_snapshot_id INTEGER NOT NULL,
|
|
3909
|
-
block_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3910
|
-
block_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3911
|
-
block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3912
|
-
block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3913
|
-
block_cost_usd REAL NOT NULL DEFAULT 0,
|
|
3914
|
-
marginal_cost_usd REAL,
|
|
3915
|
-
seven_day_pct_at_crossing REAL,
|
|
3916
|
-
UNIQUE(five_hour_window_key, percent_threshold),
|
|
3917
|
-
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
3918
|
-
)
|
|
3919
|
-
"""
|
|
3920
|
-
)
|
|
3921
|
-
conn.execute(
|
|
3922
|
-
"""
|
|
3923
|
-
CREATE INDEX IF NOT EXISTS idx_five_hour_milestones_block
|
|
3924
|
-
ON five_hour_milestones(block_id)
|
|
3925
|
-
"""
|
|
3926
|
-
)
|
|
3927
|
-
|
|
3928
|
-
# alerted_at: see the matching ALTER on `percent_milestones` above for
|
|
3929
|
-
# rationale. Same write-once forward-only semantics: the alert-dispatch
|
|
3930
|
-
# path stamps this column on milestone-INSERT rows whose threshold
|
|
3931
|
-
# matches the user's configured `alerts.five_hour_thresholds`. NULL =
|
|
3932
|
-
# "alerts disabled at moment of crossing OR threshold not configured"
|
|
3933
|
-
# — never "delivery failed".
|
|
3934
|
-
add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
|
|
3935
|
-
|
|
3936
|
-
# ── five_hour_block_models (per-(block, model) rollup-child) ──
|
|
3937
|
-
# MUST be created BEFORE the parent-backfill gate below, because
|
|
3938
|
-
# _backfill_five_hour_blocks writes into this table on the fresh-install
|
|
3939
|
-
# path. UNIQUE keyed on (five_hour_window_key, model) — durable across
|
|
3940
|
-
# parent rebuilds. Live writes use DELETE WHERE five_hour_window_key = ?.
|
|
3941
|
-
conn.execute(
|
|
3942
|
-
"""
|
|
3943
|
-
CREATE TABLE IF NOT EXISTS five_hour_block_models (
|
|
3944
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3945
|
-
block_id INTEGER NOT NULL,
|
|
3946
|
-
five_hour_window_key INTEGER NOT NULL,
|
|
3947
|
-
model TEXT NOT NULL,
|
|
3948
|
-
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3949
|
-
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3950
|
-
cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3951
|
-
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3952
|
-
cost_usd REAL NOT NULL DEFAULT 0,
|
|
3953
|
-
entry_count INTEGER NOT NULL DEFAULT 0,
|
|
3954
|
-
UNIQUE(five_hour_window_key, model),
|
|
3955
|
-
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
3956
|
-
)
|
|
3957
|
-
"""
|
|
3958
|
-
)
|
|
3959
|
-
conn.execute(
|
|
3960
|
-
"""
|
|
3961
|
-
CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_block
|
|
3962
|
-
ON five_hour_block_models(block_id)
|
|
3963
|
-
"""
|
|
3964
|
-
)
|
|
3965
|
-
conn.execute(
|
|
3966
|
-
"""
|
|
3967
|
-
CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_window
|
|
3968
|
-
ON five_hour_block_models(five_hour_window_key)
|
|
3969
|
-
"""
|
|
3970
|
-
)
|
|
3971
|
-
|
|
3972
|
-
# ── five_hour_block_projects (per-(block, project_path) rollup-child) ──
|
|
3973
|
-
# NULL session_files.project_path → '(unknown)' sentinel at write time,
|
|
3974
|
-
# keeping reconcile invariant SUM(child.cost) == parent.total intact.
|
|
3975
|
-
conn.execute(
|
|
3976
|
-
"""
|
|
3977
|
-
CREATE TABLE IF NOT EXISTS five_hour_block_projects (
|
|
3978
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3979
|
-
block_id INTEGER NOT NULL,
|
|
3980
|
-
five_hour_window_key INTEGER NOT NULL,
|
|
3981
|
-
project_path TEXT NOT NULL,
|
|
3982
|
-
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3983
|
-
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3984
|
-
cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3985
|
-
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
3986
|
-
cost_usd REAL NOT NULL DEFAULT 0,
|
|
3987
|
-
entry_count INTEGER NOT NULL DEFAULT 0,
|
|
3988
|
-
UNIQUE(five_hour_window_key, project_path),
|
|
3989
|
-
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
3990
|
-
)
|
|
3991
|
-
"""
|
|
3992
|
-
)
|
|
3993
|
-
conn.execute(
|
|
3994
|
-
"""
|
|
3995
|
-
CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_block
|
|
3996
|
-
ON five_hour_block_projects(block_id)
|
|
3997
|
-
"""
|
|
3998
|
-
)
|
|
3999
|
-
conn.execute(
|
|
4000
|
-
"""
|
|
4001
|
-
CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_window
|
|
4002
|
-
ON five_hour_block_projects(five_hour_window_key)
|
|
4003
|
-
"""
|
|
4004
|
-
)
|
|
4005
|
-
|
|
4006
|
-
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
4007
|
-
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
4008
|
-
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|
|
4009
|
-
# decorated with @stats_migration further down in this file.
|
|
4010
|
-
#
|
|
4011
|
-
# MUST run BEFORE any DDL or write that touches `schema_migrations`
|
|
4012
|
-
# (Codex P1 #1 fix on c3625ee + e7fdcc8): the dispatcher's fresh-install
|
|
4013
|
-
# detection snapshots `schema_migrations`'s existence in sqlite_master
|
|
4014
|
-
# BEFORE its own CREATE TABLE IF NOT EXISTS. Pre-creating the table
|
|
4015
|
-
# earlier in open_db() (or letting `_backfill_five_hour_blocks` insert
|
|
4016
|
-
# markers first) flips that snapshot to True on a brand-new DB and
|
|
4017
|
-
# dead-codes the stamp-only fast path. The dispatcher is now the sole
|
|
4018
|
-
# creator of `schema_migrations` + `schema_migrations_skipped`.
|
|
4019
|
-
_run_pending_migrations(
|
|
4020
|
-
conn, registry=_STATS_MIGRATIONS, db_label="stats.db",
|
|
4021
|
-
)
|
|
4022
|
-
|
|
4023
|
-
# One-time historical backfill of five_hour_blocks (rollup only;
|
|
4024
|
-
# milestones are forward-only per spec §4.3 / [Write-once milestones]).
|
|
4025
|
-
# Idempotent via UNIQUE(five_hour_window_key) + INSERT OR IGNORE.
|
|
4026
|
-
# Runs AFTER the dispatcher so `schema_migrations` exists for the
|
|
4027
|
-
# marker INSERTs inside the backfill body, and so any fresh-install
|
|
4028
|
-
# stamp-only path the dispatcher took above is already committed.
|
|
4029
|
-
existing = conn.execute(
|
|
4030
|
-
"SELECT 1 FROM five_hour_blocks LIMIT 1"
|
|
4031
|
-
).fetchone()
|
|
4032
|
-
has_snapshots = conn.execute(
|
|
4033
|
-
"SELECT 1 FROM weekly_usage_snapshots "
|
|
4034
|
-
"WHERE five_hour_window_key IS NOT NULL "
|
|
4035
|
-
" AND five_hour_percent IS NOT NULL "
|
|
4036
|
-
"LIMIT 1"
|
|
4037
|
-
).fetchone()
|
|
4038
|
-
if not existing and has_snapshots:
|
|
4039
|
-
inserted = _backfill_five_hour_blocks(conn)
|
|
4040
|
-
# Re-run the 5h dedup migration AFTER backfill creates parents.
|
|
4041
|
-
# The dispatcher above ran while five_hour_blocks was empty, so
|
|
4042
|
-
# the dedup handler no-op'd and stamped its marker. Snapshot
|
|
4043
|
-
# keys can carry jitter beyond the 600s canonical floor (the
|
|
4044
|
-
# 003_* migration handles up to 1800s grouping), so the
|
|
4045
|
-
# backfill's `DISTINCT five_hour_window_key` over those keys
|
|
4046
|
-
# can produce duplicate parent rows for one physical 5h
|
|
4047
|
-
# window. Without this re-invocation those duplicates persist
|
|
4048
|
-
# forever — the marker says it ran. Handler owns its own
|
|
4049
|
-
# BEGIN/COMMIT and is idempotent (no groups → no-op).
|
|
4050
|
-
#
|
|
4051
|
-
# Honor `db skip` here as well: if the operator marked 003 as
|
|
4052
|
-
# skipped (e.g., poison pill on their machine), we must NOT
|
|
4053
|
-
# back-door run the handler. Duplicates introduced by the
|
|
4054
|
-
# backfill will persist until they `db unskip` — which is the
|
|
4055
|
-
# explicit choice the skip records. Failure path mirrors the
|
|
4056
|
-
# dispatcher's contract: route through _log_migration_error so
|
|
4057
|
-
# the next interactive command renders the banner, and clear
|
|
4058
|
-
# the log entry on success so the banner auto-dismisses.
|
|
4059
|
-
if inserted > 0:
|
|
4060
|
-
target_name = "003_merge_5h_block_duplicates_v1"
|
|
4061
|
-
try:
|
|
4062
|
-
skipped = {
|
|
4063
|
-
row[0] for row in conn.execute(
|
|
4064
|
-
"SELECT name FROM schema_migrations_skipped"
|
|
4065
|
-
).fetchall()
|
|
4066
|
-
}
|
|
4067
|
-
except sqlite3.OperationalError:
|
|
4068
|
-
skipped = set()
|
|
4069
|
-
if target_name not in skipped:
|
|
4070
|
-
for _m in _STATS_MIGRATIONS:
|
|
4071
|
-
if _m.name == target_name:
|
|
4072
|
-
qualified = f"stats.db:{target_name}"
|
|
4073
|
-
try:
|
|
4074
|
-
_m.handler(conn)
|
|
4075
|
-
_clear_migration_error_log_entries(qualified)
|
|
4076
|
-
except Exception as exc:
|
|
4077
|
-
_log_migration_error(
|
|
4078
|
-
name=qualified,
|
|
4079
|
-
exc=exc,
|
|
4080
|
-
tb=traceback.format_exc(),
|
|
4081
|
-
)
|
|
4082
|
-
eprint(f"[migration {qualified}] failed: {exc}")
|
|
4083
|
-
break
|
|
4084
|
-
|
|
4085
|
-
conn.commit()
|
|
4086
|
-
return conn
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
3472
|
@dataclass
|
|
4091
3473
|
class CacheModelBreakdown:
|
|
4092
3474
|
model_name: str
|
|
@@ -4589,85 +3971,6 @@ def compute_week_cost(
|
|
|
4589
3971
|
)
|
|
4590
3972
|
|
|
4591
3973
|
|
|
4592
|
-
def _canonicalize_optional_iso(value: str | None, label: str) -> str | None:
|
|
4593
|
-
if value is None:
|
|
4594
|
-
return None
|
|
4595
|
-
s = value.strip()
|
|
4596
|
-
if s == "":
|
|
4597
|
-
return None
|
|
4598
|
-
normalized = _normalize_week_boundary_dt(parse_iso_datetime(s, label)).astimezone(dt.timezone.utc)
|
|
4599
|
-
return normalized.isoformat(timespec="seconds")
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
@dataclass(frozen=True)
|
|
4603
|
-
class WeekRef:
|
|
4604
|
-
week_start: dt.date
|
|
4605
|
-
week_end: dt.date | None
|
|
4606
|
-
week_start_at: str | None
|
|
4607
|
-
week_end_at: str | None
|
|
4608
|
-
key: str
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
def make_week_ref(
|
|
4612
|
-
week_start_date: str,
|
|
4613
|
-
week_end_date: str | None,
|
|
4614
|
-
week_start_at: str | None = None,
|
|
4615
|
-
week_end_at: str | None = None,
|
|
4616
|
-
) -> WeekRef:
|
|
4617
|
-
week_start = dt.date.fromisoformat(week_start_date)
|
|
4618
|
-
week_end = dt.date.fromisoformat(week_end_date) if week_end_date else None
|
|
4619
|
-
start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
|
|
4620
|
-
end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
|
|
4621
|
-
|
|
4622
|
-
return WeekRef(
|
|
4623
|
-
week_start=week_start,
|
|
4624
|
-
week_end=week_end,
|
|
4625
|
-
week_start_at=start_at,
|
|
4626
|
-
week_end_at=end_at,
|
|
4627
|
-
key=week_start.isoformat(),
|
|
4628
|
-
)
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
def _get_latest_row_for_week(
|
|
4632
|
-
conn: sqlite3.Connection,
|
|
4633
|
-
table_name: str,
|
|
4634
|
-
week_ref: WeekRef,
|
|
4635
|
-
as_of_utc: str | None = None,
|
|
4636
|
-
) -> sqlite3.Row | None:
|
|
4637
|
-
if as_of_utc is None:
|
|
4638
|
-
return conn.execute(
|
|
4639
|
-
f"""
|
|
4640
|
-
SELECT *
|
|
4641
|
-
FROM {table_name}
|
|
4642
|
-
WHERE week_start_date = ?
|
|
4643
|
-
ORDER BY captured_at_utc DESC, id DESC
|
|
4644
|
-
LIMIT 1
|
|
4645
|
-
""",
|
|
4646
|
-
(week_ref.week_start.isoformat(),),
|
|
4647
|
-
).fetchone()
|
|
4648
|
-
return conn.execute(
|
|
4649
|
-
f"""
|
|
4650
|
-
SELECT *
|
|
4651
|
-
FROM {table_name}
|
|
4652
|
-
WHERE week_start_date = ?
|
|
4653
|
-
AND captured_at_utc <= ?
|
|
4654
|
-
ORDER BY captured_at_utc DESC, id DESC
|
|
4655
|
-
LIMIT 1
|
|
4656
|
-
""",
|
|
4657
|
-
(week_ref.week_start.isoformat(), as_of_utc),
|
|
4658
|
-
).fetchone()
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
def get_latest_usage_for_week(
|
|
4662
|
-
conn: sqlite3.Connection,
|
|
4663
|
-
week_ref: WeekRef,
|
|
4664
|
-
as_of_utc: str | None = None,
|
|
4665
|
-
) -> sqlite3.Row | None:
|
|
4666
|
-
return _get_latest_row_for_week(
|
|
4667
|
-
conn, "weekly_usage_snapshots", week_ref, as_of_utc=as_of_utc,
|
|
4668
|
-
)
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
3974
|
def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
|
|
4672
3975
|
return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
|
|
4673
3976
|
|
|
@@ -5484,6 +4787,18 @@ def _load_recorded_five_hour_windows(
|
|
|
5484
4787
|
else:
|
|
5485
4788
|
d = d.astimezone(dt.timezone.utc)
|
|
5486
4789
|
credit_moments.append(d)
|
|
4790
|
+
# Issue #44: the inner-loop break below latches onto the
|
|
4791
|
+
# first credit in [next_bs, rs]. With two credits inside
|
|
4792
|
+
# the same pre-credit canonical 5h window, the wrong one
|
|
4793
|
+
# (the later one) wins when SQLite returns rows in
|
|
4794
|
+
# insertion order rather than time order — collapsing
|
|
4795
|
+
# two distinct truncated anchors onto the same floored
|
|
4796
|
+
# bucket and silently dropping one via override-map
|
|
4797
|
+
# overwrite. Sort once so the break consistently picks
|
|
4798
|
+
# the EARLIEST credit, which is the one that actually
|
|
4799
|
+
# ended the earlier block (its floor equals the next
|
|
4800
|
+
# block's block_start_at by construction).
|
|
4801
|
+
credit_moments.sort()
|
|
5487
4802
|
except sqlite3.DatabaseError:
|
|
5488
4803
|
credit_moments = []
|
|
5489
4804
|
except (sqlite3.DatabaseError, OSError):
|
|
@@ -6358,46 +5673,6 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
6358
5673
|
return 0
|
|
6359
5674
|
|
|
6360
5675
|
|
|
6361
|
-
def _command_as_of() -> dt.datetime:
|
|
6362
|
-
"""Testing hook: CCTALLY_AS_OF env var overrides wall-clock `now` for
|
|
6363
|
-
time-dependent commands. Shared by cmd_project, cmd_weekly,
|
|
6364
|
-
cmd_cache_report, cmd_codex_weekly, cmd_diff (and any future
|
|
6365
|
-
time-dependent command). Format: ISO-8601 with Z or explicit tz offset.
|
|
6366
|
-
"""
|
|
6367
|
-
override = os.environ.get("CCTALLY_AS_OF")
|
|
6368
|
-
if override:
|
|
6369
|
-
override = override.strip()
|
|
6370
|
-
if override.endswith("Z"):
|
|
6371
|
-
override = override[:-1] + "+00:00"
|
|
6372
|
-
return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
|
|
6373
|
-
return dt.datetime.now(dt.timezone.utc)
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
def _now_utc() -> dt.datetime:
|
|
6377
|
-
"""UTC now, with CCTALLY_AS_OF env override for fixture-stability.
|
|
6378
|
-
|
|
6379
|
-
Single time source for the `update` subcommand and its supporting
|
|
6380
|
-
state machine (TTL gates, ``remind_after.until_utc`` comparisons,
|
|
6381
|
-
log timestamps, install-method detection cache). Mirrors the
|
|
6382
|
-
documented CCTALLY_AS_OF precedent (see CLAUDE.md — `project` has
|
|
6383
|
-
a hidden `CCTALLY_AS_OF` env hook, and `_command_as_of` /
|
|
6384
|
-
`_share_now_utc` reuse it for `weekly`/`forecast`/share-render).
|
|
6385
|
-
Accepts ISO-8601 with `Z` or explicit offset; result is always
|
|
6386
|
-
tz-aware UTC.
|
|
6387
|
-
|
|
6388
|
-
Raises ValueError on malformed CCTALLY_AS_OF — deliberate fail-loud
|
|
6389
|
-
for the dev hook so fixture authors notice typos immediately rather
|
|
6390
|
-
than silently falling back to wall-clock time.
|
|
6391
|
-
"""
|
|
6392
|
-
override = os.environ.get("CCTALLY_AS_OF")
|
|
6393
|
-
if override:
|
|
6394
|
-
override = override.strip()
|
|
6395
|
-
if override.endswith("Z"):
|
|
6396
|
-
override = override[:-1] + "+00:00"
|
|
6397
|
-
return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
|
|
6398
|
-
return dt.datetime.now(dt.timezone.utc)
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
5676
|
def _load_week_snapshots(
|
|
6402
5677
|
since: dt.datetime, until: dt.datetime
|
|
6403
5678
|
) -> dict[dt.datetime, float]:
|
|
@@ -7632,6 +6907,14 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
7632
6907
|
# 86→0 and 34→0 cases while filtering 35→33-style jitter.
|
|
7633
6908
|
_RESET_PCT_DROP_THRESHOLD = 25.0
|
|
7634
6909
|
|
|
6910
|
+
# In-place 5h-credit threshold. Mirrors `_RESET_PCT_DROP_THRESHOLD` but
|
|
6911
|
+
# scaled down for the 5h dimension: typical 5h usage stays under ~10pp in
|
|
6912
|
+
# a single block, so a 5pp drop sits well above natural variation while
|
|
6913
|
+
# proportionally being a larger signal than 25pp is on the weekly scale.
|
|
6914
|
+
# See spec docs/superpowers/specs/2026-05-16-5h-in-place-credit-detection.md
|
|
6915
|
+
# §2.1 (Q1) for rationale.
|
|
6916
|
+
_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = 5.0
|
|
6917
|
+
|
|
7635
6918
|
|
|
7636
6919
|
def _week_ref_has_reset_event(
|
|
7637
6920
|
conn: sqlite3.Connection, ref: WeekRef
|
|
@@ -9457,6 +8740,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
9457
8740
|
# to fill seven_day_pct_at_block_end on the active row.
|
|
9458
8741
|
latest_7d, latest_window_key = _latest_seven_day_and_window(conn)
|
|
9459
8742
|
|
|
8743
|
+
# Pre-load credit events for every window_key the rows query
|
|
8744
|
+
# returned. Single index-scan over `five_hour_reset_events`;
|
|
8745
|
+
# build a window_key -> list[Credit] map keyed for in-process
|
|
8746
|
+
# JOIN against each block dict. Used by both the text/JSON
|
|
8747
|
+
# render path AND the share-output snapshot wiring (spec §5.1.1).
|
|
8748
|
+
# Loaded in a single pass — no per-block SELECT.
|
|
8749
|
+
credit_rows = conn.execute(
|
|
8750
|
+
"SELECT five_hour_window_key, prior_percent, post_percent, "
|
|
8751
|
+
" effective_reset_at_utc "
|
|
8752
|
+
" FROM five_hour_reset_events "
|
|
8753
|
+
" ORDER BY five_hour_window_key, effective_reset_at_utc"
|
|
8754
|
+
).fetchall()
|
|
8755
|
+
credits_by_window: dict[int, list[dict]] = {}
|
|
8756
|
+
for cr in credit_rows:
|
|
8757
|
+
credits_by_window.setdefault(
|
|
8758
|
+
int(cr["five_hour_window_key"]), []
|
|
8759
|
+
).append({
|
|
8760
|
+
"effectiveResetAtUtc": cr["effective_reset_at_utc"],
|
|
8761
|
+
"priorPercent": float(cr["prior_percent"]),
|
|
8762
|
+
"postPercent": float(cr["post_percent"]),
|
|
8763
|
+
"deltaPp": round(
|
|
8764
|
+
float(cr["post_percent"]) - float(cr["prior_percent"]), 1
|
|
8765
|
+
),
|
|
8766
|
+
})
|
|
8767
|
+
|
|
9460
8768
|
# Build per-block dicts with the active-flag side-channel.
|
|
9461
8769
|
block_dicts: list[dict] = []
|
|
9462
8770
|
for r in rows:
|
|
@@ -9465,6 +8773,11 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
9465
8773
|
d["__is_active"] = is_active
|
|
9466
8774
|
if is_active and latest_7d is not None:
|
|
9467
8775
|
d["seven_day_pct_at_block_end"] = latest_7d
|
|
8776
|
+
# Side-channel (parallel to __is_active): list of credit
|
|
8777
|
+
# event dicts for this block's window. Empty list when none.
|
|
8778
|
+
d["__credits"] = credits_by_window.get(
|
|
8779
|
+
int(d["five_hour_window_key"]), []
|
|
8780
|
+
)
|
|
9468
8781
|
block_dicts.append(d)
|
|
9469
8782
|
|
|
9470
8783
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
@@ -9578,18 +8891,50 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
|
|
|
9578
8891
|
)
|
|
9579
8892
|
return 2
|
|
9580
8893
|
|
|
8894
|
+
# Spec §5.2: ORDER BY captured_at_utc ASC (NOT percent_threshold)
|
|
8895
|
+
# so post-credit segments interleave with pre-credit ones in
|
|
8896
|
+
# time-order — same human threshold number can appear twice
|
|
8897
|
+
# (once per reset_event_id segment) and must render in the
|
|
8898
|
+
# order it crossed. Bucket B per §3.2: read ALL segments (no
|
|
8899
|
+
# ``reset_event_id`` filter).
|
|
9581
8900
|
milestones = conn.execute(
|
|
9582
8901
|
"""
|
|
9583
8902
|
SELECT percent_threshold, captured_at_utc,
|
|
9584
8903
|
block_cost_usd, marginal_cost_usd,
|
|
9585
|
-
seven_day_pct_at_crossing
|
|
8904
|
+
seven_day_pct_at_crossing, reset_event_id
|
|
9586
8905
|
FROM five_hour_milestones
|
|
9587
8906
|
WHERE block_id = ?
|
|
9588
|
-
ORDER BY
|
|
8907
|
+
ORDER BY captured_at_utc ASC, id ASC
|
|
9589
8908
|
""",
|
|
9590
8909
|
(block["id"],),
|
|
9591
8910
|
).fetchall()
|
|
9592
8911
|
|
|
8912
|
+
# Spec §5.2 — load in-place credit events for this block's
|
|
8913
|
+
# window, ascending by effective_reset_at_utc, so the text
|
|
8914
|
+
# renderer can interleave a ``⚡ CREDIT -Xpp @ HH:MM`` divider
|
|
8915
|
+
# row between pre- and post-credit milestone segments and JSON
|
|
8916
|
+
# consumers see the parallel ``credits[]`` array (Section 5.2).
|
|
8917
|
+
credit_rows = conn.execute(
|
|
8918
|
+
"""
|
|
8919
|
+
SELECT effective_reset_at_utc, prior_percent, post_percent
|
|
8920
|
+
FROM five_hour_reset_events
|
|
8921
|
+
WHERE five_hour_window_key = ?
|
|
8922
|
+
ORDER BY effective_reset_at_utc ASC
|
|
8923
|
+
""",
|
|
8924
|
+
(block["five_hour_window_key"],),
|
|
8925
|
+
).fetchall()
|
|
8926
|
+
credits_list: list[dict] = [
|
|
8927
|
+
{
|
|
8928
|
+
"effectiveResetAtUtc": c["effective_reset_at_utc"],
|
|
8929
|
+
"priorPercent": float(c["prior_percent"]),
|
|
8930
|
+
"postPercent": float(c["post_percent"]),
|
|
8931
|
+
"deltaPp": round(
|
|
8932
|
+
float(c["post_percent"]) - float(c["prior_percent"]), 1
|
|
8933
|
+
),
|
|
8934
|
+
}
|
|
8935
|
+
for c in credit_rows
|
|
8936
|
+
]
|
|
8937
|
+
|
|
9593
8938
|
crossed = bool(block.get("crossed_seven_day_reset"))
|
|
9594
8939
|
p_start = block.get("seven_day_pct_at_block_start")
|
|
9595
8940
|
p_end = block.get("seven_day_pct_at_block_end")
|
|
@@ -9626,6 +8971,10 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
|
|
|
9626
8971
|
"sevenDayPctDeltaPp": delta,
|
|
9627
8972
|
"crossedSevenDayReset": crossed,
|
|
9628
8973
|
}
|
|
8974
|
+
# Spec §5.2: expose ``resetEventId`` on each milestone so JSON
|
|
8975
|
+
# consumers can disambiguate post-credit threshold repeats from
|
|
8976
|
+
# pre-credit ones. ``0`` is the pre-credit/no-credit sentinel
|
|
8977
|
+
# (matches the schema default).
|
|
9629
8978
|
ms_out = [
|
|
9630
8979
|
{
|
|
9631
8980
|
"percentThreshold": m["percent_threshold"],
|
|
@@ -9636,13 +8985,23 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
|
|
|
9636
8985
|
else round(m["marginal_cost_usd"], 9)
|
|
9637
8986
|
),
|
|
9638
8987
|
"sevenDayPctAtCrossing": m["seven_day_pct_at_crossing"],
|
|
8988
|
+
"resetEventId": int(m["reset_event_id"] or 0),
|
|
9639
8989
|
}
|
|
9640
8990
|
for m in milestones
|
|
9641
8991
|
]
|
|
9642
8992
|
|
|
9643
8993
|
if args.json:
|
|
8994
|
+
# Spec §5.2: ``credits`` is the parallel array to
|
|
8995
|
+
# ``milestones`` — same shape as the ``credits`` field on
|
|
8996
|
+
# ``five-hour-blocks --json`` (§5.1). Stacked credits across
|
|
8997
|
+
# distinct 10-min slots produce multiple entries.
|
|
9644
8998
|
print(json.dumps(
|
|
9645
|
-
{
|
|
8999
|
+
{
|
|
9000
|
+
"schemaVersion": 1,
|
|
9001
|
+
"block": block_out,
|
|
9002
|
+
"milestones": ms_out,
|
|
9003
|
+
"credits": credits_list,
|
|
9004
|
+
},
|
|
9646
9005
|
indent=2,
|
|
9647
9006
|
))
|
|
9648
9007
|
return 0
|
|
@@ -9683,7 +9042,47 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
|
|
|
9683
9042
|
headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost",
|
|
9684
9043
|
"7d at crossing"]
|
|
9685
9044
|
rows = []
|
|
9686
|
-
|
|
9045
|
+
# Spec §5.2 — merged event stream. Interleave milestones and
|
|
9046
|
+
# credits in time-order (``capturedAt`` for milestones,
|
|
9047
|
+
# ``effectiveResetAtUtc`` for credits). Credits render as a
|
|
9048
|
+
# divider row with ``⚡ CREDIT`` in the Threshold cell and the
|
|
9049
|
+
# delta-pp + HH:MM in the rightmost cell; the milestone row
|
|
9050
|
+
# numbering counter (``#``) continues across the divider so the
|
|
9051
|
+
# ordinal still reflects "the Nth event in this block."
|
|
9052
|
+
merged_events: list[tuple[str, dict]] = []
|
|
9053
|
+
for m in ms_out:
|
|
9054
|
+
merged_events.append(("milestone", m))
|
|
9055
|
+
for c in credits_list:
|
|
9056
|
+
merged_events.append(("credit", c))
|
|
9057
|
+
merged_events.sort(key=lambda ev: (
|
|
9058
|
+
ev[1]["effectiveResetAtUtc"] if ev[0] == "credit"
|
|
9059
|
+
else ev[1]["capturedAt"]
|
|
9060
|
+
))
|
|
9061
|
+
idx = 0
|
|
9062
|
+
for kind, ev in merged_events:
|
|
9063
|
+
idx += 1
|
|
9064
|
+
if kind == "credit":
|
|
9065
|
+
# Spec §5.2: ⚡ CREDIT -Xpp @ HH:MM divider row.
|
|
9066
|
+
# HH:MM rendered in the display tz via format_display_dt.
|
|
9067
|
+
# ``format_display_dt`` is the documented chokepoint for
|
|
9068
|
+
# human-displayed datetimes (CLAUDE.md). The deltaPp
|
|
9069
|
+
# value is float; format as integer ppm (mirrors the
|
|
9070
|
+
# five-hour-blocks chip in §5.1).
|
|
9071
|
+
hhmm = format_display_dt(
|
|
9072
|
+
ev["effectiveResetAtUtc"],
|
|
9073
|
+
args._resolved_tz,
|
|
9074
|
+
fmt="%H:%M",
|
|
9075
|
+
suffix=False,
|
|
9076
|
+
)
|
|
9077
|
+
rows.append([
|
|
9078
|
+
str(idx),
|
|
9079
|
+
"⚡ CREDIT",
|
|
9080
|
+
f"{ev['deltaPp']:+.0f}pp",
|
|
9081
|
+
"",
|
|
9082
|
+
f"@ {hhmm}",
|
|
9083
|
+
])
|
|
9084
|
+
continue
|
|
9085
|
+
m = ev
|
|
9687
9086
|
cum = f"${m['blockCostUSD']:.6f}"
|
|
9688
9087
|
marg = (
|
|
9689
9088
|
"n/a" if m["marginalCostUSD"] is None
|
|
@@ -13832,8 +13231,21 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13832
13231
|
used_pct = float(r.get("final_five_hour_percent") or 0.0)
|
|
13833
13232
|
crossed = bool(r.get("crossed_seven_day_reset"))
|
|
13834
13233
|
cell_text = "⚡" if crossed else "—"
|
|
13234
|
+
# Spec §5.1.1 (Codex r2 finding 3): consume the ``__credits``
|
|
13235
|
+
# side-channel set by ``cmd_five_hour_blocks`` and append a
|
|
13236
|
+
# ``⚡ -Xpp, -Ypp`` chip to the block_start cell. Pure-string
|
|
13237
|
+
# cell content flows uniformly through markdown / HTML table /
|
|
13238
|
+
# SVG text renderers without per-format additions. Symmetric to
|
|
13239
|
+
# the existing ⚡ glyph in the cross_reset cell — by position
|
|
13240
|
+
# (block_start suffix vs. dedicated column) the two annotations
|
|
13241
|
+
# remain visually distinguishable.
|
|
13242
|
+
credits = r.get("__credits") or []
|
|
13243
|
+
block_cell = block_lbl
|
|
13244
|
+
if credits:
|
|
13245
|
+
deltas = ", ".join(f"{c['deltaPp']:+.0f}pp" for c in credits)
|
|
13246
|
+
block_cell = f"{block_lbl} ⚡ {deltas}"
|
|
13835
13247
|
snap_rows.append(_lib_share.Row(cells={
|
|
13836
|
-
"block_start": _lib_share.TextCell(
|
|
13248
|
+
"block_start": _lib_share.TextCell(block_cell),
|
|
13837
13249
|
"cost": _lib_share.MoneyCell(cost_usd),
|
|
13838
13250
|
"used_pct": _lib_share.PercentCell(used_pct),
|
|
13839
13251
|
"cross_reset": _lib_share.TextCell(cell_text),
|