cctally 1.103.0 → 1.105.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 (66) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_alerts.py +104 -10
  4. package/bin/_cctally_cache.py +193 -71
  5. package/bin/_cctally_config.py +62 -2
  6. package/bin/_cctally_core.py +62 -1
  7. package/bin/_cctally_dashboard.py +694 -70
  8. package/bin/_cctally_dashboard_conversation.py +400 -6
  9. package/bin/_cctally_dashboard_envelope.py +336 -14
  10. package/bin/_cctally_dashboard_perf.py +93 -0
  11. package/bin/_cctally_dashboard_share.py +56 -25
  12. package/bin/_cctally_dashboard_sources.py +988 -94
  13. package/bin/_cctally_db.py +30 -3
  14. package/bin/_cctally_diagnosis_sources.py +699 -186
  15. package/bin/_cctally_doctor.py +77 -0
  16. package/bin/_cctally_forecast.py +932 -51
  17. package/bin/_cctally_journal.py +430 -24
  18. package/bin/_cctally_parser.py +66 -0
  19. package/bin/_cctally_project.py +535 -10
  20. package/bin/_cctally_quota.py +28 -0
  21. package/bin/_cctally_quota_calibration.py +146 -0
  22. package/bin/_cctally_quota_model.py +2142 -0
  23. package/bin/_cctally_record.py +204 -30
  24. package/bin/_cctally_share.py +16 -8
  25. package/bin/_cctally_statusline.py +34 -0
  26. package/bin/_cctally_tui.py +531 -115
  27. package/bin/_lib_codex_conversation_query.py +258 -12
  28. package/bin/_lib_codex_hooks.py +26 -0
  29. package/bin/_lib_conversation_query.py +145 -15
  30. package/bin/_lib_dashboard_json.py +105 -0
  31. package/bin/_lib_dashboard_settings_contract.py +2 -0
  32. package/bin/_lib_diagnosis.py +21 -2
  33. package/bin/_lib_doctor.py +215 -1
  34. package/bin/_lib_forecast.py +337 -43
  35. package/bin/_lib_ingest_frontier.py +889 -0
  36. package/bin/_lib_meter_rate_change.py +360 -0
  37. package/bin/_lib_perf.py +22 -0
  38. package/bin/_lib_pricing.py +30 -3
  39. package/bin/_lib_quota_calibration.py +311 -0
  40. package/bin/_lib_quota_copy.py +157 -0
  41. package/bin/_lib_quota_model.py +2520 -0
  42. package/bin/_lib_rate_change_delivery.py +119 -0
  43. package/bin/_lib_record.py +50 -0
  44. package/bin/_lib_rederive.py +10 -0
  45. package/bin/_lib_render.py +6 -0
  46. package/bin/_lib_retained_size.py +187 -0
  47. package/bin/_lib_share_templates.py +37 -5
  48. package/bin/_lib_snapshot_cache.py +504 -17
  49. package/bin/_lib_statusline.py +200 -2
  50. package/bin/_lib_tick_stats.py +28 -4
  51. package/bin/_lib_view_models.py +30 -12
  52. package/bin/cctally +32 -0
  53. package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
  54. package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
  55. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
  56. package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
  57. package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
  58. package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
  59. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  60. package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
  61. package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
  62. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
  63. package/dashboard/static/dashboard.html +3 -2
  64. package/package.json +10 -1
  65. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  66. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
@@ -42,6 +42,13 @@ import re
42
42
  import socket
43
43
  import sqlite3
44
44
  import sys
45
+ import base64
46
+ import collections
47
+ import dataclasses
48
+ import hashlib
49
+ import secrets
50
+ import threading
51
+ import time
45
52
 
