cctally 1.80.3 → 1.81.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 +29 -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 +459 -59
- 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 +55 -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_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-B3y14l1o.js +0 -92
- 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
|
|
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(
|
|
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
|
-
|
|
857
|
-
|
|
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 = ""
|