cctally 1.83.0 → 1.84.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 +34 -0
- package/README.md +2 -4
- package/bin/_cctally_account.py +144 -11
- package/bin/_cctally_alerts.py +3 -1
- package/bin/_cctally_cache.py +1396 -33
- package/bin/_cctally_dashboard.py +46 -2
- package/bin/_cctally_dashboard_conversation.py +16 -7
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_share.py +58 -5
- package/bin/_cctally_dashboard_sources.py +641 -38
- package/bin/_cctally_db.py +173 -7
- package/bin/_cctally_doctor.py +112 -27
- package/bin/_cctally_journal.py +761 -74
- package/bin/_cctally_milestone_history.py +73 -16
- package/bin/_cctally_quota.py +313 -15
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_tui.py +7 -3
- package/bin/_lib_doctor.py +67 -2
- package/bin/_lib_journal.py +48 -0
- package/bin/_lib_jsonl.py +119 -0
- package/bin/_lib_quota.py +222 -13
- package/bin/_lib_rederive.py +12 -0
- package/bin/_lib_source_analytics.py +13 -0
- package/bin/_lib_source_identity.py +24 -0
- package/dashboard/static/assets/index-DlVVJeS4.js +92 -0
- package/dashboard/static/assets/index-OYBkyglj.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-3bgCMVHb.js +0 -92
- package/dashboard/static/assets/index-D27EIHEI.css +0 -1
|
@@ -720,7 +720,22 @@ def _build_codex_block_detail(context, observations, *, key: str) -> dict[str, A
|
|
|
720
720
|
and observation.identity.logical_limit_key == matched[1]
|
|
721
721
|
and observation.identity.observed_slot == matched[2]
|
|
722
722
|
and observation.identity.window_minutes == matched[3]
|
|
723
|
-
|
|
723
|
+
# CANONICAL, never the raw reset (#416 §4.1). `quota_window_blocks
|
|
724
|
+
# .resets_at_utc` — which `matched[5]` is — became the tolerance-anchored
|
|
725
|
+
# anchor, so a raw comparison here is no longer the same axis: it keeps
|
|
726
|
+
# only the members that happen to spell their reset the way the anchor
|
|
727
|
+
# does, and `block.observations`, `percent_milestones`,
|
|
728
|
+
# `quota_freshness` and `forecast_quota` are then all recomputed from
|
|
729
|
+
# that truncated set — reintroducing the very fragmentation §4.1
|
|
730
|
+
# removes. It is also a 404 generator: this read is BOUNDED (35 days /
|
|
731
|
+
# 1,000 rows) while the anchor was resolved UNBOUNDED at ingest, so
|
|
732
|
+
# whenever the anchor-establishing observation falls outside the bounds
|
|
733
|
+
# — or was another account's, since the anchor group excludes the
|
|
734
|
+
# account deliberately — a raw filter keeps nothing at all and
|
|
735
|
+
# `build_blocks(()) == ()` raises `SourceResourceNotFound`. The line
|
|
736
|
+
# below is already anchor-vs-anchor (`QuotaBlock.resets_at` is the
|
|
737
|
+
# anchor), so both sides now agree.
|
|
738
|
+
and observation.canonical_resets_at == reset_at
|
|
724
739
|
)
|
|
725
740
|
block = next(
|
|
726
741
|
(item for item in build_blocks(matching_observations) if item.resets_at == reset_at),
|
|
@@ -6386,6 +6401,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6386
6401
|
connections, no sync/migration writes from the assembly; the multi-read
|
|
6387
6402
|
assembly runs inside one transaction per DB. 400 malformed key/source;
|
|
6388
6403
|
404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
|
|
6404
|
+
|
|
6405
|
+
``?account=<account_key|unattributed>`` (#416, Codex only) focuses the
|
|
6406
|
+
cycle on one account: its own crossings, its own spend. ABSENT is the
|
|
6407
|
+
shipped merged response and is byte-identical — the client sends the
|
|
6408
|
+
qualifier only under account focus, exactly as `/api/source/` does. The
|
|
6409
|
+
vendor-wide ``*`` sentinel is rejected here (unlike `/api/source/`,
|
|
6410
|
+
where it labels an alert row): it names no cycle to render.
|
|
6389
6411
|
"""
|
|
6390
6412
|
import re as _re
|
|
6391
6413
|
import urllib.parse as _urlparse
|
|
@@ -6401,6 +6423,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6401
6423
|
self._send_milestones_json(400, {"error": "invalid source"})
|
|
6402
6424
|
return
|
|
6403
6425
|
key = _urlparse.unquote(raw_key)
|
|
6426
|
+
raw_account = _urlparse.parse_qs(
|
|
6427
|
+
_urlparse.urlsplit(self.path).query, keep_blank_values=True,
|
|
6428
|
+
).get("account")
|
|
6429
|
+
account_key = None
|
|
6430
|
+
if raw_account is not None:
|
|
6431
|
+
if (
|
|
6432
|
+
len(raw_account) != 1
|
|
6433
|
+
or not _re.fullmatch(r"[0-9a-f]{32}|unattributed", raw_account[0])
|
|
6434
|
+
):
|
|
6435
|
+
self._send_milestones_json(400, {"error": "invalid account"})
|
|
6436
|
+
return
|
|
6437
|
+
if source != "codex":
|
|
6438
|
+
# Claude weeks are not account-partitioned on this route yet;
|
|
6439
|
+
# accepting the qualifier silently would be a privacy lie.
|
|
6440
|
+
self._send_milestones_json(400, {"error": "invalid account"})
|
|
6441
|
+
return
|
|
6442
|
+
account_key = raw_account[0]
|
|
6404
6443
|
c = sys.modules["cctally"]
|
|
6405
6444
|
try:
|
|
6406
6445
|
if source == "claude":
|
|
@@ -6458,12 +6497,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6458
6497
|
# fall through to `cyc.reset > now_utc`, which marks every
|
|
6459
6498
|
# future-ending cycle current — so one cycle key described two
|
|
6460
6499
|
# different cycles depending on which route answered.
|
|
6500
|
+
# #416 QA sweep: the live boundary must belong to the SAME
|
|
6501
|
+
# account the enumeration below is scoped to, or the focused
|
|
6502
|
+
# account's own current cycle is judged against a sibling's
|
|
6503
|
+
# reset and never resolves as current.
|
|
6461
6504
|
identity = resolve_codex_cycle_detail_identity(
|
|
6462
6505
|
cache_conn, source_root_keys=roots, now_utc=now_utc,
|
|
6506
|
+
account_key=account_key,
|
|
6463
6507
|
)
|
|
6464
6508
|
result = c.build_codex_cycle_detail(
|
|
6465
6509
|
stats_conn, cache_conn, identity=identity, key=key,
|
|
6466
|
-
speed=speed, now_utc=now_utc,
|
|
6510
|
+
speed=speed, now_utc=now_utc, account_key=account_key,
|
|
6467
6511
|
)
|
|
6468
6512
|
stats_conn.commit()
|
|
6469
6513
|
cache_conn.commit()
|
|
@@ -442,22 +442,31 @@ def _parse_search_kind_impl(handler, q, valid=_CONV_SEARCH_KINDS):
|
|
|
442
442
|
return kind
|
|
443
443
|
|
|
444
444
|
def _run_conversation_query_impl(handler, kernel_call, log_label):
|
|
445
|
-
"""Open
|
|
446
|
-
500 envelopes the three conversation routes share (#151).
|
|
445
|
+
"""Open conversations.db, run ``kernel_call(conn)``, close.
|
|
447
446
|
|
|
448
|
-
Collapses the triplicated open-
|
|
447
|
+
Collapses the triplicated open-store → try/except/finally → 500
|
|
449
448
|
scaffold to one site. Returns ``(ok, body)``: ``ok=False`` means a 500
|
|
450
449
|
has ALREADY been sent and the caller must just ``return``; ``ok=True``
|
|
451
450
|
carries the kernel result (which may itself be ``None`` — the reader's
|
|
452
451
|
404 sentinel — so the explicit flag, not ``body is None``, signals
|
|
453
|
-
failure). An ``open_conversations_db`` failure is
|
|
454
|
-
|
|
455
|
-
as
|
|
452
|
+
failure). An ``open_conversations_db`` failure is logged in full but
|
|
453
|
+
returned through one privacy-safe transcript-store envelope. A kernel
|
|
454
|
+
exception is logged as ``<log_label> failed: %r`` and returned as a
|
|
455
|
+
``{type}: {msg}`` 500.
|
|
456
456
|
"""
|
|
457
457
|
try:
|
|
458
458
|
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
459
459
|
except (sqlite3.DatabaseError, OSError) as exc:
|
|
460
|
-
handler.
|
|
460
|
+
handler.log_error("%s transcript store open failed: %r", log_label, exc)
|
|
461
|
+
handler._respond_json(
|
|
462
|
+
500,
|
|
463
|
+
{
|
|
464
|
+
"error": (
|
|
465
|
+
"transcript store unavailable; "
|
|
466
|
+
"run cctally doctor for details"
|
|
467
|
+
)
|
|
468
|
+
},
|
|
469
|
+
)
|
|
461
470
|
return False, None
|
|
462
471
|
try:
|
|
463
472
|
body = kernel_call(conn)
|
|
@@ -845,6 +845,18 @@ def _sync_failure_envelope(
|
|
|
845
845
|
"action": "cctally db repair --db stats --yes",
|
|
846
846
|
}
|
|
847
847
|
|
|
848
|
+
# conversations.db is intentionally outside core dashboard generations.
|
|
849
|
+
# Typed transcript ownership therefore degrades through the generic server
|
|
850
|
+
# contract: Doctor owns integrity diagnosis, and raw SQLite wording must
|
|
851
|
+
# never manufacture a cache.db repair action for this third store.
|
|
852
|
+
if attributed("conversations"):
|
|
853
|
+
return {
|
|
854
|
+
"kind": "server_sync",
|
|
855
|
+
"label": "⚠ server sync error",
|
|
856
|
+
"detail": "The server could not complete its background sync.",
|
|
857
|
+
"action": None,
|
|
858
|
+
}
|
|
859
|
+
|
|
848
860
|
text = error.casefold()
|
|
849
861
|
if (
|
|
850
862
|
"stale maintenance marker" in text
|
|
@@ -41,7 +41,7 @@ import sqlite3
|
|
|
41
41
|
import sys
|
|
42
42
|
from collections.abc import Mapping
|
|
43
43
|
from dataclasses import replace
|
|
44
|
-
from types import SimpleNamespace
|
|
44
|
+
from types import MappingProxyType, SimpleNamespace
|
|
45
45
|
from zoneinfo import ZoneInfo
|
|
46
46
|
|
|
47
47
|
from _cctally_core import open_db, parse_iso_datetime
|
|
@@ -1317,7 +1317,7 @@ def _share_account_display_label(source: str, account: "str | None",
|
|
|
1317
1317
|
conn = c._load_sibling("_cctally_core").open_db()
|
|
1318
1318
|
try:
|
|
1319
1319
|
reg = _cctally_account.load_accounts(conn, source)
|
|
1320
|
-
label = _cctally_account.
|
|
1320
|
+
label = _cctally_account.display_account_label(conn, account)
|
|
1321
1321
|
finally:
|
|
1322
1322
|
conn.close()
|
|
1323
1323
|
except Exception:
|
|
@@ -1710,9 +1710,50 @@ def _share_digest_input(*, panel: str, template_id: str, source: str,
|
|
|
1710
1710
|
}
|
|
1711
1711
|
|
|
1712
1712
|
|
|
1713
|
+
_CODEX_ACCOUNT_SCOPED_DOMAINS = (
|
|
1714
|
+
"periods", "sessions", "projects", "cache_report", "budget", "quota",
|
|
1715
|
+
"alerts",
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _share_scope_codex_state(state, account: "str | None"):
|
|
1720
|
+
"""Restrict a Codex source state to ONE account's child (#416 §5.6, F16).
|
|
1721
|
+
|
|
1722
|
+
The share handler has always parsed `account` and stamped it on the digest,
|
|
1723
|
+
the response and the history metadata — but never passed it to the snapshot
|
|
1724
|
+
builder, so a focused user exported an ALL-ACCOUNT body LABELLED with the
|
|
1725
|
+
focused account. That is a disclosure defect, not a missing feature, and it
|
|
1726
|
+
is worse than an unlabelled export because the label asserts a scope the
|
|
1727
|
+
body does not have.
|
|
1728
|
+
|
|
1729
|
+
Substitutes the per-account children the source already publishes, so the
|
|
1730
|
+
share reads exactly what the focused dashboard reads — one read model, not a
|
|
1731
|
+
second re-derivation that could drift from it.
|
|
1732
|
+
|
|
1733
|
+
FAILS CLOSED. A decorated source that does not know this account raises
|
|
1734
|
+
rather than falling back to the merged body: falling back is precisely the
|
|
1735
|
+
leak. An UNDECORATED source (<=1 real account) publishes no children and
|
|
1736
|
+
needs none — its merged body IS that account's body.
|
|
1737
|
+
"""
|
|
1738
|
+
if account is None:
|
|
1739
|
+
return state
|
|
1740
|
+
data = state.data if isinstance(state.data, Mapping) else {}
|
|
1741
|
+
scopes = data.get("account_scopes")
|
|
1742
|
+
if not isinstance(scopes, Mapping):
|
|
1743
|
+
return state
|
|
1744
|
+
scope = scopes.get(account)
|
|
1745
|
+
if not isinstance(scope, Mapping):
|
|
1746
|
+
raise ValueError("source capability unavailable")
|
|
1747
|
+
scoped = {
|
|
1748
|
+
key: scope[key] for key in _CODEX_ACCOUNT_SCOPED_DOMAINS if key in scope
|
|
1749
|
+
}
|
|
1750
|
+
return replace(state, data=MappingProxyType({**dict(data), **scoped}))
|
|
1751
|
+
|
|
1752
|
+
|
|
1713
1753
|
def _share_build_source_snapshots(*, ls, template, template_id: str,
|
|
1714
1754
|
panel: str, options: dict, source: str,
|
|
1715
|
-
source_explicit: bool, data_snap
|
|
1755
|
+
source_explicit: bool, data_snap,
|
|
1756
|
+
account: "str | None" = None):
|
|
1716
1757
|
"""Branch by provider before invoking any provider-specific builder."""
|
|
1717
1758
|
claude_snapshot = None
|
|
1718
1759
|
claude_state = None
|
|
@@ -1744,8 +1785,11 @@ def _share_build_source_snapshots(*, ls, template, template_id: str,
|
|
|
1744
1785
|
codex_snapshot = None
|
|
1745
1786
|
codex_state = None
|
|
1746
1787
|
if source in ("codex", "all"):
|
|
1747
|
-
codex_state =
|
|
1748
|
-
|
|
1788
|
+
codex_state = _share_scope_codex_state(
|
|
1789
|
+
_share_codex_state_for_period(
|
|
1790
|
+
data_snap, panel=panel, options=options,
|
|
1791
|
+
),
|
|
1792
|
+
account,
|
|
1749
1793
|
)
|
|
1750
1794
|
codex_snapshot = _build_codex_source_share_snapshot(
|
|
1751
1795
|
ls,
|
|
@@ -1964,6 +2008,9 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
1964
2008
|
source=source,
|
|
1965
2009
|
source_explicit=source_explicit,
|
|
1966
2010
|
data_snap=data_snap,
|
|
2011
|
+
# #416 §5.6: the captured account has always reached the digest and
|
|
2012
|
+
# the response label; it must reach the BODY too.
|
|
2013
|
+
account=account,
|
|
1967
2014
|
)
|
|
1968
2015
|
except _SharePeriodError as exc:
|
|
1969
2016
|
handler._respond_json(400, exc.payload)
|
|
@@ -2153,6 +2200,11 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2153
2200
|
{"source": snap_recipe["source"]}
|
|
2154
2201
|
if "source" in snap_recipe else {}
|
|
2155
2202
|
)
|
|
2203
|
+
# #416 §5.6: a section that captured an account must be BODY-scoped
|
|
2204
|
+
# to it, exactly as the render path is. Absent (every section the
|
|
2205
|
+
# shipped composer posts today) → account-agnostic and byte-stable;
|
|
2206
|
+
# malformed → the same fail-closed rejection as `source`.
|
|
2207
|
+
section_account = _share_account_selection(snap_recipe)
|
|
2156
2208
|
except ValueError:
|
|
2157
2209
|
handler._respond_json(400, {
|
|
2158
2210
|
"code": "source_capability_unavailable",
|
|
@@ -2204,6 +2256,7 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2204
2256
|
source=source,
|
|
2205
2257
|
source_explicit=source_explicit,
|
|
2206
2258
|
data_snap=data_snap,
|
|
2259
|
+
account=section_account,
|
|
2207
2260
|
)
|
|
2208
2261
|
except _SharePeriodError as exc:
|
|
2209
2262
|
handler._respond_json(400, {
|