cctally 1.77.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.
- package/CHANGELOG.md +15 -0
- package/bin/_cctally_core.py +7 -4
- package/bin/_cctally_dashboard.py +177 -38
- package/bin/_cctally_dashboard_conversation.py +3 -2
- package/bin/_cctally_dashboard_sources.py +10 -2
- package/bin/_cctally_doctor.py +19 -3
- package/bin/_cctally_statusline.py +128 -3
- package/bin/_lib_dashboard_json.py +43 -0
- package/bin/_lib_source_analytics.py +20 -6
- package/bin/cctally +5 -0
- package/dashboard/static/assets/index-CkTe6VJt.js +87 -0
- package/dashboard/static/assets/index-DsEXuMpq.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CvcCeA4L.css +0 -1
- package/dashboard/static/assets/index-DLNXAevv.js +0 -87
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.78.0] - 2026-07-21
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Dashboard Codex Daily, Weekly, and Monthly details now retain native Input, Cached input, Output, Reasoning, and Total counters; Codex Weekly uses reset-cycle vocabulary, All Weekly uses neutral provider-period wording, and partial-metadata projects with the same basename remain collision-safe without exposing identities. (#329)
|
|
12
|
+
- Dashboard Codex session, project, and quota-block details now use bounded native hierarchies, localized timestamps, retained quota progression, and accessible prompt expansion across desktop and mobile. (#325)
|
|
13
|
+
- Dashboard All-mode Claude project and session rows now open the same useful detail hierarchy and cross-navigation as the canonical Claude views while keeping native session IDs and source paths private. (#325)
|
|
14
|
+
- Dashboard All-mode Forecast and Cache Report cards and details now compose separate, provider-labelled Claude and Codex reports with provider-local values, confidence, and degraded or unavailable reasons instead of presenting Codex-only data under an All heading. (#324)
|
|
15
|
+
- Dashboard All-mode Weekly and `$ / 1% Trend` views now keep Claude and Codex quota histories in separate provider-labelled sections and series, while explicitly combining only compatible cost totals. (#324)
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Dashboard narrow layouts now keep degraded source status readable, hide the optional Codex five-hour slot when no native 300-minute window is reported, label real All-mode five-hour rows by provider, and bound unavailable combined-total warnings without losing their accessible detail. (#329)
|
|
19
|
+
- Dashboard source-wide freshness and read-model warnings now reach Daily and Projects without hiding the fixed ten-card board; Current Usage and Share stay bound to the source that opened them, and All shows separate Claude and Codex current-cycle sections instead of silently falling back to Claude. (#328)
|
|
20
|
+
- Dashboard HTTP and live-event JSON now convert non-finite numeric values to `null` at one strict outbound boundary, so missing Doctor freshness markers no longer emit browser-invalid `Infinity` tokens and the full Doctor report opens normally. (#327)
|
|
21
|
+
- Claude 5-hour and 7-day usage now receive one account-wide authoritative OAuth confirmation per 30-second status-line cycle, so a stale Claude Code `rate_limits` payload can no longer leave dashboard milestones behind for up to five minutes. The confirmation is detached from rendering, shares the existing hook throttle lock, and preserves `Retry-After` plus exponential `429` backoff.
|
|
22
|
+
|
|
8
23
|
## [1.77.0] - 2026-07-21
|
|
9
24
|
|
|
10
25
|
### Added
|
package/bin/_cctally_core.py
CHANGED
|
@@ -252,9 +252,11 @@ def _real_prod_data_dir() -> pathlib.Path:
|
|
|
252
252
|
# Internal (no config UI — YAGNI, spec §Out of scope); test injection only.
|
|
253
253
|
# Spec 2026-07-17-usage-statusline-fallback-design.
|
|
254
254
|
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
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.
|
|
258
260
|
# OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
|
|
259
261
|
# exponential 429 backoff (base * 2**consecutive_429, capped).
|
|
260
262
|
STATUSLINE_CANDIDATE_TTL_SECONDS = 90
|
|
@@ -270,7 +272,8 @@ STATUSLINE_TOMBSTONE_DOCUMENT_MAX_BYTES = 1024
|
|
|
270
272
|
# session waits on a long subagent (event-driven updates go quiet then). MUST
|
|
271
273
|
# Add-when-absent only; a user-set value is never mutated.
|
|
272
274
|
STATUSLINE_REFRESH_INTERVAL_DEFAULT = 30
|
|
273
|
-
|
|
275
|
+
STATUSLINE_OAUTH_POLL_SECONDS = 25.0
|
|
276
|
+
OAUTH_BACKFILL_STALE_SECONDS = STATUSLINE_OAUTH_POLL_SECONDS
|
|
274
277
|
OAUTH_BACKOFF_BASE_SECONDS = 60.0
|
|
275
278
|
OAUTH_BACKOFF_CAP_SECONDS = 3600.0
|
|
276
279
|
|
|
@@ -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)))
|
|
@@ -4456,7 +4577,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4456
4577
|
_respond_json plus an explicit Allow header.
|
|
4457
4578
|
"""
|
|
4458
4579
|
if self.path.split("?", 1)[0] == "/api/settings":
|
|
4459
|
-
encoded =
|
|
4580
|
+
encoded = encode_dashboard_json_bytes(
|
|
4581
|
+
{"error": "method not allowed"}
|
|
4582
|
+
)
|
|
4460
4583
|
self.send_response(405)
|
|
4461
4584
|
self.send_header("Allow", "POST")
|
|
4462
4585
|
self.send_header("Content-Type", "application/json")
|
|
@@ -5689,7 +5812,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5689
5812
|
# ---- helpers ----
|
|
5690
5813
|
|
|
5691
5814
|
def _respond_json(self, status: int, body: dict) -> None:
|
|
5692
|
-
encoded =
|
|
5815
|
+
encoded = encode_dashboard_json_bytes(body)
|
|
5693
5816
|
self.send_response(status)
|
|
5694
5817
|
self.send_header("Content-Type", "application/json")
|
|
5695
5818
|
self.send_header("Content-Length", str(len(encoded)))
|
|
@@ -5760,7 +5883,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5760
5883
|
# transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
|
|
5761
5884
|
# finding) — one predicate, two consumers, desync impossible.
|
|
5762
5885
|
env["transcriptsEnabled"] = visible
|
|
5763
|
-
body =
|
|
5886
|
+
body = encode_dashboard_json_bytes(env, ensure_ascii=False)
|
|
5764
5887
|
self.send_response(200)
|
|
5765
5888
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5766
5889
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -5795,9 +5918,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5795
5918
|
runtime_bind=type(self).cctally_host,
|
|
5796
5919
|
)
|
|
5797
5920
|
report = _ld.run_checks(state)
|
|
5798
|
-
body =
|
|
5921
|
+
body = encode_dashboard_json_bytes(
|
|
5799
5922
|
_ld.serialize_json(report), ensure_ascii=False,
|
|
5800
|
-
)
|
|
5923
|
+
)
|
|
5801
5924
|
self.send_response(200)
|
|
5802
5925
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5803
5926
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -5845,9 +5968,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5845
5968
|
if detail is None:
|
|
5846
5969
|
self.send_error(404, "session not found")
|
|
5847
5970
|
return
|
|
5848
|
-
body =
|
|
5971
|
+
body = encode_dashboard_json_bytes(
|
|
5849
5972
|
_session_detail_to_envelope(detail), ensure_ascii=False
|
|
5850
|
-
)
|
|
5973
|
+
)
|
|
5851
5974
|
self.send_response(200)
|
|
5852
5975
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5853
5976
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -5894,14 +6017,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5894
6017
|
"error": "source capability unavailable",
|
|
5895
6018
|
})
|
|
5896
6019
|
return
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
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,
|
|
5904
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)
|
|
5905
6042
|
except SourceResourceNotFound:
|
|
5906
6043
|
self._respond_json(404, {
|
|
5907
6044
|
"code": "source_resource_not_found",
|
|
@@ -6049,7 +6186,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6049
6186
|
try:
|
|
6050
6187
|
start_at = parse_iso_datetime(decoded, "start_at")
|
|
6051
6188
|
except ValueError:
|
|
6052
|
-
body =
|
|
6189
|
+
body = encode_dashboard_json_bytes({"error": "invalid start_at"})
|
|
6053
6190
|
self.send_response(400)
|
|
6054
6191
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
6055
6192
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -6097,7 +6234,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6097
6234
|
None,
|
|
6098
6235
|
)
|
|
6099
6236
|
if target is None:
|
|
6100
|
-
body =
|
|
6237
|
+
body = encode_dashboard_json_bytes({"error": "block not found"})
|
|
6101
6238
|
self.send_response(404)
|
|
6102
6239
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
6103
6240
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -6127,7 +6264,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6127
6264
|
self.log_error("/api/block failed: %r", exc)
|
|
6128
6265
|
self.send_error(500, "block detail failed")
|
|
6129
6266
|
return
|
|
6130
|
-
body =
|
|
6267
|
+
body = encode_dashboard_json_bytes(detail, ensure_ascii=False)
|
|
6131
6268
|
self.send_response(200)
|
|
6132
6269
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
6133
6270
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -6189,7 +6326,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6189
6326
|
env["transcriptsEnabled"] = transcripts_enabled
|
|
6190
6327
|
msg = (
|
|
6191
6328
|
"event: update\n"
|
|
6192
|
-
+ "data: "
|
|
6329
|
+
+ "data: "
|
|
6330
|
+
+ encode_dashboard_json(env, ensure_ascii=False)
|
|
6331
|
+
+ "\n\n"
|
|
6193
6332
|
)
|
|
6194
6333
|
self.wfile.write(msg.encode("utf-8"))
|
|
6195
6334
|
self.wfile.flush()
|
|
@@ -6387,7 +6526,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6387
6526
|
try:
|
|
6388
6527
|
for ev in worker.stream(run_id):
|
|
6389
6528
|
ev_type = ev.get("type", "message")
|
|
6390
|
-
ev_data =
|
|
6529
|
+
ev_data = encode_dashboard_json(
|
|
6391
6530
|
{k: v for k, v in ev.items() if k != "type"},
|
|
6392
6531
|
ensure_ascii=False,
|
|
6393
6532
|
)
|
|
@@ -38,7 +38,6 @@ edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
|
|
|
38
38
|
"""
|
|
39
39
|
from __future__ import annotations
|
|
40
40
|
|
|
41
|
-
import json
|
|
42
41
|
import re
|
|
43
42
|
import socket
|
|
44
43
|
import sqlite3
|
|
@@ -51,6 +50,7 @@ from _cctally_cache import (
|
|
|
51
50
|
sync_codex_conversations,
|
|
52
51
|
_codex_provider_roots,
|
|
53
52
|
)
|
|
53
|
+
from _lib_dashboard_json import encode_dashboard_json
|
|
54
54
|
|
|
55
55
|
# Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
|
|
56
56
|
# below, so moved here with the events handler (spec §4.1 / §6).
|
|
@@ -709,7 +709,8 @@ def _run_conversation_events_stream(
|
|
|
709
709
|
import time as _time
|
|
710
710
|
watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
|
|
711
711
|
tail_frame = (
|
|
712
|
-
"event: tail\ndata: " +
|
|
712
|
+
"event: tail\ndata: " + encode_dashboard_json(tail_data) + "\n\n"
|
|
713
|
+
).encode("utf-8")
|
|
713
714
|
|
|
714
715
|
if passive:
|
|
715
716
|
# Frozen-data contract: no ingest, no emit. Keep-alive only.
|
|
@@ -49,7 +49,10 @@ from _lib_jsonl import CodexEntry, codex_model_scoped_quota_pool
|
|
|
49
49
|
from _lib_fmt import stable_sum
|
|
50
50
|
from _lib_aggregators import _aggregate_codex_buckets
|
|
51
51
|
from _lib_five_hour import _FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
52
|
-
from _lib_source_analytics import
|
|
52
|
+
from _lib_source_analytics import (
|
|
53
|
+
build_codex_project_result,
|
|
54
|
+
collision_safe_project_label_map,
|
|
55
|
+
)
|
|
53
56
|
from _lib_view_models import (
|
|
54
57
|
CodexWeeklyView,
|
|
55
58
|
build_codex_daily_view,
|
|
@@ -1783,12 +1786,17 @@ def _partial_projects_wire(
|
|
|
1783
1786
|
model_totals[field] += value
|
|
1784
1787
|
session_totals[field] += value
|
|
1785
1788
|
|
|
1789
|
+
label_map = collision_safe_project_label_map(
|
|
1790
|
+
(f"{root_key}\0{project_key}", str(group["label"]))
|
|
1791
|
+
for (root_key, project_key), group in groups.items()
|
|
1792
|
+
)
|
|
1786
1793
|
rows = []
|
|
1787
1794
|
for (root_key, _project_key), group in groups.items():
|
|
1795
|
+
internal_identity = f"{root_key}\0{group['project_key']}"
|
|
1788
1796
|
rows.append({
|
|
1789
1797
|
"key": dashboard_resource_key("project", "codex", root_key, group["project_key"]),
|
|
1790
1798
|
"source": "codex",
|
|
1791
|
-
"label":
|
|
1799
|
+
"label": label_map[internal_identity],
|
|
1792
1800
|
"session_count": len(group["sessions"]),
|
|
1793
1801
|
"first_seen": group["first_seen"].astimezone(UTC).isoformat(),
|
|
1794
1802
|
"last_seen": group["last_seen"].astimezone(UTC).isoformat(),
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -27,6 +27,7 @@ import argparse
|
|
|
27
27
|
import datetime as dt
|
|
28
28
|
import fcntl
|
|
29
29
|
import json
|
|
30
|
+
import math
|
|
30
31
|
import pathlib
|
|
31
32
|
import shutil
|
|
32
33
|
import sqlite3
|
|
@@ -35,6 +36,7 @@ import sys
|
|
|
35
36
|
import _cctally_core
|
|
36
37
|
import _lib_changelog
|
|
37
38
|
from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
|
|
39
|
+
from _lib_dashboard_json import encode_dashboard_json
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def _cctally():
|
|
@@ -53,11 +55,25 @@ def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
|
|
|
53
55
|
"tombstones": {"fiveHour": "absent", "sevenDay": "absent"},
|
|
54
56
|
}
|
|
55
57
|
try:
|
|
56
|
-
|
|
58
|
+
age = c._statusline_transport_age_seconds()
|
|
59
|
+
result["transport_age_seconds"] = (
|
|
60
|
+
age
|
|
61
|
+
if isinstance(age, (int, float))
|
|
62
|
+
and not isinstance(age, bool)
|
|
63
|
+
and math.isfinite(float(age))
|
|
64
|
+
else None
|
|
65
|
+
)
|
|
57
66
|
except Exception:
|
|
58
67
|
pass
|
|
59
68
|
try:
|
|
60
|
-
|
|
69
|
+
age = c._statusline_observe_age_seconds()
|
|
70
|
+
result["selected_age_seconds"] = (
|
|
71
|
+
age
|
|
72
|
+
if isinstance(age, (int, float))
|
|
73
|
+
and not isinstance(age, bool)
|
|
74
|
+
and math.isfinite(float(age))
|
|
75
|
+
else None
|
|
76
|
+
)
|
|
61
77
|
except Exception:
|
|
62
78
|
pass
|
|
63
79
|
try:
|
|
@@ -943,7 +959,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
943
959
|
state = c.doctor_gather_state(deep=True)
|
|
944
960
|
report = _lib_doctor.run_checks(state)
|
|
945
961
|
if getattr(args, "json", False):
|
|
946
|
-
print(
|
|
962
|
+
print(encode_dashboard_json(
|
|
947
963
|
_lib_doctor.serialize_json(report), indent=2, sort_keys=True,
|
|
948
964
|
))
|
|
949
965
|
else:
|