cctally 1.60.0 → 1.61.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 +14 -0
- package/bin/_cctally_cache.py +323 -48
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +866 -141
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +643 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +92 -2
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_snapshot_cache.py +988 -0
- package/bin/_lib_view_models.py +107 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/{index-DQH6UPc_.js → index-D_Ylyqsf.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-uGwZSssU.css +0 -1
package/bin/_lib_view_models.py
CHANGED
|
@@ -335,7 +335,8 @@ class DailyView:
|
|
|
335
335
|
display_tz_label: str = ""
|
|
336
336
|
|
|
337
337
|
|
|
338
|
-
def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"
|
|
338
|
+
def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto",
|
|
339
|
+
aggregated_override=None):
|
|
339
340
|
"""Build a ``DailyView`` from raw ``UsageEntry`` list (spec §5.1).
|
|
340
341
|
|
|
341
342
|
Gap-free: only days with entries appear in ``view.rows`` /
|
|
@@ -351,9 +352,22 @@ def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"):
|
|
|
351
352
|
Leaves ``DailyPanelRow.label`` and ``intensity_bucket`` at dataclass
|
|
352
353
|
defaults — the dashboard envelope adapter populates them. CLI /
|
|
353
354
|
share consumers ignore them and read ``view.aggregated`` instead.
|
|
355
|
+
|
|
356
|
+
``aggregated_override`` (#268): when provided (a list/tuple of
|
|
357
|
+
``BucketUsage`` in ascending bucket-key order), skip the
|
|
358
|
+
``_aggregate_daily(entries, ...)`` re-costing and build the view over
|
|
359
|
+
those pre-aggregated buckets instead. This is the reuse seam the
|
|
360
|
+
dashboard's cached daily builder uses to serve immutable past days
|
|
361
|
+
from memory while keeping every downstream derivation (rows,
|
|
362
|
+
``cache_hit_pct``, totals, ``period_start``) single-sourced here. When
|
|
363
|
+
``None`` (CLI / share / cold path) behavior is unchanged and
|
|
364
|
+
byte-identical.
|
|
354
365
|
"""
|
|
355
366
|
_agg = _load_lib("_lib_aggregators")
|
|
356
|
-
|
|
367
|
+
if aggregated_override is not None:
|
|
368
|
+
buckets = list(aggregated_override)
|
|
369
|
+
else:
|
|
370
|
+
buckets = _agg._aggregate_daily(entries, mode=mode, tz=display_tz)
|
|
357
371
|
if not buckets:
|
|
358
372
|
return DailyView(
|
|
359
373
|
rows=(),
|
|
@@ -444,7 +458,8 @@ class MonthlyView:
|
|
|
444
458
|
display_tz_label: str = ""
|
|
445
459
|
|
|
446
460
|
|
|
447
|
-
def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"
|
|
461
|
+
def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto",
|
|
462
|
+
aggregated_override=None):
|
|
448
463
|
"""Build a ``MonthlyView`` for the trailing ``n`` calendar months
|
|
449
464
|
(spec §5.2).
|
|
450
465
|
|
|
@@ -455,9 +470,21 @@ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"):
|
|
|
455
470
|
Totals (``total_cost_usd`` / ``total_tokens``) sum over the
|
|
456
471
|
truncated row set so the React panel sees the same number as the
|
|
457
472
|
CLI table footer would.
|
|
473
|
+
|
|
474
|
+
``aggregated_override`` (#268): when provided (a list/tuple of
|
|
475
|
+
``BucketUsage`` in ascending bucket-key order), skip the
|
|
476
|
+
``_aggregate_monthly(entries, ...)`` re-costing and build the view over
|
|
477
|
+
those pre-aggregated buckets. The reverse + cap-to-``n`` +
|
|
478
|
+
``delta_cost_pct`` + ``is_current`` presentation still runs over the
|
|
479
|
+
assembled list, so the current month's delta sees the prior (cached)
|
|
480
|
+
month (Codex F3). ``None`` preserves the CLI/share/cold behavior
|
|
481
|
+
byte-identically.
|
|
458
482
|
"""
|
|
459
483
|
_agg = _load_lib("_lib_aggregators")
|
|
460
|
-
|
|
484
|
+
if aggregated_override is not None:
|
|
485
|
+
buckets = list(aggregated_override)
|
|
486
|
+
else:
|
|
487
|
+
buckets = _agg._aggregate_monthly(entries, mode=mode, tz=display_tz)
|
|
461
488
|
if not buckets:
|
|
462
489
|
return MonthlyView(
|
|
463
490
|
rows=(), aggregated=(),
|
|
@@ -545,7 +572,7 @@ class WeeklyView:
|
|
|
545
572
|
|
|
546
573
|
|
|
547
574
|
def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
|
|
548
|
-
as_of_utc=None, mode="auto"):
|
|
575
|
+
as_of_utc=None, mode="auto", aggregated_override=None):
|
|
549
576
|
"""Build a ``WeeklyView`` from subscription-week boundaries
|
|
550
577
|
(spec §5.3).
|
|
551
578
|
|
|
@@ -562,10 +589,22 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
|
|
|
562
589
|
|
|
563
590
|
Output is newest-first across ``rows`` / ``aggregated`` /
|
|
564
591
|
``overlay`` — the CLI re-reverses for asc rendering.
|
|
592
|
+
|
|
593
|
+
``aggregated_override`` (#268): when provided (a list/tuple of
|
|
594
|
+
``BucketUsage`` in ascending bucket-key order — one per SubWeek WITH
|
|
595
|
+
data), skip the ``_aggregate_weekly(entries, weeks)`` re-costing and
|
|
596
|
+
run the overlay / delta / is_current presentation over those
|
|
597
|
+
pre-aggregated buckets. The overlay (``weekly_usage_snapshots``) is
|
|
598
|
+
always re-read fresh here, so a snapshot change is reflected even for a
|
|
599
|
+
cached bucket. ``None`` preserves the CLI/share/cold behavior
|
|
600
|
+
byte-identically.
|
|
565
601
|
"""
|
|
566
602
|
_agg = _load_lib("_lib_aggregators")
|
|
567
603
|
_cct_core = _load_lib("_cctally_core")
|
|
568
|
-
|
|
604
|
+
if aggregated_override is not None:
|
|
605
|
+
buckets_asc = list(aggregated_override)
|
|
606
|
+
else:
|
|
607
|
+
buckets_asc = _agg._aggregate_weekly(entries, weeks, mode=mode)
|
|
569
608
|
if not buckets_asc:
|
|
570
609
|
return WeeklyView(
|
|
571
610
|
rows=(), aggregated=(), overlay=(),
|
|
@@ -719,7 +758,8 @@ class TrendView:
|
|
|
719
758
|
display_tz_label: str = ""
|
|
720
759
|
|
|
721
760
|
|
|
722
|
-
def build_trend_view(conn, *, now_utc, n=8, display_tz=None
|
|
761
|
+
def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
762
|
+
use_weekref_cost_cache=False):
|
|
723
763
|
"""Build a ``TrendView`` of the last ``n`` subscription weeks
|
|
724
764
|
(spec §5.4).
|
|
725
765
|
|
|
@@ -731,6 +771,17 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
|
|
|
731
771
|
cmd_report path (``bin/cctally:~7969-7979``) and ``_tui_build_trend``
|
|
732
772
|
(``bin/_cctally_tui.py:~1515-1524``).
|
|
733
773
|
|
|
774
|
+
``skip_sync`` (default ``False``) threads through to
|
|
775
|
+
``_compute_cost_for_weekref`` → ``_sum_cost_for_range`` so the
|
|
776
|
+
reset-event live-cost path can read the cache WITHOUT running a
|
|
777
|
+
JSONL ingest. The #268 dashboard/TUI sync-thread rebuild sets it
|
|
778
|
+
``True`` (it ingests exactly once at the top of the rebuild); every
|
|
779
|
+
other caller — ``cmd_report``, ``_tui_build_trend`` panel, etc. —
|
|
780
|
+
keeps the default so their sync-on-read behavior is byte-identical.
|
|
781
|
+
At scale (~19 reset-event weeks over a 10K-file instance) the default
|
|
782
|
+
ran a full 10K-file glob PER reset week PER call site — the CPU peg
|
|
783
|
+
the #268 M1 sync-once refactor was meant to remove.
|
|
784
|
+
|
|
734
785
|
Rows are emitted oldest-first (chronological); each row populates
|
|
735
786
|
all 17 ``TuiTrendRow`` fields (7 historical + 10 extended).
|
|
736
787
|
``delta_dpp`` is computed against the prior chronological row's
|
|
@@ -810,7 +861,33 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
|
|
|
810
861
|
)
|
|
811
862
|
usage_captured_at = usage["captured_at_utc"] if usage else None
|
|
812
863
|
if c._week_ref_has_reset_event(conn, week_ref):
|
|
813
|
-
|
|
864
|
+
if (
|
|
865
|
+
use_weekref_cost_cache
|
|
866
|
+
and week_ref.week_start_at
|
|
867
|
+
and week_ref.week_end_at
|
|
868
|
+
):
|
|
869
|
+
# #269 §4: a closed subscription week's cost is immutable, so
|
|
870
|
+
# route the reset-event live-cost through the shared per-weekref
|
|
871
|
+
# cache (the OPEN week is decided per call and never cached).
|
|
872
|
+
# `compute` is the from-scratch closure — invoked synchronously
|
|
873
|
+
# inside this iteration, so `week_ref` capture is safe — and
|
|
874
|
+
# `skip_sync=True` unconditionally (the dashboard already synced
|
|
875
|
+
# once at the top of the rebuild; the flag is only True there).
|
|
876
|
+
_sc = _load_lib("_lib_snapshot_cache")
|
|
877
|
+
cost_usd = _sc.cached_weekref_cost(
|
|
878
|
+
week_start_at=parse_iso(
|
|
879
|
+
week_ref.week_start_at, "week_start_at"
|
|
880
|
+
),
|
|
881
|
+
week_end_at=parse_iso(week_ref.week_end_at, "week_end_at"),
|
|
882
|
+
now_utc=now_utc,
|
|
883
|
+
compute=lambda: c._compute_cost_for_weekref(
|
|
884
|
+
week_ref, skip_sync=True
|
|
885
|
+
),
|
|
886
|
+
)
|
|
887
|
+
else:
|
|
888
|
+
cost_usd = c._compute_cost_for_weekref(
|
|
889
|
+
week_ref, skip_sync=skip_sync
|
|
890
|
+
)
|
|
814
891
|
cost_captured_at = (
|
|
815
892
|
usage_captured_at if cost_usd is not None else None
|
|
816
893
|
)
|
|
@@ -1039,10 +1116,21 @@ class SessionsView:
|
|
|
1039
1116
|
display_tz_label: str = ""
|
|
1040
1117
|
|
|
1041
1118
|
|
|
1042
|
-
def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None,
|
|
1119
|
+
def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None,
|
|
1120
|
+
mode="auto", aggregated_override=None):
|
|
1043
1121
|
"""Build a ``SessionsView`` from joined Claude session entries
|
|
1044
1122
|
(spec §5.5).
|
|
1045
1123
|
|
|
1124
|
+
``aggregated_override`` (#268 Group B): when provided, SKIP the
|
|
1125
|
+
``_aggregate_claude_sessions(entries)`` re-aggregation and use this
|
|
1126
|
+
pre-aggregated ``list[ClaudeSessionUsage]`` verbatim (the caller has
|
|
1127
|
+
already assembled + sorted-by-last_activity-desc the full session set
|
|
1128
|
+
from the module-level ``SessionCache``). ``entries`` is then unused for
|
|
1129
|
+
aggregation. The ``limit`` truncation + the per-row ``TuiSessionRow``
|
|
1130
|
+
derivation below run identically over the override, so a cached rebuild
|
|
1131
|
+
is byte-identical to the from-scratch pass. ``None`` (CLI ``session`` /
|
|
1132
|
+
share / cold paths) is unchanged — the aggregator runs on ``entries``.
|
|
1133
|
+
|
|
1046
1134
|
``entries`` is the ``list[_JoinedClaudeEntry]`` from
|
|
1047
1135
|
``get_claude_session_entries(range_start, range_end)``. The caller
|
|
1048
1136
|
controls the date window + ``skip_sync`` semantics; the builder
|
|
@@ -1069,8 +1157,11 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None, mode="
|
|
|
1069
1157
|
is informational only.
|
|
1070
1158
|
"""
|
|
1071
1159
|
import os as _os # late: keep top-level imports lean.
|
|
1072
|
-
|
|
1073
|
-
|
|
1160
|
+
if aggregated_override is not None:
|
|
1161
|
+
aggregated = list(aggregated_override)
|
|
1162
|
+
else:
|
|
1163
|
+
_agg = _load_lib("_lib_aggregators")
|
|
1164
|
+
aggregated = _agg._aggregate_claude_sessions(entries, mode=mode)
|
|
1074
1165
|
# Apply limit truncation up front so `rows` and `aggregated` stay
|
|
1075
1166
|
# in lockstep (spec §4.3 invariant: `total_sessions == len(rows)
|
|
1076
1167
|
# == len(aggregated)`). limit=None → keep everything.
|
|
@@ -1507,6 +1598,7 @@ def build_forecast_view(
|
|
|
1507
1598
|
targets=(100, 90),
|
|
1508
1599
|
skip_sync: bool = False,
|
|
1509
1600
|
display_tz=None,
|
|
1601
|
+
use_weekref_cost_cache: bool = False,
|
|
1510
1602
|
):
|
|
1511
1603
|
"""Build a ``ForecastView`` (issue #57).
|
|
1512
1604
|
|
|
@@ -1525,7 +1617,10 @@ def build_forecast_view(
|
|
|
1525
1617
|
refresh skip).
|
|
1526
1618
|
"""
|
|
1527
1619
|
c = _cctally()
|
|
1528
|
-
inputs = c._load_forecast_inputs(
|
|
1620
|
+
inputs = c._load_forecast_inputs(
|
|
1621
|
+
conn, now_utc, skip_sync=skip_sync,
|
|
1622
|
+
use_weekref_cost_cache=use_weekref_cost_cache,
|
|
1623
|
+
)
|
|
1529
1624
|
if inputs is None:
|
|
1530
1625
|
return ForecastView(
|
|
1531
1626
|
output=None,
|
package/bin/cctally
CHANGED
|
@@ -487,6 +487,26 @@ build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
|
|
|
487
487
|
CodexSessionView = _lib_view_models.CodexSessionView
|
|
488
488
|
build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
489
489
|
|
|
490
|
+
# Snapshot-rebuild cache infrastructure (#268) — the composite data-version
|
|
491
|
+
# signature, the new-entry watermark, the generation counter, the Group A/B
|
|
492
|
+
# cache holders, and the Group A cached-bucket recompute helper. Registered
|
|
493
|
+
# here (before _cctally_dashboard / _cctally_tui load) so those siblings can
|
|
494
|
+
# `from _lib_snapshot_cache import ...` at module top under the _load_sibling
|
|
495
|
+
# loader, which does not place bin/ on sys.path. _load_sibling dedups via
|
|
496
|
+
# sys.modules, so this is the same instance pytest imports directly.
|
|
497
|
+
_lib_snapshot_cache = _load_sibling("_lib_snapshot_cache")
|
|
498
|
+
SnapshotSignature = _lib_snapshot_cache.SnapshotSignature
|
|
499
|
+
compute_signature = _lib_snapshot_cache.compute_signature
|
|
500
|
+
new_min_timestamp = _lib_snapshot_cache.new_min_timestamp
|
|
501
|
+
bump_generation = _lib_snapshot_cache.bump_generation
|
|
502
|
+
current_generation = _lib_snapshot_cache.current_generation
|
|
503
|
+
BucketCache = _lib_snapshot_cache.BucketCache
|
|
504
|
+
SessionCache = _lib_snapshot_cache.SessionCache
|
|
505
|
+
cached_buckets = _lib_snapshot_cache.cached_buckets
|
|
506
|
+
build_cached_group_a = _lib_snapshot_cache.build_cached_group_a
|
|
507
|
+
group_a_cache = _lib_snapshot_cache.group_a_cache
|
|
508
|
+
reset_group_a_state = _lib_snapshot_cache.reset_group_a_state
|
|
509
|
+
|
|
490
510
|
_lib_render = _load_sibling("_lib_render")
|
|
491
511
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
492
512
|
_render_blocks_table = _lib_render._render_blocks_table
|
|
@@ -665,6 +685,26 @@ _cctally_doctor = _load_sibling("_cctally_doctor")
|
|
|
665
685
|
doctor_gather_state = _cctally_doctor.doctor_gather_state
|
|
666
686
|
cmd_doctor = _cctally_doctor.cmd_doctor
|
|
667
687
|
|
|
688
|
+
# Maintainer-local preview channel (hidden `__preview` subcommand). The pure
|
|
689
|
+
# kernel `_lib_preview` loads FIRST so `_cctally_preview`'s late-bound `_lp()`
|
|
690
|
+
# accessor resolves it via sys.modules; `cmd_preview` is the parser's
|
|
691
|
+
# `set_defaults(func=c.cmd_preview)` target. Both modules are excluded from the
|
|
692
|
+
# public mirror (maintainer-only, like the release tooling) — but the public
|
|
693
|
+
# `bin/cctally` still carries THESE load lines (the mirror emits full public
|
|
694
|
+
# file content on the next non-skip commit that touches this file). Tolerate
|
|
695
|
+
# the modules being absent so a public npm/brew build never bricks at import:
|
|
696
|
+
# on failure `cmd_preview` is None and the parser skips registering the hidden
|
|
697
|
+
# `__preview` subcommand entirely (see `_cctally_parser.py`'s
|
|
698
|
+
# `getattr(c, "cmd_preview", None)` guard). In the private tree the modules ARE
|
|
699
|
+
# present, so this is a no-op — `cmd_preview` is defined and `__preview`
|
|
700
|
+
# registers and works exactly as before.
|
|
701
|
+
try:
|
|
702
|
+
_lib_preview = _load_sibling("_lib_preview")
|
|
703
|
+
_cctally_preview = _load_sibling("_cctally_preview")
|
|
704
|
+
cmd_preview = _cctally_preview.cmd_preview
|
|
705
|
+
except Exception:
|
|
706
|
+
cmd_preview = None
|
|
707
|
+
|
|
668
708
|
# config.json reader/writer/lock + validators + `cctally config` entry point.
|
|
669
709
|
# Eager-loaded with per-symbol re-export so bare-name callers in cctally
|
|
670
710
|
# (dashboard `/api/settings`, `cmd_record_usage` reading `load_config()`,
|
|
@@ -794,6 +834,9 @@ _discover_codex_session_files = _cctally_cache._discover_codex_session_files
|
|
|
794
834
|
IngestStats = _cctally_cache.IngestStats
|
|
795
835
|
_progress_stderr = _cctally_cache._progress_stderr
|
|
796
836
|
_ensure_session_files_row = _cctally_cache._ensure_session_files_row
|
|
837
|
+
_acquire_cache_flock = _cctally_cache._acquire_cache_flock
|
|
838
|
+
PruneResult = _cctally_cache.PruneResult
|
|
839
|
+
_prune_orphaned_cache_entries = _cctally_cache._prune_orphaned_cache_entries
|
|
797
840
|
sync_cache = _cctally_cache.sync_cache
|
|
798
841
|
backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
|
|
799
842
|
backfill_ai_titles = _cctally_cache.backfill_ai_titles
|
|
@@ -811,6 +854,7 @@ get_codex_entries = _cctally_cache.get_codex_entries
|
|
|
811
854
|
_sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
|
|
812
855
|
get_entries = _cctally_cache.get_entries
|
|
813
856
|
open_cache_db = _cctally_cache.open_cache_db
|
|
857
|
+
_reset_orphan_warning_throttle = _cctally_cache._reset_orphan_warning_throttle
|
|
814
858
|
cmd_cache_sync = _cctally_cache.cmd_cache_sync
|
|
815
859
|
|
|
816
860
|
|
|
@@ -1041,6 +1085,8 @@ snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
|
|
|
1041
1085
|
_session_detail_to_envelope = _cctally_dashboard._session_detail_to_envelope
|
|
1042
1086
|
_DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = _cctally_dashboard._DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS
|
|
1043
1087
|
DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
|
|
1088
|
+
_dashboard_self_heal_orphans = _cctally_dashboard._dashboard_self_heal_orphans
|
|
1089
|
+
_register_faulthandler_sigusr1 = _cctally_dashboard._register_faulthandler_sigusr1
|
|
1044
1090
|
cmd_dashboard = _cctally_dashboard.cmd_dashboard
|
|
1045
1091
|
|
|
1046
1092
|
|
|
@@ -2707,6 +2753,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2707
2753
|
base += f" (dev — {_cctally_core.APP_DIR})"
|
|
2708
2754
|
elif _cctally_core._is_dev_checkout():
|
|
2709
2755
|
base += f" (dev checkout, custom data dir — {_cctally_core.APP_DIR})"
|
|
2756
|
+
# Preview channel (CCTALLY_CHANNEL=preview): append the channel marker
|
|
2757
|
+
# so diagnostics reveal a preview-channel binary. Gated → prod output
|
|
2758
|
+
# is byte-identical when the marker is unset.
|
|
2759
|
+
if _cctally_core.is_preview_channel():
|
|
2760
|
+
base += " (preview)"
|
|
2710
2761
|
print(base)
|
|
2711
2762
|
return 0
|
|
2712
2763
|
if not getattr(args, "func", None):
|
|
@@ -2796,6 +2847,12 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
2796
2847
|
return
|
|
2797
2848
|
if command == "repair-symlinks":
|
|
2798
2849
|
return
|
|
2850
|
+
if command == "__preview":
|
|
2851
|
+
# Maintainer-local dev tooling: must not load_config()/spawn a
|
|
2852
|
+
# background update-check or write anything outside the preview dir
|
|
2853
|
+
# (it runs before the wrapper sets CCTALLY_DATA_DIR, so APP_DIR is
|
|
2854
|
+
# still cctally-dev at this point). Same rationale class as doctor.
|
|
2855
|
+
return
|
|
2799
2856
|
# Self-heal: reconcile current_version with the running binary's
|
|
2800
2857
|
# CHANGELOG. Cheap (one CHANGELOG read, write only on the first
|
|
2801
2858
|
# command after a manual upgrade). Runs before load_config so a
|