cctally 1.7.1 → 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 +37 -0
- package/bin/_cctally_dashboard.py +256 -10
- package/bin/_cctally_db.py +290 -21
- package/bin/_cctally_record.py +616 -32
- package/bin/_cctally_tui.py +217 -12
- package/bin/_lib_blocks.py +33 -6
- package/bin/_lib_doctor.py +79 -0
- package/bin/_lib_render.py +20 -0
- package/bin/cctally +841 -29
- 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/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 = ?, "
|
|
@@ -1329,6 +1358,246 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
|
|
|
1329
1358
|
raise
|
|
1330
1359
|
|
|
1331
1360
|
|
|
1361
|
+
@stats_migration("005_percent_milestones_reset_event_id")
|
|
1362
|
+
def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> None:
|
|
1363
|
+
"""Add ``reset_event_id`` to ``percent_milestones`` so post-credit
|
|
1364
|
+
threshold crossings can coexist with pre-credit ones for the same
|
|
1365
|
+
``(week_start_date, percent_threshold)``.
|
|
1366
|
+
|
|
1367
|
+
Sentinel: ``0`` = pre-credit / no event. Existing rows backfill to
|
|
1368
|
+
``0`` via the ``DEFAULT 0`` clause on the new column.
|
|
1369
|
+
|
|
1370
|
+
The new UNIQUE constraint is
|
|
1371
|
+
``UNIQUE(week_start_date, percent_threshold, reset_event_id)`` so the
|
|
1372
|
+
same (week, threshold) pair can land twice if a goodwill credit
|
|
1373
|
+
re-opens the segment under a fresh ``week_reset_events.id``. SQLite
|
|
1374
|
+
can't ALTER a UNIQUE constraint in place — we use the
|
|
1375
|
+
rename-recreate-copy idiom.
|
|
1376
|
+
|
|
1377
|
+
Companion live-path edits: ``cmd_record_usage`` now stamps the
|
|
1378
|
+
active segment (the latest ``week_reset_events.id`` for the
|
|
1379
|
+
current ``new_week_end_at``, else 0) into ``reset_event_id``; the
|
|
1380
|
+
in-place credit detection branch can re-fire the same threshold
|
|
1381
|
+
after a credit.
|
|
1382
|
+
|
|
1383
|
+
Idempotent: a second invocation finds the column already present
|
|
1384
|
+
and returns. Empty-table fast path: when the column is already
|
|
1385
|
+
present the marker still gets stamped — no schema edit needed.
|
|
1386
|
+
"""
|
|
1387
|
+
# Fast-path probe: column already present means a prior run of this
|
|
1388
|
+
# migration (or a fresh-install fast-stamp from the dispatcher that
|
|
1389
|
+
# already picked up the new live-schema CREATE TABLE) has done the
|
|
1390
|
+
# work. Just stamp the marker and return.
|
|
1391
|
+
cols = {
|
|
1392
|
+
str(r[1])
|
|
1393
|
+
for r in conn.execute("PRAGMA table_info(percent_milestones)").fetchall()
|
|
1394
|
+
}
|
|
1395
|
+
if "reset_event_id" in cols:
|
|
1396
|
+
conn.execute(
|
|
1397
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1398
|
+
"VALUES (?, ?)",
|
|
1399
|
+
("005_percent_milestones_reset_event_id", now_utc_iso()),
|
|
1400
|
+
)
|
|
1401
|
+
conn.commit()
|
|
1402
|
+
return
|
|
1403
|
+
|
|
1404
|
+
conn.execute("BEGIN")
|
|
1405
|
+
try:
|
|
1406
|
+
# Add the column with sentinel 0 default (covers existing rows).
|
|
1407
|
+
conn.execute(
|
|
1408
|
+
"ALTER TABLE percent_milestones "
|
|
1409
|
+
"ADD COLUMN reset_event_id INTEGER NOT NULL DEFAULT 0"
|
|
1410
|
+
)
|
|
1411
|
+
# SQLite can't ALTER a UNIQUE constraint in place; rename, recreate
|
|
1412
|
+
# with the new 3-column UNIQUE, copy, drop. Preserves ids and every
|
|
1413
|
+
# existing column (including those added by add_column_if_missing:
|
|
1414
|
+
# five_hour_percent_at_crossing, alerted_at).
|
|
1415
|
+
conn.execute(
|
|
1416
|
+
"ALTER TABLE percent_milestones RENAME TO percent_milestones_old_005"
|
|
1417
|
+
)
|
|
1418
|
+
conn.execute(
|
|
1419
|
+
"""
|
|
1420
|
+
CREATE TABLE percent_milestones (
|
|
1421
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1422
|
+
captured_at_utc TEXT NOT NULL,
|
|
1423
|
+
week_start_date TEXT NOT NULL,
|
|
1424
|
+
week_end_date TEXT NOT NULL,
|
|
1425
|
+
week_start_at TEXT,
|
|
1426
|
+
week_end_at TEXT,
|
|
1427
|
+
percent_threshold INTEGER NOT NULL,
|
|
1428
|
+
cumulative_cost_usd REAL NOT NULL,
|
|
1429
|
+
marginal_cost_usd REAL,
|
|
1430
|
+
usage_snapshot_id INTEGER NOT NULL,
|
|
1431
|
+
cost_snapshot_id INTEGER NOT NULL,
|
|
1432
|
+
five_hour_percent_at_crossing REAL,
|
|
1433
|
+
alerted_at TEXT,
|
|
1434
|
+
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
1435
|
+
UNIQUE(week_start_date, percent_threshold, reset_event_id)
|
|
1436
|
+
)
|
|
1437
|
+
"""
|
|
1438
|
+
)
|
|
1439
|
+
conn.execute(
|
|
1440
|
+
"""
|
|
1441
|
+
INSERT INTO percent_milestones (
|
|
1442
|
+
id, captured_at_utc, week_start_date, week_end_date,
|
|
1443
|
+
week_start_at, week_end_at, percent_threshold,
|
|
1444
|
+
cumulative_cost_usd, marginal_cost_usd,
|
|
1445
|
+
usage_snapshot_id, cost_snapshot_id,
|
|
1446
|
+
five_hour_percent_at_crossing, alerted_at, reset_event_id
|
|
1447
|
+
)
|
|
1448
|
+
SELECT id, captured_at_utc, week_start_date, week_end_date,
|
|
1449
|
+
week_start_at, week_end_at, percent_threshold,
|
|
1450
|
+
cumulative_cost_usd, marginal_cost_usd,
|
|
1451
|
+
usage_snapshot_id, cost_snapshot_id,
|
|
1452
|
+
five_hour_percent_at_crossing, alerted_at,
|
|
1453
|
+
reset_event_id
|
|
1454
|
+
FROM percent_milestones_old_005
|
|
1455
|
+
"""
|
|
1456
|
+
)
|
|
1457
|
+
conn.execute("DROP TABLE percent_milestones_old_005")
|
|
1458
|
+
conn.execute(
|
|
1459
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1460
|
+
"VALUES (?, ?)",
|
|
1461
|
+
("005_percent_milestones_reset_event_id", now_utc_iso()),
|
|
1462
|
+
)
|
|
1463
|
+
conn.commit()
|
|
1464
|
+
except Exception:
|
|
1465
|
+
conn.rollback()
|
|
1466
|
+
raise
|
|
1467
|
+
|
|
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
|
+
|
|
1332
1601
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
1333
1602
|
|
|
1334
1603
|
# ──────────────────────────────────────────────────────────────────────
|