cctally 1.64.0 → 1.66.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 +50 -0
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +262 -40
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +49 -14
- package/bin/_cctally_dashboard.py +827 -4911
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +771 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +127 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +2 -1
- package/bin/_cctally_forecast.py +78 -135
- package/bin/_cctally_parser.py +919 -784
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +8 -5
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_tui.py +487 -254
- package/bin/_cctally_update.py +5 -0
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +34 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +18 -10
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +51 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +11 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""Dashboard conversation-viewer handlers (#279 S5 F4 — line-budget seam).
|
|
2
|
+
|
|
3
|
+
Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
|
|
4
|
+
name below, so ``bin/cctally``'s re-exports and the conversation pytest
|
|
5
|
+
files (``tests/test_conversation_endpoints.py``,
|
|
6
|
+
``tests/test_conversation_query.py``) keep resolving unchanged (spec §6).
|
|
7
|
+
|
|
8
|
+
What lives here (spec §6):
|
|
9
|
+
- the conversation constants/helpers ``_CONV_SEARCH_KINDS`` /
|
|
10
|
+
``_CONV_FIND_KINDS`` / ``_BadConversationFilter`` / ``_cached_file_sigs``
|
|
11
|
+
(the ``/api/debug/backend`` helpers + ``_DEBUG_CACHE_TABLES`` +
|
|
12
|
+
``_handle_get_debug_backend`` are debug-coupled and STAY in the dashboard,
|
|
13
|
+
gate P3-2);
|
|
14
|
+
- the query plumbing (``_conversation_query_impl`` / ``_parse_search_kind_impl``
|
|
15
|
+
/ ``_run_conversation_query_impl`` / ``_parse_conversation_filters_impl``);
|
|
16
|
+
- the eleven ``_handle_get_conversation*_impl(handler, …)`` handlers
|
|
17
|
+
(including ``_handle_get_conversation_events_impl``, the live-tail SSE
|
|
18
|
+
watch loop, with its ``_lib_conversation_watch`` usage). The dashboard
|
|
19
|
+
keeps thin bound delegators on ``DashboardHTTPHandler``; the privacy gates
|
|
20
|
+
(``_require_transcripts_allowed`` / ``_transcript_gate`` / …) STAY on the
|
|
21
|
+
class and are reached via the ``handler`` parameter (spec §6).
|
|
22
|
+
|
|
23
|
+
Late-binding (spec §6): the two bare ``open_cache_db`` calls (in
|
|
24
|
+
``_run_conversation_query_impl`` + the events handler) reach
|
|
25
|
+
``sys.modules["_cctally_dashboard"].open_cache_db(...)`` — patched on the
|
|
26
|
+
dashboard module object at ``tests/test_conversation_endpoints.py:674``.
|
|
27
|
+
|
|
28
|
+
Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
|
|
29
|
+
cctally-forwarding shims the moved code called by bare name
|
|
30
|
+
(``_resolve_display_tz_obj`` / ``_apply_display_tz_override`` / ``load_config``)
|
|
31
|
+
are inlined to their ``sys.modules["cctally"].X`` call-time reach — behavior
|
|
32
|
+
+ ns["X"] patch surface preserved (none is dashboard-object-patched;
|
|
33
|
+
audited). The generic query-string helpers ``_qs_str`` / ``_qs_int`` stay in
|
|
34
|
+
the dashboard (used across every handler); they have 24 call sites here, so
|
|
35
|
+
they are reached via module-local forwarders (below) rather than 24 inline
|
|
36
|
+
edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
|
|
37
|
+
(no cycle: the dashboard is fully loaded before any handler runs).
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import socket
|
|
43
|
+
import sqlite3
|
|
44
|
+
import sys
|
|
45
|
+
|
|
46
|
+
from _cctally_cache import sync_cache
|
|
47
|
+
|
|
48
|
+
# Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
|
|
49
|
+
# below, so moved here with the events handler (spec §4.1 / §6).
|
|
50
|
+
_LIVE_TAIL_POLL_INTERVAL = 1.0 # seconds between stat polls of the open file(s)
|
|
51
|
+
_LIVE_TAIL_DEBOUNCE = 0.25 # settle window after first detected growth
|
|
52
|
+
_LIVE_TAIL_KEEPALIVE = 15.0 # idle keep-alive cadence (proxy guard)
|
|
53
|
+
_LIVE_TAIL_FILE_RESET_EVERY = 10 # re-resolve the session file set every N cycles
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Module-local forwarders for the two generic query-string helpers that STAY
|
|
57
|
+
# in the dashboard (24 call sites in the moved handlers; the dashboard is fully
|
|
58
|
+
# loaded before any handler runs, so the call-time reach is cycle-free).
|
|
59
|
+
def _qs_str(*args, **kwargs):
|
|
60
|
+
return sys.modules["_cctally_dashboard"]._qs_str(*args, **kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _qs_int(*args, **kwargs):
|
|
64
|
+
return sys.modules["_cctally_dashboard"]._qs_int(*args, **kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# #177 S6 / #217 S2: valid kind facets for the conversation routes. Kept in
|
|
68
|
+
# lockstep with the kernel (``_lib_conversation_query._SEARCH_KINDS`` /
|
|
69
|
+
# ``_FIND_KINDS``; the kernel re-raises ValueError on an unknown kind, and the
|
|
70
|
+
# handlers reject with a 400 BEFORE the call — ``_run_conversation_query``
|
|
71
|
+
# collapses every kernel exception to a 500, so a per-route 4xx must be decided
|
|
72
|
+
# in the handler, not via try/except around the kernel).
|
|
73
|
+
#
|
|
74
|
+
# P1-1 (load-bearing kind-validation SPLIT): the cross-session search route
|
|
75
|
+
# accepts ``title`` and ``files``; the in-conversation ``/find`` route does NOT —
|
|
76
|
+
# its kernel (``find_in_conversation``) indexes ``_FIND_KIND_COLUMNS[kind]``,
|
|
77
|
+
# which has no ``title``/``files`` entry, so accepting them there would be a 500
|
|
78
|
+
# KeyError. Two distinct tuples keep ``/find?kind=title`` and ``/find?kind=files``
|
|
79
|
+
# a clean 400.
|
|
80
|
+
_CONV_SEARCH_KINDS = (
|
|
81
|
+
"all", "prompts", "assistant", "tools", "thinking", "title", "files")
|
|
82
|
+
_CONV_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _BadConversationFilter(Exception):
|
|
86
|
+
"""Internal sentinel: a browse-filter query param failed validation. The
|
|
87
|
+
parse helper has ALREADY sent the 400 response when this is raised, so the
|
|
88
|
+
caller just unwinds and returns (the conversation routes all 400 on bad
|
|
89
|
+
input, consistent with the search ``kind`` facet). Module-private."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cached_file_sigs(conn, paths):
|
|
93
|
+
"""{path: size_bytes} from session_files for the given paths — the cache's
|
|
94
|
+
own view of how far each file is ingested. Size-only by design, matching the
|
|
95
|
+
watch kernel's size-only signature (`file_sig`) and sync_cache's size-only
|
|
96
|
+
delta signal: mtime is NOT consulted, because a size-unchanged ingest does
|
|
97
|
+
not refresh session_files.mtime_ns and a stale mtime would re-detect
|
|
98
|
+
'changed' every cycle forever. Used to baseline the live-tail watch so a file
|
|
99
|
+
the cache hasn't caught up on reads as 'changed' on cycle 1 (spec §2.4).
|
|
100
|
+
Paths with no row are simply absent → treated as changed."""
|
|
101
|
+
out = {}
|
|
102
|
+
if not paths:
|
|
103
|
+
return out
|
|
104
|
+
placeholders = ",".join("?" for _ in paths)
|
|
105
|
+
try:
|
|
106
|
+
rows = conn.execute(
|
|
107
|
+
f"SELECT path, size_bytes FROM session_files "
|
|
108
|
+
f"WHERE path IN ({placeholders})", list(paths)).fetchall()
|
|
109
|
+
except sqlite3.OperationalError:
|
|
110
|
+
return out
|
|
111
|
+
for p, size in rows:
|
|
112
|
+
out[p] = size
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _conversation_query_impl():
|
|
117
|
+
"""Lazy-load the pure conversation query kernel (Plan 2, §3)."""
|
|
118
|
+
return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
|
|
119
|
+
|
|
120
|
+
def _parse_search_kind_impl(handler, q, valid=_CONV_SEARCH_KINDS):
|
|
121
|
+
"""Read + validate the ``kind`` facet for a conversation route (#177 S6 /
|
|
122
|
+
#217 S2). Returns the kind on success, or ``None`` after having ALREADY
|
|
123
|
+
sent a 400 — callers just ``return`` on ``None``.
|
|
124
|
+
|
|
125
|
+
``valid`` is the per-route kind set (P1-1 split): the cross-session search
|
|
126
|
+
route passes ``_CONV_SEARCH_KINDS`` (includes ``title``), the
|
|
127
|
+
in-conversation ``/find`` route passes ``_CONV_FIND_KINDS`` (excludes
|
|
128
|
+
``title``/``files``), so ``/find?kind=title`` is a 400 here — never a 500
|
|
129
|
+
KeyError downstream in ``find_in_conversation``. Kept in lockstep with the
|
|
130
|
+
kernel's ``_SEARCH_KINDS`` / ``_FIND_KINDS`` (the kernel module is
|
|
131
|
+
resolved lazily per-request, so the handler keeps literal tuples rather
|
|
132
|
+
than reaching across that import edge for a nit)."""
|
|
133
|
+
kind = _qs_str(q, "kind", "all")
|
|
134
|
+
if kind not in valid:
|
|
135
|
+
handler._respond_json(400, {"error": f"unknown kind: {kind}"})
|
|
136
|
+
return None
|
|
137
|
+
return kind
|
|
138
|
+
|
|
139
|
+
def _run_conversation_query_impl(handler, kernel_call, log_label):
|
|
140
|
+
"""Open cache.db, run ``kernel_call(conn)``, close — with the uniform
|
|
141
|
+
500 envelopes the three conversation routes share (#151).
|
|
142
|
+
|
|
143
|
+
Collapses the triplicated open-cache → try/except/finally → 500
|
|
144
|
+
scaffold to one site. Returns ``(ok, body)``: ``ok=False`` means a 500
|
|
145
|
+
has ALREADY been sent and the caller must just ``return``; ``ok=True``
|
|
146
|
+
carries the kernel result (which may itself be ``None`` — the reader's
|
|
147
|
+
404 sentinel — so the explicit flag, not ``body is None``, signals
|
|
148
|
+
failure). An ``open_cache_db`` failure is a ``cache unavailable:`` 500;
|
|
149
|
+
a kernel exception is logged as ``<log_label> failed: %r`` and returned
|
|
150
|
+
as a ``{type}: {msg}`` 500 — byte-identical to the inlined handlers.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
|
|
154
|
+
except (sqlite3.DatabaseError, OSError) as exc:
|
|
155
|
+
handler._respond_json(500, {"error": f"cache unavailable: {exc}"})
|
|
156
|
+
return False, None
|
|
157
|
+
try:
|
|
158
|
+
body = kernel_call(conn)
|
|
159
|
+
except Exception as exc: # noqa: BLE001
|
|
160
|
+
handler.log_error("%s failed: %r", log_label, exc)
|
|
161
|
+
handler._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
162
|
+
return False, None
|
|
163
|
+
finally:
|
|
164
|
+
conn.close()
|
|
165
|
+
return True, body
|
|
166
|
+
|
|
167
|
+
def _parse_conversation_filters_impl(handler, q):
|
|
168
|
+
"""Parse the browse-list filter params (spec §2) from a ``parse_qs``
|
|
169
|
+
mapping. On any malformed value this sends a **400** and returns
|
|
170
|
+
``None`` — the caller just ``return``s (the conversation routes all 400
|
|
171
|
+
on bad input). On success returns a dict of ``list_conversations``
|
|
172
|
+
kwargs: ``date_from``/``date_to`` (UTC-ISO bounds), ``projects``
|
|
173
|
+
(list[str] | None), ``cost_min``/``cost_max`` (float | None),
|
|
174
|
+
``rebuild_min`` (int | None), ``models`` (list[str] | None — the #278
|
|
175
|
+
Theme C model-family axis). Empty/blank params drop to ``None``.
|
|
176
|
+
|
|
177
|
+
Numeric axes validate strictly (a non-numeric cost / non-integer
|
|
178
|
+
rebuild threshold is a hard 400). Date bounds route through the pure
|
|
179
|
+
``_lib_dashboard_dates.parse_filter_date_range`` helper, which resolves
|
|
180
|
+
naive date-only bounds in ``display.tz`` and raises ``ValueError`` (→
|
|
181
|
+
400) on a malformed date. Projects AND models accept BOTH repeated
|
|
182
|
+
``?projects=a&projects=b`` and a single comma-joined ``?projects=a,b``.
|
|
183
|
+
"""
|
|
184
|
+
def _float(name):
|
|
185
|
+
v = _qs_str(q, name, "")
|
|
186
|
+
if v is None or v == "":
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
return float(v)
|
|
190
|
+
except ValueError:
|
|
191
|
+
handler._respond_json(400, {"error": f"bad {name}: {v}"})
|
|
192
|
+
raise _BadConversationFilter
|
|
193
|
+
|
|
194
|
+
def _int(name):
|
|
195
|
+
v = _qs_str(q, name, "")
|
|
196
|
+
if v is None or v == "":
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
return int(v)
|
|
200
|
+
except ValueError:
|
|
201
|
+
handler._respond_json(400, {"error": f"bad {name}: {v}"})
|
|
202
|
+
raise _BadConversationFilter
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
cost_min = _float("cost_min")
|
|
206
|
+
cost_max = _float("cost_max")
|
|
207
|
+
rebuild_min = _int("rebuild_min")
|
|
208
|
+
except _BadConversationFilter:
|
|
209
|
+
return None # 400 already sent
|
|
210
|
+
|
|
211
|
+
projects = [p for p in q.get("projects", []) if p] or None
|
|
212
|
+
# Single comma-joined value -> split (the client may send either form).
|
|
213
|
+
if projects and len(projects) == 1 and "," in projects[0]:
|
|
214
|
+
projects = [s for s in projects[0].split(",") if s] or None
|
|
215
|
+
|
|
216
|
+
# #278 Theme C: the model-family axis, mirroring projects. Accepts both
|
|
217
|
+
# repeated ?models=opus&models=sonnet and a single comma-joined
|
|
218
|
+
# ?models=opus,sonnet; blank/empty -> None. No numeric validation (enum-ish
|
|
219
|
+
# strings); an unknown/typo'd family is a PRESENT axis that resolves to
|
|
220
|
+
# zero ids in _model_clause -> zero results, never a silent unrestrict.
|
|
221
|
+
models = [m for m in q.get("models", []) if m] or None
|
|
222
|
+
if models and len(models) == 1 and "," in models[0]:
|
|
223
|
+
models = [s for s in models[0].split(",") if s] or None
|
|
224
|
+
|
|
225
|
+
date_from = _qs_str(q, "date_from", "") or None
|
|
226
|
+
date_to = _qs_str(q, "date_to", "") or None
|
|
227
|
+
if date_from or date_to:
|
|
228
|
+
from importlib import import_module
|
|
229
|
+
tz = sys.modules["cctally"]._resolve_display_tz_obj(
|
|
230
|
+
sys.modules["cctally"]._apply_display_tz_override(
|
|
231
|
+
sys.modules["cctally"].load_config(), type(handler).display_tz_pref_override
|
|
232
|
+
)
|
|
233
|
+
).key
|
|
234
|
+
try:
|
|
235
|
+
df, dtt = import_module(
|
|
236
|
+
"_lib_dashboard_dates"
|
|
237
|
+
).parse_filter_date_range(date_from, date_to, tz_name=tz)
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
handler._respond_json(400, {"error": str(exc)})
|
|
240
|
+
return None
|
|
241
|
+
else:
|
|
242
|
+
df = dtt = None
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"date_from": df,
|
|
246
|
+
"date_to": dtt,
|
|
247
|
+
"projects": projects,
|
|
248
|
+
"cost_min": cost_min,
|
|
249
|
+
"cost_max": cost_max,
|
|
250
|
+
"rebuild_min": rebuild_min,
|
|
251
|
+
"models": models,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def _handle_get_conversations_impl(handler) -> None:
|
|
255
|
+
"""``GET /api/conversations`` — the browse rail (spec §3.1).
|
|
256
|
+
|
|
257
|
+
Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
|
|
258
|
+
are read from the query string; the kernel clamps bounds. The browse
|
|
259
|
+
filters (date/project/cost/rebuild — spec §2) are parsed/validated here
|
|
260
|
+
(malformed → 400) and threaded into the kernel. Cache-open failures are
|
|
261
|
+
500s, never 5xx-with-stacktrace.
|
|
262
|
+
"""
|
|
263
|
+
if not handler._require_transcripts_allowed():
|
|
264
|
+
return
|
|
265
|
+
import urllib.parse as _u
|
|
266
|
+
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
267
|
+
sort = _qs_str(q, "sort", "recent")
|
|
268
|
+
limit = _qs_int(q, "limit", 50)
|
|
269
|
+
offset = _qs_int(q, "offset", 0)
|
|
270
|
+
filters = handler._parse_conversation_filters(q)
|
|
271
|
+
if filters is None:
|
|
272
|
+
return # a 400 has already been sent
|
|
273
|
+
ok, body = handler._run_conversation_query(
|
|
274
|
+
lambda conn: handler._conversation_query().list_conversations(
|
|
275
|
+
conn, sort=sort, limit=limit, offset=offset, **filters),
|
|
276
|
+
"/api/conversations")
|
|
277
|
+
if not ok:
|
|
278
|
+
return
|
|
279
|
+
handler._respond_json(200, body)
|
|
280
|
+
|
|
281
|
+
def _handle_get_conversations_facets_impl(handler) -> None:
|
|
282
|
+
"""``GET /api/conversations/facets`` — distinct project labels + their
|
|
283
|
+
conversation counts, for the browse filter's project multi-select (spec
|
|
284
|
+
§2). Behind the SAME loopback/Host privacy gate as the list route; a
|
|
285
|
+
cheap indexed GROUP BY over the rollup. The popover loads its options
|
|
286
|
+
once from here (deriving from a paginated page would be incomplete).
|
|
287
|
+
"""
|
|
288
|
+
if not handler._require_transcripts_allowed():
|
|
289
|
+
return
|
|
290
|
+
ok, body = handler._run_conversation_query(
|
|
291
|
+
lambda conn: handler._conversation_query().list_conversation_facets(conn),
|
|
292
|
+
"/api/conversations/facets")
|
|
293
|
+
if not ok:
|
|
294
|
+
return
|
|
295
|
+
handler._respond_json(200, body)
|
|
296
|
+
|
|
297
|
+
def _handle_get_conversation_detail_impl(handler, path: str) -> None:
|
|
298
|
+
"""``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
|
|
299
|
+
|
|
300
|
+
The id is percent-decoded so clients that encode reserved chars
|
|
301
|
+
round-trip. Unknown id → 404. ``after``/``before``/``tail``/``limit``
|
|
302
|
+
page the items; ``after``/``before``/``tail`` are mutually exclusive
|
|
303
|
+
(>1 supplied → 400). ``tail=1`` opens at the bottom; ``before=<id>``
|
|
304
|
+
pages backward (#217 S2 / U4).
|
|
305
|
+
"""
|
|
306
|
+
if not handler._require_transcripts_allowed():
|
|
307
|
+
return
|
|
308
|
+
import urllib.parse as _u
|
|
309
|
+
# ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
|
|
310
|
+
# so the cursor params (?after=/?before=/?tail=/?limit=) live ONLY on the
|
|
311
|
+
# raw ``self.path``. Sibling handlers read ``self.path`` directly — the
|
|
312
|
+
# detail route must too, or every request re-serves the head and
|
|
313
|
+
# pagination is dead.
|
|
314
|
+
query_str = handler.path.partition("?")[2]
|
|
315
|
+
session_id = _u.unquote(path[len("/api/conversation/"):])
|
|
316
|
+
if not session_id:
|
|
317
|
+
handler.send_error(404, "conversation not found")
|
|
318
|
+
return
|
|
319
|
+
q = _u.parse_qs(query_str)
|
|
320
|
+
after = _qs_str(q, "after", None)
|
|
321
|
+
before = _qs_str(q, "before", None)
|
|
322
|
+
tail = _qs_str(q, "tail", None) in ("1", "true", "yes")
|
|
323
|
+
limit = _qs_int(q, "limit", 500)
|
|
324
|
+
# Mutual-exclusion 400 (#217 S2 / U4). The kernel ALSO raises ValueError
|
|
325
|
+
# on >1 cursor as its own invariant, but ``_run_conversation_query``
|
|
326
|
+
# collapses every kernel exception to a 500, so the 400 must be decided
|
|
327
|
+
# HERE, before the kernel call — this explicit pre-call check is the
|
|
328
|
+
# authoritative backstop for the handler path.
|
|
329
|
+
if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
|
|
330
|
+
handler.send_error(400, "after/before/tail are mutually exclusive")
|
|
331
|
+
return
|
|
332
|
+
ok, body = handler._run_conversation_query(
|
|
333
|
+
lambda conn: handler._conversation_query().get_conversation(
|
|
334
|
+
conn, session_id, after=after, before=before, tail=tail,
|
|
335
|
+
limit=limit),
|
|
336
|
+
"/api/conversation")
|
|
337
|
+
if not ok:
|
|
338
|
+
return
|
|
339
|
+
if body is None:
|
|
340
|
+
handler.send_error(404, "conversation not found")
|
|
341
|
+
return
|
|
342
|
+
handler._respond_json(200, body)
|
|
343
|
+
|
|
344
|
+
def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
345
|
+
"""``GET /api/conversation/<id>/events`` — per-conversation live-tail
|
|
346
|
+
SSE (spec §2). Fail-closed behind the same transcript privacy gate as
|
|
347
|
+
the other conversation routes. Watches only this session's file(s);
|
|
348
|
+
emits ``event: tail`` on growth, ``: keep-alive`` when idle. Passive
|
|
349
|
+
(no ingest, no emit) under ``--no-sync``."""
|
|
350
|
+
if not handler._require_transcripts_allowed():
|
|
351
|
+
return
|
|
352
|
+
import time as _time
|
|
353
|
+
import urllib.parse as _u
|
|
354
|
+
watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
|
|
355
|
+
cq = handler._conversation_query()
|
|
356
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
357
|
+
if not session_id:
|
|
358
|
+
handler.send_error(404, "conversation not found")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
handler.send_response(200)
|
|
362
|
+
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
363
|
+
handler.send_header("Cache-Control", "no-cache")
|
|
364
|
+
handler.send_header("Connection", "keep-alive")
|
|
365
|
+
handler.send_header("X-Accel-Buffering", "no")
|
|
366
|
+
handler.end_headers()
|
|
367
|
+
|
|
368
|
+
passive = bool(type(handler).no_sync)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
|
|
372
|
+
except (sqlite3.DatabaseError, OSError):
|
|
373
|
+
# Cache unavailable — degrade to keep-alive only; client backstop
|
|
374
|
+
# tick still surfaces turns. (Headers already sent; can't 500.)
|
|
375
|
+
passive = True
|
|
376
|
+
conn = None
|
|
377
|
+
|
|
378
|
+
def _resolve():
|
|
379
|
+
return cq.session_source_paths(conn, session_id) if conn else []
|
|
380
|
+
|
|
381
|
+
def _ingest(changed):
|
|
382
|
+
return sync_cache(conn, only_paths=set(changed))
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
if passive:
|
|
386
|
+
# Frozen-data contract: no ingest, no emit. Keep-alive only.
|
|
387
|
+
while True:
|
|
388
|
+
_time.sleep(_LIVE_TAIL_KEEPALIVE)
|
|
389
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
390
|
+
handler.wfile.flush()
|
|
391
|
+
|
|
392
|
+
# #278 Theme B: signal that this connection is ACTIVELY live-tailing
|
|
393
|
+
# (not degraded to keep-alive). The client sets `live` only on this, so
|
|
394
|
+
# passive/cache-open-failed streams fall back to the memo-backed global
|
|
395
|
+
# tick instead of stranding updates (Codex F4). Passive branch above
|
|
396
|
+
# emits only ': keep-alive', so 'ready' is unambiguous.
|
|
397
|
+
handler.wfile.write(b"event: ready\ndata: {}\n\n")
|
|
398
|
+
handler.wfile.flush()
|
|
399
|
+
|
|
400
|
+
files = _resolve()
|
|
401
|
+
# Best-effort connect ingest for immediacy, then baseline `seen`
|
|
402
|
+
# from the cache's own offsets (session_files) so any pre-connect
|
|
403
|
+
# growth the connect-ingest declined is still caught on cycle 1.
|
|
404
|
+
try:
|
|
405
|
+
if files:
|
|
406
|
+
sync_cache(conn, only_paths=set(files))
|
|
407
|
+
except sqlite3.DatabaseError:
|
|
408
|
+
pass
|
|
409
|
+
seen = _cached_file_sigs(conn, files)
|
|
410
|
+
|
|
411
|
+
idle = 0.0
|
|
412
|
+
cycles = 0
|
|
413
|
+
while True:
|
|
414
|
+
_time.sleep(_LIVE_TAIL_POLL_INTERVAL)
|
|
415
|
+
cycles += 1
|
|
416
|
+
changed = watch.changed_paths(files, seen)
|
|
417
|
+
if changed:
|
|
418
|
+
_time.sleep(_LIVE_TAIL_DEBOUNCE)
|
|
419
|
+
new_seen, emitted = watch.watch_step(
|
|
420
|
+
files, seen, ingest_fn=_ingest,
|
|
421
|
+
committed_sig_fn=lambda p: _cached_file_sigs(conn, [p]).get(p))
|
|
422
|
+
seen = new_seen
|
|
423
|
+
if emitted:
|
|
424
|
+
handler.wfile.write(
|
|
425
|
+
("event: tail\ndata: "
|
|
426
|
+
+ json.dumps({"sessionId": session_id})
|
|
427
|
+
+ "\n\n").encode("utf-8"))
|
|
428
|
+
handler.wfile.flush()
|
|
429
|
+
idle = 0.0
|
|
430
|
+
# §6 P2-H — a brand-new subagent file's FIRST content was
|
|
431
|
+
# just ingested by this emitting cycle, so the session's
|
|
432
|
+
# source-path set may have grown. Re-resolve it now (vs
|
|
433
|
+
# waiting up to _LIVE_TAIL_FILE_RESET_EVERY cycles) so the
|
|
434
|
+
# new thread (incl. a skill invoked inside it) live-tails
|
|
435
|
+
# promptly. A new path seeds seen=None (cur lacks a row),
|
|
436
|
+
# so changed_paths flags it next cycle → it ingests + emits.
|
|
437
|
+
# setdefault never disturbs an existing cursor.
|
|
438
|
+
new_files = _resolve()
|
|
439
|
+
if set(new_files) != set(files):
|
|
440
|
+
files = new_files
|
|
441
|
+
cur = _cached_file_sigs(conn, files)
|
|
442
|
+
for p in files:
|
|
443
|
+
seen.setdefault(p, cur.get(p))
|
|
444
|
+
continue
|
|
445
|
+
idle += _LIVE_TAIL_POLL_INTERVAL
|
|
446
|
+
if idle >= _LIVE_TAIL_KEEPALIVE:
|
|
447
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
448
|
+
handler.wfile.flush()
|
|
449
|
+
idle = 0.0
|
|
450
|
+
if cycles % _LIVE_TAIL_FILE_RESET_EVERY == 0:
|
|
451
|
+
files = _resolve()
|
|
452
|
+
seen = {p: s for p, s in seen.items() if p in set(files)}
|
|
453
|
+
except (BrokenPipeError, ConnectionResetError,
|
|
454
|
+
ConnectionAbortedError, socket.timeout):
|
|
455
|
+
# #279 S1 F3: a stalled send past the handler timeout raises
|
|
456
|
+
# socket.timeout inside the SSE loop — treat it as a client
|
|
457
|
+
# disconnect (same as the other peer-gone classes), not an error.
|
|
458
|
+
pass # client disconnect is normal
|
|
459
|
+
except Exception as exc: # noqa: BLE001
|
|
460
|
+
# #279 S5 F6.2 (spec §8): a genuine bug mid-stream used to kill the
|
|
461
|
+
# live-tail SSE silently via handle_error. Headers are already
|
|
462
|
+
# committed — route the operator signal through the _lib_log chokepoint
|
|
463
|
+
# (handler.log_error) + a deliberate clean close via the finally below.
|
|
464
|
+
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
465
|
+
finally:
|
|
466
|
+
if conn is not None:
|
|
467
|
+
conn.close()
|
|
468
|
+
|
|
469
|
+
def _handle_get_conversation_search_impl(handler) -> None:
|
|
470
|
+
"""``GET /api/conversation/search?q=...&kind=...`` — cross-session
|
|
471
|
+
FTS/LIKE search (spec §3.3). Matched BEFORE the ``<id>`` reader in
|
|
472
|
+
``do_GET``. ``kind`` (#177 S6) is validated to ``_CONV_SEARCH_KINDS``
|
|
473
|
+
(else 400) before the kernel call.
|
|
474
|
+
|
|
475
|
+
#217 S2 / Filtered-search: the browse filters (date/project/cost/rebuild)
|
|
476
|
+
are parsed by the SAME ``_parse_conversation_filters`` the browse rail uses
|
|
477
|
+
(malformed → 400 already sent) and threaded into the kernel, applied as a
|
|
478
|
+
session-scope restriction across every kind. The 400s (bad kind, bad
|
|
479
|
+
filter) are decided HERE, before the kernel call — ``_run_conversation_query``
|
|
480
|
+
collapses kernel exceptions to a 500.
|
|
481
|
+
"""
|
|
482
|
+
if not handler._require_transcripts_allowed():
|
|
483
|
+
return
|
|
484
|
+
import urllib.parse as _u
|
|
485
|
+
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
486
|
+
query = _qs_str(q, "q", "")
|
|
487
|
+
limit = _qs_int(q, "limit", 50)
|
|
488
|
+
offset = _qs_int(q, "offset", 0)
|
|
489
|
+
kind = handler._parse_search_kind(q)
|
|
490
|
+
if kind is None:
|
|
491
|
+
return
|
|
492
|
+
filters = handler._parse_conversation_filters(q)
|
|
493
|
+
if filters is None:
|
|
494
|
+
return # a 400 has already been sent
|
|
495
|
+
ok, body = handler._run_conversation_query(
|
|
496
|
+
lambda conn: handler._conversation_query().search_conversations(
|
|
497
|
+
conn, query, limit=limit, offset=offset, kind=kind, **filters),
|
|
498
|
+
"/api/conversation/search")
|
|
499
|
+
if not ok:
|
|
500
|
+
return
|
|
501
|
+
handler._respond_json(200, body)
|
|
502
|
+
|
|
503
|
+
def _handle_get_conversation_payload_impl(handler, path: str) -> None:
|
|
504
|
+
"""``GET /api/conversation/<sid>/payload?tool_use_id=<id>&which=<result|input>``
|
|
505
|
+
— the #178 on-demand load-full route. Re-reads the source JSONL line so
|
|
506
|
+
a clipped result/input can be expanded without enlarging the cache.
|
|
507
|
+
|
|
508
|
+
Gated FIRST by the same loopback/Host transcript privacy predicate the
|
|
509
|
+
three other conversation routes use (fail-closed 403). ``locate_tool_payload``
|
|
510
|
+
runs against cache.db (via the shared 500-envelope scaffold); the actual
|
|
511
|
+
full body is re-read from disk by ``read_full_payload`` (no cache conn).
|
|
512
|
+
``which`` is validated to ``result``/``input`` (else 400); a missing
|
|
513
|
+
tool_use_id is 400; an unknown id is 404; a gone/unparseable source line
|
|
514
|
+
is 410 (the documented consequence of storing only capped text).
|
|
515
|
+
"""
|
|
516
|
+
if not handler._require_transcripts_allowed():
|
|
517
|
+
return
|
|
518
|
+
import urllib.parse as _u
|
|
519
|
+
session_id = _u.unquote(
|
|
520
|
+
path[len("/api/conversation/"):-len("/payload")])
|
|
521
|
+
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
522
|
+
tool_use_id = _qs_str(q, "tool_use_id", "")
|
|
523
|
+
which = _qs_str(q, "which", "result")
|
|
524
|
+
if not session_id or which not in ("result", "input") or not tool_use_id:
|
|
525
|
+
handler._respond_json(400, {"error": "bad request"})
|
|
526
|
+
return
|
|
527
|
+
cq = handler._conversation_query()
|
|
528
|
+
ok, loc = handler._run_conversation_query(
|
|
529
|
+
lambda conn: cq.locate_tool_payload(
|
|
530
|
+
conn, session_id, tool_use_id, which),
|
|
531
|
+
"/api/conversation/payload")
|
|
532
|
+
if not ok:
|
|
533
|
+
return
|
|
534
|
+
if loc is None:
|
|
535
|
+
handler._respond_json(404, {"error": "not found"})
|
|
536
|
+
return
|
|
537
|
+
payload = cq.read_full_payload(loc[0], loc[1], tool_use_id, which)
|
|
538
|
+
if payload is None:
|
|
539
|
+
handler._respond_json(410, {"error": "source no longer available"})
|
|
540
|
+
return
|
|
541
|
+
handler._respond_json(200, payload)
|
|
542
|
+
|
|
543
|
+
def _handle_get_conversation_outline_impl(handler, path: str) -> None:
|
|
544
|
+
"""``GET /api/conversation/<sid>/outline`` — full-session skeleton +
|
|
545
|
+
session stats (#177 S5). Same fail-closed privacy gate; unknown id → 404.
|
|
546
|
+
"""
|
|
547
|
+
if not handler._require_transcripts_allowed():
|
|
548
|
+
return
|
|
549
|
+
import urllib.parse as _u
|
|
550
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/outline")])
|
|
551
|
+
if not session_id:
|
|
552
|
+
handler.send_error(404, "conversation not found")
|
|
553
|
+
return
|
|
554
|
+
ok, body = handler._run_conversation_query(
|
|
555
|
+
lambda conn: handler._conversation_query().get_conversation_outline(conn, session_id),
|
|
556
|
+
"/api/conversation/outline")
|
|
557
|
+
if not ok:
|
|
558
|
+
return
|
|
559
|
+
if body is None:
|
|
560
|
+
handler.send_error(404, "conversation not found")
|
|
561
|
+
return
|
|
562
|
+
handler._respond_json(200, body)
|
|
563
|
+
|
|
564
|
+
def _handle_get_conversation_prompts_impl(handler, path: str) -> None:
|
|
565
|
+
"""``GET /api/conversation/<sid>/prompts`` — ordered main-thread human
|
|
566
|
+
prompts + full text (#217 S7 F10, the session-comparison spine). Same
|
|
567
|
+
fail-closed transcript privacy gate as ``/outline`` —
|
|
568
|
+
``_require_transcripts_allowed()`` ONLY (no ``_check_origin_csrf``: the
|
|
569
|
+
sibling transcript GETs gate on this predicate alone). Unknown id → 404.
|
|
570
|
+
"""
|
|
571
|
+
if not handler._require_transcripts_allowed():
|
|
572
|
+
return
|
|
573
|
+
import urllib.parse as _u
|
|
574
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/prompts")])
|
|
575
|
+
if not session_id:
|
|
576
|
+
handler.send_error(404, "conversation not found")
|
|
577
|
+
return
|
|
578
|
+
ok, body = handler._run_conversation_query(
|
|
579
|
+
lambda conn: handler._conversation_query().get_conversation_prompts(conn, session_id),
|
|
580
|
+
"/api/conversation/prompts")
|
|
581
|
+
if not ok:
|
|
582
|
+
return
|
|
583
|
+
if body is None:
|
|
584
|
+
handler.send_error(404, "conversation not found")
|
|
585
|
+
return
|
|
586
|
+
handler._respond_json(200, body)
|
|
587
|
+
|
|
588
|
+
_CONV_EXPORT_SCOPES = ("all", "prompts", "chat", "recipe")
|
|
589
|
+
|
|
590
|
+
def _handle_get_conversation_export_impl(handler, path: str) -> None:
|
|
591
|
+
"""``GET /api/conversation/<sid>/export?scope=<all|prompts|chat|recipe>``
|
|
592
|
+
— whole-session Markdown (issue #217 S5 F1/F5).
|
|
593
|
+
|
|
594
|
+
Same fail-closed transcript privacy gate as ``/outline`` / ``/payload``
|
|
595
|
+
/ ``/find`` — ``_require_transcripts_allowed()`` ONLY. **No
|
|
596
|
+
``_check_origin_csrf``** (Codex P0-1): the sibling transcript GETs gate
|
|
597
|
+
on this predicate alone; ``_check_origin_csrf`` rejects a missing
|
|
598
|
+
``Origin`` and would make export STRICTER than its sibling reader routes.
|
|
599
|
+
|
|
600
|
+
``scope`` is validated HERE, BEFORE the kernel (the
|
|
601
|
+
``_run_conversation_query``-collapses-kernel-exceptions-to-500 gotcha —
|
|
602
|
+
an invalid scope is a clean 400, never a 500). Unknown session → 404.
|
|
603
|
+
Emits ``text/markdown; charset=utf-8`` (the client builds the download
|
|
604
|
+
Blob/filename, so no ``Content-Disposition`` is needed)."""
|
|
605
|
+
if not handler._require_transcripts_allowed():
|
|
606
|
+
return
|
|
607
|
+
import urllib.parse as _u
|
|
608
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/export")])
|
|
609
|
+
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
610
|
+
scope = _qs_str(q, "scope", "all")
|
|
611
|
+
if scope not in _CONV_EXPORT_SCOPES:
|
|
612
|
+
handler._respond_json(400, {"error": f"unknown scope: {scope}"})
|
|
613
|
+
return
|
|
614
|
+
if not session_id:
|
|
615
|
+
handler.send_error(404, "conversation not found")
|
|
616
|
+
return
|
|
617
|
+
ok, body = handler._run_conversation_query(
|
|
618
|
+
lambda conn: handler._conversation_query().get_conversation_export(
|
|
619
|
+
conn, session_id, scope),
|
|
620
|
+
"/api/conversation/export")
|
|
621
|
+
if not ok:
|
|
622
|
+
return
|
|
623
|
+
if body is None:
|
|
624
|
+
handler.send_error(404, "conversation not found")
|
|
625
|
+
return
|
|
626
|
+
data = body.encode("utf-8")
|
|
627
|
+
handler.send_response(200)
|
|
628
|
+
handler.send_header("Content-Type", "text/markdown; charset=utf-8")
|
|
629
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
630
|
+
handler.end_headers()
|
|
631
|
+
handler.wfile.write(data)
|
|
632
|
+
|
|
633
|
+
def _handle_get_conversation_find_impl(handler, path: str) -> None:
|
|
634
|
+
"""``GET /api/conversation/<sid>/find?q=...&kind=...`` — in-conversation
|
|
635
|
+
find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
|
|
636
|
+
privacy gate as the sibling routes; unknown id → 404; an invalid ``kind``
|
|
637
|
+
→ 400. Matched BEFORE the ``<id>`` reader catch-all in ``do_GET``.
|
|
638
|
+
|
|
639
|
+
P1-1: validates against ``_CONV_FIND_KINDS`` (NOT the search set), so the
|
|
640
|
+
cross-session-only ``kind=title``/``files`` return 400 here, never a 500.
|
|
641
|
+
|
|
642
|
+
#217 S4 / I-1.2: ``regex``/``case`` are truthy params. An invalid regex
|
|
643
|
+
is PRE-COMPILED here, BEFORE dispatching to the kernel — exactly as the
|
|
644
|
+
detail route pre-validates ``after/before/tail`` — because
|
|
645
|
+
``_run_conversation_query`` collapses every kernel exception to a 500, so
|
|
646
|
+
a ``re.error`` from the kernel's ``re.compile`` would otherwise leak as a
|
|
647
|
+
500 instead of the actionable 400 the client maps to "invalid regex".
|
|
648
|
+
"""
|
|
649
|
+
if not handler._require_transcripts_allowed():
|
|
650
|
+
return
|
|
651
|
+
import re as _re
|
|
652
|
+
import urllib.parse as _u
|
|
653
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/find")])
|
|
654
|
+
if not session_id:
|
|
655
|
+
handler.send_error(404, "conversation not found")
|
|
656
|
+
return
|
|
657
|
+
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
658
|
+
query = _qs_str(q, "q", "")
|
|
659
|
+
kind = handler._parse_search_kind(q, valid=_CONV_FIND_KINDS)
|
|
660
|
+
if kind is None:
|
|
661
|
+
return
|
|
662
|
+
regex = _qs_str(q, "regex", None) in ("1", "true", "yes")
|
|
663
|
+
case = _qs_str(q, "case", None) in ("1", "true", "yes")
|
|
664
|
+
# Pre-validate the regex HERE (Codex P1): the kernel compiles the same
|
|
665
|
+
# pattern, but its ``re.error`` would be swallowed into the generic 500
|
|
666
|
+
# envelope below. Compiling first turns a bad pattern into a clean 400.
|
|
667
|
+
if regex:
|
|
668
|
+
try:
|
|
669
|
+
_re.compile(query, 0 if case else _re.IGNORECASE)
|
|
670
|
+
except _re.error as e:
|
|
671
|
+
handler._respond_json(400, {"error": f"invalid regex: {e}"})
|
|
672
|
+
return
|
|
673
|
+
ok, body = handler._run_conversation_query(
|
|
674
|
+
lambda conn: handler._conversation_query().find_in_conversation(
|
|
675
|
+
conn, session_id, query, kind=kind, regex=regex, case=case),
|
|
676
|
+
"/api/conversation/find")
|
|
677
|
+
if not ok:
|
|
678
|
+
return
|
|
679
|
+
if body is None:
|
|
680
|
+
handler.send_error(404, "conversation not found")
|
|
681
|
+
return
|
|
682
|
+
handler._respond_json(200, body)
|
|
683
|
+
|
|
684
|
+
_MEDIA_FETCH_SITE_ALLOWED = ("same-origin", "same-site", "none")
|
|
685
|
+
|
|
686
|
+
def _handle_get_conversation_media_impl(handler, path: str) -> None:
|
|
687
|
+
"""``GET /api/conversation/<sid>/media?tool_use_id=<id>&index=N`` or
|
|
688
|
+
``?uuid=<uuid>&index=N`` (#177 S4) — serves decoded image/PDF bytes by
|
|
689
|
+
re-reading the source JSONL line (the #178 mechanism). Nothing is ever
|
|
690
|
+
written to cache.db or disk; no outbound requests.
|
|
691
|
+
|
|
692
|
+
Gated FIRST by the transcript privacy predicate (fail-closed 403),
|
|
693
|
+
then by Fetch-Metadata: unlike the JSON routes, images embed
|
|
694
|
+
cross-origin (an <img src> on any website the user visits passes the
|
|
695
|
+
Host/loopback gate and leaks existence + dimensions via
|
|
696
|
+
onload/naturalWidth), so a PRESENT Sec-Fetch-Site header must be
|
|
697
|
+
same-origin/same-site/none; an absent header (curl, older browsers)
|
|
698
|
+
is allowed — defense-in-depth, not the primary gate (Codex F1).
|
|
699
|
+
Exactly one addressing key (tool_use_id XOR uuid) + a non-negative
|
|
700
|
+
integer index, else 400. Content-Type is the kernel's allowlist
|
|
701
|
+
constant; images get CSP default-src 'none'; PDFs get inline
|
|
702
|
+
Content-Disposition instead (a CSP sandbox would break native PDF
|
|
703
|
+
viewers)."""
|
|
704
|
+
if not handler._require_transcripts_allowed():
|
|
705
|
+
return
|
|
706
|
+
sfs = (handler.headers.get("Sec-Fetch-Site") or "").strip().lower()
|
|
707
|
+
if sfs and sfs not in _MEDIA_FETCH_SITE_ALLOWED:
|
|
708
|
+
handler._respond_json(403, {"error": "cross-site media fetch not allowed"})
|
|
709
|
+
return
|
|
710
|
+
import urllib.parse as _u
|
|
711
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/media")])
|
|
712
|
+
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
713
|
+
tool_use_id = _qs_str(q, "tool_use_id", "")
|
|
714
|
+
uuid = _qs_str(q, "uuid", "")
|
|
715
|
+
index_raw = _qs_str(q, "index", "")
|
|
716
|
+
if (not session_id or bool(tool_use_id) == bool(uuid)
|
|
717
|
+
or not index_raw.isdigit()):
|
|
718
|
+
handler._respond_json(400, {"error": "bad request"})
|
|
719
|
+
return
|
|
720
|
+
index = int(index_raw)
|
|
721
|
+
key = ({"tool_use_id": tool_use_id} if tool_use_id else {"uuid": uuid})
|
|
722
|
+
cq = handler._conversation_query()
|
|
723
|
+
ok, loc = handler._run_conversation_query(
|
|
724
|
+
lambda conn: cq.locate_media(conn, session_id, index=index, **key),
|
|
725
|
+
"/api/conversation/media")
|
|
726
|
+
if not ok:
|
|
727
|
+
return
|
|
728
|
+
if loc is None:
|
|
729
|
+
handler._respond_json(404, {"error": "not found"})
|
|
730
|
+
return
|
|
731
|
+
# Defensive envelope parity with the sibling byte-serving handlers
|
|
732
|
+
# (`_handle_get_doctor` / `_serve_static_file`): `locate_media` already
|
|
733
|
+
# runs inside the `_run_conversation_query` 500-envelope, but the
|
|
734
|
+
# `read_media_bytes` read + the byte emission did not. `read_media_bytes`
|
|
735
|
+
# is internally defensive (OSError/ValueError → `gone`), so this guards
|
|
736
|
+
# only an UNEXPECTED escape — but an unguarded one would kill the handler
|
|
737
|
+
# thread with no logged 500. `response_started` tracks the commit point:
|
|
738
|
+
# an exception BEFORE `send_response(200)` sends a clean logged 500; one
|
|
739
|
+
# AFTER (mid-`wfile.write`, headers already out) can't re-send a status,
|
|
740
|
+
# so it's logged only — never a silent thread death.
|
|
741
|
+
response_started = False
|
|
742
|
+
try:
|
|
743
|
+
status, media_type, raw = cq.read_media_bytes(
|
|
744
|
+
loc[0], loc[1], index=index, **key)
|
|
745
|
+
if status == "unsupported":
|
|
746
|
+
handler._respond_json(404, {"error": "not found"})
|
|
747
|
+
return
|
|
748
|
+
if status == "too_large":
|
|
749
|
+
handler._respond_json(413, {"error": "media too large"})
|
|
750
|
+
return
|
|
751
|
+
if status != "ok":
|
|
752
|
+
handler._respond_json(410, {"error": "source no longer available"})
|
|
753
|
+
return
|
|
754
|
+
handler.send_response(200)
|
|
755
|
+
response_started = True
|
|
756
|
+
handler.send_header("Content-Type", media_type)
|
|
757
|
+
handler.send_header("Content-Length", str(len(raw)))
|
|
758
|
+
handler.send_header("X-Content-Type-Options", "nosniff")
|
|
759
|
+
handler.send_header("Cache-Control", "private, max-age=86400")
|
|
760
|
+
if media_type == "application/pdf":
|
|
761
|
+
handler.send_header("Content-Disposition",
|
|
762
|
+
f'inline; filename="attachment-{index}.pdf"')
|
|
763
|
+
else:
|
|
764
|
+
handler.send_header("Content-Security-Policy", "default-src 'none'")
|
|
765
|
+
handler.end_headers()
|
|
766
|
+
handler.wfile.write(raw)
|
|
767
|
+
except Exception as exc: # noqa: BLE001
|
|
768
|
+
handler.log_error("/api/conversation/media failed: %r", exc)
|
|
769
|
+
if not response_started:
|
|
770
|
+
handler._respond_json(
|
|
771
|
+
500, {"error": f"{type(exc).__name__}: {exc}"})
|