cctally 1.7.2 → 1.7.3
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 +17 -0
- package/bin/_cctally_dashboard.py +64 -3
- package/bin/_cctally_db.py +182 -21
- package/bin/_cctally_record.py +457 -58
- package/bin/_cctally_tui.py +100 -1
- package/bin/_lib_render.py +20 -0
- package/bin/cctally +191 -6
- 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 +1 -1
- package/dashboard/static/assets/index-BgpoazlS.js +0 -18
- package/dashboard/static/assets/index-nJdUaGys.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.3] - 2026-05-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `cctally record-usage`: detect in-place 5h credits on a `>=5.0pp` drop within the same canonical `five_hour_window_key` while `prior_5h_resets_at > now_utc`; write a `five_hour_reset_events` row with the credit moment floored to a 10-minute slot on `effective_reset_at_utc` (supports stacked credits across DISTINCT 10-min slots up to ~30 per 5h block; same-slot collisions silently absorbed by `INSERT OR IGNORE` per spec §2.3 documented cap — an intentional bound, not a bug); force-write `hwm-5h` via the credit-only escape hatch since the normal monotonic-up write at `bin/_cctally_record.py:1722-1731` would refuse to decrease it; then DELETE stale-replica snapshots scoped to `(five_hour_window_key = ?, captured_at_utc >= effective_iso, round(five_hour_percent, 1) = round(prior_5h_pct, 1))` so `claude-statusline`'s in-memory replay of pre-credit `--percent` values across the credit moment cannot poison the post-credit clamp segment. Mirrors the v1.7.2 weekly credit-detection path parallel-not-identical; threshold is 5pp (not 25) because intra-block percents are smaller, and `effective_reset_at_utc` floor is 10 minutes (not the hour) so distinct intra-hour stacked credits stay reachable.
|
|
12
|
+
- schema: new `five_hour_reset_events` table with columns `(detected_at_utc, five_hour_window_key, prior_percent, post_percent, effective_reset_at_utc)` and `UNIQUE(five_hour_window_key, effective_reset_at_utc)`. Payload diverges from the weekly precedent (`prior_percent` + `post_percent` instead of boundary keys) because the 5h variant has a stable canonical window key and only the percent moves — storing both lets renderers show `−Δpp` without re-querying snapshots and supports the rare stacked-credit chain across distinct 10-minute slots. Created adjacent to `week_reset_events` in `_apply_schema` (both are parallel concepts at different cadences); no FK on `five_hour_window_key` per the CLAUDE.md documentation-only-FK gotcha; no `_backfill_five_hour_reset_events` call (forward-only ship per spec Q5; historical backfill deferred).
|
|
13
|
+
- migration `006_five_hour_milestones_reset_event_id`: adds `reset_event_id INTEGER NOT NULL DEFAULT 0` to `five_hour_milestones` and reshapes UNIQUE from `(five_hour_window_key, percent_threshold)` to `(five_hour_window_key, percent_threshold, reset_event_id)` so post-credit threshold crossings re-fire as distinct rows instead of being silently absorbed by `INSERT OR IGNORE` against the pre-credit row at the same threshold. SQLite can't ALTER a UNIQUE constraint in place — the handler uses the rename-recreate-copy idiom inside its own `BEGIN/COMMIT`. Live DDL at `bin/cctally:3902` is updated in lockstep with the migration handler so the dispatcher's fresh-install fast-stamp path lands the correct shape without invoking the handler; the `PRAGMA table_info` probe stamps the marker without redoing the rename when the column is already present (covers fresh-install + partial-failure-retry cases). Mirrors weekly migration 005's pattern. Sentinel `0` = pre-credit / no-event; existing rows backfill to 0 via the column DEFAULT.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- 5h DB clamp at `bin/_cctally_record.py:1521-1527` becomes reset-aware via JOIN on `five_hour_reset_events` keyed on the canonical `five_hour_window_key`; the `COALESCE`-to-epoch-zero default keeps legacy clamp behavior byte-identical on DBs without any event rows, while a credited window's `MAX(five_hour_percent)` over the post-credit segment correctly excludes pre-credit snapshots so a fresh post-credit OAuth value (e.g. 4%) lands instead of being held back by stale pre-credit history (e.g. 28%). `unixepoch()` wraps both sides of the captured-vs-effective comparison so the `Z`-vs-`+00:00` offset mix in stored values doesn't break the bound under lex compare — same rule as the production weekly clamp from v1.7.2.
|
|
17
|
+
- renderers: `cctally five-hour-blocks` shows an inline `⚡ credited −Xpp @ HH:MM` chip beside the block-start cell on credited block rows (multiple credits in one block concatenate as `⚡ −Xpp, −Ypp` across distinct 10-min slots); text + JSON envelope (new `credits[]` field per block) + share-output (`--format md` / `html` / `svg`) all carry the annotation via `_build_five_hour_blocks_snapshot`'s `__credits` side-channel piggy-backed on the existing `block_start` cell formatting. `cctally five-hour-breakdown` interleaves a `⚡ CREDIT −Xpp @ HH:MM` divider row between pre-credit and post-credit milestones (merged stream ordered by `captured_at_utc`); JSON gains `credits[]` parallel to `milestones[]` and each milestone now carries `resetEventId` (sentinel 0 = pre-credit, positive integer = post-credit segment id). Dashboard 5h panel chip + `CurrentWeekModal.tsx` 5h milestones section (new envelope key `current_week.five_hour_milestones` parallel to weekly's `current_week.milestones`) + dashboard alerts list row-identity widening (alert id becomes `five_hour:{window_key}:{threshold}:{reset_event_id}` mirroring the weekly precedent at `bin/_cctally_dashboard.py:2597` — NOT a filter, both segments stay in the list as distinct rows) + TUI 5h tile `[⚡ −Xpp]` badge.
|
|
18
|
+
- migration `003_merge_5h_block_duplicates_v1`: defensive update to the milestone dedup loop — widens the dedup key from `percent_threshold` alone to `(percent_threshold, reset_event_id)` via a `PRAGMA table_info` probe so an operator-triggered re-run after migration 006 (`cctally db unskip 003_merge_5h_block_duplicates_v1`, fresh-DB-from-corrupted-backup, future tooling) doesn't silently collapse legitimately distinct pre/post-credit milestone rows at the same threshold inside one physical block. Byte-identical on the legacy upgrade path where the column doesn't yet exist (003 runs before 006 in migration order; `has_seg=False` collapses the key tuple to `(threshold, 0)`).
|
|
19
|
+
- `cctally record-usage`: credit pivots (HWM force-write + stale-replica DELETE) now run UNCONDITIONALLY when a credit is detected on either axis — gating them on `INSERT OR IGNORE`'s `rowcount` (5h branch) or on the `already is None` pre-check (weekly branch) wedged the system permanently on pre-credit HWM + stale-replica rows if a prior invocation committed the event row but crashed before the pivots could run (memory `project_dedup_must_not_gate_side_effects.md`: "Skipping a no-op INSERT must NOT skip milestones/rollups/alerts; prior run may have died mid-flight"). The event-row INSERT stays gated against duplicates; pivots are individually idempotent so re-running them on the recovery tick is safe. Regressions: `tests/test_in_place_5h_credit_detection.py::test_pivots_run_when_event_row_already_committed` + `tests/test_in_place_credit_detection.py::test_weekly_pivots_run_when_event_row_already_committed`.
|
|
20
|
+
- `cctally record-usage`: round-4 follow-up — 5h credit-detection dedup pre-check refined from a single-field `post_percent` compare to a pair-check against BOTH `prior_percent` AND `post_percent` of the most-recent stored event row, AND the post-detection pivots (HWM force-write + stale-replica DELETE) hoisted OUT of the `if not is_dup:` block so they fire on every detection entry. The round-3 single-field predicate false-positived on a legitimate second credit when the user was idle between credits (Credit 1 lands prior=20/post=5; Credit 2 arrives with new CLI percent=0 while prior_5h_pct still reads 5 from the post-Credit-1 snapshot — `5 == 5` matched the stored `post_percent` so the second credit was silently swallowed, no event row written, no HWM force-write, no DELETE). Pair-check disambiguates: a genuine replay matches BOTH fields; a new credit-with-idle matches at most ONE. Hoisting the pivots is the second half of the same fix — a recovery tick where the pair-check legitimately dedupes (event row already durable from a crashed prior invocation, snapshot rolled back so prior_5h_pct still reads the pre-credit value, pair `(20,5)` matches stored `(20,5)`) still needs to force HWM down and DELETE stale replicas; pre-fix those pivots sat INSIDE `if not is_dup:` and got skipped on every recovery tick, leaving the system wedged on the pre-credit HWM forever. Both pivots are individually idempotent (file overwrite, DELETE on stable predicate) so re-running on a replay or recovery tick is always safe. Codex r4 P1 finding ([PR #46 comment 3252721643](https://github.com/omrikais/cctally-dev/pull/46#discussion_r3252721643), issue [#43](https://github.com/omrikais/cctally-dev/issues/43)). Regressions: `tests/test_in_place_5h_credit_detection.py::test_consecutive_credits_with_idle_between` (proves pair-check lets Credit 2 land) + `tests/test_in_place_5h_credit_detection.py::test_replay_with_pair_match_still_runs_pivots` (proves pivots fire when pair-check dedupes).
|
|
21
|
+
- `cctally record-usage`: 5h self-heal probe at `bin/_cctally_record.py:1958+` becomes segment-aware via `_resolve_active_five_hour_reset_event_id`, mirroring the weekly Probe 1 precedent at `bin/_cctally_record.py:1880-1908`. Without segment scoping a credited block's `MAX(percent_threshold)` over the whole ledger reads the pre-credit ceiling (e.g. 28%) and silently suppresses the post-credit ledger's heal even when it has zero rows; with scoping, post-credit threshold crossings inside a stale-`last_observed_at_utc` slot finally trigger heal. Regression: `tests/test_in_place_5h_credit_detection.py::test_5h_self_heal_probe_scoped_to_active_segment`.
|
|
22
|
+
- dashboard alerts envelope: weekly alerts `context` block now exposes `reset_event_id` parallel to the 5h shape — pre-Round-3 the weekly path widened the row identity (`id` includes `:{reset_event_id}`) but did NOT include the segment in `context`, while the 5h path did. Round-3 adds `context.reset_event_id` to weekly so both axes mirror the same shape and downstream consumers (panel, modal, third-party) can discriminate pre- vs post-credit crossings of the same (week, threshold) without scraping the opaque `id` string. Regression: `tests/test_5h_milestone_segment_threading.py::test_weekly_alerts_context_exposes_reset_event_id`.
|
|
23
|
+
- React component coverage: new credit-chip tests in `dashboard/web/__tests__/CurrentWeekPanel.test.tsx` (single + stacked deltas, `data-credit-count` analytics attr, in-row placement) + new 5h-milestone-section tests in `CurrentWeekModal.test.tsx` (section visibility gate, count pill, `⚡ CREDIT` divider row with `colspan=5`, React row key disambiguation by `reset_event_id` so post-credit threshold repeats render as distinct rows). Server-side envelope coverage extended in `tests/test_five_hour_block_envelope.py` (new `_seed_credit_event` helper + populated `credits[]` + empty-array contract + ascending-order chain across stacked credits). Share-output goldens: new `tests/fixtures/share/five-hour-blocks-credit-{md,html,svg}/` fixtures (one in-place credit seeded in `stats.db` via `bin/build-share-fixtures.py`) assert the `⚡ -20pp` chip carries uniformly through all three share-render formats. Dashboard CSS: new `.credit-chip` + `.mcw-5h-credit-row` + `.mcw-5h-credit-cell` + `.m-sec.sec-5h` rules in `dashboard/web/src/index.css` so the credit annotations match the rest of the panel/modal's accent-amber treatment.
|
|
24
|
+
|
|
8
25
|
## [1.7.2] - 2026-05-16
|
|
9
26
|
|
|
10
27
|
### Fixed
|
|
@@ -2490,7 +2490,7 @@ def _select_current_block_for_envelope(
|
|
|
2490
2490
|
|
|
2491
2491
|
block = conn.execute(
|
|
2492
2492
|
"""
|
|
2493
|
-
SELECT block_start_at, last_observed_at_utc,
|
|
2493
|
+
SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
|
|
2494
2494
|
seven_day_pct_at_block_start,
|
|
2495
2495
|
crossed_seven_day_reset
|
|
2496
2496
|
FROM five_hour_blocks
|
|
@@ -2539,11 +2539,41 @@ def _select_current_block_for_envelope(
|
|
|
2539
2539
|
None if (p_anchor is None or current_used_pct is None)
|
|
2540
2540
|
else round(current_used_pct - p_anchor, 9)
|
|
2541
2541
|
)
|
|
2542
|
+
|
|
2543
|
+
# Spec §5.3 — in-place credit events for this 5h block's window,
|
|
2544
|
+
# ascending by ``effective_reset_at_utc``. Drives the
|
|
2545
|
+
# ``CurrentWeekPanel.tsx`` ``⚡ credited -Xpp`` chip and the
|
|
2546
|
+
# ``CurrentWeekModal.tsx`` merged-stream 5h milestones section.
|
|
2547
|
+
# Snake_case keys to match the envelope convention (see CLAUDE.md;
|
|
2548
|
+
# CLI ``--json`` uses camelCase, dashboard envelope is snake_case).
|
|
2549
|
+
cred_rows = conn.execute(
|
|
2550
|
+
"""
|
|
2551
|
+
SELECT effective_reset_at_utc, prior_percent, post_percent
|
|
2552
|
+
FROM five_hour_reset_events
|
|
2553
|
+
WHERE five_hour_window_key = ?
|
|
2554
|
+
ORDER BY effective_reset_at_utc ASC
|
|
2555
|
+
""",
|
|
2556
|
+
(int(block["five_hour_window_key"]),),
|
|
2557
|
+
).fetchall()
|
|
2558
|
+
credits = [
|
|
2559
|
+
{
|
|
2560
|
+
"effective_reset_at_utc": c["effective_reset_at_utc"],
|
|
2561
|
+
"prior_percent": float(c["prior_percent"]),
|
|
2562
|
+
"post_percent": float(c["post_percent"]),
|
|
2563
|
+
"delta_pp": round(
|
|
2564
|
+
float(c["post_percent"]) - float(c["prior_percent"]), 1
|
|
2565
|
+
),
|
|
2566
|
+
}
|
|
2567
|
+
for c in cred_rows
|
|
2568
|
+
]
|
|
2569
|
+
|
|
2542
2570
|
return {
|
|
2543
2571
|
"block_start_at": block["block_start_at"],
|
|
2572
|
+
"five_hour_window_key": int(block["five_hour_window_key"]),
|
|
2544
2573
|
"seven_day_pct_at_block_start": p_start,
|
|
2545
2574
|
"seven_day_pct_delta_pp": delta,
|
|
2546
2575
|
"crossed_seven_day_reset": crossed,
|
|
2576
|
+
"credits": credits,
|
|
2547
2577
|
}
|
|
2548
2578
|
|
|
2549
2579
|
|
|
@@ -2603,13 +2633,29 @@ def _build_alerts_envelope_array(
|
|
|
2603
2633
|
"week_start_date": r["week_start_date"],
|
|
2604
2634
|
"cumulative_cost_usd": cumulative,
|
|
2605
2635
|
"dollars_per_percent": dpp,
|
|
2636
|
+
# Round-3: parallel to the 5h context block below — both
|
|
2637
|
+
# axes now expose ``reset_event_id`` so downstream
|
|
2638
|
+
# clients (panel, modal, third-party consumers) can
|
|
2639
|
+
# discriminate pre- vs post-credit crossings of the
|
|
2640
|
+
# same (week, threshold) without scraping the
|
|
2641
|
+
# envelope ``id`` string. 0 = pre-credit / no-event;
|
|
2642
|
+
# event.id = post-credit segment.
|
|
2643
|
+
"reset_event_id": int(r["reset_event_id"]),
|
|
2606
2644
|
},
|
|
2607
2645
|
})
|
|
2608
2646
|
|
|
2647
|
+
# Site F (spec §3.2 bucket C / §3.3): widen the row identity to
|
|
2648
|
+
# include ``reset_event_id`` so post-credit (seg=event.id) crossings
|
|
2649
|
+
# of the same (window_key, threshold) don't collide with pre-credit
|
|
2650
|
+
# (seg=0) crossings on the React row key. Older clients tolerate
|
|
2651
|
+
# longer ids — the id is opaque to them; only the React key
|
|
2652
|
+
# uniqueness invariant matters. Mirrors the weekly precedent at
|
|
2653
|
+
# line ~2597.
|
|
2609
2654
|
fh_rows = conn.execute(
|
|
2610
2655
|
"""
|
|
2611
2656
|
SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
|
|
2612
|
-
m.alerted_at, m.block_cost_usd,
|
|
2657
|
+
m.alerted_at, m.block_cost_usd, m.reset_event_id,
|
|
2658
|
+
b.block_start_at
|
|
2613
2659
|
FROM five_hour_milestones m
|
|
2614
2660
|
LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
|
|
2615
2661
|
WHERE m.alerted_at IS NOT NULL
|
|
@@ -2621,7 +2667,10 @@ def _build_alerts_envelope_array(
|
|
|
2621
2667
|
for r in fh_rows:
|
|
2622
2668
|
threshold = int(r["percent_threshold"])
|
|
2623
2669
|
out.append({
|
|
2624
|
-
"id":
|
|
2670
|
+
"id": (
|
|
2671
|
+
f"five_hour:{int(r['five_hour_window_key'])}:"
|
|
2672
|
+
f"{threshold}:{int(r['reset_event_id'])}"
|
|
2673
|
+
),
|
|
2625
2674
|
"axis": "five_hour",
|
|
2626
2675
|
"threshold": threshold,
|
|
2627
2676
|
"crossed_at": r["captured_at_utc"],
|
|
@@ -2630,6 +2679,7 @@ def _build_alerts_envelope_array(
|
|
|
2630
2679
|
"five_hour_window_key": int(r["five_hour_window_key"]),
|
|
2631
2680
|
"block_start_at": r["block_start_at"] or "",
|
|
2632
2681
|
"block_cost_usd": float(r["block_cost_usd"] or 0.0),
|
|
2682
|
+
"reset_event_id": int(r["reset_event_id"]),
|
|
2633
2683
|
},
|
|
2634
2684
|
})
|
|
2635
2685
|
|
|
@@ -3065,6 +3115,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
3065
3115
|
}
|
|
3066
3116
|
for m in (snap.percent_milestones or [])
|
|
3067
3117
|
],
|
|
3118
|
+
# Spec §5.3 (Codex r1 finding 3) — NEW envelope key
|
|
3119
|
+
# parallel to ``milestones`` (which carries the WEEKLY
|
|
3120
|
+
# timeline). 5h-block milestones for the active block,
|
|
3121
|
+
# in capture-time order, both pre- and post-credit
|
|
3122
|
+
# segments included (bucket B per §3.2 — no
|
|
3123
|
+
# ``reset_event_id`` filter; the React layer renders
|
|
3124
|
+
# repeated thresholds as distinct rows keyed on
|
|
3125
|
+
# ``reset_event_id``). Empty list when no 5h block is
|
|
3126
|
+
# bound or the data source crashed during sync
|
|
3127
|
+
# (recorded on ``last_sync_error``).
|
|
3128
|
+
"five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
|
|
3068
3129
|
},
|
|
3069
3130
|
|
|
3070
3131
|
"forecast":
|
package/bin/_cctally_db.py
CHANGED
|
@@ -1009,36 +1009,65 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
|
|
|
1009
1009
|
|
|
1010
1010
|
# (c) Milestones: per-threshold dedup, keep earliest
|
|
1011
1011
|
# captured_at_utc, re-FK keepers to canonical.
|
|
1012
|
+
#
|
|
1013
|
+
# Defensive widening (Codex r2 finding 1, spec §3.4): if
|
|
1014
|
+
# migration 006 has already landed and added ``reset_event_id``,
|
|
1015
|
+
# key the dedup on ``(percent_threshold, reset_event_id)`` so
|
|
1016
|
+
# we don't silently collapse legitimately distinct pre/post-
|
|
1017
|
+
# credit rows at the same physical threshold. On the legacy
|
|
1018
|
+
# upgrade path (column doesn't exist yet because 003 runs
|
|
1019
|
+
# before 006 in migration order), ``has_seg`` is False and the
|
|
1020
|
+
# dedup key collapses to ``(threshold, 0)`` — byte-identical
|
|
1021
|
+
# to the original threshold-only shape. PRAGMA probe rather
|
|
1022
|
+
# than version-detect so the path also covers operator
|
|
1023
|
+
# re-runs (e.g. ``cctally db unskip 003_*``) post-006.
|
|
1024
|
+
ms_cols = {
|
|
1025
|
+
str(r[1])
|
|
1026
|
+
for r in conn.execute(
|
|
1027
|
+
"PRAGMA table_info(five_hour_milestones)"
|
|
1028
|
+
).fetchall()
|
|
1029
|
+
}
|
|
1030
|
+
has_seg = "reset_event_id" in ms_cols
|
|
1012
1031
|
ms_id_placeholders = ",".join(
|
|
1013
1032
|
"?" * (len(dropped_ids) + 1)
|
|
1014
1033
|
)
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1034
|
+
if has_seg:
|
|
1035
|
+
all_milestones = conn.execute(
|
|
1036
|
+
f"SELECT id, percent_threshold, captured_at_utc, "
|
|
1037
|
+
f" reset_event_id "
|
|
1038
|
+
f" FROM five_hour_milestones "
|
|
1039
|
+
f" WHERE block_id IN ({ms_id_placeholders})",
|
|
1040
|
+
[canonical["id"], *dropped_ids],
|
|
1041
|
+
).fetchall()
|
|
1042
|
+
else:
|
|
1043
|
+
all_milestones = conn.execute(
|
|
1044
|
+
f"SELECT id, percent_threshold, captured_at_utc "
|
|
1045
|
+
f" FROM five_hour_milestones "
|
|
1046
|
+
f" WHERE block_id IN ({ms_id_placeholders})",
|
|
1047
|
+
[canonical["id"], *dropped_ids],
|
|
1048
|
+
).fetchall()
|
|
1049
|
+
by_key: dict[tuple[int, int], dict] = {}
|
|
1022
1050
|
for m in all_milestones:
|
|
1023
|
-
|
|
1051
|
+
seg = int(m["reset_event_id"]) if has_seg else 0
|
|
1052
|
+
key = (int(m["percent_threshold"]), seg)
|
|
1024
1053
|
md = dict(m)
|
|
1025
1054
|
if (
|
|
1026
|
-
|
|
1055
|
+
key not in by_key
|
|
1027
1056
|
or md["captured_at_utc"]
|
|
1028
|
-
<
|
|
1057
|
+
< by_key[key]["captured_at_utc"]
|
|
1029
1058
|
):
|
|
1030
|
-
|
|
1031
|
-
keep_ids = {m["id"] for m in
|
|
1059
|
+
by_key[key] = md
|
|
1060
|
+
keep_ids = {m["id"] for m in by_key.values()}
|
|
1032
1061
|
# DELETE non-keepers BEFORE rekeying keepers. Otherwise, when
|
|
1033
1062
|
# both canonical and a dropped block hold a milestone for the
|
|
1034
|
-
# same
|
|
1035
|
-
#
|
|
1036
|
-
#
|
|
1037
|
-
#
|
|
1038
|
-
# back the migration. After this DELETE the only
|
|
1039
|
-
# referencing dropped_keys are the keepers
|
|
1040
|
-
# (one per
|
|
1041
|
-
# free.
|
|
1063
|
+
# same physical key and the dropped row's milestone is the
|
|
1064
|
+
# earlier keeper, UPDATEing it to the canonical key collides
|
|
1065
|
+
# with canonical's still-present non-keeper on UNIQUE
|
|
1066
|
+
# (either the 2-col legacy shape or the 3-col post-006 shape),
|
|
1067
|
+
# rolling back the migration. After this DELETE the only
|
|
1068
|
+
# milestones referencing dropped_keys are the keepers
|
|
1069
|
+
# themselves (one per dedup key), so the UPDATE loop below is
|
|
1070
|
+
# collision-free.
|
|
1042
1071
|
non_keep_ids = [
|
|
1043
1072
|
m["id"] for m in all_milestones if m["id"] not in keep_ids
|
|
1044
1073
|
]
|
|
@@ -1049,7 +1078,7 @@ def _migration_merge_5h_block_duplicates_v1(conn: sqlite3.Connection) -> None:
|
|
|
1049
1078
|
f" WHERE id IN ({nk_placeholders})",
|
|
1050
1079
|
non_keep_ids,
|
|
1051
1080
|
)
|
|
1052
|
-
for m in
|
|
1081
|
+
for m in by_key.values():
|
|
1053
1082
|
conn.execute(
|
|
1054
1083
|
"UPDATE five_hour_milestones "
|
|
1055
1084
|
" SET block_id = ?, "
|
|
@@ -1437,6 +1466,138 @@ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> No
|
|
|
1437
1466
|
raise
|
|
1438
1467
|
|
|
1439
1468
|
|
|
1469
|
+
@stats_migration("006_five_hour_milestones_reset_event_id")
|
|
1470
|
+
def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) -> None:
|
|
1471
|
+
"""Add ``reset_event_id`` to ``five_hour_milestones`` so post-credit
|
|
1472
|
+
threshold crossings can coexist with pre-credit ones for the same
|
|
1473
|
+
``(five_hour_window_key, percent_threshold)``.
|
|
1474
|
+
|
|
1475
|
+
Sentinel: ``0`` = pre-credit / no event. Existing rows backfill to
|
|
1476
|
+
``0`` via the ``DEFAULT 0`` clause on the new column.
|
|
1477
|
+
|
|
1478
|
+
The new UNIQUE constraint is
|
|
1479
|
+
``UNIQUE(five_hour_window_key, percent_threshold, reset_event_id)`` so
|
|
1480
|
+
the same (window_key, threshold) pair can land twice if a goodwill
|
|
1481
|
+
credit re-opens the segment under a fresh ``five_hour_reset_events.id``.
|
|
1482
|
+
SQLite can't ALTER a UNIQUE constraint in place — we use the
|
|
1483
|
+
rename-recreate-copy idiom (same as migration 005 did for
|
|
1484
|
+
``percent_milestones``).
|
|
1485
|
+
|
|
1486
|
+
Companion live-path edits land at (Task 2 of issue #43):
|
|
1487
|
+
- bin/_cctally_record.py — 5h milestone INSERT + alert paths
|
|
1488
|
+
(Sites A-E in spec §3.3); grep ``active_reset_event_id`` to
|
|
1489
|
+
locate (line numbers drift per ``gotcha_cited_line_numbers_stale``)
|
|
1490
|
+
- bin/_cctally_dashboard.py — alerts list row-identity widening
|
|
1491
|
+
(Site F in spec §3.3 — bucket C per spec §3.2's three-bucket model);
|
|
1492
|
+
grep ``reset_event_id`` near the 5h alerts SELECT
|
|
1493
|
+
|
|
1494
|
+
Idempotent: a second invocation finds the column already present and
|
|
1495
|
+
returns. Empty-table fast path: when the column is already present
|
|
1496
|
+
(fresh-install fast-stamp from the dispatcher because the live
|
|
1497
|
+
``CREATE TABLE IF NOT EXISTS five_hour_milestones`` already carries
|
|
1498
|
+
the new shape — REQUIRED for fresh-install correctness per spec §3.2),
|
|
1499
|
+
the marker still gets stamped — no schema edit needed.
|
|
1500
|
+
"""
|
|
1501
|
+
# Fast-path probe: column already present means a prior run of this
|
|
1502
|
+
# migration (or a fresh-install fast-stamp from the dispatcher that
|
|
1503
|
+
# already picked up the new live-schema CREATE TABLE) has done the
|
|
1504
|
+
# work. Just stamp the marker and return. The marker INSERT runs in
|
|
1505
|
+
# SQLite's implicit transaction (auto-opened by the write, closed by
|
|
1506
|
+
# ``commit()`` — same shape as migration 005's fast path); no explicit
|
|
1507
|
+
# ``BEGIN`` is needed for a single-statement DML.
|
|
1508
|
+
cols = {
|
|
1509
|
+
str(r[1])
|
|
1510
|
+
for r in conn.execute("PRAGMA table_info(five_hour_milestones)").fetchall()
|
|
1511
|
+
}
|
|
1512
|
+
if "reset_event_id" in cols:
|
|
1513
|
+
conn.execute(
|
|
1514
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1515
|
+
"VALUES (?, ?)",
|
|
1516
|
+
("006_five_hour_milestones_reset_event_id", now_utc_iso()),
|
|
1517
|
+
)
|
|
1518
|
+
conn.commit()
|
|
1519
|
+
return
|
|
1520
|
+
|
|
1521
|
+
conn.execute("BEGIN")
|
|
1522
|
+
try:
|
|
1523
|
+
# Add the column with sentinel 0 default (covers existing rows).
|
|
1524
|
+
conn.execute(
|
|
1525
|
+
"ALTER TABLE five_hour_milestones "
|
|
1526
|
+
"ADD COLUMN reset_event_id INTEGER NOT NULL DEFAULT 0"
|
|
1527
|
+
)
|
|
1528
|
+
# SQLite can't ALTER a UNIQUE constraint in place; rename, recreate
|
|
1529
|
+
# with the new 3-column UNIQUE, copy, drop. Preserves ids and every
|
|
1530
|
+
# existing column (including those added by add_column_if_missing:
|
|
1531
|
+
# alerted_at).
|
|
1532
|
+
conn.execute(
|
|
1533
|
+
"ALTER TABLE five_hour_milestones "
|
|
1534
|
+
"RENAME TO five_hour_milestones_old_006"
|
|
1535
|
+
)
|
|
1536
|
+
conn.execute(
|
|
1537
|
+
"""
|
|
1538
|
+
CREATE TABLE five_hour_milestones (
|
|
1539
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1540
|
+
block_id INTEGER NOT NULL,
|
|
1541
|
+
five_hour_window_key INTEGER NOT NULL,
|
|
1542
|
+
percent_threshold INTEGER NOT NULL,
|
|
1543
|
+
captured_at_utc TEXT NOT NULL,
|
|
1544
|
+
usage_snapshot_id INTEGER NOT NULL,
|
|
1545
|
+
block_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1546
|
+
block_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1547
|
+
block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1548
|
+
block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1549
|
+
block_cost_usd REAL NOT NULL DEFAULT 0,
|
|
1550
|
+
marginal_cost_usd REAL,
|
|
1551
|
+
seven_day_pct_at_crossing REAL,
|
|
1552
|
+
alerted_at TEXT,
|
|
1553
|
+
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
1554
|
+
UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
|
|
1555
|
+
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
1556
|
+
)
|
|
1557
|
+
"""
|
|
1558
|
+
)
|
|
1559
|
+
conn.execute(
|
|
1560
|
+
"""
|
|
1561
|
+
INSERT INTO five_hour_milestones (
|
|
1562
|
+
id, block_id, five_hour_window_key, percent_threshold,
|
|
1563
|
+
captured_at_utc, usage_snapshot_id,
|
|
1564
|
+
block_input_tokens, block_output_tokens,
|
|
1565
|
+
block_cache_create_tokens, block_cache_read_tokens,
|
|
1566
|
+
block_cost_usd, marginal_cost_usd,
|
|
1567
|
+
seven_day_pct_at_crossing, alerted_at, reset_event_id
|
|
1568
|
+
)
|
|
1569
|
+
SELECT id, block_id, five_hour_window_key, percent_threshold,
|
|
1570
|
+
captured_at_utc, usage_snapshot_id,
|
|
1571
|
+
block_input_tokens, block_output_tokens,
|
|
1572
|
+
block_cache_create_tokens, block_cache_read_tokens,
|
|
1573
|
+
block_cost_usd, marginal_cost_usd,
|
|
1574
|
+
seven_day_pct_at_crossing, alerted_at, reset_event_id
|
|
1575
|
+
FROM five_hour_milestones_old_006
|
|
1576
|
+
"""
|
|
1577
|
+
)
|
|
1578
|
+
# Recreate the block_id index that was attached to the original
|
|
1579
|
+
# table; the rename carried index metadata with the table, but
|
|
1580
|
+
# the new table needs its own index entry. Safe under
|
|
1581
|
+
# IF NOT EXISTS if the rename preserved it (it does in practice,
|
|
1582
|
+
# but the explicit recreate is defensive).
|
|
1583
|
+
conn.execute(
|
|
1584
|
+
"""
|
|
1585
|
+
CREATE INDEX IF NOT EXISTS idx_five_hour_milestones_block
|
|
1586
|
+
ON five_hour_milestones(block_id)
|
|
1587
|
+
"""
|
|
1588
|
+
)
|
|
1589
|
+
conn.execute("DROP TABLE five_hour_milestones_old_006")
|
|
1590
|
+
conn.execute(
|
|
1591
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1592
|
+
"VALUES (?, ?)",
|
|
1593
|
+
("006_five_hour_milestones_reset_event_id", now_utc_iso()),
|
|
1594
|
+
)
|
|
1595
|
+
conn.commit()
|
|
1596
|
+
except Exception:
|
|
1597
|
+
conn.rollback()
|
|
1598
|
+
raise
|
|
1599
|
+
|
|
1600
|
+
|
|
1440
1601
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
1441
1602
|
|
|
1442
1603
|
# ──────────────────────────────────────────────────────────────────────
|