cctally 1.76.0 → 1.78.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.
@@ -60,8 +60,10 @@ def _init_paths_from_env() -> None:
60
60
  `import _cctally_core`).
61
61
  """
62
62
  global APP_DIR, LEGACY_APP_DIR, LOG_DIR, DEV_MODE
63
- global DB_PATH, CACHE_DB_PATH
63
+ global DB_PATH, CACHE_DB_PATH, CONVERSATIONS_DB_PATH
64
64
  global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
65
+ global CONVERSATIONS_LOCK_PATH, CONVERSATIONS_LOCK_CODEX_PATH
66
+ global CONVERSATIONS_LOCK_MAINTENANCE_PATH
65
67
  global CONFIG_LOCK_PATH
66
68
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
67
69
  global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
@@ -101,6 +103,7 @@ def _init_paths_from_env() -> None:
101
103
 
102
104
  DB_PATH = APP_DIR / "stats.db"
103
105
  CACHE_DB_PATH = APP_DIR / "cache.db"
106
+ CONVERSATIONS_DB_PATH = APP_DIR / "conversations.db"
104
107
 
105
108
  CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
106
109
  CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
@@ -108,6 +111,14 @@ def _init_paths_from_env() -> None:
108
111
  # retention prune across processes, held ABOVE the two provider flocks so a
109
112
  # rebuild/reingest cannot land between candidate selection and deletion.
110
113
  CACHE_LOCK_MAINTENANCE_PATH = APP_DIR / "cache.db.maintenance.lock"
114
+ # #320: transcript ingest and maintenance are physically independent from
115
+ # the latency-sensitive token/quota cache. Never reuse the cache.db flock
116
+ # namespace for conversations.db writes.
117
+ CONVERSATIONS_LOCK_PATH = APP_DIR / "conversations.db.lock"
118
+ CONVERSATIONS_LOCK_CODEX_PATH = APP_DIR / "conversations.db.codex.lock"
119
+ CONVERSATIONS_LOCK_MAINTENANCE_PATH = (
120
+ APP_DIR / "conversations.db.maintenance.lock"
121
+ )
111
122
  CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
112
123
 
113
124
  CONFIG_PATH = APP_DIR / "config.json"
@@ -241,9 +252,11 @@ def _real_prod_data_dir() -> pathlib.Path:
241
252
  # Internal (no config UI — YAGNI, spec §Out of scope); test injection only.
242
253
  # Spec 2026-07-17-usage-statusline-fallback-design.
243
254
  #
244
- # OAUTH_BACKFILL_STALE_SECONDS: the OAuth poll only backfills once the
245
- # selected-freshness marker is at least this stale. The 30-second statusline
246
- # transport tick does not run OAuth.
255
+ # STATUSLINE_OAUTH_POLL_SECONDS: the account-wide authoritative confirmation
256
+ # cadence driven by Claude Code's 30-second statusline timer. Keep it below
257
+ # the timer so scheduling jitter does not skip every other tick.
258
+ # OAUTH_BACKFILL_STALE_SECONDS: the matching selected-freshness gate shared by
259
+ # statusline-driven and hook-driven automatic OAuth refreshes.
247
260
  # OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
248
261
  # exponential 429 backoff (base * 2**consecutive_429, capped).
249
262
  STATUSLINE_CANDIDATE_TTL_SECONDS = 90
@@ -259,7 +272,8 @@ STATUSLINE_TOMBSTONE_DOCUMENT_MAX_BYTES = 1024
259
272
  # session waits on a long subagent (event-driven updates go quiet then). MUST
260
273
  # Add-when-absent only; a user-set value is never mutated.
261
274
  STATUSLINE_REFRESH_INTERVAL_DEFAULT = 30
262
- OAUTH_BACKFILL_STALE_SECONDS = 300.0
275
+ STATUSLINE_OAUTH_POLL_SECONDS = 25.0
276
+ OAUTH_BACKFILL_STALE_SECONDS = STATUSLINE_OAUTH_POLL_SECONDS
263
277
  OAUTH_BACKOFF_BASE_SECONDS = 60.0
264
278
  OAUTH_BACKOFF_CAP_SECONDS = 3600.0
265
279
 
@@ -356,10 +356,16 @@ from _lib_blocks import _group_entries_into_blocks
356
356
  # #279 S2 F3: stdlib-logging chokepoint — server errors reach stderr via
357
357
  # the real log_error override below (leaf import, no cycle).
358
358
  import _lib_log
359
+ from _lib_dashboard_json import (
360
+ encode_dashboard_json,
361
+ encode_dashboard_json_bytes,
362
+ )
359
363
  from _cctally_config import save_config, _load_config_unlocked
360
364
  from _cctally_db import _render_migration_error_banner
361
365
  from _cctally_cache import (
362
- get_entries, iter_entries, iter_entries_with_id, open_cache_db, sync_cache,
366
+ get_entries, iter_entries, iter_entries_with_id, open_cache_db,
367
+ open_conversations_db, sync_cache, sync_claude_conversations,
368
+ sync_codex_conversations,
363
369
  _prune_orphaned_cache_entries,
364
370
  )
365
371
  from _lib_snapshot_cache import (
@@ -798,12 +804,20 @@ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
798
804
  return detail
799
805
 
800
806
 
801
- def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
807
+ def _source_safe_claude_session_detail(
808
+ detail, *, key: str, project_key: str | None = None, label: str | None = None,
809
+ ) -> dict[str, Any]:
802
810
  """Adapt the existing Claude session detail builder to S4's safe route."""
