cctally 1.107.0 → 1.108.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/bin/_cctally_alerts.py +147 -6
  3. package/bin/_cctally_core.py +298 -2
  4. package/bin/_cctally_dashboard.py +8 -20
  5. package/bin/_cctally_dashboard_envelope.py +103 -12
  6. package/bin/_cctally_diff.py +15 -8
  7. package/bin/_cctally_doctor.py +71 -3
  8. package/bin/_cctally_forecast.py +31 -6
  9. package/bin/_cctally_journal.py +280 -28
  10. package/bin/_cctally_parser.py +9 -5
  11. package/bin/_cctally_percent_breakdown.py +278 -12
  12. package/bin/_cctally_quota.py +16 -20
  13. package/bin/_cctally_quota_model.py +156 -15
  14. package/bin/_cctally_record.py +416 -217
  15. package/bin/_cctally_rederive.py +0 -2
  16. package/bin/_cctally_setup.py +272 -110
  17. package/bin/_cctally_statusline.py +30 -1
  18. package/bin/_cctally_tui.py +28 -25
  19. package/bin/_cctally_weekrefs.py +315 -102
  20. package/bin/_lib_alerts_payload.py +48 -1
  21. package/bin/_lib_codex_hooks.py +694 -83
  22. package/bin/_lib_diff_kernel.py +33 -10
  23. package/bin/_lib_doctor.py +172 -24
  24. package/bin/_lib_journal.py +64 -0
  25. package/bin/_lib_meter_rate_change.py +132 -3
  26. package/bin/_lib_pricing.py +22 -5
  27. package/bin/_lib_pricing_check.py +5 -4
  28. package/bin/_lib_subscription_weeks.py +140 -63
  29. package/bin/cctally +14 -3
  30. package/dashboard/static/assets/{ConversationsView-BHbw2W1l.js → ConversationsView-BOSaBRtu.js} +12 -12
  31. package/dashboard/static/assets/{DoctorModal-DcyMPwvn.js → DoctorModal-CWn3U4Wl.js} +1 -1
  32. package/dashboard/static/assets/ModalRoot-SR070V6c.js +1 -0
  33. package/dashboard/static/assets/{ProjectsDrillPanel-ecN8oCwv.js → ProjectsDrillPanel-QLI9i5mZ.js} +1 -1
  34. package/dashboard/static/assets/{SourceDetailModal-CUwdD7_v.js → SourceDetailModal-pv0wlFxl.js} +1 -1
  35. package/dashboard/static/assets/{UpdateModal-CKlFE0ER.js → UpdateModal-D3m8GG6V.js} +3 -3
  36. package/dashboard/static/assets/index-BHu4mxd8.css +1 -0
  37. package/dashboard/static/assets/index-CIWsbux3.js +13 -0
  38. package/dashboard/static/assets/{outlineNavigation-CVse0Hj9.js → outlineNavigation-CJvKqmLV.js} +1 -1
  39. package/dashboard/static/assets/useKeymap-ffqsS5G0.js +1 -0
  40. package/dashboard/static/dashboard.html +3 -3
  41. package/package.json +1 -1
  42. package/dashboard/static/assets/ModalRoot-BYV-99Rq.js +0 -1
  43. package/dashboard/static/assets/index-D8svRv_9.js +0 -13
  44. package/dashboard/static/assets/index-klO46NcU.css +0 -1
  45. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +0 -1
@@ -40,6 +40,7 @@ from __future__ import annotations
40
40
  import bisect
41
41
  import datetime as dt
42
42
  import importlib.util as _ilu
43
+ import json
43
44
  import os
44
45
  import sqlite3
45
46
  import sys
