cctally 1.80.0 → 1.80.2
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 +11 -0
- package/bin/_cctally_cache.py +120 -29
- package/bin/_cctally_db.py +4 -2
- package/bin/_cctally_journal.py +149 -5
- package/bin/_cctally_store.py +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.80.2] - 2026-07-23
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- The dashboard no longer crashes with a native SQLite `SIGBUS` when a corrupt `cache.db` is detected while another dashboard or hook reader is active. Cache recovery now blocks new opens, refuses to replace a file family with live handles, preserves the complete family in quarantine, and retries recreation only after replacement is safe.
|
|
12
|
+
- Re-reading the same retained Codex quota observation now produces a byte-identical journal record instead of a new command-time identity. A compact natural-key index also recognizes records already written by v1.80.1, preventing cache recovery from multiplying historical quota records and making subsequent rebuilds progressively slower.
|
|
13
|
+
|
|
14
|
+
## [1.80.1] - 2026-07-23
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
8
19
|
## [1.80.0] - 2026-07-23
|
|
9
20
|
|
|
10
21
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -1211,26 +1211,25 @@ def _append_codex_quota_obs(quota_rows: list) -> None:
|
|
|
1211
1211
|
lock is a LEAF — legal to take inside a provider flock, spec §4.3) and BEFORE
|
|
1212
1212
|
the cache write / offset advance, so a crash re-reads the same bytes and
|
|
1213
1213
|
re-appends (idempotent at the QUOTA_APPLIER's natural-key INSERT OR IGNORE)
|
|
1214
|
-
rather than losing the observation. ``at`` is the
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
are never combined."""
|
|
1214
|
+
rather than losing the observation. ``at`` is the observation's retained
|
|
1215
|
+
``captured_at_utc`` rather than the later sync clock, so re-reading the same
|
|
1216
|
+
rollout bytes emits the same content id and byte-identical journal record.
|
|
1217
|
+
$CODEX_HOME multi-root scoping is preserved: each row carries its own
|
|
1218
|
+
``source_root_key`` and roots are never combined."""
|
|
1220
1219
|
if not quota_rows:
|
|
1221
1220
|
return
|
|
1222
1221
|
import _cctally_journal as _jr
|
|
1223
1222
|
import _lib_journal as _jl
|
|
1224
|
-
at = (
|
|
1225
|
-
_cctally_core._command_as_of()
|
|
1226
|
-
.isoformat(timespec="seconds")
|
|
1227
|
-
.replace("+00:00", "Z")
|
|
1228
|
-
)
|
|
1229
1223
|
for row in quota_rows:
|
|
1230
1224
|
(source, source_root_key, source_path, line_offset, captured_at_utc,
|
|
1231
1225
|
observed_slot, logical_limit_key, limit_id, limit_name, window_minutes,
|
|
1232
1226
|
used_percent, resets_at_utc, plan_type, individual_limit_json,
|
|
1233
1227
|
reached_type, observed_model) = row
|
|
1228
|
+
at = captured_at_utc or (
|
|
1229
|
+
_cctally_core._command_as_of()
|
|
1230
|
+
.isoformat(timespec="seconds")
|
|
1231
|
+
.replace("+00:00", "Z")
|
|
1232
|
+
)
|
|
1234
1233
|
try:
|
|
1235
1234
|
_jr.append_record(_jl.make_obs(
|
|
1236
1235
|
at=at, src="codex-quota", provider="codex",
|
|
@@ -1246,7 +1245,7 @@ def _append_codex_quota_obs(quota_rows: list) -> None:
|
|
|
1246
1245
|
"plan_type": plan_type,
|
|
1247
1246
|
"individual_limit_json": individual_limit_json,
|
|
1248
1247
|
"reached_type": reached_type, "observed_model": observed_model,
|
|
1249
|
-
}))
|
|
1248
|
+
}), dedupe_codex_quota=True)
|
|
1250
1249
|
except Exception as exc: # best-effort; a journal append must not break sync
|
|
1251
1250
|
eprint(f"[codex-cache] quota obs journal append failed: {exc}")
|
|
1252
1251
|
|
|
@@ -4789,12 +4788,113 @@ def _harden_cache_sidecars() -> None:
|
|
|
4789
4788
|
# === Region 6: open_cache_db (was bin/cctally:9040-9155) ===
|
|
4790
4789
|
|
|
4791
4790
|
|
|
4791
|
+
def _cache_open_guarded() -> sqlite3.Connection:
|
|
4792
|
+
"""Open cache.db while excluding a destructive recovery handshake.
|
|
4793
|
+
|
|
4794
|
+
The shared maintenance flock covers both marker checks and the SQLite
|
|
4795
|
+
connect/probe. A repair owns the exclusive side; after it claims the marker,
|
|
4796
|
+
no new cctally opener can escape into the live family while it verifies that
|
|
4797
|
+
all pre-marker handles have drained.
|
|
4798
|
+
"""
|
|
4799
|
+
path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
|
|
4800
|
+
marker = _cctally_db_sib._repair_marker_path(path)
|
|
4801
|
+
lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
|
|
4802
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4803
|
+
lock_fh = open(lock_path, "a+")
|
|
4804
|
+
conn = None
|
|
4805
|
+
try:
|
|
4806
|
+
fcntl.flock(lock_fh, fcntl.LOCK_SH)
|
|
4807
|
+
if marker.exists():
|
|
4808
|
+
raise sqlite3.DatabaseError(
|
|
4809
|
+
f"cache.db maintenance is in progress ({marker})"
|
|
4810
|
+
)
|
|
4811
|
+
conn = _cctally_store.open_index("cache")
|
|
4812
|
+
# Force a real header/schema read. SELECT 1 is constant-folded and can
|
|
4813
|
+
# report success without touching a malformed database file.
|
|
4814
|
+
conn.execute("PRAGMA schema_version").fetchone()
|
|
4815
|
+
if marker.exists():
|
|
4816
|
+
conn.close()
|
|
4817
|
+
conn = None
|
|
4818
|
+
raise sqlite3.DatabaseError(
|
|
4819
|
+
f"cache.db maintenance is in progress ({marker})"
|
|
4820
|
+
)
|
|
4821
|
+
return conn
|
|
4822
|
+
except Exception:
|
|
4823
|
+
if conn is not None:
|
|
4824
|
+
try:
|
|
4825
|
+
conn.close()
|
|
4826
|
+
except Exception:
|
|
4827
|
+
pass
|
|
4828
|
+
raise
|
|
4829
|
+
finally:
|
|
4830
|
+
try:
|
|
4831
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4832
|
+
finally:
|
|
4833
|
+
lock_fh.close()
|
|
4834
|
+
|
|
4835
|
+
|
|
4836
|
+
def _recover_corrupt_cache(exc: sqlite3.DatabaseError) -> bool:
|
|
4837
|
+
"""Quarantine a corrupt cache family only after every reader has drained.
|
|
4838
|
+
|
|
4839
|
+
Returns True after a safe quarantine, so the caller may create a fresh
|
|
4840
|
+
re-derivable cache. Raises a guided DatabaseError when recovery cannot prove
|
|
4841
|
+
exclusivity; callers then use their established direct-JSONL fallback.
|
|
4842
|
+
"""
|
|
4843
|
+
if not _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
4844
|
+
return False
|
|
4845
|
+
|
|
4846
|
+
path = pathlib.Path(_cctally_core.CACHE_DB_PATH)
|
|
4847
|
+
try:
|
|
4848
|
+
claimed, reason = _cctally_db_sib._claim_repair_marker(path)
|
|
4849
|
+
except OSError as marker_exc:
|
|
4850
|
+
raise sqlite3.DatabaseError(
|
|
4851
|
+
f"cache.db recovery could not claim maintenance: {marker_exc}"
|
|
4852
|
+
) from exc
|
|
4853
|
+
if not claimed:
|
|
4854
|
+
raise sqlite3.DatabaseError(
|
|
4855
|
+
f"cache.db maintenance is in progress: {reason}"
|
|
4856
|
+
) from exc
|
|
4857
|
+
|
|
4858
|
+
lock_path = pathlib.Path(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH)
|
|
4859
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4860
|
+
lock_fh = open(lock_path, "a+")
|
|
4861
|
+
try:
|
|
4862
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
|
4863
|
+
open_pids = _cctally_db_sib._db_family_open_pids(path)
|
|
4864
|
+
if open_pids is None:
|
|
4865
|
+
raise sqlite3.DatabaseError(
|
|
4866
|
+
"cache.db recovery cannot verify that the database family has "
|
|
4867
|
+
"no open handles; leaving it untouched"
|
|
4868
|
+
) from exc
|
|
4869
|
+
if open_pids:
|
|
4870
|
+
raise sqlite3.DatabaseError(
|
|
4871
|
+
"cache.db is still open in process(es) "
|
|
4872
|
+
+ ", ".join(str(pid) for pid in sorted(open_pids))
|
|
4873
|
+
+ "; leaving the live family untouched"
|
|
4874
|
+
) from exc
|
|
4875
|
+
|
|
4876
|
+
_cctally_db_sib.write_corruption_forensics(path, db_label="cache")
|
|
4877
|
+
incident = _cctally_db_sib.quarantine_db_family(path)
|
|
4878
|
+
eprint(
|
|
4879
|
+
f"[cache] corrupt cache DB ({exc}); quarantined its file family "
|
|
4880
|
+
f"under {incident} and recreating from source JSONL"
|
|
4881
|
+
)
|
|
4882
|
+
return True
|
|
4883
|
+
finally:
|
|
4884
|
+
try:
|
|
4885
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4886
|
+
finally:
|
|
4887
|
+
lock_fh.close()
|
|
4888
|
+
_cctally_db_sib._release_repair_marker(path)
|
|
4889
|
+
|
|
4890
|
+
|
|
4792
4891
|
def open_cache_db() -> sqlite3.Connection:
|
|
4793
4892
|
"""Open (or create) the session-entry cache DB.
|
|
4794
4893
|
|
|
4795
4894
|
Enables WAL mode so queries can run concurrently with an in-progress
|
|
4796
|
-
ingest. On
|
|
4797
|
-
|
|
4895
|
+
ingest. On positively classified corruption, the whole file family is
|
|
4896
|
+
quarantined and recreated only after a marker + maintenance flock + active
|
|
4897
|
+
handle check prove replacement cannot invalidate a live WAL reader.
|
|
4798
4898
|
"""
|
|
4799
4899
|
c = _cctally()
|
|
4800
4900
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -4807,22 +4907,13 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
4807
4907
|
except OSError as exc:
|
|
4808
4908
|
eprint(f"[cache] could not chmod data dir 0700 ({exc}); continuing")
|
|
4809
4909
|
try:
|
|
4810
|
-
|
|
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")
|
|
4814
|
-
conn.execute("SELECT 1").fetchone()
|
|
4910
|
+
conn = _cache_open_guarded()
|
|
4815
4911
|
except sqlite3.DatabaseError as exc:
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
try:
|
|
4822
|
-
_cctally_core.CACHE_DB_PATH.unlink()
|
|
4823
|
-
except FileNotFoundError:
|
|
4824
|
-
pass
|
|
4825
|
-
conn = _cctally_store.open_index("cache")
|
|
4912
|
+
if not _recover_corrupt_cache(exc):
|
|
4913
|
+
raise
|
|
4914
|
+
# One retry only. A second failure surfaces to the existing direct-JSONL
|
|
4915
|
+
# fallback instead of looping through destructive recovery.
|
|
4916
|
+
conn = _cache_open_guarded()
|
|
4826
4917
|
|
|
4827
4918
|
# Best-effort 0600 on cache.db itself (the 0700 dir above backstops the
|
|
4828
4919
|
# sidecars until the first write hardens them in sync_cache).
|
package/bin/_cctally_db.py
CHANGED
|
@@ -6695,7 +6695,7 @@ def _db_backup_timestamp() -> str:
|
|
|
6695
6695
|
|
|
6696
6696
|
|
|
6697
6697
|
def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
|
|
6698
|
-
return path.with_name("
|
|
6698
|
+
return path.with_name(f"{path.name}.repairing")
|
|
6699
6699
|
|
|
6700
6700
|
|
|
6701
6701
|
def _pid_is_alive(pid: int) -> bool:
|
|
@@ -6722,7 +6722,9 @@ def _claim_repair_marker(path: pathlib.Path) -> "tuple[bool, str]":
|
|
|
6722
6722
|
except (OSError, ValueError):
|
|
6723
6723
|
owner = -1
|
|
6724
6724
|
if _pid_is_alive(owner):
|
|
6725
|
-
return False,
|
|
6725
|
+
return False, (
|
|
6726
|
+
f"another {path.name} repair owns {marker} (pid {owner})"
|
|
6727
|
+
)
|
|
6726
6728
|
try:
|
|
6727
6729
|
marker.unlink()
|
|
6728
6730
|
except FileNotFoundError:
|
package/bin/_cctally_journal.py
CHANGED
|
@@ -45,6 +45,15 @@ import _lib_record
|
|
|
45
45
|
_TAIL_WINDOW = 64 * 1024
|
|
46
46
|
_MAX_LINE_BYTES = _TAIL_WINDOW
|
|
47
47
|
|
|
48
|
+
# Process-local acceleration for the durable Codex-quota dedupe index. The
|
|
49
|
+
# compact index is re-derivable from the journal and lets short-lived hook
|
|
50
|
+
# processes load ~20-byte natural-key digests instead of rescanning every full
|
|
51
|
+
# JSONL segment.
|
|
52
|
+
_QUOTA_DEDUP_INDEX_NAME = ".quota-observation-keys"
|
|
53
|
+
_QUOTA_DEDUP_DIR: str | None = None
|
|
54
|
+
_QUOTA_DEDUP_KEYS: set[str] = set()
|
|
55
|
+
_QUOTA_DEDUP_LOADED = False
|
|
56
|
+
|
|
48
57
|
|
|
49
58
|
class JournalError(Exception):
|
|
50
59
|
"""A structural journal-append failure (line too long, unrepairable tail)."""
|
|
@@ -134,17 +143,136 @@ def _repair_torn_tail(fd: int) -> None:
|
|
|
134
143
|
# public append surface
|
|
135
144
|
# --------------------------------------------------------------------------
|
|
136
145
|
|
|
137
|
-
def
|
|
146
|
+
def _is_codex_quota_obs(record: dict) -> bool:
|
|
147
|
+
return (
|
|
148
|
+
record.get("t") == "obs"
|
|
149
|
+
and record.get("provider") == "codex"
|
|
150
|
+
and (record.get("payload") or {}).get("kind") == "quota_window_snapshot"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _codex_quota_natural_key(record: dict) -> str | None:
|
|
155
|
+
"""Digest the cache table's stable UNIQUE key for one quota observation."""
|
|
156
|
+
if not _is_codex_quota_obs(record):
|
|
157
|
+
return None
|
|
158
|
+
payload = record.get("payload") or {}
|
|
159
|
+
source = payload.get("source")
|
|
160
|
+
source_path = payload.get("source_path")
|
|
161
|
+
line_offset = payload.get("line_offset")
|
|
162
|
+
logical_limit_key = payload.get("logical_limit_key")
|
|
163
|
+
if (
|
|
164
|
+
not isinstance(source, str)
|
|
165
|
+
or not isinstance(source_path, str)
|
|
166
|
+
or not isinstance(line_offset, int)
|
|
167
|
+
or not isinstance(logical_limit_key, str)
|
|
168
|
+
):
|
|
169
|
+
return None
|
|
170
|
+
return _lib_journal.content_id({
|
|
171
|
+
"t": "quota-natural-key",
|
|
172
|
+
"payload": {
|
|
173
|
+
"source": source,
|
|
174
|
+
"source_path": source_path,
|
|
175
|
+
"line_offset": line_offset,
|
|
176
|
+
"logical_limit_key": logical_limit_key,
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _load_quota_dedup_keys() -> None:
|
|
182
|
+
"""Load or atomically rebuild the re-derivable quota natural-key index.
|
|
183
|
+
|
|
184
|
+
Caller owns journal.lock, so an initial full scan observes one stable
|
|
185
|
+
journal prefix and only one process can publish the compact index. Normal
|
|
186
|
+
quota appends fsync the journal first and this index second; a crash in
|
|
187
|
+
between can cause at most one harmless duplicate on retry, never a skipped
|
|
188
|
+
durable observation.
|
|
189
|
+
"""
|
|
190
|
+
global _QUOTA_DEDUP_DIR, _QUOTA_DEDUP_LOADED
|
|
191
|
+
journal_dir = _cctally_core.JOURNAL_DIR
|
|
192
|
+
dir_key = str(journal_dir)
|
|
193
|
+
if _QUOTA_DEDUP_DIR != dir_key:
|
|
194
|
+
_QUOTA_DEDUP_DIR = dir_key
|
|
195
|
+
_QUOTA_DEDUP_KEYS.clear()
|
|
196
|
+
_QUOTA_DEDUP_LOADED = False
|
|
197
|
+
if _QUOTA_DEDUP_LOADED:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
index_path = journal_dir / _QUOTA_DEDUP_INDEX_NAME
|
|
201
|
+
try:
|
|
202
|
+
raw_keys = index_path.read_text(encoding="ascii").splitlines()
|
|
203
|
+
if any(not item.startswith("o:") or len(item) != 18 for item in raw_keys):
|
|
204
|
+
raise ValueError("invalid quota dedupe index")
|
|
205
|
+
_QUOTA_DEDUP_KEYS.update(raw_keys)
|
|
206
|
+
_QUOTA_DEDUP_LOADED = True
|
|
207
|
+
return
|
|
208
|
+
except (FileNotFoundError, OSError, UnicodeError, ValueError):
|
|
209
|
+
_QUOTA_DEDUP_KEYS.clear()
|
|
210
|
+
|
|
211
|
+
for name in list_segments():
|
|
212
|
+
with (journal_dir / name).open("rb") as fh:
|
|
213
|
+
for raw in fh:
|
|
214
|
+
if not raw.endswith(b"\n"):
|
|
215
|
+
break
|
|
216
|
+
decoded = _lib_journal.decode_line(raw)
|
|
217
|
+
if decoded is not None:
|
|
218
|
+
natural_key = _codex_quota_natural_key(decoded)
|
|
219
|
+
if natural_key is not None:
|
|
220
|
+
_QUOTA_DEDUP_KEYS.add(natural_key)
|
|
221
|
+
|
|
222
|
+
tmp = index_path.with_name(
|
|
223
|
+
f"{index_path.name}.tmp-{os.getpid()}-{time.monotonic_ns()}"
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
227
|
+
try:
|
|
228
|
+
payload = "".join(
|
|
229
|
+
f"{natural_key}\n" for natural_key in sorted(_QUOTA_DEDUP_KEYS)
|
|
230
|
+
).encode("ascii")
|
|
231
|
+
_write_all(fd, payload)
|
|
232
|
+
os.fsync(fd)
|
|
233
|
+
finally:
|
|
234
|
+
os.close(fd)
|
|
235
|
+
os.replace(str(tmp), str(index_path))
|
|
236
|
+
_fsync_dir(journal_dir)
|
|
237
|
+
finally:
|
|
238
|
+
try:
|
|
239
|
+
tmp.unlink()
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
pass
|
|
242
|
+
_QUOTA_DEDUP_LOADED = True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _append_quota_dedup_key(natural_key: str) -> None:
|
|
246
|
+
"""Journal-first second leg: append+fsync one key to the compact index."""
|
|
247
|
+
index_path = _cctally_core.JOURNAL_DIR / _QUOTA_DEDUP_INDEX_NAME
|
|
248
|
+
fd = os.open(str(index_path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
249
|
+
try:
|
|
250
|
+
_write_all(fd, f"{natural_key}\n".encode("ascii"))
|
|
251
|
+
os.fsync(fd)
|
|
252
|
+
finally:
|
|
253
|
+
os.close(fd)
|
|
254
|
+
_QUOTA_DEDUP_KEYS.add(natural_key)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def append_record(
|
|
258
|
+
record: dict,
|
|
259
|
+
*,
|
|
260
|
+
now_utc: dt.datetime | None = None,
|
|
261
|
+
dedupe_codex_quota: bool = False,
|
|
262
|
+
) -> tuple[str, int] | None:
|
|
138
263
|
"""Append one encoded line to the current UTC month's segment.
|
|
139
264
|
|
|
140
265
|
Implements spec §4.3 exactly: ``O_RDWR|O_APPEND|O_CREAT`` (0o600) → blocking
|
|
141
266
|
leaf flock → torn-tail repair → loop-write the full line → ``fsync(fd)`` →
|
|
142
267
|
parent-dir fsync on first segment/dir creation → unlock.
|
|
143
268
|
|
|
144
|
-
``now_utc`` selects the segment (defaults to the current UTC time).
|
|
145
|
-
``
|
|
146
|
-
|
|
147
|
-
|
|
269
|
+
``now_utc`` selects the segment (defaults to the current UTC time).
|
|
270
|
+
``dedupe_codex_quota`` skips a retained Codex quota obs whose table natural
|
|
271
|
+
key already exists in any segment; this is the cache-recovery replay path
|
|
272
|
+
and returns ``None`` on a skip. Otherwise returns ``(segment_basename,
|
|
273
|
+
end_offset)`` where ``end_offset`` is the file size just past the appended
|
|
274
|
+
line — the byte position the ingest cursor advances to when it consumes
|
|
275
|
+
this line."""
|
|
148
276
|
if now_utc is None:
|
|
149
277
|
now_utc = dt.datetime.now(dt.timezone.utc)
|
|
150
278
|
data = _lib_journal.encode_line(record)
|
|
@@ -167,6 +295,20 @@ def append_record(record: dict, *, now_utc: dt.datetime | None = None) -> tuple[
|
|
|
167
295
|
|
|
168
296
|
lock_fd = _acquire_leaf_lock()
|
|
169
297
|
try:
|
|
298
|
+
if dedupe_codex_quota:
|
|
299
|
+
if not _is_codex_quota_obs(record):
|
|
300
|
+
raise ValueError(
|
|
301
|
+
"dedupe_codex_quota is valid only for Codex quota obs"
|
|
302
|
+
)
|
|
303
|
+
_load_quota_dedup_keys()
|
|
304
|
+
natural_key = _codex_quota_natural_key(record)
|
|
305
|
+
if natural_key is None:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
"dedupe_codex_quota requires a complete quota natural key"
|
|
308
|
+
)
|
|
309
|
+
if natural_key in _QUOTA_DEDUP_KEYS:
|
|
310
|
+
return None
|
|
311
|
+
|
|
170
312
|
seg_created = not seg_path.exists()
|
|
171
313
|
fd = os.open(str(seg_path), os.O_RDWR | os.O_APPEND | os.O_CREAT, 0o600)
|
|
172
314
|
try:
|
|
@@ -181,6 +323,8 @@ def append_record(record: dict, *, now_utc: dt.datetime | None = None) -> tuple[
|
|
|
181
323
|
end_offset = os.fstat(fd).st_size
|
|
182
324
|
finally:
|
|
183
325
|
os.close(fd)
|
|
326
|
+
if dedupe_codex_quota:
|
|
327
|
+
_append_quota_dedup_key(natural_key)
|
|
184
328
|
# Durably record new directory entries (spec §4.3: fsync the parent
|
|
185
329
|
# directory on first creation of a segment or the journal dir).
|
|
186
330
|
if seg_created:
|
package/bin/_cctally_store.py
CHANGED
|
@@ -363,7 +363,10 @@ def _probe_stats_ok(path) -> bool:
|
|
|
363
363
|
try:
|
|
364
364
|
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
365
365
|
try:
|
|
366
|
-
|
|
366
|
+
# Force SQLite to read the database header. A constant-only
|
|
367
|
+
# ``SELECT 1`` can succeed on Linux without touching a corrupt file,
|
|
368
|
+
# falsely reporting that a sibling already healed the index.
|
|
369
|
+
c.execute("PRAGMA schema_version").fetchone()
|
|
367
370
|
finally:
|
|
368
371
|
c.close()
|
|
369
372
|
return True
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.80.
|
|
3
|
+
"version": "1.80.2",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|