cctally 1.95.4 → 1.96.0
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 +71 -0
- package/README.md +4 -5
- package/bin/_cctally_config.py +46 -22
- package/bin/_cctally_core.py +125 -42
- package/bin/_cctally_dashboard.py +258 -100
- package/bin/_cctally_dashboard_cache_report.py +24 -1
- package/bin/_cctally_dashboard_envelope.py +11 -0
- package/bin/_cctally_dashboard_share.py +38 -4
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_journal.py +272 -20
- package/bin/_cctally_parser.py +14 -3
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_reporting.py +12 -8
- package/bin/_cctally_share.py +86 -12
- package/bin/_cctally_store.py +26 -21
- package/bin/_cctally_tui.py +11 -5
- package/bin/_lib_dashboard_settings_contract.py +86 -0
- package/bin/_lib_journal.py +4 -2
- package/bin/_lib_render.py +47 -20
- package/bin/_lib_share.py +191 -48
- package/bin/cctally +5 -1
- package/dashboard/static/assets/index-Bhr5gZ14.js +97 -0
- package/dashboard/static/assets/index-DfN_fsLZ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BvCbpJJA.css +0 -1
- package/dashboard/static/assets/index-CRZMxeYI.js +0 -97
package/bin/_cctally_share.py
CHANGED
|
@@ -284,6 +284,32 @@ def _share_period_label(
|
|
|
284
284
|
)
|
|
285
285
|
|
|
286
286
|
|
|
287
|
+
def _share_resolve_period_start(
|
|
288
|
+
period_start: dt.datetime,
|
|
289
|
+
*,
|
|
290
|
+
since_explicit: bool,
|
|
291
|
+
displayed_dates: "list[dt.date]",
|
|
292
|
+
display_tz: str,
|
|
293
|
+
) -> dt.datetime:
|
|
294
|
+
"""Keep a requested start, otherwise start at the first displayed day.
|
|
295
|
+
|
|
296
|
+
``_parse_cli_date_range`` uses 2020-01-01 as the query sentinel when
|
|
297
|
+
``--since`` is absent. That remains the correct read bound, but it is not
|
|
298
|
+
a period the user selected. Share artifacts therefore replace only that
|
|
299
|
+
defaulted presentation bound with the first civil day they actually
|
|
300
|
+
render. Empty artifacts retain the query bound because there is no
|
|
301
|
+
content-derived boundary to state (#527).
|
|
302
|
+
"""
|
|
303
|
+
if since_explicit or not displayed_dates:
|
|
304
|
+
return period_start
|
|
305
|
+
first = min(displayed_dates)
|
|
306
|
+
zone = _resolve_tz(
|
|
307
|
+
display_tz,
|
|
308
|
+
fallback=period_start.tzinfo or dt.timezone.utc,
|
|
309
|
+
)
|
|
310
|
+
return dt.datetime(first.year, first.month, first.day, tzinfo=zone)
|
|
311
|
+
|
|
312
|
+
|
|
287
313
|
def _share_display_zone(tz: "ZoneInfo | None"):
|
|
288
314
|
"""The concrete zone `_share_display_tz_label` names, as a tzinfo.
|
|
289
315
|
|
|
@@ -484,6 +510,7 @@ def _build_daily_snapshot(
|
|
|
484
510
|
period_end: dt.datetime,
|
|
485
511
|
display_tz: str,
|
|
486
512
|
version: str,
|
|
513
|
+
since_explicit: bool,
|
|
487
514
|
) -> "ShareSnapshot":
|
|
488
515
|
"""Build a ShareSnapshot for `cctally daily`.
|
|
489
516
|
|
|
@@ -504,11 +531,12 @@ def _build_daily_snapshot(
|
|
|
504
531
|
- `top_model` is the first entry of `model_breakdowns` (sorted by cost
|
|
505
532
|
desc per upstream ccusage parity); empty → "—".
|
|
506
533
|
|
|
507
|
-
`period_start` / `period_end` / `display_tz` are passed by the
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
534
|
+
`period_start` / `period_end` / `display_tz` are passed by the caller.
|
|
535
|
+
An explicit range may extend past the data window; when `--since` was
|
|
536
|
+
omitted, `since_explicit=False` replaces only the share artifact's
|
|
537
|
+
sentinel start with the first displayed day (#527). `theme` and
|
|
538
|
+
`reveal_projects` flow into the subtitle directly so the builder owns the
|
|
539
|
+
canonical subtitle shape — no post-build re-stamp at the gate site.
|
|
512
540
|
"""
|
|
513
541
|
_lib_share = _share_load_lib()
|
|
514
542
|
columns = (
|
|
@@ -528,13 +556,16 @@ def _build_daily_snapshot(
|
|
|
528
556
|
|
|
529
557
|
snap_rows: list = []
|
|
530
558
|
chart_pts: list = []
|
|
559
|
+
displayed_period_dates: list[dt.date] = []
|
|
531
560
|
for i, r in enumerate(rows):
|
|
532
561
|
# `BucketUsage.bucket` is typed `str` (YYYY-MM-DD); guard against
|
|
533
562
|
# empty / unparseable but skip the dead `dt.date` branch.
|
|
534
563
|
bucket = getattr(r, "bucket", None)
|
|
535
564
|
if isinstance(bucket, str) and bucket:
|
|
536
565
|
try:
|
|
537
|
-
|
|
566
|
+
parsed_date = dt.date.fromisoformat(bucket)
|
|
567
|
+
date_str = parsed_date.isoformat()
|
|
568
|
+
displayed_period_dates.append(parsed_date)
|
|
538
569
|
except ValueError:
|
|
539
570
|
date_str = bucket
|
|
540
571
|
else:
|
|
@@ -564,6 +595,12 @@ def _build_daily_snapshot(
|
|
|
564
595
|
_lib_share.Totalled(label="Days", value=str(len(chart_pts))),
|
|
565
596
|
_lib_share.Totalled(label="Avg / day", value=f"${avg_cost:,.2f}"),
|
|
566
597
|
)
|
|
598
|
+
period_start = _share_resolve_period_start(
|
|
599
|
+
period_start,
|
|
600
|
+
since_explicit=since_explicit,
|
|
601
|
+
displayed_dates=displayed_period_dates,
|
|
602
|
+
display_tz=display_tz,
|
|
603
|
+
)
|
|
567
604
|
if rows:
|
|
568
605
|
title = (
|
|
569
606
|
f"Daily usage — {period_start.date().isoformat()} → "
|
|
@@ -595,6 +632,7 @@ def _build_monthly_snapshot(
|
|
|
595
632
|
period_end: dt.datetime,
|
|
596
633
|
display_tz: str,
|
|
597
634
|
version: str,
|
|
635
|
+
since_explicit: bool,
|
|
598
636
|
) -> "ShareSnapshot":
|
|
599
637
|
"""Build a ShareSnapshot for `cctally monthly`.
|
|
600
638
|
|
|
@@ -613,11 +651,12 @@ def _build_monthly_snapshot(
|
|
|
613
651
|
- `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
|
|
614
652
|
months, matching the plan's intent.
|
|
615
653
|
|
|
616
|
-
`period_start` / `period_end` / `display_tz` are passed by the
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
654
|
+
`period_start` / `period_end` / `display_tz` are passed by the caller.
|
|
655
|
+
An explicit range may extend past the data window; when `--since` was
|
|
656
|
+
omitted, `since_explicit=False` replaces only the share artifact's
|
|
657
|
+
sentinel start with the first displayed month (#527). `theme` /
|
|
658
|
+
`reveal_projects` flow into the subtitle directly so the builder owns the
|
|
659
|
+
canonical subtitle shape — no post-build re-stamp at the gate site.
|
|
621
660
|
"""
|
|
622
661
|
# Caller MUST pass rows in chronological order so the BarChart bars
|
|
623
662
|
# line up left-to-right with time. view.aggregated is newest-first.
|
|
@@ -632,12 +671,20 @@ def _build_monthly_snapshot(
|
|
|
632
671
|
)
|
|
633
672
|
snap_rows: list = []
|
|
634
673
|
chart_pts: list = []
|
|
674
|
+
displayed_period_dates: list[dt.date] = []
|
|
635
675
|
prev_cost: float | None = None
|
|
636
676
|
for i, r in enumerate(rows):
|
|
637
677
|
# `BucketUsage.bucket` is typed `str` ("YYYY-MM"); guard against
|
|
638
678
|
# empty / unparseable but skip the dead `dt.date` branch.
|
|
639
679
|
bucket = getattr(r, "bucket", None)
|
|
640
680
|
month_str = bucket if isinstance(bucket, str) and bucket else "—"
|
|
681
|
+
if isinstance(bucket, str):
|
|
682
|
+
try:
|
|
683
|
+
displayed_period_dates.append(
|
|
684
|
+
dt.date.fromisoformat(f"{bucket}-01")
|
|
685
|
+
)
|
|
686
|
+
except ValueError:
|
|
687
|
+
pass
|
|
641
688
|
cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
642
689
|
total_tokens = int(getattr(r, "total_tokens", 0) or 0)
|
|
643
690
|
if prev_cost is not None and prev_cost > 0:
|
|
@@ -668,6 +715,12 @@ def _build_monthly_snapshot(
|
|
|
668
715
|
_lib_share.Totalled(label="Months", value=str(len(chart_pts))),
|
|
669
716
|
_lib_share.Totalled(label="Avg / month", value=f"${avg_cost:,.2f}"),
|
|
670
717
|
)
|
|
718
|
+
period_start = _share_resolve_period_start(
|
|
719
|
+
period_start,
|
|
720
|
+
since_explicit=since_explicit,
|
|
721
|
+
displayed_dates=displayed_period_dates,
|
|
722
|
+
display_tz=display_tz,
|
|
723
|
+
)
|
|
671
724
|
if rows:
|
|
672
725
|
title = (
|
|
673
726
|
f"Monthly usage — {period_start.strftime('%Y-%m')} → "
|
|
@@ -703,6 +756,7 @@ def _build_weekly_snapshot(
|
|
|
703
756
|
display_tz: str,
|
|
704
757
|
version: str,
|
|
705
758
|
breakdown_model: bool,
|
|
759
|
+
since_explicit: bool,
|
|
706
760
|
) -> "ShareSnapshot":
|
|
707
761
|
"""Build a ShareSnapshot for `cctally weekly`.
|
|
708
762
|
|
|
@@ -780,6 +834,7 @@ def _build_weekly_snapshot(
|
|
|
780
834
|
|
|
781
835
|
snap_rows: list = []
|
|
782
836
|
chart_pts: list = []
|
|
837
|
+
displayed_period_dates: list[dt.date] = []
|
|
783
838
|
stacks: dict[str, list] = {}
|
|
784
839
|
for i, r in enumerate(rows):
|
|
785
840
|
# `BucketUsage.bucket` is typed `str` ("YYYY-MM-DD"); guard against
|
|
@@ -787,7 +842,9 @@ def _build_weekly_snapshot(
|
|
|
787
842
|
bucket = getattr(r, "bucket", None)
|
|
788
843
|
if isinstance(bucket, str) and bucket:
|
|
789
844
|
try:
|
|
790
|
-
|
|
845
|
+
parsed_date = dt.date.fromisoformat(bucket)
|
|
846
|
+
week_label = parsed_date.isoformat()
|
|
847
|
+
displayed_period_dates.append(parsed_date)
|
|
791
848
|
except ValueError:
|
|
792
849
|
week_label = bucket
|
|
793
850
|
else:
|
|
@@ -852,6 +909,12 @@ def _build_weekly_snapshot(
|
|
|
852
909
|
_lib_share.Totalled(label="Avg %/wk", value=f"{avg_pct:.1f}%"),
|
|
853
910
|
_lib_share.Totalled(label="Peak %", value=f"{peak_pct:.1f}%"),
|
|
854
911
|
)
|
|
912
|
+
period_start = _share_resolve_period_start(
|
|
913
|
+
period_start,
|
|
914
|
+
since_explicit=since_explicit,
|
|
915
|
+
displayed_dates=displayed_period_dates,
|
|
916
|
+
display_tz=display_tz,
|
|
917
|
+
)
|
|
855
918
|
title = (
|
|
856
919
|
f"Weekly usage — last {len(rows)} weeks"
|
|
857
920
|
if rows
|
|
@@ -1438,6 +1501,7 @@ def _build_session_snapshot(
|
|
|
1438
1501
|
version: str,
|
|
1439
1502
|
top_n: int | None,
|
|
1440
1503
|
tz: "ZoneInfo | None",
|
|
1504
|
+
since_explicit: bool,
|
|
1441
1505
|
) -> "ShareSnapshot":
|
|
1442
1506
|
"""Build a ShareSnapshot for `cctally session`.
|
|
1443
1507
|
|
|
@@ -1528,6 +1592,7 @@ def _build_session_snapshot(
|
|
|
1528
1592
|
augmented = _session_disambiguate_labels(sorted_sessions)
|
|
1529
1593
|
snap_rows: list = []
|
|
1530
1594
|
chart_pts: list = []
|
|
1595
|
+
displayed_period_dates: list[dt.date] = []
|
|
1531
1596
|
for idx, s in enumerate(sorted_sessions):
|
|
1532
1597
|
bare_label = (
|
|
1533
1598
|
os.path.basename(s.project_path or "")
|
|
@@ -1545,6 +1610,9 @@ def _build_session_snapshot(
|
|
|
1545
1610
|
last_str = format_display_dt(
|
|
1546
1611
|
s.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
|
|
1547
1612
|
)
|
|
1613
|
+
displayed_period_dates.append(
|
|
1614
|
+
s.last_activity.astimezone(_share_display_zone(tz)).date()
|
|
1615
|
+
)
|
|
1548
1616
|
models_text = ", ".join(s.models) if s.models else "—"
|
|
1549
1617
|
snap_rows.append(_lib_share.Row(cells={
|
|
1550
1618
|
"session": _lib_share.TextCell(sid_short),
|
|
@@ -1576,6 +1644,12 @@ def _build_session_snapshot(
|
|
|
1576
1644
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
1577
1645
|
_lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
|
|
1578
1646
|
)
|
|
1647
|
+
period_start = _share_resolve_period_start(
|
|
1648
|
+
period_start,
|
|
1649
|
+
since_explicit=since_explicit,
|
|
1650
|
+
displayed_dates=displayed_period_dates,
|
|
1651
|
+
display_tz=display_tz,
|
|
1652
|
+
)
|
|
1579
1653
|
if sorted_sessions:
|
|
1580
1654
|
if truncated:
|
|
1581
1655
|
title = f"Top {len(snap_rows)} sessions"
|
package/bin/_cctally_store.py
CHANGED
|
@@ -748,7 +748,7 @@ def stats_open_time_guard(
|
|
|
748
748
|
except (BlockingIOError, OSError):
|
|
749
749
|
if time.monotonic() >= deadline:
|
|
750
750
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
751
|
-
|
|
751
|
+
_stats_open_maintenance_timeout_message()
|
|
752
752
|
)
|
|
753
753
|
time.sleep(0.02)
|
|
754
754
|
_cctally_core.note_stats_maintenance_acquired()
|
|
@@ -829,6 +829,18 @@ _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG = (
|
|
|
829
829
|
)
|
|
830
830
|
|
|
831
831
|
|
|
832
|
+
def _stats_open_maintenance_timeout_message() -> str:
|
|
833
|
+
"""Name a confirmed detached rebuild when it owns the maintenance wait."""
|
|
834
|
+
request = _read_stats_heal_request()
|
|
835
|
+
if (
|
|
836
|
+
request
|
|
837
|
+
and request.get("forensicsDisposition") == "confirmed"
|
|
838
|
+
and _stats_heal_worker_active()
|
|
839
|
+
):
|
|
840
|
+
return str(_cctally_db.StatsHealDeferred("pending"))
|
|
841
|
+
return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
|
|
842
|
+
|
|
843
|
+
|
|
832
844
|
def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
|
|
833
845
|
"""Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
|
|
834
846
|
|
|
@@ -953,10 +965,10 @@ def _pending_stats_publication_never_replaced(db_path) -> bool:
|
|
|
953
965
|
reads that as "replaced". The proxy is used only across processes, where
|
|
954
966
|
that cleanup cannot reach, so the weaker property is the one it needs.
|
|
955
967
|
|
|
956
|
-
A marker carrying no `scratchPath`
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
968
|
+
A marker carrying no `scratchPath` proves neither outcome and therefore
|
|
969
|
+
still owes a verdict. No released binary has ever written one — the marker
|
|
970
|
+
and this field ship together — but failing closed keeps that malformed
|
|
971
|
+
state from silently accepting unvalidated live bytes.
|
|
960
972
|
|
|
961
973
|
**In-place publication** answers with the publication's own stamp, because
|
|
962
974
|
it attaches the scratch read-only and the scratch survives commit and
|
|
@@ -973,16 +985,9 @@ def _pending_stats_publication_never_replaced(db_path) -> bool:
|
|
|
973
985
|
return True
|
|
974
986
|
if str(state.get("status") or "") != "pending":
|
|
975
987
|
return False
|
|
976
|
-
|
|
977
|
-
import _cctally_journal
|
|
988
|
+
import _cctally_journal
|
|
978
989
|
|
|
979
|
-
|
|
980
|
-
db_path, state
|
|
981
|
-
)
|
|
982
|
-
scratch = state.get("scratchPath")
|
|
983
|
-
if not isinstance(scratch, str) or not scratch:
|
|
984
|
-
return True
|
|
985
|
-
return pathlib.Path(scratch).exists()
|
|
990
|
+
return _cctally_journal._pending_publication_owes_nothing(db_path, state)
|
|
986
991
|
|
|
987
992
|
|
|
988
993
|
def _stats_publication_failed_error(
|
|
@@ -1411,7 +1416,7 @@ def stats_open_guarded(
|
|
|
1411
1416
|
lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
|
|
1412
1417
|
):
|
|
1413
1418
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
1414
|
-
|
|
1419
|
+
_stats_open_maintenance_timeout_message()
|
|
1415
1420
|
)
|
|
1416
1421
|
if marker.exists():
|
|
1417
1422
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -1425,7 +1430,7 @@ def stats_open_guarded(
|
|
|
1425
1430
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1426
1431
|
):
|
|
1427
1432
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
1428
|
-
|
|
1433
|
+
_stats_open_maintenance_timeout_message()
|
|
1429
1434
|
)
|
|
1430
1435
|
try:
|
|
1431
1436
|
if marker.exists():
|
|
@@ -1450,7 +1455,7 @@ def stats_open_guarded(
|
|
|
1450
1455
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1451
1456
|
):
|
|
1452
1457
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
1453
|
-
|
|
1458
|
+
_stats_open_maintenance_timeout_message()
|
|
1454
1459
|
)
|
|
1455
1460
|
# Recovery calls `rebuild_stats_index`, whose in-place publisher
|
|
1456
1461
|
# reopens the live destination through `stats_open_guarded`.
|
|
@@ -1496,7 +1501,7 @@ def stats_open_guarded(
|
|
|
1496
1501
|
lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
|
|
1497
1502
|
):
|
|
1498
1503
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
1499
|
-
|
|
1504
|
+
_stats_open_maintenance_timeout_message()
|
|
1500
1505
|
)
|
|
1501
1506
|
if marker.exists() or pending.exists():
|
|
1502
1507
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -1516,7 +1521,7 @@ def stats_open_guarded(
|
|
|
1516
1521
|
lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
|
|
1517
1522
|
):
|
|
1518
1523
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
1519
|
-
|
|
1524
|
+
_stats_open_maintenance_timeout_message()
|
|
1520
1525
|
)
|
|
1521
1526
|
try:
|
|
1522
1527
|
_resolve_stats_publication_marker(db_path)
|
|
@@ -1526,7 +1531,7 @@ def stats_open_guarded(
|
|
|
1526
1531
|
lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
|
|
1527
1532
|
):
|
|
1528
1533
|
raise _cctally_db.StatsDbMaintenanceError(
|
|
1529
|
-
|
|
1534
|
+
_stats_open_maintenance_timeout_message()
|
|
1530
1535
|
)
|
|
1531
1536
|
if marker.exists() or pending.exists():
|
|
1532
1537
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
@@ -2538,7 +2543,7 @@ def cmd_stats_corruption_heal_internal(args) -> int:
|
|
|
2538
2543
|
heal_id = str(request.get("healId") or "")
|
|
2539
2544
|
try:
|
|
2540
2545
|
outcome = _run_stats_corruption_heal(request)
|
|
2541
|
-
except Exception as exc:
|
|
2546
|
+
except (_cctally_db.StatsRebuildDeferred, Exception) as exc:
|
|
2542
2547
|
# Retryable: the marker stays so a later detection is admitted
|
|
2543
2548
|
# once its retry window expires.
|
|
2544
2549
|
_record_stats_heal_outcome(heal_id, "failed", error=exc)
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -4304,10 +4304,18 @@ def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
|
|
|
4304
4304
|
)
|
|
4305
4305
|
|
|
4306
4306
|
|
|
4307
|
+
def _stats_open_failure_is_corruption(exc: BaseException) -> bool:
|
|
4308
|
+
"""Keep epoch deferral distinct from heal deferral and read failures."""
|
|
4309
|
+
c = _cctally()
|
|
4310
|
+
if isinstance(exc, c.StatsRebuildDeferred):
|
|
4311
|
+
return isinstance(exc, c.StatsHealDeferred)
|
|
4312
|
+
return True
|
|
4313
|
+
|
|
4314
|
+
|
|
4307
4315
|
def _tui_stats_retry_degraded_snapshot(
|
|
4308
4316
|
*,
|
|
4309
4317
|
now_utc: dt.datetime,
|
|
4310
|
-
exc:
|
|
4318
|
+
exc: BaseException,
|
|
4311
4319
|
precompute_envelope: bool,
|
|
4312
4320
|
runtime_bind: "str | None",
|
|
4313
4321
|
) -> DataSnapshot:
|
|
@@ -4333,7 +4341,7 @@ def _tui_stats_retry_degraded_snapshot(
|
|
|
4333
4341
|
SyncFailureAttribution(
|
|
4334
4342
|
leg="stats-open",
|
|
4335
4343
|
database="stats",
|
|
4336
|
-
corruption=
|
|
4344
|
+
corruption=_stats_open_failure_is_corruption(exc),
|
|
4337
4345
|
),
|
|
4338
4346
|
),
|
|
4339
4347
|
doctor_payload=doctor_payload,
|
|
@@ -6724,9 +6732,7 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
6724
6732
|
SyncFailureAttribution(
|
|
6725
6733
|
leg="stats-open",
|
|
6726
6734
|
database="stats",
|
|
6727
|
-
corruption=
|
|
6728
|
-
exc, _cctally().StatsHealDeferred
|
|
6729
|
-
),
|
|
6735
|
+
corruption=_stats_open_failure_is_corruption(exc),
|
|
6730
6736
|
),
|
|
6731
6737
|
),
|
|
6732
6738
|
generated_at=dt.datetime.now(dt.timezone.utc),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Which settings leaves ``POST /api/settings`` may write.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the dashboard settings contract (#513 S1). Kept
|
|
4
|
+
pure and dependency-free -- it imports nothing -- so the documentation test
|
|
5
|
+
can read the contract without loading the dashboard, and so there is no cycle
|
|
6
|
+
with ``_cctally_config``.
|
|
7
|
+
|
|
8
|
+
Three states, not two. A leaf the endpoint persists is ``WRITABLE``. A leaf
|
|
9
|
+
it deliberately accepts and does not persist is ``KNOWN_IGNORED``: there are
|
|
10
|
+
exactly four, pinned by #134 (the Codex partial-merge contract, where amounts
|
|
11
|
+
stay CLI-only) and #143 (``budget.period`` drives the forward-only reconcile
|
|
12
|
+
without being stored here). Anything absent from the map is unknown to this
|
|
13
|
+
endpoint and is rejected with the offending dotted path as ``field``.
|
|
14
|
+
|
|
15
|
+
The third state matters because two states would force ``budget.period`` to
|
|
16
|
+
be described as either writable or rejected, and it is neither.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
WRITABLE = "writable"
|
|
20
|
+
KNOWN_IGNORED = "known_ignored"
|
|
21
|
+
|
|
22
|
+
#: Fully-qualified dotted path -> disposition. Order is the endpoint's block
|
|
23
|
+
#: order and is not significant; membership is.
|
|
24
|
+
SETTINGS_LEAF_DISPOSITIONS = {
|
|
25
|
+
"display.tz": WRITABLE,
|
|
26
|
+
"alerts.enabled": WRITABLE,
|
|
27
|
+
"alerts.projected_enabled": WRITABLE,
|
|
28
|
+
"alerts.notifier": WRITABLE,
|
|
29
|
+
"dashboard.cache_failure_markers": WRITABLE,
|
|
30
|
+
"dashboard.live_tail": WRITABLE,
|
|
31
|
+
"dashboard.lan_auth": WRITABLE,
|
|
32
|
+
"update.check.enabled": WRITABLE,
|
|
33
|
+
"update.check.ttl_hours": WRITABLE,
|
|
34
|
+
"update.channel": WRITABLE,
|
|
35
|
+
"cache_report.anomaly_threshold_pp": WRITABLE,
|
|
36
|
+
"budget.weekly_usd": WRITABLE,
|
|
37
|
+
"budget.alerts_enabled": WRITABLE,
|
|
38
|
+
"budget.alert_thresholds": WRITABLE,
|
|
39
|
+
"budget.projected_enabled": WRITABLE,
|
|
40
|
+
"budget.project_alerts_enabled": WRITABLE,
|
|
41
|
+
"budget.codex.alerts_enabled": WRITABLE,
|
|
42
|
+
"budget.codex.projected_enabled": WRITABLE,
|
|
43
|
+
# #143: answered 200 and drives the forward-only reconcile, never stored.
|
|
44
|
+
"budget.period": KNOWN_IGNORED,
|
|
45
|
+
# #134: CLI-only amounts, preserved from the persisted block on a merge.
|
|
46
|
+
"budget.codex.amount_usd": KNOWN_IGNORED,
|
|
47
|
+
"budget.codex.period": KNOWN_IGNORED,
|
|
48
|
+
"budget.codex.alert_thresholds": KNOWN_IGNORED,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ancestors(path):
|
|
53
|
+
"""Yield every proper dotted prefix of ``path``, outermost first."""
|
|
54
|
+
parts = path.split(".")
|
|
55
|
+
for i in range(1, len(parts)):
|
|
56
|
+
yield ".".join(parts[:i])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Every interior path a request may descend through, derived from the leaf
|
|
60
|
+
#: paths so a new leaf can never forget to register its parent.
|
|
61
|
+
SETTINGS_OBJECT_PATHS = frozenset(
|
|
62
|
+
parent
|
|
63
|
+
for path in SETTINGS_LEAF_DISPOSITIONS
|
|
64
|
+
for parent in _ancestors(path)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: The six blocks the endpoint accepts at the top level.
|
|
68
|
+
SETTINGS_TOP_LEVEL_BLOCKS = frozenset(
|
|
69
|
+
path.split(".")[0] for path in SETTINGS_LEAF_DISPOSITIONS
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: Blocks that are NOT a valid no-op when sent empty. A named block carrying
|
|
73
|
+
#: no leaves is an ordinary partial-PUT no-op everywhere else -- notably
|
|
74
|
+
#: ``{"cache_report": {}}``, which is what a combined save sends when the user
|
|
75
|
+
#: never opened that tab, and which must keep answering 200.
|
|
76
|
+
SETTINGS_REQUIRED_LEAVES = {"display": frozenset({"display.tz"})}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def disposition_for(path):
|
|
80
|
+
"""Return ``WRITABLE``, ``KNOWN_IGNORED``, or ``None`` for an unknown path."""
|
|
81
|
+
return SETTINGS_LEAF_DISPOSITIONS.get(path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_object_path(path):
|
|
85
|
+
"""True when ``path`` is an interior node a request may descend through."""
|
|
86
|
+
return path in SETTINGS_OBJECT_PATHS
|
package/bin/_lib_journal.py
CHANGED
|
@@ -259,8 +259,10 @@ def reusable_bootstrap_name(candidate_digest, candidate_size, existing):
|
|
|
259
259
|
|
|
260
260
|
Reusing an older match instead would stamp the cursor behind a bootstrap the
|
|
261
261
|
cursor does not cover, and the next ingest would fold that stale bootstrap's
|
|
262
|
-
records into stats.db. Writing a fresh segment
|
|
263
|
-
|
|
262
|
+
records into stats.db. Writing a fresh segment invokes the caller's
|
|
263
|
+
monotonic name selector, which compares wall time with the published
|
|
264
|
+
bootstrap set and mints after the canonically newest segment when the clock
|
|
265
|
+
moved backwards (#509).
|
|
264
266
|
|
|
265
267
|
Returns None when the newest bootstrap does not match, which covers the
|
|
266
268
|
ordinary first-cutover path, the genuinely-differing-export path, and the
|
package/bin/_lib_render.py
CHANGED
|
@@ -28,6 +28,8 @@ Sibling dependencies (loaded at module-load time via ``_load_lib``):
|
|
|
28
28
|
used across every breakdown-aware table).
|
|
29
29
|
* ``_lib_display_tz`` — ``_resolve_tz`` (IANA tz resolution for the
|
|
30
30
|
Codex session-table date columns).
|
|
31
|
+
* ``_lib_share`` — ``disambiguate_basenames`` (the single project-label
|
|
32
|
+
collision algorithm shared with artifact preparation).
|
|
31
33
|
|
|
32
34
|
``bin/cctally`` back-references via module-level callable shims
|
|
33
35
|
(spec §5.5; same precedent as ``bin/_cctally_record.py``'s 34 shims):
|
|
@@ -98,6 +100,7 @@ _lib_pricing = _load_lib("_lib_pricing")
|
|
|
98
100
|
_short_model_name = _lib_pricing._short_model_name
|
|
99
101
|
|
|
100
102
|
_lib_display_tz = _load_lib("_lib_display_tz")
|
|
103
|
+
_lib_share = _load_lib("_lib_share")
|
|
101
104
|
_resolve_tz = _lib_display_tz._resolve_tz
|
|
102
105
|
|
|
103
106
|
# JSON wire-format kernel — the additive camelCase schemaVersion stamp
|
|
@@ -2722,11 +2725,11 @@ def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
|
|
|
2722
2725
|
"""Return ``{row_index: disambiguated_label}`` for project rows whose
|
|
2723
2726
|
bare ``display_key`` collides with another row's basename.
|
|
2724
2727
|
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2728
|
+
``_lib_share.disambiguate_basenames`` owns the collision algorithm. This
|
|
2729
|
+
adapter identifies the colliding project identities in the row-shaped
|
|
2730
|
+
renderer input, deduplicates repeated rows for one canonical bucket, and
|
|
2731
|
+
fans the kernel labels back out. Prefer ``key.git_root`` as the kernel
|
|
2732
|
+
path when present; fall back to ``key.bucket_path`` for no-git rows.
|
|
2730
2733
|
|
|
2731
2734
|
Used by:
|
|
2732
2735
|
- ``_render_project_table`` (terminal table render).
|
|
@@ -2737,20 +2740,45 @@ def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
|
|
|
2737
2740
|
Rows that do not collide are absent from the returned dict; callers
|
|
2738
2741
|
fall back to ``key.display_key`` for those.
|
|
2739
2742
|
"""
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
for
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2743
|
+
identity_to_unique: dict[str, int] = {}
|
|
2744
|
+
unique_projects: list[tuple[str, str]] = []
|
|
2745
|
+
row_unique_indices: list[int] = []
|
|
2746
|
+
display_groups: dict[str, list[int]] = {}
|
|
2747
|
+
|
|
2748
|
+
for row in rows:
|
|
2749
|
+
key = row["key"]
|
|
2750
|
+
identity = key.bucket_path
|
|
2751
|
+
unique_idx = identity_to_unique.get(identity)
|
|
2752
|
+
if unique_idx is None:
|
|
2753
|
+
unique_idx = len(unique_projects)
|
|
2754
|
+
identity_to_unique[identity] = unique_idx
|
|
2755
|
+
source_path = key.git_root or key.bucket_path
|
|
2756
|
+
unique_projects.append((key.display_key, source_path))
|
|
2757
|
+
display_groups.setdefault(key.display_key, []).append(unique_idx)
|
|
2758
|
+
row_unique_indices.append(unique_idx)
|
|
2759
|
+
|
|
2760
|
+
colliding_unique = {
|
|
2761
|
+
unique_idx
|
|
2762
|
+
for unique_indices in display_groups.values()
|
|
2763
|
+
if len(unique_indices) > 1
|
|
2764
|
+
for unique_idx in unique_indices
|
|
2765
|
+
}
|
|
2766
|
+
if not colliding_unique:
|
|
2767
|
+
return {}
|
|
2768
|
+
|
|
2769
|
+
ordered_unique = sorted(colliding_unique)
|
|
2770
|
+
kernel_labels = _lib_share.disambiguate_basenames(
|
|
2771
|
+
[unique_projects[unique_idx][1] for unique_idx in ordered_unique]
|
|
2772
|
+
)
|
|
2773
|
+
labels_by_unique = {
|
|
2774
|
+
unique_idx: kernel_labels[kernel_idx]
|
|
2775
|
+
for kernel_idx, unique_idx in enumerate(ordered_unique)
|
|
2776
|
+
}
|
|
2777
|
+
return {
|
|
2778
|
+
row_idx: labels_by_unique[unique_idx]
|
|
2779
|
+
for row_idx, unique_idx in enumerate(row_unique_indices)
|
|
2780
|
+
if unique_idx in labels_by_unique
|
|
2781
|
+
}
|
|
2754
2782
|
|
|
2755
2783
|
|
|
2756
2784
|
def _render_project_table(
|
|
@@ -3214,4 +3242,3 @@ def _render_five_hour_blocks_table(
|
|
|
3214
3242
|
print(_boxed_table(headers, rows, aligns, compact=args.compact))
|
|
3215
3243
|
glyph = " · ⚡ = block crossed weekly reset" if has_crossed else ""
|
|
3216
3244
|
print(f"\n{len(block_dicts)} blocks · cost: ${total_cost:.2f}{glyph}")
|
|
3217
|
-
|