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.
@@ -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
- and observation.resets_at == reset_at
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 cache.db, run ``kernel_call(conn)``, close — with the uniform
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-cache → try/except/finally → 500
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 a ``cache unavailable:`` 500;
454
- a kernel exception is logged as ``<log_label> failed: %r`` and returned
455
- as a ``{type}: {msg}`` 500 byte-identical to the inlined handlers.
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._respond_json(500, {"error": f"cache unavailable: {exc}"})
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.account_label(conn, 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 = _share_codex_state_for_period(
1748
- data_snap, panel=panel, options=options,
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, {