cctally 1.109.0 → 1.111.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 (47) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_cache.py +62 -13
  4. package/bin/_cctally_core.py +8 -1
  5. package/bin/_cctally_dashboard.py +422 -195
  6. package/bin/_cctally_dashboard_conversation.py +231 -40
  7. package/bin/_cctally_dashboard_envelope.py +48 -32
  8. package/bin/_cctally_dashboard_sources.py +503 -109
  9. package/bin/_cctally_db.py +44 -0
  10. package/bin/_cctally_diagnosis_sources.py +181 -15
  11. package/bin/_cctally_doctor.py +15 -3
  12. package/bin/_cctally_forecast.py +25 -12
  13. package/bin/_cctally_journal.py +59 -32
  14. package/bin/_cctally_milestone_history.py +65 -9
  15. package/bin/_cctally_parser.py +5 -0
  16. package/bin/_cctally_record.py +50 -12
  17. package/bin/_cctally_rederive.py +899 -30
  18. package/bin/_cctally_source_analytics.py +567 -133
  19. package/bin/_cctally_transcript.py +27 -2
  20. package/bin/_cctally_tui.py +39 -6
  21. package/bin/_cctally_update.py +420 -14
  22. package/bin/_lib_cache_report.py +7 -10
  23. package/bin/_lib_codex_conversation_query.py +286 -63
  24. package/bin/_lib_codex_metadata.py +207 -0
  25. package/bin/_lib_conversation_query.py +254 -37
  26. package/bin/_lib_doctor.py +16 -2
  27. package/bin/_lib_pricing.py +80 -276
  28. package/bin/_lib_rederive.py +903 -2
  29. package/bin/_lib_view_models.py +5 -0
  30. package/bin/cctally +18 -5
  31. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  32. package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
  33. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  34. package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
  35. package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
  36. package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
  37. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  38. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  39. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  40. package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
  41. package/dashboard/static/dashboard.html +3 -3
  42. package/package.json +2 -1
  43. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
  44. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
  45. package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
  46. package/dashboard/static/assets/index-DLVwACUM.js +0 -13
  47. package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
@@ -47,6 +47,7 @@ import collections
47
47
  import dataclasses
48
48
  import hashlib
49
49
  import secrets
50
+ import select
50
51
  import threading
51
52
  import time
52
53
 
@@ -115,10 +116,16 @@ _OUTLINE_TRANSFER_ITEM_CAP = 32 * 1024 * 1024
115
116
  _OUTLINE_TRANSFER_TOTAL_CAP = 64 * 1024 * 1024
116
117
  _OUTLINE_TRANSFER_COUNT_CAP = 128
117
118
  _OUTLINE_TRANSFER_TTL = 120.0
119
+ _OUTLINE_TRANSFER_REQUEST_DEADLINE = 15.0
120
+ _OUTLINE_TRANSFER_MAX_WAITERS = 32
121
+ _OUTLINE_TRANSFER_MAX_WAITERS_PER_TOKEN = 16
122
+ _OUTLINE_TRANSFER_MAX_PRODUCERS = 2
118
123
  _OUTLINE_TRANSFERS = collections.OrderedDict()
119
124
  _OUTLINE_TRANSFERS_BYTES = 0
120
125
  _OUTLINE_TRANSFERS_EVICTIONS = 0
121
126
  _OUTLINE_TRANSFERS_FALLBACKS = 0
127
+ _OUTLINE_TRANSFERS_WAITERS = 0
128
+ _OUTLINE_TRANSFERS_PRODUCERS = 0
122
129
  _OUTLINE_TRANSFERS_LOCK = threading.Lock()
123
130
 
124
131
 
@@ -129,6 +136,10 @@ class _OutlineTransferRecord:
129
136
  payload: bytes | None = None
130
137
  digest: str | None = None
131
138
  state: str = "pending"
139
+ waiters: int = 0
140
+ deadline: float | None = None
141
+ failure_status: int | None = None
142
+ failure_body: dict | None = None
132
143
  cancelled: threading.Event = dataclasses.field(default_factory=threading.Event)
133
144
  ready: threading.Event = dataclasses.field(default_factory=threading.Event)
134
145
 
