cctally 1.80.4 → 1.82.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 +32 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +434 -48
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +56 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_pricing.py +27 -3
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
package/bin/_cctally_diff.py
CHANGED
|
@@ -220,6 +220,13 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
220
220
|
if args.min_delta_pct is not None:
|
|
221
221
|
threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
|
|
222
222
|
|
|
223
|
+
# #341 --account: resolve the render filter (provider=claude; fail closed
|
|
224
|
+
# with exit 3 when the entry cache is unavailable). None = merged.
|
|
225
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
226
|
+
args, "claude", needs_cache=True)
|
|
227
|
+
if acct_exit is not None:
|
|
228
|
+
return acct_exit
|
|
229
|
+
|
|
223
230
|
try:
|
|
224
231
|
result = dk._build_diff_result(
|
|
225
232
|
window_a, window_b,
|
|
@@ -229,6 +236,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
229
236
|
allow_mismatch=bool(args.allow_mismatch),
|
|
230
237
|
skip_sync=not bool(args.sync),
|
|
231
238
|
top=args.top,
|
|
239
|
+
account_key=acct_key,
|
|
232
240
|
)
|
|
233
241
|
except dk.WindowMismatchError as exc:
|
|
234
242
|
print(f"diff: {exc}", file=sys.stderr)
|
|
@@ -250,6 +258,7 @@ def cmd_diff(args: argparse.Namespace) -> int:
|
|
|
250
258
|
|
|
251
259
|
if args.emit_json:
|
|
252
260
|
payload = dk._diff_to_json_payload(result, options=options)
|
|
261
|
+
payload.update(c.account_json_fields(acct_key)) # #341 R8 decoration
|
|
253
262
|
sink = getattr(args, "_source_result_sink", None)
|
|
254
263
|
if sink is not None:
|
|
255
264
|
sink(payload)
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -183,11 +183,159 @@ def _codex_lifecycle_activity_24h(
|
|
|
183
183
|
return records
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _gather_accounts_state(now_utc: "dt.datetime") -> dict:
|
|
187
|
+
"""Best-effort account-attribution state for the doctor `accounts.*` legs
|
|
188
|
+
(#341). Never raises: identity + registry reads are read-only and each guard
|
|
189
|
+
swallows OperationalError so a legacy / pre-account stats.db degrades to the
|
|
190
|
+
empty (all-OK) shape."""
|
|
191
|
+
state: dict = {
|
|
192
|
+
"claude_identity_status": None, "claude_email": None,
|
|
193
|
+
"real_account_count": 0, "by_provider": {}, "missing_provider": 0,
|
|
194
|
+
"freshest_last_seen_age_s": None,
|
|
195
|
+
"recent_attributed": 0, "recent_unattributed": 0,
|
|
196
|
+
}
|
|
197
|
+
try:
|
|
198
|
+
ident = _cctally_core._resolve_active_claude_identity()
|
|
199
|
+
state["claude_identity_status"] = ident.get("status")
|
|
200
|
+
state["claude_email"] = ident.get("email")
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
if not _cctally_core.DB_PATH.exists():
|
|
204
|
+
return state
|
|
205
|
+
try:
|
|
206
|
+
conn = sqlite3.connect(f"file:{_cctally_core.DB_PATH}?mode=ro", uri=True)
|
|
207
|
+
except sqlite3.Error:
|
|
208
|
+
return state
|
|
209
|
+
try:
|
|
210
|
+
try:
|
|
211
|
+
rows = conn.execute(
|
|
212
|
+
"SELECT provider, account_key, last_seen_utc FROM accounts"
|
|
213
|
+
).fetchall()
|
|
214
|
+
except sqlite3.DatabaseError:
|
|
215
|
+
# OperationalError (table missing) OR a corrupt / non-DB file
|
|
216
|
+
# ("file is not a database") -> degrade to the empty (all-OK) shape.
|
|
217
|
+
rows = []
|
|
218
|
+
by_provider: dict = {}
|
|
219
|
+
missing = 0
|
|
220
|
+
freshest = None
|
|
221
|
+
for provider, account_key, last_seen in rows:
|
|
222
|
+
if not provider:
|
|
223
|
+
missing += 1
|
|
224
|
+
if account_key and account_key != "unattributed":
|
|
225
|
+
p = provider or "?"
|
|
226
|
+
by_provider[p] = by_provider.get(p, 0) + 1
|
|
227
|
+
if last_seen:
|
|
228
|
+
try:
|
|
229
|
+
ls = parse_iso_datetime(
|
|
230
|
+
last_seen, "accounts.last_seen_utc"
|
|
231
|
+
).astimezone(dt.timezone.utc)
|
|
232
|
+
if freshest is None or ls > freshest:
|
|
233
|
+
freshest = ls
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
state["by_provider"] = by_provider
|
|
237
|
+
state["real_account_count"] = sum(by_provider.values())
|
|
238
|
+
state["missing_provider"] = missing
|
|
239
|
+
if freshest is not None:
|
|
240
|
+
state["freshest_last_seen_age_s"] = max(
|
|
241
|
+
0, int((now_utc - freshest).total_seconds()))
|
|
242
|
+
cutoff = (now_utc - dt.timedelta(days=7)).astimezone(
|
|
243
|
+
dt.timezone.utc).isoformat()
|
|
244
|
+
try:
|
|
245
|
+
for account_key, cnt in conn.execute(
|
|
246
|
+
"SELECT account_key, COUNT(*) FROM weekly_usage_snapshots "
|
|
247
|
+
"WHERE captured_at_utc >= ? GROUP BY account_key", (cutoff,)
|
|
248
|
+
).fetchall():
|
|
249
|
+
if account_key and account_key != "unattributed":
|
|
250
|
+
state["recent_attributed"] += int(cnt)
|
|
251
|
+
else:
|
|
252
|
+
state["recent_unattributed"] += int(cnt)
|
|
253
|
+
except sqlite3.DatabaseError:
|
|
254
|
+
pass
|
|
255
|
+
finally:
|
|
256
|
+
conn.close()
|
|
257
|
+
return state
|
|
258
|
+
|
|
259
|
+
|
|
186
260
|
def doctor_gather_state(
|
|
187
261
|
*,
|
|
188
262
|
now_utc: "dt.datetime | None" = None,
|
|
189
263
|
runtime_bind: "str | None" = None,
|
|
190
264
|
deep: bool = False,
|
|
265
|
+
):
|
|
266
|
+
"""Gather doctor state while excluding cache-family replacement.
|
|
267
|
+
|
|
268
|
+
Doctor is read-only, so it never creates the maintenance lock. When the
|
|
269
|
+
lock already exists it holds maintenance-shared from the marker check
|
|
270
|
+
through every cache probe. A live/stale marker or a pending quarantine
|
|
271
|
+
suppresses all raw SQLite cache opens; when a cache exists but its lock is
|
|
272
|
+
absent, probes also degrade rather than racing a newly starting repair.
|
|
273
|
+
"""
|
|
274
|
+
cache_lock = None
|
|
275
|
+
cache_probe_allowed = not _cctally_core.CACHE_DB_PATH.exists()
|
|
276
|
+
cache_repair_marker = {
|
|
277
|
+
"exists": False,
|
|
278
|
+
"live": None,
|
|
279
|
+
"reason": None,
|
|
280
|
+
}
|
|
281
|
+
try:
|
|
282
|
+
lock_path = _cctally_core.CACHE_LOCK_MAINTENANCE_PATH
|
|
283
|
+
if lock_path.exists():
|
|
284
|
+
cache_lock = open(lock_path, "r")
|
|
285
|
+
fcntl.flock(cache_lock, fcntl.LOCK_SH)
|
|
286
|
+
cache_probe_allowed = True
|
|
287
|
+
|
|
288
|
+
c = _cctally()
|
|
289
|
+
db_mod = c._load_sibling("_cctally_db")
|
|
290
|
+
repair_marker = db_mod._repair_marker_path(
|
|
291
|
+
_cctally_core.CACHE_DB_PATH
|
|
292
|
+
)
|
|
293
|
+
pending = db_mod._quarantine_pending_path(
|
|
294
|
+
_cctally_core.CACHE_DB_PATH
|
|
295
|
+
)
|
|
296
|
+
if repair_marker.exists():
|
|
297
|
+
live, reason = db_mod._repair_marker_is_live(repair_marker)
|
|
298
|
+
cache_repair_marker = {
|
|
299
|
+
"exists": True,
|
|
300
|
+
"live": live,
|
|
301
|
+
"reason": reason,
|
|
302
|
+
}
|
|
303
|
+
cache_probe_allowed = False
|
|
304
|
+
elif pending.exists():
|
|
305
|
+
cache_repair_marker = {
|
|
306
|
+
"exists": True,
|
|
307
|
+
"live": False,
|
|
308
|
+
"reason": "interrupted quarantine is pending",
|
|
309
|
+
}
|
|
310
|
+
cache_probe_allowed = False
|
|
311
|
+
|
|
312
|
+
if not cache_probe_allowed and cache_lock is not None:
|
|
313
|
+
fcntl.flock(cache_lock, fcntl.LOCK_UN)
|
|
314
|
+
cache_lock.close()
|
|
315
|
+
cache_lock = None
|
|
316
|
+
|
|
317
|
+
return _doctor_gather_state_impl(
|
|
318
|
+
now_utc=now_utc,
|
|
319
|
+
runtime_bind=runtime_bind,
|
|
320
|
+
deep=deep,
|
|
321
|
+
_cache_probe_allowed=cache_probe_allowed,
|
|
322
|
+
_cache_repair_marker=cache_repair_marker,
|
|
323
|
+
)
|
|
324
|
+
finally:
|
|
325
|
+
if cache_lock is not None:
|
|
326
|
+
try:
|
|
327
|
+
fcntl.flock(cache_lock, fcntl.LOCK_UN)
|
|
328
|
+
finally:
|
|
329
|
+
cache_lock.close()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _doctor_gather_state_impl(
|
|
333
|
+
*,
|
|
334
|
+
now_utc: "dt.datetime | None" = None,
|
|
335
|
+
runtime_bind: "str | None" = None,
|
|
336
|
+
deep: bool = False,
|
|
337
|
+
_cache_probe_allowed: bool,
|
|
338
|
+
_cache_repair_marker: dict,
|
|
191
339
|
):
|
|
192
340
|
"""I/O chokepoint for `cctally doctor` (spec §7.2).
|
|
193
341
|
|
|
@@ -313,14 +461,32 @@ def doctor_gather_state(
|
|
|
313
461
|
# uv==1000 as HEALTHY (not a #145 version-ahead FAIL). registry_size stays the
|
|
314
462
|
# frozen legacy head (13) and serves as the legacy-range boundary.
|
|
315
463
|
stats_db_status["epoch"] = _cctally_core.STATS_INDEX_EPOCH
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
464
|
+
if _cache_probe_allowed:
|
|
465
|
+
try:
|
|
466
|
+
cache_db_status = c._db_status_for(
|
|
467
|
+
_cctally_core.CACHE_DB_PATH,
|
|
468
|
+
c._CACHE_MIGRATIONS,
|
|
469
|
+
"cache.db",
|
|
470
|
+
)
|
|
471
|
+
if not _cctally_core.CACHE_DB_PATH.exists():
|
|
472
|
+
cache_db_status["_file_exists"] = False
|
|
473
|
+
except sqlite3.Error as exc:
|
|
474
|
+
cache_db_status = {
|
|
475
|
+
"path": str(_cctally_core.CACHE_DB_PATH),
|
|
476
|
+
"user_version": 0,
|
|
477
|
+
"registry_size": len(c._CACHE_MIGRATIONS),
|
|
478
|
+
"migrations": [],
|
|
479
|
+
"_open_error": str(exc),
|
|
480
|
+
}
|
|
481
|
+
else:
|
|
482
|
+
cache_db_status = {
|
|
483
|
+
"path": str(_cctally_core.CACHE_DB_PATH),
|
|
484
|
+
"user_version": 0,
|
|
485
|
+
"registry_size": len(c._CACHE_MIGRATIONS),
|
|
486
|
+
"migrations": [],
|
|
487
|
+
"_open_error": "cache maintenance excludes read probes",
|
|
488
|
+
}
|
|
489
|
+
cache_repair_marker = _cache_repair_marker
|
|
324
490
|
|
|
325
491
|
# ── Data freshness ───────────────────────────────────────────────
|
|
326
492
|
latest_snapshot_at = None
|
|
@@ -427,7 +593,7 @@ def doctor_gather_state(
|
|
|
427
593
|
cache_db_page_count = None
|
|
428
594
|
cache_db_freelist_count = None
|
|
429
595
|
try:
|
|
430
|
-
if _cctally_core.CACHE_DB_PATH.exists():
|
|
596
|
+
if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
|
|
431
597
|
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
432
598
|
try:
|
|
433
599
|
try:
|
|
@@ -562,7 +728,7 @@ def doctor_gather_state(
|
|
|
562
728
|
codex_project_metadata_health = None
|
|
563
729
|
codex_project_metadata_error = None
|
|
564
730
|
try:
|
|
565
|
-
if _cctally_core.CACHE_DB_PATH.exists():
|
|
731
|
+
if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
|
|
566
732
|
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
567
733
|
try:
|
|
568
734
|
row = conn.execute(
|
|
@@ -619,7 +785,11 @@ def doctor_gather_state(
|
|
|
619
785
|
# inspector supplies the exact owned-hook state without exposing paths.
|
|
620
786
|
codex_quota_windows: list[dict] = []
|
|
621
787
|
try:
|
|
622
|
-
observations =
|
|
788
|
+
observations = (
|
|
789
|
+
c._cctally_quota.load_codex_quota_observations()
|
|
790
|
+
if _cache_probe_allowed
|
|
791
|
+
else ()
|
|
792
|
+
)
|
|
623
793
|
by_identity: dict[object, list] = {}
|
|
624
794
|
for observation in observations:
|
|
625
795
|
by_identity.setdefault(observation.identity, []).append(observation)
|
|
@@ -672,7 +842,7 @@ def doctor_gather_state(
|
|
|
672
842
|
# ── Parse health (#279 S2 F5a) ───────────────────────────────────
|
|
673
843
|
parse_health_claude = parse_health_codex = None
|
|
674
844
|
try:
|
|
675
|
-
if _cctally_core.CACHE_DB_PATH.exists():
|
|
845
|
+
if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
|
|
676
846
|
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
677
847
|
try:
|
|
678
848
|
for _key in ("parse_health_claude", "parse_health_codex"):
|
|
@@ -702,7 +872,10 @@ def doctor_gather_state(
|
|
|
702
872
|
("cache", _cctally_core.CACHE_DB_PATH)):
|
|
703
873
|
_result = None
|
|
704
874
|
try:
|
|
705
|
-
if
|
|
875
|
+
if (
|
|
876
|
+
_path.exists()
|
|
877
|
+
and (_label != "cache" or _cache_probe_allowed)
|
|
878
|
+
):
|
|
706
879
|
_conn = sqlite3.connect(str(_path))
|
|
707
880
|
try:
|
|
708
881
|
_row = _conn.execute(
|
|
@@ -871,19 +1044,18 @@ def doctor_gather_state(
|
|
|
871
1044
|
# the rest of the report is unaffected — same posture as the cache reads
|
|
872
1045
|
# above. `_pricing_observed_models` honors the no-mutation contract.
|
|
873
1046
|
pricing_coverage = None
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
pricing_coverage = None
|
|
1047
|
+
if _cache_probe_allowed:
|
|
1048
|
+
try:
|
|
1049
|
+
observed = c._pricing_observed_models(now_utc)
|
|
1050
|
+
# Detection-only: pass warn=False so finding an unpriced model here
|
|
1051
|
+
# does NOT fire the cost-engine's unknown-model warning.
|
|
1052
|
+
pricing_coverage = c.classify_coverage(
|
|
1053
|
+
observed,
|
|
1054
|
+
lambda m: c._resolve_model_pricing(m, warn=False),
|
|
1055
|
+
c._is_codex_fallback,
|
|
1056
|
+
)
|
|
1057
|
+
except Exception:
|
|
1058
|
+
pricing_coverage = None
|
|
887
1059
|
|
|
888
1060
|
# ── Meta ─────────────────────────────────────────────────────────
|
|
889
1061
|
# ── Journal (DB journal redesign §9) ─────────────────────────────
|
|
@@ -1100,6 +1272,9 @@ def doctor_gather_state(
|
|
|
1100
1272
|
journal_hw_segment=journal_hw_segment,
|
|
1101
1273
|
journal_cursor_segment=journal_cursor_segment,
|
|
1102
1274
|
journal_heal_incidents=journal_heal_incidents,
|
|
1275
|
+
# Multi-account attribution legs (#341).
|
|
1276
|
+
accounts_state=_gather_accounts_state(now_utc),
|
|
1277
|
+
cache_repair_marker=cache_repair_marker,
|
|
1103
1278
|
)
|
|
1104
1279
|
|
|
1105
1280
|
|