46
53
  from _cctally_cache import (
47
54
  open_cache_db,
@@ -51,7 +58,11 @@ from _cctally_cache import (
51
58
  sync_codex_conversations,
52
59
  _codex_provider_roots,
53
60
  )
54
- from _lib_dashboard_json import encode_dashboard_json
61
+ from _lib_dashboard_json import (
62
+ encode_dashboard_json,
63
+ encode_dashboard_json_bytes_capped,
64
+ )
65
+ from _lib_retained_size import retained_size_bytes
55
66
 
56
67
  # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
57
68
  # below, so moved here with the events handler (spec §4.1 / §6).
@@ -61,6 +72,224 @@ _LIVE_TAIL_KEEPALIVE = 15.0 # idle keep-alive cadence (proxy guard)
61
72
  _LIVE_TAIL_FILE_RESET_EVERY = 10 # re-resolve the session file set every N cycles
62
73
 
63
74
 
75
+ # #682 — progressive outline transport. The canonical outline remains the
76
+ # legacy endpoint body; an opt-in request gets a constant-size opaque transfer
77
+ # ticket. The first bounded chunk request materializes the canonical body, so
78
+ # the initial reader response never waits on or transfers the whole outline.
79
+ # Keeping the cache process-local avoids a second durable transcript surface.
80
+ _OUTLINE_TRANSFER_CHUNK = 192 * 1024
81
+ _OUTLINE_TRANSFER_ITEM_CAP = 32 * 1024 * 1024
82
+ _OUTLINE_TRANSFER_TOTAL_CAP = 64 * 1024 * 1024
83
+ _OUTLINE_TRANSFER_COUNT_CAP = 128
84
+ _OUTLINE_TRANSFER_TTL = 120.0
85
+ _OUTLINE_TRANSFERS = collections.OrderedDict()
86
+ _OUTLINE_TRANSFERS_BYTES = 0
87
+ _OUTLINE_TRANSFERS_EVICTIONS = 0
88
+ _OUTLINE_TRANSFERS_FALLBACKS = 0
89
+ _OUTLINE_TRANSFERS_LOCK = threading.Lock()
90
+
91
+
92
+ @dataclasses.dataclass
93
+ class _OutlineTransferRecord:
94
+ created: float
95
+ builder: object
96
+ payload: bytes | None = None
97
+ digest: str | None = None
98
+ state: str = "pending"
99
+ cancelled: threading.Event = dataclasses.field(default_factory=threading.Event)
100
+ ready: threading.Event = dataclasses.field(default_factory=threading.Event)
101
+
102
+
103
+ def _outline_transfer_estimated_bytes_locked() -> int:
104
+ return retained_size_bytes(
105
+ _OUTLINE_TRANSFERS, stop_after=_OUTLINE_TRANSFER_TOTAL_CAP)
106
+
107
+
108
+ def outline_transfer_cache_stats():
109
+ with _OUTLINE_TRANSFERS_LOCK:
110
+ return {
111
+ "estimatedBytes": _outline_transfer_estimated_bytes_locked(),
112
+ "maxBytes": _OUTLINE_TRANSFER_TOTAL_CAP,
113
+ "payloadBytes": int(_OUTLINE_TRANSFERS_BYTES),
114
+ "entryCount": len(_OUTLINE_TRANSFERS),
115
+ "maxEntries": _OUTLINE_TRANSFER_COUNT_CAP,
116
+ "evictionCount": int(_OUTLINE_TRANSFERS_EVICTIONS),
117
+ "fallbackCount": int(_OUTLINE_TRANSFERS_FALLBACKS),
118
+ }
119
+
120
+
121
+ def _drop_outline_transfer_locked(token):
122
+ """Remove one transfer and keep the materialized-byte count exact."""
123
+ global _OUTLINE_TRANSFERS_BYTES
124
+ record = _OUTLINE_TRANSFERS.pop(token, None)
125
+ if record is not None:
126
+ if record.payload is not None:
127
+ _OUTLINE_TRANSFERS_BYTES -= len(record.payload)
128
+ record.cancelled.set()
129
+ record.ready.set()
130
+ return record
131
+
132
+
133
+ def _prune_outline_transfers_locked(now):
134
+ expired = [token for token, record in _OUTLINE_TRANSFERS.items()
135
+ if now - record.created > _OUTLINE_TRANSFER_TTL]
136
+ for token in expired:
137
+ _drop_outline_transfer_locked(token)
138
+
139
+
140
+ def _store_outline_transfer(builder):
141
+ """Store a lazy canonical-body builder and return opaque metadata."""
142
+ global _OUTLINE_TRANSFERS_BYTES, _OUTLINE_TRANSFERS_EVICTIONS
143
+ now = time.monotonic()
144
+ token = secrets.token_urlsafe(24)
145
+ with _OUTLINE_TRANSFERS_LOCK:
146
+ _prune_outline_transfers_locked(now)
147
+ # A deterministic token factory in tests, or a vanishingly unlikely
148
+ # production collision, cancels the prior generation before replacement.
149
+ _drop_outline_transfer_locked(token)
150
+ _OUTLINE_TRANSFERS[token] = _OutlineTransferRecord(now, builder)
151
+ while _OUTLINE_TRANSFERS and (
152
+ len(_OUTLINE_TRANSFERS) > _OUTLINE_TRANSFER_COUNT_CAP
153
+ or _outline_transfer_estimated_bytes_locked()
154
+ > _OUTLINE_TRANSFER_TOTAL_CAP
155
+ ):
156
+ _drop_outline_transfer_locked(next(iter(_OUTLINE_TRANSFERS)))
157
+ _OUTLINE_TRANSFERS_EVICTIONS += 1
158
+ return {"token": token, "chunk_size": _OUTLINE_TRANSFER_CHUNK}
159
+
160
+
161
+ def _claim_outline_transfer(token):
162
+ """Atomically claim a pending builder or join its in-flight generation."""
163
+ now = time.monotonic()
164
+ with _OUTLINE_TRANSFERS_LOCK:
165
+ _prune_outline_transfers_locked(now)
166
+ record = _OUTLINE_TRANSFERS.get(token)
167
+ if record is None:
168
+ return None
169
+ _OUTLINE_TRANSFERS.move_to_end(token)
170
+ if record.payload is not None:
171
+ return "ready", record, None
172
+ if record.state == "pending":
173
+ record.state = "building"
174
+ return "build", record, record.builder
175
+ return "wait", record, None
176
+
177
+
178
+ def _outline_transfer_builder(token):
179
+ """Compatibility probe: claim one builder, ``False`` if joined/ready."""
180
+ claim = _claim_outline_transfer(token)
181
+ if claim is None:
182
+ return None
183
+ action, _record, builder = claim
184
+ return builder if action == "build" else False
185
+
186
+
187
+ def _materialize_outline_transfer(token, payload, *, claim=None):
188
+ """Publish immutable canonical bytes into a still-live lazy transfer."""
189
+ global _OUTLINE_TRANSFERS_BYTES
190
+ global _OUTLINE_TRANSFERS_EVICTIONS, _OUTLINE_TRANSFERS_FALLBACKS
191
+ if len(payload) > _OUTLINE_TRANSFER_ITEM_CAP:
192
+ with _OUTLINE_TRANSFERS_LOCK:
193
+ current = _OUTLINE_TRANSFERS.get(token)
194
+ if current is not None and (claim is None or current is claim):
195
+ _drop_outline_transfer_locked(token)
196
+ _OUTLINE_TRANSFERS_FALLBACKS += 1
197
+ return "too_large"
198
+ now = time.monotonic()
199
+ digest = hashlib.sha256(payload).hexdigest()
200
+ with _OUTLINE_TRANSFERS_LOCK:
201
+ _prune_outline_transfers_locked(now)
202
+ record = _OUTLINE_TRANSFERS.get(token)
203
+ if record is None or (claim is not None and record is not claim):
204
+ return "expired"
205
+ if record.cancelled.is_set():
206
+ return "expired"
207
+ if record.payload is not None:
208
+ return "ready"
209
+ while (_OUTLINE_TRANSFERS and
210
+ _OUTLINE_TRANSFERS_BYTES + len(payload) > _OUTLINE_TRANSFER_TOTAL_CAP):
211
+ victim = next(
212
+ ((old_token, old_record)
213
+ for old_token, old_record in _OUTLINE_TRANSFERS.items()
214
+ if old_token != token),
215
+ None,
216
+ )
217
+ if victim is None:
218
+ break
219
+ old_token, _old_record = victim
220
+ _drop_outline_transfer_locked(old_token)
221
+ _OUTLINE_TRANSFERS_EVICTIONS += 1
222
+ record.payload = payload
223
+ record.digest = digest
224
+ record.builder = None
225
+ record.state = "ready"
226
+ _OUTLINE_TRANSFERS_BYTES += len(payload)
227
+ while (_OUTLINE_TRANSFERS
228
+ and _outline_transfer_estimated_bytes_locked()
229
+ > _OUTLINE_TRANSFER_TOTAL_CAP):
230
+ victim = next(
231
+ (old_token for old_token in _OUTLINE_TRANSFERS
232
+ if old_token != token),
233
+ None,
234
+ )
235
+ if victim is None:
236
+ _drop_outline_transfer_locked(token)
237
+ _OUTLINE_TRANSFERS_FALLBACKS += 1
238
+ return "too_large"
239
+ _drop_outline_transfer_locked(victim)
240
+ _OUTLINE_TRANSFERS_EVICTIONS += 1
241
+ record.ready.set()
242
+ return "ready"
243
+
244
+
245
+ def _cancel_outline_transfer(token, *, claim=None) -> bool:
246
+ """Cancel one exact transfer generation and wake every joined reader."""
247
+ with _OUTLINE_TRANSFERS_LOCK:
248
+ current = _OUTLINE_TRANSFERS.get(token)
249
+ if current is None or (claim is not None and current is not claim):
250
+ return False
251
+ _drop_outline_transfer_locked(token)
252
+ return True
253
+
254
+
255
+ def _reject_outline_transfer_too_large(token, *, claim) -> bool:
256
+ """Drop one exact over-cap generation without allocating a sentinel body."""
257
+ global _OUTLINE_TRANSFERS_FALLBACKS
258
+ with _OUTLINE_TRANSFERS_LOCK:
259
+ current = _OUTLINE_TRANSFERS.get(token)
260
+ if current is None or current is not claim:
261
+ return False
262
+ _drop_outline_transfer_locked(token)
263
+ _OUTLINE_TRANSFERS_FALLBACKS += 1
264
+ return True
265
+
266
+
267
+ def _read_outline_transfer(token, offset):
268
+ """Return one immutable chunk, or ``None`` when the token is gone."""
269
+ now = time.monotonic()
270
+ with _OUTLINE_TRANSFERS_LOCK:
271
+ _prune_outline_transfers_locked(now)
272
+ record = _OUTLINE_TRANSFERS.get(token)
273
+ if record is None:
274
+ return None
275
+ payload, digest = record.payload, record.digest
276
+ _OUTLINE_TRANSFERS.move_to_end(token)
277
+ if payload is None:
278
+ return None
279
+ if offset < 0 or offset > len(payload):
280
+ raise ValueError("offset out of range")
281
+ chunk = payload[offset:offset + _OUTLINE_TRANSFER_CHUNK]
282
+ next_offset = offset + len(chunk)
283
+ return {
284
+ "offset": offset,
285
+ "next_offset": next_offset,
286
+ "total": len(payload),
287
+ "sha256": digest,
288
+ "done": next_offset == len(payload),
289
+ "chunk": base64.b64encode(chunk).decode("ascii"),
290
+ }
291
+
292
+
64
293
  # Module-local forwarders for the two generic query-string helpers that STAY
65
294
  # in the dashboard (24 call sites in the moved handlers; the dashboard is fully
66
295
  # loaded before any handler runs, so the call-time reach is cycle-free).
@@ -450,7 +679,9 @@ def _parse_search_kind_impl(handler, q, valid=_CONV_SEARCH_KINDS):
450
679
  return None
451
680
  return kind
452
681
 
453
- def _run_conversation_query_impl(handler, kernel_call, log_label):
682
+ def _run_conversation_query_impl(
683
+ handler, kernel_call, log_label, *, cancelled=None,
684
+ ):
454
685
  """Open conversations.db, run ``kernel_call(conn)``, close.
455
686
 
456
687
  Collapses the triplicated open-store → try/except/finally → 500
@@ -487,12 +718,25 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
487
718
  handler._respond_json(400, {"error": "invalid account"})
488
719
  return False, None
489
720
  scope_conversations_db_to_account(conn, account_vals[0])
721
+ if cancelled is not None:
722
+ conn.set_progress_handler(lambda: 1 if cancelled() else 0, 1000)
723
+ if cancelled():
724
+ return None, None
490
725
  body = kernel_call(conn)
726
+ if cancelled is not None and cancelled():
727
+ return None, None
491
728
  except Exception as exc: # noqa: BLE001
729
+ if cancelled is not None and cancelled():
730
+ return None, None
492
731
  handler.log_error("%s failed: %r", log_label, exc)
493
732
  handler._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
494
733
  return False, None
495
734
  finally:
735
+ if cancelled is not None:
736
+ try:
737
+ conn.set_progress_handler(None, 0)
738
+ except sqlite3.Error:
739
+ pass
496
740
  conn.close()
497
741
  return True, body
498
742
 
@@ -1261,6 +1505,12 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
1261
1505
  if not handler._require_transcripts_allowed():
1262
1506
  return
1263
1507
  import urllib.parse as _u
1508
+ query = _u.parse_qs(_u.urlsplit(handler.path).query, keep_blank_values=True)
1509
+ progressive_values = query.get("progressive")
1510
+ if progressive_values is not None and progressive_values != ["1"]:
1511
+ handler._respond_json(400, {"error": "invalid progressive param"})
1512
+ return
1513
+ progressive = progressive_values == ["1"]
1264
1514
  session_id = _u.unquote(path[len("/api/conversation/"):-len("/outline")])
1265
1515
  if not session_id:
1266
1516
  handler.send_error(404, "conversation not found")
@@ -1268,11 +1518,75 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
1268
1518
  if session_id.startswith("v1."):
1269
1519
  speed = _resolve_effective_speed()
1270
1520
  disp = _conversation_dispatch()
1271
- _serve_qualified_entity(
1272
- handler,
1273
- lambda conn: disp.neutral_outline(
1274
- conn, session_id, effective_speed=speed),
1521
+ if not progressive:
1522
+ ok, body = handler._run_conversation_query(
1523
+ lambda conn: disp.neutral_outline(
1524
+ conn, session_id, effective_speed=speed),
1525
+ "/api/conversation/outline")
1526
+ if not ok:
1527
+ return
1528
+ _respond_qualified_json(handler, body)
1529
+ return
1530
+ ok, preflight = handler._run_conversation_query(
1531
+ lambda conn: disp.neutral_events_preflight(conn, session_id),
1275
1532
  "/api/conversation/outline")
1533
+ if not ok:
1534
+ return
1535
+ if (preflight or {}).get("status") != "ok":
1536
+ _respond_qualified_json(handler, preflight)
1537
+ return
1538
+ account_values = query.get("account")
1539
+ account_key = account_values[0] if account_values else None
1540
+
1541
+ def _build_qualified(transfer_handler, cancelled):
1542
+ def _query(conn):
1543
+ if account_key is not None:
1544
+ scope_conversations_db_to_account(conn, account_key)
1545
+ return disp.neutral_outline(
1546
+ conn, session_id, effective_speed=speed)
1547
+ return _run_conversation_query_impl(
1548
+ transfer_handler,
1549
+ _query,
1550
+ "/api/conversation/outline-transfer",
1551
+ cancelled=cancelled,
1552
+ )
1553
+
1554
+ handler._respond_json(200, {
1555
+ "progressive": 1,
1556
+ "transfer": _store_outline_transfer(_build_qualified),
1557
+ })
1558
+ return
1559
+ if progressive:
1560
+ ok, exists = handler._run_conversation_query(
1561
+ lambda conn: conn.execute(
1562
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
1563
+ (session_id,),
1564
+ ).fetchone() is not None,
1565
+ "/api/conversation/outline")
1566
+ if not ok:
1567
+ return
1568
+ if not exists:
1569
+ handler.send_error(404, "conversation not found")
1570
+ return
1571
+
1572
+ def _build_bare(transfer_handler, cancelled):
1573
+ def _query(conn):
1574
+ account_values = query.get("account")
1575
+ if account_values:
1576
+ scope_conversations_db_to_account(conn, account_values[0])
1577
+ return transfer_handler._conversation_query(
1578
+ ).get_conversation_outline(conn, session_id)
1579
+ return _run_conversation_query_impl(
1580
+ transfer_handler,
1581
+ _query,
1582
+ "/api/conversation/outline-transfer",
1583
+ cancelled=cancelled,
1584
+ )
1585
+
1586
+ handler._respond_json(200, {
1587
+ "progressive": 1,
1588
+ "transfer": _store_outline_transfer(_build_bare),
1589
+ })
1276
1590
  return
1277
1591
  ok, body = handler._run_conversation_query(
1278
1592
  lambda conn: handler._conversation_query().get_conversation_outline(conn, session_id),
@@ -1284,6 +1598,86 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
1284
1598
  return
1285
1599
  handler._respond_json(200, body)
1286
1600
 
1601
+
1602
+ def _handle_get_conversation_outline_transfer_impl(handler, path: str) -> None:
1603
+ """Serve one bounded chunk from an opaque, immutable outline transfer."""
1604
+ if not handler._require_transcripts_allowed():
1605
+ return
1606
+ import urllib.parse as _u
1607
+ token = _u.unquote(path[len("/api/conversation/outline-transfer/"):])
1608
+ if not token or not re.fullmatch(r"[A-Za-z0-9_-]+", token):
1609
+ handler._respond_json(410, {"error": "outline transfer expired"})
1610
+ return
1611
+ parsed = _u.parse_qs(_u.urlsplit(handler.path).query, keep_blank_values=True)
1612
+ values = parsed.get("offset")
1613
+ if values is None:
1614
+ offset = 0
1615
+ elif len(values) != 1 or not values[0].isascii() or not values[0].isdigit():
1616
+ handler._respond_json(400, {"error": "invalid offset"})
1617
+ return
1618
+ else:
1619
+ offset = int(values[0])
1620
+ claim = _claim_outline_transfer(token)
1621
+ if claim is None:
1622
+ handler._respond_json(410, {"error": "outline transfer expired"})
1623
+ return
1624
+ action, record, builder = claim
1625
+ if action == "build":
1626
+ ok, full_body = builder(handler, record.cancelled.is_set)
1627
+ if ok is None:
1628
+ handler._respond_json(410, {"error": "outline transfer expired"})
1629
+ return
1630
+ if not ok:
1631
+ _cancel_outline_transfer(token, claim=record)
1632
+ return
1633
+ if full_body is None:
1634
+ _cancel_outline_transfer(token, claim=record)
1635
+ handler._respond_json(410, {"error": "outline transfer expired"})
1636
+ return
1637
+ payload = encode_dashboard_json_bytes_capped(
1638
+ full_body, max_bytes=_OUTLINE_TRANSFER_ITEM_CAP)
1639
+ if payload is None:
1640
+ _reject_outline_transfer_too_large(token, claim=record)
1641
+ state = "too_large"
1642
+ else:
1643
+ state = _materialize_outline_transfer(token, payload, claim=record)
1644
+ if state == "too_large":
1645
+ handler._respond_json(
1646
+ 413, {"error": "outline exceeds progressive transfer limit"})
1647
+ return
1648
+ if state == "expired":
1649
+ handler._respond_json(410, {"error": "outline transfer expired"})
1650
+ return
1651
+ elif action == "wait":
1652
+ record.ready.wait(_OUTLINE_TRANSFER_TTL)
1653
+ if record.cancelled.is_set() or record.payload is None:
1654
+ handler._respond_json(410, {"error": "outline transfer expired"})
1655
+ return
1656
+ try:
1657
+ body = _read_outline_transfer(token, offset)
1658
+ except ValueError:
1659
+ handler._respond_json(400, {"error": "offset out of range"})
1660
+ return
1661
+ if body is None:
1662
+ handler._respond_json(410, {"error": "outline transfer expired"})
1663
+ return
1664
+ handler._respond_json(200, body)
1665
+
1666
+
1667
+ def _handle_delete_conversation_outline_transfer_impl(handler, path: str) -> None:
1668
+ """Best-effort cancellation for a browser-abandoned progressive token."""
1669
+ if not handler._require_transcripts_allowed():
1670
+ return
1671
+ import urllib.parse as _u
1672
+ token = _u.unquote(path[len("/api/conversation/outline-transfer/"):])
1673
+ if not token or not re.fullmatch(r"[A-Za-z0-9_-]+", token):
1674
+ handler._respond_json(400, {"error": "invalid outline transfer token"})
1675
+ return
1676
+ _cancel_outline_transfer(token)
1677
+ handler.send_response(204)
1678
+ handler.send_header("Content-Length", "0")
1679
+ handler.end_headers()
1680
+
1287
1681
  def _handle_get_conversation_prompts_impl(handler, path: str) -> None:
1288
1682
  """``GET /api/conversation/<sid>/prompts`` — ordered main-thread human
1289
1683
  prompts + full text (#217 S7 F10, the session-comparison spine). Same