803
811
  payload = _session_detail_to_envelope(detail)
804
812
  return {
805
813
  "detail_kind": "claude_session",
806
814
  "key": key,
815
+ "label": label or "Claude session",
816
+ "project_label": payload["project_label"],
817
+ "project_key": (
818
+ dashboard_resource_key("project", "claude", project_key)
819
+ if project_key else None
820
+ ),
807
821
  "started_utc": payload["started_utc"],
808
822
  "last_activity_utc": payload["last_activity_utc"],
809
823
  "duration_min": payload["duration_min"],
@@ -815,20 +829,32 @@ def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
815
829
  "cache_hit_pct": payload["cache_hit_pct"],
816
830
  "cost_per_model": payload["cost_per_model"],
817
831
  "cost_total_usd": payload["cost_total_usd"],
832
+ "privacy_note": (
833
+ "Native session identity and source files are withheld to preserve "
834
+ "opaque identity. Cache rebuild history belongs to the private "
835
+ "conversation-detail surface and is intentionally not exposed by "
836
+ "this qualified accounting route."
837
+ ),
818
838
  }
819
839
 
820
840
 
821
- def _source_safe_claude_project_detail(detail: Mapping, *, key: str) -> dict[str, Any]:
841
+ def _source_safe_claude_project_detail(
842
+ detail: Mapping, *, key: str, label: str,
843
+ ) -> dict[str, Any]:
822
844
  """Drop legacy raw bucket/session identifiers from a Claude project drill."""
