cctally 1.97.0 → 1.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +960 -55
- package/bin/_cctally_dashboard_envelope.py +43 -23
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1580 -131
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +65 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +811 -67
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +488 -19
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +248 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +25 -0
- package/dashboard/static/assets/index-Bcbm-DNP.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BSESoPIK.css +0 -1
- package/dashboard/static/assets/index-DgsMz5hA.js +0 -97
package/bin/_cctally_rederive.py
CHANGED
|
@@ -342,6 +342,25 @@ def _derive_desired_events_into(records, cache_conn, scratch_path) -> list[dict]
|
|
|
342
342
|
conn.close()
|
|
343
343
|
|
|
344
344
|
|
|
345
|
+
def _planner_op_kinds() -> "set[str]":
|
|
346
|
+
"""Every `op` kind the planner validates against `_OP_CLASSIFICATIONS`.
|
|
347
|
+
|
|
348
|
+
One definition, so a test can assert what the planner ACTUALLY validates
|
|
349
|
+
rather than re-deriving the same union beside it (review round 2, finding
|
|
350
|
+
R2-6). A test that rebuilds the expression locally is a membership check
|
|
351
|
+
against a set it constructed itself: it passes whatever this module does.
|
|
352
|
+
|
|
353
|
+
`sync_week` is named literally because it is journaled as an `op` with no
|
|
354
|
+
fold applier and no accounts machinery entry, so neither registry carries
|
|
355
|
+
it, and an unclassified op kind raises `RederiveConflict`.
|
|
356
|
+
"""
|
|
357
|
+
return (
|
|
358
|
+
set(_journal.FOLD_APPLIERS)
|
|
359
|
+
| set(_journal._ACCOUNTS_MACHINERY_KINDS)
|
|
360
|
+
| {"sync_week"}
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
345
364
|
def plan_claude_usage(
|
|
346
365
|
records,
|
|
347
366
|
*,
|
|
@@ -360,11 +379,7 @@ def plan_claude_usage(
|
|
|
360
379
|
_normalize_legacy_accounts(records)
|
|
361
380
|
report = _lib_rederive.validate_family_registry(
|
|
362
381
|
evt_kinds=set(_journal._EVT_SPECS),
|
|
363
|
-
op_kinds=(
|
|
364
|
-
set(_journal.FOLD_APPLIERS)
|
|
365
|
-
| set(_journal._ACCOUNTS_MACHINERY_KINDS)
|
|
366
|
-
| {"sync_week"}
|
|
367
|
-
),
|
|
382
|
+
op_kinds=_planner_op_kinds(),
|
|
368
383
|
)
|
|
369
384
|
if report.unclassified_evt_kinds or report.unclassified_op_kinds:
|
|
370
385
|
raise _lib_rederive.RederiveConflict(
|
|
@@ -737,6 +752,143 @@ def _rederive_locks(*, apply: bool, timeout: float):
|
|
|
737
752
|
release_cache_writer_flocks(held)
|
|
738
753
|
|
|
739
754
|
|
|
755
|
+
# --------------------------------------------------------------------------
|
|
756
|
+
# #500 §8/§8.1 — the operator-attribution apply lock set
|
|
757
|
+
# --------------------------------------------------------------------------
|
|
758
|
+
#
|
|
759
|
+
# `_rederive_locks` above yields no handles and releases its whole set together,
|
|
760
|
+
# so there is no way to drop only the two cache flocks at the end of the cache
|
|
761
|
+
# transaction while retaining stats maintenance and the ingest lock for the
|
|
762
|
+
# stats transaction that follows. `account attribute` needs exactly that: its
|
|
763
|
+
# cache work must be committed and unlocked before the stats transaction begins,
|
|
764
|
+
# which is the repository law that all cache work precedes the stats transaction
|
|
765
|
+
# (`docs/journal-gotchas.md`), while the ingest lock has to be held ACROSS both
|
|
766
|
+
# so nothing can consume the appended prefix in between.
|
|
767
|
+
#
|
|
768
|
+
# ORDERED PARTIAL RELEASE is the whole mechanism: the owner below releases a
|
|
769
|
+
# SUFFIX of the acquired set in reverse acquisition order and nothing else. A
|
|
770
|
+
# release that skipped an inner lock, or that took them in acquisition order,
|
|
771
|
+
# would be the lock-order violation this exists to avoid rather than a
|
|
772
|
+
# convenience on top of it.
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class _OrderedApplyLockOwner:
|
|
776
|
+
"""A held ordered flock set that can release a suffix of itself."""
|
|
777
|
+
|
|
778
|
+
__slots__ = ("_paths", "_held", "_cache_flock_count", "_cache_flocks_noted")
|
|
779
|
+
|
|
780
|
+
def __init__(self, paths, held, *, cache_flock_count: int) -> None:
|
|
781
|
+
self._paths = tuple(paths)
|
|
782
|
+
self._held = list(held)
|
|
783
|
+
self._cache_flock_count = int(cache_flock_count)
|
|
784
|
+
self._cache_flocks_noted = True
|
|
785
|
+
_cctally_core.note_attribution_apply_cache_flocks_acquired()
|
|
786
|
+
|
|
787
|
+
@property
|
|
788
|
+
def held_paths(self) -> tuple:
|
|
789
|
+
return tuple(self._paths[:len(self._held)])
|
|
790
|
+
|
|
791
|
+
def _note_cache_flocks_released(self) -> None:
|
|
792
|
+
if self._cache_flocks_noted:
|
|
793
|
+
self._cache_flocks_noted = False
|
|
794
|
+
_cctally_core.note_attribution_apply_cache_flocks_released()
|
|
795
|
+
|
|
796
|
+
def release_cache_flocks(self) -> None:
|
|
797
|
+
"""Drop the global cache writer flock and the Codex provider flock.
|
|
798
|
+
|
|
799
|
+
Idempotent, because the apply sequence releases them at the end of its
|
|
800
|
+
cache transaction and the context manager's ``finally`` releases
|
|
801
|
+
whatever is left — the second call must not close a descriptor twice.
|
|
802
|
+
|
|
803
|
+
The release is RECORDED as well as performed, and that is what turns the
|
|
804
|
+
repository lock-order law into something the code enforces:
|
|
805
|
+
`_run_stats_ingest_once(locks_held=True)` refuses while this context
|
|
806
|
+
still holds them, so a caller that forgets this call gets a loud refusal
|
|
807
|
+
instead of a stats transaction opened underneath live cache flocks.
|
|
808
|
+
"""
|
|
809
|
+
from _lib_cache_writer_lock import release_cache_writer_flocks
|
|
810
|
+
|
|
811
|
+
if len(self._held) <= len(self._paths) - self._cache_flock_count:
|
|
812
|
+
self._note_cache_flocks_released()
|
|
813
|
+
return
|
|
814
|
+
keep = len(self._paths) - self._cache_flock_count
|
|
815
|
+
# `release_cache_writer_flocks` releases the list it is given in REVERSE
|
|
816
|
+
# order, which is what makes this a suffix release rather than an
|
|
817
|
+
# arbitrary one.
|
|
818
|
+
release_cache_writer_flocks(self._held[keep:])
|
|
819
|
+
del self._held[keep:]
|
|
820
|
+
self._note_cache_flocks_released()
|
|
821
|
+
|
|
822
|
+
def release_all(self) -> None:
|
|
823
|
+
from _lib_cache_writer_lock import release_cache_writer_flocks
|
|
824
|
+
|
|
825
|
+
release_cache_writer_flocks(self._held)
|
|
826
|
+
self._held.clear()
|
|
827
|
+
self._note_cache_flocks_released()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@contextlib.contextmanager
|
|
831
|
+
def codex_attribution_apply_locks(*, timeout: float = _REDERIVE_LOCK_TIMEOUT_SECONDS):
|
|
832
|
+
"""Acquire the #500 §8 apply order and yield an ordered-partial-release owner.
|
|
833
|
+
|
|
834
|
+
The order is ``_rederive_locks``' applying order extended by the Codex
|
|
835
|
+
provider flock, exactly as the spec states:
|
|
836
|
+
|
|
837
|
+
1. stats maintenance, exclusive
|
|
838
|
+
2. cache maintenance, shared
|
|
839
|
+
3. ``journal.ingest.lock``, exclusive
|
|
840
|
+
4. global ``cache.db.lock``, exclusive
|
|
841
|
+
5. the Codex provider flock, exclusive
|
|
842
|
+
|
|
843
|
+
``owner.release_cache_flocks()`` drops 5 then 4 and retains 1-3, so the
|
|
844
|
+
stats transaction runs with the ingest lock still held while every cache
|
|
845
|
+
writer is free again.
|
|
846
|
+
|
|
847
|
+
Do not ``os.fork()`` and do not start a thread inside this block. The
|
|
848
|
+
lock-order guard behind ``release_cache_flocks()`` is a ContextVar counter,
|
|
849
|
+
so a child context starts from a COPY of this one and never observes the
|
|
850
|
+
later release — a forked or spawned worker would either refuse a stats
|
|
851
|
+
ingest that is legitimately unblocked, or, if it were created before the
|
|
852
|
+
acquisition, run one while the cache flocks are still held. The flocks
|
|
853
|
+
themselves are process-wide and unaffected; the guard is what is advisory
|
|
854
|
+
across a context boundary.
|
|
855
|
+
"""
|
|
856
|
+
from _lib_cache_writer_lock import acquire_ordered_flocks
|
|
857
|
+
|
|
858
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
859
|
+
locks = [
|
|
860
|
+
(_cctally_core.STATS_LOCK_MAINTENANCE_PATH, fcntl.LOCK_EX),
|
|
861
|
+
(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
|
|
862
|
+
(_cctally_core.JOURNAL_INGEST_LOCK_PATH, fcntl.LOCK_EX),
|
|
863
|
+
(_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
|
|
864
|
+
(_cctally_core.CACHE_LOCK_CODEX_PATH, fcntl.LOCK_EX),
|
|
865
|
+
]
|
|
866
|
+
held = acquire_ordered_flocks(locks, timeout=timeout)
|
|
867
|
+
if held is None:
|
|
868
|
+
raise RederiveBusy(
|
|
869
|
+
"another database sync or maintenance operation holds the "
|
|
870
|
+
"attribution apply lock set; retry shortly"
|
|
871
|
+
)
|
|
872
|
+
owner = _OrderedApplyLockOwner(
|
|
873
|
+
[path for path, _mode in locks], held, cache_flock_count=2)
|
|
874
|
+
# #386, the same declaration `_rederive_locks` makes: record the stats
|
|
875
|
+
# maintenance hold so a nested live `open_db()` does not request SHARED on a
|
|
876
|
+
# second fd of this same file and self-deadlock, and declare the sanctioned
|
|
877
|
+
# write regime plus the ingest hold so a heal reached from in here
|
|
878
|
+
# recognises itself as the serialized writer.
|
|
879
|
+
import _cctally_store
|
|
880
|
+
|
|
881
|
+
_cctally_core.note_stats_maintenance_acquired()
|
|
882
|
+
try:
|
|
883
|
+
with _cctally_store.stats_write_scope(
|
|
884
|
+
"codex-window-attribution", ingest_lock=True,
|
|
885
|
+
):
|
|
886
|
+
yield owner
|
|
887
|
+
finally:
|
|
888
|
+
_cctally_core.note_stats_maintenance_released()
|
|
889
|
+
owner.release_all()
|
|
890
|
+
|
|
891
|
+
|
|
740
892
|
def preview_db_rederive(
|
|
741
893
|
family: str,
|
|
742
894
|
*,
|
|
@@ -99,7 +99,8 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
|
|
|
99
99
|
entries.output_tokens, entries.reasoning_output_tokens,
|
|
100
100
|
entries.total_tokens, threads.cwd, threads.git_json,
|
|
101
101
|
threads.conversation_key AS joined_conversation_key,
|
|
102
|
-
threads.source_root_key AS joined_source_root_key
|
|
102
|
+
threads.source_root_key AS joined_source_root_key,
|
|
103
|
+
entries.id AS cache_entry_id
|
|
103
104
|
FROM codex_session_entries AS entries
|
|
104
105
|
INDEXED BY idx_codex_entries_ts_root_conversation
|
|
105
106
|
LEFT JOIN codex_conversation_threads AS threads
|
|
@@ -112,6 +113,21 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
|
|
|
112
113
|
"""
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
def _qualified_codex_path_entries_sql(identity_count: int) -> str:
|
|
117
|
+
"""Return the qualified read constrained to physical cache identities."""
|
|
118
|
+
predicates = " OR ".join(
|
|
119
|
+
"(entries.source_root_key = ? AND entries.source_path = ?)"
|
|
120
|
+
for _ in range(identity_count)
|
|
121
|
+
)
|
|
122
|
+
return _QUALIFIED_CODEX_ENTRIES_SQL.replace(
|
|
123
|
+
"INDEXED BY idx_codex_entries_ts_root_conversation",
|
|
124
|
+
"INDEXED BY idx_codex_entries_root_path",
|
|
125
|
+
).replace(
|
|
126
|
+
" ORDER BY entries.timestamp_utc ASC",
|
|
127
|
+
f" AND ({predicates})\n ORDER BY entries.timestamp_utc ASC",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
115
131
|
_INHERITED_CODEX_PROJECT_METADATA_SQL = """
|
|
116
132
|
SELECT files.source_root_key, files.path, inherited.cwd, inherited.git_json
|
|
117
133
|
FROM codex_session_files AS files
|
|
@@ -124,6 +140,21 @@ _INHERITED_CODEX_PROJECT_METADATA_SQL = """
|
|
|
124
140
|
"""
|
|
125
141
|
|
|
126
142
|
|
|
143
|
+
def _inherited_codex_path_metadata_sql(identity_count: int) -> str:
|
|
144
|
+
"""Constrain inherited file-alias metadata to dirty physical paths."""
|
|
145
|
+
if identity_count < 1:
|
|
146
|
+
raise ValueError("identity_count must be positive")
|
|
147
|
+
predicates = " OR ".join(
|
|
148
|
+
"(files.source_root_key = ? AND files.path = ?)"
|
|
149
|
+
for _ in range(identity_count)
|
|
150
|
+
)
|
|
151
|
+
return _INHERITED_CODEX_PROJECT_METADATA_SQL.replace(
|
|
152
|
+
" WHERE files.last_native_thread_id IS NOT NULL",
|
|
153
|
+
f" WHERE ({predicates})\n"
|
|
154
|
+
" AND files.last_native_thread_id IS NOT NULL",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
127
158
|
_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
128
159
|
SELECT timestamp_utc, source_root_key, conversation_key, model,
|
|
129
160
|
input_tokens, cached_input_tokens, output_tokens,
|
|
@@ -458,6 +489,7 @@ def load_qualified_codex_entries(
|
|
|
458
489
|
sync: bool = True,
|
|
459
490
|
group: str = "git-root",
|
|
460
491
|
cache_conn: sqlite3.Connection | None = None,
|
|
492
|
+
source_identities: Iterable[tuple[str, str]] | None = None,
|
|
461
493
|
) -> tuple[QualifiedCodexEntry, ...]:
|
|
462
494
|
"""Load exactly one bounded, root-qualified Codex accounting read.
|
|
463
495
|
|
|
@@ -475,6 +507,15 @@ def load_qualified_codex_entries(
|
|
|
475
507
|
if cache_conn is not None and sync:
|
|
476
508
|
raise ValueError("cache_conn requires sync=False")
|
|
477
509
|
|
|
510
|
+
identities: tuple[tuple[str, str], ...] | None = None
|
|
511
|
+
if source_identities is not None:
|
|
512
|
+
identities = tuple(sorted({
|
|
513
|
+
(str(root), str(path)) for root, path in source_identities
|
|
514
|
+
if str(root) and str(path)
|
|
515
|
+
}))
|
|
516
|
+
if not identities:
|
|
517
|
+
return ()
|
|
518
|
+
|
|
478
519
|
c = _cctally()
|
|
479
520
|
owns_conn = cache_conn is None
|
|
480
521
|
if owns_conn:
|
|
@@ -496,13 +537,25 @@ def load_qualified_codex_entries(
|
|
|
496
537
|
if stats.lock_contended:
|
|
497
538
|
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
|
|
498
539
|
conn.row_factory = sqlite3.Row
|
|
499
|
-
|
|
500
|
-
_QUALIFIED_CODEX_ENTRIES_SQL
|
|
501
|
-
|
|
502
|
-
)
|
|
540
|
+
sql = (
|
|
541
|
+
_QUALIFIED_CODEX_ENTRIES_SQL if identities is None
|
|
542
|
+
else _qualified_codex_path_entries_sql(len(identities))
|
|
543
|
+
)
|
|
544
|
+
params: tuple[object, ...] = (
|
|
545
|
+
start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(),
|
|
546
|
+
*(value for identity in (identities or ()) for value in identity),
|
|
547
|
+
)
|
|
548
|
+
rows = tuple(conn.execute(sql, params))
|
|
503
549
|
inherited_metadata: dict[tuple[str, str], sqlite3.Row] = {}
|
|
504
550
|
if _supports_native_file_aliases(conn):
|
|
505
|
-
|
|
551
|
+
inherited_sql = (
|
|
552
|
+
_INHERITED_CODEX_PROJECT_METADATA_SQL if identities is None
|
|
553
|
+
else _inherited_codex_path_metadata_sql(len(identities))
|
|
554
|
+
)
|
|
555
|
+
inherited_params = tuple(
|
|
556
|
+
value for identity in (identities or ()) for value in identity
|
|
557
|
+
)
|
|
558
|
+
for inherited in conn.execute(inherited_sql, inherited_params):
|
|
506
559
|
identity = (str(inherited["source_root_key"] or ""), str(inherited["path"] or ""))
|
|
507
560
|
if all(identity):
|
|
508
561
|
inherited_metadata.setdefault(identity, inherited)
|
|
@@ -580,6 +633,7 @@ def load_qualified_codex_entries(
|
|
|
580
633
|
# NULL ≡ unattributed — the cache-read rule (#416 §5.2). Carried,
|
|
581
634
|
# never grouped; see the field's comment on `QualifiedCodexEntry`.
|
|
582
635
|
account_key=str(row["account_key"] or _lib_accounts.UNATTRIBUTED),
|
|
636
|
+
cache_entry_id=int(row["cache_entry_id"]),
|
|
583
637
|
))
|
|
584
638
|
return tuple(result)
|
|
585
639
|
|