cctally 1.92.0 → 1.92.2

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.
@@ -2966,7 +2966,22 @@ def _tui_build_snapshot(
2966
2966
  # ``_tui_build_snapshot_once`` closes its live stats connection before
2967
2967
  # this boundary. The replacement-capable hook can therefore satisfy
2968
2968
  # the whole-family drain gate, and the retry opens the published family.
2969
- _tui_heal_post_query_stats(fault.cause)
2969
+ #
2970
+ # #496 S3 §6: the heal now DEFERS, and its signal is a
2971
+ # `BaseException`. This call sits before the retry's own `except`, so
2972
+ # the shared deferral base is caught AT the heal-call boundary and the
2973
+ # corruption-typed degraded frame is built directly. The retry must not
2974
+ # spin: after deferral the index is still corrupt, so a second attempt
2975
+ # would only fail again, and the frame converges on a later tick.
2976
+ try:
2977
+ _tui_heal_post_query_stats(fault.cause)
2978
+ except _cctally().StatsRebuildDeferred as deferred:
2979
+ return _tui_stats_retry_degraded_snapshot(
2980
+ now_utc=now_utc or dt.datetime.now(dt.timezone.utc),
2981
+ exc=deferred,
2982
+ precompute_envelope=precompute_envelope,
2983
+ runtime_bind=runtime_bind,
2984
+ )
2970
2985
  try:
2971
2986
  return _tui_build_snapshot_once(
2972
2987
  now_utc=now_utc,
@@ -3279,6 +3294,8 @@ def _tui_build_snapshot_once(
3279
3294
  runtime_bind=runtime_bind, raw_config=raw_config,
3280
3295
  display_tz_pref_override=display_tz_pref_override,
3281
3296
  source_stats_conn=conn,
3297
+ failures=sync_failures,
3298
+ stats_heal_attempted=stats_heal_attempted,
3282
3299
  source_display_tz_name=(
3283
3300
  getattr(_build_display_tz, "key", None)
3284
3301
  if _build_display_tz is not None else None
@@ -4068,7 +4085,9 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4068
4085
  codex_ingest_contended=False,
4069
4086
  codex_ingest_failed=False,
4070
4087
  claude_ingest_contended=False,
4071
- claude_ingest_failed=False):
4088
+ claude_ingest_failed=False,
4089
+ failures=None,
4090
+ stats_heal_attempted=False):
4072
4091
  """Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
4073
4092
  time-derived fields + the doctor payload / envelope precompute on each idle
4074
4093
  tick (spec §3 idle path).
@@ -4108,6 +4127,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4108
4127
  envelope_precompute = _tui_precompute_envelope_config(raw_config)
4109
4128
  except Exception as exc: # noqa: BLE001 — never crash the rebuild
4110
4129
  errors.append(f"envelope-precompute: {exc}")
4130
+ idle_failures = failures if failures is not None else []
4111
4131
  source_bundle = prior.source_bundle
4112
4132
  if source_bundle is not None:
4113
4133
  try:
@@ -4180,15 +4200,36 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4180
4200
  prior_bundle=source_bundle,
4181
4201
  raw_config=raw_config,
4182
4202
  )
4203
+ except _StatsSnapshotCorruption:
4204
+ raise
4183
4205
  except Exception as exc: # noqa: BLE001 — retain prior complete bundle
4184
- errors.append(f"source-clock-refresh: {exc}")
4206
+ # #496 S3 §8 (F16). This branch read stats through
4207
+ # `source_stats_conn` and swallowed the failure into a plain
4208
+ # string, so it built no `SyncFailureAttribution`, never raised
4209
+ # `_StatsSnapshotCorruption`, and never reached the heal — and
4210
+ # `_sync_failure_envelope`, finding no typed attribution, fell to
4211
+ # its raw-text matcher and told the user to run
4212
+ # `cctally cache-sync --rebuild` for a STATS fault. Classify it at
4213
+ # the catch site instead, against the connection that faulted.
4214
+ if source_stats_conn is not None:
4215
+ _tui_capture_sync_failure(
4216
+ source_stats_conn,
4217
+ errors,
4218
+ idle_failures,
4219
+ leg="source-clock-refresh",
4220
+ database="stats_or_cache",
4221
+ exc=exc,
4222
+ stats_heal_attempted=stats_heal_attempted,
4223
+ )
4224
+ else:
4225
+ errors.append(f"source-clock-refresh: {exc}")
4185
4226
  source_bundle = prior.source_bundle
4186
4227
  return dataclasses.replace(
4187
4228
  prior,
4188
4229
  generated_at=now_utc,
4189
4230
  last_sync_at=time.monotonic(),
4190
4231
  last_sync_error=("; ".join(errors) if errors else None),
4191
- sync_failures=(),
4232
+ sync_failures=tuple(idle_failures),
4192
4233
  doctor_payload=doctor_payload,
4193
4234
  envelope_precompute=envelope_precompute,
4194
4235
  source_bundle=source_bundle,
@@ -6611,12 +6652,17 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6611
6652
  snap = dataclasses.replace(snap, last_sync_at=None, hydrating=False)
6612
6653
  ref.set(snap)
6613
6654
  hub.publish(snap)
6614
- except _cctally().StatsEpochRebuildDeferred as exc:
6655
+ except _cctally().StatsRebuildDeferred as exc:
6615
6656
  # #453: the first periodic tick runs before HTTP bind. Preserve the
6616
6657
  # initial hydrating/degraded frame while the dedicated replay owns
6617
6658
  # stats maintenance; a generic crash frame would clear the latch
6618
6659
  # before any client could observe it. The loop retries normally on
6619
6660
  # its next cadence and publishes a full frame after convergence.
6661
+ #
6662
+ # #496 S3: the two deferral classes must NOT flatten here. A wrong
6663
+ # EPOCH is a readable index (`corruption=False`); a deferred heal is
6664
+ # an index that could not be read, and reporting that as
6665
+ # non-corruption makes the envelope name the wrong fault.
6620
6666
  prev = ref.get()
6621
6667
  pending = dataclasses.replace(
6622
6668
  prev,
@@ -6625,7 +6671,9 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6625
6671
  SyncFailureAttribution(
6626
6672
  leg="stats-open",
6627
6673
  database="stats",
6628
- corruption=False,
6674
+ corruption=isinstance(
6675
+ exc, _cctally().StatsHealDeferred
6676
+ ),
6629
6677
  ),
6630
6678
  ),
6631
6679
  generated_at=dt.datetime.now(dt.timezone.utc),
@@ -34,11 +34,13 @@ import _lib_codex_conversation as kern
34
34
  import _lib_codex_landmarks as landmarks
35
35
  import _lib_codex_segments as segkern
36
36
  from _lib_codex_find_projection import (
37
+ CODEX_FIND_PROJECTION_VERSION,
37
38
  ProjectedLeaf,
38
39
  RenderLeaf,
39
40
  iter_literal_ranges,
40
41
  iter_regex_ranges,
41
42
  literal_ranges,
43
+ project_context,
42
44
  project_markdown,
43
45
  project_plain,
44
46
  regex_ranges,
@@ -3006,6 +3008,7 @@ def list_codex_conversations(
3006
3008
  model: str | None = None,
3007
3009
  limit: int = 50,
3008
3010
  cursor: str | None = None,
3011
+ selected: str | None = None,
3009
3012
  ) -> dict:
3010
3013
  """Browse envelope (§5.6 / §6.1): a page of conversation rows ordered by last
3011
3014
  activity, with project/model facets. Dual-branch — the stored rollup fast
@@ -3021,7 +3024,13 @@ def list_codex_conversations(
3021
3024
  page_rows, page = _stored_browse_page(
3022
3025
  conn, effective_speed=effective_speed, project_key=project_key,
3023
3026
  model=model, limit=limit, cursor=cursor)
3024
- return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
3027
+ result = {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
3028
+ if selected is not None:
3029
+ fields = _rollup_fields(conn, selected)
3030
+ if fields is not None:
3031
+ result["selected"] = _browse_row(
3032
+ conn, selected, effective_speed, fields)
3033
+ return result
3025
3034
 
3026
3035
  fields_rows = _live_browse_fields(conn)
3027
3036
  rows = [
@@ -3034,7 +3043,13 @@ def list_codex_conversations(
3034
3043
  and (model is None or model in (row["models"] or []))]
3035
3044
  filtered.sort(key=_recent_sort_key, reverse=True)
3036
3045
  page_rows, page = _paginate_rows(filtered, cursor=cursor, limit=limit)
3037
- return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
3046
+ result = {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
3047
+ if selected is not None:
3048
+ selected_row = next(
3049
+ (row for row in rows if row["conversation_key"] == selected), None)
3050
+ if selected_row is not None:
3051
+ result["selected"] = selected_row
3052
+ return result
3038
3053
 
3039
3054
 
3040
3055
  # ── search (§6.2) ─────────────────────────────────────────────────────────────
@@ -3111,7 +3126,6 @@ def _pos_to_item_key(conn: sqlite3.Connection, conversation_key: str) -> dict:
3111
3126
 
3112
3127
  # ── #482 visible render-leaf projection ─────────────────────────────────────
3113
3128
 
3114
- CODEX_FIND_PROJECTION_VERSION = 1
3115
3129
  _COMPLETION_EVENT_TYPES = {
3116
3130
  "patch_apply_end",
3117
3131
  "web_search_end",
@@ -3120,7 +3134,7 @@ _COMPLETION_EVENT_TYPES = {
3120
3134
 
3121
3135
 
3122
3136
  def _find_surface(row) -> str | None:
3123
- if row.kind in {"user", "assistant", "reasoning"}:
3137
+ if row.kind in {"user", "assistant", "reasoning", "meta"}:
3124
3138
  return "body"
3125
3139
  if row.kind == "tool_call":
3126
3140
  return "call"
@@ -3252,6 +3266,17 @@ def _project_find_row(row, *, payload: dict | None = None, block: dict | None =
3252
3266
  text = _row_display(row)
3253
3267
  if not text:
3254
3268
  return None
3269
+ if row.kind == "meta":
3270
+ try:
3271
+ detail = json.loads(row.detail_json or "{}")
3272
+ except (TypeError, json.JSONDecodeError):
3273
+ detail = {}
3274
+ meta_kind = detail.get("meta_kind") if isinstance(detail, dict) else None
3275
+ if meta_kind == "command":
3276
+ return project_plain((RenderLeaf("t0", text),))
3277
+ if meta_kind == "context":
3278
+ return project_context(text)
3279
+ return project_markdown(text)
3255
3280
  if row.kind == "reasoning" and isinstance(block, dict):
3256
3281
  detail = block.get("detail")
3257
3282
  reasoning = detail.get("reasoning") if isinstance(detail, dict) else None
@@ -3397,7 +3422,7 @@ def materialize_codex_find_projection(
3397
3422
  return
3398
3423
  disclosure = (
3399
3424
  [container_block_key]
3400
- if row.kind == "reasoning" or surface != "body"
3425
+ if row.kind in {"reasoning", "meta"} or surface != "body"
3401
3426
  else []
3402
3427
  )
3403
3428
  conn.execute(
@@ -12,6 +12,9 @@ import re
12
12
  from typing import Iterable, Iterator, Sequence
13
13
 
14
14
 
15
+ CODEX_FIND_PROJECTION_VERSION = 2
16
+
17
+
15
18
  @dataclass(frozen=True)
16
19
  class RenderLeaf:
17
20
  key: str
@@ -301,6 +304,148 @@ def project_plain(leaves: Sequence[RenderLeaf]) -> tuple[str, tuple[ProjectedLea
301
304
  return builder.value()
302
305
 
303
306
 
307
+ _CONTEXT_DIFF_GIT_RE = re.compile(r"diff --git a/\S+ b/\S+")
308
+ _CONTEXT_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
309
+ _CONTEXT_EXTENDED_HEADER_PREFIXES = (
310
+ "old mode ", "new mode ", "new file mode ", "deleted file mode ",
311
+ "rename from ", "rename to ", "copy from ", "copy to ",
312
+ "similarity index ", "dissimilarity index ", "index ",
313
+ )
314
+
315
+
316
+ def _context_is_diff_line(line: str) -> bool:
317
+ if _CONTEXT_DIFF_GIT_RE.search(line):
318
+ return True
319
+ if line.startswith(("--- ", "+++ ", "@@")):
320
+ return True
321
+ if line.startswith(_CONTEXT_EXTENDED_HEADER_PREFIXES):
322
+ return True
323
+ return line == "" or line[0] in "+- \\"
324
+
325
+
326
+ def _segment_context_body(text: str) -> list[tuple[str, str]]:
327
+ """Mirror ``contextDiff.ts::segmentContextBody`` without rendering HTML."""
328
+ lines = text.split("\n")
329
+ if lines and lines[-1] == "":
330
+ lines.pop()
331
+ segments: list[tuple[str, str]] = []
332
+ prose: list[str] = []
333
+ diff: list[str] = []
334
+ in_diff = False
335
+
336
+ def flush(kind: str, values: list[str]) -> None:
337
+ if values:
338
+ segments.append((kind, "\n".join(values)))
339
+ values.clear()
340
+
341
+ for line in lines:
342
+ if not in_diff:
343
+ match = _CONTEXT_DIFF_GIT_RE.search(line)
344
+ if match is None:
345
+ prose.append(line)
346
+ continue
347
+ before = line[:match.start()].rstrip()
348
+ if before:
349
+ prose.append(before)
350
+ flush("prose", prose)
351
+ in_diff = True
352
+ diff.append(line[match.start():])
353
+ elif _context_is_diff_line(line):
354
+ diff.append(line)
355
+ else:
356
+ flush("diff", diff)
357
+ in_diff = False
358
+ prose.append(line)
359
+ flush("prose", prose)
360
+ flush("diff", diff)
361
+ return segments
362
+
363
+
364
+ def _context_diff_rows(text: str) -> list[tuple[int, int, int, str]]:
365
+ """Mirror the visible row walk in ``contextDiff.ts::parseUnifiedDiff``."""
366
+ rows: list[tuple[int, int, int, str]] = []
367
+ file_index = -1
368
+ hunk_index = -1
369
+ row_index = 0
370
+ in_hunk = False
371
+ for line in text.split("\n"):
372
+ if _CONTEXT_DIFF_GIT_RE.search(line):
373
+ file_index += 1
374
+ hunk_index = -1
375
+ row_index = 0
376
+ in_hunk = False
377
+ continue
378
+ if _CONTEXT_HUNK_RE.match(line):
379
+ hunk_index += 1
380
+ row_index = 0
381
+ in_hunk = True
382
+ continue
383
+ if not in_hunk:
384
+ continue
385
+ if line.startswith(("--- ", "+++ ")) or line.startswith(
386
+ _CONTEXT_EXTENDED_HEADER_PREFIXES
387
+ ):
388
+ continue
389
+ if line == "" or line.startswith("\\"):
390
+ continue
391
+ rows.append((file_index, hunk_index, row_index, line[1:]))
392
+ row_index += 1
393
+ return rows
394
+
395
+
396
+ def _append_projected(
397
+ builder: _ProjectionBuilder,
398
+ projected: tuple[str, tuple[ProjectedLeaf, ...]],
399
+ *,
400
+ prefix: str,
401
+ ) -> None:
402
+ text, leaves = projected
403
+ if not text:
404
+ return
405
+ start = builder.length
406
+ builder.parts.append(text)
407
+ builder.length += len(text)
408
+ builder.boundary()
409
+ builder.leaves.extend({
410
+ "key": f"{prefix}/{leaf.key}",
411
+ "start": start + leaf.start,
412
+ "end": start + leaf.end,
413
+ } for leaf in leaves)
414
+
415
+
416
+ def project_context(source: str) -> tuple[str, tuple[ProjectedLeaf, ...]]:
417
+ """Project visible prose and diff-row leaves from one context body.
418
+
419
+ File headers and +/- statistics are derived card chrome, matching #482's
420
+ rule that only provider-authored render leaves enter the search surface.
421
+ """
422
+ builder = _ProjectionBuilder()
423
+ for segment_index, (kind, text) in enumerate(_segment_context_body(source)):
424
+ if kind == "prose":
425
+ projected = project_markdown(text)
426
+ if not projected[0]:
427
+ continue
428
+ if builder.parts:
429
+ builder.separator("\n")
430
+ _append_projected(
431
+ builder, projected, prefix=f"segments.{segment_index}.prose"
432
+ )
433
+ continue
434
+ for file_index, hunk_index, row_index, row_text in _context_diff_rows(text):
435
+ if not row_text:
436
+ continue
437
+ if builder.parts:
438
+ builder.separator("\n")
439
+ builder.emit(
440
+ row_text,
441
+ key=(
442
+ f"segments.{segment_index}.files.{file_index}."
443
+ f"hunks.{hunk_index}.rows.{row_index}"
444
+ ),
445
+ )
446
+ return builder.value()
447
+
448
+
304
449
  def _single_scalar_lower(value: str) -> str:
305
450
  return "".join((lowered if len(lowered := scalar.lower()) == 1 else scalar) for scalar in value)
306
451
 
@@ -356,6 +501,7 @@ def slice_range_to_leaves(
356
501
 
357
502
 
358
503
  __all__ = [
504
+ "CODEX_FIND_PROJECTION_VERSION",
359
505
  "FindRange",
360
506
  "LeafFragment",
361
507
  "ProjectedLeaf",
@@ -364,6 +510,7 @@ __all__ = [
364
510
  "iter_literal_ranges",
365
511
  "iter_regex_ranges",
366
512
  "project_markdown",
513
+ "project_context",
367
514
  "project_plain",
368
515
  "regex_ranges",
369
516
  "slice_range_to_leaves",
@@ -266,6 +266,7 @@ def _claude_browse(
266
266
  model: str | None = None,
267
267
  limit: int = 50,
268
268
  cursor: str | None = None,
269
+ selected: str | None = None,
269
270
  ) -> dict:
270
271
  """Claude browse envelope (§5.6 / §6.1). Claude is always authoritative — the
271
272
  status is always ``ok`` (never ``normalization_pending``). Facets are built
@@ -296,7 +297,13 @@ def _claude_browse(
296
297
  ]
297
298
  filtered.sort(key=q._recent_sort_key, reverse=True)
298
299
  page_rows, page = q._paginate_rows(filtered, cursor=cursor, limit=limit)
299
- return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
300
+ result = {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
301
+ if selected is not None:
302
+ selected_row = next(
303
+ (row for row in rows if row["conversation_key"] == selected), None)
304
+ if selected_row is not None:
305
+ result["selected"] = selected_row
306
+ return result
300
307
 
301
308
 
302
309
  # ── Claude adapter: detail with cursor translation (§5.6) ─────────────────────
@@ -620,6 +627,13 @@ def neutral_browse(
620
627
  migration 025 has not run); Claude routes to the adapter (always ``ok``).
621
628
  ``filters``: ``project_key``, ``model``, ``limit``, ``cursor``."""
622
629
  speed = effective_speed or _DEFAULT_SPEED
630
+ selected = filters.pop("selected", None)
631
+ if selected is not None:
632
+ selected_ref = resolve_conversation_ref(selected)
633
+ selected = (selected_ref.conversation_key
634
+ if selected_ref is not None and selected_ref.source == source
635
+ else None)
636
+ filters["selected"] = selected
623
637
  if source == "codex":
624
638
  return q.list_codex_conversations(conn, effective_speed=speed, **filters)
625
639
  if source == "claude":
@@ -1439,10 +1439,65 @@ def list_conversation_facets(conn) -> dict:
1439
1439
  return {"projects": projects, "models": models}
1440
1440
 
1441
1441
 
1442
+ def _selected_conversation_summary(conn, session_id):
1443
+ """One exact browse-row projection, independent of the current page.
1444
+
1445
+ Mirrors ``list_conversations``' authoritative/live shaping so #501 can pin
1446
+ the selected conversation without paging an unbounded corpus.
1447
+ """
1448
+ if _rollup_authoritative(conn):
1449
+ row = conn.execute(
1450
+ "SELECT session_id, msg_count, started_utc, last_activity_utc, "
1451
+ "cost_usd, project_label, git_branch, models_json, title "
1452
+ "FROM conversation_sessions WHERE session_id=?",
1453
+ (session_id,),
1454
+ ).fetchone()
1455
+ if row is None:
1456
+ return None
1457
+ (sid, msg_count, started, last_activity, cost_usd, project_label,
1458
+ git_branch, models_json, rollup_title) = row
1459
+ ai = _session_ai_titles_map(conn, [sid])
1460
+ return {
1461
+ "session_id": sid,
1462
+ "title": ai.get(sid) or rollup_title or project_label or sid,
1463
+ "project_label": project_label,
1464
+ "git_branch": git_branch,
1465
+ "started_utc": started,
1466
+ "last_activity_utc": last_activity,
1467
+ "msg_count": msg_count,
1468
+ "cost_usd": round(cost_usd or 0.0, 6),
1469
+ "models": _json.loads(models_json) if models_json else [],
1470
+ }
1471
+ row = conn.execute(
1472
+ "SELECT session_id, COUNT(*), MIN(timestamp_utc), MAX(timestamp_utc) "
1473
+ "FROM conversation_messages WHERE session_id=? GROUP BY session_id",
1474
+ (session_id,),
1475
+ ).fetchone()
1476
+ if row is None:
1477
+ return None
1478
+ sid, msg_count, started, last_activity = row
1479
+ costs = _session_cost_map(conn, [sid])
1480
+ models = _session_models_map(conn, [sid])
1481
+ meta = _session_latest_meta_map(conn, [sid])
1482
+ titles = _session_titles_map(conn, [sid])
1483
+ project_label = _project_label(meta.get(sid, (None, None))[0])
1484
+ return {
1485
+ "session_id": sid,
1486
+ "title": titles.get(sid) or project_label or sid,
1487
+ "project_label": project_label,
1488
+ "git_branch": meta.get(sid, (None, None))[1],
1489
+ "started_utc": started,
1490
+ "last_activity_utc": last_activity,
1491
+ "msg_count": msg_count,
1492
+ "cost_usd": round(costs.get(sid, 0.0), 6),
1493
+ "models": models.get(sid, []),
1494
+ }
1495
+
1496
+
1442
1497
  def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1443
1498
  date_from=None, date_to=None, projects=None,
1444
1499
  cost_min=None, cost_max=None, rebuild_min=None,
1445
- models=None) -> dict:
1500
+ models=None, selected=None) -> dict:
1446
1501
  """All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
1447
1502
 
1448
1503
  Reads the conversation_sessions rollup (Task A) when it is authoritative —
@@ -1565,10 +1620,15 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1565
1620
  # The rail surfaces this: cost/project ordering becomes available once
1566
1621
  # the rollup finishes indexing; this page fell back to recent order.
1567
1622
  page["sort_degraded"] = True
1568
- return {
1623
+ result = {
1569
1624
  "conversations": conversations,
1570
1625
  "page": page,
1571
1626
  }
1627
+ if selected is not None:
1628
+ selected_row = _selected_conversation_summary(conn, selected)
1629
+ if selected_row is not None:
1630
+ result["selected"] = selected_row
1631
+ return result
1572
1632
 
1573
1633
 
1574
1634
  def _turn_cost_map(conn, turn_keys):