cctally 1.7.1 → 1.7.2
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 +20 -0
- package/bin/_cctally_dashboard.py +192 -7
- package/bin/_cctally_db.py +108 -0
- package/bin/_cctally_record.py +199 -14
- package/bin/_cctally_tui.py +117 -11
- package/bin/_lib_blocks.py +33 -6
- package/bin/_lib_doctor.py +79 -0
- package/bin/cctally +650 -23
- package/package.json +1 -1
package/bin/cctally
CHANGED
|
@@ -3817,12 +3817,20 @@ def open_db() -> sqlite3.Connection:
|
|
|
3817
3817
|
marginal_cost_usd REAL,
|
|
3818
3818
|
usage_snapshot_id INTEGER NOT NULL,
|
|
3819
3819
|
cost_snapshot_id INTEGER NOT NULL,
|
|
3820
|
-
|
|
3820
|
+
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
3821
|
+
UNIQUE(week_start_date, percent_threshold, reset_event_id)
|
|
3821
3822
|
)
|
|
3822
3823
|
"""
|
|
3823
3824
|
)
|
|
3824
3825
|
|
|
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).
|
|
3826
3834
|
|
|
3827
3835
|
# alerted_at: populated by the alert-dispatch path when a milestone-INSERT
|
|
3828
3836
|
# row's threshold matches the user's configured alerts.weekly_thresholds /
|
|
@@ -4717,15 +4725,27 @@ def insert_cost_snapshot(
|
|
|
4717
4725
|
def get_max_milestone_for_week(
|
|
4718
4726
|
conn: sqlite3.Connection,
|
|
4719
4727
|
week_start_date: str,
|
|
4728
|
+
*,
|
|
4729
|
+
reset_event_id: int = 0,
|
|
4720
4730
|
) -> int | None:
|
|
4721
|
-
"""Return the highest percent_threshold recorded for a week,
|
|
4731
|
+
"""Return the highest percent_threshold recorded for a week's segment,
|
|
4732
|
+
or None.
|
|
4733
|
+
|
|
4734
|
+
``reset_event_id`` (v1.7.2): default 0 (= pre-credit / no-event
|
|
4735
|
+
sentinel) preserves legacy behavior on un-credited weeks. When an
|
|
4736
|
+
in-place credit lifts a week into a new segment, callers pass the
|
|
4737
|
+
segment id so the segment's threshold ledger is independent of the
|
|
4738
|
+
pre-credit one — the post-credit 1% / 2% / 3% milestones fire even
|
|
4739
|
+
if the pre-credit segment already crossed those thresholds.
|
|
4740
|
+
"""
|
|
4722
4741
|
row = conn.execute(
|
|
4723
4742
|
"""
|
|
4724
4743
|
SELECT MAX(percent_threshold) AS max_pct
|
|
4725
4744
|
FROM percent_milestones
|
|
4726
4745
|
WHERE week_start_date = ?
|
|
4746
|
+
AND reset_event_id = ?
|
|
4727
4747
|
""",
|
|
4728
|
-
(week_start_date,),
|
|
4748
|
+
(week_start_date, reset_event_id),
|
|
4729
4749
|
).fetchone()
|
|
4730
4750
|
if row and row["max_pct"] is not None:
|
|
4731
4751
|
return int(row["max_pct"])
|
|
@@ -4736,15 +4756,27 @@ def get_milestone_cost_for_week(
|
|
|
4736
4756
|
conn: sqlite3.Connection,
|
|
4737
4757
|
week_start_date: str,
|
|
4738
4758
|
percent_threshold: int,
|
|
4759
|
+
*,
|
|
4760
|
+
reset_event_id: int = 0,
|
|
4739
4761
|
) -> float | None:
|
|
4740
|
-
"""Return the cumulative_cost_usd for a specific threshold,
|
|
4762
|
+
"""Return the cumulative_cost_usd for a specific (week, threshold,
|
|
4763
|
+
segment), or None.
|
|
4764
|
+
|
|
4765
|
+
``reset_event_id`` (v1.7.2): segment-aware lookup. Default 0 preserves
|
|
4766
|
+
legacy behavior. Used by ``maybe_record_milestone`` to compute the
|
|
4767
|
+
marginal cost between consecutive thresholds inside the SAME segment
|
|
4768
|
+
— without the filter, the post-credit threshold-3 row would compute
|
|
4769
|
+
its marginal against the pre-credit threshold-2 cost (wrong segment).
|
|
4770
|
+
"""
|
|
4741
4771
|
row = conn.execute(
|
|
4742
4772
|
"""
|
|
4743
4773
|
SELECT cumulative_cost_usd
|
|
4744
4774
|
FROM percent_milestones
|
|
4745
|
-
WHERE week_start_date = ?
|
|
4775
|
+
WHERE week_start_date = ?
|
|
4776
|
+
AND percent_threshold = ?
|
|
4777
|
+
AND reset_event_id = ?
|
|
4746
4778
|
""",
|
|
4747
|
-
(week_start_date, percent_threshold),
|
|
4779
|
+
(week_start_date, percent_threshold, reset_event_id),
|
|
4748
4780
|
).fetchone()
|
|
4749
4781
|
if row:
|
|
4750
4782
|
return float(row["cumulative_cost_usd"])
|
|
@@ -4781,18 +4813,29 @@ def insert_percent_milestone(
|
|
|
4781
4813
|
five_hour_percent_at_crossing: float | None = None,
|
|
4782
4814
|
*,
|
|
4783
4815
|
commit: bool = True,
|
|
4816
|
+
reset_event_id: int = 0,
|
|
4784
4817
|
) -> int:
|
|
4785
4818
|
"""Insert a percent_milestones row idempotently.
|
|
4786
4819
|
|
|
4787
4820
|
Returns the SQLite rowcount: 1 on a genuinely new crossing, 0 if a row
|
|
4788
|
-
for (week_start_date, percent_threshold) already exists.
|
|
4789
|
-
concurrent record-usage instances — aligns with the
|
|
4790
|
-
INSERT OR IGNORE pattern (see five_hour_milestones
|
|
4821
|
+
for (week_start_date, percent_threshold, reset_event_id) already exists.
|
|
4822
|
+
Race-safe under concurrent record-usage instances — aligns with the
|
|
4823
|
+
existing 5h-milestone INSERT OR IGNORE pattern (see five_hour_milestones
|
|
4824
|
+
write path).
|
|
4825
|
+
|
|
4826
|
+
``reset_event_id`` (v1.7.2 segment column, migration 005): defaults to
|
|
4827
|
+
``0`` (= pre-credit / no-event sentinel). When an in-place credit fires
|
|
4828
|
+
for the current week, the caller (``maybe_record_milestone``) resolves
|
|
4829
|
+
the active segment from ``week_reset_events`` and passes it in so
|
|
4830
|
+
post-credit threshold crossings land as a SEPARATE row from any
|
|
4831
|
+
pre-credit one at the same (week, threshold). The UNIQUE constraint
|
|
4832
|
+
is on the 3-tuple, so (week=W, threshold=T, segment=0) and (W, T,
|
|
4833
|
+
event_id) coexist.
|
|
4791
4834
|
|
|
4792
4835
|
Callers that need the row id MUST follow up with an explicit
|
|
4793
4836
|
`SELECT id FROM percent_milestones WHERE week_start_date=? AND
|
|
4794
|
-
percent_threshold=?` query — `lastrowid` is
|
|
4795
|
-
is the silent-duplicate target.
|
|
4837
|
+
percent_threshold=? AND reset_event_id=?` query — `lastrowid` is
|
|
4838
|
+
unreliable when the row is the silent-duplicate target.
|
|
4796
4839
|
|
|
4797
4840
|
``commit=False`` skips the inner ``conn.commit()`` so the caller can
|
|
4798
4841
|
bundle the INSERT with a follow-up ``alerted_at`` UPDATE in a single
|
|
@@ -4817,9 +4860,10 @@ def insert_percent_milestone(
|
|
|
4817
4860
|
marginal_cost_usd,
|
|
4818
4861
|
usage_snapshot_id,
|
|
4819
4862
|
cost_snapshot_id,
|
|
4820
|
-
five_hour_percent_at_crossing
|
|
4863
|
+
five_hour_percent_at_crossing,
|
|
4864
|
+
reset_event_id
|
|
4821
4865
|
)
|
|
4822
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4866
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4823
4867
|
""",
|
|
4824
4868
|
(
|
|
4825
4869
|
now_utc_iso(),
|
|
@@ -4833,6 +4877,7 @@ def insert_percent_milestone(
|
|
|
4833
4877
|
usage_snapshot_id,
|
|
4834
4878
|
cost_snapshot_id,
|
|
4835
4879
|
five_hour_percent_at_crossing,
|
|
4880
|
+
reset_event_id,
|
|
4836
4881
|
),
|
|
4837
4882
|
)
|
|
4838
4883
|
if commit:
|
|
@@ -5343,10 +5388,31 @@ def _select_non_overlapping_recorded_windows(
|
|
|
5343
5388
|
def _load_recorded_five_hour_windows(
|
|
5344
5389
|
range_start: dt.datetime,
|
|
5345
5390
|
range_end: dt.datetime,
|
|
5346
|
-
) -> list[dt.datetime]:
|
|
5391
|
+
) -> tuple[list[dt.datetime], dict[dt.datetime, dt.datetime]]:
|
|
5347
5392
|
"""Return sorted, UTC-aware recorded ``five_hour_resets_at`` values
|
|
5348
5393
|
that anchor real 5h windows in ``[range_start, range_end]``.
|
|
5349
5394
|
|
|
5395
|
+
Two sources contribute to the merged anchor set:
|
|
5396
|
+
|
|
5397
|
+
1. ``weekly_usage_snapshots.five_hour_resets_at`` — every
|
|
5398
|
+
record-usage tick stores the API-derived reset moment here. The
|
|
5399
|
+
count of supporting rows weights each anchor (low-count anchors
|
|
5400
|
+
are downvoted in ``_select_non_overlapping_recorded_windows``).
|
|
5401
|
+
|
|
5402
|
+
2. ``five_hour_blocks.five_hour_resets_at`` — the canonical
|
|
5403
|
+
API-anchored rollup table. Each row represents ONE accepted 5h
|
|
5404
|
+
window after ``maybe_update_five_hour_block`` has merged jittered
|
|
5405
|
+
reset values via ``_canonical_5h_window_key``. These are the
|
|
5406
|
+
authoritative anchors; we count them with a heavy weight (1000)
|
|
5407
|
+
so they always dominate over jittered raw snapshot values when
|
|
5408
|
+
both sources see the same physical window. Without this source,
|
|
5409
|
+
``cctally blocks`` falls back to the heuristic anchor for the
|
|
5410
|
+
ACTIVE row whenever the most recent
|
|
5411
|
+
``weekly_usage_snapshots.five_hour_resets_at`` value disagrees
|
|
5412
|
+
with the canonical anchor — Bug C in v1.7.2 round 3. Tied
|
|
5413
|
+
windows (jitter within 10-minute floor) collapse to the same
|
|
5414
|
+
key and the canonical weight dominates.
|
|
5415
|
+
|
|
5350
5416
|
Each value is parsed as ISO-8601 (the storage format produced by
|
|
5351
5417
|
``cmd_record_usage``) and normalized to UTC. Naive datetimes are
|
|
5352
5418
|
treated as already-UTC. Values are floored to the previous
|
|
@@ -5373,11 +5439,58 @@ def _load_recorded_five_hour_windows(
|
|
|
5373
5439
|
" AND five_hour_resets_at <= ?",
|
|
5374
5440
|
(range_start.isoformat(), range_end.isoformat()),
|
|
5375
5441
|
).fetchall()
|
|
5442
|
+
# Canonical API-anchored windows from the rollup table.
|
|
5443
|
+
# Heavy-weight (1000 per row) so they always dominate over
|
|
5444
|
+
# any jittered raw-snapshot value sharing the same floored
|
|
5445
|
+
# 10-minute bucket. Wrapped in a defensive try in case the
|
|
5446
|
+
# five_hour_blocks table doesn't exist yet (very-old DB on
|
|
5447
|
+
# first open before the bootstrap migration ran).
|
|
5448
|
+
# Pull ``block_start_at`` alongside ``five_hour_resets_at``
|
|
5449
|
+
# so Bug J's overlap-truncation step (below) can preserve
|
|
5450
|
+
# the real display start for credit-truncated blocks.
|
|
5451
|
+
canonical_rows: list[Any] = []
|
|
5452
|
+
try:
|
|
5453
|
+
canonical_rows = conn.execute(
|
|
5454
|
+
"SELECT five_hour_resets_at, block_start_at "
|
|
5455
|
+
"FROM five_hour_blocks "
|
|
5456
|
+
"WHERE five_hour_resets_at IS NOT NULL "
|
|
5457
|
+
" AND five_hour_resets_at >= ? "
|
|
5458
|
+
" AND five_hour_resets_at <= ?",
|
|
5459
|
+
(range_start.isoformat(), range_end.isoformat()),
|
|
5460
|
+
).fetchall()
|
|
5461
|
+
except sqlite3.DatabaseError:
|
|
5462
|
+
canonical_rows = []
|
|
5463
|
+
# In-place credit events — used by Bug J to detect canonical
|
|
5464
|
+
# block overlaps that should be resolved by truncating the
|
|
5465
|
+
# earlier block at the credit moment (rather than dropping
|
|
5466
|
+
# one via _select_non_overlapping_recorded_windows, which
|
|
5467
|
+
# leaves the dropped block's entries unanchored and
|
|
5468
|
+
# rendered as a phantom heuristic "~" row).
|
|
5469
|
+
credit_moments: list[dt.datetime] = []
|
|
5470
|
+
try:
|
|
5471
|
+
credit_rows = conn.execute(
|
|
5472
|
+
"SELECT effective_reset_at_utc "
|
|
5473
|
+
"FROM week_reset_events "
|
|
5474
|
+
"WHERE old_week_end_at = effective_reset_at_utc"
|
|
5475
|
+
).fetchall()
|
|
5476
|
+
for c in credit_rows:
|
|
5477
|
+
raw = c["effective_reset_at_utc"]
|
|
5478
|
+
try:
|
|
5479
|
+
d = dt.datetime.fromisoformat(str(raw))
|
|
5480
|
+
except ValueError:
|
|
5481
|
+
continue
|
|
5482
|
+
if d.tzinfo is None:
|
|
5483
|
+
d = d.replace(tzinfo=dt.timezone.utc)
|
|
5484
|
+
else:
|
|
5485
|
+
d = d.astimezone(dt.timezone.utc)
|
|
5486
|
+
credit_moments.append(d)
|
|
5487
|
+
except sqlite3.DatabaseError:
|
|
5488
|
+
credit_moments = []
|
|
5376
5489
|
except (sqlite3.DatabaseError, OSError):
|
|
5377
5490
|
# OSError covers ensure_dirs() failures (read-only FS, permission
|
|
5378
5491
|
# denied on parent dir) that propagate from open_db() before any
|
|
5379
5492
|
# SQL runs. Either way, fall back to the heuristic anchor path.
|
|
5380
|
-
return []
|
|
5493
|
+
return [], {}
|
|
5381
5494
|
counts: dict[dt.datetime, int] = {}
|
|
5382
5495
|
for row in rows:
|
|
5383
5496
|
raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
|
|
@@ -5393,7 +5506,110 @@ def _load_recorded_five_hour_windows(
|
|
|
5393
5506
|
d = d.astimezone(dt.timezone.utc)
|
|
5394
5507
|
snapped = _floor_to_ten_minutes(d)
|
|
5395
5508
|
counts[snapped] = counts.get(snapped, 0) + 1
|
|
5396
|
-
|
|
5509
|
+
# Overlay canonical rollup anchors at heavy weight. Same flooring
|
|
5510
|
+
# rule so a jittered raw value (e.g. 17:48Z) and its canonicalized
|
|
5511
|
+
# rollup (e.g. 17:50Z) collapse into the same bucket; without that
|
|
5512
|
+
# the high-weight canonical entry would create a NEW bucket and
|
|
5513
|
+
# both would be reported as separate windows, then
|
|
5514
|
+
# `_select_non_overlapping_recorded_windows` (5h-disjoint
|
|
5515
|
+
# invariant) would drop the lower-weight one — but the wrong
|
|
5516
|
+
# one would win when jitter exceeds 10 minutes.
|
|
5517
|
+
#
|
|
5518
|
+
# Bug J (v1.7.2 round-5): collect canonical (block_start, R) pairs
|
|
5519
|
+
# so we can detect in-place-credit overlaps before flattening into
|
|
5520
|
+
# the weighted scheduler. When two canonical 5h blocks overlap AND
|
|
5521
|
+
# an in-place credit event falls inside the overlap, truncate the
|
|
5522
|
+
# EARLIER block's R to the credit moment (floored to 10 min so it
|
|
5523
|
+
# collapses with any same-bucket raw-snapshot value). The
|
|
5524
|
+
# truncated R keeps both blocks visible — without this fix the
|
|
5525
|
+
# earlier block's entries are silently rendered as a phantom
|
|
5526
|
+
# heuristic "~" row by `_group_entries_into_blocks`.
|
|
5527
|
+
canonical_pairs: list[tuple[dt.datetime, dt.datetime]] = []
|
|
5528
|
+
for row in canonical_rows:
|
|
5529
|
+
rs_raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
|
|
5530
|
+
bs_raw = row["block_start_at"] if hasattr(row, "keys") else row[1]
|
|
5531
|
+
if rs_raw is None or bs_raw is None:
|
|
5532
|
+
continue
|
|
5533
|
+
try:
|
|
5534
|
+
rs = dt.datetime.fromisoformat(str(rs_raw))
|
|
5535
|
+
bs = dt.datetime.fromisoformat(str(bs_raw))
|
|
5536
|
+
except ValueError:
|
|
5537
|
+
continue
|
|
5538
|
+
if rs.tzinfo is None:
|
|
5539
|
+
rs = rs.replace(tzinfo=dt.timezone.utc)
|
|
5540
|
+
else:
|
|
5541
|
+
rs = rs.astimezone(dt.timezone.utc)
|
|
5542
|
+
if bs.tzinfo is None:
|
|
5543
|
+
bs = bs.replace(tzinfo=dt.timezone.utc)
|
|
5544
|
+
else:
|
|
5545
|
+
bs = bs.astimezone(dt.timezone.utc)
|
|
5546
|
+
canonical_pairs.append((bs, rs))
|
|
5547
|
+
canonical_pairs.sort(key=lambda p: p[0])
|
|
5548
|
+
|
|
5549
|
+
# Detect overlap-with-credit and replace the earlier R with a
|
|
5550
|
+
# credit-truncated anchor. The (anchor → real_block_start) map is
|
|
5551
|
+
# returned alongside the anchor list so the renderer can show the
|
|
5552
|
+
# real block_start_at on the display row (instead of the default
|
|
5553
|
+
# R - 5h, which would be hours earlier for a 2h-truncated block).
|
|
5554
|
+
block_start_overrides: dict[dt.datetime, dt.datetime] = {}
|
|
5555
|
+
truncated_pairs: list[tuple[dt.datetime, dt.datetime]] = []
|
|
5556
|
+
for i, (bs, rs) in enumerate(canonical_pairs):
|
|
5557
|
+
truncated_R = rs
|
|
5558
|
+
if i + 1 < len(canonical_pairs):
|
|
5559
|
+
next_bs, _next_rs = canonical_pairs[i + 1]
|
|
5560
|
+
if rs > next_bs: # overlap with next block
|
|
5561
|
+
# Look for a credit moment inside [next_bs, rs] — the
|
|
5562
|
+
# part of the earlier block that overlaps the next.
|
|
5563
|
+
for cm in credit_moments:
|
|
5564
|
+
if next_bs <= cm <= rs:
|
|
5565
|
+
cm_floored = _floor_to_ten_minutes(cm)
|
|
5566
|
+
# Only truncate if cm is strictly inside the
|
|
5567
|
+
# earlier block; otherwise leave R alone and
|
|
5568
|
+
# let `_select_non_overlapping_recorded_windows`
|
|
5569
|
+
# drop one via its weight-tiebreaker.
|
|
5570
|
+
if bs < cm_floored < rs:
|
|
5571
|
+
truncated_R = cm_floored
|
|
5572
|
+
block_start_overrides[cm_floored] = bs
|
|
5573
|
+
break
|
|
5574
|
+
truncated_pairs.append((bs, truncated_R))
|
|
5575
|
+
|
|
5576
|
+
# Truncated anchors are credit-adjusted and known-good; bypass the
|
|
5577
|
+
# `_select_non_overlapping_recorded_windows` weighted scheduler for
|
|
5578
|
+
# them (the scheduler treats every R as the END of a fixed 5h
|
|
5579
|
+
# window and would see a truncated R conflicting with the adjacent
|
|
5580
|
+
# canonical block one slot earlier — e.g. truncated R=17:50 would
|
|
5581
|
+
# collide with the prior block's R=15:50 even though their REAL
|
|
5582
|
+
# intervals are [15:50, 17:50] and [10:50, 15:50] respectively —
|
|
5583
|
+
# adjacent, not overlapping). Add their R directly to the selector
|
|
5584
|
+
# input weight (so jittered same-bucket raw values still collapse)
|
|
5585
|
+
# but skip them when computing the overlap-safe subset.
|
|
5586
|
+
truncated_anchors: set[dt.datetime] = set()
|
|
5587
|
+
for bs, rs in truncated_pairs:
|
|
5588
|
+
snapped = _floor_to_ten_minutes(rs)
|
|
5589
|
+
if rs != _floor_to_ten_minutes(rs):
|
|
5590
|
+
if rs in block_start_overrides:
|
|
5591
|
+
block_start_overrides[snapped] = block_start_overrides.pop(rs)
|
|
5592
|
+
# Identify truncated anchors by membership in the override map
|
|
5593
|
+
# (only credit-truncated entries land there).
|
|
5594
|
+
if snapped in block_start_overrides:
|
|
5595
|
+
truncated_anchors.add(snapped)
|
|
5596
|
+
counts[snapped] = counts.get(snapped, 0) + 1000
|
|
5597
|
+
|
|
5598
|
+
non_truncated_items = [
|
|
5599
|
+
(a, w) for a, w in counts.items() if a not in truncated_anchors
|
|
5600
|
+
]
|
|
5601
|
+
selected_non_truncated = _select_non_overlapping_recorded_windows(
|
|
5602
|
+
non_truncated_items
|
|
5603
|
+
)
|
|
5604
|
+
# Merge truncated anchors back in, sorted ascending. Their non-
|
|
5605
|
+
# overlap with the surrounding canonical blocks is guaranteed by
|
|
5606
|
+
# the credit-moment truncation: a truncated R sits strictly
|
|
5607
|
+
# between its real block_start (which equals the prior block's R)
|
|
5608
|
+
# and the next block's R.
|
|
5609
|
+
selected = sorted(
|
|
5610
|
+
list(selected_non_truncated) + list(truncated_anchors)
|
|
5611
|
+
)
|
|
5612
|
+
return selected, block_start_overrides
|
|
5397
5613
|
|
|
5398
5614
|
|
|
5399
5615
|
def cmd_blocks(args: argparse.Namespace) -> int:
|
|
@@ -5438,15 +5654,39 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
5438
5654
|
# reset R just after ``range_end`` (e.g. the active window when
|
|
5439
5655
|
# range_end is wall-clock "now") can still anchor entries that fall
|
|
5440
5656
|
# inside [range_start, range_end].
|
|
5441
|
-
recorded_windows = _load_recorded_five_hour_windows(
|
|
5657
|
+
recorded_windows, block_start_overrides = _load_recorded_five_hour_windows(
|
|
5442
5658
|
range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
|
|
5443
5659
|
)
|
|
5444
5660
|
|
|
5445
5661
|
# Group into blocks
|
|
5446
5662
|
blocks = _group_entries_into_blocks(
|
|
5447
5663
|
all_entries, mode="auto",
|
|
5448
|
-
recorded_windows=recorded_windows,
|
|
5449
|
-
|
|
5664
|
+
recorded_windows=recorded_windows,
|
|
5665
|
+
block_start_overrides=block_start_overrides,
|
|
5666
|
+
now=now_utc,
|
|
5667
|
+
)
|
|
5668
|
+
|
|
5669
|
+
# Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
|
|
5670
|
+
# but a canonical ``five_hour_blocks`` row exists for the current 5h
|
|
5671
|
+
# window key, swap the active block's times to the API-anchored
|
|
5672
|
+
# ``block_start_at`` / ``five_hour_resets_at`` and flip its anchor to
|
|
5673
|
+
# ``"recorded"`` so the renderer drops the ``~`` prefix. The
|
|
5674
|
+
# heuristic anchor can sit in a different 10-minute floor bucket
|
|
5675
|
+
# than the canonical anchor (e.g. 23:00 IDT vs 20:50 IDT — 130 min
|
|
5676
|
+
# apart), so round-3's anchor-overlay in
|
|
5677
|
+
# ``_load_recorded_five_hour_windows`` doesn't catch this case.
|
|
5678
|
+
# Match by the live 5h window key (the same key
|
|
5679
|
+
# ``cmd_five_hour_blocks`` would surface for the ACTIVE row) — falls
|
|
5680
|
+
# back to heuristic behavior whenever the canonical row is missing.
|
|
5681
|
+
#
|
|
5682
|
+
# Bug F (v1.7.2 round-5): pass ``all_entries`` so the swap also
|
|
5683
|
+
# re-aggregates token / cost totals over the canonical interval. The
|
|
5684
|
+
# heuristic block holds only entries from the heuristic anchor
|
|
5685
|
+
# onwards; the canonical block may start earlier and include 1-2h of
|
|
5686
|
+
# additional entries. Without re-aggregation the displayed window
|
|
5687
|
+
# said one thing and the cost said another (live data: window
|
|
5688
|
+
# 20:50→01:50 with $45 cost vs the real $128).
|
|
5689
|
+
_maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc)
|
|
5450
5690
|
|
|
5451
5691
|
if args.json:
|
|
5452
5692
|
print(_blocks_to_json(blocks))
|
|
@@ -5457,6 +5697,105 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
5457
5697
|
return 0
|
|
5458
5698
|
|
|
5459
5699
|
|
|
5700
|
+
def _maybe_swap_active_block_to_canonical(
|
|
5701
|
+
blocks: list[Any],
|
|
5702
|
+
all_entries: list[Any],
|
|
5703
|
+
*,
|
|
5704
|
+
now: dt.datetime,
|
|
5705
|
+
) -> None:
|
|
5706
|
+
"""In-place swap of an ACTIVE heuristic block to its API-anchored
|
|
5707
|
+
canonical window — timestamps AND token/cost totals.
|
|
5708
|
+
|
|
5709
|
+
Looks up the live ``five_hour_window_key`` from the most recent
|
|
5710
|
+
``weekly_usage_snapshots`` row, then joins to ``five_hour_blocks``
|
|
5711
|
+
for that key. If found AND the canonical window still contains
|
|
5712
|
+
``now`` (resets_at > now), rewrites the active block to span the
|
|
5713
|
+
canonical ``[block_start_at, five_hour_resets_at)`` interval and
|
|
5714
|
+
flips ``anchor`` to ``"recorded"``. Token / cost totals are
|
|
5715
|
+
re-aggregated from ``all_entries`` filtered to that interval via
|
|
5716
|
+
``_aggregate_block`` — the canonical window may contain 1-2h more
|
|
5717
|
+
activity than the heuristic grouping did, so the cost shown next
|
|
5718
|
+
to the swapped timestamps stays consistent with them (Bug F).
|
|
5719
|
+
|
|
5720
|
+
No-op when:
|
|
5721
|
+
- No block is active (no ``is_active`` and not gap).
|
|
5722
|
+
- The active block's anchor is already ``"recorded"``.
|
|
5723
|
+
- No live snapshot exists, or the snapshot's ``five_hour_window_key``
|
|
5724
|
+
is NULL.
|
|
5725
|
+
- No canonical ``five_hour_blocks`` row matches the live key.
|
|
5726
|
+
- The canonical window's ``five_hour_resets_at`` is already in
|
|
5727
|
+
the past relative to ``now`` (canonical block is closed; the
|
|
5728
|
+
heuristic block is genuinely the current activity).
|
|
5729
|
+
|
|
5730
|
+
Surgical helper called once from ``cmd_blocks`` after grouping.
|
|
5731
|
+
"""
|
|
5732
|
+
# Find the active (non-gap, heuristic) block — there's at most one.
|
|
5733
|
+
active_idx = None
|
|
5734
|
+
for i, b in enumerate(blocks):
|
|
5735
|
+
if not b.is_gap and b.is_active:
|
|
5736
|
+
active_idx = i
|
|
5737
|
+
break
|
|
5738
|
+
if active_idx is None or blocks[active_idx].anchor != "heuristic":
|
|
5739
|
+
return
|
|
5740
|
+
active = blocks[active_idx]
|
|
5741
|
+
try:
|
|
5742
|
+
with open_db() as conn:
|
|
5743
|
+
snap = conn.execute(
|
|
5744
|
+
"SELECT five_hour_window_key FROM weekly_usage_snapshots "
|
|
5745
|
+
"WHERE five_hour_window_key IS NOT NULL "
|
|
5746
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
5747
|
+
).fetchone()
|
|
5748
|
+
if snap is None or snap["five_hour_window_key"] is None:
|
|
5749
|
+
return
|
|
5750
|
+
key = int(snap["five_hour_window_key"])
|
|
5751
|
+
row = conn.execute(
|
|
5752
|
+
"SELECT block_start_at, five_hour_resets_at "
|
|
5753
|
+
"FROM five_hour_blocks WHERE five_hour_window_key = ? "
|
|
5754
|
+
"LIMIT 1",
|
|
5755
|
+
(key,),
|
|
5756
|
+
).fetchone()
|
|
5757
|
+
except (sqlite3.DatabaseError, OSError):
|
|
5758
|
+
return
|
|
5759
|
+
if row is None:
|
|
5760
|
+
return
|
|
5761
|
+
try:
|
|
5762
|
+
block_start = parse_iso_datetime(
|
|
5763
|
+
row["block_start_at"], "five_hour_blocks.block_start_at"
|
|
5764
|
+
)
|
|
5765
|
+
block_end = parse_iso_datetime(
|
|
5766
|
+
row["five_hour_resets_at"], "five_hour_blocks.five_hour_resets_at"
|
|
5767
|
+
)
|
|
5768
|
+
except ValueError:
|
|
5769
|
+
return
|
|
5770
|
+
# Normalize to UTC for stable comparisons (block_start_at can carry
|
|
5771
|
+
# the host-local offset; five_hour_resets_at is UTC).
|
|
5772
|
+
block_start_utc = block_start.astimezone(dt.timezone.utc)
|
|
5773
|
+
block_end_utc = block_end.astimezone(dt.timezone.utc)
|
|
5774
|
+
# If the canonical window has already ended, don't displace the
|
|
5775
|
+
# heuristic active block — the canonical block is closed and the
|
|
5776
|
+
# heuristic anchor reflects real ongoing activity in a later window.
|
|
5777
|
+
if block_end_utc <= now.astimezone(dt.timezone.utc):
|
|
5778
|
+
return
|
|
5779
|
+
# Re-aggregate entries over the canonical interval. Build a fresh
|
|
5780
|
+
# Block via ``_build_activity_block`` (mode="auto" matches
|
|
5781
|
+
# ``_group_entries_into_blocks``'s default) so every total stays in
|
|
5782
|
+
# one code path — no field-by-field assignment that could drift if
|
|
5783
|
+
# the dataclass grows new fields.
|
|
5784
|
+
canonical_entries = [
|
|
5785
|
+
e for e in all_entries
|
|
5786
|
+
if block_start_utc <= e.timestamp < block_end_utc
|
|
5787
|
+
]
|
|
5788
|
+
rebuilt = _build_activity_block(
|
|
5789
|
+
canonical_entries,
|
|
5790
|
+
block_start_utc,
|
|
5791
|
+
block_end_utc,
|
|
5792
|
+
now.astimezone(dt.timezone.utc),
|
|
5793
|
+
"auto",
|
|
5794
|
+
anchor="recorded",
|
|
5795
|
+
)
|
|
5796
|
+
blocks[active_idx] = rebuilt
|
|
5797
|
+
|
|
5798
|
+
|
|
5460
5799
|
def _parse_cli_date_range(
|
|
5461
5800
|
args: argparse.Namespace,
|
|
5462
5801
|
*,
|
|
@@ -7048,11 +7387,29 @@ def _apply_reset_events_to_weekrefs(
|
|
|
7048
7387
|
week: its API-derived start (= new resets_at - 7d) backdates into
|
|
7049
7388
|
the pre-reset week. Override ref.week_start_at = effective_reset_at_utc
|
|
7050
7389
|
so the new week starts at the actual reset moment.
|
|
7390
|
+
- **In-place credit (v1.7.2 round-3, Bug B).** Detected via the row
|
|
7391
|
+
shape ``old_week_end_at == effective_reset_at_utc`` (the live and
|
|
7392
|
+
backfill detection paths both write this shape — see
|
|
7393
|
+
``test_event_row_old_is_effective_not_cur_end``). For these events,
|
|
7394
|
+
the credited week's ref matches ``new_week_end_at`` (the original
|
|
7395
|
+
resets_at is unchanged), so the post-credit override above
|
|
7396
|
+
rewrites ``week_start_at`` to ``effective``. But the pre-credit
|
|
7397
|
+
segment of the SAME week — where the user spent the bulk of their
|
|
7398
|
+
usage before the credit — is dropped, because no other ref in
|
|
7399
|
+
``refs`` carries ``week_end_at == effective``. Synthesize a
|
|
7400
|
+
pre-credit ref alongside the post-credit one: its
|
|
7401
|
+
``week_start_at`` stays at the ref's original API-derived value,
|
|
7402
|
+
its ``week_end_at`` becomes ``effective`` (closes the pre-credit
|
|
7403
|
+
segment). Credited weeks render as TWO trend rows downstream.
|
|
7051
7404
|
|
|
7052
7405
|
The ref's `week_start` (date) and `key` fields are intentionally left at
|
|
7053
7406
|
the API-derived values — they're the lookup keys for
|
|
7054
7407
|
weekly_usage_snapshots / weekly_cost_snapshots. Only the display-facing
|
|
7055
7408
|
`week_start_at` / `week_end_at` (and the derived `week_end` date) shift.
|
|
7409
|
+
Both the pre-credit and post-credit synthesized refs share the same
|
|
7410
|
+
`key` so downstream per-segment readers
|
|
7411
|
+
(``cmd_percent_breakdown`` / dashboard milestone panel) can still
|
|
7412
|
+
filter milestones by ``reset_event_id`` against the same lookup keys.
|
|
7056
7413
|
"""
|
|
7057
7414
|
events = conn.execute(
|
|
7058
7415
|
"SELECT old_week_end_at, new_week_end_at, effective_reset_at_utc "
|
|
@@ -7062,6 +7419,15 @@ def _apply_reset_events_to_weekrefs(
|
|
|
7062
7419
|
return refs
|
|
7063
7420
|
pre_map = {e["old_week_end_at"]: e["effective_reset_at_utc"] for e in events}
|
|
7064
7421
|
post_map = {e["new_week_end_at"]: e["effective_reset_at_utc"] for e in events}
|
|
7422
|
+
# In-place credit events have `old == effective` (the row shape the
|
|
7423
|
+
# live + backfill detection paths agree on). Project the set of
|
|
7424
|
+
# `new_week_end_at` values for those events so we can detect them
|
|
7425
|
+
# while iterating refs and split the credited week into TWO refs.
|
|
7426
|
+
in_place_credit_new_ends: set[str] = {
|
|
7427
|
+
e["new_week_end_at"]
|
|
7428
|
+
for e in events
|
|
7429
|
+
if e["old_week_end_at"] == e["effective_reset_at_utc"]
|
|
7430
|
+
}
|
|
7065
7431
|
out: list[WeekRef] = []
|
|
7066
7432
|
for ref in refs:
|
|
7067
7433
|
new_ref = ref
|
|
@@ -7076,7 +7442,43 @@ def _apply_reset_events_to_weekrefs(
|
|
|
7076
7442
|
pass
|
|
7077
7443
|
if ref.week_end_at and ref.week_end_at in post_map:
|
|
7078
7444
|
reset_at = post_map[ref.week_end_at]
|
|
7445
|
+
# In-place credit: synthesize a pre-credit ref FIRST so it
|
|
7446
|
+
# lands in `out` before the post-credit ref. The pre-credit
|
|
7447
|
+
# ref keeps the ORIGINAL API-derived week_start_at; only its
|
|
7448
|
+
# week_end_at shifts to `effective`. The post-credit ref
|
|
7449
|
+
# (constructed below via the standard `replace`) carries
|
|
7450
|
+
# week_start_at = effective, week_end_at = original.
|
|
7451
|
+
# Order: pre-credit BEFORE post-credit so chronological
|
|
7452
|
+
# iteration in cmd_report's trend table renders them
|
|
7453
|
+
# naturally (older segment above the newer one in DESC
|
|
7454
|
+
# ordering: post-credit is "more recent" so the post-credit
|
|
7455
|
+
# row should come FIRST in the DESC list — but the original
|
|
7456
|
+
# ref was already in DESC position, and we insert pre-credit
|
|
7457
|
+
# AFTER the post-credit. Concretely: post-credit takes the
|
|
7458
|
+
# ref's original slot; pre-credit goes one slot later).
|
|
7459
|
+
if ref.week_end_at in in_place_credit_new_ends:
|
|
7460
|
+
try:
|
|
7461
|
+
reset_dt = parse_iso_datetime(
|
|
7462
|
+
reset_at, "reset_event.effective"
|
|
7463
|
+
)
|
|
7464
|
+
pre_end_date = (
|
|
7465
|
+
# internal fallback: host-local intentional
|
|
7466
|
+
reset_dt - dt.timedelta(seconds=1)
|
|
7467
|
+
).astimezone().date()
|
|
7468
|
+
pre_credit_ref = replace(
|
|
7469
|
+
ref,
|
|
7470
|
+
week_end_at=reset_at,
|
|
7471
|
+
week_end=pre_end_date,
|
|
7472
|
+
)
|
|
7473
|
+
except ValueError:
|
|
7474
|
+
pre_credit_ref = None
|
|
7475
|
+
else:
|
|
7476
|
+
pre_credit_ref = None
|
|
7079
7477
|
new_ref = replace(new_ref, week_start_at=reset_at)
|
|
7478
|
+
out.append(new_ref)
|
|
7479
|
+
if pre_credit_ref is not None:
|
|
7480
|
+
out.append(pre_credit_ref)
|
|
7481
|
+
continue
|
|
7080
7482
|
out.append(new_ref)
|
|
7081
7483
|
return out
|
|
7082
7484
|
|
|
@@ -7151,6 +7553,71 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
|
|
|
7151
7553
|
" effective_reset_at_utc) VALUES (?, ?, ?, ?)",
|
|
7152
7554
|
(row["captured_at_utc"], prior_end, cur_end, effective_iso),
|
|
7153
7555
|
)
|
|
7556
|
+
elif prior_end and cur_end == prior_end:
|
|
7557
|
+
# In-place credit branch (v1.7.2). Mirrors the live detection
|
|
7558
|
+
# in cmd_record_usage: same end_at across two captures + ≥25pp
|
|
7559
|
+
# drop in weekly_percent + prior end still in the future at
|
|
7560
|
+
# captured_dt → Anthropic-issued goodwill credit. One event
|
|
7561
|
+
# row with old == new == cur_end, effective = floor_to_hour
|
|
7562
|
+
# of the captured_at when the drop was first observed.
|
|
7563
|
+
try:
|
|
7564
|
+
prior_end_dt = parse_iso_datetime(prior_end, "backfill.prior")
|
|
7565
|
+
captured_dt = parse_iso_datetime(row["captured_at_utc"], "backfill.cap")
|
|
7566
|
+
except ValueError:
|
|
7567
|
+
prior_end = cur_end
|
|
7568
|
+
prior_pct = cur_pct
|
|
7569
|
+
continue
|
|
7570
|
+
if (
|
|
7571
|
+
captured_dt < prior_end_dt
|
|
7572
|
+
and prior_pct is not None and cur_pct is not None
|
|
7573
|
+
and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
|
|
7574
|
+
):
|
|
7575
|
+
# Pre-check on ``new_week_end_at`` (mirrors the live
|
|
7576
|
+
# detection path's pre-check). Necessary because the
|
|
7577
|
+
# UNIQUE(old, new) constraint alone WON'T dedup against
|
|
7578
|
+
# legacy/broken-shape rows: pre-fix DBs may have
|
|
7579
|
+
# ``(cur_end, cur_end)`` rows for the same credit that
|
|
7580
|
+
# the new shape writes as ``(effective_iso, cur_end)``.
|
|
7581
|
+
# Without this pre-check, the backfill writes a second
|
|
7582
|
+
# row for the same credit on every open_db() call after
|
|
7583
|
+
# upgrade. (See round-2 review Bug 1.)
|
|
7584
|
+
already = conn.execute(
|
|
7585
|
+
"SELECT 1 FROM week_reset_events "
|
|
7586
|
+
"WHERE new_week_end_at = ? LIMIT 1",
|
|
7587
|
+
(cur_end,),
|
|
7588
|
+
).fetchone()
|
|
7589
|
+
if already is not None:
|
|
7590
|
+
prior_end = cur_end
|
|
7591
|
+
prior_pct = cur_pct
|
|
7592
|
+
continue
|
|
7593
|
+
# Canonicalize to UTC before isoformat so the stored
|
|
7594
|
+
# offset is `+00:00`, matching the live detection path
|
|
7595
|
+
# (cmd_record_usage uses now_utc which is already UTC).
|
|
7596
|
+
# parse_iso_datetime returns .astimezone() (host-local
|
|
7597
|
+
# fallback at bin/cctally:_local_tz_name gate); without
|
|
7598
|
+
# this normalization, non-UTC hosts would store the
|
|
7599
|
+
# column as e.g. `+03:00`, breaking lex comparisons
|
|
7600
|
+
# downstream (CLAUDE.md gotcha: 5h-block cross-reset
|
|
7601
|
+
# comparisons go through unixepoch(), NOT lex
|
|
7602
|
+
# BETWEEN/</>; the reset-aware DB clamp here applies
|
|
7603
|
+
# the same rule). The reset-aware clamp now wraps both
|
|
7604
|
+
# sides with unixepoch() (Bug 2 fix), but a canonical
|
|
7605
|
+
# UTC offset on write is the right defense-in-depth.
|
|
7606
|
+
effective_iso = (
|
|
7607
|
+
_floor_to_hour(captured_dt.astimezone(dt.timezone.utc))
|
|
7608
|
+
.isoformat(timespec="seconds")
|
|
7609
|
+
)
|
|
7610
|
+
# Row shape: old=effective_iso, new=cur_end (distinct
|
|
7611
|
+
# values). See the live-detection site in
|
|
7612
|
+
# bin/_cctally_record.py for the full rationale; in
|
|
7613
|
+
# short, old==new collapses the credited week to a
|
|
7614
|
+
# zero-width window in _apply_reset_events_to_weekrefs.
|
|
7615
|
+
conn.execute(
|
|
7616
|
+
"INSERT OR IGNORE INTO week_reset_events "
|
|
7617
|
+
"(detected_at_utc, old_week_end_at, new_week_end_at, "
|
|
7618
|
+
" effective_reset_at_utc) VALUES (?, ?, ?, ?)",
|
|
7619
|
+
(row["captured_at_utc"], effective_iso, cur_end, effective_iso),
|
|
7620
|
+
)
|
|
7154
7621
|
prior_end = cur_end
|
|
7155
7622
|
prior_pct = cur_pct
|
|
7156
7623
|
# Flush implicit transaction so callers using explicit BEGIN
|
|
@@ -8080,6 +8547,24 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
8080
8547
|
week_end_date=current_end.isoformat(),
|
|
8081
8548
|
)
|
|
8082
8549
|
|
|
8550
|
+
# Bug D (v1.7.2 round-4): when an in-place credit event exists for
|
|
8551
|
+
# the current subscription week, `_apply_reset_events_to_weekrefs`
|
|
8552
|
+
# synthesizes a pre-credit ref alongside the post-credit one (both
|
|
8553
|
+
# share `WeekRef.key`). The live "current week" segment is the
|
|
8554
|
+
# POST-credit one (its `week_start_at` was shifted to the
|
|
8555
|
+
# effective reset moment). Route `current_ref` through the same
|
|
8556
|
+
# override so its `week_start_at` reflects the post-credit start;
|
|
8557
|
+
# this lets the per-row match below disambiguate the synthesized
|
|
8558
|
+
# pre-credit ref from the live post-credit ref via both
|
|
8559
|
+
# `key` AND `week_start_at`. Order contract from
|
|
8560
|
+
# `_apply_reset_events_to_weekrefs`: post-credit ref lands at
|
|
8561
|
+
# index 0, pre-credit at index 1. Non-credit weeks return the
|
|
8562
|
+
# single input ref unchanged, so this is a no-op on the common
|
|
8563
|
+
# path.
|
|
8564
|
+
_adjusted_current = _apply_reset_events_to_weekrefs(conn, [current_ref])
|
|
8565
|
+
if _adjusted_current:
|
|
8566
|
+
current_ref = _adjusted_current[0]
|
|
8567
|
+
|
|
8083
8568
|
weeks = get_recent_weeks(conn, max(1, args.weeks))
|
|
8084
8569
|
if not weeks:
|
|
8085
8570
|
# Format-aware empty path mirrors cmd_forecast:18578-18629 — a
|
|
@@ -8132,8 +8617,38 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
8132
8617
|
except OauthUsageConfigError:
|
|
8133
8618
|
_fresh_cfg = _get_oauth_usage_config({})
|
|
8134
8619
|
|
|
8620
|
+
# Build a set of week_start_date keys that occur MORE THAN ONCE
|
|
8621
|
+
# in `weeks` — these are credited weeks (round-3, Bug B) where
|
|
8622
|
+
# `_apply_reset_events_to_weekrefs` synthesized a pre-credit
|
|
8623
|
+
# ref alongside the post-credit ref. Both refs share
|
|
8624
|
+
# `week_start_date` so the default
|
|
8625
|
+
# ``get_latest_usage_for_week(conn, ref)`` returns the SAME row
|
|
8626
|
+
# (the most recent snapshot in the whole week) for both,
|
|
8627
|
+
# collapsing the pre-credit row's `weeklyPercent` to the
|
|
8628
|
+
# post-credit value. For those keys ONLY, pin `as_of_utc` to
|
|
8629
|
+
# the ref's `week_end_at` so each segment renders its own
|
|
8630
|
+
# latest snapshot. Non-credit weeks (single ref per key) keep
|
|
8631
|
+
# the legacy unfiltered lookup so test fixtures that seed
|
|
8632
|
+
# snapshots OUTSIDE the API-derived week window (e.g.
|
|
8633
|
+
# `test_report_freshness`) keep finding their rows.
|
|
8634
|
+
_split_keys = {
|
|
8635
|
+
r.week_start.isoformat()
|
|
8636
|
+
for r in weeks
|
|
8637
|
+
if sum(
|
|
8638
|
+
1 for x in weeks
|
|
8639
|
+
if x.week_start.isoformat() == r.week_start.isoformat()
|
|
8640
|
+
) > 1
|
|
8641
|
+
}
|
|
8642
|
+
|
|
8135
8643
|
for week_ref in weeks:
|
|
8136
|
-
|
|
8644
|
+
key = week_ref.week_start.isoformat()
|
|
8645
|
+
usage = get_latest_usage_for_week(
|
|
8646
|
+
conn,
|
|
8647
|
+
week_ref,
|
|
8648
|
+
as_of_utc=(
|
|
8649
|
+
week_ref.week_end_at if key in _split_keys else None
|
|
8650
|
+
),
|
|
8651
|
+
)
|
|
8137
8652
|
|
|
8138
8653
|
# For weeks touched by a reset event, the cached
|
|
8139
8654
|
# weekly_cost_snapshots row covers the API-derived range (which
|
|
@@ -8197,7 +8712,20 @@ def cmd_report(args: argparse.Namespace) -> int:
|
|
|
8197
8712
|
"age_seconds": int(age_s),
|
|
8198
8713
|
}
|
|
8199
8714
|
trend.append(row)
|
|
8200
|
-
|
|
8715
|
+
# Bug D (v1.7.2 round-4): for credited weeks `weeks` contains
|
|
8716
|
+
# TWO refs sharing `key` (pre-credit + post-credit segments).
|
|
8717
|
+
# `current_ref` was routed through
|
|
8718
|
+
# `_apply_reset_events_to_weekrefs` above so its
|
|
8719
|
+
# `week_start_at` reflects the post-credit segment's effective
|
|
8720
|
+
# start (or the original start for non-credit weeks). Match on
|
|
8721
|
+
# BOTH `key` AND `week_start_at` so the pre-credit ref doesn't
|
|
8722
|
+
# overwrite the post-credit row's selection on the second
|
|
8723
|
+
# iteration (last-write-wins on key-only equality picked the
|
|
8724
|
+
# wrong row on the user's live data).
|
|
8725
|
+
if (
|
|
8726
|
+
week_ref.key == current_ref.key
|
|
8727
|
+
and week_ref.week_start_at == current_ref.week_start_at
|
|
8728
|
+
):
|
|
8201
8729
|
current_row = row
|
|
8202
8730
|
|
|
8203
8731
|
if current_row is None and trend:
|
|
@@ -8637,7 +9165,39 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
|
|
|
8637
9165
|
except ValueError:
|
|
8638
9166
|
pass
|
|
8639
9167
|
|
|
8640
|
-
|
|
9168
|
+
# v1.7.2 segment filter: when a week_reset_events row exists for
|
|
9169
|
+
# the current ``week_end_at``, narrow the milestone listing to
|
|
9170
|
+
# the active (latest) segment so a credited week's header (which
|
|
9171
|
+
# already reflects the post-credit window via the canon-boundary
|
|
9172
|
+
# rewrite above) is coherent with the body. Sentinel ``0`` covers
|
|
9173
|
+
# pre-credit / no-event weeks; pre-005 DBs that didn't have the
|
|
9174
|
+
# column also default to 0 via the migration's ALTER DEFAULT.
|
|
9175
|
+
active_segment = 0
|
|
9176
|
+
canon_end_for_lookup = None
|
|
9177
|
+
latest_end_row = conn.execute(
|
|
9178
|
+
"SELECT week_end_at FROM weekly_usage_snapshots "
|
|
9179
|
+
"WHERE week_start_date = ? AND week_end_at IS NOT NULL "
|
|
9180
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
9181
|
+
(week_start_date,),
|
|
9182
|
+
).fetchone()
|
|
9183
|
+
if latest_end_row is not None:
|
|
9184
|
+
canon_end_for_lookup = _canonicalize_optional_iso(
|
|
9185
|
+
latest_end_row["week_end_at"], "pb.cur"
|
|
9186
|
+
)
|
|
9187
|
+
if canon_end_for_lookup:
|
|
9188
|
+
seg_row = conn.execute(
|
|
9189
|
+
"SELECT id FROM week_reset_events "
|
|
9190
|
+
"WHERE new_week_end_at = ? "
|
|
9191
|
+
"ORDER BY id DESC LIMIT 1",
|
|
9192
|
+
(canon_end_for_lookup,),
|
|
9193
|
+
).fetchone()
|
|
9194
|
+
if seg_row is not None:
|
|
9195
|
+
active_segment = int(seg_row["id"])
|
|
9196
|
+
|
|
9197
|
+
milestones = [
|
|
9198
|
+
m for m in get_milestones_for_week(conn, week_start_date)
|
|
9199
|
+
if int(m["reset_event_id"] or 0) == active_segment
|
|
9200
|
+
]
|
|
8641
9201
|
|
|
8642
9202
|
milestone_list = []
|
|
8643
9203
|
for m in milestones:
|
|
@@ -8672,7 +9232,17 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
|
|
|
8672
9232
|
else:
|
|
8673
9233
|
print(f"Week: {week_start_date}..{week_end_date}")
|
|
8674
9234
|
if not milestone_list:
|
|
8675
|
-
|
|
9235
|
+
if active_segment > 0:
|
|
9236
|
+
# v1.7.2: distinguish post-credit empty (just got
|
|
9237
|
+
# credited, no crossings yet) from genuinely-empty week.
|
|
9238
|
+
# The pre-credit ledger still exists in the DB — just
|
|
9239
|
+
# filtered out of the body — so the user shouldn't see
|
|
9240
|
+
# "No milestones" and assume the data is gone.
|
|
9241
|
+
print(
|
|
9242
|
+
"(post-credit segment, no milestones crossed yet)"
|
|
9243
|
+
)
|
|
9244
|
+
else:
|
|
9245
|
+
print("No percent milestones recorded for this week.")
|
|
8676
9246
|
return 0
|
|
8677
9247
|
|
|
8678
9248
|
print("Percent breakdown:\n")
|
|
@@ -9888,6 +10458,7 @@ def doctor_gather_state(
|
|
|
9888
10458
|
# ── Data freshness ───────────────────────────────────────────────
|
|
9889
10459
|
latest_snapshot_at = None
|
|
9890
10460
|
forked_bucket_counts: dict | None = None
|
|
10461
|
+
credited_weeks: list[dict] | None = None
|
|
9891
10462
|
try:
|
|
9892
10463
|
if DB_PATH.exists():
|
|
9893
10464
|
conn = sqlite3.connect(str(DB_PATH))
|
|
@@ -9924,6 +10495,61 @@ def doctor_gather_state(
|
|
|
9924
10495
|
)
|
|
9925
10496
|
except sqlite3.OperationalError:
|
|
9926
10497
|
forked_bucket_counts[key] = 0
|
|
10498
|
+
# v1.7.2 credited-week tracking. For each week with a
|
|
10499
|
+
# past-effective ``week_reset_events`` row, gather the
|
|
10500
|
+
# latest weekly_percent + count of post-credit milestones.
|
|
10501
|
+
# The check warns when latest_percent >= 1.0 AND
|
|
10502
|
+
# post_credit_milestone_count == 0.
|
|
10503
|
+
# unixepoch() normalizes the cross-offset comparison.
|
|
10504
|
+
try:
|
|
10505
|
+
credit_rows = conn.execute(
|
|
10506
|
+
"""
|
|
10507
|
+
SELECT wre.id AS event_id,
|
|
10508
|
+
wre.new_week_end_at AS end_at,
|
|
10509
|
+
wre.effective_reset_at_utc AS effective
|
|
10510
|
+
FROM week_reset_events wre
|
|
10511
|
+
WHERE unixepoch(wre.effective_reset_at_utc)
|
|
10512
|
+
<= unixepoch(?)
|
|
10513
|
+
""",
|
|
10514
|
+
(now_utc_iso(),),
|
|
10515
|
+
).fetchall()
|
|
10516
|
+
credited_weeks = []
|
|
10517
|
+
for cr in credit_rows:
|
|
10518
|
+
end_at = cr[1]
|
|
10519
|
+
evt_id = cr[0]
|
|
10520
|
+
latest = conn.execute(
|
|
10521
|
+
"""
|
|
10522
|
+
SELECT week_start_date, weekly_percent
|
|
10523
|
+
FROM weekly_usage_snapshots
|
|
10524
|
+
WHERE week_end_at = ?
|
|
10525
|
+
ORDER BY captured_at_utc DESC, id DESC
|
|
10526
|
+
LIMIT 1
|
|
10527
|
+
""",
|
|
10528
|
+
(end_at,),
|
|
10529
|
+
).fetchone()
|
|
10530
|
+
if latest is None or latest[0] is None:
|
|
10531
|
+
continue
|
|
10532
|
+
ws = latest[0]
|
|
10533
|
+
lp = float(latest[1] or 0.0)
|
|
10534
|
+
try:
|
|
10535
|
+
mc_row = conn.execute(
|
|
10536
|
+
"SELECT COUNT(*) FROM percent_milestones "
|
|
10537
|
+
"WHERE week_start_date = ? AND reset_event_id = ?",
|
|
10538
|
+
(ws, evt_id),
|
|
10539
|
+
).fetchone()
|
|
10540
|
+
mc = int(mc_row[0]) if mc_row and mc_row[0] else 0
|
|
10541
|
+
except sqlite3.OperationalError:
|
|
10542
|
+
mc = 0
|
|
10543
|
+
credited_weeks.append({
|
|
10544
|
+
"week_start_date": ws,
|
|
10545
|
+
"latest_weekly_percent": lp,
|
|
10546
|
+
"post_credit_milestone_count": mc,
|
|
10547
|
+
"event_id": evt_id,
|
|
10548
|
+
})
|
|
10549
|
+
except sqlite3.OperationalError:
|
|
10550
|
+
# week_reset_events table missing — treat as no
|
|
10551
|
+
# credited weeks (pre-feature DB).
|
|
10552
|
+
credited_weeks = []
|
|
9927
10553
|
finally:
|
|
9928
10554
|
conn.close()
|
|
9929
10555
|
except Exception:
|
|
@@ -10069,6 +10695,7 @@ def doctor_gather_state(
|
|
|
10069
10695
|
cache_last_entry_at=cache_last_entry_at,
|
|
10070
10696
|
claude_jsonl_present=claude_jsonl_present,
|
|
10071
10697
|
forked_bucket_counts=forked_bucket_counts,
|
|
10698
|
+
credited_weeks=credited_weeks,
|
|
10072
10699
|
codex_entries_count=codex_entries_count,
|
|
10073
10700
|
codex_last_entry_at=codex_last_entry_at,
|
|
10074
10701
|
codex_jsonl_present=codex_jsonl_present,
|