823
845
  return {
824
846
  "detail_kind": "claude_project",
825
847
  "key": key,
848
+ "label": label,
826
849
  "window_weeks": detail.get("window_weeks"),
827
850
  "window_cost_usd": detail.get("window_cost_usd"),
828
851
  "window_attributed_pct": detail.get("window_attributed_pct"),
829
852
  "models": detail.get("models", []),
830
853
  "sessions": [
831
854
  {
855
+ "key": dashboard_resource_key(
856
+ "session", "claude", item.get("session_id"),
857
+ ),
832
858
  "started_at": item.get("started_at"),
833
859
  "last_activity_at": item.get("last_activity_at"),
834
860
  "primary_model": item.get("primary_model"),
@@ -857,13 +883,73 @@ def _source_safe_claude_block_detail(detail: Mapping, *, key: str) -> dict[str,
857
883
  }
858
884
 
859
885
 
860
- def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
886
+ def _claude_session_row_for_source_key(snapshot, key: str):
861
887
  for row in getattr(snapshot, "sessions", ()) or ():
862
888
  session_id = getattr(row, "session_id", None)
863
889
  if isinstance(session_id, str) and session_id and (
864
890
  dashboard_resource_key("session", "claude", session_id) == key
865
891
  ):
866
- return session_id
892
+ return row
893
+ return None
894
+
895
+
896
+ def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
897
+ """Resolve a qualified session key without exposing its native identity.
898
+
899
+ The published source bundle contains the bounded dashboard rows. A project
900
+ drill can legitimately link to a recent session outside that panel slice,
901
+ so fall back to the compact ``session_files`` identity index and compare
902
+ opaque hashes. This never scans transcript entries or returns a raw ID to
903
+ the client.
904
+ """
905
+ row = _claude_session_row_for_source_key(snapshot, key)
906
+ if row is not None:
907
+ return getattr(row, "session_id", None)
908
+ try:
909
+ conn = open_cache_db()
910
+ except (sqlite3.DatabaseError, OSError):
911
+ return None
912
+ try:
913
+ rows = conn.execute(
914
+ "SELECT DISTINCT session_id FROM session_files "
915
+ "WHERE session_id IS NOT NULL AND session_id <> '' "
916
+ "ORDER BY session_id"
917
+ )
918
+ for (session_id,) in rows:
919
+ if (
920
+ isinstance(session_id, str)
921
+ and dashboard_resource_key(
922
+ "session", "claude", session_id,
923
+ ) == key
924
+ ):
925
+ return session_id
926
+ except sqlite3.Error:
927
+ return None
928
+ finally:
929
+ conn.close()
930
+ return None
931
+
932
+
933
+ def _claude_project_key_for_path(snapshot, project_path: object) -> str | None:
934
+ """Map one private project path back to its public display key."""
935
+ if not isinstance(project_path, str) or not project_path:
936
+ return None
937
+ env = getattr(snapshot, "projects_envelope", None)
938
+ if not isinstance(env, Mapping):
939
+ return None
940
+ candidates: list[object] = []
941
+ current = env.get("current_week")
942
+ if isinstance(current, Mapping):
943
+ candidates.extend(current.get("rows") or ())
944
+ trend = env.get("trend")
945
+ if isinstance(trend, Mapping):
946
+ candidates.extend(trend.get("projects") or ())
947
+ for row in candidates:
948
+ if not isinstance(row, Mapping) or row.get("bucket_path") != project_path:
949
+ continue
950
+ project_key = row.get("key")
951
+ if isinstance(project_key, str) and project_key:
952
+ return project_key
867
953
  return None
868
954
 
869
955
 
@@ -904,19 +990,39 @@ def _claude_block_start_for_source_key(snapshot, key: str) -> dt.datetime | None
904
990
  return None
905
991
 
906
992
 
907
- def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[str, Any]:
993
+ def _build_claude_source_detail(
994
+ snapshot, *, resource: str, key: str, row: Mapping,
995
+ window_weeks: int = 4,
996
+ ) -> dict[str, Any]:
908
997
  """Map a bounded legacy row to its existing, cache-only detail builder."""
909
998
  now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
910
999
  if resource == "session":
911
- session_id = _claude_session_id_for_source_key(snapshot, key)
912
- if session_id is None:
1000
+ session_row = _claude_session_row_for_source_key(snapshot, key)
1001
+ session_id = (
1002
+ getattr(session_row, "session_id", None)
1003
+ if session_row is not None
1004
+ else _claude_session_id_for_source_key(snapshot, key)
1005
+ )
1006
+ if not isinstance(session_id, str) or not session_id:
913
1007
  raise SourceResourceNotFound()
914
1008
  detail = sys.modules["cctally"]._tui_build_session_detail(
915
1009
  session_id, now_utc=now_utc,
916
1010
  )
917
1011
  if detail is None:
918
1012
  raise SourceResourceNotFound()
919
- return _source_safe_claude_session_detail(detail, key=key)
1013
+ project_key = (
1014
+ getattr(session_row, "project_key", None)
1015
+ if session_row is not None
1016
+ else _claude_project_key_for_path(
1017
+ snapshot, getattr(detail, "project_path", None),
1018
+ )
1019
+ )
1020
+ return _source_safe_claude_session_detail(
1021
+ detail,
1022
+ key=key,
1023
+ project_key=project_key,
1024
+ label=str(row.get("title") or row.get("label") or "Claude session"),
1025
+ )
920
1026
 
921
1027
  if resource == "project":
922
1028
  project_key = _claude_project_key_for_source_key(snapshot, key)
@@ -930,7 +1036,7 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
930
1036
  detail = _project_detail_for_window(
931
1037
  conn,
932
1038
  project_key=project_key,
933
- weeks_back=1,
1039
+ weeks_back=window_weeks,
934
1040
  now_utc=now_utc,
935
1041
  current_week=getattr(snapshot, "current_week", None),
936
1042
  projects_envelope=getattr(snapshot, "projects_envelope", None),
@@ -945,7 +1051,9 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
945
1051
  conn.close()
946
1052
  if detail is None:
947
1053
  raise SourceResourceNotFound()
948
- return _source_safe_claude_project_detail(detail, key=key)
1054
+ return _source_safe_claude_project_detail(
1055
+ detail, key=key, label=project_key,
1056
+ )
949
1057
 
950
1058
  start_at = _claude_block_start_for_source_key(snapshot, key)
951
1059
  if start_at is None:
@@ -982,7 +1090,7 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
982
1090
 
983
1091
 
984
1092
  def build_source_detail(*, snapshot, source: str, resource: str,
985
- key: str) -> dict[str, Any]:
1093
+ key: str, window_weeks: int = 4) -> dict[str, Any]:
986
1094
  """Resolve one qualified opaque key to a provider-native read-only detail.
987
1095
 
988
1096
  The frozen published bundle is the first bounded key index. Claude then
@@ -990,9 +1098,24 @@ def build_source_detail(*, snapshot, source: str, resource: str,
990
1098
  the native S1-S3 adapter detail without a Claude fallback or a rollout
991
1099
  parse. Every branch preserves the generic handler errors above it.
992
1100
  """
993
- row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
1101
+ try:
1102
+ row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
1103
+ except SourceResourceNotFound:
1104
+ # A Claude project drill can expose a recent session outside the
1105
+ # dashboard panel's bounded published slice. The provider adapter
1106
+ # resolves that opaque key against session_files below; all other
1107
+ # resources retain the strict frozen-bundle membership gate.
1108
+ if source != "claude" or resource != "session":
1109
+ raise
1110
+ row = {}
994
1111
  if source == "claude":
995
- return _build_claude_source_detail(snapshot, resource=resource, key=key)
1112
+ return _build_claude_source_detail(
1113
+ snapshot,
1114
+ resource=resource,
1115
+ key=key,
1116
+ row=row,
1117
+ window_weeks=window_weeks,
1118
+ )
996
1119
  try:
997
1120
  detail = _build_codex_source_detail(snapshot, resource=resource, key=key)
998
1121
  if resource == "session":
@@ -1175,8 +1298,7 @@ def _dashboard_sync_loop(
1175
1298
 
1176
1299
  def _dashboard_maybe_prune_retention() -> None:
1177
1300
  """#313 P3 (F7): throttled transcript retention prune driven from the
1178
- dashboard sync thread. Runs once per iteration INSIDE the measured cooldown
1179
- work (Task 6), so its cost paces the deadline. Opens a dedicated cache
1301
+ independent conversation sync thread. Opens a dedicated transcript
1180
1302
  connection (no provider flock, no open transaction) for the orchestrator.
1181
1303
  No-op when retention is disabled; never raises (a prune failure must not
1182
1304
  crash the sync thread)."""
@@ -1187,7 +1309,7 @@ def _dashboard_maybe_prune_retention() -> None:
1187
1309
  retention_days = resolve_retention_days(c.load_config())
1188
1310
  if retention_days <= 0:
1189
1311
  return
1190
- conn = c.open_cache_db()
1312
+ conn = c.open_conversations_db(attach_cache=False)
1191
1313
  try:
1192
1314
  retention._maybe_prune_conversation_retention(
1193
1315
  conn,
@@ -3933,7 +4055,7 @@ def _handle_get_project_detail_impl(handler, *,
3933
4055
  except (TypeError, ValueError):
3934
4056
  weeks = None
3935
4057
  if weeks not in {1, 4, 8, 12}:
3936
- body = json.dumps({"error": "invalid weeks param"}).encode("utf-8")
4058
+ body = encode_dashboard_json_bytes({"error": "invalid weeks param"})
3937
4059
  handler.send_response(400)
3938
4060
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3939
4061
  handler.send_header("Content-Length", str(len(body)))
@@ -3976,9 +4098,9 @@ def _handle_get_project_detail_impl(handler, *,
3976
4098
  )
3977
4099
  except Exception as exc:
3978
4100
  handler.log_error("/api/project failed: %r", exc)
3979
- body = json.dumps(
4101
+ body = encode_dashboard_json_bytes(
3980
4102
  {"error": "project detail failed"}
3981
- ).encode("utf-8")
4103
+ )
3982
4104
  handler.send_response(500)
3983
4105
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3984
4106
  handler.send_header("Content-Length", str(len(body)))
@@ -3986,16 +4108,16 @@ def _handle_get_project_detail_impl(handler, *,
3986
4108
  handler.wfile.write(body)
3987
4109
  return
3988
4110
  if detail is None:
3989
- body = json.dumps(
4111
+ body = encode_dashboard_json_bytes(
3990
4112
  {"error": "project not found", "key": project_key},
3991
- ).encode("utf-8")
4113
+ )
3992
4114
  handler.send_response(404)
3993
4115
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3994
4116
  handler.send_header("Content-Length", str(len(body)))
3995
4117
  handler.end_headers()
3996
4118
  handler.wfile.write(body)
3997
4119
  return
3998
- body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
4120
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
3999
4121
  handler.send_response(200)
4000
4122
  handler.send_header("Content-Type", "application/json; charset=utf-8")
4001
4123
  handler.send_header("Content-Length", str(len(body)))
@@ -4100,10 +4222,6 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
4100
4222
  _DEBUG_CACHE_TABLES = (
4101
4223
  "session_entries",
4102
4224
  "session_files",
4103
- "conversation_messages",
4104
- "conversation_sessions",
4105
- "conversation_ai_titles",
4106
- "conversation_file_touches",
4107
4225
  "codex_session_entries",
4108
4226
  "codex_session_files",
4109
4227
  )
@@ -4459,7 +4577,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4459
4577
  _respond_json plus an explicit Allow header.
4460
4578
  """
4461
4579
  if self.path.split("?", 1)[0] == "/api/settings":
4462
- encoded = json.dumps({"error": "method not allowed"}).encode("utf-8")
4580
+ encoded = encode_dashboard_json_bytes(
4581
+ {"error": "method not allowed"}
4582
+ )
4463
4583
  self.send_response(405)
4464
4584
  self.send_header("Allow", "POST")
4465
4585
  self.send_header("Content-Type", "application/json")
@@ -5692,7 +5812,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5692
5812
  # ---- helpers ----
5693
5813
 
5694
5814
  def _respond_json(self, status: int, body: dict) -> None:
5695
- encoded = json.dumps(body).encode("utf-8")
5815
+ encoded = encode_dashboard_json_bytes(body)
5696
5816
  self.send_response(status)
5697
5817
  self.send_header("Content-Type", "application/json")
5698
5818
  self.send_header("Content-Length", str(len(encoded)))
@@ -5763,7 +5883,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5763
5883
  # transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
5764
5884
  # finding) — one predicate, two consumers, desync impossible.
5765
5885
  env["transcriptsEnabled"] = visible
5766
- body = json.dumps(env, ensure_ascii=False).encode("utf-8")
5886
+ body = encode_dashboard_json_bytes(env, ensure_ascii=False)
5767
5887
  self.send_response(200)
5768
5888
  self.send_header("Content-Type", "application/json; charset=utf-8")
5769
5889
  self.send_header("Content-Length", str(len(body)))
@@ -5798,9 +5918,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5798
5918
  runtime_bind=type(self).cctally_host,
5799
5919
  )
5800
5920
  report = _ld.run_checks(state)
5801
- body = json.dumps(
5921
+ body = encode_dashboard_json_bytes(
5802
5922
  _ld.serialize_json(report), ensure_ascii=False,
5803
- ).encode("utf-8")
5923
+ )
5804
5924
  self.send_response(200)
5805
5925
  self.send_header("Content-Type", "application/json; charset=utf-8")
5806
5926
  self.send_header("Content-Length", str(len(body)))
@@ -5848,9 +5968,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5848
5968
  if detail is None:
5849
5969
  self.send_error(404, "session not found")
5850
5970
  return
5851
- body = json.dumps(
5971
+ body = encode_dashboard_json_bytes(
5852
5972
  _session_detail_to_envelope(detail), ensure_ascii=False
5853
- ).encode("utf-8")
5973
+ )
5854
5974
  self.send_response(200)
5855
5975
  self.send_header("Content-Type", "application/json; charset=utf-8")
5856
5976
  self.send_header("Content-Length", str(len(body)))
@@ -5897,14 +6017,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5897
6017
  "error": "source capability unavailable",
5898
6018
  })
5899
6019
  return
5900
- try:
5901
- snap = self.snapshot_ref.get()
5902
- detail = build_source_detail(
5903
- snapshot=snap,
5904
- source=source,
5905
- resource=resource,
5906
- key=key,
6020
+ detail_kwargs = {
6021
+ "source": source,
6022
+ "resource": resource,
6023
+ "key": key,
6024
+ }
6025
+ if resource == "project":
6026
+ query = _urlparse.parse_qs(
6027
+ _urlparse.urlsplit(self.path).query,
6028
+ keep_blank_values=True,
5907
6029
  )
6030
+ raw_weeks = query.get("weeks")
6031
+ if raw_weeks is not None:
6032
+ if len(raw_weeks) != 1 or raw_weeks[0] not in {"1", "4", "8", "12"}:
6033
+ self._respond_json(400, {
6034
+ "code": "source_capability_unavailable",
6035
+ "error": "source capability unavailable",
6036
+ })
6037
+ return
6038
+ detail_kwargs["window_weeks"] = int(raw_weeks[0])
6039
+ try:
6040
+ detail_kwargs["snapshot"] = self.snapshot_ref.get()
6041
+ detail = build_source_detail(**detail_kwargs)
5908
6042
  except SourceResourceNotFound:
5909
6043
  self._respond_json(404, {
5910
6044
  "code": "source_resource_not_found",
@@ -6052,7 +6186,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6052
6186
  try:
6053
6187
  start_at = parse_iso_datetime(decoded, "start_at")
6054
6188
  except ValueError:
6055
- body = json.dumps({"error": "invalid start_at"}).encode("utf-8")
6189
+ body = encode_dashboard_json_bytes({"error": "invalid start_at"})
6056
6190
  self.send_response(400)
6057
6191
  self.send_header("Content-Type", "application/json; charset=utf-8")
6058
6192
  self.send_header("Content-Length", str(len(body)))
@@ -6100,7 +6234,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6100
6234
  None,
6101
6235
  )
6102
6236
  if target is None:
6103
- body = json.dumps({"error": "block not found"}).encode("utf-8")
6237
+ body = encode_dashboard_json_bytes({"error": "block not found"})
6104
6238
  self.send_response(404)
6105
6239
  self.send_header("Content-Type", "application/json; charset=utf-8")
6106
6240
  self.send_header("Content-Length", str(len(body)))
@@ -6130,7 +6264,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6130
6264
  self.log_error("/api/block failed: %r", exc)
6131
6265
  self.send_error(500, "block detail failed")
6132
6266
  return
6133
- body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
6267
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
6134
6268
  self.send_response(200)
6135
6269
  self.send_header("Content-Type", "application/json; charset=utf-8")
6136
6270
  self.send_header("Content-Length", str(len(body)))
@@ -6192,7 +6326,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6192
6326
  env["transcriptsEnabled"] = transcripts_enabled
6193
6327
  msg = (
6194
6328
  "event: update\n"
6195
- + "data: " + json.dumps(env, ensure_ascii=False) + "\n\n"
6329
+ + "data: "
6330
+ + encode_dashboard_json(env, ensure_ascii=False)
6331
+ + "\n\n"
6196
6332
  )
6197
6333
  self.wfile.write(msg.encode("utf-8"))
6198
6334
  self.wfile.flush()
@@ -6390,7 +6526,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6390
6526
  try:
6391
6527
  for ev in worker.stream(run_id):
6392
6528
  ev_type = ev.get("type", "message")
6393
- ev_data = json.dumps(
6529
+ ev_data = encode_dashboard_json(
6394
6530
  {k: v for k, v in ev.items() if k != "type"},
6395
6531
  ensure_ascii=False,
6396
6532
  )
@@ -6769,11 +6905,6 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6769
6905
  and _time.monotonic() - last_heal[0] >= 60.0):
6770
6906
  last_heal[0] = _time.monotonic()
6771
6907
  _dashboard_self_heal_orphans(skip_sync=self._skip_sync)
6772
- # #313 P3 (F7): throttled transcript retention prune, inside the
6773
- # measured iteration so its cost is in the cooldown work. Gated
6774
- # off under --no-sync (frozen mode makes no cache writes).
6775
- if not self._skip_sync:
6776
- _dashboard_maybe_prune_retention()
6777
6908
 
6778
6909
  # Work-proportional cooldown (F10): sleep to t0 + max(interval, work)
6779
6910
  # so a slow rebuild cannot peg a full core. The manual POST /api/sync
@@ -6797,6 +6928,47 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6797
6928
  if sync_thread is not None:
6798
6929
  sync_thread.start()
6799
6930
 
6931
+ # #320: transcript/search ingestion runs on its own thread and SQLite file.
6932
+ # A multi-GB first rebuild or a contended conversations.db therefore cannot
6933
+ # delay `_run_sync_now`, its `last_sync_at` stamp, or core SSE publication.
6934
+ conversation_sync_stop = threading.Event()
6935
+
6936
+ def _conversation_sync_loop() -> None:
6937
+ interval = max(5.0, float(args.sync_interval))
6938
+ while not conversation_sync_stop.is_set():
6939
+ conn = None
6940
+ try:
6941
+ conn = open_conversations_db()
6942
+ sync_claude_conversations(conn)
6943
+ sync_codex_conversations(conn)
6944
+ _dashboard_maybe_prune_retention()
6945
+ except (OSError, sqlite3.DatabaseError) as exc:
6946
+ eprint(f"[conversations] background sync unavailable: {exc}")
6947
+ except Exception as exc: # noqa: BLE001
6948
+ # Transcript parsing/normalization is deliberately outside the
6949
+ # core freshness loop. Keep this worker alive so a later clean
6950
+ # tick can self-heal instead of permanently stopping after one
6951
+ # malformed provider record.
6952
+ eprint(
6953
+ "[conversations] background sync failed: "
6954
+ f"{type(exc).__name__}: {exc}"
6955
+ )
6956
+ finally:
6957
+ if conn is not None:
6958
+ conn.close()
6959
+ conversation_sync_stop.wait(interval)
6960
+
6961
+ conversation_sync_thread = (
6962
+ None if args.no_sync
6963
+ else threading.Thread(
6964
+ target=_conversation_sync_loop,
6965
+ daemon=True,
6966
+ name="dashboard-conversations-sync",
6967
+ )
6968
+ )
6969
+ if conversation_sync_thread is not None:
6970
+ conversation_sync_thread.start()
6971
+
6800
6972
  # Spec §3.5 (codex review fix #5): update-check thread runs even
6801
6973
  # under --no-sync (frozen-data sessions still surface new versions).
6802
6974
  # Dedicated stop event so we can join cleanly on shutdown without
@@ -6879,6 +7051,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
6879
7051
  finally:
6880
7052
  if sync_thread is not None:
6881
7053
  sync_thread.stop()
7054
+ conversation_sync_stop.set()
7055
+ if conversation_sync_thread is not None:
7056
+ conversation_sync_thread.join(timeout=2)
6882
7057
  update_check_stop.set()
6883
7058
  srv.shutdown()
6884
7059
  http_thread.join(timeout=2)