cctally 1.79.2 → 1.80.1
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 +25 -0
- package/bin/_cctally_cache.py +148 -61
- package/bin/_cctally_config.py +23 -6
- package/bin/_cctally_core.py +342 -88
- package/bin/_cctally_dashboard.py +13 -7
- package/bin/_cctally_db.py +361 -36
- package/bin/_cctally_doctor.py +156 -0
- package/bin/_cctally_five_hour.py +19 -7
- package/bin/_cctally_forecast.py +43 -18
- package/bin/_cctally_journal.py +2148 -0
- package/bin/_cctally_milestones.py +116 -26
- package/bin/_cctally_parser.py +21 -3
- package/bin/_cctally_quota.py +195 -76
- package/bin/_cctally_record.py +1596 -1133
- package/bin/_cctally_statusline.py +13 -1
- package/bin/_cctally_store.py +508 -0
- package/bin/_cctally_sync_week.py +139 -2
- package/bin/_lib_doctor.py +209 -12
- package/bin/_lib_journal.py +188 -0
- package/bin/cctally +11 -1
- package/dashboard/static/assets/index-Cc2ykqF_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CrIlpAlQ.css +0 -1
- /package/dashboard/static/assets/{index-BDkr0bdD.js → index-B3y14l1o.js} +0 -0
package/bin/_cctally_core.py
CHANGED
|
@@ -64,6 +64,8 @@ def _init_paths_from_env() -> None:
|
|
|
64
64
|
global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
|
|
65
65
|
global CONVERSATIONS_LOCK_PATH, CONVERSATIONS_LOCK_CODEX_PATH
|
|
66
66
|
global CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
67
|
+
global STATS_LOCK_MAINTENANCE_PATH
|
|
68
|
+
global JOURNAL_DIR, JOURNAL_LOCK_PATH, JOURNAL_INGEST_LOCK_PATH
|
|
67
69
|
global CONFIG_LOCK_PATH
|
|
68
70
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
69
71
|
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
@@ -119,6 +121,26 @@ def _init_paths_from_env() -> None:
|
|
|
119
121
|
CONVERSATIONS_LOCK_MAINTENANCE_PATH = (
|
|
120
122
|
APP_DIR / "conversations.db.maintenance.lock"
|
|
121
123
|
)
|
|
124
|
+
# Stats index maintenance flock (2026-07-22 DB journal redesign, spec §6.3 /
|
|
125
|
+
# §6.4). Held at the TOP of the lock-order law (maintenance → ingest →
|
|
126
|
+
# provider flocks → txn → journal.lock) by the classifier-gated corruption
|
|
127
|
+
# auto-heal and the `db rebuild --db stats` operator command, so a rebuild
|
|
128
|
+
# never races a concurrent healer/vacuum and only one process quarantines +
|
|
129
|
+
# recreates stats.db at a time. cache.db already has its own maintenance
|
|
130
|
+
# flock above; stats gets the symmetric one now that it, too, auto-heals.
|
|
131
|
+
STATS_LOCK_MAINTENANCE_PATH = APP_DIR / "stats.db.maintenance.lock"
|
|
132
|
+
# Append-only observation journal (2026-07-22 DB journal redesign, spec
|
|
133
|
+
# docs/superpowers/specs/2026-07-22-db-journal-redesign-design.md §4).
|
|
134
|
+
# JOURNAL_DIR holds the monthly observations-YYYY-MM.jsonl segments plus
|
|
135
|
+
# the one-time bootstrap-<ts>.jsonl export; the two lock files sit
|
|
136
|
+
# alongside the DBs in APP_DIR. journal.lock is the µs-scale, blocking,
|
|
137
|
+
# LEAF append lock (spec §4.3 — no other lock is ever taken while it is
|
|
138
|
+
# held); journal.ingest.lock admits at most one stats.db writer (spec
|
|
139
|
+
# §5.1). All three are APP_DIR-derived so dev/data-dir redirection carries
|
|
140
|
+
# them along, exactly like the cache/conversations locks above.
|
|
141
|
+
JOURNAL_DIR = APP_DIR / "journal"
|
|
142
|
+
JOURNAL_LOCK_PATH = APP_DIR / "journal.lock"
|
|
143
|
+
JOURNAL_INGEST_LOCK_PATH = APP_DIR / "journal.ingest.lock"
|
|
122
144
|
CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
|
|
123
145
|
|
|
124
146
|
CONFIG_PATH = APP_DIR / "config.json"
|
|
@@ -289,6 +311,22 @@ _init_paths_from_env()
|
|
|
289
311
|
STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
|
|
290
312
|
|
|
291
313
|
|
|
314
|
+
# === stats.db epoch-rebuild versioning (DB journal redesign §7.1/§8) ==
|
|
315
|
+
#
|
|
316
|
+
# stats.db is no longer a versioned migration target — it is a DISPOSABLE index
|
|
317
|
+
# materialized from the append-only journal. A single ``STATS_INDEX_EPOCH``
|
|
318
|
+
# integer (stamped via ``PRAGMA user_version``, safely above the legacy 0–13
|
|
319
|
+
# range) versions the index; ANY mismatch resolves by journal rebuild, and
|
|
320
|
+
# ``DowngradeDetected``-bricking ceases for stats. ``LEGACY_STATS_HEAD`` is the
|
|
321
|
+
# frozen legacy migration head (``len(_STATS_MIGRATIONS)``): a stats.db whose
|
|
322
|
+
# ``user_version`` is still in the legacy range (<= 13) is a pre-journal install
|
|
323
|
+
# that ``open_db`` cuts over to the epoch on first open (spec §8). Schema change
|
|
324
|
+
# = bump ``STATS_INDEX_EPOCH`` (never a new stats migration — the registry is
|
|
325
|
+
# frozen).
|
|
326
|
+
STATS_INDEX_EPOCH = 1000
|
|
327
|
+
LEGACY_STATS_HEAD = 13
|
|
328
|
+
|
|
329
|
+
|
|
292
330
|
# === Telemetry constants (non-path; see spec 2026-07-07) =============
|
|
293
331
|
#
|
|
294
332
|
# These are static (not APP_DIR-derived) so they live outside
|
|
@@ -493,6 +531,34 @@ def _command_as_of() -> dt.datetime:
|
|
|
493
531
|
return dt.datetime.now(dt.timezone.utc)
|
|
494
532
|
|
|
495
533
|
|
|
534
|
+
def _as_of_or_command(as_of: "str | None") -> dt.datetime:
|
|
535
|
+
"""Capture-time reference datetime for the derivation chokepoints (DB
|
|
536
|
+
journal redesign spec §5.2.3). When the ingester injects a record's ``at``
|
|
537
|
+
as ``as_of`` (ISO-8601 with ``Z`` / explicit offset), parse it to UTC so
|
|
538
|
+
derivation is replay-deterministic; otherwise fall back to the legacy
|
|
539
|
+
``_command_as_of()`` wall clock (honoring the ``CCTALLY_AS_OF`` test hook).
|
|
540
|
+
Passing ``None`` keeps today's behavior bit-identical for legacy callers.
|
|
541
|
+
"""
|
|
542
|
+
if as_of:
|
|
543
|
+
s = as_of.strip()
|
|
544
|
+
if s.endswith("Z"):
|
|
545
|
+
s = s[:-1] + "+00:00"
|
|
546
|
+
parsed = dt.datetime.fromisoformat(s)
|
|
547
|
+
# I3 gate pickup: reject a naive capture time. `.astimezone()` on a
|
|
548
|
+
# naive datetime silently assumes HOST-LOCAL, which would make
|
|
549
|
+
# capture-time-pure derivation non-deterministic across hosts (the very
|
|
550
|
+
# thing §5.2.3 injection exists to prevent). A journal `at` is always
|
|
551
|
+
# UTC ISO-Z / explicit-offset by construction, so a naive value here is
|
|
552
|
+
# a caller bug — fail loud instead of guessing the offset.
|
|
553
|
+
if parsed.tzinfo is None:
|
|
554
|
+
raise ValueError(
|
|
555
|
+
f"_as_of_or_command: naive capture time {as_of!r} "
|
|
556
|
+
"(expected ISO-8601 with 'Z' or an explicit offset)"
|
|
557
|
+
)
|
|
558
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
559
|
+
return _command_as_of()
|
|
560
|
+
|
|
561
|
+
|
|
496
562
|
def _now_utc() -> dt.datetime:
|
|
497
563
|
"""UTC now, with CCTALLY_AS_OF env override for fixture-stability.
|
|
498
564
|
|
|
@@ -1094,7 +1160,14 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1094
1160
|
)
|
|
1095
1161
|
|
|
1096
1162
|
|
|
1097
|
-
def open_db() -> sqlite3.Connection:
|
|
1163
|
+
def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
1164
|
+
# ``_target_path`` (internal, keyword-only) builds/opens the stats index at
|
|
1165
|
+
# an ALTERNATE path instead of the module-global ``DB_PATH`` — the seam
|
|
1166
|
+
# ``rebuild_stats_index`` uses to materialize a FRESH schema'd index at a
|
|
1167
|
+
# scratch/target path without rebinding the global (thread-safe: no other
|
|
1168
|
+
# caller's ``open_db()`` is affected). Every existing call passes nothing and
|
|
1169
|
+
# is byte-identical (spec §5.4 rebuild / §6.3 heal). ``None`` -> ``DB_PATH``.
|
|
1170
|
+
db_path = pathlib.Path(_target_path) if _target_path is not None else DB_PATH
|
|
1098
1171
|
c = _cctally()
|
|
1099
1172
|
# Spec §2.6 carve-out: open_db reaches the migration framework
|
|
1100
1173
|
# (lives in _cctally_db + bin/cctally). Direct imports would
|
|
@@ -1111,14 +1184,27 @@ def open_db() -> sqlite3.Connection:
|
|
|
1111
1184
|
_STATS_MIGRATIONS = c._STATS_MIGRATIONS
|
|
1112
1185
|
_log_migration_error = c._log_migration_error
|
|
1113
1186
|
_clear_migration_error_log_entries = c._clear_migration_error_log_entries
|
|
1114
|
-
|
|
1115
|
-
|
|
1187
|
+
# Unified opener policy (spec §6.1). Call-time import so the shared PRAGMA
|
|
1188
|
+
# policy applies without a module-load cycle (_cctally_store imports this
|
|
1189
|
+
# module). Routed through importlib.import_module rather than a bare
|
|
1190
|
+
# `import _cctally_store` statement so core stays a static IMPORT leaf
|
|
1191
|
+
# (tests/test_kernel_extraction_invariants.py::test_core_imports_no_siblings):
|
|
1192
|
+
# the leaf rule guards against module-LOAD cycles, and a call-time reach
|
|
1193
|
+
# inside open_db() creates none — but the guard's regex matches any
|
|
1194
|
+
# `import _cctally_*` statement, so the deliberate call-time reach uses the
|
|
1195
|
+
# importlib form (still a recognized runtime-loader edge for the package
|
|
1196
|
+
# closure test). stats keeps its own schema-apply until Task 9 flips it to
|
|
1197
|
+
# the epoch gate — this task only routes the PRAGMAs through the shared table.
|
|
1198
|
+
import importlib
|
|
1199
|
+
_cctally_store = importlib.import_module("_cctally_store")
|
|
1200
|
+
|
|
1201
|
+
repair_marker = db_path.with_name("stats.db.repairing")
|
|
1116
1202
|
if repair_marker.exists():
|
|
1117
1203
|
raise c.StatsDbMaintenanceError()
|
|
1118
1204
|
ensure_dirs()
|
|
1119
1205
|
if repair_marker.exists():
|
|
1120
1206
|
raise c.StatsDbMaintenanceError()
|
|
1121
|
-
conn = sqlite3.connect(
|
|
1207
|
+
conn = sqlite3.connect(db_path)
|
|
1122
1208
|
if repair_marker.exists():
|
|
1123
1209
|
conn.close()
|
|
1124
1210
|
raise c.StatsDbMaintenanceError()
|
|
@@ -1132,33 +1218,105 @@ def open_db() -> sqlite3.Connection:
|
|
|
1132
1218
|
# (corruption that only surfaces mid-DDL stays a raw error; the probe
|
|
1133
1219
|
# catches the common case).
|
|
1134
1220
|
try:
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
#
|
|
1138
|
-
#
|
|
1139
|
-
#
|
|
1140
|
-
#
|
|
1141
|
-
#
|
|
1142
|
-
#
|
|
1143
|
-
|
|
1144
|
-
# lock up front — BEGIN IMMEDIATE, or a write as the transaction's first
|
|
1145
|
-
# DML. See cctally-dev#87.
|
|
1146
|
-
conn.execute("PRAGMA busy_timeout=15000")
|
|
1147
|
-
# #297: bound the persistent WAL file (symmetry with open_cache_db).
|
|
1148
|
-
conn.execute(f"PRAGMA journal_size_limit={STATS_WAL_SIZE_LIMIT_BYTES}")
|
|
1221
|
+
# §6.1 PRAGMA policy via the shared table (stats: WAL / NORMAL /
|
|
1222
|
+
# busy_timeout 15000 / journal_size_limit 16 MiB / auto_vacuum unset).
|
|
1223
|
+
# #297: busy_timeout 15000 lets a writer wait out a slow-but-normal sync
|
|
1224
|
+
# (>5 s) instead of erroring "database is locked"; it does NOT absorb
|
|
1225
|
+
# SQLITE_BUSY_SNAPSHOT (defended by BEGIN IMMEDIATE / write-first at the
|
|
1226
|
+
# write sites, cctally-dev#87). journal_size_limit bounds the persistent
|
|
1227
|
+
# WAL. The corruption probe (SELECT 1) stays here inside the narrow
|
|
1228
|
+
# StatsDbCorruptError boundary.
|
|
1229
|
+
_cctally_store.apply_policy(conn, "stats")
|
|
1149
1230
|
conn.execute("SELECT 1").fetchone()
|
|
1150
1231
|
except sqlite3.DatabaseError as exc:
|
|
1151
1232
|
try:
|
|
1152
1233
|
conn.close()
|
|
1153
1234
|
except Exception:
|
|
1154
1235
|
pass
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1236
|
+
# Classifier-gated corruption auto-heal (spec §6.3, Task 8). On a
|
|
1237
|
+
# POSITIVELY classified corruption of the REAL stats index (never a
|
|
1238
|
+
# ``_target_path`` rebuild build — that path is disarmed to avoid
|
|
1239
|
+
# recursion), hand off to the store's HEAL_HOOK: it re-checks under the
|
|
1240
|
+
# maintenance lock, writes the forensics bundle FIRST, quarantines the
|
|
1241
|
+
# damaged family into an incident dir, rebuilds a fresh index from the
|
|
1242
|
+
# journal, and returns True. A single retry of the connect+probe then
|
|
1243
|
+
# runs against the fresh index; a second failure surfaces loudly. HEAL
|
|
1244
|
+
# returns False (declined) for a non-corruption ``DatabaseError`` (BUSY,
|
|
1245
|
+
# disk-full, permissions), the dev-checkout-on-prod guard, or when a
|
|
1246
|
+
# concurrent healer already fixed it — all of which fall through to the
|
|
1247
|
+
# original guided StatsDbCorruptError so the manual path still applies.
|
|
1248
|
+
heal = getattr(_cctally_store, "HEAL_HOOK", None)
|
|
1249
|
+
if _target_path is None and heal is not None and heal("stats", exc):
|
|
1250
|
+
conn = sqlite3.connect(db_path)
|
|
1251
|
+
conn.row_factory = sqlite3.Row
|
|
1252
|
+
try:
|
|
1253
|
+
_cctally_store.apply_policy(conn, "stats")
|
|
1254
|
+
conn.execute("SELECT 1").fetchone()
|
|
1255
|
+
except sqlite3.DatabaseError as exc2:
|
|
1256
|
+
try:
|
|
1257
|
+
conn.close()
|
|
1258
|
+
except Exception:
|
|
1259
|
+
pass
|
|
1260
|
+
raise c.StatsDbCorruptError(
|
|
1261
|
+
f"stats.db is still unreadable after an auto-heal rebuild "
|
|
1262
|
+
f"({exc2}). path: {db_path}. The damaged original was "
|
|
1263
|
+
"quarantined under quarantine/ with a forensics bundle in "
|
|
1264
|
+
"logs/. This is not expected — please report it."
|
|
1265
|
+
) from exc2
|
|
1266
|
+
else:
|
|
1267
|
+
raise c.StatsDbCorruptError(
|
|
1268
|
+
f"stats.db appears corrupt or unreadable ({exc}). path: {db_path}. "
|
|
1269
|
+
f"Not auto-recreated — it holds your recorded usage history. "
|
|
1270
|
+
"Recovery: run `cctally db repair --db stats --yes`; it preserves "
|
|
1271
|
+
"the corrupt original before replacing anything. Do not copy, "
|
|
1272
|
+
"restore, or move the live DB by hand."
|
|
1273
|
+
) from exc
|
|
1274
|
+
|
|
1275
|
+
# ── stats.db epoch gate / in-place cutover (DB journal redesign §7.1/§8) ──
|
|
1276
|
+
# The epoch machinery engages ONLY against the FROZEN production stats
|
|
1277
|
+
# registry (len(_STATS_MIGRATIONS) == LEGACY_STATS_HEAD); under
|
|
1278
|
+
# CCTALLY_MIGRATION_TEST_MODE the injected 14th stats migration lifts the
|
|
1279
|
+
# count and disables it, so the migration-framework harness keeps exercising
|
|
1280
|
+
# the legacy dispatcher path with no cutover.
|
|
1281
|
+
_epoch_engaged = _cctally_store.stats_epoch_enabled()
|
|
1282
|
+
if _epoch_engaged:
|
|
1283
|
+
_uv = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
1284
|
+
if _uv == STATS_INDEX_EPOCH:
|
|
1285
|
+
# STEADY STATE — zero schema work (spec §6.2/§7.1). Applies to a
|
|
1286
|
+
# ``_target_path`` open too: a rebuilt/scratch index is stamped at the
|
|
1287
|
+
# epoch, so opening it must be a pure connect + PRAGMA + version read.
|
|
1288
|
+
return conn
|
|
1289
|
+
if _target_path is None:
|
|
1290
|
+
if _uv > LEGACY_STATS_HEAD:
|
|
1291
|
+
# Neither legacy (<=13) nor the current epoch — a future epoch or
|
|
1292
|
+
# a stray value. Resolve by journal REBUILD (spec §7.1), never by
|
|
1293
|
+
# the corruption heal path (disjoint); a mismatch with no journal
|
|
1294
|
+
# is a hard error, never a silent rebuild-to-empty.
|
|
1295
|
+
conn.close()
|
|
1296
|
+
return _cctally_store.resolve_stats_epoch_mismatch()
|
|
1297
|
+
# _uv <= LEGACY_STATS_HEAD → a pre-journal install: cut over below. A
|
|
1298
|
+
# dev/worktree binary must REFUSE to cut over a DB in the real prod
|
|
1299
|
+
# dir (mirrors #146 — the epoch stamp would brick the installed
|
|
1300
|
+
# release via DowngradeDetected). Leave the DB untouched; exit like
|
|
1301
|
+
# ProdMigrationRefused.
|
|
1302
|
+
if _cctally_store.would_block_prod_stats_cutover(db_path):
|
|
1303
|
+
conn.close()
|
|
1304
|
+
raise c.ProdMigrationRefused("stats.db", "cutover→epoch")
|
|
1305
|
+
# else: a ``_target_path`` build below LEGACY_STATS_HEAD (a fresh scratch
|
|
1306
|
+
# index) — fall through to the schema apply; the final block stamps the
|
|
1307
|
+
# epoch directly (no cutover export).
|
|
1308
|
+
|
|
1309
|
+
# §6.2 one-shot backfill gate (Task 8). Read the ``stats_open_fixups`` marker
|
|
1310
|
+
# ONCE, right after the corruption probe. When True (the marker is stamped at
|
|
1311
|
+
# the binary's current fixup version) the three self-extinguishing open-time
|
|
1312
|
+
# backfills below — five_hour_window_key backfill, quota-projection schema,
|
|
1313
|
+
# historical five_hour_blocks rollup — plus their probe SELECTs are skipped
|
|
1314
|
+
# entirely, so the steady-state open does ZERO backfill work. Fresh / pre-gate
|
|
1315
|
+
# DBs read False, run all three once, and re-stamp the marker at the end. The
|
|
1316
|
+
# surrounding schema apply (CREATE TABLE IF NOT EXISTS / add_column_if_missing
|
|
1317
|
+
# / dispatcher) still runs every open — Task 9 folds THAT under the
|
|
1318
|
+
# STATS_INDEX_EPOCH gate; this task only removes the recurring backfill cost.
|
|
1319
|
+
_fixups_current = _cctally_store.stats_open_fixups_current(conn)
|
|
1162
1320
|
conn.execute(
|
|
1163
1321
|
"""
|
|
1164
1322
|
CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
|
|
@@ -1224,13 +1382,19 @@ def open_db() -> sqlite3.Connection:
|
|
|
1224
1382
|
needs_5h_key_backfill = add_column_if_missing(
|
|
1225
1383
|
conn, "weekly_usage_snapshots", "five_hour_window_key", "INTEGER"
|
|
1226
1384
|
)
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1385
|
+
# §6.2 backfill gate (Task 8): the resumable-partial probe + the backfill
|
|
1386
|
+
# loop are open-time backfill work — skipped once the fixups marker is
|
|
1387
|
+
# stamped. (The `add_column_if_missing` above is schema apply, Task 9's.)
|
|
1388
|
+
if not _fixups_current:
|
|
1389
|
+
if not needs_5h_key_backfill and conn.execute(
|
|
1390
|
+
"SELECT 1 FROM weekly_usage_snapshots "
|
|
1391
|
+
"WHERE five_hour_resets_at IS NOT NULL "
|
|
1392
|
+
" AND five_hour_window_key IS NULL "
|
|
1393
|
+
"LIMIT 1"
|
|
1394
|
+
).fetchone() is not None:
|
|
1395
|
+
needs_5h_key_backfill = True
|
|
1396
|
+
else:
|
|
1397
|
+
needs_5h_key_backfill = False
|
|
1234
1398
|
|
|
1235
1399
|
if needs_5h_key_backfill:
|
|
1236
1400
|
backfill_rows = conn.execute(
|
|
@@ -1667,7 +1831,13 @@ def open_db() -> sqlite3.Connection:
|
|
|
1667
1831
|
# schema in the fresh-install path before the dispatcher, exactly like the
|
|
1668
1832
|
# existing live CREATE tables; its handler calls this same idempotent helper
|
|
1669
1833
|
# for an older stats.db and the dispatcher central-stamps on clean return.
|
|
1670
|
-
|
|
1834
|
+
# §6.2 backfill gate (Task 8): the quota-projection schema apply is one of
|
|
1835
|
+
# the three open-time backfills — skipped once the fixups marker is stamped
|
|
1836
|
+
# (the marker is set only AFTER this ran, so marker-present ⇒ tables present).
|
|
1837
|
+
# Fresh installs read False and apply it here (the dispatcher fast-stamps 013
|
|
1838
|
+
# without running its handler, so this open-time call is the sole creator).
|
|
1839
|
+
if not _fixups_current:
|
|
1840
|
+
_apply_quota_projection_schema(conn)
|
|
1671
1841
|
|
|
1672
1842
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1673
1843
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
@@ -1692,63 +1862,147 @@ def open_db() -> sqlite3.Connection:
|
|
|
1692
1862
|
# Runs AFTER the dispatcher so `schema_migrations` exists for the
|
|
1693
1863
|
# marker INSERTs inside the backfill body, and so any fresh-install
|
|
1694
1864
|
# stamp-only path the dispatcher took above is already committed.
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1865
|
+
# §6.2 backfill gate (Task 8): the two probe SELECTs + the backfill + its
|
|
1866
|
+
# migration-003 re-invocation are open-time backfill work — skipped once the
|
|
1867
|
+
# fixups marker is stamped. Dead in the journal world (the ingest cycle writes
|
|
1868
|
+
# blocks with their snapshots), so it fires only on a pre-journal upgrade DB's
|
|
1869
|
+
# first open; after that the marker gates it out permanently.
|
|
1870
|
+
if not _fixups_current:
|
|
1871
|
+
existing = conn.execute(
|
|
1872
|
+
"SELECT 1 FROM five_hour_blocks LIMIT 1"
|
|
1873
|
+
).fetchone()
|
|
1874
|
+
has_snapshots = conn.execute(
|
|
1875
|
+
"SELECT 1 FROM weekly_usage_snapshots "
|
|
1876
|
+
"WHERE five_hour_window_key IS NOT NULL "
|
|
1877
|
+
" AND five_hour_percent IS NOT NULL "
|
|
1878
|
+
"LIMIT 1"
|
|
1879
|
+
).fetchone()
|
|
1880
|
+
if not existing and has_snapshots:
|
|
1881
|
+
inserted = _backfill_five_hour_blocks(conn)
|
|
1882
|
+
# Re-run the 5h dedup migration AFTER backfill creates parents.
|
|
1883
|
+
# The dispatcher above ran while five_hour_blocks was empty, so
|
|
1884
|
+
# the dedup handler no-op'd and stamped its marker. Snapshot
|
|
1885
|
+
# keys can carry jitter beyond the 600s canonical floor (the
|
|
1886
|
+
# 003_* migration handles up to 1800s grouping), so the
|
|
1887
|
+
# backfill's `DISTINCT five_hour_window_key` over those keys
|
|
1888
|
+
# can produce duplicate parent rows for one physical 5h
|
|
1889
|
+
# window. Without this re-invocation those duplicates persist
|
|
1890
|
+
# forever — the marker says it ran. Handler owns its own
|
|
1891
|
+
# BEGIN/COMMIT and is idempotent (no groups → no-op).
|
|
1892
|
+
#
|
|
1893
|
+
# Honor `db skip` here as well: if the operator marked 003 as
|
|
1894
|
+
# skipped (e.g., poison pill on their machine), we must NOT
|
|
1895
|
+
# back-door run the handler. Duplicates introduced by the
|
|
1896
|
+
# backfill will persist until they `db unskip` — which is the
|
|
1897
|
+
# explicit choice the skip records. Failure path mirrors the
|
|
1898
|
+
# dispatcher's contract: route through _log_migration_error so
|
|
1899
|
+
# the next interactive command renders the banner, and clear
|
|
1900
|
+
# the log entry on success so the banner auto-dismisses.
|
|
1901
|
+
if inserted > 0:
|
|
1902
|
+
target_name = "003_merge_5h_block_duplicates_v1"
|
|
1903
|
+
try:
|
|
1904
|
+
skipped = {
|
|
1905
|
+
row[0] for row in conn.execute(
|
|
1906
|
+
"SELECT name FROM schema_migrations_skipped"
|
|
1907
|
+
).fetchall()
|
|
1908
|
+
}
|
|
1909
|
+
except sqlite3.OperationalError:
|
|
1910
|
+
skipped = set()
|
|
1911
|
+
if target_name not in skipped:
|
|
1912
|
+
for _m in _STATS_MIGRATIONS:
|
|
1913
|
+
if _m.name == target_name:
|
|
1914
|
+
qualified = f"stats.db:{target_name}"
|
|
1915
|
+
try:
|
|
1916
|
+
_m.handler(conn)
|
|
1917
|
+
_clear_migration_error_log_entries(qualified)
|
|
1918
|
+
except Exception as exc:
|
|
1919
|
+
_log_migration_error(
|
|
1920
|
+
name=qualified,
|
|
1921
|
+
exc=exc,
|
|
1922
|
+
tb=traceback.format_exc(),
|
|
1923
|
+
)
|
|
1924
|
+
eprint(f"[migration {qualified}] failed: {exc}")
|
|
1925
|
+
break
|
|
1926
|
+
|
|
1927
|
+
# ── Append-only journal replay-identity columns + ingest cursor ──
|
|
1928
|
+
# (2026-07-22 DB journal redesign, spec §4.2 / §5.2). Every row a
|
|
1929
|
+
# journal fold materializes carries the originating line's stable `id`
|
|
1930
|
+
# in `journal_id`, with a partial UNIQUE index so the ingester's
|
|
1931
|
+
# INSERT OR IGNORE fold is idempotent under replay/re-ingest. Runs AFTER
|
|
1932
|
+
# the migration dispatcher so the columns land on the final (migrated)
|
|
1933
|
+
# table shape — migrations 005/006 recreate percent/5h milestone tables
|
|
1934
|
+
# and must not drop the column. All additive (add_column_if_missing /
|
|
1935
|
+
# CREATE ... IF NOT EXISTS), framework-untracked — same posture as
|
|
1936
|
+
# weekly_credit_floors / project_budget_milestones. Task 9 folds this
|
|
1937
|
+
# under the STATS_INDEX_EPOCH version gate; until then it is idempotent
|
|
1938
|
+
# per open (add_column_if_missing / IF NOT EXISTS no-op once present).
|
|
1939
|
+
for _jtable in (
|
|
1940
|
+
"weekly_usage_snapshots", "weekly_cost_snapshots", "week_reset_events",
|
|
1941
|
+
"five_hour_reset_events", "five_hour_blocks", "weekly_credit_floors",
|
|
1942
|
+
"percent_milestones", "five_hour_milestones", "budget_milestones",
|
|
1943
|
+
"projected_milestones", "project_budget_milestones",
|
|
1944
|
+
):
|
|
1945
|
+
add_column_if_missing(conn, _jtable, "journal_id", "TEXT")
|
|
1946
|
+
conn.execute(
|
|
1947
|
+
f"CREATE UNIQUE INDEX IF NOT EXISTS idx_{_jtable}_journal_id "
|
|
1948
|
+
f"ON {_jtable}(journal_id) WHERE journal_id IS NOT NULL"
|
|
1949
|
+
)
|
|
1950
|
+
# Companion partial index (spec §5.3 harvest / Task 6 gate P2): the ingest
|
|
1951
|
+
# cycle's harvest scans every natural-keyed family for rows the pipeline
|
|
1952
|
+
# inserted this cycle (`WHERE journal_id IS NULL`). A partial index over
|
|
1953
|
+
# exactly those un-stamped rows keeps that scan O(this-cycle inserts), not
|
|
1954
|
+
# O(table) — at the 10x envelope the stamped rows are ~all of the table, so
|
|
1955
|
+
# a full scan would be pathological. Only the 8 HARVEST families need it
|
|
1956
|
+
# (the Model-A / op-fold tables — weekly_usage_snapshots, weekly_cost_
|
|
1957
|
+
# snapshots, weekly_credit_floors — are never harvest-scanned).
|
|
1958
|
+
for _htable in (
|
|
1959
|
+
"week_reset_events", "five_hour_reset_events", "five_hour_blocks",
|
|
1960
|
+
"percent_milestones", "five_hour_milestones", "budget_milestones",
|
|
1961
|
+
"projected_milestones", "project_budget_milestones",
|
|
1962
|
+
):
|
|
1963
|
+
conn.execute(
|
|
1964
|
+
f"CREATE INDEX IF NOT EXISTS idx_{_htable}_journal_id_null "
|
|
1965
|
+
f"ON {_htable}(id) WHERE journal_id IS NULL"
|
|
1966
|
+
)
|
|
1967
|
+
# Single-row segment+offset consumption watermark (spec §5.2). The cursor
|
|
1968
|
+
# never advances past a byte range the ingest cycle did not read.
|
|
1969
|
+
conn.execute(
|
|
1970
|
+
"CREATE TABLE IF NOT EXISTS journal_cursor ("
|
|
1971
|
+
"id INTEGER PRIMARY KEY CHECK (id = 1), "
|
|
1972
|
+
"segment TEXT NOT NULL, "
|
|
1973
|
+
"offset INTEGER NOT NULL)"
|
|
1974
|
+
)
|
|
1975
|
+
|
|
1976
|
+
# §6.2 backfill gate (Task 8): stamp the one-shot marker AFTER the three
|
|
1977
|
+
# open-time backfills ran, so the next open skips them (and their probes)
|
|
1978
|
+
# entirely. The marker table DDL runs ONLY on this "fixups ran" path, never
|
|
1979
|
+
# on the steady-state open. A crash before this commit leaves the marker
|
|
1980
|
+
# unset and everything re-runs idempotently next open (invariant).
|
|
1981
|
+
if not _fixups_current:
|
|
1982
|
+
_cctally_store.mark_stats_open_fixups_done(conn)
|
|
1750
1983
|
|
|
1751
1984
|
conn.commit()
|
|
1985
|
+
|
|
1986
|
+
# ── Epoch stamp / in-place cutover (DB journal redesign §7.1/§8) ──
|
|
1987
|
+
# The full schema is now applied (head 13 + journal_id columns + cursor). A
|
|
1988
|
+
# ``_target_path`` build (rebuild scratch) stamps the epoch DIRECTLY (no
|
|
1989
|
+
# export — the rebuild folds the journal itself). A real legacy install runs
|
|
1990
|
+
# the cutover: export history to a bootstrap segment, stamp ``journal_id`` on
|
|
1991
|
+
# every row, advance the cursor, and stamp the epoch — all atomic (spec §8).
|
|
1992
|
+
# Under test mode (epoch disabled) neither runs, so the DB stays at
|
|
1993
|
+
# len(registry) for the migration-framework harness.
|
|
1994
|
+
if _epoch_engaged:
|
|
1995
|
+
if _target_path is not None:
|
|
1996
|
+
conn.execute(f"PRAGMA user_version = {STATS_INDEX_EPOCH}")
|
|
1997
|
+
conn.commit()
|
|
1998
|
+
elif conn.execute("PRAGMA user_version").fetchone()[0] == LEGACY_STATS_HEAD:
|
|
1999
|
+
# Cut over ONLY once the legacy dispatcher reached the export baseline
|
|
2000
|
+
# (head 13). A DEFERRED migration (MigrationGateNotMet — e.g. the
|
|
2001
|
+
# 008/009/010 recompute gate) leaves user_version < 13; skip the
|
|
2002
|
+
# cutover so the next open retries the dispatcher first, then cuts over
|
|
2003
|
+
# (spec §8 step 1: "run any pending legacy stats migrations, reaching
|
|
2004
|
+
# the export baseline"). Never journal a pre-recompute stats shape.
|
|
2005
|
+
importlib.import_module("_cctally_journal").run_cutover(conn)
|
|
1752
2006
|
return conn
|
|
1753
2007
|
|
|
1754
2008
|
|
|
@@ -5534,12 +5534,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5534
5534
|
# forward-only, exactly like the CLI `config set budget.period` path
|
|
5535
5535
|
# (`_cctally_config.py`) — else the next record-usage tick would
|
|
5536
5536
|
# instant-popup retroactive alerts under the new period window.
|
|
5537
|
+
# 6f writer reroute: the DYNAMIC per-leaf axis union is UNCHANGED,
|
|
5538
|
+
# but the reconciles now run THROUGH the ingest cycle (opportunistic
|
|
5539
|
+
# + exception-wrapped) so their latched crossings are journaled and
|
|
5540
|
+
# rebuild-replayable — the last budget-config write site off the
|
|
5541
|
+
# bespoke-stats.db-connection path. `touched_projects=None` (the
|
|
5542
|
+
# dashboard write is wholesale, never project-scoped).
|
|
5543
|
+
_reconcile_axes: set = set()
|
|
5537
5544
|
if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds", "period"}:
|
|
5538
|
-
|
|
5545
|
+
_reconcile_axes.add("budget")
|
|
5539
5546
|
if touched & {"project_alerts_enabled", "alert_thresholds"}:
|
|
5540
|
-
|
|
5541
|
-
validated_budget
|
|
5542
|
-
)
|
|
5547
|
+
_reconcile_axes.add("project_budget")
|
|
5543
5548
|
# Codex actual-spend reconcile (#134). Key it on the ``alerts_enabled``
|
|
5544
5549
|
# SUB-leaf specifically — NOT ``"codex" in touched``. The helper
|
|
5545
5550
|
# (`_reconcile_codex_budget_on_config_write`) is itself gated on
|
|
@@ -5552,9 +5557,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5552
5557
|
# live-pace, Q4).
|
|
5553
5558
|
codex_in = budget_in.get("codex")
|
|
5554
5559
|
if isinstance(codex_in, dict) and "alerts_enabled" in codex_in:
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5560
|
+
_reconcile_axes.add("codex_budget")
|
|
5561
|
+
if _reconcile_axes:
|
|
5562
|
+
import _cctally_journal as _jr
|
|
5563
|
+
_jr.reconcile_budget_config(validated_budget, axes=_reconcile_axes)
|
|
5558
5564
|
if (
|
|
5559
5565
|
update_check_validated is not None
|
|
5560
5566
|
or update_channel_validated is not None
|