@@ -383,14 +384,23 @@ def _envelope_rows_weekly(
383
384
  # collide on the duplicate (week, threshold) pair. Older clients
384
385
  # tolerate longer ids — the id is opaque to them; only the React
385
386
  # key uniqueness invariant matters.
387
+ # The LEFT JOIN resolves the segment number into the instant its cycle
388
+ # began (#750 S3). ``reset_event_id`` is a ``week_reset_events.id``, whose
389
+ # AUTOINCREMENT never issues 0, so the pre-credit sentinel matches no row
390
+ # and the mapper falls back to the week's own start below. The join is on
391
+ # the id alone: the id IS the identity the milestone recorded, and adding
392
+ # an account predicate could only turn a legitimately-stamped row into a
393
+ # silent NULL.
386
394
  rows = conn.execute(
387
395
  f"""
388
- SELECT week_start_date, week_start_at, percent_threshold,
389
- captured_at_utc, alerted_at, cumulative_cost_usd,
390
- reset_event_id, account_key
391
- FROM {descriptor.milestone_table}
392
- WHERE alerted_at IS NOT NULL
393
- ORDER BY {_CANON_ALERTED_AT} DESC
396
+ SELECT m.week_start_date, m.week_start_at, m.percent_threshold,
397
+ m.captured_at_utc, m.alerted_at, m.cumulative_cost_usd,
398
+ m.reset_event_id, m.account_key,
399
+ e.effective_reset_at_utc
400
+ FROM {descriptor.milestone_table} m
401
+ LEFT JOIN week_reset_events e ON e.id = m.reset_event_id
402
+ WHERE m.alerted_at IS NOT NULL
403
+ ORDER BY {_CANON_ALERTED_AT_M} DESC
394
404
  LIMIT ?
395
405
  """,
396
406
  (limit,),
@@ -420,6 +430,20 @@ def _envelope_rows_weekly(
420
430
  # key stays on the wire and the reader degrades to day
421
431
  # granularity instead of inventing a clock reading.
422
432
  "week_start_at": r["week_start_at"] or "",
433
+ # The start instant of the BILLING CYCLE this crossing
434
+ # belongs to (#750 S3). A credit ends one cycle and begins
435
+ # another without moving either week boundary, so a week
436
+ # credited twice publishes three rows carrying one
437
+ # `week_start_date` and the week alone identifies none of
438
+ # them. Segment 0 has no reset event and its cycle begins
439
+ # with the week. Empty string when the row retains neither
440
+ # instant, mirroring `week_start_at` above. This is a
441
+ # SEPARATE field on purpose: `week_start_at` is what both
442
+ # scope kernels add seven days to, and the week's end does
443
+ # not move when a cycle inside it does.
444
+ "cycle_start_at": (
445
+ r["effective_reset_at_utc"] or r["week_start_at"] or ""
446
+ ),
423
447
  "cumulative_cost_usd": cumulative,
424
448
  "dollars_per_percent": dpp,
425
449
  # Round-3: parallel to the 5h context block below — both
@@ -850,22 +874,53 @@ def _build_meter_rate_change_array(
850
874
  SCOPE (the account, decorated under R8 exactly as the alert rows are).
851
875
  """
852
876
  account_fields = _alert_account_resolver(conn)
877
+ # #747: the stored `severity` column is unconstrained, so an older writer,
878
+ # a hand-repaired row or a future kernel can put a token here that no
879
+ # `RateChangeSeverity` border rule matches; such a row renders on the base
880
+ # amber border with nothing reporting it. Clamp to `info` through the
881
+ # kernel's own tuple — the same rule `_cctally_alerts._dispatch_alert_
882
+ # notification` already applies on the notifier path, applied at the second
883
+ # site rather than written out a third time.
884
+ #
885
+ # Clamped, not asserted: an assertion in the envelope builder would let one
886
+ # malformed stored row break the entire payload, which is a worse outcome
887
+ # than rendering that row at `info`.
888
+ #
889
+ # An unresolvable kernel degrades to NO clamp, which is the behaviour that
890
+ # shipped before this change. Degrading to an empty vocabulary instead
891
+ # would send every row — including every correct one — to `info`.
892
+ try:
893
+ _severities = frozenset(
894
+ sys.modules["cctally"]._load_sibling(
895
+ "_lib_meter_rate_change").RATE_CHANGE_SEVERITIES)
896
+ except Exception: # noqa: BLE001
897
+ _severities = None
853
898
  try:
854
899
  rows = conn.execute(
855
900
  "SELECT provider, account_key, effective_from,"
856
901
  " previous_units_per_point, new_units_per_point, severity,"
857
- " detected_at_utc, created_at_utc"
902
+ " detected_at_utc, created_at_utc,"
903
+ " withholding_status, detector_input_causes,"
904
+ " composition_provenance, baseline_withheld_days"
858
905
  " FROM meter_rate_change_events"
859
906
  " ORDER BY unixepoch(effective_from) DESC, id DESC"
860
907
  " LIMIT ?", (int(limit),)).fetchall()
861
908
  except sqlite3.Error:
862
- # A store predating epoch 1011 has no such table, and the rebuild
863
- # that creates it is deferred to a background worker. An empty array
864
- # renders as "no change recorded", which is the truth on that store.
909
+ # A store predating epoch 1012 does not have the shape this SELECT
910
+ # asks for: below 1011 there is no `meter_rate_change_events` table at
911
+ # all, and at 1011 the table exists but the four #690 disclosure
912
+ # columns do not. Both raise `sqlite3.Error` and both are answered the
913
+ # same way, because the rebuild that supplies either is deferred to a
914
+ # background worker. An empty array renders as "no change recorded",
915
+ # which is the truth on that store, rather than emptying the envelope.
865
916
  return []
866
917
  out: list[dict] = []
867
918
  for (provider, account_key, effective_from, previous, new, severity,
868
- detected_at, created_at) in rows:
919
+ detected_at, created_at, status, causes, provenance,
920
+ baseline_days) in rows:
921
+ severity_token = str(severity or "info")
922
+ if _severities is not None and severity_token not in _severities:
923
+ severity_token = "info"
869
924
  entry = {
870
925
  # Opaque React key. It is never parsed — the same contract the
871
926
  # alert `id` carries.
@@ -873,19 +928,55 @@ def _build_meter_rate_change_array(
873
928
  "family": "meter_rate_change",
874
929
  "provider": str(provider),
875
930
  "owner": str(provider),
876
- "severity": str(severity or "info"),
931
+ "severity": severity_token,
877
932
  "effective_from": str(effective_from),
878
933
  "detected_at": str(detected_at or created_at or ""),
879
934
  "recorded_at": str(created_at or ""),
880
935
  "previous_units_per_point": (
881
936
  None if previous is None else float(previous)),
882
937
  "new_units_per_point": None if new is None else float(new),
938
+ # #690: the disclosure evidence, published ALWAYS and nulled
939
+ # rather than dropped. `null`, `[]` and `0` are three distinct
940
+ # states the client must be able to tell apart: null means legacy
941
+ # or unrecoverable evidence, `[]` means assessed with no such
942
+ # origin, and 0 means a clean baseline. The two array columns are
943
+ # decoded from their canonical JSON rather than forwarded as
944
+ # strings, so the client receives typed arrays; a column that
945
+ # will not decode degrades to null, which is the honest "cannot
946
+ # be read" answer and never an empty measurement.
947
+ "withholding_status": (
948
+ None if status is None else str(status)),
949
+ "detector_input_causes": _decode_evidence_array(causes),
950
+ "composition_provenance": _decode_evidence_array(provenance),
951
+ "baseline_withheld_days": (
952
+ None if baseline_days is None else int(baseline_days)),
883
953
  }
884
954
  entry.update(account_fields(str(provider), account_key))
885
955
  out.append(entry)
886
956
  return out
887
957
 
888
958
 
959
+ def _decode_evidence_array(value) -> "list | None":
960
+ """One stored evidence column as a typed list, or None (#690).
961
+
962
+ The column holds a canonical JSON array of enum VALUES. Decoding here
963
+ rather than forwarding the string keeps the wire contract typed and keeps
964
+ the null-versus-empty distinction intact on the client: `null` and `[]`
965
+ are different answers and a string `"[]"` would be neither.
966
+
967
+ Anything that will not decode to a list degrades to None. That is the
968
+ honest "cannot be read" answer; returning `[]` would publish an empty
969
+ MEASUREMENT — assessed, no such origin — for a column nobody could read.
970
+ """
971
+ if value is None:
972
+ return None
973
+ try:
974
+ decoded = json.loads(value)
975
+ except (TypeError, ValueError):
976
+ return None
977
+ return decoded if isinstance(decoded, list) else None
978
+
979
+
889
980
  def _build_alerts_envelope_array(
890
981
  conn: sqlite3.Connection,
891
982
  limit: int = 100,
@@ -118,11 +118,25 @@ def cmd_diff(args: argparse.Namespace) -> int:
118
118
  # windows for Claude instead of attempting to reinterpret the original
119
119
  # tokens against its subscription-week anchor. Ordinary Claude invocations
120
120
  # keep the established anchor/parser path byte-for-byte.
121
+ # #341 --account: resolve the render filter (provider=claude; fail closed
122
+ # with exit 3 when the entry cache is unavailable). None = merged.
123
+ #
124
+ # #750 S3 B3: this resolves BEFORE the anchor, not a hundred lines after
125
+ # both windows are built. `docs/accounts-gotchas.md` states the rule — the
126
+ # account resolves before the interval is constructed — because an
127
+ # account-filtered read whose interval came from the merged boundary set
128
+ # buckets one account's dollars into a window that belongs to neither.
129
+ acct_key, acct_exit = c.resolve_account_filter(
130
+ args, "claude", needs_cache=True)
131
+ if acct_exit is not None:
132
+ return acct_exit
133
+
121
134
  supplied_windows = getattr(args, "_source_analytics_windows", None)
122
135
  if supplied_windows is None:
123
136
  # Resolve anchors (None when no snapshots exist; week tokens then
124
137
  # raise NoAnchorError in the parser).
125
- anchor_week_start, anchor_resets_at = dk._diff_resolve_anchor(now_utc)
138
+ anchor_week_start, anchor_resets_at = dk._diff_resolve_anchor(
139
+ now_utc, account_key=acct_key)
126
140
  else:
127
141
  anchor_week_start, anchor_resets_at = None, None
128
142
 
@@ -222,13 +236,6 @@ def cmd_diff(args: argparse.Namespace) -> int:
222
236
  if args.min_delta_pct is not None:
223
237
  threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
224
238
 
225
- # #341 --account: resolve the render filter (provider=claude; fail closed
226
- # with exit 3 when the entry cache is unavailable). None = merged.
227
- acct_key, acct_exit = c.resolve_account_filter(
228
- args, "claude", needs_cache=True)
229
- if acct_exit is not None:
230
- return acct_exit
231
-
232
239
  try:
233
240
  result = dk._build_diff_result(
234
241
  window_a, window_b,
@@ -31,6 +31,7 @@ import json
31
31
  import math
32
32
  import os
33
33
  import pathlib
34
+ import re
34
35
  import shutil
35
36
  import sqlite3
36
37
  import subprocess
@@ -588,6 +589,61 @@ def _codex_lifecycle_activity_24h(
588
589
  return records
589
590
 
590
591
 
592
+ _CODEX_ACCOUNT_KEY_PATTERN = re.compile(r"^[0-9a-f]{32}$")
593
+
594
+
595
+ def _codex_hook_liveness_markers(*, root_keys: set[str]) -> dict[str, dict]:
596
+ """Enumerate `<root>.last-success` / `<root>.<account>.last-success` mtimes.
597
+
598
+ Marker enumeration lives here, never in `bin/_lib_doctor.py`: the pure
599
+ kernel decides every check without touching the filesystem, the network
600
+ or the clock, and this check is not the exception.
601
+
602
+ A bare `*` glob would also admit backup-like siblings, so the account
603
+ suffix is filtered against the 32-hex `account_key` shape rather than
604
+ globbed. Account suffixes are never exposed to the report.
605
+ """
606
+ if not root_keys:
607
+ return {}
608
+ base = _cctally_core.APP_DIR / "codex-hook-tick"
609
+ records: dict[str, dict] = {
610
+ key: {"last_success_at": None, "marker_count": 0, "unavailable": False}
611
+ for key in root_keys
612
+ }
613
+ try:
614
+ names = sorted(entry.name for entry in os.scandir(base))
615
+ except FileNotFoundError:
616
+ # No marker directory yet is `never`, not `unavailable`: the hook has
617
+ # simply not run here.
618
+ return records
619
+ except OSError:
620
+ for record in records.values():
621
+ record["unavailable"] = True
622
+ return records
623
+ for name in names:
624
+ if not name.endswith(".last-success"):
625
+ continue
626
+ stem = name[: -len(".last-success")]
627
+ root_key = stem
628
+ if stem not in records:
629
+ head, _sep, suffix = stem.rpartition(".")
630
+ if not head or not _CODEX_ACCOUNT_KEY_PATTERN.match(suffix):
631
+ continue
632
+ root_key = head
633
+ if root_key not in records:
634
+ continue
635
+ try:
636
+ stamp = (base / name).stat().st_mtime
637
+ except OSError:
638
+ continue
639
+ record = records[root_key]
640
+ record["marker_count"] += 1
641
+ observed = dt.datetime.fromtimestamp(stamp, dt.timezone.utc)
642
+ if record["last_success_at"] is None or observed > record["last_success_at"]:
643
+ record["last_success_at"] = observed
644
+ return records
645
+
646
+
591
647
  def _codex_quota_verify_activity_24h(*, now_utc: "dt.datetime") -> dict:
592
648
  """Aggregate the detached `_codex-quota-verify` worker's 24h outcomes.
593
649
 
@@ -1801,18 +1857,29 @@ def _doctor_gather_state_impl(
1801
1857
 
1802
1858
  with _lib_perf.phase("doctor.codex_hooks"):
1803
1859
  codex_hook_roots: list[dict] = []
1860
+ codex_hook_liveness: dict = {}
1804
1861
  try:
1805
- codex_binary = str(c._setup_resolve_hook_target(repo_root))
1806
1862
  hook_rows = [
1807
- c._cctally_setup._codex_hook_row(root, codex_binary)
1863
+ c._cctally_setup._codex_hook_row(root)
1808
1864
  for root in c._setup_codex_hook_roots()
1809
1865
  ]
1810
1866
  codex_hook_roots = [
1811
- {"source_root_key": row["source_root_key"], "state": row["state"]}
1867
+ {
1868
+ "source_root_key": row["source_root_key"],
1869
+ "state": row["state"],
1870
+ "requires_review": row["requires_review"],
1871
+ "remediation": row["remediation"],
1872
+ }
1812
1873
  for row in sorted(hook_rows, key=lambda row: row["source_root_key"])
1813
1874
  ]
1814
1875
  except Exception:
1815
1876
  codex_hook_roots = []
1877
+ try:
1878
+ codex_hook_liveness = _codex_hook_liveness_markers(
1879
+ root_keys={row["source_root_key"] for row in codex_hook_roots},
1880
+ )
1881
+ except Exception:
1882
+ codex_hook_liveness = {}
1816
1883
 
1817
1884
  with _lib_perf.phase("doctor.codex_lifecycle"):
1818
1885
  try:
@@ -2601,6 +2668,7 @@ def _doctor_gather_state_impl(
2601
2668
  conversations_db_freelist_count=conversations_db_freelist_count,
2602
2669
  codex_quota_windows=codex_quota_windows,
2603
2670
  codex_hook_roots=codex_hook_roots,
2671
+ codex_hook_liveness=codex_hook_liveness,
2604
2672
  codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
2605
2673
  codex_quota_verify_activity=codex_quota_verify_activity,
2606
2674
  # #311: precomputed statusLine.refreshInterval classification.
@@ -2134,13 +2134,22 @@ def cmd_report(args: argparse.Namespace) -> int:
2134
2134
  # effective reset moment). Route `current_ref` through the same
2135
2135
  # override so its `week_start_at` reflects the post-credit start;
2136
2136
  # this lets the per-row match below disambiguate the synthesized
2137
- # pre-credit ref from the live post-credit ref via both
2138
- # `key` AND `week_start_at`. Order contract from
2139
- # `_apply_reset_events_to_weekrefs`: post-credit ref lands at
2140
- # index 0, pre-credit at index 1. Non-credit weeks return the
2137
+ # earlier refs from the live one via both `key` AND `week_start_at`.
2138
+ # Order contract from `_apply_reset_events_to_weekrefs`: the output is
2139
+ # NEWEST-FIRST, so the live segment is at index 0 and every earlier
2140
+ # segment follows it in descending order. #750 S3 made the applier
2141
+ # N-ary, so index 1 is the segment before the live one rather than
2142
+ # "the pre-credit ref" — a week credited twice puts a middle segment
2143
+ # there. Only index 0 is read here. Non-credit weeks return the
2141
2144
  # single input ref unchanged, so this is a no-op on the common
2142
2145
  # path.
2143
- _adjusted_current = c._apply_reset_events_to_weekrefs(conn, [current_ref])
2146
+ # SCOPED to the requesting account (#750 S3, Unit B review). The match
2147
+ # below disambiguates the live segment by `week_start_at`, so a merged
2148
+ # read lets ANOTHER account's cut shift that value and the current-week
2149
+ # row is then misidentified. `acct_key` is None without `--account`,
2150
+ # which is the explicit merged read this call always made.
2151
+ _adjusted_current = c._apply_reset_events_to_weekrefs(
2152
+ conn, [current_ref], account_key=acct_key)
2144
2153
  if _adjusted_current:
2145
2154
  current_ref = _adjusted_current[0]
2146
2155
 
@@ -2396,12 +2405,28 @@ def cmd_report(args: argparse.Namespace) -> int:
2396
2405
  if milestone_rows:
2397
2406
  print()
2398
2407
  print("Percent breakdown (current week):\n")
2408
+ # #750 S3 Unit C (issue #738). This table is a SECOND surface
2409
+ # over the same rows, with its own column set and its own bare
2410
+ # `n/a`, so it had the same defect and shipping the disclosure
2411
+ # on only one of the two would leave the issue half fixed. The
2412
+ # classifier is C1's, reached on the namespace — the predicate
2413
+ # has one home. It groups by `(account_key, reset_event_id)`
2414
+ # itself, which matters here because this table is NOT
2415
+ # segment-filtered and renders every epoch of the week.
2416
+ gap_disclosure = c.classify_observation_gaps(milestone_rows)
2417
+ for note in c.observation_gap_notes(gap_disclosure, tz=tz):
2418
+ print(note)
2419
+ if gap_disclosure.runs:
2420
+ print()
2399
2421
  m_headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost"]
2400
2422
  m_rows: list[list[str]] = []
2401
2423
  for idx, m in enumerate(milestone_rows, start=1):
2402
2424
  pct = f"{int(m['percent_threshold'])}%"
2403
2425
  cum = f"${float(m['cumulative_cost_usd']):.6f}"
2404
- marg = f"${float(m['marginal_cost_usd']):.6f}" if m["marginal_cost_usd"] is not None else "n/a"
2426
+ marg = c.observation_gap_marginal_cell(
2427
+ m["marginal_cost_usd"],
2428
+ withheld=(idx - 1) in gap_disclosure.withheld_indexes,
2429
+ )
2405
2430
  m_rows.append([str(idx), pct, cum, marg])
2406
2431
  print(c._boxed_table(m_headers, m_rows, ["right", "right", "right", "right"]))
2407
2432