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