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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.80.1] - 2026-07-23
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Corrupted `stats.db` indexes now auto-heal correctly on Linux: the locked re-check reads the SQLite header instead of accepting a constant-only query that could succeed without touching the damaged file.
|
|
12
|
+
|
|
13
|
+
## [1.80.0] - 2026-07-23
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Your usage history is now backed by an append-only journal (`~/.local/share/cctally/journal/`) that is the durable source of truth; `stats.db` becomes a disposable, rebuildable index, so a corrupted or deleted stats database now self-heals on the very next command — forensics bundle, quarantine of the damaged file, then an automatic rebuild from the journal — with zero loss of usage history and no manual repair step. (#336)
|
|
17
|
+
- `cctally db rebuild --db stats` rebuilds the stats index from the journal on demand (the manual form of the automatic self-heal), reporting per-table row counts and timing. (#336)
|
|
18
|
+
- `cctally doctor` gains a Journal section reporting the journal's presence and writability, torn/malformed-line counts, how far the stats index has fallen behind the journal, and the most recent self-heal incident. (#336)
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Upgrading to this version migrates existing installs in place automatically on first run, with zero loss of irreplaceable usage history and no manual steps (a slow first run is expected while the one-time journal is written). (#336)
|
|
22
|
+
- Every stats write now flows through a single-flight ingester, ending the `database is locked` pile-ups and wedged handles that concurrent multi-agent hook storms used to cause. (#336)
|
|
23
|
+
- Opening any database is faster: schema work now runs only when the schema version actually changes, not on every open (the status line opens databases several times per render). (#336)
|
|
24
|
+
- Codex quota observations are now preserved durably in the journal, so that history is no longer silently lost when the source rollout files are pruned. (#336)
|
|
25
|
+
- Back up `~/.local/share/cctally/journal/` — hand-copying a `.db` file is now pointless rather than risky, because every database rebuilds from the journal plus surviving provider logs. (#336)
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- The mobile dashboard hero now keeps a clear gap between its 7-day and 5-hour usage values instead of letting a wide 7-day percentage overflow into the adjacent metric.
|
|
29
|
+
|
|
30
|
+
### Removed
|
|
31
|
+
- `cctally db recover --db stats` is retired: a stats-index version mismatch now self-heals by rebuild, so use `cctally db rebuild --db stats` instead (`db recover --db cache` is unchanged). (#336)
|
|
32
|
+
|
|
8
33
|
## [1.79.2] - 2026-07-23
|
|
9
34
|
|
|
10
35
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -331,9 +331,14 @@ def _iter_claude_jsonl_files():
|
|
|
331
331
|
yield jp
|
|
332
332
|
|
|
333
333
|
_cctally_db_sib = _load_lib("_cctally_db")
|
|
334
|
+
# Unified store opener (spec §6.1/§6.2). Owns the per-store PRAGMA policy and
|
|
335
|
+
# the version gate; open_cache_db / open_conversations_db route their
|
|
336
|
+
# connect+policy through it and skip the schema apply when schema_current().
|
|
337
|
+
_cctally_store = _load_lib("_cctally_store")
|
|
334
338
|
add_column_if_missing = _cctally_db_sib.add_column_if_missing
|
|
335
339
|
_run_pending_migrations = _cctally_db_sib._run_pending_migrations
|
|
336
340
|
_CACHE_MIGRATIONS = _cctally_db_sib._CACHE_MIGRATIONS
|
|
341
|
+
_CONVERSATIONS_MIGRATIONS = _cctally_db_sib._CONVERSATIONS_MIGRATIONS
|
|
337
342
|
# Storm-free conversation_messages + FTS full-clear (#138). Owns the trigger
|
|
338
343
|
# drop/recreate dance so the per-row delete trigger never fires O(rows) under
|
|
339
344
|
# the held lock on a rebuild / truncation escalation.
|
|
@@ -1191,6 +1196,61 @@ def _prune_inactive_codex_source_roots(
|
|
|
1191
1196
|
)
|
|
1192
1197
|
|
|
1193
1198
|
|
|
1199
|
+
def _append_codex_quota_obs(quota_rows: list) -> None:
|
|
1200
|
+
"""Journal one Codex quota `obs` per newly-read quota observation (Task 7
|
|
1201
|
+
Item 1, spec §4.2 / §5.3 / Appendix A).
|
|
1202
|
+
|
|
1203
|
+
The direct cache.db write in ``_write_codex_file_batch`` stays byte-identical;
|
|
1204
|
+
this obs is the DURABLE truth for the observation. Codex rollout JSONL
|
|
1205
|
+
evaporates over time (§1 latent data-loss hole), so ``quota_window_snapshots``
|
|
1206
|
+
are silently irreplaceable today — journaling the raw capture closes that
|
|
1207
|
+
hole, and the ingest cycle's ``QUOTA_APPLIER`` (spec §5.2 step 3)
|
|
1208
|
+
re-materializes cache.db from these obs.
|
|
1209
|
+
|
|
1210
|
+
Called UNDER the ``cache.db.codex.lock`` provider flock (the journal append
|
|
1211
|
+
lock is a LEAF — legal to take inside a provider flock, spec §4.3) and BEFORE
|
|
1212
|
+
the cache write / offset advance, so a crash re-reads the same bytes and
|
|
1213
|
+
re-appends (idempotent at the QUOTA_APPLIER's natural-key INSERT OR IGNORE)
|
|
1214
|
+
rather than losing the observation. ``at`` is the detection clock
|
|
1215
|
+
(``_command_as_of()``-aware, honoring the CCTALLY_AS_OF test hook); the raw
|
|
1216
|
+
row (incl. its own ``captured_at_utc``) rides the payload (spec §4.2 raw
|
|
1217
|
+
capture — no derivation baked into the stored line). $CODEX_HOME multi-root
|
|
1218
|
+
scoping is preserved: each row carries its own ``source_root_key`` and roots
|
|
1219
|
+
are never combined."""
|
|
1220
|
+
if not quota_rows:
|
|
1221
|
+
return
|
|
1222
|
+
import _cctally_journal as _jr
|
|
1223
|
+
import _lib_journal as _jl
|
|
1224
|
+
at = (
|
|
1225
|
+
_cctally_core._command_as_of()
|
|
1226
|
+
.isoformat(timespec="seconds")
|
|
1227
|
+
.replace("+00:00", "Z")
|
|
1228
|
+
)
|
|
1229
|
+
for row in quota_rows:
|
|
1230
|
+
(source, source_root_key, source_path, line_offset, captured_at_utc,
|
|
1231
|
+
observed_slot, logical_limit_key, limit_id, limit_name, window_minutes,
|
|
1232
|
+
used_percent, resets_at_utc, plan_type, individual_limit_json,
|
|
1233
|
+
reached_type, observed_model) = row
|
|
1234
|
+
try:
|
|
1235
|
+
_jr.append_record(_jl.make_obs(
|
|
1236
|
+
at=at, src="codex-quota", provider="codex",
|
|
1237
|
+
payload={
|
|
1238
|
+
"kind": "quota_window_snapshot",
|
|
1239
|
+
"source": source, "source_root_key": source_root_key,
|
|
1240
|
+
"source_path": source_path, "line_offset": line_offset,
|
|
1241
|
+
"captured_at_utc": captured_at_utc,
|
|
1242
|
+
"observed_slot": observed_slot,
|
|
1243
|
+
"logical_limit_key": logical_limit_key, "limit_id": limit_id,
|
|
1244
|
+
"limit_name": limit_name, "window_minutes": window_minutes,
|
|
1245
|
+
"used_percent": used_percent, "resets_at_utc": resets_at_utc,
|
|
1246
|
+
"plan_type": plan_type,
|
|
1247
|
+
"individual_limit_json": individual_limit_json,
|
|
1248
|
+
"reached_type": reached_type, "observed_model": observed_model,
|
|
1249
|
+
}))
|
|
1250
|
+
except Exception as exc: # best-effort; a journal append must not break sync
|
|
1251
|
+
eprint(f"[codex-cache] quota obs journal append failed: {exc}")
|
|
1252
|
+
|
|
1253
|
+
|
|
1194
1254
|
def _write_codex_file_batch(
|
|
1195
1255
|
conn: sqlite3.Connection,
|
|
1196
1256
|
*,
|
|
@@ -4333,6 +4393,15 @@ def sync_codex_cache(
|
|
|
4333
4393
|
# owns the authoritative transcript-local value.
|
|
4334
4394
|
new_last_turn_id = prev_turn_id
|
|
4335
4395
|
|
|
4396
|
+
# Task 7 Item 1: journal the Codex quota observations BEFORE the cache
|
|
4397
|
+
# write (and before the offset advances), under the codex flock this
|
|
4398
|
+
# function already holds. Durable-first: a crash after the append but
|
|
4399
|
+
# before the commit re-reads the same bytes next sync and re-appends
|
|
4400
|
+
# (idempotent at the QUOTA_APPLIER natural key) rather than losing the
|
|
4401
|
+
# observation. Appended once here, not inside the retry loop, so a DB
|
|
4402
|
+
# retry never double-journals.
|
|
4403
|
+
_append_codex_quota_obs(quota_rows)
|
|
4404
|
+
|
|
4336
4405
|
# Every derived row above was buffered before the first DML. A
|
|
4337
4406
|
# late database failure therefore rolls the whole file back and
|
|
4338
4407
|
# retries that same in-memory batch exactly once.
|
|
@@ -4738,15 +4807,22 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
4738
4807
|
except OSError as exc:
|
|
4739
4808
|
eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
|
|
4740
4809
|
try:
|
|
4741
|
-
|
|
4810
|
+
# Connect + install row factory / test trace hook via the unified
|
|
4811
|
+
# opener (spec §6.1). PRAGMAs are applied AFTER the corruption probe so
|
|
4812
|
+
# a mode-change never writes a WAL onto a DB we are about to unlink.
|
|
4813
|
+
conn = _cctally_store.open_index("cache")
|
|
4742
4814
|
conn.execute("SELECT 1").fetchone()
|
|
4743
4815
|
except sqlite3.DatabaseError as exc:
|
|
4744
4816
|
eprint(f"[cache] corrupt cache DB ({exc}); recreating")
|
|
4817
|
+
try:
|
|
4818
|
+
conn.close()
|
|
4819
|
+
except Exception:
|
|
4820
|
+
pass
|
|
4745
4821
|
try:
|
|
4746
4822
|
_cctally_core.CACHE_DB_PATH.unlink()
|
|
4747
4823
|
except FileNotFoundError:
|
|
4748
4824
|
pass
|
|
4749
|
-
conn =
|
|
4825
|
+
conn = _cctally_store.open_index("cache")
|
|
4750
4826
|
|
|
4751
4827
|
# Best-effort 0600 on cache.db itself (the 0700 dir above backstops the
|
|
4752
4828
|
# sidecars until the first write hardens them in sync_cache).
|
|
@@ -4755,56 +4831,46 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
4755
4831
|
except OSError as exc:
|
|
4756
4832
|
eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
|
|
4757
4833
|
|
|
4758
|
-
#
|
|
4759
|
-
#
|
|
4760
|
-
#
|
|
4761
|
-
#
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
#
|
|
4765
|
-
#
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
#
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
# is newly added (i.e. this is the first run after upgrade), purge the
|
|
4794
|
-
# Codex cache so the duplicate-counted rows produced by the previous
|
|
4795
|
-
# iterator are reingested cleanly by sync_codex_cache(). The cache is
|
|
4796
|
-
# fully re-derivable from ~/.codex/sessions/*.jsonl so this is safe.
|
|
4797
|
-
if add_column_if_missing(conn, "codex_session_files", "last_total_tokens", "INTEGER"):
|
|
4798
|
-
conn.execute("DELETE FROM codex_session_entries")
|
|
4799
|
-
conn.execute("DELETE FROM codex_session_files")
|
|
4800
|
-
conn.commit()
|
|
4801
|
-
eprint("[cache] migrated codex cache — re-ingesting")
|
|
4834
|
+
# §6.1 PRAGMA policy (auto_vacuum=INCREMENTAL emitted first — #313 P3
|
|
4835
|
+
# reclaim needs it before the first page; journal_mode=WAL / busy_timeout /
|
|
4836
|
+
# journal_size_limit #297; synchronous=NORMAL #279 S1 F8). Byte-equivalent
|
|
4837
|
+
# to the former inline block, now owned by the shared policy table.
|
|
4838
|
+
_cctally_store.apply_policy(conn, "cache")
|
|
4839
|
+
|
|
4840
|
+
# §6.2 version gate: run the full schema executescript + add_column_if_missing
|
|
4841
|
+
# probes + the codex last_total_tokens ALTER/purge ONLY when the stamped
|
|
4842
|
+
# user_version differs from the registry head. On a steady-state open of an
|
|
4843
|
+
# up-to-date cache.db this whole block is skipped — the open becomes
|
|
4844
|
+
# connect → PRAGMAs → one user_version read → the dispatcher fast-path.
|
|
4845
|
+
if not _cctally_store.schema_current(conn, "cache"):
|
|
4846
|
+
# Apply the shared cache.db schema (cctally-dev#93, D4): Claude tables +
|
|
4847
|
+
# indexes, the session_id / project_path column adds on session_files
|
|
4848
|
+
# (A2 `session` metadata, populated lazily in sync_cache() /
|
|
4849
|
+
# _ensure_session_files_row()), the Codex base tables + indexes, and the
|
|
4850
|
+
# cache_meta sentinel table. This is the single cache.db schema source —
|
|
4851
|
+
# the eager-apply path (_eagerly_apply_cache_migrations) uses the SAME
|
|
4852
|
+
# helper, so the two can no longer drift. The Codex last_total_tokens
|
|
4853
|
+
# ALTER + purge stays below (out of the shared helper — D4/P1#3).
|
|
4854
|
+
_cctally_db_sib._apply_cache_schema(conn)
|
|
4855
|
+
|
|
4856
|
+
# Migration: add last_total_tokens to codex_session_files. When the
|
|
4857
|
+
# column is newly added (i.e. this is the first run after upgrade),
|
|
4858
|
+
# purge the Codex cache so the duplicate-counted rows produced by the
|
|
4859
|
+
# previous iterator are reingested cleanly by sync_codex_cache(). The
|
|
4860
|
+
# cache is fully re-derivable from ~/.codex/sessions/*.jsonl so this is
|
|
4861
|
+
# safe. Under the version gate now (spec §6.2): on an up-to-date DB the
|
|
4862
|
+
# column already exists so add_column_if_missing was a no-op / no purge,
|
|
4863
|
+
# making the gate byte-equivalent.
|
|
4864
|
+
if add_column_if_missing(conn, "codex_session_files", "last_total_tokens", "INTEGER"):
|
|
4865
|
+
conn.execute("DELETE FROM codex_session_entries")
|
|
4866
|
+
conn.execute("DELETE FROM codex_session_files")
|
|
4867
|
+
conn.commit()
|
|
4868
|
+
eprint("[cache] migrated codex cache — re-ingesting")
|
|
4802
4869
|
|
|
4803
|
-
# Migration framework dispatcher for cache.db.
|
|
4804
|
-
#
|
|
4805
|
-
#
|
|
4806
|
-
#
|
|
4807
|
-
# §2.5, §3.3 + the @cache_migration decorator further down in this file.
|
|
4870
|
+
# Migration framework dispatcher for cache.db. Runs unconditionally (spec
|
|
4871
|
+
# §6.2: only the schema apply moves under the gate; the dispatcher keeps its
|
|
4872
|
+
# own fast-path that returns early when user_version == registry head). See
|
|
4873
|
+
# the @cache_migration decorator further down in this file.
|
|
4808
4874
|
_run_pending_migrations(
|
|
4809
4875
|
conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
|
|
4810
4876
|
recover_version_ahead=True,
|
|
@@ -4854,10 +4920,13 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
|
|
|
4854
4920
|
path = _cctally_core.CONVERSATIONS_DB_PATH
|
|
4855
4921
|
conn: sqlite3.Connection | None = None
|
|
4856
4922
|
try:
|
|
4857
|
-
#
|
|
4858
|
-
#
|
|
4859
|
-
#
|
|
4860
|
-
|
|
4923
|
+
# Connect via the unified opener (spec §6.1). URI mode belongs to the
|
|
4924
|
+
# connection, not only the later ATTACH value — without it, some
|
|
4925
|
+
# supported system-Python SQLite builds interpret the read-only
|
|
4926
|
+
# ``file:...cache.db?mode=ro`` attachment as a literal path; the
|
|
4927
|
+
# conversations policy carries ``uri=True``. PRAGMAs are applied after
|
|
4928
|
+
# the corruption probe.
|
|
4929
|
+
conn = _cctally_store.open_index("conversations")
|
|
4861
4930
|
conn.execute("SELECT 1").fetchone()
|
|
4862
4931
|
except sqlite3.DatabaseError as exc:
|
|
4863
4932
|
if conn is not None:
|
|
@@ -4878,13 +4947,31 @@ def open_conversations_db(*, attach_cache: bool = True) -> sqlite3.Connection:
|
|
|
4878
4947
|
f"[conversations] could not chmod conversations.db 0600 ({exc}); continuing"
|
|
4879
4948
|
)
|
|
4880
4949
|
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4950
|
+
# §6.1 PRAGMA policy (auto_vacuum=INCREMENTAL first / WAL / busy_timeout /
|
|
4951
|
+
# journal_size_limit / synchronous=NORMAL) via the shared table.
|
|
4952
|
+
_cctally_store.apply_policy(conn, "conversations")
|
|
4953
|
+
# §6.2 version gate. conversations.db is under the migration framework
|
|
4954
|
+
# (spec §7.2): the schema apply runs only when the stamped user_version is
|
|
4955
|
+
# behind the registry head; the dispatcher below then stamps user_version to
|
|
4956
|
+
# the head so the next open gates the schema apply out. On an existing
|
|
4957
|
+
# populated DB _apply_conversations_schema short-circuits on its own marker,
|
|
4958
|
+
# so no transcript row is touched even when the gate re-runs it.
|
|
4959
|
+
if not _cctally_store.schema_current(conn, "conversations"):
|
|
4960
|
+
_cctally_db_sib._apply_conversations_schema(conn)
|
|
4961
|
+
# Commit the schema apply BEFORE the dispatcher: _apply_conversations_schema
|
|
4962
|
+
# can leave an open implicit transaction (its trailing marker INSERT), and
|
|
4963
|
+
# the dispatcher's bootstrap-rename opens its own BEGIN — which would collide
|
|
4964
|
+
# with a still-open transaction. Committing here leaves the connection in
|
|
4965
|
+
# autocommit so the dispatcher starts clean.
|
|
4887
4966
|
conn.commit()
|
|
4967
|
+
# Migration framework dispatcher for conversations.db (spec §7.2). Runs
|
|
4968
|
+
# unconditionally with its own fast-path (user_version == registry head).
|
|
4969
|
+
# recover_version_ahead=True because conversations.db is re-derivable from
|
|
4970
|
+
# provider JSONL, matching the cache.db posture.
|
|
4971
|
+
_run_pending_migrations(
|
|
4972
|
+
conn, registry=_CONVERSATIONS_MIGRATIONS, db_label="conversations.db",
|
|
4973
|
+
recover_version_ahead=True,
|
|
4974
|
+
)
|
|
4888
4975
|
|
|
4889
4976
|
if attach_cache:
|
|
4890
4977
|
# Ensure the compact schema exists before opening it read-only. This
|
package/bin/_cctally_config.py
CHANGED
|
@@ -1050,8 +1050,11 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1050
1050
|
except _BudgetConfigError as exc:
|
|
1051
1051
|
eprint(f"cctally config: {exc}")
|
|
1052
1052
|
return 2
|
|
1053
|
-
# Deliberately outside the config lock
|
|
1054
|
-
|
|
1053
|
+
# Deliberately outside the config lock. 6f writer reroute: routed THROUGH
|
|
1054
|
+
# the ingest cycle (opportunistic + exception-wrapped) so the latched
|
|
1055
|
+
# Codex crossings are journaled and rebuild-replayable.
|
|
1056
|
+
import _cctally_journal as _jr
|
|
1057
|
+
_jr.reconcile_budget_config({"codex": configured}, axes={"codex_budget"})
|
|
1055
1058
|
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
1056
1059
|
value = configured[leaf]
|
|
1057
1060
|
if getattr(args, "emit_json", False):
|
|
@@ -1765,21 +1768,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1765
1768
|
# forward-only-from-set reconcile prevents (`budget set --period` already
|
|
1766
1769
|
# reconciles via the same helper). Both run OUTSIDE config_writer_lock
|
|
1767
1770
|
# (each helper has its own open_db lock).
|
|
1771
|
+
# 6f writer reroute: the conditional per-leaf axis mapping is UNCHANGED,
|
|
1772
|
+
# but the reconciles now run THROUGH the ingest cycle (opportunistic +
|
|
1773
|
+
# exception-wrapped) so their latched crossings are journaled and
|
|
1774
|
+
# rebuild-replayable. A leaf that feeds two axes (`alert_thresholds`
|
|
1775
|
+
# feeds both budget + project_budget) reconciles both in ONE cycle via
|
|
1776
|
+
# the axes union. `touched_projects=None` = reconcile every configured
|
|
1777
|
+
# project (a `config set` write is wholesale, not project-scoped).
|
|
1778
|
+
_reconcile_axes: set = set()
|
|
1768
1779
|
if inner_key in (
|
|
1769
1780
|
"weekly_usd", "alerts_enabled", "alert_thresholds", "period"
|
|
1770
1781
|
):
|
|
1771
|
-
|
|
1782
|
+
_reconcile_axes.add("budget")
|
|
1772
1783
|
if inner_key in (
|
|
1773
1784
|
"projects", "project_alerts_enabled", "alert_thresholds"
|
|
1774
1785
|
):
|
|
1775
|
-
|
|
1786
|
+
_reconcile_axes.add("project_budget")
|
|
1776
1787
|
# Codex budget axis (spec §6): the nested budget.codex block is set
|
|
1777
1788
|
# wholesale via `config set budget.codex '<json>'`, so the only key that
|
|
1778
1789
|
# touches it is `codex` itself. Gated on the codex block carrying
|
|
1779
1790
|
# alerts_enabled + thresholds (the helper re-checks); records nothing
|
|
1780
1791
|
# otherwise.
|
|
1781
1792
|
if inner_key == "codex":
|
|
1782
|
-
|
|
1793
|
+
_reconcile_axes.add("codex_budget")
|
|
1794
|
+
if _reconcile_axes:
|
|
1795
|
+
import _cctally_journal as _jr
|
|
1796
|
+
_jr.reconcile_budget_config(validated, axes=_reconcile_axes)
|
|
1783
1797
|
out_val = validated[inner_key]
|
|
1784
1798
|
if getattr(args, "emit_json", False):
|
|
1785
1799
|
print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
|
|
@@ -1843,8 +1857,11 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1843
1857
|
return 2
|
|
1844
1858
|
# An optional leaf leaves a configured block, even when it already
|
|
1845
1859
|
# held its default; the forward-only state must be reconciled once.
|
|
1860
|
+
# 6f writer reroute: routed THROUGH the ingest cycle (opportunistic +
|
|
1861
|
+
# exception-wrapped) so the latched Codex crossings are journaled.
|
|
1846
1862
|
assert configured is not None
|
|
1847
|
-
|
|
1863
|
+
import _cctally_journal as _jr
|
|
1864
|
+
_jr.reconcile_budget_config({"codex": configured}, axes={"codex_budget"})
|
|
1848
1865
|
return 0
|
|
1849
1866
|
if key == "display.tz":
|
|
1850
1867
|
with config_writer_lock():
|