cctally 1.6.3 → 1.7.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/_lib_doctor.py +903 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/cctally +835 -52
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
package/bin/cctally
CHANGED
|
@@ -10057,7 +10057,10 @@ def _persist_install_method_to_state(method: InstallMethod) -> None:
|
|
|
10057
10057
|
_save_update_state(state)
|
|
10058
10058
|
|
|
10059
10059
|
|
|
10060
|
-
def _stamp_install_success_to_state(
|
|
10060
|
+
def _stamp_install_success_to_state(
|
|
10061
|
+
installed_version: str | None,
|
|
10062
|
+
method: "InstallMethod | None" = None,
|
|
10063
|
+
) -> None:
|
|
10061
10064
|
"""Stamp ``update-state.json`` with the just-installed version so the
|
|
10062
10065
|
post-install banner predicate + dashboard auto-close fire immediately.
|
|
10063
10066
|
|
|
@@ -10070,19 +10073,84 @@ def _stamp_install_success_to_state(installed_version: str | None) -> None:
|
|
|
10070
10073
|
``refreshUpdateState`` auto-close (``current === latest``) never
|
|
10071
10074
|
matches.
|
|
10072
10075
|
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
|
|
10076
|
-
|
|
10076
|
+
Resolution order:
|
|
10077
|
+
1. ``installed_version`` — caller passed an explicit ``--version``.
|
|
10078
|
+
2. For brew (when ``method.method == "brew"`` and no explicit
|
|
10079
|
+
version), ``state.latest_version`` — the freshly-probed value
|
|
10080
|
+
that drove the install. The running process's ``CHANGELOG_PATH``
|
|
10081
|
+
resolved to the OLD Cellar at boot, so a CHANGELOG read here
|
|
10082
|
+
returns the pre-upgrade version and would stamp the wrong
|
|
10083
|
+
``current_version`` until the next dashboard self-heal (up to
|
|
10084
|
+
30 min) or the next CLI invocation. The stale-probe regression
|
|
10085
|
+
that pushed CHANGELOG ahead of ``latest_version`` (1.6.0-after-
|
|
10086
|
+
installing-1.6.3) does not apply on the brew path: brew's
|
|
10087
|
+
install probe ran inside the user's just-issued
|
|
10088
|
+
``cctally update``, so ``latest_version`` is current.
|
|
10089
|
+
3. Freshly-installed CHANGELOG (``_release_read_latest_release_version``).
|
|
10090
|
+
For npm the install overwrites ``CHANGELOG.md`` in place, so
|
|
10091
|
+
this read inside the same Python process returns the just-
|
|
10092
|
+
installed version. Skipped on brew for the reason above.
|
|
10093
|
+
4. ``state.latest_version`` — last resort, also covers the npm
|
|
10094
|
+
path when CHANGELOG is unreadable.
|
|
10077
10095
|
"""
|
|
10078
10096
|
state = _load_update_state() or {"_schema": 1}
|
|
10079
|
-
cur = installed_version
|
|
10097
|
+
cur = installed_version
|
|
10098
|
+
if cur is None and method is not None and method.method == "brew":
|
|
10099
|
+
# Brew: prefer the cached probe (just observed by `cctally
|
|
10100
|
+
# update`) over CHANGELOG, which reads from the OLD Cellar.
|
|
10101
|
+
cur = state.get("latest_version")
|
|
10102
|
+
if cur is None:
|
|
10103
|
+
fresh = _release_read_latest_release_version()
|
|
10104
|
+
if fresh:
|
|
10105
|
+
cur = fresh[0]
|
|
10106
|
+
if cur is None:
|
|
10107
|
+
cur = state.get("latest_version")
|
|
10080
10108
|
if cur:
|
|
10081
10109
|
state["current_version"] = cur
|
|
10082
10110
|
state["last_install_success_at_utc"] = _now_utc().isoformat()
|
|
10083
10111
|
_save_update_state(state)
|
|
10084
10112
|
|
|
10085
10113
|
|
|
10114
|
+
def _self_heal_current_version() -> None:
|
|
10115
|
+
"""Reconcile ``update-state.json``'s ``current_version`` with the
|
|
10116
|
+
running binary's CHANGELOG when they disagree.
|
|
10117
|
+
|
|
10118
|
+
Closes the documented gap (memory:
|
|
10119
|
+
``gotcha_update_state_cache_lies_after_version_bump``) where a user
|
|
10120
|
+
upgrades via ``npm install -g cctally@latest`` (or any out-of-band
|
|
10121
|
+
path that bypasses ``cctally update``) and ``current_version``
|
|
10122
|
+
stays frozen on the pre-upgrade value until the next TTL probe
|
|
10123
|
+
fires (24h default). The dashboard's brand-version label and the
|
|
10124
|
+
CLI banner predicate both read ``current_version``, so users see
|
|
10125
|
+
a stale "you're on <old>" indefinitely.
|
|
10126
|
+
|
|
10127
|
+
Best-effort: any failure — state missing/corrupt, CHANGELOG
|
|
10128
|
+
unreadable, save fails — is silently swallowed. The caller is in
|
|
10129
|
+
a post-command hook and must never break the parent command.
|
|
10130
|
+
|
|
10131
|
+
Why not bootstrap when state is missing: a ``None`` state means no
|
|
10132
|
+
update probe has ever run, so we have no ``latest_version`` /
|
|
10133
|
+
``install`` block to seed alongside ``current_version``. Writing a
|
|
10134
|
+
partial state would mask the missing-probe condition that
|
|
10135
|
+
``_check_safety_update_state`` and the doctor report rely on; the
|
|
10136
|
+
next ``_do_update_check`` creates the file fully.
|
|
10137
|
+
"""
|
|
10138
|
+
try:
|
|
10139
|
+
fresh = _release_read_latest_release_version()
|
|
10140
|
+
if fresh is None:
|
|
10141
|
+
return
|
|
10142
|
+
running = fresh[0]
|
|
10143
|
+
state = _load_update_state()
|
|
10144
|
+
if state is None:
|
|
10145
|
+
return
|
|
10146
|
+
if state.get("current_version") == running:
|
|
10147
|
+
return
|
|
10148
|
+
state["current_version"] = running
|
|
10149
|
+
_save_update_state(state)
|
|
10150
|
+
except Exception:
|
|
10151
|
+
pass
|
|
10152
|
+
|
|
10153
|
+
|
|
10086
10154
|
# === Update subcommand: version-check pipeline (spec §3) ====================
|
|
10087
10155
|
# Per-vector parsers, TTL gate, and the chokepoint `_do_update_check` that
|
|
10088
10156
|
# touches the throttle marker FIRST (crash safety) before attempting any
|
|
@@ -10791,7 +10859,7 @@ def _do_update_install(
|
|
|
10791
10859
|
if rc != 0:
|
|
10792
10860
|
return 1
|
|
10793
10861
|
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
10794
|
-
_stamp_install_success_to_state(version)
|
|
10862
|
+
_stamp_install_success_to_state(version, method)
|
|
10795
10863
|
return 0
|
|
10796
10864
|
finally:
|
|
10797
10865
|
_release_update_lock(lock_fd)
|
|
@@ -10966,7 +11034,7 @@ class UpdateWorker:
|
|
|
10966
11034
|
self._emit(run_id, {"type": "done", "success": False})
|
|
10967
11035
|
return
|
|
10968
11036
|
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
10969
|
-
_stamp_install_success_to_state(version)
|
|
11037
|
+
_stamp_install_success_to_state(version, method)
|
|
10970
11038
|
entrypoint, exec_argv = _resolve_execvp_target()
|
|
10971
11039
|
self._emit(run_id, {"type": "execvp", "argv": exec_argv})
|
|
10972
11040
|
try:
|
|
@@ -11049,6 +11117,26 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
11049
11117
|
def run(self) -> None:
|
|
11050
11118
|
while not self._stop.is_set():
|
|
11051
11119
|
try:
|
|
11120
|
+
# Self-heal runs every tick (every 30 min by default),
|
|
11121
|
+
# NOT gated by `_is_update_check_due`'s 24h TTL. Catches
|
|
11122
|
+
# the case where the user upgrades the npm package
|
|
11123
|
+
# out-of-band (no `cctally update` invocation) — the
|
|
11124
|
+
# dashboard's brand-version label needs to reflect the
|
|
11125
|
+
# new binary without waiting up to 24h for the next
|
|
11126
|
+
# TTL probe. Re-publish the snapshot after a self-heal
|
|
11127
|
+
# write so live SSE subscribers pick up the corrected
|
|
11128
|
+
# `current_version` on their next envelope.
|
|
11129
|
+
healed_before = _load_update_state()
|
|
11130
|
+
_self_heal_current_version()
|
|
11131
|
+
healed_after = _load_update_state()
|
|
11132
|
+
if (
|
|
11133
|
+
healed_before != healed_after
|
|
11134
|
+
and self._hub is not None
|
|
11135
|
+
and self._ref is not None
|
|
11136
|
+
):
|
|
11137
|
+
snap = self._ref.get()
|
|
11138
|
+
if snap is not None:
|
|
11139
|
+
self._hub.publish(snap)
|
|
11052
11140
|
config = load_config()
|
|
11053
11141
|
if _is_update_check_due(config):
|
|
11054
11142
|
_do_update_check()
|
|
@@ -15056,6 +15144,9 @@ _BANNER_SUPPRESSED_COMMANDS = frozenset({
|
|
|
15056
15144
|
"tui", # rich Live mode takes over the screen; banner would be clobbered
|
|
15057
15145
|
"db", # `db status` shows failure state in its own output;
|
|
15058
15146
|
# `db skip` / `db unskip` are mid-fix — banner would be redundant.
|
|
15147
|
+
"doctor", # consolidates migration + update banner state into its
|
|
15148
|
+
# own report; double-printing the banner would duplicate
|
|
15149
|
+
# findings doctor already surfaces structurally.
|
|
15059
15150
|
# Note: `setup` carve-out handled separately (only suppressed w/o --status).
|
|
15060
15151
|
# Note: `dashboard` carve-out handled separately (banner printed in cmd_dashboard).
|
|
15061
15152
|
})
|
|
@@ -15113,6 +15204,58 @@ def _semver_gt(a: str, b: str) -> bool:
|
|
|
15113
15204
|
_release_semver_sort_key(_release_parse_semver(b))
|
|
15114
15205
|
|
|
15115
15206
|
|
|
15207
|
+
def _compute_effective_update_available(
|
|
15208
|
+
state: dict[str, Any] | None,
|
|
15209
|
+
suppress: dict[str, Any] | None,
|
|
15210
|
+
now_utc: "dt.datetime",
|
|
15211
|
+
) -> "tuple[bool, str | None]":
|
|
15212
|
+
"""Shared core of "is there a *real* pending update?"
|
|
15213
|
+
|
|
15214
|
+
Returns ``(available, reason)`` where ``reason`` is:
|
|
15215
|
+
- ``"missing_state"`` — current/latest unknown (no probe yet)
|
|
15216
|
+
- ``"no_newer"`` — latest is not strictly greater than current
|
|
15217
|
+
- ``"skipped"`` — user has skipped the latest version
|
|
15218
|
+
- ``"reminded"`` — user has deferred and the window is still active
|
|
15219
|
+
- ``None`` — available (warn-worthy)
|
|
15220
|
+
|
|
15221
|
+
Single source of truth for both ``_should_show_update_banner`` and
|
|
15222
|
+
``cctally doctor``'s ``safety.update_available`` check. Keeping this
|
|
15223
|
+
shared avoids the bug where doctor would advertise an update the
|
|
15224
|
+
banner suppresses (see review finding "Respect skipped/reminded
|
|
15225
|
+
updates"). Malformed ``remind_after`` fails open — matches the
|
|
15226
|
+
banner's pre-extraction posture: better to show a real reminder
|
|
15227
|
+
than to silently drop one because of a corrupt suppress file.
|
|
15228
|
+
"""
|
|
15229
|
+
if state is None:
|
|
15230
|
+
return False, "missing_state"
|
|
15231
|
+
cur = state.get("current_version")
|
|
15232
|
+
lat = state.get("latest_version")
|
|
15233
|
+
if not cur or not lat:
|
|
15234
|
+
return False, "missing_state"
|
|
15235
|
+
try:
|
|
15236
|
+
if not _semver_gt(lat, cur):
|
|
15237
|
+
return False, "no_newer"
|
|
15238
|
+
except ValueError:
|
|
15239
|
+
return False, "no_newer"
|
|
15240
|
+
sup = suppress or {}
|
|
15241
|
+
if lat in sup.get("skipped_versions", []):
|
|
15242
|
+
return False, "skipped"
|
|
15243
|
+
remind = sup.get("remind_after")
|
|
15244
|
+
if remind is not None:
|
|
15245
|
+
try:
|
|
15246
|
+
# Hide while the deferral is active AND the user-pinned version
|
|
15247
|
+
# is still the latest. A newer drop overrides the deferral.
|
|
15248
|
+
if not _semver_gt(lat, remind["version"]):
|
|
15249
|
+
until = dt.datetime.fromisoformat(remind["until_utc"])
|
|
15250
|
+
if now_utc < until:
|
|
15251
|
+
return False, "reminded"
|
|
15252
|
+
except (KeyError, ValueError):
|
|
15253
|
+
# Malformed remind_after: fail-open. Better to surface a
|
|
15254
|
+
# real update than to silently drop it.
|
|
15255
|
+
pass
|
|
15256
|
+
return True, None
|
|
15257
|
+
|
|
15258
|
+
|
|
15116
15259
|
def _should_show_update_banner(
|
|
15117
15260
|
command: str | None,
|
|
15118
15261
|
args: argparse.Namespace,
|
|
@@ -15130,6 +15273,10 @@ def _should_show_update_banner(
|
|
|
15130
15273
|
subcommand inherits the suppression automatically. Adding a parallel
|
|
15131
15274
|
list here would silently regress that invariant — the spec
|
|
15132
15275
|
amendment for Codex finding #8 codifies this.
|
|
15276
|
+
|
|
15277
|
+
Semver + skipped + remind logic is delegated to
|
|
15278
|
+
:func:`_compute_effective_update_available` so ``cctally doctor``
|
|
15279
|
+
stays in lockstep with this predicate.
|
|
15133
15280
|
"""
|
|
15134
15281
|
if command in _BANNER_SUPPRESSED_COMMANDS or command in _UPDATE_BANNER_EXTRA_SUPPRESSED:
|
|
15135
15282
|
return False
|
|
@@ -15143,33 +15290,8 @@ def _should_show_update_banner(
|
|
|
15143
15290
|
return False
|
|
15144
15291
|
if not config.get("update", {}).get("check", {}).get("enabled", True):
|
|
15145
15292
|
return False
|
|
15146
|
-
|
|
15147
|
-
|
|
15148
|
-
cur = state.get("current_version")
|
|
15149
|
-
lat = state.get("latest_version")
|
|
15150
|
-
if not cur or not lat:
|
|
15151
|
-
return False
|
|
15152
|
-
try:
|
|
15153
|
-
if not _semver_gt(lat, cur):
|
|
15154
|
-
return False
|
|
15155
|
-
except ValueError:
|
|
15156
|
-
return False
|
|
15157
|
-
if lat in suppress.get("skipped_versions", []):
|
|
15158
|
-
return False
|
|
15159
|
-
remind = suppress.get("remind_after")
|
|
15160
|
-
if remind is not None:
|
|
15161
|
-
try:
|
|
15162
|
-
# Hide while the deferral is active AND the user-pinned version
|
|
15163
|
-
# is still the latest. A newer drop overrides the deferral.
|
|
15164
|
-
if not _semver_gt(lat, remind["version"]):
|
|
15165
|
-
until = dt.datetime.fromisoformat(remind["until_utc"])
|
|
15166
|
-
if _now_utc() < until:
|
|
15167
|
-
return False
|
|
15168
|
-
except (KeyError, ValueError):
|
|
15169
|
-
# Malformed remind_after: fail-open (banner shows). Better
|
|
15170
|
-
# than silently dropping a real update reminder.
|
|
15171
|
-
pass
|
|
15172
|
-
return True
|
|
15293
|
+
available, _ = _compute_effective_update_available(state, suppress, _now_utc())
|
|
15294
|
+
return available
|
|
15173
15295
|
|
|
15174
15296
|
|
|
15175
15297
|
def _format_update_banner(state: dict[str, Any]) -> str:
|
|
@@ -24128,19 +24250,303 @@ def _setup_recent_log_stats(seconds: float = 24 * 3600) -> dict:
|
|
|
24128
24250
|
return counts
|
|
24129
24251
|
|
|
24130
24252
|
|
|
24131
|
-
def
|
|
24253
|
+
def doctor_gather_state(
|
|
24254
|
+
*,
|
|
24255
|
+
now_utc: "dt.datetime | None" = None,
|
|
24256
|
+
runtime_bind: "str | None" = None,
|
|
24257
|
+
):
|
|
24258
|
+
"""I/O chokepoint for `cctally doctor` (spec §7.2).
|
|
24259
|
+
|
|
24260
|
+
H1 invariant: config.json is read RAW (NOT via load_config), since
|
|
24261
|
+
load_config auto-creates the file on first run — a read-only
|
|
24262
|
+
diagnostic command must never mutate user state.
|
|
24263
|
+
"""
|
|
24264
|
+
import _lib_doctor
|
|
24265
|
+
|
|
24266
|
+
if now_utc is None:
|
|
24267
|
+
now_utc = _now_utc()
|
|
24268
|
+
|
|
24269
|
+
# ── Install ──────────────────────────────────────────────────────
|
|
24132
24270
|
repo_root = _setup_resolve_repo_root()
|
|
24133
24271
|
dst_dir = _setup_local_bin_dir()
|
|
24134
|
-
|
|
24272
|
+
try:
|
|
24273
|
+
symlink_state = _setup_compute_symlink_state(repo_root, dst_dir)
|
|
24274
|
+
except Exception:
|
|
24275
|
+
symlink_state = None
|
|
24276
|
+
try:
|
|
24277
|
+
path_includes = _setup_path_includes_local_bin()
|
|
24278
|
+
except Exception:
|
|
24279
|
+
path_includes = None
|
|
24280
|
+
try:
|
|
24281
|
+
legacy_snippet = _setup_detect_legacy_snippet()
|
|
24282
|
+
except Exception:
|
|
24283
|
+
legacy_snippet = None
|
|
24284
|
+
|
|
24285
|
+
# ── Hooks ────────────────────────────────────────────────────────
|
|
24286
|
+
try:
|
|
24287
|
+
settings = _load_claude_settings()
|
|
24288
|
+
except SetupError:
|
|
24289
|
+
settings = None
|
|
24290
|
+
# Below: fail-soft posture for the diagnostic — any unexpected error
|
|
24291
|
+
# in a sub-probe degrades that field to None rather than aborting the
|
|
24292
|
+
# whole report.
|
|
24293
|
+
try:
|
|
24294
|
+
hook_counts = _setup_count_hook_entries(settings or {})
|
|
24295
|
+
except Exception:
|
|
24296
|
+
hook_counts = None
|
|
24297
|
+
try:
|
|
24298
|
+
legacy_bespoke = _setup_detect_legacy_bespoke_hooks(settings or {})
|
|
24299
|
+
except Exception:
|
|
24300
|
+
legacy_bespoke = None
|
|
24301
|
+
try:
|
|
24302
|
+
activity = _setup_recent_log_stats()
|
|
24303
|
+
except Exception:
|
|
24304
|
+
activity = None
|
|
24305
|
+
|
|
24306
|
+
# ── Auth ─────────────────────────────────────────────────────────
|
|
24307
|
+
try:
|
|
24308
|
+
oauth_token_present = _setup_oauth_token_present()
|
|
24309
|
+
except OSError:
|
|
24310
|
+
oauth_token_present = None
|
|
24311
|
+
|
|
24312
|
+
# ── DB ───────────────────────────────────────────────────────────
|
|
24313
|
+
try:
|
|
24314
|
+
stats_db_status = _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db")
|
|
24315
|
+
if not DB_PATH.exists():
|
|
24316
|
+
stats_db_status["_file_exists"] = False
|
|
24317
|
+
except sqlite3.Error as exc:
|
|
24318
|
+
stats_db_status = {"path": str(DB_PATH), "user_version": 0,
|
|
24319
|
+
"registry_size": len(_STATS_MIGRATIONS),
|
|
24320
|
+
"migrations": [], "_open_error": str(exc)}
|
|
24321
|
+
try:
|
|
24322
|
+
cache_db_status = _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
|
|
24323
|
+
if not CACHE_DB_PATH.exists():
|
|
24324
|
+
cache_db_status["_file_exists"] = False
|
|
24325
|
+
except sqlite3.Error as exc:
|
|
24326
|
+
cache_db_status = {"path": str(CACHE_DB_PATH), "user_version": 0,
|
|
24327
|
+
"registry_size": len(_CACHE_MIGRATIONS),
|
|
24328
|
+
"migrations": [], "_open_error": str(exc)}
|
|
24329
|
+
|
|
24330
|
+
# ── Data freshness ───────────────────────────────────────────────
|
|
24331
|
+
latest_snapshot_at = None
|
|
24332
|
+
try:
|
|
24333
|
+
if DB_PATH.exists():
|
|
24334
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
24335
|
+
try:
|
|
24336
|
+
row = conn.execute(
|
|
24337
|
+
"SELECT MAX(captured_at_utc) FROM weekly_usage_snapshots"
|
|
24338
|
+
).fetchone()
|
|
24339
|
+
if row and row[0]:
|
|
24340
|
+
latest_snapshot_at = parse_iso_datetime(
|
|
24341
|
+
row[0], "weekly_usage_snapshots.captured_at_utc",
|
|
24342
|
+
).astimezone(dt.timezone.utc)
|
|
24343
|
+
except sqlite3.OperationalError:
|
|
24344
|
+
pass # table missing — treat as no snapshots yet
|
|
24345
|
+
finally:
|
|
24346
|
+
conn.close()
|
|
24347
|
+
except Exception:
|
|
24348
|
+
pass
|
|
24349
|
+
|
|
24350
|
+
cache_entries_count = None
|
|
24351
|
+
cache_last_entry_at = None
|
|
24352
|
+
try:
|
|
24353
|
+
if CACHE_DB_PATH.exists():
|
|
24354
|
+
conn = sqlite3.connect(str(CACHE_DB_PATH))
|
|
24355
|
+
try:
|
|
24356
|
+
row = conn.execute(
|
|
24357
|
+
"SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
|
|
24358
|
+
).fetchone()
|
|
24359
|
+
if row:
|
|
24360
|
+
cache_entries_count = int(row[0]) if row[0] is not None else 0
|
|
24361
|
+
if row[1]:
|
|
24362
|
+
cache_last_entry_at = parse_iso_datetime(
|
|
24363
|
+
row[1], "session_entries.timestamp_utc",
|
|
24364
|
+
).astimezone(dt.timezone.utc)
|
|
24365
|
+
except sqlite3.OperationalError:
|
|
24366
|
+
pass # table missing — treat as zero
|
|
24367
|
+
finally:
|
|
24368
|
+
conn.close()
|
|
24369
|
+
except Exception:
|
|
24370
|
+
pass
|
|
24371
|
+
|
|
24372
|
+
claude_jsonl_present = False
|
|
24373
|
+
try:
|
|
24374
|
+
claude_dir = pathlib.Path.home() / ".claude" / "projects"
|
|
24375
|
+
if claude_dir.exists():
|
|
24376
|
+
claude_jsonl_present = next(claude_dir.glob("**/*.jsonl"), None) is not None
|
|
24377
|
+
except Exception:
|
|
24378
|
+
pass
|
|
24379
|
+
|
|
24380
|
+
codex_entries_count = None
|
|
24381
|
+
codex_last_entry_at = None
|
|
24382
|
+
try:
|
|
24383
|
+
if CACHE_DB_PATH.exists():
|
|
24384
|
+
conn = sqlite3.connect(str(CACHE_DB_PATH))
|
|
24385
|
+
try:
|
|
24386
|
+
row = conn.execute(
|
|
24387
|
+
"SELECT COUNT(*), MAX(timestamp_utc) FROM codex_session_entries"
|
|
24388
|
+
).fetchone()
|
|
24389
|
+
if row:
|
|
24390
|
+
codex_entries_count = int(row[0]) if row[0] is not None else 0
|
|
24391
|
+
if row[1]:
|
|
24392
|
+
codex_last_entry_at = parse_iso_datetime(
|
|
24393
|
+
row[1], "codex_session_entries.timestamp_utc",
|
|
24394
|
+
).astimezone(dt.timezone.utc)
|
|
24395
|
+
except sqlite3.OperationalError:
|
|
24396
|
+
pass
|
|
24397
|
+
finally:
|
|
24398
|
+
conn.close()
|
|
24399
|
+
except Exception:
|
|
24400
|
+
pass
|
|
24401
|
+
|
|
24402
|
+
codex_jsonl_present = False
|
|
24403
|
+
try:
|
|
24404
|
+
codex_dir = pathlib.Path.home() / ".codex" / "sessions"
|
|
24405
|
+
if codex_dir.exists():
|
|
24406
|
+
codex_jsonl_present = next(codex_dir.glob("**/*.jsonl"), None) is not None
|
|
24407
|
+
except Exception:
|
|
24408
|
+
pass
|
|
24409
|
+
|
|
24410
|
+
# ── Safety ───────────────────────────────────────────────────────
|
|
24411
|
+
# `dashboard.bind` is read via the same chokepoint that powers
|
|
24412
|
+
# `cctally config get dashboard.bind` — `_config_known_value`
|
|
24413
|
+
# normalizes hand-edited junk back to "loopback", matching the
|
|
24414
|
+
# value cmd_dashboard would actually bind to.
|
|
24415
|
+
#
|
|
24416
|
+
# Raw JSON read (NOT load_config or _load_config_unlocked): both
|
|
24417
|
+
# call `ensure_dirs()`, which creates `~/.local/share/cctally/`
|
|
24418
|
+
# and `logs/` on a fresh HOME. Doctor is a read-only diagnostic
|
|
24419
|
+
# (H1 invariant) — it must never mutate user state, even by
|
|
24420
|
+
# creating an empty directory tree. Corrupt JSON yields
|
|
24421
|
+
# `dashboard_bind_stored = "loopback"` (the same fallback the
|
|
24422
|
+
# original try/except gave); the dedicated `config_json_valid`
|
|
24423
|
+
# check surfaces the corruption separately.
|
|
24424
|
+
dashboard_bind_stored = "loopback"
|
|
24425
|
+
try:
|
|
24426
|
+
if CONFIG_PATH.exists():
|
|
24427
|
+
raw_cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
24428
|
+
if isinstance(raw_cfg, dict):
|
|
24429
|
+
dashboard_bind_stored = (
|
|
24430
|
+
_config_known_value(raw_cfg, "dashboard.bind") or "loopback"
|
|
24431
|
+
)
|
|
24432
|
+
except (json.JSONDecodeError, OSError):
|
|
24433
|
+
pass
|
|
24434
|
+
|
|
24435
|
+
# config.json — RAW READ, never load_config(). load_config()
|
|
24436
|
+
# auto-creates on first run AND silently falls back to defaults
|
|
24437
|
+
# on corruption — both behaviors would hide diagnostic state
|
|
24438
|
+
# (codex H1).
|
|
24439
|
+
config_json_error = None
|
|
24440
|
+
try:
|
|
24441
|
+
if CONFIG_PATH.exists():
|
|
24442
|
+
json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
24443
|
+
except json.JSONDecodeError as exc:
|
|
24444
|
+
config_json_error = f"{type(exc).__name__}: {exc}"
|
|
24445
|
+
except OSError as exc:
|
|
24446
|
+
config_json_error = f"OSError: {exc}"
|
|
24447
|
+
|
|
24448
|
+
update_state = None
|
|
24449
|
+
update_state_error = None
|
|
24450
|
+
try:
|
|
24451
|
+
update_state = _load_update_state()
|
|
24452
|
+
except Exception as exc:
|
|
24453
|
+
update_state_error = f"{type(exc).__name__}: {exc}"
|
|
24454
|
+
|
|
24455
|
+
update_suppress = None
|
|
24456
|
+
update_suppress_error = None
|
|
24457
|
+
try:
|
|
24458
|
+
update_suppress = _load_update_suppress()
|
|
24459
|
+
except Exception as exc:
|
|
24460
|
+
update_suppress_error = f"{type(exc).__name__}: {exc}"
|
|
24461
|
+
|
|
24462
|
+
# Same predicate the update banner uses; doctor must not warn about
|
|
24463
|
+
# updates the user has already skipped or deferred.
|
|
24464
|
+
effective_update_available, effective_update_reason = (
|
|
24465
|
+
_compute_effective_update_available(update_state, update_suppress, now_utc)
|
|
24466
|
+
)
|
|
24467
|
+
|
|
24468
|
+
# ── Meta ─────────────────────────────────────────────────────────
|
|
24469
|
+
cctally_version_tuple = _release_read_latest_release_version()
|
|
24470
|
+
cctally_version = (
|
|
24471
|
+
cctally_version_tuple[0] if cctally_version_tuple else "unknown"
|
|
24472
|
+
)
|
|
24473
|
+
|
|
24474
|
+
return _lib_doctor.DoctorState(
|
|
24475
|
+
symlink_state=symlink_state,
|
|
24476
|
+
path_includes_local_bin=path_includes,
|
|
24477
|
+
legacy_snippet=legacy_snippet,
|
|
24478
|
+
legacy_bespoke=legacy_bespoke,
|
|
24479
|
+
claude_settings=settings,
|
|
24480
|
+
hook_counts=hook_counts,
|
|
24481
|
+
log_activity_24h=activity,
|
|
24482
|
+
oauth_token_present=oauth_token_present,
|
|
24483
|
+
stats_db_status=stats_db_status,
|
|
24484
|
+
cache_db_status=cache_db_status,
|
|
24485
|
+
latest_snapshot_at=latest_snapshot_at,
|
|
24486
|
+
cache_entries_count=cache_entries_count,
|
|
24487
|
+
cache_last_entry_at=cache_last_entry_at,
|
|
24488
|
+
claude_jsonl_present=claude_jsonl_present,
|
|
24489
|
+
codex_entries_count=codex_entries_count,
|
|
24490
|
+
codex_last_entry_at=codex_last_entry_at,
|
|
24491
|
+
codex_jsonl_present=codex_jsonl_present,
|
|
24492
|
+
dashboard_bind_stored=dashboard_bind_stored,
|
|
24493
|
+
runtime_bind=runtime_bind,
|
|
24494
|
+
config_json_error=config_json_error,
|
|
24495
|
+
update_state=update_state,
|
|
24496
|
+
update_state_error=update_state_error,
|
|
24497
|
+
update_suppress=update_suppress,
|
|
24498
|
+
update_suppress_error=update_suppress_error,
|
|
24499
|
+
effective_update_available=effective_update_available,
|
|
24500
|
+
effective_update_reason=effective_update_reason,
|
|
24501
|
+
now_utc=now_utc,
|
|
24502
|
+
cctally_version=cctally_version,
|
|
24503
|
+
)
|
|
24504
|
+
|
|
24505
|
+
|
|
24506
|
+
def _setup_compute_symlink_state(
|
|
24507
|
+
repo_root: pathlib.Path, dst_dir: pathlib.Path,
|
|
24508
|
+
) -> "list[tuple[str, str]]":
|
|
24509
|
+
"""Per-symlink (name, state) for `_setup_status` + `doctor_gather_state`.
|
|
24510
|
+
|
|
24511
|
+
state ∈ {"ok", "wrong", "missing"}:
|
|
24512
|
+
- "ok": ``dst_dir/name`` is a symlink whose target is reachable.
|
|
24513
|
+
The target is NOT required to match ``repo_root/bin/<name>`` —
|
|
24514
|
+
power users routinely have the symlinks installed by one
|
|
24515
|
+
cctally channel (npm/brew) while running ``doctor`` from a
|
|
24516
|
+
parallel source clone, which would otherwise produce a 0/N
|
|
24517
|
+
false negative on a perfectly healthy install. The diagnostic
|
|
24518
|
+
question is "is `cctally-X` invokable from PATH?", not "did
|
|
24519
|
+
THIS checkout install the symlink?". ``_setup_create_symlinks``
|
|
24520
|
+
keeps its own strict equality check for install-management
|
|
24521
|
+
(replace-vs-already).
|
|
24522
|
+
- "wrong": a non-symlink file occupies the slot, or the symlink
|
|
24523
|
+
target is dangling.
|
|
24524
|
+
- "missing": nothing at ``dst_dir/name``.
|
|
24525
|
+
|
|
24526
|
+
``repo_root`` is unused here — retained on the signature for
|
|
24527
|
+
call-site stability across `_setup_status` and `doctor_gather_state`.
|
|
24528
|
+
"""
|
|
24529
|
+
del repo_root # unused; see docstring
|
|
24530
|
+
out: list[tuple[str, str]] = []
|
|
24135
24531
|
for name in SETUP_SYMLINK_NAMES:
|
|
24136
24532
|
dst = dst_dir / name
|
|
24137
|
-
|
|
24138
|
-
|
|
24139
|
-
|
|
24533
|
+
if dst.is_symlink():
|
|
24534
|
+
try:
|
|
24535
|
+
dst.resolve(strict=True)
|
|
24536
|
+
out.append((name, "ok"))
|
|
24537
|
+
except (FileNotFoundError, OSError):
|
|
24538
|
+
out.append((name, "wrong"))
|
|
24140
24539
|
elif dst.exists():
|
|
24141
|
-
|
|
24540
|
+
out.append((name, "wrong"))
|
|
24142
24541
|
else:
|
|
24143
|
-
|
|
24542
|
+
out.append((name, "missing"))
|
|
24543
|
+
return out
|
|
24544
|
+
|
|
24545
|
+
|
|
24546
|
+
def _setup_status(args: argparse.Namespace) -> int:
|
|
24547
|
+
repo_root = _setup_resolve_repo_root()
|
|
24548
|
+
dst_dir = _setup_local_bin_dir()
|
|
24549
|
+
sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
|
|
24144
24550
|
sym_ok = sum(1 for _, s in sym_state if s == "ok")
|
|
24145
24551
|
on_path = _setup_path_includes_local_bin()
|
|
24146
24552
|
try:
|
|
@@ -25255,6 +25661,39 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
25255
25661
|
return 3
|
|
25256
25662
|
|
|
25257
25663
|
|
|
25664
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
25665
|
+
"""Run all doctor checks and emit the report. Spec §4, §7.3.
|
|
25666
|
+
|
|
25667
|
+
Calls the I/O chokepoint (doctor_gather_state) → pure kernel
|
|
25668
|
+
(_lib_doctor.run_checks) → renderer (render_text or
|
|
25669
|
+
serialize_json). The argparse `add_mutually_exclusive_group`
|
|
25670
|
+
handles the --quiet/--verbose collision at parse time; the
|
|
25671
|
+
defense-in-depth check here covers programmatic invocation that
|
|
25672
|
+
bypasses argparse.
|
|
25673
|
+
|
|
25674
|
+
Exit code follows the loose mapping in spec §4.5: 0 unless
|
|
25675
|
+
overall_severity == "fail", then 2. Note that warn → 0; doctor
|
|
25676
|
+
is read-only and warn-class findings are advisories, not errors.
|
|
25677
|
+
"""
|
|
25678
|
+
import _lib_doctor
|
|
25679
|
+
quiet = bool(getattr(args, "quiet", False))
|
|
25680
|
+
verbose = bool(getattr(args, "verbose", False))
|
|
25681
|
+
if quiet and verbose:
|
|
25682
|
+
eprint("doctor: --quiet and --verbose are mutually exclusive")
|
|
25683
|
+
return 2
|
|
25684
|
+
state = doctor_gather_state()
|
|
25685
|
+
report = _lib_doctor.run_checks(state)
|
|
25686
|
+
if getattr(args, "json", False):
|
|
25687
|
+
print(json.dumps(
|
|
25688
|
+
_lib_doctor.serialize_json(report), indent=2, sort_keys=True,
|
|
25689
|
+
))
|
|
25690
|
+
else:
|
|
25691
|
+
sys.stdout.write(_lib_doctor.render_text(
|
|
25692
|
+
report, quiet=quiet, verbose=verbose,
|
|
25693
|
+
))
|
|
25694
|
+
return 2 if report.overall_severity == "fail" else 0
|
|
25695
|
+
|
|
25696
|
+
|
|
25258
25697
|
def cmd_setup(args: argparse.Namespace) -> int:
|
|
25259
25698
|
# Migration flags are install-mode-only. Reject combinations with
|
|
25260
25699
|
# --status or --uninstall (per spec Section 2 mode×flag matrix). The
|
|
@@ -27236,6 +27675,43 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
27236
27675
|
)
|
|
27237
27676
|
db_unskip.set_defaults(func=cmd_db_unskip)
|
|
27238
27677
|
|
|
27678
|
+
# ─── doctor (Diagnostics) ───────────────────────────────────────────
|
|
27679
|
+
doctor_p = sub.add_parser(
|
|
27680
|
+
"doctor",
|
|
27681
|
+
help="Diagnose data freshness and install state",
|
|
27682
|
+
formatter_class=CLIHelpFormatter,
|
|
27683
|
+
description=textwrap.dedent(
|
|
27684
|
+
"""\
|
|
27685
|
+
Run all read-only diagnostic checks and emit a report.
|
|
27686
|
+
|
|
27687
|
+
Categories: install, hooks, auth, db, data, safety. Each
|
|
27688
|
+
category renders a severity (✓ ok / ⚠ warn / ✗ fail) and
|
|
27689
|
+
actionable remediation guidance for non-OK rows.
|
|
27690
|
+
|
|
27691
|
+
Exit code: 0 unless any check is FAIL (then exit 2). WARN
|
|
27692
|
+
rows do not change the exit code — doctor is a read-only
|
|
27693
|
+
diagnostic and warn-class findings are advisories.
|
|
27694
|
+
|
|
27695
|
+
See docs/commands/doctor.md for the full check inventory
|
|
27696
|
+
and JSON schema reference.
|
|
27697
|
+
"""
|
|
27698
|
+
),
|
|
27699
|
+
)
|
|
27700
|
+
doctor_p.add_argument(
|
|
27701
|
+
"--json", action="store_true",
|
|
27702
|
+
help="Emit machine-readable JSON to stdout (schema_version: 1)",
|
|
27703
|
+
)
|
|
27704
|
+
doctor_mutex = doctor_p.add_mutually_exclusive_group()
|
|
27705
|
+
doctor_mutex.add_argument(
|
|
27706
|
+
"--quiet", "-q", action="store_true",
|
|
27707
|
+
help="Hide OK rows (human mode only)",
|
|
27708
|
+
)
|
|
27709
|
+
doctor_mutex.add_argument(
|
|
27710
|
+
"--verbose", "-v", action="store_true",
|
|
27711
|
+
help="Include each check's details block (human mode only)",
|
|
27712
|
+
)
|
|
27713
|
+
doctor_p.set_defaults(func=cmd_doctor)
|
|
27714
|
+
|
|
27239
27715
|
# ---- release (issue #24 release automation) ----
|
|
27240
27716
|
p_release = sub.add_parser(
|
|
27241
27717
|
"release",
|
|
@@ -29335,6 +29811,147 @@ def _share_top_projects_for_range(
|
|
|
29335
29811
|
return [(path, cost) for path, cost in ranked[:_SHARE_TOP_PROJECTS_BUILDER_CAP]]
|
|
29336
29812
|
|
|
29337
29813
|
|
|
29814
|
+
def _share_all_projects_for_range(
|
|
29815
|
+
range_start: "dt.datetime",
|
|
29816
|
+
range_end: "dt.datetime",
|
|
29817
|
+
*,
|
|
29818
|
+
skip_sync: bool = True,
|
|
29819
|
+
) -> dict[str, float]:
|
|
29820
|
+
"""Like `_share_top_projects_for_range` but uncapped and unsorted.
|
|
29821
|
+
|
|
29822
|
+
Returns {project_path_or_'(unknown)': cost_usd} for every project
|
|
29823
|
+
active in the range. Caller orders or caps as needed. Used by
|
|
29824
|
+
`_share_per_block_per_project`'s fallback path so the fallback's
|
|
29825
|
+
accuracy matches the canonical rollup-table path (spec §7.2.1,
|
|
29826
|
+
issue #33).
|
|
29827
|
+
"""
|
|
29828
|
+
bucket: dict[str, float] = {}
|
|
29829
|
+
try:
|
|
29830
|
+
entries = get_claude_session_entries(
|
|
29831
|
+
range_start, range_end, skip_sync=skip_sync,
|
|
29832
|
+
)
|
|
29833
|
+
except Exception:
|
|
29834
|
+
return bucket
|
|
29835
|
+
for entry in entries:
|
|
29836
|
+
usage = {
|
|
29837
|
+
"input_tokens": entry.input_tokens,
|
|
29838
|
+
"output_tokens": entry.output_tokens,
|
|
29839
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
29840
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
29841
|
+
}
|
|
29842
|
+
cost = _calculate_entry_cost(
|
|
29843
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
29844
|
+
)
|
|
29845
|
+
key = entry.project_path or "(unknown)"
|
|
29846
|
+
bucket[key] = bucket.get(key, 0.0) + cost
|
|
29847
|
+
return bucket
|
|
29848
|
+
|
|
29849
|
+
|
|
29850
|
+
def _share_per_day_per_project_for_range(
|
|
29851
|
+
range_start: "dt.datetime",
|
|
29852
|
+
range_end: "dt.datetime",
|
|
29853
|
+
*,
|
|
29854
|
+
display_tz: str,
|
|
29855
|
+
skip_sync: bool = True,
|
|
29856
|
+
) -> dict[str, dict[str, float]]:
|
|
29857
|
+
"""Aggregate session_entries in [range_start, range_end] by
|
|
29858
|
+
(day-in-display_tz, project_path).
|
|
29859
|
+
|
|
29860
|
+
Returns {date_str: {project_path_or_'(unknown)': cost_usd}}. Same
|
|
29861
|
+
cache-first/lock-contention/direct-JSONL fallback chain as
|
|
29862
|
+
`_share_top_projects_for_range`. Day bucket computed in display_tz
|
|
29863
|
+
so the rendered row label matches. Issue #33.
|
|
29864
|
+
"""
|
|
29865
|
+
try:
|
|
29866
|
+
tz = ZoneInfo(display_tz) if display_tz else dt.timezone.utc
|
|
29867
|
+
except Exception:
|
|
29868
|
+
tz = dt.timezone.utc
|
|
29869
|
+
out: dict[str, dict[str, float]] = {}
|
|
29870
|
+
try:
|
|
29871
|
+
entries = get_claude_session_entries(
|
|
29872
|
+
range_start, range_end, skip_sync=skip_sync,
|
|
29873
|
+
)
|
|
29874
|
+
except Exception:
|
|
29875
|
+
return out
|
|
29876
|
+
for entry in entries:
|
|
29877
|
+
usage = {
|
|
29878
|
+
"input_tokens": entry.input_tokens,
|
|
29879
|
+
"output_tokens": entry.output_tokens,
|
|
29880
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
29881
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
29882
|
+
}
|
|
29883
|
+
cost = _calculate_entry_cost(
|
|
29884
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
29885
|
+
)
|
|
29886
|
+
day = entry.timestamp.astimezone(tz).strftime("%Y-%m-%d")
|
|
29887
|
+
proj = entry.project_path or "(unknown)"
|
|
29888
|
+
out.setdefault(day, {})
|
|
29889
|
+
out[day][proj] = out[day].get(proj, 0.0) + cost
|
|
29890
|
+
return out
|
|
29891
|
+
|
|
29892
|
+
|
|
29893
|
+
def _share_per_block_per_project(
|
|
29894
|
+
recent_blocks: list[dict],
|
|
29895
|
+
) -> dict[str, dict[str, float]]:
|
|
29896
|
+
"""Aggregate per-block per-project costs from `five_hour_block_projects`.
|
|
29897
|
+
|
|
29898
|
+
Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
|
|
29899
|
+
Block.start_at → five_hour_window_key via `_canonical_5h_window_key`
|
|
29900
|
+
(10-min floor; same chokepoint as `maybe_update_five_hour_block`,
|
|
29901
|
+
per CLAUDE.md "5-hour windows" gotcha — never derive a third key shape).
|
|
29902
|
+
|
|
29903
|
+
Fallback (rollup empty/unreadable): per-block sweep over
|
|
29904
|
+
`_share_all_projects_for_range` — uncapped, accuracy parity with the
|
|
29905
|
+
canonical path. Fires only during the first tick after fresh install
|
|
29906
|
+
or before stats-migration `002_five_hour_block_projects_backfill_v1`
|
|
29907
|
+
completes. Issue #33.
|
|
29908
|
+
"""
|
|
29909
|
+
if not recent_blocks:
|
|
29910
|
+
return {}
|
|
29911
|
+
out: dict[str, dict[str, float]] = {}
|
|
29912
|
+
keys: list[int] = []
|
|
29913
|
+
iso_by_key: dict[int, str] = {}
|
|
29914
|
+
for b in recent_blocks:
|
|
29915
|
+
try:
|
|
29916
|
+
ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
|
|
29917
|
+
except (ValueError, KeyError):
|
|
29918
|
+
continue
|
|
29919
|
+
wk = _canonical_5h_window_key(int(ts.timestamp()))
|
|
29920
|
+
keys.append(wk)
|
|
29921
|
+
iso_by_key[wk] = b["start_at"]
|
|
29922
|
+
if not keys:
|
|
29923
|
+
return out
|
|
29924
|
+
try:
|
|
29925
|
+
conn = open_db()
|
|
29926
|
+
placeholders = ",".join("?" for _ in keys)
|
|
29927
|
+
rows = conn.execute(
|
|
29928
|
+
f"SELECT five_hour_window_key, project_path, cost_usd "
|
|
29929
|
+
f"FROM five_hour_block_projects "
|
|
29930
|
+
f"WHERE five_hour_window_key IN ({placeholders})",
|
|
29931
|
+
keys,
|
|
29932
|
+
).fetchall()
|
|
29933
|
+
for wk, project_path, cost in rows:
|
|
29934
|
+
block_iso = iso_by_key.get(wk)
|
|
29935
|
+
if block_iso is None:
|
|
29936
|
+
continue
|
|
29937
|
+
proj = project_path or "(unknown)"
|
|
29938
|
+
out.setdefault(block_iso, {})
|
|
29939
|
+
out[block_iso][proj] = out[block_iso].get(proj, 0.0) + float(cost)
|
|
29940
|
+
if out:
|
|
29941
|
+
return out
|
|
29942
|
+
except (sqlite3.DatabaseError, OSError):
|
|
29943
|
+
pass
|
|
29944
|
+
# Fallback: per-block uncapped session_entries sweep.
|
|
29945
|
+
for b in recent_blocks:
|
|
29946
|
+
try:
|
|
29947
|
+
ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
|
|
29948
|
+
except (ValueError, KeyError):
|
|
29949
|
+
continue
|
|
29950
|
+
end = ts + dt.timedelta(hours=5)
|
|
29951
|
+
out[b["start_at"]] = _share_all_projects_for_range(ts, end)
|
|
29952
|
+
return out
|
|
29953
|
+
|
|
29954
|
+
|
|
29338
29955
|
def _build_share_panel_data(panel: str, options: dict,
|
|
29339
29956
|
snap: "DataSnapshot | None") -> dict:
|
|
29340
29957
|
"""Dispatch to the per-panel builder; reuses the dashboard DataSnapshot.
|
|
@@ -29414,12 +30031,19 @@ def _build_weekly_share_panel_data(options: dict,
|
|
|
29414
30031
|
ws_dt = we_dt = None
|
|
29415
30032
|
if ws_dt is not None and we_dt is not None:
|
|
29416
30033
|
top_projects = _share_top_projects_for_range(ws_dt, we_dt)
|
|
30034
|
+
# Per-week × per-model breakdown (issue #33 cross-tab Detail).
|
|
30035
|
+
models_list = getattr(r, "models", None) or []
|
|
30036
|
+
models = {
|
|
30037
|
+
(m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
|
|
30038
|
+
for m in models_list
|
|
30039
|
+
}
|
|
29417
30040
|
weeks.append({
|
|
29418
30041
|
"start_date": start_date,
|
|
29419
30042
|
"cost_usd": cost,
|
|
29420
30043
|
"pct_used": used_pct,
|
|
29421
30044
|
"dollar_per_pct": dpp,
|
|
29422
30045
|
"top_projects": top_projects,
|
|
30046
|
+
"models": models,
|
|
29423
30047
|
})
|
|
29424
30048
|
if not weeks:
|
|
29425
30049
|
weeks = [_share_empty_week_stub()]
|
|
@@ -29556,6 +30180,16 @@ def _build_daily_share_panel_data(options: dict,
|
|
|
29556
30180
|
"pct_of_period": cost / total,
|
|
29557
30181
|
"top_model": top_model,
|
|
29558
30182
|
})
|
|
30183
|
+
# `days[*].date` is bucketed in display_tz by `_dashboard_build_daily_panel`,
|
|
30184
|
+
# so the query window must use display-tz midnights too — otherwise entries
|
|
30185
|
+
# near midnight (up to ±UTC-offset hours) get queried under the wrong UTC
|
|
30186
|
+
# day and either spill into Other or vanish from cross-tab cells while
|
|
30187
|
+
# still counted in the row total.
|
|
30188
|
+
display_tz_name = options.get("display_tz", "Etc/UTC")
|
|
30189
|
+
try:
|
|
30190
|
+
_range_tz = ZoneInfo(display_tz_name) if display_tz_name else dt.timezone.utc
|
|
30191
|
+
except Exception:
|
|
30192
|
+
_range_tz = dt.timezone.utc
|
|
29559
30193
|
# Daily top_projects: aggregate over the 7-day window. Derive the
|
|
29560
30194
|
# range from the dates rendered above so the rollup covers exactly
|
|
29561
30195
|
# what the panel shows (rather than re-deriving "7 days ago" from
|
|
@@ -29563,19 +30197,43 @@ def _build_daily_share_panel_data(options: dict,
|
|
|
29563
30197
|
top_projects: list[tuple[str, float]] = []
|
|
29564
30198
|
if days:
|
|
29565
30199
|
try:
|
|
29566
|
-
|
|
29567
|
-
|
|
30200
|
+
first_date = dt.date.fromisoformat(days[0]["date"])
|
|
30201
|
+
last_date = dt.date.fromisoformat(days[-1]["date"])
|
|
30202
|
+
range_start = dt.datetime(
|
|
30203
|
+
first_date.year, first_date.month, first_date.day,
|
|
30204
|
+
tzinfo=_range_tz,
|
|
29568
30205
|
)
|
|
29569
30206
|
# Include the last day in full — end-exclusive boundary at
|
|
29570
|
-
# the start of the next
|
|
29571
|
-
last_date = dt.date.fromisoformat(days[-1]["date"])
|
|
30207
|
+
# the start of the next display-tz day.
|
|
29572
30208
|
range_end = dt.datetime(
|
|
29573
30209
|
last_date.year, last_date.month, last_date.day,
|
|
29574
|
-
tzinfo=
|
|
30210
|
+
tzinfo=_range_tz,
|
|
29575
30211
|
) + dt.timedelta(days=1)
|
|
29576
30212
|
top_projects = _share_top_projects_for_range(range_start, range_end)
|
|
29577
30213
|
except (ValueError, KeyError):
|
|
29578
30214
|
top_projects = []
|
|
30215
|
+
# Per-day × per-project breakdown (issue #33 cross-tab Detail).
|
|
30216
|
+
per_day_per_project: dict[str, dict[str, float]] = {}
|
|
30217
|
+
if days:
|
|
30218
|
+
try:
|
|
30219
|
+
first_date = dt.date.fromisoformat(days[0]["date"])
|
|
30220
|
+
last_date = dt.date.fromisoformat(days[-1]["date"])
|
|
30221
|
+
pdpp_range_start = dt.datetime(
|
|
30222
|
+
first_date.year, first_date.month, first_date.day,
|
|
30223
|
+
tzinfo=_range_tz,
|
|
30224
|
+
)
|
|
30225
|
+
pdpp_range_end = dt.datetime(
|
|
30226
|
+
last_date.year, last_date.month, last_date.day,
|
|
30227
|
+
tzinfo=_range_tz,
|
|
30228
|
+
) + dt.timedelta(days=1)
|
|
30229
|
+
per_day_per_project = _share_per_day_per_project_for_range(
|
|
30230
|
+
pdpp_range_start, pdpp_range_end,
|
|
30231
|
+
display_tz=display_tz_name,
|
|
30232
|
+
)
|
|
30233
|
+
except (ValueError, KeyError):
|
|
30234
|
+
per_day_per_project = {}
|
|
30235
|
+
for d in days:
|
|
30236
|
+
d["projects"] = per_day_per_project.get(d["date"], {})
|
|
29579
30237
|
return {"days": days, "top_projects": top_projects}
|
|
29580
30238
|
|
|
29581
30239
|
|
|
@@ -29594,13 +30252,19 @@ def _build_monthly_share_panel_data(options: dict,
|
|
|
29594
30252
|
rows = list(reversed(rows))
|
|
29595
30253
|
months: list[dict] = []
|
|
29596
30254
|
for r in rows:
|
|
29597
|
-
|
|
29598
|
-
top_model = (
|
|
30255
|
+
models_list = getattr(r, "models", None) or []
|
|
30256
|
+
top_model = (models_list[0].get("model") if models_list else None) or "—"
|
|
30257
|
+
# Per-month × per-model breakdown (issue #33 cross-tab Detail).
|
|
30258
|
+
models = {
|
|
30259
|
+
(m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
|
|
30260
|
+
for m in models_list
|
|
30261
|
+
}
|
|
29599
30262
|
months.append({
|
|
29600
30263
|
"month": getattr(r, "label", "") or "", # "YYYY-MM"
|
|
29601
30264
|
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
29602
30265
|
"pct_used": 0.0,
|
|
29603
30266
|
"top_model": top_model,
|
|
30267
|
+
"models": models,
|
|
29604
30268
|
})
|
|
29605
30269
|
# Monthly top_projects: aggregate across the entire 12-month window.
|
|
29606
30270
|
# Range = [first day of oldest month, last day of newest month + 1].
|
|
@@ -29755,6 +30419,10 @@ def _build_blocks_share_panel_data(options: dict,
|
|
|
29755
30419
|
top_projects = _share_top_projects_for_range(range_start, range_end)
|
|
29756
30420
|
except (ValueError, KeyError):
|
|
29757
30421
|
top_projects = []
|
|
30422
|
+
# Per-block × per-project breakdown (issue #33 cross-tab Detail).
|
|
30423
|
+
per_block_per_project = _share_per_block_per_project(recent)
|
|
30424
|
+
for r in recent:
|
|
30425
|
+
r["projects"] = per_block_per_project.get(r["start_at"], {})
|
|
29758
30426
|
return {
|
|
29759
30427
|
"current_block": cb,
|
|
29760
30428
|
"recent_blocks": recent,
|
|
@@ -32471,7 +33139,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
32471
33139
|
now_utc: "dt.datetime",
|
|
32472
33140
|
monotonic_now: "float | None" = None,
|
|
32473
33141
|
oauth_usage_cfg: "dict | None" = None,
|
|
32474
|
-
display_tz_pref_override: "str | None" = None
|
|
33142
|
+
display_tz_pref_override: "str | None" = None,
|
|
33143
|
+
runtime_bind: "str | None" = None) -> dict:
|
|
32475
33144
|
"""Serialize a DataSnapshot into the JSON envelope consumed by the
|
|
32476
33145
|
browser (design spec §2.2).
|
|
32477
33146
|
|
|
@@ -32803,6 +33472,32 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
32803
33472
|
"suppress": _update_suppress_envelope,
|
|
32804
33473
|
}
|
|
32805
33474
|
|
|
33475
|
+
# Doctor aggregate block (spec §5.5). Only the small severity tree
|
|
33476
|
+
# rides on every SSE tick (~120 bytes); the full per-check payload
|
|
33477
|
+
# is fetched lazily via `GET /api/doctor`. `runtime_bind` is the
|
|
33478
|
+
# actual host the dashboard process is bound to (Codex H4) so
|
|
33479
|
+
# `safety.dashboard_bind` reflects the running state, not just
|
|
33480
|
+
# `config.json`. Defensive: never crash the snapshot pipeline on
|
|
33481
|
+
# a doctor failure — surface a synthetic FAIL block with `_error`.
|
|
33482
|
+
try:
|
|
33483
|
+
import _lib_doctor as _ld
|
|
33484
|
+
_doc_state = doctor_gather_state(now_utc=now_utc, runtime_bind=runtime_bind)
|
|
33485
|
+
_doc_report = _ld.run_checks(_doc_state)
|
|
33486
|
+
doctor_envelope: "dict" = {
|
|
33487
|
+
"severity": _doc_report.overall_severity,
|
|
33488
|
+
"counts": dict(_doc_report.counts),
|
|
33489
|
+
"generated_at": _ld._iso_z(_doc_report.generated_at),
|
|
33490
|
+
"fingerprint": _ld.fingerprint(_doc_report),
|
|
33491
|
+
}
|
|
33492
|
+
except Exception as exc: # noqa: BLE001 — never crash SSE on doctor failure
|
|
33493
|
+
doctor_envelope = {
|
|
33494
|
+
"severity": "fail",
|
|
33495
|
+
"counts": {"ok": 0, "warn": 0, "fail": 1},
|
|
33496
|
+
"generated_at": _iso_z(now_utc),
|
|
33497
|
+
"fingerprint": "sha1:" + ("0" * 40),
|
|
33498
|
+
"_error": f"{type(exc).__name__}: {exc}",
|
|
33499
|
+
}
|
|
33500
|
+
|
|
32806
33501
|
return {
|
|
32807
33502
|
"envelope_version": 2,
|
|
32808
33503
|
"generated_at": _iso_z(snap.generated_at),
|
|
@@ -32939,6 +33634,10 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
32939
33634
|
# dashboard client's existing coerceUpdateState/Suppress logic
|
|
32940
33635
|
# consumes both surfaces uniformly.
|
|
32941
33636
|
"update": update_envelope,
|
|
33637
|
+
|
|
33638
|
+
# Doctor aggregate-only block (spec §5.5). Full per-check
|
|
33639
|
+
# report fetched lazily via GET /api/doctor.
|
|
33640
|
+
"doctor": doctor_envelope,
|
|
32942
33641
|
}
|
|
32943
33642
|
|
|
32944
33643
|
|
|
@@ -33000,6 +33699,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
33000
33699
|
GET /api/events → SSE stream (Task 5)
|
|
33001
33700
|
GET /api/session/:id → per-session detail JSON (v2)
|
|
33002
33701
|
GET /api/block/:start_at → per-block detail JSON (v3)
|
|
33702
|
+
GET /api/doctor → full doctor report JSON (spec §5.6)
|
|
33003
33703
|
POST /api/sync → trigger a sync tick (v2)
|
|
33004
33704
|
Anything else → 404.
|
|
33005
33705
|
|
|
@@ -33025,6 +33725,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
33025
33725
|
# routes through `_apply_display_tz_override` so the override wins
|
|
33026
33726
|
# over `config.display.tz`. Set by cmd_dashboard before serve_forever.
|
|
33027
33727
|
display_tz_pref_override: "str | None" = None
|
|
33728
|
+
# Doctor (spec §5.5, §7.4): the host the dashboard process is
|
|
33729
|
+
# ACTUALLY bound to (`args.host`). Threaded into `doctor_gather_state`
|
|
33730
|
+
# so `safety.dashboard_bind` reflects the running state — the CLI
|
|
33731
|
+
# path leaves this None and only sees the `config.json` view, which
|
|
33732
|
+
# is the Codex H4 finding. Set by cmd_dashboard before serve_forever.
|
|
33733
|
+
cctally_host: "str | None" = None
|
|
33028
33734
|
|
|
33029
33735
|
# Silence the default per-request access log — noisy in the parent
|
|
33030
33736
|
# terminal, and we pipe it through our own logger in cmd_dashboard.
|
|
@@ -33093,6 +33799,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
33093
33799
|
self._handle_share_presets_get()
|
|
33094
33800
|
elif path == "/api/share/history":
|
|
33095
33801
|
self._handle_share_history_get()
|
|
33802
|
+
elif path == "/api/doctor":
|
|
33803
|
+
self._handle_get_doctor()
|
|
33096
33804
|
else:
|
|
33097
33805
|
self.send_error(404, "not found")
|
|
33098
33806
|
|
|
@@ -33776,6 +34484,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
33776
34484
|
"field": "options",
|
|
33777
34485
|
})
|
|
33778
34486
|
return
|
|
34487
|
+
# Client `ShareOptions` (dashboard/web/src/share/types.ts) does
|
|
34488
|
+
# not carry `display_tz`; server-side config is the source of
|
|
34489
|
+
# truth. Inject before `_share_apply_period_override` so the
|
|
34490
|
+
# daily panel rebuild and per-day cross-tab bucketing both see
|
|
34491
|
+
# the user's display tz instead of falling back to UTC.
|
|
34492
|
+
if "display_tz" not in options:
|
|
34493
|
+
options["display_tz"] = get_display_tz_pref(load_config())
|
|
33779
34494
|
if not isinstance(panel, str) or not panel:
|
|
33780
34495
|
self._respond_json(400, {
|
|
33781
34496
|
"error": "missing or non-string panel",
|
|
@@ -33984,6 +34699,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
33984
34699
|
ls = _share_load_lib()
|
|
33985
34700
|
snap_ref = type(self).snapshot_ref
|
|
33986
34701
|
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
34702
|
+
# Resolve display_tz from config once (client `ShareOptions`
|
|
34703
|
+
# does not carry it); applied to every section's options below
|
|
34704
|
+
# so daily panel rebuilds and per-day cross-tab cells bucket in
|
|
34705
|
+
# the user's display tz, not UTC.
|
|
34706
|
+
composite_display_tz = get_display_tz_pref(load_config())
|
|
33987
34707
|
|
|
33988
34708
|
composed_sections: list = []
|
|
33989
34709
|
section_results: list[dict] = []
|
|
@@ -34037,6 +34757,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
34037
34757
|
composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
|
|
34038
34758
|
"theme": theme, "format": fmt,
|
|
34039
34759
|
"no_branding": no_branding}
|
|
34760
|
+
composite_opts.setdefault("display_tz", composite_display_tz)
|
|
34040
34761
|
# Per-section period override — each basket item carries its
|
|
34041
34762
|
# own period recipe, independent of the composite anon flag.
|
|
34042
34763
|
sec_snap, period_err = _share_apply_period_override(
|
|
@@ -34473,10 +35194,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
34473
35194
|
cfg_oauth = dict(_OAUTH_USAGE_DEFAULTS)
|
|
34474
35195
|
env = snapshot_to_envelope(
|
|
34475
35196
|
snap,
|
|
34476
|
-
|
|
35197
|
+
# `_now_utc()` honors CCTALLY_AS_OF for harness determinism;
|
|
35198
|
+
# zero production impact (the env var is never set outside
|
|
35199
|
+
# fixture tests). Without this, the doctor block's now_utc
|
|
35200
|
+
# flows from wall clock and severity flips on borderline-age
|
|
35201
|
+
# checks between parallel test runs, churning goldens.
|
|
35202
|
+
now_utc=_now_utc(),
|
|
34477
35203
|
monotonic_now=time.monotonic(),
|
|
34478
35204
|
oauth_usage_cfg=cfg_oauth,
|
|
34479
35205
|
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
35206
|
+
runtime_bind=type(self).cctally_host,
|
|
34480
35207
|
)
|
|
34481
35208
|
body = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
|
34482
35209
|
self.send_response(200)
|
|
@@ -34486,6 +35213,40 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
34486
35213
|
self.end_headers()
|
|
34487
35214
|
self.wfile.write(body)
|
|
34488
35215
|
|
|
35216
|
+
def _handle_get_doctor(self) -> None:
|
|
35217
|
+
"""`GET /api/doctor` — full kernel-serialized doctor report (spec §5.6).
|
|
35218
|
+
|
|
35219
|
+
Lazy companion to the aggregate-only `doctor` block on the SSE
|
|
35220
|
+
envelope. Re-runs the gather + checks fresh on every call (cheap;
|
|
35221
|
+
no per-tick cache needed — the kernel's identity-slice fingerprint
|
|
35222
|
+
carries the dedup story for the SSE channel, not this endpoint).
|
|
35223
|
+
Passes `runtime_bind = cctally_host` so `safety.dashboard_bind`
|
|
35224
|
+
sees the bind the dashboard is ACTUALLY serving (Codex H4 — CLI
|
|
35225
|
+
path stays config-only).
|
|
35226
|
+
|
|
35227
|
+
GET endpoints are read-only and loopback-protected by the
|
|
35228
|
+
dashboard's default bind; no CSRF gating here (mirrors
|
|
35229
|
+
`/api/data`, `/api/session/:id`, `/api/block/:start_at`).
|
|
35230
|
+
"""
|
|
35231
|
+
try:
|
|
35232
|
+
import _lib_doctor as _ld
|
|
35233
|
+
state = doctor_gather_state(
|
|
35234
|
+
runtime_bind=type(self).cctally_host,
|
|
35235
|
+
)
|
|
35236
|
+
report = _ld.run_checks(state)
|
|
35237
|
+
body = json.dumps(
|
|
35238
|
+
_ld.serialize_json(report), ensure_ascii=False,
|
|
35239
|
+
).encode("utf-8")
|
|
35240
|
+
self.send_response(200)
|
|
35241
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
35242
|
+
self.send_header("Content-Length", str(len(body)))
|
|
35243
|
+
self.send_header("Cache-Control", "no-cache")
|
|
35244
|
+
self.end_headers()
|
|
35245
|
+
self.wfile.write(body)
|
|
35246
|
+
except Exception as exc: # noqa: BLE001
|
|
35247
|
+
self.log_error("/api/doctor failed: %r", exc)
|
|
35248
|
+
self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
|
|
35249
|
+
|
|
34489
35250
|
def _handle_get_session_detail(self, path: str) -> None:
|
|
34490
35251
|
"""Return TuiSessionDetail JSON for the given session id (spec §3.2).
|
|
34491
35252
|
|
|
@@ -34666,6 +35427,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
34666
35427
|
monotonic_now=time.monotonic(),
|
|
34667
35428
|
oauth_usage_cfg=cfg_oauth,
|
|
34668
35429
|
display_tz_pref_override=type(self).display_tz_pref_override,
|
|
35430
|
+
runtime_bind=type(self).cctally_host,
|
|
34669
35431
|
)
|
|
34670
35432
|
msg = (
|
|
34671
35433
|
"event: update\n"
|
|
@@ -36834,6 +37596,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
36834
37596
|
DashboardHTTPHandler.sync_lock = sync_lock
|
|
36835
37597
|
DashboardHTTPHandler.no_sync = bool(args.no_sync)
|
|
36836
37598
|
DashboardHTTPHandler.display_tz_pref_override = display_tz_pref_override
|
|
37599
|
+
# Doctor (spec §7.4 / Codex H4): runtime bind, so safety.dashboard_bind
|
|
37600
|
+
# in the doctor SSE block + /api/doctor reflects the actual --host
|
|
37601
|
+
# the process is serving, not just the config-only view the CLI sees.
|
|
37602
|
+
DashboardHTTPHandler.cctally_host = args.host
|
|
36837
37603
|
DashboardHTTPHandler.run_sync_now = staticmethod(
|
|
36838
37604
|
lambda: _run_sync_now(skip_sync=args.no_sync)
|
|
36839
37605
|
)
|
|
@@ -37391,9 +38157,26 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
37391
38157
|
``ensure_dirs()`` first-run path, leaving a stub config.json behind
|
|
37392
38158
|
and breaking the post-purge "data dir gone" invariant. Skip the
|
|
37393
38159
|
hook entirely for that command — the banner is moot post-uninstall
|
|
37394
|
-
anyway.
|
|
38160
|
+
anyway.
|
|
38161
|
+
|
|
38162
|
+
Skip-for-doctor: ``doctor`` is a read-only diagnostic by contract.
|
|
38163
|
+
``load_config()`` would call ``ensure_dirs()`` and write a stub
|
|
38164
|
+
``config.json`` on a fresh HOME; ``_spawn_background_update_check``
|
|
38165
|
+
would write ``update-state.json`` and ``update.log``. Adding doctor
|
|
38166
|
+
to ``_BANNER_SUPPRESSED_COMMANDS`` only silences the banner — it
|
|
38167
|
+
does not stop these side effects. Doctor reads update state for
|
|
38168
|
+
its report via the gather layer; it must not refresh that state
|
|
38169
|
+
opportunistically. Users who want a fresh check have
|
|
38170
|
+
``cctally update --check``."""
|
|
37395
38171
|
if command == "setup" and getattr(args, "uninstall", False):
|
|
37396
38172
|
return
|
|
38173
|
+
if command == "doctor":
|
|
38174
|
+
return
|
|
38175
|
+
# Self-heal: reconcile current_version with the running binary's
|
|
38176
|
+
# CHANGELOG. Cheap (one CHANGELOG read, write only on the first
|
|
38177
|
+
# command after a manual upgrade). Runs before load_config so a
|
|
38178
|
+
# bumped version is visible to the banner predicate immediately.
|
|
38179
|
+
_self_heal_current_version()
|
|
37397
38180
|
config = load_config()
|
|
37398
38181
|
state = _load_update_state()
|
|
37399
38182
|
suppress = _load_update_suppress()
|