cctally 1.116.1 → 1.117.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 +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +57 -3
- package/bin/_cctally_cache.py +5720 -885
- package/bin/_cctally_claude_attribution.py +997 -0
- package/bin/_cctally_completion.py +101 -28
- package/bin/_cctally_core.py +235 -12
- package/bin/_cctally_dashboard.py +263 -17
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +44 -0
- package/bin/_cctally_db.py +928 -80
- package/bin/_cctally_diagnosis_sources.py +78 -13
- package/bin/_cctally_doctor.py +211 -86
- package/bin/_cctally_five_hour.py +446 -22
- package/bin/_cctally_forecast.py +302 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_milestone_history.py +379 -91
- package/bin/_cctally_oauth_samples.py +185 -7
- package/bin/_cctally_parser.py +38 -0
- package/bin/_cctally_project.py +34 -8
- package/bin/_cctally_quarantine.py +821 -343
- package/bin/_cctally_quota.py +69 -4
- package/bin/_cctally_quota_calibration.py +677 -3
- package/bin/_cctally_quota_model.py +927 -127
- package/bin/_cctally_record.py +922 -445
- package/bin/_cctally_rederive.py +119 -15
- package/bin/_cctally_statusline.py +1048 -176
- package/bin/_cctally_store.py +544 -110
- package/bin/_cctally_tui.py +455 -42
- package/bin/_cctally_update.py +77 -14
- package/bin/_lib_account_protocol.py +227 -19
- package/bin/_lib_claude_attribution.py +421 -0
- package/bin/_lib_clone_copy.py +30 -0
- package/bin/_lib_codex_conversation_query.py +45 -9
- package/bin/_lib_codex_conversation_watch.py +72 -13
- package/bin/_lib_codex_rollout_container.py +234 -19
- package/bin/_lib_codex_worker_lifecycle.py +374 -0
- package/bin/_lib_credit.py +22 -13
- package/bin/_lib_dashboard_sources.py +257 -18
- package/bin/_lib_detector_access_registry.py +1335 -166
- package/bin/_lib_doctor.py +276 -92
- package/bin/_lib_ingest_frontier.py +269 -36
- package/bin/_lib_journal.py +273 -22
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_meter_rate_change.py +72 -0
- package/bin/_lib_outcome_quarantine.py +917 -554
- package/bin/_lib_provider_activity.py +24 -6
- package/bin/_lib_quota_calibration.py +57 -3
- package/bin/_lib_quota_copy.py +7 -2
- package/bin/_lib_quota_lineage.py +613 -0
- package/bin/_lib_quota_model.py +464 -33
- package/bin/_lib_rate_change_delivery.py +16 -0
- package/bin/_lib_reclaim_planner.py +6 -4
- package/bin/_lib_rederive.py +2046 -21
- package/bin/_lib_selector_state.py +131 -3
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_stale_proof.py +113 -4
- package/bin/_lib_statusline.py +72 -3
- package/bin/_lib_statusline_candidates.py +246 -42
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +419 -6
- package/bin/cctally +28 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
- package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +5 -1
- package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
- package/dashboard/static/assets/index-BKITk1CT.css +0 -1
|
@@ -34,6 +34,7 @@ Spec: docs/superpowers/specs/2026-08-19-620-s2-on-demand-diagnosis.md §3, §5
|
|
|
34
34
|
"""
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
|
+
import contextlib
|
|
37
38
|
import datetime as dt
|
|
38
39
|
import hashlib
|
|
39
40
|
import json
|
|
@@ -231,7 +232,17 @@ def open_read_only(kind: str) -> sqlite3.Connection:
|
|
|
231
232
|
connection cannot write, so migration, legacy import, contract repair and
|
|
232
233
|
replay cannot run behind our back and change the very bytes whose digest
|
|
233
234
|
we are about to publish.
|
|
235
|
+
|
|
236
|
+
#1000 §5.10: `conversations` is not served here. A `mode=ro` open still
|
|
237
|
+
creates `-wal`/`-shm` beside the store, so its only opener is
|
|
238
|
+
`StoreBundle.connection`, under the diagnostic read admission held for
|
|
239
|
+
the connection's life.
|
|
234
240
|
"""
|
|
241
|
+
if kind == "conversations":
|
|
242
|
+
raise EstablishmentFailure(
|
|
243
|
+
EstablishmentError.STORE_UNAVAILABLE.value,
|
|
244
|
+
"conversations store opens only under the read admission",
|
|
245
|
+
)
|
|
235
246
|
path = _store_path(kind)
|
|
236
247
|
if not path.exists():
|
|
237
248
|
raise EstablishmentFailure(
|
|
@@ -732,14 +743,59 @@ class StoreBundle:
|
|
|
732
743
|
# Memoized schema-gap answer. `_UNPROBED` rather than `None`, because
|
|
733
744
|
# `None` is the answer "this store can serve every statement".
|
|
734
745
|
self._conversations_gap: Any = _UNPROBED
|
|
746
|
+
# #1000 §5.10: the conversations read admission, held exactly as long
|
|
747
|
+
# as the conversations connection and released by `close()`.
|
|
748
|
+
self._admissions = contextlib.ExitStack()
|
|
735
749
|
|
|
736
750
|
def connection(self, kind: str) -> sqlite3.Connection:
|
|
737
751
|
conn = self._connections.get(kind)
|
|
738
752
|
if conn is None:
|
|
739
|
-
|
|
753
|
+
if kind == "conversations":
|
|
754
|
+
conn = self._open_conversations()
|
|
755
|
+
else:
|
|
756
|
+
conn = open_read_only(kind)
|
|
740
757
|
self._connections[kind] = conn
|
|
741
758
|
return conn
|
|
742
759
|
|
|
760
|
+
def _open_conversations(self) -> sqlite3.Connection:
|
|
761
|
+
"""Open `conversations.db` under the diagnostic read admission.
|
|
762
|
+
|
|
763
|
+
The admission (`_cctally_cache.conversation_ro_guarded`) holds the
|
|
764
|
+
conversation maintenance flock SHARED for the connection's life, so
|
|
765
|
+
the pre-rebuild probe's in-place read and recovery's quarantine
|
|
766
|
+
cannot run under this reader, and it declines rather than open the
|
|
767
|
+
family while they, or a repair, own it (#1000 §5.10).
|
|
768
|
+
"""
|
|
769
|
+
import _cctally_cache
|
|
770
|
+
|
|
771
|
+
unavailable = EstablishmentError.STORE_UNAVAILABLE.value
|
|
772
|
+
admission = contextlib.ExitStack()
|
|
773
|
+
try:
|
|
774
|
+
conn = admission.enter_context(
|
|
775
|
+
_cctally_cache.conversation_ro_guarded(
|
|
776
|
+
timeout=READ_BUSY_TIMEOUT_MS / 1000))
|
|
777
|
+
except (sqlite3.Error, OSError) as exc:
|
|
778
|
+
# OSError: the admission's lock file cannot be opened (an
|
|
779
|
+
# unwritable data folder), which is unavailability, not a crash.
|
|
780
|
+
admission.close()
|
|
781
|
+
raise EstablishmentFailure(
|
|
782
|
+
unavailable,
|
|
783
|
+
f"conversations store could not be opened read-only: {exc}",
|
|
784
|
+
) from exc
|
|
785
|
+
if conn is None:
|
|
786
|
+
admission.close()
|
|
787
|
+
if not os.path.lexists(_store_path("conversations")):
|
|
788
|
+
raise EstablishmentFailure(
|
|
789
|
+
unavailable, "conversations store is not present")
|
|
790
|
+
raise EstablishmentFailure(
|
|
791
|
+
unavailable,
|
|
792
|
+
"conversations store could not be opened read-only: "
|
|
793
|
+
+ _cctally_cache.conversation_ro_decline_reason(),
|
|
794
|
+
)
|
|
795
|
+
self._admissions.enter_context(admission)
|
|
796
|
+
conn.row_factory = sqlite3.Row
|
|
797
|
+
return conn
|
|
798
|
+
|
|
743
799
|
def conversations_schema_gap(self) -> str | None:
|
|
744
800
|
"""Which of our own tables this store cannot serve, memoized.
|
|
745
801
|
|
|
@@ -753,12 +809,15 @@ class StoreBundle:
|
|
|
753
809
|
return self._conversations_gap
|
|
754
810
|
|
|
755
811
|
def close(self) -> None:
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
812
|
+
try:
|
|
813
|
+
for conn in self._connections.values():
|
|
814
|
+
try:
|
|
815
|
+
conn.close()
|
|
816
|
+
except sqlite3.Error:
|
|
817
|
+
pass
|
|
818
|
+
self._connections.clear()
|
|
819
|
+
finally:
|
|
820
|
+
self._admissions.close()
|
|
762
821
|
|
|
763
822
|
def __enter__(self) -> "StoreBundle":
|
|
764
823
|
return self
|
|
@@ -4451,6 +4510,16 @@ def _finish_forked_provider(worker: tuple[int, int]):
|
|
|
4451
4510
|
return payload[1]
|
|
4452
4511
|
|
|
4453
4512
|
|
|
4513
|
+
# The `_cctally_core` paths a spawned or forkserver worker takes from its
|
|
4514
|
+
# parent. The worker reloads cctally, which re-derives every other path from
|
|
4515
|
+
# the forkserver's environment, frozen when that server started. The
|
|
4516
|
+
# conversation maintenance lock must be the parent's own: the transcript read
|
|
4517
|
+
# admission excludes the pre-rebuild probe and recovery on that file and on
|
|
4518
|
+
# no other (#1000 §5.10).
|
|
4519
|
+
_WORKER_CORE_PATHS = ("CACHE_DB_PATH", "DB_PATH", "CONVERSATIONS_DB_PATH",
|
|
4520
|
+
"CONVERSATIONS_LOCK_MAINTENANCE_PATH", "CONFIG_PATH")
|
|
4521
|
+
|
|
4522
|
+
|
|
4454
4523
|
def _spawn_worker_init(cctally_path: str,
|
|
4455
4524
|
store_paths: Mapping[str, str]) -> None:
|
|
4456
4525
|
"""Initialize a safe spawned/forkserver dashboard worker."""
|
|
@@ -4590,9 +4659,7 @@ def build_diagnosis_bounded(scope: DiagnosisScope, *,
|
|
|
4590
4659
|
"""Build one dashboard diagnosis in a process retired at TIMEOUT_SECONDS."""
|
|
4591
4660
|
c = _cctally()
|
|
4592
4661
|
paths = {
|
|
4593
|
-
name: str(getattr(_cctally_core, name))
|
|
4594
|
-
for name in ("CACHE_DB_PATH", "DB_PATH",
|
|
4595
|
-
"CONVERSATIONS_DB_PATH", "CONFIG_PATH")
|
|
4662
|
+
name: str(getattr(_cctally_core, name)) for name in _WORKER_CORE_PATHS
|
|
4596
4663
|
}
|
|
4597
4664
|
return _run_process_with_deadline(
|
|
4598
4665
|
_build_diagnosis_process_task,
|
|
@@ -4610,9 +4677,7 @@ def _build_isolated_provider(args):
|
|
|
4610
4677
|
|
|
4611
4678
|
c = _cctally()
|
|
4612
4679
|
paths = {
|
|
4613
|
-
name: str(getattr(_cctally_core, name))
|
|
4614
|
-
for name in ("CACHE_DB_PATH", "DB_PATH",
|
|
4615
|
-
"CONVERSATIONS_DB_PATH", "CONFIG_PATH")
|
|
4680
|
+
name: str(getattr(_cctally_core, name)) for name in _WORKER_CORE_PATHS
|
|
4616
4681
|
}
|
|
4617
4682
|
methods = multiprocessing.get_all_start_methods()
|
|
4618
4683
|
method = "forkserver" if "forkserver" in methods else "spawn"
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -84,51 +84,18 @@ def _stats_ro_guarded():
|
|
|
84
84
|
)
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
@contextlib.contextmanager
|
|
88
87
|
def _conversation_ro_guarded(*, timeout: float):
|
|
89
|
-
"""Bounded read-only transcript handle participating in #415 recovery.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
lock_fh = conn = None
|
|
101
|
-
try:
|
|
102
|
-
maintenance.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
-
lock_fh = open(maintenance, "a+")
|
|
104
|
-
try:
|
|
105
|
-
fcntl.flock(lock_fh, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
106
|
-
except BlockingIOError:
|
|
107
|
-
yield None
|
|
108
|
-
return
|
|
109
|
-
if marker.exists() or pending.exists() or recovery.exists():
|
|
110
|
-
yield None
|
|
111
|
-
return
|
|
112
|
-
conn = sqlite3.connect(
|
|
113
|
-
path.resolve().as_uri() + "?mode=ro",
|
|
114
|
-
uri=True,
|
|
115
|
-
timeout=max(0.0, timeout),
|
|
116
|
-
)
|
|
117
|
-
if marker.exists() or pending.exists() or recovery.exists():
|
|
118
|
-
conn.close()
|
|
119
|
-
conn = None
|
|
120
|
-
yield None
|
|
121
|
-
return
|
|
122
|
-
yield conn
|
|
123
|
-
finally:
|
|
124
|
-
if conn is not None:
|
|
125
|
-
conn.close()
|
|
126
|
-
if lock_fh is not None:
|
|
127
|
-
try:
|
|
128
|
-
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
129
|
-
except OSError:
|
|
130
|
-
pass
|
|
131
|
-
lock_fh.close()
|
|
88
|
+
"""Bounded read-only transcript handle participating in #415 recovery.
|
|
89
|
+
|
|
90
|
+
#1000 §5.10: the admission now lives in
|
|
91
|
+
``_cctally_cache.conversation_ro_guarded``, shared with `db status` and
|
|
92
|
+
the `explain` reader. Doctor keeps this name for its call sites (and the
|
|
93
|
+
tests that patch it), and reaches the sibling call-time, as its other
|
|
94
|
+
sibling imports do (F1), because doctor loads before `_cctally_cache`.
|
|
95
|
+
"""
|
|
96
|
+
import _cctally_cache
|
|
97
|
+
|
|
98
|
+
return _cctally_cache.conversation_ro_guarded(timeout=timeout)
|
|
132
99
|
|
|
133
100
|
|
|
134
101
|
def _journal_heal_incident(kind: str, name: str, now_utc: dt.datetime) -> dict:
|
|
@@ -646,18 +613,16 @@ def _scan_cause(exc: BaseException) -> str:
|
|
|
646
613
|
|
|
647
614
|
|
|
648
615
|
def _durable_inventory_marker(conn) -> bool:
|
|
649
|
-
"""Whether the validated index
|
|
650
|
-
|
|
651
|
-
caller's validated snapshot; a
|
|
616
|
+
"""Whether the validated index shows the legacy spool inventory episode
|
|
617
|
+
open (#948 spec 7.3: the `journal.legacy_writer` addendum says detection
|
|
618
|
+
is paused only then). Read inside the caller's validated snapshot; a
|
|
619
|
+
missing quarantine table is no open episode."""
|
|
652
620
|
import _cctally_quarantine
|
|
653
|
-
import _lib_account_protocol
|
|
654
621
|
try:
|
|
655
622
|
state = _cctally_quarantine.load_state(conn)
|
|
656
623
|
except Exception: # noqa: BLE001 — no readable marker claims no pause
|
|
657
624
|
return False
|
|
658
|
-
return
|
|
659
|
-
== _lib_account_protocol.INVENTORY_INCOMPLETE_REASON
|
|
660
|
-
for marker in state.spool_markers)
|
|
625
|
+
return state.open_episode() is not None
|
|
661
626
|
|
|
662
627
|
|
|
663
628
|
def _legacy_spool_inventory_probe(paused: bool):
|
|
@@ -687,32 +652,26 @@ def _legacy_spool_inventory_probe(paused: bool):
|
|
|
687
652
|
return None
|
|
688
653
|
|
|
689
654
|
|
|
690
|
-
def
|
|
691
|
-
"""
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
continue
|
|
706
|
-
quarantine = None if transition is None else transition.quarantine
|
|
707
|
-
commit = None if quarantine is None else quarantine.get(
|
|
708
|
-
"authorityCommit")
|
|
709
|
-
if commit is not None:
|
|
710
|
-
commits[account_key] = commit
|
|
711
|
-
return commits
|
|
655
|
+
def _quarantine_inventory_path() -> "str | None":
|
|
656
|
+
"""The file a limit waiting for `inventory` names (#948 spec 7.3): the
|
|
657
|
+
path doctor's own legacy spool inventory fails on, else the first legacy
|
|
658
|
+
spool location (the inventory now completes and a writer has yet to
|
|
659
|
+
record that). Read-only; None when neither is known."""
|
|
660
|
+
import _cctally_journal
|
|
661
|
+
probe = _legacy_spool_inventory_probe(True)
|
|
662
|
+
if probe and probe.get("path"):
|
|
663
|
+
return str(probe["path"])
|
|
664
|
+
try:
|
|
665
|
+
for path in _cctally_journal._legacy_spool_paths():
|
|
666
|
+
return str(path)
|
|
667
|
+
except Exception: # noqa: BLE001 — a diagnostic read never crashes doctor
|
|
668
|
+
return None
|
|
669
|
+
return None
|
|
712
670
|
|
|
713
671
|
|
|
714
672
|
def _gather_outcome_quarantine() -> dict:
|
|
715
|
-
"""The `accounting.outcome_quarantine` evidence (#
|
|
673
|
+
"""The `accounting.outcome_quarantine` evidence (#948 spec 7.1): the
|
|
674
|
+
per-account, per-limit state of the journal-derived recovery barrier.
|
|
716
675
|
|
|
717
676
|
Read from ONE snapshot proven current (epoch and protocol); an absent,
|
|
718
677
|
unreadable or unvalidated index is unavailable, never "no quarantine".
|
|
@@ -724,12 +683,10 @@ def _gather_outcome_quarantine() -> dict:
|
|
|
724
683
|
try:
|
|
725
684
|
if not _cctally_core.DB_PATH.exists():
|
|
726
685
|
return out
|
|
727
|
-
commits = _statusline_authority_commits()
|
|
728
686
|
pc = _stats_ro_guarded()
|
|
729
687
|
try:
|
|
730
688
|
with _cctally_store.current_stats_snapshot(pc):
|
|
731
|
-
report = _cctally_quarantine.report(
|
|
732
|
-
pc, authority_commits=commits)
|
|
689
|
+
report = _cctally_quarantine.report(pc)
|
|
733
690
|
labels = {}
|
|
734
691
|
for row in pc.execute(
|
|
735
692
|
"SELECT account_key, label, email FROM accounts"):
|
|
@@ -743,10 +700,13 @@ def _gather_outcome_quarantine() -> dict:
|
|
|
743
700
|
accounts.append({**row, "accountLabel": labels.get(
|
|
744
701
|
row["account"], row["account"])})
|
|
745
702
|
out = {"available": True, "triggered": report["triggered"],
|
|
703
|
+
"generation": report.get("generation"),
|
|
704
|
+
"cause": report.get("cause"),
|
|
705
|
+
"inventoryEpisode": report.get("inventoryEpisode"),
|
|
746
706
|
"accounts": accounts}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
out["
|
|
707
|
+
if any(doc.get("waitingFor") == "inventory"
|
|
708
|
+
for row in accounts for doc in (row.get("axes") or {}).values()):
|
|
709
|
+
out["inventoryPath"] = _quarantine_inventory_path()
|
|
750
710
|
return out
|
|
751
711
|
|
|
752
712
|
|
|
@@ -1165,7 +1125,148 @@ def _journal_protocol_violation_rows(conn: sqlite3.Connection) -> list:
|
|
|
1165
1125
|
return [json.loads(str(row[3])) for row in rows]
|
|
1166
1126
|
|
|
1167
1127
|
|
|
1168
|
-
|
|
1128
|
+
_CLAUDE_COST_EXPORT_MIGRATION = "050_preserve_claude_cost_attribution"
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _claude_cost_export_state(conn, doc) -> str:
|
|
1132
|
+
"""Migration 050's export state (#944 spec 5.9): ``complete`` once
|
|
1133
|
+
applied, ``skipped`` when skipped, else ``stalled`` when its last attempt
|
|
1134
|
+
failed after its last completed chunk (or no chunk completed), else
|
|
1135
|
+
``running``."""
|
|
1136
|
+
if conn.execute("SELECT 1 FROM schema_migrations WHERE name=?",
|
|
1137
|
+
(_CLAUDE_COST_EXPORT_MIGRATION,)).fetchone():
|
|
1138
|
+
return "complete"
|
|
1139
|
+
try:
|
|
1140
|
+
if conn.execute(
|
|
1141
|
+
"SELECT 1 FROM schema_migrations_skipped WHERE name=?",
|
|
1142
|
+
(_CLAUDE_COST_EXPORT_MIGRATION,)).fetchone():
|
|
1143
|
+
return "skipped"
|
|
1144
|
+
except sqlite3.OperationalError:
|
|
1145
|
+
pass
|
|
1146
|
+
doc = doc or {}
|
|
1147
|
+
error_at, chunk_at = doc.get("lastErrorAt"), doc.get("lastChunkAt")
|
|
1148
|
+
if error_at and (not chunk_at or str(error_at) > str(chunk_at)):
|
|
1149
|
+
return "stalled"
|
|
1150
|
+
return "running"
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _gather_claude_cost_attribution(
|
|
1154
|
+
now_utc: "dt.datetime", *, cache_probe_allowed: bool = True) -> dict:
|
|
1155
|
+
"""Read-only counts for ``accounts.claude_cost_attribution`` (#944 spec
|
|
1156
|
+
5.9). The population is the ``timeline_agreement`` / ``fallback``
|
|
1157
|
+
decisions decided in the preceding 7 UTC days that a retained accounting
|
|
1158
|
+
row links to: a keyed decision through ``idx_entries_dedup`` with link
|
|
1159
|
+
equality, a physical or legacy one exactly on the link through
|
|
1160
|
+
``idx_entries_attr_nonkeyed``. A decision no row links to (a replaced
|
|
1161
|
+
physical record, an orphan) is not counted. ``preservedRows`` counts the
|
|
1162
|
+
``preserved_history`` decisions decided in the window without probing
|
|
1163
|
+
their links (#944 F4): for a week after an upgrade that is every
|
|
1164
|
+
existing row (~700K measured), and probing each one's link cost ~2.8 s of
|
|
1165
|
+
SQL on every doctor run, while every export links the row it records.
|
|
1166
|
+
Never raises; an absent or unreadable cache.db yields the empty shape."""
|
|
1167
|
+
import _lib_claude_attribution as K
|
|
1168
|
+
end_us = K.us_from_iso(
|
|
1169
|
+
now_utc.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"))
|
|
1170
|
+
start_us = end_us - 7 * 86_400 * 1_000_000
|
|
1171
|
+
out = {
|
|
1172
|
+
"windowStart": K.iso_from_us(start_us),
|
|
1173
|
+
"windowEnd": K.iso_from_us(end_us),
|
|
1174
|
+
"population": "deduplicatedClaudeCostRows",
|
|
1175
|
+
"totalRows": 0, "fallbackRows": 0, "fallbackRealAccountRows": 0,
|
|
1176
|
+
"fallbackUnattributedRows": 0, "byReason": {},
|
|
1177
|
+
"firstFallbackAt": None, "lastFallbackAt": None,
|
|
1178
|
+
"preservedRows": 0, "unlinkedRows": 0,
|
|
1179
|
+
"preservation": {"state": None, "saved": None, "total": None,
|
|
1180
|
+
"lastError": None, "lastErrorAt": None},
|
|
1181
|
+
"decisionConflicts": 0, "alignedRows": 0, "lastDeferral": None,
|
|
1182
|
+
}
|
|
1183
|
+
path = _cctally_core.CACHE_DB_PATH
|
|
1184
|
+
# A live cache repair owner bars every cache probe (#344).
|
|
1185
|
+
if not cache_probe_allowed or not path.exists():
|
|
1186
|
+
return out
|
|
1187
|
+
try:
|
|
1188
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=0.0)
|
|
1189
|
+
except sqlite3.Error:
|
|
1190
|
+
return out
|
|
1191
|
+
try:
|
|
1192
|
+
import _cctally_claude_attribution as G
|
|
1193
|
+
if not G.schema_present(conn):
|
|
1194
|
+
return out
|
|
1195
|
+
# #944 F10: compare in decided_at's own format. ``decided_at_utc``
|
|
1196
|
+
# is written by ``now_utc_iso`` (seconds precision, ``Z``), and a
|
|
1197
|
+
# microsecond bound sorts after every decision of its own second
|
|
1198
|
+
# ('.' < 'Z'), dropping a decision made in the second that ends the
|
|
1199
|
+
# window. The bounds are the window's instants at that precision.
|
|
1200
|
+
window = tuple(
|
|
1201
|
+
_cctally_core.now_utc_iso(
|
|
1202
|
+
dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)
|
|
1203
|
+
+ dt.timedelta(microseconds=us))
|
|
1204
|
+
for us in (start_us, end_us))
|
|
1205
|
+
# The population is the evidence and fallback decisions only: the
|
|
1206
|
+
# preserved ones are excluded here, before any link is probed.
|
|
1207
|
+
population = (K.BASIS_AGREEMENT, K.BASIS_FALLBACK)
|
|
1208
|
+
rows = conn.execute(
|
|
1209
|
+
"SELECT d.basis, d.account_key, d.reason, d.decided_at_utc"
|
|
1210
|
+
" FROM claude_cost_decisions d"
|
|
1211
|
+
" WHERE d.decided_at_utc >= ? AND d.decided_at_utc <= ?"
|
|
1212
|
+
" AND d.basis IN (?, ?)"
|
|
1213
|
+
" AND d.form = ? AND EXISTS (SELECT 1 FROM session_entries e"
|
|
1214
|
+
" WHERE e.msg_id = d.msg_id AND e.req_id = d.req_id"
|
|
1215
|
+
" AND e.attribution_decision_key = d.decision_key)"
|
|
1216
|
+
" UNION ALL"
|
|
1217
|
+
" SELECT d.basis, d.account_key, d.reason, d.decided_at_utc"
|
|
1218
|
+
" FROM claude_cost_decisions d"
|
|
1219
|
+
" WHERE d.decided_at_utc >= ? AND d.decided_at_utc <= ?"
|
|
1220
|
+
" AND d.basis IN (?, ?)"
|
|
1221
|
+
" AND d.form <> ? AND EXISTS (SELECT 1 FROM session_entries e"
|
|
1222
|
+
" WHERE e.attribution_decision_key = d.decision_key"
|
|
1223
|
+
" AND (e.msg_id IS NULL OR e.req_id IS NULL))",
|
|
1224
|
+
(*window, *population, K.FORM_KEYED,
|
|
1225
|
+
*window, *population, K.FORM_KEYED)).fetchall()
|
|
1226
|
+
out["preservedRows"] = conn.execute(
|
|
1227
|
+
"SELECT COUNT(*) FROM claude_cost_decisions"
|
|
1228
|
+
" WHERE decided_at_utc >= ? AND decided_at_utc <= ?"
|
|
1229
|
+
" AND basis = ?", (*window, K.BASIS_PRESERVED)).fetchone()[0]
|
|
1230
|
+
fallback_at = []
|
|
1231
|
+
for basis, account, reason, decided_at in rows:
|
|
1232
|
+
out["totalRows"] += 1
|
|
1233
|
+
if basis != K.BASIS_FALLBACK:
|
|
1234
|
+
continue
|
|
1235
|
+
out["fallbackRows"] += 1
|
|
1236
|
+
if account and account != K.UNATTRIBUTED:
|
|
1237
|
+
out["fallbackRealAccountRows"] += 1
|
|
1238
|
+
else:
|
|
1239
|
+
out["fallbackUnattributedRows"] += 1
|
|
1240
|
+
key = reason or "unknown"
|
|
1241
|
+
out["byReason"][key] = out["byReason"].get(key, 0) + 1
|
|
1242
|
+
fallback_at.append(decided_at)
|
|
1243
|
+
if fallback_at:
|
|
1244
|
+
out["firstFallbackAt"] = min(fallback_at)
|
|
1245
|
+
out["lastFallbackAt"] = max(fallback_at)
|
|
1246
|
+
out["byReason"] = dict(sorted(out["byReason"].items()))
|
|
1247
|
+
out["unlinkedRows"] = conn.execute(
|
|
1248
|
+
"SELECT COUNT(*) FROM session_entries"
|
|
1249
|
+
" WHERE attribution_decision_key IS NULL").fetchone()[0]
|
|
1250
|
+
doc = G.read_preservation(conn)
|
|
1251
|
+
out["preservation"] = {
|
|
1252
|
+
"state": _claude_cost_export_state(conn, doc),
|
|
1253
|
+
"saved": (doc or {}).get("saved"),
|
|
1254
|
+
"total": (doc or {}).get("total"),
|
|
1255
|
+
"lastError": (doc or {}).get("lastError"),
|
|
1256
|
+
"lastErrorAt": (doc or {}).get("lastErrorAt"),
|
|
1257
|
+
}
|
|
1258
|
+
out["decisionConflicts"] = G.decision_conflicts(conn)
|
|
1259
|
+
out["alignedRows"] = G.aligned_rows(conn)
|
|
1260
|
+
out["lastDeferral"] = G.last_deferral(conn)
|
|
1261
|
+
except sqlite3.Error:
|
|
1262
|
+
return out
|
|
1263
|
+
finally:
|
|
1264
|
+
conn.close()
|
|
1265
|
+
return out
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _gather_accounts_state(now_utc: "dt.datetime", *,
|
|
1269
|
+
cache_probe_allowed: bool = True) -> dict:
|
|
1169
1270
|
"""Best-effort account-attribution state for the doctor `accounts.*` legs
|
|
1170
1271
|
(#341). Never raises: identity + registry reads are read-only and each guard
|
|
1171
1272
|
swallows OperationalError so a legacy / pre-account stats.db degrades to the
|
|
@@ -1182,6 +1283,10 @@ def _gather_accounts_state(now_utc: "dt.datetime") -> dict:
|
|
|
1182
1283
|
state["claude_email"] = ident.get("email")
|
|
1183
1284
|
except Exception:
|
|
1184
1285
|
pass
|
|
1286
|
+
# #944 spec 5.9: gathered from cache.db, so before the stats.db early
|
|
1287
|
+
# return; the check is always present.
|
|
1288
|
+
state["claude_cost_attribution"] = _gather_claude_cost_attribution(
|
|
1289
|
+
now_utc, cache_probe_allowed=cache_probe_allowed)
|
|
1185
1290
|
if not _cctally_core.DB_PATH.exists():
|
|
1186
1291
|
return state
|
|
1187
1292
|
try:
|
|
@@ -1465,13 +1570,20 @@ def _gather_quota_rate_change(c, rejection=None) -> "dict | None":
|
|
|
1465
1570
|
"""
|
|
1466
1571
|
if rejection == "calibration-absent":
|
|
1467
1572
|
return dict(_QUOTA_NOT_ASSESSED)
|
|
1468
|
-
mrc = c._load_sibling("_lib_meter_rate_change")
|
|
1469
1573
|
glue = c._load_sibling("_cctally_quota_model")
|
|
1574
|
+
calibration = c._load_sibling("_cctally_quota_calibration")
|
|
1470
1575
|
loaded = glue.read_stored_state_readonly()
|
|
1471
1576
|
if loaded is None:
|
|
1472
1577
|
return None
|
|
1473
1578
|
regimes = glue.stored_regimes(loaded, None)
|
|
1474
|
-
|
|
1579
|
+
# #1015 spec 5.6: over the CLEAN regimes, with a superseded transition
|
|
1580
|
+
# suppressed. Correction facts that cannot be read (the fail-fast reader
|
|
1581
|
+
# never recovers or waits) leave the change unverified: not assessed,
|
|
1582
|
+
# never asserted.
|
|
1583
|
+
state, active = calibration.assess_active_rate_change(
|
|
1584
|
+
None, regimes=regimes)
|
|
1585
|
+
if state == calibration.MARKER_UNAVAILABLE:
|
|
1586
|
+
return dict(_QUOTA_NOT_ASSESSED)
|
|
1475
1587
|
if active is None:
|
|
1476
1588
|
return dict(_QUOTA_NO_CHANGE)
|
|
1477
1589
|
return dict(active, assessed=True)
|
|
@@ -1659,8 +1771,10 @@ def _gather_codex_container_state(c, *, now_utc, cache_probe_allowed):
|
|
|
1659
1771
|
|
|
1660
1772
|
try:
|
|
1661
1773
|
decoder = rc.decoder_capability()
|
|
1774
|
+
# #970 spec 5.9: the census reads the same locations discovery does,
|
|
1775
|
+
# each native home's flat archive directory included.
|
|
1662
1776
|
selected = [item for item in cache._iter_codex_logical_containers(
|
|
1663
|
-
|
|
1777
|
+
cache._codex_home_locations()) if item.kind == rc.ZSTD]
|
|
1664
1778
|
census = dict(compressedOnly=len(selected), retainedComplete=0,
|
|
1665
1779
|
retainedIncomplete=0, neverIngested=0, unverified=0)
|
|
1666
1780
|
status = None
|
|
@@ -2354,13 +2468,23 @@ def _doctor_gather_state_impl(
|
|
|
2354
2468
|
# hardcoded ~/.codex/sessions), matching the multi-root ingestion path
|
|
2355
2469
|
# from #108. _codex_session_roots() already applies the sessions/-subdir
|
|
2356
2470
|
# rule and filters to existing dirs, so a bare glob per root suffices.
|
|
2471
|
+
# #970 spec 5.9: a native home's archived_sessions/ directory (globbed
|
|
2472
|
+
# flat, as discovery does) also holds Codex history.
|
|
2357
2473
|
codex_jsonl_present = False
|
|
2358
2474
|
try:
|
|
2359
|
-
|
|
2475
|
+
import _cctally_cache as _cache_for_codex_files
|
|
2476
|
+
for location in _cache_for_codex_files._codex_home_locations():
|
|
2477
|
+
codex_dir = location.walk_root
|
|
2360
2478
|
if (next(codex_dir.glob("**/*.jsonl"), None) is not None
|
|
2361
2479
|
or next(codex_dir.glob("**/*.jsonl.zst"), None) is not None):
|
|
2362
2480
|
codex_jsonl_present = True
|
|
2363
2481
|
break
|
|
2482
|
+
archive = location.archive_root
|
|
2483
|
+
if archive is not None and (
|
|
2484
|
+
next(archive.glob("*.jsonl"), None) is not None
|
|
2485
|
+
or next(archive.glob("*.jsonl.zst"), None) is not None):
|
|
2486
|
+
codex_jsonl_present = True
|
|
2487
|
+
break
|
|
2364
2488
|
except Exception:
|
|
2365
2489
|
pass
|
|
2366
2490
|
|
|
@@ -3219,7 +3343,8 @@ def _doctor_gather_state_impl(
|
|
|
3219
3343
|
cctally_version = (
|
|
3220
3344
|
cctally_version_tuple[0] if cctally_version_tuple else "unknown"
|
|
3221
3345
|
)
|
|
3222
|
-
accounts_state = _gather_accounts_state(
|
|
3346
|
+
accounts_state = _gather_accounts_state(
|
|
3347
|
+
now_utc, cache_probe_allowed=_cache_probe_allowed)
|
|
3223
3348
|
accounts_state["codex_null_reset_anchors"] = codex_null_reset_anchors
|
|
3224
3349
|
accounts_state["codex_window_attribution"] = (
|
|
3225
3350
|
_gather_codex_window_attribution_state()
|