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.
Files changed (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +56 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_pricing.py +27 -3
  41. package/bin/_lib_quota.py +81 -4
  42. package/bin/_lib_render.py +19 -5
  43. package/bin/_lib_share.py +25 -0
  44. package/bin/_lib_snapshot_cache.py +8 -0
  45. package/bin/_lib_subscription_weeks.py +16 -2
  46. package/bin/_lib_view_models.py +18 -9
  47. package/bin/cctally +43 -4
  48. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  49. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +4 -1
  52. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  53. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -1090,16 +1090,23 @@ def _build_claude_source_detail(
1090
1090
 
1091
1091
 
1092
1092
  def build_source_detail(*, snapshot, source: str, resource: str,
1093
- key: str, window_weeks: int = 4) -> dict[str, Any]:
1093
+ key: str, window_weeks: int = 4,
1094
+ account: "str | None" = None) -> dict[str, Any]:
1094
1095
  """Resolve one qualified opaque key to a provider-native read-only detail.
1095
1096
 
1096
1097
  The frozen published bundle is the first bounded key index. Claude then
1097
1098
  invokes the existing detail builders with cache-only reads; Codex returns
1098
1099
  the native S1-S3 adapter detail without a Claude fallback or a rollout
1099
1100
  parse. Every branch preserves the generic handler errors above it.
1101
+
1102
+ ``account`` (#341 Task 4): the modal-captured account qualifier. When set,
1103
+ ``source_detail_lookup`` verifies row ownership before returning, so an
1104
+ account-focused modal can never surface another account's row.
1100
1105
  """
1101
1106
  try:
1102
- row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
1107
+ row = source_detail_lookup(
1108
+ snapshot.source_bundle, source, resource, key, account=account,
1109
+ )
1103
1110
  except SourceResourceNotFound:
1104
1111
  # A Claude project drill can expose a recent session outside the
1105
1112
  # dashboard panel's bounded published slice. The provider adapter
@@ -2131,7 +2138,8 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
2131
2138
  """
2132
2139
  range_end = now_utc
2133
2140
  range_start = now_utc - dt.timedelta(days=7 * (n + 1))
2134
- weeks = _compute_subscription_weeks(conn, range_start, range_end)
2141
+ weeks = _compute_subscription_weeks(conn, range_start, range_end,
2142
+ account_key=None) # #341: merged read
2135
2143
  if not weeks:
2136
2144
  return []
2137
2145
  fetch_start = min(
@@ -6062,11 +6070,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6062
6070
  "resource": resource,
6063
6071
  "key": key,
6064
6072
  }
6073
+ query = _urlparse.parse_qs(
6074
+ _urlparse.urlsplit(self.path).query,
6075
+ keep_blank_values=True,
6076
+ )
6077
+ # #341 Task 4 (spec §4 finding 10): the account qualifier the modal
6078
+ # captured at open. When present, the detail lookup verifies row
6079
+ # ownership so an account-focused modal never surfaces another account's
6080
+ # row. Absent = today's account-agnostic behavior. Validate the shape (a
6081
+ # 32-hex account_key or a reserved sentinel) before it reaches the reader.
6082
+ raw_account = query.get("account")
6083
+ if raw_account is not None:
6084
+ if (
6085
+ len(raw_account) != 1
6086
+ or not re.fullmatch(r"[0-9a-f]{32}|unattributed|\*", raw_account[0])
6087
+ ):
6088
+ self._respond_json(400, {
6089
+ "code": "source_capability_unavailable",
6090
+ "error": "source capability unavailable",
6091
+ })
6092
+ return
6093
+ detail_kwargs["account"] = raw_account[0]
6065
6094
  if resource == "project":
6066
- query = _urlparse.parse_qs(
6067
- _urlparse.urlsplit(self.path).query,
6068
- keep_blank_values=True,
6069
- )
6070
6095
  raw_weeks = query.get("weeks")
6071
6096
  if raw_weeks is not None:
6072
6097
  if len(raw_weeks) != 1 or raw_weeks[0] not in {"1", "4", "8", "12"}:
@@ -853,8 +853,12 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
853
853
  # the transcript cursor. Neither lock is held across the other.
854
854
  core = open_cache_db()
855
855
  try:
856
- core_stats = sync_codex_cache(
857
- core, only_paths=set(to_ingest)
856
+ cache_mod = sys.modules["cctally"]._load_sibling("_cctally_cache")
857
+ core_stats, core = cache_mod._run_cache_operation_with_recovery(
858
+ core,
859
+ lambda active_conn: sync_codex_cache(
860
+ active_conn, only_paths=set(to_ingest)
861
+ ),
858
862
  )
859
863
  finally:
860
864
  core.close()
@@ -788,6 +788,51 @@ def _build_alerts_envelope_array(
788
788
  return out[:limit]
789
789
 
790
790
 
791
+ def _sync_failure_envelope(error: str | None) -> dict | None:
792
+ """Classify a raw server sync failure into a privacy-safe UI contract."""
793
+ if not error:
794
+ return None
795
+
796
+ text = error.casefold()
797
+ if (
798
+ "stale maintenance marker" in text
799
+ or "stale repair marker" in text
800
+ or "recovery could not claim maintenance" in text
801
+ ):
802
+ return {
803
+ "kind": "maintenance_stale",
804
+ "label": "⚠ cache repair blocked",
805
+ "detail": "A stale cache repair owner is blocking server sync.",
806
+ "action": "cctally cache-sync --rebuild",
807
+ }
808
+ if "maintenance is in progress" in text:
809
+ return {
810
+ "kind": "maintenance_active",
811
+ "label": "cache repair in progress",
812
+ "detail": "Another cctally process is repairing the server cache.",
813
+ "action": None,
814
+ }
815
+
816
+ c = sys.modules["cctally"]
817
+ if (
818
+ c._is_sqlite_corruption_error(error)
819
+ or "cache.db recovery" in text
820
+ or "cache.db is still open" in text
821
+ ):
822
+ return {
823
+ "kind": "cache_corruption",
824
+ "label": "⚠ cache recovery needed",
825
+ "detail": "The server cache database could not be read safely.",
826
+ "action": "cctally cache-sync --rebuild",
827
+ }
828
+ return {
829
+ "kind": "server_sync",
830
+ "label": "⚠ server sync error",
831
+ "detail": "The server could not complete its background sync.",
832
+ "action": None,
833
+ }
834
+
835
+
791
836
  def snapshot_to_envelope(snap: "DataSnapshot", *,
792
837
  now_utc: "dt.datetime",
793
838
  monotonic_now: "float | None" = None,
@@ -1369,6 +1414,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1369
1414
  "last_sync_at": None,
1370
1415
  "sync_age_s": sync_age_s,
1371
1416
  "last_sync_error": snap.last_sync_error,
1417
+ # Privacy-safe classification for user-facing dashboard copy. Keep the
1418
+ # raw legacy field for API compatibility and diagnostics, but never
1419
+ # place it in visible text/title/aria surfaces.
1420
+ "sync_failure": _sync_failure_envelope(snap.last_sync_error),
1372
1421
 
1373
1422
  # F1 (server-resolves "local" → IANA): the browser never has to
1374
1423
  # guess. {tz, resolved_tz, offset_label, offset_seconds} computed
@@ -36,6 +36,7 @@ from __future__ import annotations
36
36
  import datetime as dt
37
37
  import json
38
38
  import pathlib
39
+ import re
39
40
  import sqlite3
40
41
  import sys
41
42
  from collections.abc import Mapping
@@ -1266,6 +1267,60 @@ def _share_source_selection(req: dict) -> tuple[str, bool]:
1266
1267
  return source, explicit
1267
1268
 
1268
1269
 
1270
+ _ACCOUNT_KEY_RE = re.compile(r"[0-9a-f]{32}|unattributed|\*")
1271
+
1272
+
1273
+ def _share_account_selection(req: dict) -> "str | None":
1274
+ """Resolve the optional captured account qualifier (#341 Task 4, spec §4).
1275
+
1276
+ The composer captures ``(source, account)`` at OPEN_SHARE (client
1277
+ ``shareModal.account``) and posts the resolved account_key. A legacy request
1278
+ (no ``account`` field, or an explicit null) stays account-agnostic — today's
1279
+ byte-identical behavior. A malformed value is rejected so a bad qualifier
1280
+ can never reach the reader. The account carries into the data_digest (a focus
1281
+ change busts the composer drift check) and the response/history metadata.
1282
+ """
1283
+ if "account" not in req:
1284
+ return None
1285
+ account = req.get("account")
1286
+ if account is None:
1287
+ return None
1288
+ if not isinstance(account, str) or not _ACCOUNT_KEY_RE.fullmatch(account):
1289
+ raise ValueError("source capability unavailable")
1290
+ return account
1291
+
1292
+
1293
+ def _share_account_display_label(source: str, account: "str | None",
1294
+ *, reveal: bool) -> "str | None":
1295
+ """The reveal-aware account label to stamp on a share artifact (#341 Task 4).
1296
+
1297
+ Resolves the account's registry label + deterministic index for its provider
1298
+ (``all`` has no account selector) and routes both through the fail-closed
1299
+ kernel chokepoint ``anonymize_account_label`` — so anon-mode (the default)
1300
+ emits ``Account A/B/C`` and only an explicit reveal shows the real label.
1301
+ Returns ``None`` when no account is captured or it cannot be resolved (never
1302
+ guesses; never leaks). Emails are never consulted — only the label.
1303
+ """
1304
+ if account is None or source not in ("claude", "codex"):
1305
+ return None
1306
+ ls = _share_load_lib()
1307
+ c = sys.modules["cctally"]
1308
+ try:
1309
+ import _cctally_account
1310
+ conn = c._load_sibling("_cctally_core").open_db()
1311
+ try:
1312
+ reg = _cctally_account.load_accounts(conn, source)
1313
+ label = _cctally_account.account_label(conn, account)
1314
+ finally:
1315
+ conn.close()
1316
+ except Exception:
1317
+ return None
1318
+ index = next(
1319
+ (i for i, r in enumerate(reg) if r["account_key"] == account), -1,
1320
+ )
1321
+ return ls.anonymize_account_label(label, index, reveal=reveal)
1322
+
1323
+
1269
1324
  def _source_state_for_share(data_snap, source: str):
1270
1325
  try:
1271
1326
  bundle = data_snap.source_bundle
@@ -1583,12 +1638,17 @@ def _share_state_domain(state, panel: str):
1583
1638
 
1584
1639
  def _share_digest_input(*, panel: str, template_id: str, source: str,
1585
1640
  source_explicit: bool, states, snapshots,
1586
- panel_data):
1641
+ panel_data, account: "str | None" = None):
1642
+ # #341 Task 4: the captured account participates in the digest so switching
1643
+ # the focused account registers as data drift in the composer (spec §4).
1644
+ # Absent (legacy / account-agnostic) → omitted, so the digest is byte-stable.
1645
+ account_key = {"account": account} if account is not None else {}
1587
1646
  if not source_explicit:
1588
1647
  return {
1589
1648
  "panel": panel,
1590
1649
  "template_id": template_id,
1591
1650
  "panel_data": panel_data,
1651
+ **account_key,
1592
1652
  }
1593
1653
  providers = []
1594
1654
  for state, snapshot in zip(states, snapshots):
@@ -1612,6 +1672,7 @@ def _share_digest_input(*, panel: str, template_id: str, source: str,
1612
1672
  {"claude_panel_data": panel_data}
1613
1673
  if source in ("claude", "all") else {}
1614
1674
  ),
1675
+ **account_key,
1615
1676
  }
1616
1677
 
1617
1678
 
@@ -1763,6 +1824,7 @@ def _handle_share_render_post_impl(handler) -> None:
1763
1824
  return
1764
1825
  try:
1765
1826
  source, source_explicit = _share_source_selection(req)
1827
+ account = _share_account_selection(req)
1766
1828
  except ValueError:
1767
1829
  handler._respond_json(400, {
1768
1830
  "code": "source_capability_unavailable",
@@ -1924,6 +1986,7 @@ def _handle_share_render_post_impl(handler) -> None:
1924
1986
  states=source_states,
1925
1987
  snapshots=source_snaps,
1926
1988
  panel_data=panel_data,
1989
+ account=account,
1927
1990
  )
1928
1991
  try:
1929
1992
  data_digest = ls._data_digest(digest_input)
@@ -1933,6 +1996,16 @@ def _handle_share_render_post_impl(handler) -> None:
1933
1996
  # "always drifted" rather than failing the whole render.
1934
1997
  data_digest = ""
1935
1998
 
1999
+ # #341 Task 4: the captured account + its reveal-aware anonymized label
2000
+ # (fail-closed via the kernel chokepoint). Present only when an account was
2001
+ # captured, so a legacy/account-agnostic share's response is byte-stable.
2002
+ account_label = _share_account_display_label(source, account, reveal=reveal)
2003
+ account_meta = {}
2004
+ if account is not None:
2005
+ account_meta["account"] = account
2006
+ if account_label is not None:
2007
+ account_meta["account_label"] = account_label
2008
+
1936
2009
  handler._respond_json(200, {
1937
2010
  "body": body,
1938
2011
  "content_type": content_type,
@@ -1944,6 +2017,7 @@ def _handle_share_render_post_impl(handler) -> None:
1944
2017
  "generated_at": _share_now_utc_iso(),
1945
2018
  "data_digest": data_digest,
1946
2019
  **({"source": source} if source_explicit else {}),
2020
+ **account_meta,
1947
2021
  },
1948
2022
  })
1949
2023
 
@@ -2132,6 +2206,9 @@ def _handle_share_compose_post_impl(handler) -> None:
2132
2206
  states=source_states,
2133
2207
  snapshots=source_snaps,
2134
2208
  panel_data=panel_data,
2209
+ # #341 Task 4: carry the section's captured account so a
2210
+ # focus-changed section re-digests as drift (matching render).
2211
+ account=_share_account_selection(snap_recipe),
2135
2212
  ))
2136
2213
  except Exception:
2137
2214
  digest_now = ""