@@ -148,6 +159,8 @@ def outline_transfer_cache_stats():
148
159
  "maxEntries": _OUTLINE_TRANSFER_COUNT_CAP,
149
160
  "evictionCount": int(_OUTLINE_TRANSFERS_EVICTIONS),
150
161
  "fallbackCount": int(_OUTLINE_TRANSFERS_FALLBACKS),
162
+ "admittedWaiters": int(_OUTLINE_TRANSFERS_WAITERS),
163
+ "activeProducers": int(_OUTLINE_TRANSFERS_PRODUCERS),
151
164
  }
152
165
 
153
166
 
@@ -191,8 +204,9 @@ def _store_outline_transfer(builder):
191
204
  return {"token": token, "chunk_size": _OUTLINE_TRANSFER_CHUNK}
192
205
 
193
206
 
194
- def _claim_outline_transfer(token):
207
+ def _claim_outline_transfer(token, *, admit=False):
195
208
  """Atomically claim a pending builder or join its in-flight generation."""
209
+ global _OUTLINE_TRANSFERS_WAITERS, _OUTLINE_TRANSFERS_PRODUCERS
196
210
  now = time.monotonic()
197
211
  with _OUTLINE_TRANSFERS_LOCK:
198
212
  _prune_outline_transfers_locked(now)
@@ -202,12 +216,49 @@ def _claim_outline_transfer(token):
202
216
  _OUTLINE_TRANSFERS.move_to_end(token)
203
217
  if record.payload is not None:
204
218
  return "ready", record, None
219
+ if admit:
220
+ if (_OUTLINE_TRANSFERS_WAITERS >= _OUTLINE_TRANSFER_MAX_WAITERS
221
+ or record.waiters >= _OUTLINE_TRANSFER_MAX_WAITERS_PER_TOKEN
222
+ or (record.state == "pending" and
223
+ _OUTLINE_TRANSFERS_PRODUCERS >=
224
+ _OUTLINE_TRANSFER_MAX_PRODUCERS)):
225
+ return "busy", record, None
226
+ record.waiters += 1
227
+ _OUTLINE_TRANSFERS_WAITERS += 1
205
228
  if record.state == "pending":
206
229
  record.state = "building"
230
+ if admit:
231
+ record.deadline = min(
232
+ now + _OUTLINE_TRANSFER_REQUEST_DEADLINE,
233
+ record.created + _OUTLINE_TRANSFER_TTL,
234
+ )
235
+ _OUTLINE_TRANSFERS_PRODUCERS += 1
207
236
  return "build", record, record.builder
208
237
  return "wait", record, None
209
238
 
210
239
 
240
+ def _release_outline_transfer_waiter(token, record):
241
+ """An abandoned last reader cancels its producer without releasing its slot."""
242
+ global _OUTLINE_TRANSFERS_WAITERS
243
+ with _OUTLINE_TRANSFERS_LOCK:
244
+ record.waiters -= 1
245
+ _OUTLINE_TRANSFERS_WAITERS -= 1
246
+ if (record.waiters == 0 and record.state == "building"
247
+ and _OUTLINE_TRANSFERS.get(token) is record):
248
+ _drop_outline_transfer_locked(token)
249
+
250
+
251
+ def _finish_outline_transfer_failure(token, record, status, body):
252
+ """Publish one failure to admitted readers and retire only this generation."""
253
+ with _OUTLINE_TRANSFERS_LOCK:
254
+ if _OUTLINE_TRANSFERS.get(token) is not record:
255
+ return False
256
+ record.failure_status = status
257
+ record.failure_body = body
258
+ _drop_outline_transfer_locked(token)
259
+ return True
260
+
261
+
211
262
  def _outline_transfer_builder(token):
212
263
  """Compatibility probe: claim one builder, ``False`` if joined/ready."""
213
264
  claim = _claim_outline_transfer(token)
@@ -225,6 +276,9 @@ def _materialize_outline_transfer(token, payload, *, claim=None):
225
276
  with _OUTLINE_TRANSFERS_LOCK:
226
277
  current = _OUTLINE_TRANSFERS.get(token)
227
278
  if current is not None and (claim is None or current is claim):
279
+ current.failure_status = 413
280
+ current.failure_body = {
281
+ "error": "outline exceeds progressive transfer limit"}
228
282
  _drop_outline_transfer_locked(token)
