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.
- package/CHANGELOG.md +52 -0
- package/README.md +6 -6
- package/bin/_cctally_cache.py +62 -13
- package/bin/_cctally_core.py +8 -1
- package/bin/_cctally_dashboard.py +422 -195
- package/bin/_cctally_dashboard_conversation.py +231 -40
- package/bin/_cctally_dashboard_envelope.py +48 -32
- package/bin/_cctally_dashboard_sources.py +503 -109
- package/bin/_cctally_db.py +44 -0
- package/bin/_cctally_diagnosis_sources.py +181 -15
- package/bin/_cctally_doctor.py +15 -3
- package/bin/_cctally_forecast.py +25 -12
- package/bin/_cctally_journal.py +59 -32
- package/bin/_cctally_milestone_history.py +65 -9
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_record.py +50 -12
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_source_analytics.py +567 -133
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +39 -6
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_codex_conversation_query.py +286 -63
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation_query.py +254 -37
- package/bin/_lib_doctor.py +16 -2
- package/bin/_lib_pricing.py +80 -276
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_view_models.py +5 -0
- package/bin/cctally +18 -5
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +2 -1
- package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
- package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
- package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
- package/dashboard/static/assets/index-DLVwACUM.js +0 -13
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
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
|
-
|
|
2239
|
+
result = cq.build_anon_plan_for_sources(
|
|
2065
2240
|
conn, home_dir=_os.path.expanduser("~"), sources=srcs)
|
|
2066
|
-
|
|
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
|
-
|
|
2337
|
+
result = cq.build_anon_plan_for_sources(
|
|
2155
2338
|
conn, home_dir=_os.path.expanduser("~"), sources={cref.source})
|
|
2156
|
-
|
|
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``.
|
|
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
|
|
253
|
-
#
|
|
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
|
-
#
|
|
258
|
-
#
|
|
259
|
-
#
|
|
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
|
-
|
|
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"],
|
|
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
|
-
|
|
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
|
|
404
|
-
#:
|
|
405
|
-
#:
|
|
406
|
-
|
|
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
|
-
"""
|
|
421
|
+
"""Publish the current block with R8-gated account identity.
|
|
412
422
|
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|