cctally 1.77.0 → 1.79.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.
@@ -356,6 +356,10 @@ 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 (
@@ -800,12 +804,20 @@ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
800
804
  return detail
801
805
 
802
806
 
803
- 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]:
804
810
  """Adapt the existing Claude session detail builder to S4's safe route."""
805
811
  payload = _session_detail_to_envelope(detail)
806
812
  return {
807
813
  "detail_kind": "claude_session",
808
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
+ ),
809
821
  "started_utc": payload["started_utc"],
810
822
  "last_activity_utc": payload["last_activity_utc"],
811
823
  "duration_min": payload["duration_min"],
@@ -817,20 +829,32 @@ def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
817
829
  "cache_hit_pct": payload["cache_hit_pct"],
818
830
  "cost_per_model": payload["cost_per_model"],
819
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
+ ),
820
838
  }
821
839
 
822
840
 
823
- 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]:
824
844
  """Drop legacy raw bucket/session identifiers from a Claude project drill."""
825
845
  return {
826
846
  "detail_kind": "claude_project",
827
847
  "key": key,
848
+ "label": label,
828
849
  "window_weeks": detail.get("window_weeks"),
829
850
  "window_cost_usd": detail.get("window_cost_usd"),
830
851
  "window_attributed_pct": detail.get("window_attributed_pct"),
831
852
  "models": detail.get("models", []),
832
853
  "sessions": [
833
854
  {
855
+ "key": dashboard_resource_key(
856
+ "session", "claude", item.get("session_id"),
857
+ ),
834
858
  "started_at": item.get("started_at"),
835
859
  "last_activity_at": item.get("last_activity_at"),
836
860
  "primary_model": item.get("primary_model"),
@@ -859,13 +883,73 @@ def _source_safe_claude_block_detail(detail: Mapping, *, key: str) -> dict[str,
859
883
  }
860
884
 
861
885
 
862
- def _claude_session_id_for_source_key(snapshot, key: str) -> str | None:
886
+ def _claude_session_row_for_source_key(snapshot, key: str):
863
887
  for row in getattr(snapshot, "sessions", ()) or ():
864
888
  session_id = getattr(row, "session_id", None)
865
889
  if isinstance(session_id, str) and session_id and (
866
890
  dashboard_resource_key("session", "claude", session_id) == key
867
891
  ):
868
- 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
869
953
  return None
870
954
 
871
955
 
@@ -906,19 +990,39 @@ def _claude_block_start_for_source_key(snapshot, key: str) -> dt.datetime | None
906
990
  return None
907
991
 
908
992
 
909
- 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]:
910
997
  """Map a bounded legacy row to its existing, cache-only detail builder."""
911
998
  now_utc = getattr(snapshot, "generated_at", None) or _command_as_of()
912
999
  if resource == "session":
913
- session_id = _claude_session_id_for_source_key(snapshot, key)
914
- 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:
915
1007
  raise SourceResourceNotFound()
916
1008
  detail = sys.modules["cctally"]._tui_build_session_detail(
917
1009
  session_id, now_utc=now_utc,
918
1010
  )
919
1011
  if detail is None:
920
1012
  raise SourceResourceNotFound()
921
- 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
+ )
922
1026
 
923
1027
  if resource == "project":
924
1028
  project_key = _claude_project_key_for_source_key(snapshot, key)
@@ -932,7 +1036,7 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
932
1036
  detail = _project_detail_for_window(
933
1037
  conn,
934
1038
  project_key=project_key,
935
- weeks_back=1,
1039
+ weeks_back=window_weeks,
936
1040
  now_utc=now_utc,
937
1041
  current_week=getattr(snapshot, "current_week", None),
938
1042
  projects_envelope=getattr(snapshot, "projects_envelope", None),
@@ -947,7 +1051,9 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
947
1051
  conn.close()
948
1052
  if detail is None:
949
1053
  raise SourceResourceNotFound()
950
- return _source_safe_claude_project_detail(detail, key=key)
1054
+ return _source_safe_claude_project_detail(
1055
+ detail, key=key, label=project_key,
1056
+ )
951
1057
 
952
1058
  start_at = _claude_block_start_for_source_key(snapshot, key)
953
1059
  if start_at is None:
@@ -984,7 +1090,7 @@ def _build_claude_source_detail(snapshot, *, resource: str, key: str) -> dict[st
984
1090
 
985
1091
 
986
1092
  def build_source_detail(*, snapshot, source: str, resource: str,
987
- key: str) -> dict[str, Any]:
1093
+ key: str, window_weeks: int = 4) -> dict[str, Any]:
988
1094
  """Resolve one qualified opaque key to a provider-native read-only detail.
989
1095
 
990
1096
  The frozen published bundle is the first bounded key index. Claude then
@@ -992,9 +1098,24 @@ def build_source_detail(*, snapshot, source: str, resource: str,
992
1098
  the native S1-S3 adapter detail without a Claude fallback or a rollout
993
1099
  parse. Every branch preserves the generic handler errors above it.
994
1100
  """
995
- 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 = {}
996
1111
  if source == "claude":
997
- 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
+ )
998
1119
  try:
999
1120
  detail = _build_codex_source_detail(snapshot, resource=resource, key=key)
1000
1121
  if resource == "session":
@@ -3934,7 +4055,7 @@ def _handle_get_project_detail_impl(handler, *,
3934
4055
  except (TypeError, ValueError):
3935
4056
  weeks = None
3936
4057
  if weeks not in {1, 4, 8, 12}:
3937
- body = json.dumps({"error": "invalid weeks param"}).encode("utf-8")
4058
+ body = encode_dashboard_json_bytes({"error": "invalid weeks param"})
3938
4059
  handler.send_response(400)
3939
4060
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3940
4061
  handler.send_header("Content-Length", str(len(body)))
@@ -3977,9 +4098,9 @@ def _handle_get_project_detail_impl(handler, *,
3977
4098
  )
3978
4099
  except Exception as exc:
3979
4100
  handler.log_error("/api/project failed: %r", exc)
3980
- body = json.dumps(
4101
+ body = encode_dashboard_json_bytes(
3981
4102
  {"error": "project detail failed"}
3982
- ).encode("utf-8")
4103
+ )
3983
4104
  handler.send_response(500)
3984
4105
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3985
4106
  handler.send_header("Content-Length", str(len(body)))
@@ -3987,16 +4108,16 @@ def _handle_get_project_detail_impl(handler, *,
3987
4108
  handler.wfile.write(body)
3988
4109
  return
3989
4110
  if detail is None:
3990
- body = json.dumps(
4111
+ body = encode_dashboard_json_bytes(
3991
4112
  {"error": "project not found", "key": project_key},
3992
- ).encode("utf-8")
4113
+ )
3993
4114
  handler.send_response(404)
3994
4115
  handler.send_header("Content-Type", "application/json; charset=utf-8")
3995
4116
  handler.send_header("Content-Length", str(len(body)))
3996
4117
  handler.end_headers()
3997
4118
  handler.wfile.write(body)
3998
4119
  return
3999
- body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
4120
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
4000
4121
  handler.send_response(200)
4001
4122
  handler.send_header("Content-Type", "application/json; charset=utf-8")
4002
4123
  handler.send_header("Content-Length", str(len(body)))
@@ -4314,6 +4435,7 @@ _GET_ROUTES = (
4314
4435
  ("prefix", "/api/session/", "_handle_get_session_detail", None, True),
4315
4436
  ("prefix", "/api/project/", "_handle_get_project_detail", None, False),
4316
4437
  ("prefix", "/api/block/", "_handle_get_block_detail", None, True),
4438
+ ("prefix", "/api/milestones/", "_handle_get_milestones_week", None, True),
4317
4439
  ("exact", "/api/update/status", "_handle_get_update_status", None, False),
4318
4440
  ("prefix", "/api/update/stream/", "_handle_get_update_stream", None, True),
4319
4441
  ("exact", "/api/share/templates", "_handle_share_templates_get", None, False),
@@ -4456,7 +4578,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4456
4578
  _respond_json plus an explicit Allow header.
4457
4579
  """
4458
4580
  if self.path.split("?", 1)[0] == "/api/settings":
4459
- encoded = json.dumps({"error": "method not allowed"}).encode("utf-8")
4581
+ encoded = encode_dashboard_json_bytes(
4582
+ {"error": "method not allowed"}
4583
+ )
4460
4584
  self.send_response(405)
4461
4585
  self.send_header("Allow", "POST")
4462
4586
  self.send_header("Content-Type", "application/json")
@@ -5105,6 +5229,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5105
5229
  # compatible. `enabled` must be a JSON bool; `ttl_hours` an int
5106
5230
  # (bools rejected — see _validate_update_check_ttl_hours_value).
5107
5231
  update_check_validated: "dict | None" = None
5232
+ update_channel_validated: "str | None" = None
5108
5233
  if "update" in payload:
5109
5234
  update_in = payload["update"]
5110
5235
  if not isinstance(update_in, dict):
@@ -5113,13 +5238,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5113
5238
  )
5114
5239
  return
5115
5240
  for inner in update_in.keys():
5116
- if inner != "check":
5241
+ if inner not in ("check", "channel"):
5117
5242
  self._respond_json(
5118
5243
  400,
5119
5244
  {"error": f"unknown update settings key: {inner}",
5120
5245
  "field": f"update.{inner}"},
5121
5246
  )
5122
5247
  return
5248
+ # Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
5249
+ # {stable,beta}; 400 on invalid. Mirrors the config-key validator.
5250
+ if "channel" in update_in:
5251
+ try:
5252
+ update_channel_validated = (
5253
+ _cctally()._validate_update_channel_value(
5254
+ update_in["channel"]
5255
+ )
5256
+ )
5257
+ except ValueError as exc:
5258
+ self._respond_json(
5259
+ 400,
5260
+ {"error": str(exc), "field": "update.channel"},
5261
+ )
5262
+ return
5123
5263
  check_in = update_in.get("check", {})
5124
5264
  if not isinstance(check_in, dict):
5125
5265
  self._respond_json(
@@ -5275,7 +5415,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5275
5415
  self._respond_json(400, {"error": str(exc)})
5276
5416
  return
5277
5417
 
5278
- if update_check_validated is not None:
5418
+ if (
5419
+ update_check_validated is not None
5420
+ or update_channel_validated is not None
5421
+ ):
5279
5422
  # Same hand-edited-junk guard as alerts: a non-dict
5280
5423
  # `update` or `update.check` block in config.json should
5281
5424
  # surface as a recoverable 400, not a 500.
@@ -5289,18 +5432,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5289
5432
  )
5290
5433
  return
5291
5434
  merged_update = dict(existing_update or {})
5292
- existing_check = merged_update.get("check")
5293
- if existing_check is not None and not isinstance(
5294
- existing_check, dict
5295
- ):
5296
- self._respond_json(
5297
- 400, {"error": "update.check must be an object",
5298
- "field": "update.check"}
5299
- )
5300
- return
5301
- merged_check = dict(existing_check or {})
5302
- merged_check.update(update_check_validated)
5303
- merged_update["check"] = merged_check
5435
+ if update_check_validated is not None:
5436
+ existing_check = merged_update.get("check")
5437
+ if existing_check is not None and not isinstance(
5438
+ existing_check, dict
5439
+ ):
5440
+ self._respond_json(
5441
+ 400, {"error": "update.check must be an object",
5442
+ "field": "update.check"}
5443
+ )
5444
+ return
5445
+ merged_check = dict(existing_check or {})
5446
+ merged_check.update(update_check_validated)
5447
+ merged_update["check"] = merged_check
5448
+ if update_channel_validated is not None:
5449
+ # Partial-PUT: preserve the sibling `update.check` block.
5450
+ merged_update["channel"] = update_channel_validated
5304
5451
  merged["update"] = merged_update
5305
5452
 
5306
5453
  if cache_report_validated is not None:
@@ -5408,11 +5555,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5408
5555
  _cctally()._reconcile_codex_budget_on_config_write(
5409
5556
  validated_budget
5410
5557
  )
5411
- if update_check_validated is not None:
5412
- # Echo the full merged check block (cooked defaults included)
5413
- # so the SettingsOverlay can repaint without a follow-up GET.
5414
- out["update"] = {
5415
- "check": {
5558
+ if (
5559
+ update_check_validated is not None
5560
+ or update_channel_validated is not None
5561
+ ):
5562
+ # Echo the touched update leaves (cooked defaults included) so the
5563
+ # SettingsOverlay can repaint without a follow-up GET. The channel
5564
+ # echo uses the read-surface name `configured_channel` (spec §3
5565
+ # naming), mirrored from the merged config via the single resolver.
5566
+ out_update: dict = {}
5567
+ if update_check_validated is not None:
5568
+ out_update["check"] = {
5416
5569
  "enabled": _config_known_value(
5417
5570
  merged, "update.check.enabled"
5418
5571
  ),
@@ -5420,7 +5573,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5420
5573
  merged, "update.check.ttl_hours"
5421
5574
  ),
5422
5575
  }
5423
- }
5576
+ if update_channel_validated is not None:
5577
+ out_update["configured_channel"] = (
5578
+ _cctally().resolve_update_channel(merged)
5579
+ )
5580
+ out["update"] = out_update
5424
5581
  if cache_report_validated is not None:
5425
5582
  # Echo the full cooked block (resolved defaults included) so
5426
5583
  # the dashboard composer can repaint without a follow-up GET
@@ -5689,7 +5846,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5689
5846
  # ---- helpers ----
5690
5847
 
5691
5848
  def _respond_json(self, status: int, body: dict) -> None:
5692
- encoded = json.dumps(body).encode("utf-8")
5849
+ encoded = encode_dashboard_json_bytes(body)
5693
5850
  self.send_response(status)
5694
5851
  self.send_header("Content-Type", "application/json")
5695
5852
  self.send_header("Content-Length", str(len(encoded)))
@@ -5760,7 +5917,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5760
5917
  # transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
5761
5918
  # finding) — one predicate, two consumers, desync impossible.
5762
5919
  env["transcriptsEnabled"] = visible
5763
- body = json.dumps(env, ensure_ascii=False).encode("utf-8")
5920
+ body = encode_dashboard_json_bytes(env, ensure_ascii=False)
5764
5921
  self.send_response(200)
5765
5922
  self.send_header("Content-Type", "application/json; charset=utf-8")
5766
5923
  self.send_header("Content-Length", str(len(body)))
@@ -5795,9 +5952,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5795
5952
  runtime_bind=type(self).cctally_host,
5796
5953
  )
5797
5954
  report = _ld.run_checks(state)
5798
- body = json.dumps(
5955
+ body = encode_dashboard_json_bytes(
5799
5956
  _ld.serialize_json(report), ensure_ascii=False,
5800
- ).encode("utf-8")
5957
+ )
5801
5958
  self.send_response(200)
5802
5959
  self.send_header("Content-Type", "application/json; charset=utf-8")
5803
5960
  self.send_header("Content-Length", str(len(body)))
@@ -5845,9 +6002,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5845
6002
  if detail is None:
5846
6003
  self.send_error(404, "session not found")
5847
6004
  return
5848
- body = json.dumps(
6005
+ body = encode_dashboard_json_bytes(
5849
6006
  _session_detail_to_envelope(detail), ensure_ascii=False
5850
- ).encode("utf-8")
6007
+ )
5851
6008
  self.send_response(200)
5852
6009
  self.send_header("Content-Type", "application/json; charset=utf-8")
5853
6010
  self.send_header("Content-Length", str(len(body)))
@@ -5894,14 +6051,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5894
6051
  "error": "source capability unavailable",
5895
6052
  })
5896
6053
  return
5897
- try:
5898
- snap = self.snapshot_ref.get()
5899
- detail = build_source_detail(
5900
- snapshot=snap,
5901
- source=source,
5902
- resource=resource,
5903
- key=key,
6054
+ detail_kwargs = {
6055
+ "source": source,
6056
+ "resource": resource,
6057
+ "key": key,
6058
+ }
6059
+ if resource == "project":
6060
+ query = _urlparse.parse_qs(
6061
+ _urlparse.urlsplit(self.path).query,
6062
+ keep_blank_values=True,
5904
6063
  )
6064
+ raw_weeks = query.get("weeks")
6065
+ if raw_weeks is not None:
6066
+ if len(raw_weeks) != 1 or raw_weeks[0] not in {"1", "4", "8", "12"}:
6067
+ self._respond_json(400, {
6068
+ "code": "source_capability_unavailable",
6069
+ "error": "source capability unavailable",
6070
+ })
6071
+ return
6072
+ detail_kwargs["window_weeks"] = int(raw_weeks[0])
6073
+ try:
6074
+ detail_kwargs["snapshot"] = self.snapshot_ref.get()
6075
+ detail = build_source_detail(**detail_kwargs)
5905
6076
  except SourceResourceNotFound:
5906
6077
  self._respond_json(404, {
5907
6078
  "code": "source_resource_not_found",
@@ -6049,7 +6220,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6049
6220
  try:
6050
6221
  start_at = parse_iso_datetime(decoded, "start_at")
6051
6222
  except ValueError:
6052
- body = json.dumps({"error": "invalid start_at"}).encode("utf-8")
6223
+ body = encode_dashboard_json_bytes({"error": "invalid start_at"})
6053
6224
  self.send_response(400)
6054
6225
  self.send_header("Content-Type", "application/json; charset=utf-8")
6055
6226
  self.send_header("Content-Length", str(len(body)))
@@ -6097,7 +6268,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6097
6268
  None,
6098
6269
  )
6099
6270
  if target is None:
6100
- body = json.dumps({"error": "block not found"}).encode("utf-8")
6271
+ body = encode_dashboard_json_bytes({"error": "block not found"})
6101
6272
  self.send_response(404)
6102
6273
  self.send_header("Content-Type", "application/json; charset=utf-8")
6103
6274
  self.send_header("Content-Length", str(len(body)))
@@ -6127,7 +6298,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6127
6298
  self.log_error("/api/block failed: %r", exc)
6128
6299
  self.send_error(500, "block detail failed")
6129
6300
  return
6130
- body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
6301
+ body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
6131
6302
  self.send_response(200)
6132
6303
  self.send_header("Content-Type", "application/json; charset=utf-8")
6133
6304
  self.send_header("Content-Length", str(len(body)))
@@ -6135,6 +6306,117 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6135
6306
  self.end_headers()
6136
6307
  self.wfile.write(body)
6137
6308
 
6309
+ def _send_milestones_json(self, status: int, body: dict) -> None:
6310
+ payload = encode_dashboard_json_bytes(body, ensure_ascii=False)
6311
+ self.send_response(status)
6312
+ self.send_header("Content-Type", "application/json; charset=utf-8")
6313
+ self.send_header("Content-Length", str(len(payload)))
6314
+ self.send_header("Cache-Control", "no-cache")
6315
+ self.end_headers()
6316
+ self.wfile.write(payload)
6317
+
6318
+ def _handle_get_milestones_week(self, path: str) -> None:
6319
+ """GET /api/milestones/<source>/week/<key> — one week's/cycle's
6320
+ complete milestone-history payload (spec §2, hero-modal history).
6321
+
6322
+ Dedicated route (deliberately NOT the /api/source/ family, whose
6323
+ not-found semantics are scoped to the current frozen bundle). Wire is
6324
+ snake_case via ``encode_dashboard_json_bytes`` (the dashboard
6325
+ convention — NOT the CLI camelCase envelope). Read-only, per-request
6326
+ connections, no sync/migration writes from the assembly; the multi-read
6327
+ assembly runs inside one transaction per DB. 400 malformed key/source;
6328
+ 404 ``{code: "unknown_key", reason}`` for keys that don't resolve.
6329
+ """
6330
+ import re as _re
6331
+ import types as _types
6332
+ import urllib.parse as _urlparse
6333
+ from _cctally_cache import open_cache_db
6334
+
6335
+ tail = path[len("/api/milestones/"):]
6336
+ parts = tail.split("/week/", 1)
6337
+ if len(parts) != 2 or not parts[1]:
6338
+ self._send_milestones_json(400, {"error": "invalid path"})
6339
+ return
6340
+ source, raw_key = parts[0], parts[1]
6341
+ if source not in ("claude", "codex"):
6342
+ self._send_milestones_json(400, {"error": "invalid source"})
6343
+ return
6344
+ key = _urlparse.unquote(raw_key)
6345
+ c = sys.modules["cctally"]
6346
+ try:
6347
+ if source == "claude":
6348
+ # Claude key is a week_start_date (YYYY-MM-DD).
6349
+ if not _re.fullmatch(r"\d{4}-\d{2}-\d{2}", key):
6350
+ self._send_milestones_json(400, {"error": "invalid week key"})
6351
+ return
6352
+ conn = open_db()
6353
+ try:
6354
+ conn.execute("BEGIN")
6355
+ detail = c.build_claude_week_detail(conn, key)
6356
+ conn.commit()
6357
+ finally:
6358
+ conn.close()
6359
+ if detail is None:
6360
+ self._send_milestones_json(404, {
6361
+ "error": "unknown week", "code": "unknown_key",
6362
+ "reason": "unknown",
6363
+ })
6364
+ return
6365
+ self._send_milestones_json(200, {**detail, "source": "claude", "key": key})
6366
+ return
6367
+
6368
+ # Codex: opaque server-issued cycle key. Resolve across all codex
6369
+ # roots (keys are unique per cycle, so a client-supplied index key
6370
+ # matches its exact cycle without needing the hero-cycle identity —
6371
+ # this also keeps a just-closed former-current cycle fetchable when
6372
+ # the live hero cycle is momentarily unavailable, spec §2).
6373
+ from _cctally_dashboard_sources import resolve_dashboard_source_semantics
6374
+ speed = resolve_dashboard_source_semantics(
6375
+ load_config(), display_tz_name="UTC",
6376
+ ).speed
6377
+ now_utc = _command_as_of()
6378
+ stats_conn = open_db()
6379
+ cache_conn = open_cache_db()
6380
+ try:
6381
+ # Stable read across BOTH DBs for a single response (spec §2):
6382
+ # the cache conn supplies the physical cost correlation, so it
6383
+ # gets the same BEGIN/commit envelope as the stats conn.
6384
+ stats_conn.execute("BEGIN")
6385
+ cache_conn.execute("BEGIN")
6386
+ roots = tuple(sorted({
6387
+ str(r[0]) for r in stats_conn.execute(
6388
+ "SELECT DISTINCT source_root_key FROM quota_window_blocks "
6389
+ "WHERE source='codex'"
6390
+ )
6391
+ }))
6392
+ identity = _types.SimpleNamespace(source_root_keys=roots, resets_at=None)
6393
+ result = c.build_codex_cycle_detail(
6394
+ stats_conn, cache_conn, identity=identity, key=key,
6395
+ speed=speed, now_utc=now_utc,
6396
+ )
6397
+ stats_conn.commit()
6398
+ cache_conn.commit()
6399
+ finally:
6400
+ stats_conn.close()
6401
+ cache_conn.close()
6402
+ if isinstance(result, tuple):
6403
+ _none, reason = result
6404
+ self._send_milestones_json(404, {
6405
+ "error": "unknown cycle", "code": "unknown_key",
6406
+ "reason": reason,
6407
+ })
6408
+ return
6409
+ if result is None:
6410
+ self._send_milestones_json(404, {
6411
+ "error": "unknown cycle", "code": "unknown_key",
6412
+ "reason": "unknown",
6413
+ })
6414
+ return
6415
+ self._send_milestones_json(200, {**result, "source": "codex", "key": key})
6416
+ except Exception as exc: # noqa: BLE001
6417
+ self.log_error("/api/milestones failed: %r", exc)
6418
+ self.send_error(500, "milestones detail failed")
6419
+
6138
6420
  def _serve_api_events(self) -> None:
6139
6421
  import queue as _queue
6140
6422
  self.send_response(200)
@@ -6189,7 +6471,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6189
6471
  env["transcriptsEnabled"] = transcripts_enabled
6190
6472
  msg = (
6191
6473
  "event: update\n"
6192
- + "data: " + json.dumps(env, ensure_ascii=False) + "\n\n"
6474
+ + "data: "
6475
+ + encode_dashboard_json(env, ensure_ascii=False)
6476
+ + "\n\n"
6193
6477
  )
6194
6478
  self.wfile.write(msg.encode("utf-8"))
6195
6479
  self.wfile.flush()
@@ -6357,9 +6641,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6357
6641
  _w.status() if _w is not None
6358
6642
  else {"current_run_id": None}
6359
6643
  )
6644
+ # Beta-channel (spec 2026-07-21 §3): carry the configured release
6645
+ # channel (from config, not cached state) so the badge/modal keep the
6646
+ # `(beta)` marker + exact-version command after an execvp refresh, when
6647
+ # the client polls /api/update/status instead of the SSE envelope.
6648
+ try:
6649
+ _c = _cctally()
6650
+ configured_channel = _c.resolve_update_channel(_c.load_config())
6651
+ except Exception:
6652
+ configured_channel = "stable"
6360
6653
  body = {
6361
6654
  "state": state,
6362
6655
  "suppress": suppress,
6656
+ "configured_channel": configured_channel,
6363
6657
  **worker_status,
6364
6658
  }
6365
6659
  self._respond_json(200, body)
@@ -6387,7 +6681,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6387
6681
  try:
6388
6682
  for ev in worker.stream(run_id):
6389
6683
  ev_type = ev.get("type", "message")
6390
- ev_data = json.dumps(
6684
+ ev_data = encode_dashboard_json(
6391
6685
  {k: v for k, v in ev.items() if k != "type"},
6392
6686
  ensure_ascii=False,
6393
6687
  )