229
283
  _OUTLINE_TRANSFERS_FALLBACKS += 1
230
284
  return "too_large"
@@ -266,6 +320,9 @@ def _materialize_outline_transfer(token, payload, *, claim=None):
266
320
  None,
267
321
  )
268
322
  if victim is None:
323
+ record.failure_status = 413
324
+ record.failure_body = {
325
+ "error": "outline exceeds progressive transfer limit"}
269
326
  _drop_outline_transfer_locked(token)
270
327
  _OUTLINE_TRANSFERS_FALLBACKS += 1
271
328
  return "too_large"
@@ -292,6 +349,9 @@ def _reject_outline_transfer_too_large(token, *, claim) -> bool:
292
349
  current = _OUTLINE_TRANSFERS.get(token)
293
350
  if current is None or current is not claim:
294
351
  return False
352
+ current.failure_status = 413
353
+ current.failure_body = {
354
+ "error": "outline exceeds progressive transfer limit"}
295
355
  _drop_outline_transfer_locked(token)
296
356
  _OUTLINE_TRANSFERS_FALLBACKS += 1
297
357
  return True
@@ -1186,7 +1246,9 @@ def _run_conversation_events_stream(
1186
1246
  if passive:
1187
1247
  # Frozen-data contract: no ingest, no emit. Keep-alive only.
1188
1248
  while True:
1189
- _time.sleep(_LIVE_TAIL_KEEPALIVE)
1249
+ if _request_peer_gone(
1250
+ handler, timeout=_LIVE_TAIL_KEEPALIVE):
1251
+ return
1190
1252
  handler.wfile.write(b": keep-alive\n\n")
1191
1253
  handler.wfile.flush()
1192
1254
 
@@ -1221,7 +1283,8 @@ def _run_conversation_events_stream(
1221
1283
  idle = 0.0
1222
1284
  cycles = 0
1223
1285
  while True:
1224
- _time.sleep(_LIVE_TAIL_POLL_INTERVAL)
1286
+ if _request_peer_gone(handler, timeout=_LIVE_TAIL_POLL_INTERVAL):
1287
+ return
1225
1288
  cycles += 1
1226
1289
  changed = watch.changed_paths(files, seen)
1227
1290
  if changed:
@@ -1793,7 +1856,8 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
1793
1856
 
1794
1857
  def _handle_get_conversation_outline_impl(handler, path: str) -> None:
1795
1858
  """``GET /api/conversation/<sid>/outline`` — full-session skeleton +
1796
- session stats (#177 S5). Same fail-closed privacy gate; unknown id → 404.
1859
+ session stats (#177 S5). Same fail-closed privacy gate; unknown id → 404
1860
+ for the legacy form, typed absence on the progressive preflight.
1797
1861
  """
1798
1862
  if not handler._require_transcripts_allowed():
1799
1863
  return
@@ -1859,7 +1923,7 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
1859
1923
  if not ok:
1860
1924
  return
1861
1925
  if not exists:
1862
- handler.send_error(404, "conversation not found")
1926
+ handler._respond_json(200, {"status": "not_found"})
1863
1927
  return
1864
1928
 
1865
1929
  def _build_bare(transfer_handler, cancelled):
@@ -1892,6 +1956,99 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
1892
1956
  handler._respond_json(200, body)
1893
1957
 
1894
1958
 
1959
+ class _OutlineTransferBuildHandler:
1960
+ """Give a producer read access to its request without writing its socket."""
1961
+
1962
+ def __init__(self, handler):
1963
+ self._handler = handler
1964
+ self.response = None
1965
+
1966
+ def __getattr__(self, name):
1967
+ return getattr(self._handler, name)
1968
+
1969
+ def _respond_json(self, status, body):
1970
+ self.response = (status, body)
1971
+
1972
+
1973
+ def _produce_outline_transfer(token, record, builder, handler):
1974
+ """Build off the request thread; the producer slot lasts until real exit."""
1975
+ global _OUTLINE_TRANSFERS_PRODUCERS
1976
+ proxy = _OutlineTransferBuildHandler(handler)
1977
+ try:
1978
+ try:
1979
+ ok, full_body = builder(proxy, record.cancelled.is_set)
1980
+ if record.cancelled.is_set():
1981
+ return
1982
+ if ok is None or full_body is None and ok:
1983
+ _finish_outline_transfer_failure(
1984
+ token, record, 410,
1985
+ {"error": "outline transfer expired"})
1986
+ return
1987
+ if not ok:
1988
+ status, body = proxy.response or (
1989
+ 500, {"error": "outline transfer failed"})
1990
+ _finish_outline_transfer_failure(token, record, status, body)
1991
+ return
1992
+ payload = encode_dashboard_json_bytes_capped(
1993
+ full_body, max_bytes=_OUTLINE_TRANSFER_ITEM_CAP)
1994
+ if payload is None:
1995
+ _reject_outline_transfer_too_large(token, claim=record)
1996
+ else:
1997
+ _materialize_outline_transfer(token, payload, claim=record)
1998
+ except Exception as exc: # noqa: BLE001
1999
+ handler.log_error("outline transfer build failed: %r", exc)
2000
+ _finish_outline_transfer_failure(
2001
+ token, record, 500, {"error": "outline transfer failed"})
2002
+ finally:
2003
+ with _OUTLINE_TRANSFERS_LOCK:
2004
+ _OUTLINE_TRANSFERS_PRODUCERS -= 1
2005
+
2006
+
2007
+ def _request_peer_gone(handler, *, timeout=0.0):
2008
+ """Detect a closed request socket while no response is being written."""
2009
+ conn = getattr(handler, "connection", None)
2010
+ if conn is None:
2011
+ return False
2012
+ try:
2013
+ if not select.select([conn], [], [], timeout)[0]:
2014
+ return False
2015
+ return not conn.recv(
2016
+ 1, socket.MSG_PEEK | getattr(socket, "MSG_DONTWAIT", 0))
2017
+ except (OSError, ValueError):
2018
+ return True
2019
+
2020
+
2021
+ def _wait_outline_transfer(handler, token, record):
2022
+ """Wait only to the shared build deadline, waking for expiry/disconnect."""
2023
+ while True:
2024
+ if _request_peer_gone(handler):
2025
+ return False
2026
+ if record.ready.is_set():
2027
+ if record.failure_status is not None:
2028
+ handler._respond_json(
2029
+ record.failure_status, record.failure_body)
2030
+ return False
2031
+ if record.cancelled.is_set() or record.payload is None:
2032
+ handler._respond_json(
2033
+ 410, {"error": "outline transfer expired"})
2034
+ return False
2035
+ return True
2036
+ now = time.monotonic()
2037
+ if now - record.created > _OUTLINE_TRANSFER_TTL:
2038
+ _cancel_outline_transfer(token, claim=record)
2039
+ handler._respond_json(410, {"error": "outline transfer expired"})
2040
+ return False
2041
+ if now >= record.deadline:
2042
+ _finish_outline_transfer_failure(
2043
+ token, record, 504,
2044
+ {"error": "outline transfer timed out"})
2045
+ handler._respond_json(
2046
+ record.failure_status or 410,
2047
+ record.failure_body or {"error": "outline transfer expired"})
2048
+ return False
2049
+ record.ready.wait(min(0.05, record.deadline - now))
2050
+
2051
+
1895
2052
  def _handle_get_conversation_outline_transfer_impl(handler, path: str) -> None:
1896
2053
  """Serve one bounded chunk from an opaque, immutable outline transfer."""
1897
2054
  if not handler._require_transcripts_allowed():
@@ -1910,42 +2067,33 @@ def _handle_get_conversation_outline_transfer_impl(handler, path: str) -> None:
1910
2067
  return
1911
2068
  else:
1912
2069
  offset = int(values[0])
1913
- claim = _claim_outline_transfer(token)
2070
+ claim = _claim_outline_transfer(token, admit=True)
1914
2071
  if claim is None:
1915
2072
  handler._respond_json(410, {"error": "outline transfer expired"})
1916
2073
  return
1917
2074
  action, record, builder = claim
2075
+ if action == "busy":
2076
+ handler._respond_json(503, {"error": "outline transfer busy"})
2077
+ return
1918
2078
  if action == "build":
1919
- ok, full_body = builder(handler, record.cancelled.is_set)
1920
- if ok is None:
1921
- handler._respond_json(410, {"error": "outline transfer expired"})
1922
- return
1923
- if not ok:
1924
- _cancel_outline_transfer(token, claim=record)
1925
- return
1926
- if full_body is None:
1927
- _cancel_outline_transfer(token, claim=record)
1928
- handler._respond_json(410, {"error": "outline transfer expired"})
1929
- return
1930
- payload = encode_dashboard_json_bytes_capped(
1931
- full_body, max_bytes=_OUTLINE_TRANSFER_ITEM_CAP)
1932
- if payload is None:
1933
- _reject_outline_transfer_too_large(token, claim=record)
1934
- state = "too_large"
1935
- else:
1936
- state = _materialize_outline_transfer(token, payload, claim=record)
1937
- if state == "too_large":
1938
- handler._respond_json(
1939
- 413, {"error": "outline exceeds progressive transfer limit"})
1940
- return
1941
- if state == "expired":
1942
- handler._respond_json(410, {"error": "outline transfer expired"})
1943
- return
1944
- elif action == "wait":
1945
- record.ready.wait(_OUTLINE_TRANSFER_TTL)
1946
- if record.cancelled.is_set() or record.payload is None:
1947
- handler._respond_json(410, {"error": "outline transfer expired"})
1948
- return
2079
+ try:
2080
+ threading.Thread(
2081
+ target=_produce_outline_transfer,
2082
+ args=(token, record, builder, handler),
2083
+ name="cctally-outline-build", daemon=True,
2084
+ ).start()
2085
+ except RuntimeError:
2086
+ with _OUTLINE_TRANSFERS_LOCK:
2087
+ global _OUTLINE_TRANSFERS_PRODUCERS
2088
+ _OUTLINE_TRANSFERS_PRODUCERS -= 1
2089
+ _finish_outline_transfer_failure(
2090
+ token, record, 503, {"error": "outline transfer busy"})
2091
+ if action != "ready":
2092
+ try:
2093
+ if not _wait_outline_transfer(handler, token, record):
2094
+ return
2095
+ finally:
2096
+ _release_outline_transfer_waiter(token, record)
1949
2097
  try:
1950
2098
  body = _read_outline_transfer(token, offset)
1951
2099
  except ValueError:
@@ -2004,6 +2152,33 @@ def _handle_get_conversation_prompts_impl(handler, path: str) -> None:
2004
2152
 
2005
2153
  _CONV_EXPORT_SCOPES = ("all", "prompts", "chat", "recipe")
2006
2154
 
2155
+ #: The #850 M5 fail-closed body, shared by `/export?anonymize=1` and
2156
+ #: `/anon-map`. Both routes answer HTTP 409 with it while any Codex thread
2157
+ #: `cwd` is undecodable: a path the store cannot supply as a scrub token can
2158
+ #: appear in any transcript, so neither route may hand back bytes or a token
2159
+ #: map that only looks scrubbed.
2160
+ _ANON_UNAVAILABLE_STATUS = "anonymization_unavailable"
2161
+ _ANON_UNAVAILABLE_REMEDY = "cctally cache-sync --source codex --rebuild"
2162
+
2163
+
2164
+ def _anon_unavailable_body(undecodable_cwd_rows: int) -> dict:
2165
+ return {
2166
+ "status": _ANON_UNAVAILABLE_STATUS,
2167
+ "undecodable_cwd_rows": int(undecodable_cwd_rows),
2168
+ "remedy": _ANON_UNAVAILABLE_REMEDY,
2169
+ }
2170
+
2171
+
2172
+ def _anon_ambiguous_body(ambiguous_cwd_rows: int) -> dict:
2173
+ """Typed refusal for a scoped path whose account ownership is unproven."""
2174
+ return {
2175
+ "status": _ANON_UNAVAILABLE_STATUS,
2176
+ "ambiguous_cwd_rows": int(ambiguous_cwd_rows),
2177
+ "reason": "ambiguous_account_provenance",
2178
+ "remedy": "use a raw export or omit the account scope",
2179
+ }
2180
+
2181
+
2007
2182
  def _handle_get_conversation_export_impl(handler, path: str) -> None:
2008
2183
  """``GET /api/conversation/<sid>/export?scope=<all|prompts|chat|recipe>``
2009
2184
  — whole-session Markdown (issue #217 S5 F1/F5).
@@ -2061,15 +2236,23 @@ def _handle_get_conversation_export_impl(handler, path: str) -> None:
2061
2236
  cq = handler._conversation_query()
2062
2237
  anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
2063
2238
  srcs = {cref.source} if cref else set()
2064
- plan = cq.build_anon_plan_for_sources(
2239
+ result = cq.build_anon_plan_for_sources(
2065
2240
  conn, home_dir=_os.path.expanduser("~"), sources=srcs)
2066
- return {**env, "markdown": anon.scrub_text(env["markdown"], plan)}
2241
+ if result.undecodable_cwd_rows:
2242
+ return _anon_unavailable_body(result.undecodable_cwd_rows)
2243
+ if result.ambiguous_cwd_rows:
2244
+ return _anon_ambiguous_body(result.ambiguous_cwd_rows)
2245
+ return {**env,
2246
+ "markdown": anon.scrub_text(env["markdown"], result.plan)}
2067
2247
 
2068
2248
  ok, env = handler._run_conversation_query(
2069
2249
  _q_kernel, "/api/conversation/export")
2070
2250
  if not ok:
2071
2251
  return
2072
2252
  status = (env or {}).get("status")
2253
+ if status == _ANON_UNAVAILABLE_STATUS:
2254
+ handler._respond_json(409, env)
2255
+ return
2073
2256
  if status == "ok":
2074
2257
  data = env["markdown"].encode("utf-8")
2075
2258
  handler.send_response(200)
@@ -2151,14 +2334,22 @@ def _handle_get_conversation_anon_map_impl(handler, path: str) -> None:
2151
2334
  exists = cq.conversation_exists(conn, cref.native_key)
2152
2335
  if not exists:
2153
2336
  return None
2154
- plan = cq.build_anon_plan_for_sources(
2337
+ result = cq.build_anon_plan_for_sources(
2155
2338
  conn, home_dir=_os.path.expanduser("~"), sources={cref.source})
2156
- return anon.plan_to_wire(plan)
2339
+ if result.undecodable_cwd_rows:
2340
+ return _anon_unavailable_body(result.undecodable_cwd_rows)
2341
+ if result.ambiguous_cwd_rows:
2342
+ return _anon_ambiguous_body(result.ambiguous_cwd_rows)
2343
+ return anon.plan_to_wire(result.plan)
2157
2344
 
2158
2345
  ok, body = handler._run_conversation_query(
2159
2346
  _q_kernel, "/api/conversation/anon-map")
2160
2347
  if not ok:
2161
2348
  return
2349
+ if isinstance(body, dict) and body.get(
2350
+ "status") == _ANON_UNAVAILABLE_STATUS:
2351
+ handler._respond_json(409, body)
2352
+ return
2162
2353
  if body is None:
2163
2354
  handler._respond_json(
2164
2355
  404, {"status": "not_found", "conversation_key": session_id})
@@ -172,12 +172,16 @@ def _select_current_block_for_envelope(
172
172
  *,
173
173
  current_used_pct: "float | None",
174
174
  now_utc: "dt.datetime",
175
+ account_key: "str | None" = None,
175
176
  ) -> "dict | None":
176
177
  """Select the current 5h block for the dashboard's current_week envelope.
177
178
 
178
179
  Selection rule (spec §4.1): pick the row from ``five_hour_blocks`` whose
179
180
  ``five_hour_window_key`` matches the latest ``weekly_usage_snapshots``
180
- row's ``five_hour_window_key``. Returns None when no row matches —
181
+ row's ``five_hour_window_key``. When ``account_key`` names the account
182
+ whose weekly percentage is displayed, all physical reads here use it;
183
+ a multi-account caller without that identity receives no block. Returns
184
+ None when no row matches —
181
185
  binding to the latest snapshot's key (rather than highest
182
186
  ``block_start_at``) prevents the panel's "current 5h session" copy from
183
187
  surfacing stale closed blocks.
@@ -236,34 +240,34 @@ def _select_current_block_for_envelope(
236
240
  SELECT five_hour_window_key, week_start_at
237
241
  FROM weekly_usage_snapshots
238
242
  WHERE captured_at_utc <= ?
243
+ AND (? IS NULL OR account_key = ?)
239
244
  ORDER BY captured_at_utc DESC, id DESC
240
245
  LIMIT 1
241
246
  """,
242
- (_iso_z(now_utc),),
247
+ (_iso_z(now_utc), account_key, account_key),
243
248
  ).fetchone()
244
249
  if snap is None or snap["five_hour_window_key"] is None:
245
250
  return None
246
251
 
252
+ # The account supplied by `_tui_build_current_week` is the owner of the
253
+ # displayed weekly percentage. On a decorated multi-account store the
254
+ # absence of that identity is not permission to select an arbitrary block.
255
+ import _cctally_account
256
+ if account_key is None and _cctally_account.provider_is_decorated(
257
+ conn, "claude"):
258
+ return None
259
+
247
260
  # #834 S1 (#836): `id` and `account_key` are selected so the live
248
261
  # current-week milestone read can name ONE account's block. Block uniqueness
249
262
  # is `(account_key, five_hour_window_key)`, and the milestone read used to
250
263
  # filter on the window key alone, so one physical window's per-account blocks
251
264
  # all showed every account's milestones. Both land on the returned dict under
252
- # a leading underscore and `_five_hour_block_wire` strips them before
253
- # publication, so the envelope's wire shape is unchanged — and no account key
254
- # reaches a client that #341's R8 rule says must see no account decoration at
255
- # all on a single-account install.
265
+ # a leading underscore. `_five_hour_block_wire` strips the selectors and
266
+ # publishes the account identity only above #341's R8 decoration threshold.
256
267
  #
257
- # `ORDER BY id ASC LIMIT 1` (#834 S1 Gate A R6): block uniqueness is
258
- # `(account_key, five_hour_window_key)`, so two accounts observing one physical
259
- # window own two rows here and this query had no order and no limit — SQLite
260
- # decided which one `fetchone()` returned, and since #836 `_block_id` inherits
261
- # that decision and it also decides whose milestones the live route loads. The
262
- # order is `id` because that is what the current plan already yields: the
263
- # `UNIQUE` index leads on `account_key`, so it is unusable for this predicate
264
- # and SQLite scans the table in rowid order. Stating it therefore changes
265
- # nothing about WHICH ACCOUNT IS SERVED, which is a product decision filed as
266
- # #839 and deliberately not taken here.
268
+ # Account-qualified selection binds the block and its downstream milestone
269
+ # read to the displayed weekly account; the unqualified order remains the
270
+ # historical <=1-real-account fallback for published byte stability.
267
271
  # #834 S1 (#835) Gate A R8: `first_observed_at_utc` and `five_hour_resets_at`
268
272
  # are selected because the credit-aware read below needs them — they are the
269
273
  # START axis's capture instant and its fallback. `seven_day_pct_at_block_end`
@@ -286,14 +290,16 @@ def _select_current_block_for_envelope(
286
290
  first_observed_at_utc, last_observed_at_utc,
287
291
  seven_day_pct_at_block_start,
288
292
  crossed_seven_day_reset
289
- FROM five_hour_blocks
293
+ FROM five_hour_blocks
290
294
  WHERE five_hour_window_key = ?
295
+ AND (? IS NULL OR account_key = ?)
291
296
  AND is_closed = 0
292
297
  AND five_hour_resets_at > ?
293
298
  ORDER BY id ASC
294
299
  LIMIT 1
295
300
  """,
296
- (snap["five_hour_window_key"], _iso_z(now_utc)),
301
+ (snap["five_hour_window_key"], account_key, account_key,
302
+ _iso_z(now_utc)),
297
303
  ).fetchone()
298
304
  if block is None:
299
305
  return None
@@ -337,8 +343,9 @@ def _select_current_block_for_envelope(
337
343
  post = conn.execute(
338
344
  """
339
345
  SELECT weekly_percent
340
- FROM weekly_usage_snapshots
346
+ FROM weekly_usage_snapshots
341
347
  WHERE week_start_at = ?
348
+ AND (? IS NULL OR account_key = ?)
342
349
  AND weekly_observation_held = 0
343
350
  AND unixepoch(captured_at_utc) >= unixepoch(?)
344
351
  AND unixepoch(captured_at_utc) <= unixepoch(?)
@@ -347,6 +354,7 @@ def _select_current_block_for_envelope(
347
354
  """,
348
355
  (
349
356
  snap["week_start_at"],
357
+ account_key, account_key,
350
358
  block["block_start_at"],
351
359
  _iso_z(now_utc),
352
360
  ),
@@ -370,9 +378,10 @@ def _select_current_block_for_envelope(
370
378
  SELECT effective_reset_at_utc, prior_percent, post_percent
371
379
  FROM five_hour_reset_events
372
380
  WHERE five_hour_window_key = ?
381
+ AND (? IS NULL OR account_key = ?)
373
382
  ORDER BY effective_reset_at_utc ASC
374
383
  """,
375
- (int(block["five_hour_window_key"]),),
384
+ (int(block["five_hour_window_key"]), account_key, account_key),
376
385
  ).fetchall()
377
386
  credits = [
378
387
  {
@@ -397,26 +406,28 @@ def _select_current_block_for_envelope(
397
406
  # `_five_hour_block_wire` before publication. See the block SELECT above.
398
407
  "_block_id": int(block["id"]),
399
408
  "_account_key": block["account_key"],
409
+ "_account_decorated": _cctally_account.provider_is_decorated(
410
+ conn, "claude"),
400
411
  }
401
412
 
402
413
 
403
- #: The internal selector fields `_select_current_block_for_envelope` carries for
404
- #: the live milestone read. They are NOT part of the published envelope: a rowid
405
- #: is meaningless to a client, and an account key would be account decoration on
406
- #: a surface #341's R8 rule keeps undecorated below two real accounts.
407
- _INTERNAL_BLOCK_KEYS = ("_block_id", "_account_key")
414
+ #: The selector fields retained for downstream reads. A rowid is meaningless to
415
+ #: a client; the account key is published only when #341's R8 gate permits
416
+ #: account decoration (more than one real Claude account).
417
+ _INTERNAL_BLOCK_KEYS = ("_block_id", "_account_key", "_account_decorated")
408
418
 
409
419
 
410
420
  def _five_hour_block_wire(block: "dict | None") -> "dict | None":
411
- """Drop the internal selector fields from a current-week block dict.
421
+ """Publish the current block with R8-gated account identity.
412
422
 
413
- #834 S1 (#836). Called at the ONE publication site so the envelope's
414
- `current_week.five_hour_block` shape is byte-unchanged. A pure function over a
415
- copy — the caller's dict keeps its selectors, because the live milestone read
416
- runs off the same object."""
423
+ Single-account output keeps the historical six-key shape. The caller's
424
+ dict retains its selectors for the live milestone read."""
417
425
  if not isinstance(block, dict):
418
426
  return block
419
- return {k: v for k, v in block.items() if k not in _INTERNAL_BLOCK_KEYS}
427
+ wire = {k: v for k, v in block.items() if k not in _INTERNAL_BLOCK_KEYS}
428
+ if block.get("_account_decorated"):
429
+ wire["account_key"] = block["_account_key"]
430
+ return wire
420
431
 
421
432
 
422
433
  # === Alerts-envelope per-axis row-mappers (Task F) =========================
@@ -1886,7 +1897,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1886
1897
  }
1887
1898
 
1888
1899
  def _blocks_row_to_dict(r: "BlocksPanelRow") -> dict:
1889
- return {
1900
+ row = {
1890
1901
  "start_at": r.start_at,
1891
1902
  "end_at": r.end_at,
1892
1903
  "anchor": r.anchor,
@@ -1895,6 +1906,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1895
1906
  "models": list(r.models),
1896
1907
  "label": r.label,
1897
1908
  }
1909
+ # Additive only for retained rows: the client treats absence as
1910
+ # computed, preserving the pre-existing computed-row wire shape.
1911
+ if r.facts_source == "retained":
1912
+ row["facts_source"] = "retained"
1913
+ return row
1898
1914
 
1899
1915
  def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
1900
1916
  # #556 S2 §6.3a: one owner for the daily row wire shape. The All-only