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.
- package/CHANGELOG.md +28 -0
- package/README.md +2 -0
- package/bin/_cctally_cache.py +140 -2
- package/bin/_cctally_config.py +50 -0
- package/bin/_cctally_core.py +7 -4
- package/bin/_cctally_dashboard.py +352 -58
- package/bin/_cctally_dashboard_conversation.py +4 -3
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_sources.py +25 -3
- package/bin/_cctally_doctor.py +35 -4
- package/bin/_cctally_milestone_history.py +845 -0
- package/bin/_cctally_statusline.py +128 -3
- package/bin/_cctally_tui.py +23 -0
- package/bin/_cctally_update.py +338 -15
- package/bin/_cctally_weekrefs.py +6 -2
- package/bin/_lib_codex_conversation.py +1342 -18
- package/bin/_lib_codex_conversation_export.py +21 -1
- package/bin/_lib_codex_conversation_query.py +530 -67
- package/bin/_lib_conversation_dispatch.py +2 -2
- package/bin/_lib_dashboard_json.py +43 -0
- package/bin/_lib_doctor.py +38 -0
- package/bin/_lib_milestone_history.py +158 -0
- package/bin/_lib_source_analytics.py +20 -6
- package/bin/cctally +36 -0
- package/dashboard/static/assets/index-BwvAcS_k.js +92 -0
- package/dashboard/static/assets/index-CrIlpAlQ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CvcCeA4L.css +0 -1
- package/dashboard/static/assets/index-DLNXAevv.js +0 -87
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
4101
|
+
body = encode_dashboard_json_bytes(
|
|
3981
4102
|
{"error": "project detail failed"}
|
|
3982
|
-
)
|
|
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 =
|
|
4111
|
+
body = encode_dashboard_json_bytes(
|
|
3991
4112
|
{"error": "project not found", "key": project_key},
|
|
3992
|
-
)
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
5293
|
-
|
|
5294
|
-
existing_check
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
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
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
5955
|
+
body = encode_dashboard_json_bytes(
|
|
5799
5956
|
_ld.serialize_json(report), ensure_ascii=False,
|
|
5800
|
-
)
|
|
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 =
|
|
6005
|
+
body = encode_dashboard_json_bytes(
|
|
5849
6006
|
_session_detail_to_envelope(detail), ensure_ascii=False
|
|
5850
|
-
)
|
|
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
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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: "
|
|
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 =
|
|
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
|
)
|