cctally 1.67.1 → 1.69.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 +68 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +754 -109
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +187 -15
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +366 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +131 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +321 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +238 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +325 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_core.py
CHANGED
|
@@ -65,6 +65,8 @@ def _init_paths_from_env() -> None:
|
|
|
65
65
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
66
66
|
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
67
67
|
global HOOK_TICK_THROTTLE_PATH, HOOK_TICK_THROTTLE_LOCK_PATH
|
|
68
|
+
global STATUSLINE_OBSERVE_MARKER_PATH, STATUSLINE_PERSIST_LOCK_PATH
|
|
69
|
+
global OAUTH_BACKOFF_MARKER_PATH, OAUTH_BACKOFF_COUNT_PATH
|
|
68
70
|
global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
|
|
69
71
|
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
70
72
|
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
@@ -119,6 +121,31 @@ def _init_paths_from_env() -> None:
|
|
|
119
121
|
HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
|
|
120
122
|
HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
|
|
121
123
|
|
|
124
|
+
# Statusline usage-persistence markers + lock (spec 2026-07-17
|
|
125
|
+
# usage-statusline-fallback). The statusline is the PRIMARY automatic
|
|
126
|
+
# writer of weekly/5h usage snapshots; these gate it.
|
|
127
|
+
# - STATUSLINE_OBSERVE_MARKER_PATH: liveness marker (mtime-based,
|
|
128
|
+
# like HOOK_TICK_THROTTLE_PATH) — touched after every statusline
|
|
129
|
+
# persist that fed valid 7d input through cmd_record_usage, INCLUDING
|
|
130
|
+
# a dedup no-op. Represents "the statusline is alive and feeding".
|
|
131
|
+
# The statusline persist throttle AND the OAuth backfill gate both
|
|
132
|
+
# key off this, NOT snapshot age (cmd_record_usage dedups unchanged
|
|
133
|
+
# percentages without refreshing captured_at, so snapshot age keeps
|
|
134
|
+
# growing while the statusline is actively feeding).
|
|
135
|
+
# - STATUSLINE_PERSIST_LOCK_PATH: cross-process flock serializing the
|
|
136
|
+
# detached persist feeder so a multi-session render herd yields at
|
|
137
|
+
# most one snapshot per throttle window.
|
|
138
|
+
# - OAUTH_BACKOFF_MARKER_PATH: shared 429 cooldown. Stores an ABSOLUTE
|
|
139
|
+
# epoch deadline in the file CONTENT (never the mtime — future-dating
|
|
140
|
+
# an mtime would corrupt HOOK_TICK_THROTTLE_PATH's age reading).
|
|
141
|
+
STATUSLINE_OBSERVE_MARKER_PATH = APP_DIR / "statusline-observe.last"
|
|
142
|
+
STATUSLINE_PERSIST_LOCK_PATH = APP_DIR / "statusline-persist.lock"
|
|
143
|
+
OAUTH_BACKOFF_MARKER_PATH = APP_DIR / "oauth-backoff.until"
|
|
144
|
+
# Consecutive-429 counter (text int) driving the headerless exponential
|
|
145
|
+
# backoff (base * 2**count). Separate from the deadline marker so the
|
|
146
|
+
# deadline file stays a single parseable float.
|
|
147
|
+
OAUTH_BACKOFF_COUNT_PATH = APP_DIR / "oauth-backoff.count"
|
|
148
|
+
|
|
122
149
|
UPDATE_STATE_PATH = APP_DIR / "update-state.json"
|
|
123
150
|
UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
|
|
124
151
|
UPDATE_LOCK_PATH = APP_DIR / "update.lock"
|
|
@@ -210,9 +237,35 @@ def _real_prod_data_dir() -> pathlib.Path:
|
|
|
210
237
|
return home / ".local" / "share" / "cctally"
|
|
211
238
|
|
|
212
239
|
|
|
240
|
+
# === Statusline-persist / OAuth-backfill tunables ==========================
|
|
241
|
+
# Internal (no config UI — YAGNI, spec §Out of scope); test injection only.
|
|
242
|
+
# Spec 2026-07-17-usage-statusline-fallback-design.
|
|
243
|
+
#
|
|
244
|
+
# STATUSLINE_PERSIST_THROTTLE_SECONDS: min seconds between statusline
|
|
245
|
+
# persist attempts (keyed off STATUSLINE_OBSERVE_MARKER_PATH liveness).
|
|
246
|
+
# OAUTH_BACKFILL_STALE_SECONDS: the OAuth poll only backfills once the
|
|
247
|
+
# observation marker is at least this stale (i.e. the statusline has
|
|
248
|
+
# NOT fed recently). Strictly greater than the persist throttle so the
|
|
249
|
+
# statusline is the primary writer and OAuth only covers its absence.
|
|
250
|
+
# OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
|
|
251
|
+
# exponential 429 backoff (base * 2**consecutive_429, capped).
|
|
252
|
+
STATUSLINE_PERSIST_THROTTLE_SECONDS = 60.0
|
|
253
|
+
OAUTH_BACKFILL_STALE_SECONDS = 300.0
|
|
254
|
+
OAUTH_BACKOFF_BASE_SECONDS = 60.0
|
|
255
|
+
OAUTH_BACKOFF_CAP_SECONDS = 3600.0
|
|
256
|
+
|
|
257
|
+
|
|
213
258
|
_init_paths_from_env()
|
|
214
259
|
|
|
215
260
|
|
|
261
|
+
# stats.db WAL cap (#297). Bounds the persistent WAL file so a resetting
|
|
262
|
+
# checkpoint truncates it back down instead of leaving it at its high-water
|
|
263
|
+
# size. stats.db writes are small (its -wal was observed at 0 bytes even under
|
|
264
|
+
# the contention that bloated cache.db to multi-GB), so a tighter 16 MB cap is
|
|
265
|
+
# ample. See docs/superpowers/specs/2026-07-13-cache-db-wal-hardening-design.md.
|
|
266
|
+
STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
|
|
267
|
+
|
|
268
|
+
|
|
216
269
|
# === Telemetry constants (non-path; see spec 2026-07-07) =============
|
|
217
270
|
#
|
|
218
271
|
# These are static (not APP_DIR-derived) so they live outside
|
|
@@ -673,6 +726,10 @@ def _validate_positive_budget_amount(v: object, label: str) -> float:
|
|
|
673
726
|
# are reused by the parser (`--period` choices) and the config layer.
|
|
674
727
|
BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
|
|
675
728
|
CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
|
|
729
|
+
CODEX_BUDGET_LEAVES = (
|
|
730
|
+
"amount_usd", "period", "alerts_enabled", "alert_thresholds",
|
|
731
|
+
"projected_enabled",
|
|
732
|
+
)
|
|
676
733
|
_BUDGET_DEFAULTS = {
|
|
677
734
|
"weekly_usd": None, # None = no budget (default)
|
|
678
735
|
"alerts_enabled": True, # "on when set"
|
|
@@ -835,12 +892,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
835
892
|
f"budget.codex must be an object or null, got {type(v).__name__}"
|
|
836
893
|
)
|
|
837
894
|
# warn-and-ignore unknown sub-keys (forward compat, like the parent block)
|
|
838
|
-
_codex_valid = {
|
|
839
|
-
"amount_usd", "period", "alerts_enabled", "alert_thresholds",
|
|
840
|
-
"projected_enabled",
|
|
841
|
-
}
|
|
842
895
|
for k in v.keys():
|
|
843
|
-
if k not in
|
|
896
|
+
if k not in CODEX_BUDGET_LEAVES:
|
|
844
897
|
print(
|
|
845
898
|
f"warning: ignoring unknown budget.codex config key: {k}",
|
|
846
899
|
file=sys.stderr,
|
|
@@ -906,6 +959,118 @@ def _budget_alerts_active(budget_cfg: dict) -> bool:
|
|
|
906
959
|
# === DB primitive ===================================================
|
|
907
960
|
|
|
908
961
|
|
|
962
|
+
def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
963
|
+
"""Create the current durable provider-neutral quota projection schema.
|
|
964
|
+
|
|
965
|
+
The physical observations remain in cache.db. These tables are the
|
|
966
|
+
idempotent interpreted index consumed by the Codex quota adapter; migration
|
|
967
|
+
013 calls this same helper for old databases while ``open_db`` calls it for
|
|
968
|
+
fresh installs before the migration dispatcher stamps the migration.
|
|
969
|
+
"""
|
|
970
|
+
conn.executescript(
|
|
971
|
+
"""
|
|
972
|
+
CREATE TABLE IF NOT EXISTS quota_window_blocks (
|
|
973
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
974
|
+
source TEXT NOT NULL,
|
|
975
|
+
source_root_key TEXT NOT NULL,
|
|
976
|
+
logical_limit_key TEXT NOT NULL,
|
|
977
|
+
observed_slot TEXT NOT NULL,
|
|
978
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
979
|
+
limit_id TEXT,
|
|
980
|
+
limit_name TEXT,
|
|
981
|
+
resets_at_utc TEXT NOT NULL,
|
|
982
|
+
nominal_start_at_utc TEXT NOT NULL,
|
|
983
|
+
first_observed_at_utc TEXT NOT NULL,
|
|
984
|
+
last_observed_at_utc TEXT NOT NULL,
|
|
985
|
+
first_percent REAL NOT NULL,
|
|
986
|
+
current_percent REAL NOT NULL,
|
|
987
|
+
last_source_path TEXT NOT NULL,
|
|
988
|
+
last_line_offset INTEGER NOT NULL,
|
|
989
|
+
generation TEXT NOT NULL,
|
|
990
|
+
orphaned_at TEXT,
|
|
991
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
992
|
+
window_minutes, resets_at_utc)
|
|
993
|
+
);
|
|
994
|
+
CREATE INDEX IF NOT EXISTS idx_quota_blocks_active
|
|
995
|
+
ON quota_window_blocks(source, source_root_key, orphaned_at,
|
|
996
|
+
logical_limit_key, observed_slot,
|
|
997
|
+
window_minutes, resets_at_utc);
|
|
998
|
+
|
|
999
|
+
CREATE TABLE IF NOT EXISTS quota_percent_milestones (
|
|
1000
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1001
|
+
source TEXT NOT NULL,
|
|
1002
|
+
source_root_key TEXT NOT NULL,
|
|
1003
|
+
logical_limit_key TEXT NOT NULL,
|
|
1004
|
+
observed_slot TEXT NOT NULL,
|
|
1005
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1006
|
+
resets_at_utc TEXT NOT NULL,
|
|
1007
|
+
percent_threshold INTEGER NOT NULL CHECK(percent_threshold BETWEEN 1 AND 100),
|
|
1008
|
+
captured_at_utc TEXT NOT NULL,
|
|
1009
|
+
source_path TEXT NOT NULL,
|
|
1010
|
+
line_offset INTEGER NOT NULL,
|
|
1011
|
+
high_water_percent INTEGER NOT NULL CHECK(high_water_percent BETWEEN 1 AND 100),
|
|
1012
|
+
generation TEXT NOT NULL,
|
|
1013
|
+
orphaned_at TEXT,
|
|
1014
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
1015
|
+
window_minutes, resets_at_utc, percent_threshold)
|
|
1016
|
+
);
|
|
1017
|
+
CREATE INDEX IF NOT EXISTS idx_quota_milestones_active
|
|
1018
|
+
ON quota_percent_milestones(source, source_root_key, orphaned_at,
|
|
1019
|
+
logical_limit_key, observed_slot,
|
|
1020
|
+
window_minutes, resets_at_utc,
|
|
1021
|
+
percent_threshold);
|
|
1022
|
+
|
|
1023
|
+
CREATE TABLE IF NOT EXISTS quota_threshold_events (
|
|
1024
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1025
|
+
source TEXT NOT NULL,
|
|
1026
|
+
source_root_key TEXT NOT NULL,
|
|
1027
|
+
logical_limit_key TEXT NOT NULL,
|
|
1028
|
+
observed_slot TEXT NOT NULL,
|
|
1029
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1030
|
+
resets_at_utc TEXT NOT NULL,
|
|
1031
|
+
threshold INTEGER NOT NULL CHECK(threshold BETWEEN 1 AND 100),
|
|
1032
|
+
qualifying_kind TEXT NOT NULL CHECK(qualifying_kind IN ('actual','projected')),
|
|
1033
|
+
qualifying_percent REAL,
|
|
1034
|
+
projected_percent REAL,
|
|
1035
|
+
severity TEXT NOT NULL,
|
|
1036
|
+
created_at_utc TEXT NOT NULL,
|
|
1037
|
+
disposition TEXT NOT NULL CHECK(disposition IN ('alerted','suppressed_backfill')),
|
|
1038
|
+
alerted_at TEXT,
|
|
1039
|
+
suppressed_at TEXT,
|
|
1040
|
+
orphaned_at TEXT,
|
|
1041
|
+
CHECK((disposition = 'alerted' AND alerted_at IS NOT NULL AND suppressed_at IS NULL)
|
|
1042
|
+
OR (disposition = 'suppressed_backfill' AND suppressed_at IS NOT NULL AND alerted_at IS NULL)),
|
|
1043
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
1044
|
+
window_minutes, resets_at_utc, threshold)
|
|
1045
|
+
);
|
|
1046
|
+
CREATE INDEX IF NOT EXISTS idx_quota_threshold_events_active
|
|
1047
|
+
ON quota_threshold_events(source, source_root_key, orphaned_at,
|
|
1048
|
+
logical_limit_key, observed_slot,
|
|
1049
|
+
window_minutes, resets_at_utc, threshold);
|
|
1050
|
+
|
|
1051
|
+
CREATE TABLE IF NOT EXISTS quota_projection_state (
|
|
1052
|
+
source_root_key TEXT PRIMARY KEY,
|
|
1053
|
+
generation TEXT NOT NULL,
|
|
1054
|
+
physical_signature TEXT NOT NULL,
|
|
1055
|
+
completed_at_utc TEXT NOT NULL
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
CREATE TABLE IF NOT EXISTS quota_alert_arming (
|
|
1059
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1060
|
+
source TEXT NOT NULL,
|
|
1061
|
+
source_root_key TEXT NOT NULL,
|
|
1062
|
+
logical_limit_key TEXT NOT NULL,
|
|
1063
|
+
observed_slot TEXT NOT NULL,
|
|
1064
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1065
|
+
rule_fingerprint TEXT NOT NULL,
|
|
1066
|
+
activated_at_utc TEXT NOT NULL,
|
|
1067
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
1068
|
+
window_minutes)
|
|
1069
|
+
);
|
|
1070
|
+
"""
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
909
1074
|
def open_db() -> sqlite3.Connection:
|
|
910
1075
|
c = _cctally()
|
|
911
1076
|
# Spec §2.6 carve-out: open_db reaches the migration framework
|
|
@@ -939,16 +1104,17 @@ def open_db() -> sqlite3.Connection:
|
|
|
939
1104
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
940
1105
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
941
1106
|
# Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
|
|
942
|
-
#
|
|
943
|
-
#
|
|
944
|
-
#
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
|
|
951
|
-
|
|
1107
|
+
# #297: raised 5000 -> 15000 so a writer waits out a slow-but-normal
|
|
1108
|
+
# sync (>5 s) instead of instantly erroring with "database is locked".
|
|
1109
|
+
# NOTE: busy_timeout does NOT absorb SQLITE_BUSY_SNAPSHOT (a WAL
|
|
1110
|
+
# read-then-write transaction whose snapshot is invalidated by a
|
|
1111
|
+
# competing commit raises "database is locked" instantly, bypassing the
|
|
1112
|
+
# busy handler). The write paths defend against that by taking the write
|
|
1113
|
+
# lock up front — BEGIN IMMEDIATE, or a write as the transaction's first
|
|
1114
|
+
# DML. See cctally-dev#87.
|
|
1115
|
+
conn.execute("PRAGMA busy_timeout=15000")
|
|
1116
|
+
# #297: bound the persistent WAL file (symmetry with open_cache_db).
|
|
1117
|
+
conn.execute(f"PRAGMA journal_size_limit={STATS_WAL_SIZE_LIMIT_BYTES}")
|
|
952
1118
|
conn.execute("SELECT 1").fetchone()
|
|
953
1119
|
except sqlite3.DatabaseError as exc:
|
|
954
1120
|
try:
|
|
@@ -1465,6 +1631,12 @@ def open_db() -> sqlite3.Connection:
|
|
|
1465
1631
|
"""
|
|
1466
1632
|
)
|
|
1467
1633
|
|
|
1634
|
+
# Stats migration 013 owns durable quota interpretation. Keep the current
|
|
1635
|
+
# schema in the fresh-install path before the dispatcher, exactly like the
|
|
1636
|
+
# existing live CREATE tables; its handler calls this same idempotent helper
|
|
1637
|
+
# for an older stats.db and the dispatcher central-stamps on clean return.
|
|
1638
|
+
_apply_quota_projection_schema(conn)
|
|
1639
|
+
|
|
1468
1640
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1469
1641
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
1470
1642
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|