cctally 1.110.0 → 1.112.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 +23 -0
- package/README.md +5 -6
- package/bin/_cctally_cache.py +14 -7
- package/bin/_cctally_core.py +12 -1
- package/bin/_cctally_dashboard.py +60 -154
- package/bin/_cctally_dashboard_envelope.py +7 -6
- package/bin/_cctally_dashboard_sources.py +90 -55
- package/bin/_cctally_doctor.py +73 -27
- package/bin/_cctally_forecast.py +2 -1
- package/bin/_cctally_journal.py +147 -6
- package/bin/_cctally_milestones.py +29 -3
- package/bin/_cctally_quota.py +10 -1
- package/bin/_cctally_record.py +136 -4
- package/bin/_cctally_rederive.py +311 -8
- package/bin/_cctally_source_analytics.py +20 -8
- package/bin/_cctally_tui.py +546 -45
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_codex_conversation_query.py +40 -4
- package/bin/_lib_codex_metadata.py +57 -11
- package/bin/_lib_cost_provenance.py +303 -0
- package/bin/_lib_dashboard_sources.py +263 -28
- package/bin/_lib_doctor.py +2 -2
- package/bin/_lib_ingest_frontier.py +137 -38
- package/bin/_lib_pricing.py +24 -263
- package/bin/_lib_rederive.py +704 -36
- package/bin/_lib_snapshot_cache.py +332 -9
- package/bin/cctally +18 -5
- package/dashboard/static/assets/{ConversationsView-BB9vUiL6.js → ConversationsView-CEZ-BUyi.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFY2iPDf.js → DoctorModal-CE1oH_Zy.js} +1 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DAv39uSo.js → ProjectsDrillPanel-B3DZoCeD.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-DSAWo2Fp.js → SourceDetailModal-CHp9EgLP.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-R_9dOVnh.js → UpdateModal-5iKST6jo.js} +1 -1
- package/dashboard/static/assets/{index-DdU62sov.js → index-Cm0VTxJC.js} +2 -2
- package/dashboard/static/assets/index-Dt4tqf43.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-BOCLcRa6.js → outlineNavigation-DkXGL8J6.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +0 -1
- package/dashboard/static/assets/index-COhxe3vU.css +0 -1
package/bin/_cctally_update.py
CHANGED
|
@@ -75,7 +75,12 @@ worker / polling thread:
|
|
|
75
75
|
(two-thread pump → callbacks + log lines),
|
|
76
76
|
``_do_update_install`` (acquire lock → run steps → release →
|
|
77
77
|
rotate log; dry-run path skips lock + subprocesses),
|
|
78
|
-
``_resolve_execvp_target`` (
|
|
78
|
+
``_resolve_execvp_target`` (spec §5.7; npm execs Python directly
|
|
79
|
+
since #868) and ``_execvp_target_problem`` (target validity).
|
|
80
|
+
- Install evidence (#868): ``install-success.json`` via
|
|
81
|
+
``_write_install_success_record`` / ``_read_install_success_record``,
|
|
82
|
+
``_update_lock_is_held``, ``_install_root_for_entrypoint``,
|
|
83
|
+
``_read_changelog_version_at``, ``_install_layout``.
|
|
79
84
|
- Dashboard surface: ``UpdateWorker`` (single-slot orchestrator,
|
|
80
85
|
spec §5.6; idempotent-release contract per §5.6.1),
|
|
81
86
|
``_DashboardUpdateCheckThread`` (poll cadence ≠ network-call
|
|
@@ -184,12 +189,14 @@ import argparse
|
|
|
184
189
|
import datetime as dt
|
|
185
190
|
import fcntl
|
|
186
191
|
import json
|
|
192
|
+
import math
|
|
187
193
|
import os
|
|
188
194
|
import pathlib
|
|
189
195
|
import queue
|
|
190
196
|
import re
|
|
191
197
|
import secrets
|
|
192
198
|
import shlex
|
|
199
|
+
import shutil
|
|
193
200
|
import subprocess
|
|
194
201
|
import sys
|
|
195
202
|
import threading
|
|
@@ -677,6 +684,101 @@ def _release_update_lock(fd: int) -> None:
|
|
|
677
684
|
pass
|
|
678
685
|
|
|
679
686
|
|
|
687
|
+
_INSTALL_SUCCESS_RECORD_SCHEMA = 1
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@dataclass(frozen=True)
|
|
691
|
+
class InstallSuccessRecord:
|
|
692
|
+
"""One install event from ``install-success.json`` (#868)."""
|
|
693
|
+
|
|
694
|
+
version: str
|
|
695
|
+
at_utc: str
|
|
696
|
+
token: str
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _write_install_success_record(version: str) -> None:
|
|
700
|
+
"""Atomically replace the install-only success record (#868).
|
|
701
|
+
|
|
702
|
+
``_do_update_check`` loads ``update-state.json`` before its network fetch
|
|
703
|
+
and saves that object afterward, so an install that stamps success during
|
|
704
|
+
the fetch loses its fields there. Only the install paths write this file,
|
|
705
|
+
so no update check can erase an install event. Every write mints a fresh
|
|
706
|
+
random token: a running dashboard compares tokens, never times, so a clock
|
|
707
|
+
step can neither hide a new install nor revive an old one.
|
|
708
|
+
"""
|
|
709
|
+
path = _cctally_core.INSTALL_SUCCESS_RECORD_PATH
|
|
710
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
711
|
+
payload = (json.dumps({
|
|
712
|
+
"_schema": _INSTALL_SUCCESS_RECORD_SCHEMA,
|
|
713
|
+
"version": version,
|
|
714
|
+
"at_utc": _now_utc().isoformat(),
|
|
715
|
+
"token": secrets.token_hex(16),
|
|
716
|
+
}, sort_keys=True) + "\n").encode("utf-8")
|
|
717
|
+
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
|
718
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
719
|
+
try:
|
|
720
|
+
try:
|
|
721
|
+
view = memoryview(payload)
|
|
722
|
+
while view:
|
|
723
|
+
view = view[os.write(fd, view):]
|
|
724
|
+
os.fsync(fd)
|
|
725
|
+
finally:
|
|
726
|
+
os.close(fd)
|
|
727
|
+
os.replace(str(tmp), str(path))
|
|
728
|
+
except BaseException:
|
|
729
|
+
try:
|
|
730
|
+
os.unlink(str(tmp))
|
|
731
|
+
except OSError:
|
|
732
|
+
pass
|
|
733
|
+
raise
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _read_install_success_record() -> "InstallSuccessRecord | None":
|
|
737
|
+
"""The current install-success record, or None when absent or malformed."""
|
|
738
|
+
try:
|
|
739
|
+
raw = json.loads(
|
|
740
|
+
_cctally_core.INSTALL_SUCCESS_RECORD_PATH.read_text(encoding="utf-8"))
|
|
741
|
+
except (OSError, ValueError):
|
|
742
|
+
return None
|
|
743
|
+
if not isinstance(raw, dict) or raw.get("_schema") != _INSTALL_SUCCESS_RECORD_SCHEMA:
|
|
744
|
+
return None
|
|
745
|
+
fields = (raw.get("version"), raw.get("at_utc"), raw.get("token"))
|
|
746
|
+
if not all(isinstance(v, str) and v for v in fields):
|
|
747
|
+
return None
|
|
748
|
+
try:
|
|
749
|
+
_release_parse_semver(fields[0])
|
|
750
|
+
except ValueError:
|
|
751
|
+
return None
|
|
752
|
+
return InstallSuccessRecord(*fields)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _update_lock_is_held() -> bool:
|
|
756
|
+
"""True unless ``update.lock`` is provably free; never creates the file.
|
|
757
|
+
|
|
758
|
+
A shared non-blocking probe conflicts with the updater's exclusive lock. A
|
|
759
|
+
lock file left behind by a finished or crashed updater carries no lock, so
|
|
760
|
+
it reads as free. The probe holds its shared lock for an instant, so an
|
|
761
|
+
updater whose own non-blocking attempt lands in that instant takes its
|
|
762
|
+
contention path. Any other error proves nothing about the updater and
|
|
763
|
+
reads as held.
|
|
764
|
+
"""
|
|
765
|
+
try:
|
|
766
|
+
fd = os.open(str(_cctally_core.UPDATE_LOCK_PATH), os.O_RDONLY)
|
|
767
|
+
except FileNotFoundError:
|
|
768
|
+
return False
|
|
769
|
+
except OSError:
|
|
770
|
+
return True
|
|
771
|
+
try:
|
|
772
|
+
try:
|
|
773
|
+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
774
|
+
except OSError:
|
|
775
|
+
return True
|
|
776
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
777
|
+
return False
|
|
778
|
+
finally:
|
|
779
|
+
os.close(fd)
|
|
780
|
+
|
|
781
|
+
|
|
680
782
|
def _rotate_update_log_if_needed() -> None:
|
|
681
783
|
"""Rotate ``update.log`` → ``update.log.1`` when the live log
|
|
682
784
|
crosses :data:`UPDATE_LOG_ROTATE_BYTES` (1 MB, spec §1.5).
|
|
@@ -905,6 +1007,9 @@ def _stamp_install_success_to_state(
|
|
|
905
1007
|
installed version. Skipped on brew for the reason above.
|
|
906
1008
|
4. ``state.latest_version`` — last resort, also covers the npm
|
|
907
1009
|
path when CHANGELOG is unreadable.
|
|
1010
|
+
|
|
1011
|
+
The same version is also written to ``install-success.json`` (#868),
|
|
1012
|
+
which a running dashboard reads to decide whether to restart itself.
|
|
908
1013
|
"""
|
|
909
1014
|
c = _cctally()
|
|
910
1015
|
state = c._load_update_state() or {"_schema": 1}
|
|
@@ -923,6 +1028,15 @@ def _stamp_install_success_to_state(
|
|
|
923
1028
|
state["current_version"] = cur
|
|
924
1029
|
state["last_install_success_at_utc"] = _now_utc().isoformat()
|
|
925
1030
|
c._save_update_state(state)
|
|
1031
|
+
try:
|
|
1032
|
+
c._write_install_success_record(cur)
|
|
1033
|
+
except OSError as exc:
|
|
1034
|
+
# The install itself succeeded; only a running dashboard's
|
|
1035
|
+
# automatic restart loses its evidence.
|
|
1036
|
+
eprint(f"cctally: could not write "
|
|
1037
|
+
f"{_cctally_core.INSTALL_SUCCESS_RECORD_PATH.name} ({exc}); "
|
|
1038
|
+
"a running dashboard will not restart itself for this "
|
|
1039
|
+
"install")
|
|
926
1040
|
|
|
927
1041
|
|
|
928
1042
|
def _self_heal_current_version() -> None:
|
|
@@ -2106,7 +2220,7 @@ def _do_update_install(
|
|
|
2106
2220
|
c._rotate_update_log_if_needed()
|
|
2107
2221
|
|
|
2108
2222
|
|
|
2109
|
-
# === Dashboard execvp re-entry (spec §5.7) ===
|
|
2223
|
+
# === Dashboard execvp re-entry (spec §5.7; #868) ===
|
|
2110
2224
|
# ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
|
|
2111
2225
|
# server boot in cmd_dashboard (in bin/cctally, written via
|
|
2112
2226
|
# ``global ORIGINAL_SYS_ARGV, ORIGINAL_ENTRYPOINT`` so the running
|
|
@@ -2117,26 +2231,81 @@ def _do_update_install(
|
|
|
2117
2231
|
#
|
|
2118
2232
|
# _resolve_execvp_target uses them to return (entrypoint, exec_argv)
|
|
2119
2233
|
# for os.execvp:
|
|
2120
|
-
# - npm:
|
|
2121
|
-
#
|
|
2122
|
-
#
|
|
2234
|
+
# - npm: the interpreter the Node shim would use (CCTALLY_PYTHON, else
|
|
2235
|
+
# python3 on PATH) on the install's bin/cctally. Re-entering the shim
|
|
2236
|
+
# would add a Node process per restart, because the shim starts Python
|
|
2237
|
+
# with spawnSync and waits for it (#868).
|
|
2123
2238
|
# - brew: entrypoint = <brew>/bin/cctally → symlink into the
|
|
2124
2239
|
# post-upgrade Python script with its rewritten shebang.
|
|
2125
2240
|
# - Fallback when shutil.which("cctally") returned None: use
|
|
2126
|
-
# sys.argv[0] directly.
|
|
2127
|
-
#
|
|
2241
|
+
# sys.argv[0] directly.
|
|
2242
|
+
# The install watcher (#868) and UpdateWorker both refuse a target that
|
|
2243
|
+
# _execvp_target_problem rejects.
|
|
2128
2244
|
|
|
2129
2245
|
|
|
2130
|
-
def
|
|
2131
|
-
"""
|
|
2246
|
+
def _install_root_for_entrypoint(entrypoint: "str | None") -> "pathlib.Path | None":
|
|
2247
|
+
"""Install root behind a user-facing entrypoint, resolved afresh (#868).
|
|
2132
2248
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
``
|
|
2136
|
-
|
|
2249
|
+
npm: ``<prefix>/bin/cctally`` → ``…/node_modules/cctally/bin/cctally-npm-shim.js``
|
|
2250
|
+
→ ``…/node_modules/cctally``. Homebrew: ``<prefix>/bin/cctally`` →
|
|
2251
|
+
``Cellar/cctally/<v>/libexec/bin/cctally`` → ``…/libexec``, where the
|
|
2252
|
+
formula installs ``CHANGELOG.md``. The process's own ``CHANGELOG_PATH`` is
|
|
2253
|
+
never used for this: it stays inside the install the process booted from.
|
|
2254
|
+
"""
|
|
2255
|
+
if not entrypoint:
|
|
2256
|
+
return None
|
|
2257
|
+
try:
|
|
2258
|
+
real = pathlib.Path(os.path.realpath(entrypoint))
|
|
2259
|
+
except (OSError, ValueError):
|
|
2260
|
+
return None
|
|
2261
|
+
if real.parent.name != "bin":
|
|
2262
|
+
return None
|
|
2263
|
+
return real.parent.parent
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def _read_changelog_version_at(root) -> "str | None":
|
|
2267
|
+
"""Latest stamped version in ``<root>/CHANGELOG.md``, or None."""
|
|
2268
|
+
try:
|
|
2269
|
+
text = (pathlib.Path(root) / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
2270
|
+
except (OSError, UnicodeDecodeError):
|
|
2271
|
+
return None
|
|
2272
|
+
m = _cctally().RELEASE_HEADER_RE.search(text)
|
|
2273
|
+
return m.group(1) if m else None
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
def _install_layout(root) -> str:
|
|
2277
|
+
"""``"npm"``, ``"brew"`` or ``"other"`` for an install root."""
|
|
2278
|
+
root = pathlib.Path(root)
|
|
2279
|
+
if ((root / "bin" / "cctally-npm-shim.js").is_file()
|
|
2280
|
+
and (root / "package.json").is_file()):
|
|
2281
|
+
return "npm"
|
|
2282
|
+
parts = root.parts
|
|
2283
|
+
if (root.name == "libexec" and len(parts) >= 4
|
|
2284
|
+
and parts[-4] == "Cellar" and parts[-3] == "cctally"):
|
|
2285
|
+
return "brew"
|
|
2286
|
+
return "other"
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def _resolve_execvp_target() -> tuple[str, list[str]]:
|
|
2290
|
+
"""Return (entrypoint, exec_argv) for the in-place restart.
|
|
2291
|
+
|
|
2292
|
+
npm installs exec the interpreter the shim would use (``CCTALLY_PYTHON``,
|
|
2293
|
+
else ``python3`` on ``PATH``) on the install's ``bin/cctally`` directly, so
|
|
2294
|
+
the serving Python process keeps its PID and the original shim stays its
|
|
2295
|
+
only Node parent (#868). Homebrew re-enters the captured entrypoint, which
|
|
2296
|
+
resolves into the newly linked keg. Falls back to ``sys.argv[0]`` only when
|
|
2297
|
+
``shutil.which`` returned ``None`` at dashboard boot.
|
|
2137
2298
|
"""
|
|
2138
2299
|
c = _cctally()
|
|
2139
2300
|
if c.ORIGINAL_ENTRYPOINT is not None:
|
|
2301
|
+
root = _install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT)
|
|
2302
|
+
if root is not None and _install_layout(root) == "npm":
|
|
2303
|
+
python = os.environ.get("CCTALLY_PYTHON") or "python3"
|
|
2304
|
+
if os.path.sep not in python:
|
|
2305
|
+
python = shutil.which(python) or python
|
|
2306
|
+
return (python,
|
|
2307
|
+
[python, str(root / "bin" / "cctally"),
|
|
2308
|
+
*c.ORIGINAL_SYS_ARGV[1:]])
|
|
2140
2309
|
return (
|
|
2141
2310
|
c.ORIGINAL_ENTRYPOINT,
|
|
2142
2311
|
[c.ORIGINAL_ENTRYPOINT, *c.ORIGINAL_SYS_ARGV[1:]],
|
|
@@ -2144,6 +2313,34 @@ def _resolve_execvp_target() -> tuple[str, list[str]]:
|
|
|
2144
2313
|
return (c.ORIGINAL_SYS_ARGV[0], list(c.ORIGINAL_SYS_ARGV))
|
|
2145
2314
|
|
|
2146
2315
|
|
|
2316
|
+
def _execvp_target_problem(entrypoint: str, argv: "list[str]") -> "str | None":
|
|
2317
|
+
"""Why ``os.execvp(entrypoint, argv)`` cannot start cctally, or None.
|
|
2318
|
+
|
|
2319
|
+
The executable must resolve to an executable file. When ``argv[1]`` is an
|
|
2320
|
+
install's ``bin/cctally`` (the npm target), that script must exist and be
|
|
2321
|
+
readable, the same file the npm shim checks before it starts Python.
|
|
2322
|
+
"""
|
|
2323
|
+
path = entrypoint if os.path.sep in entrypoint else shutil.which(entrypoint)
|
|
2324
|
+
if not path or not os.path.isfile(path) or not os.access(path, os.X_OK):
|
|
2325
|
+
return f"restart target {entrypoint!r} is not an executable file"
|
|
2326
|
+
if (len(argv) >= 2 and os.path.isabs(argv[1])
|
|
2327
|
+
and pathlib.Path(argv[1]).parts[-2:] == ("bin", "cctally")):
|
|
2328
|
+
if not (os.path.isfile(argv[1]) and os.access(argv[1], os.R_OK)):
|
|
2329
|
+
return f"restart script {argv[1]!r} is missing or unreadable"
|
|
2330
|
+
return None
|
|
2331
|
+
|
|
2332
|
+
|
|
2333
|
+
class AutomaticRestartInProgress(Exception):
|
|
2334
|
+
"""``UpdateWorker.start`` refused: the install watcher holds the restart
|
|
2335
|
+
claim (#868, maintainer decision 6)."""
|
|
2336
|
+
|
|
2337
|
+
def __init__(self, version: str) -> None:
|
|
2338
|
+
self.version = version
|
|
2339
|
+
super().__init__(
|
|
2340
|
+
f"The dashboard is restarting to load v{version}. "
|
|
2341
|
+
"Try again in a moment.")
|
|
2342
|
+
|
|
2343
|
+
|
|
2147
2344
|
class UpdateWorker:
|
|
2148
2345
|
"""Single-slot dashboard-side update orchestrator (spec §5.6).
|
|
2149
2346
|
|
|
@@ -2175,15 +2372,23 @@ class UpdateWorker:
|
|
|
2175
2372
|
# consumer ever subscribes, ``start()`` reaps stale entries on
|
|
2176
2373
|
# the next run.
|
|
2177
2374
|
self._streams: dict[str, "queue.Queue"] = {}
|
|
2375
|
+
# The install watcher's restart claim (#868): the candidate version
|
|
2376
|
+
# while held, else None. Guarded by ``_lock`` together with
|
|
2377
|
+
# ``_current_id``, so a claim and an admitted run exclude each other.
|
|
2378
|
+
self._auto_restart_version: "str | None" = None
|
|
2178
2379
|
|
|
2179
2380
|
def start(self, version: "str | None") -> tuple[bool, str]:
|
|
2180
2381
|
"""Begin a run. Returns (accepted, run_id).
|
|
2181
2382
|
|
|
2182
2383
|
``accepted=False`` when another run is in progress; the
|
|
2183
2384
|
returned ``run_id`` is the in-progress one (so the caller can
|
|
2184
|
-
surface it as ``run_id_in_progress`` to the client).
|
|
2385
|
+
surface it as ``run_id_in_progress`` to the client). Raises
|
|
2386
|
+
:class:`AutomaticRestartInProgress` while the install watcher holds
|
|
2387
|
+
the restart claim (#868).
|
|
2185
2388
|
"""
|
|
2186
2389
|
with self._lock:
|
|
2390
|
+
if self._auto_restart_version is not None:
|
|
2391
|
+
raise AutomaticRestartInProgress(self._auto_restart_version)
|
|
2187
2392
|
if self._current_id is not None:
|
|
2188
2393
|
return (False, self._current_id)
|
|
2189
2394
|
# Reap any stale entries from prior no-consumer runs. Safe
|
|
@@ -2206,6 +2411,26 @@ class UpdateWorker:
|
|
|
2206
2411
|
with self._lock:
|
|
2207
2412
|
return {"current_run_id": self._current_id}
|
|
2208
2413
|
|
|
2414
|
+
def claim_automatic_restart(self, version: str) -> bool:
|
|
2415
|
+
"""Claim the in-place restart for the install watcher (#868).
|
|
2416
|
+
|
|
2417
|
+
Fails while an update run is admitted, because that run owns its own
|
|
2418
|
+
stamp, flush, lock release and exec. The claim is not an update run:
|
|
2419
|
+
it has no run id, stream or event, so ``status()`` still reports no
|
|
2420
|
+
run and a client that clicks Update meanwhile gets
|
|
2421
|
+
:class:`AutomaticRestartInProgress` from ``start``.
|
|
2422
|
+
"""
|
|
2423
|
+
with self._lock:
|
|
2424
|
+
if (self._current_id is not None
|
|
2425
|
+
or self._auto_restart_version is not None):
|
|
2426
|
+
return False
|
|
2427
|
+
self._auto_restart_version = version
|
|
2428
|
+
return True
|
|
2429
|
+
|
|
2430
|
+
def release_automatic_restart(self) -> None:
|
|
2431
|
+
with self._lock:
|
|
2432
|
+
self._auto_restart_version = None
|
|
2433
|
+
|
|
2209
2434
|
def _emit(self, run_id: str, event: dict) -> None:
|
|
2210
2435
|
q = self._streams.get(run_id)
|
|
2211
2436
|
if q is not None:
|
|
@@ -2310,6 +2535,9 @@ class UpdateWorker:
|
|
|
2310
2535
|
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
2311
2536
|
c._stamp_install_success_to_state(resolved_version, method)
|
|
2312
2537
|
entrypoint, exec_argv = c._resolve_execvp_target()
|
|
2538
|
+
problem = c._execvp_target_problem(entrypoint, exec_argv)
|
|
2539
|
+
if problem is not None:
|
|
2540
|
+
raise UpdateError(problem)
|
|
2313
2541
|
self._emit(run_id, {"type": "execvp", "argv": exec_argv})
|
|
2314
2542
|
try:
|
|
2315
2543
|
log_fd.close()
|
|
@@ -2347,6 +2575,184 @@ class UpdateWorker:
|
|
|
2347
2575
|
# start() sweeps stale entries on the next run.
|
|
2348
2576
|
|
|
2349
2577
|
|
|
2578
|
+
# === Install watch (#868) =================================================
|
|
2579
|
+
# A running dashboard restarts itself in place when a proven install puts a
|
|
2580
|
+
# strictly newer cctally under it. Spec:
|
|
2581
|
+
# docs/superpowers/specs/2026-09-23-868-dashboard-restart-on-install-design.md
|
|
2582
|
+
|
|
2583
|
+
_INSTALL_WATCH_INTERVAL_S = 60.0
|
|
2584
|
+
_INSTALL_RESTART_RETRY_DELAYS_S = (60.0, 300.0, 900.0)
|
|
2585
|
+
_INSTALL_RESTART_FLUSH_S = 0.5
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
@dataclass(frozen=True)
|
|
2589
|
+
class InstallBootState:
|
|
2590
|
+
"""What the dashboard process booted from. Later polls compare against it,
|
|
2591
|
+
never against wall-clock time, so a restarted process cannot qualify on the
|
|
2592
|
+
install it booted from."""
|
|
2593
|
+
|
|
2594
|
+
version: "str | None"
|
|
2595
|
+
root: "pathlib.Path | None"
|
|
2596
|
+
record_token: "str | None"
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def capture_install_boot_state() -> InstallBootState:
|
|
2600
|
+
c = _cctally()
|
|
2601
|
+
latest = c._release_read_latest_release_version()
|
|
2602
|
+
record = _read_install_success_record()
|
|
2603
|
+
return InstallBootState(
|
|
2604
|
+
version=latest[0] if latest else None,
|
|
2605
|
+
root=_install_root_for_entrypoint(c.ORIGINAL_ENTRYPOINT),
|
|
2606
|
+
record_token=record.token if record else None,
|
|
2607
|
+
)
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
@dataclass(frozen=True)
|
|
2611
|
+
class RestartCandidate:
|
|
2612
|
+
"""A proven newer install. ``identity`` keys the retry budget: the version
|
|
2613
|
+
with the record token (updater) or the resolved install root (Homebrew)."""
|
|
2614
|
+
|
|
2615
|
+
version: str
|
|
2616
|
+
evidence: str
|
|
2617
|
+
identity: tuple
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
class InstallWatch:
|
|
2621
|
+
"""Restart the dashboard in place after a proven newer install (#868).
|
|
2622
|
+
|
|
2623
|
+
Proven means cctally's updater wrote a new install-success record, or
|
|
2624
|
+
Homebrew switched the entrypoint into a newer keg. A manual npm install
|
|
2625
|
+
rewrites files in place with no completion signal, so it never qualifies.
|
|
2626
|
+
|
|
2627
|
+
The restart meets the Update-button safety bar and no more: nothing is
|
|
2628
|
+
stopped before the exec, and an in-flight write is interrupted as in a
|
|
2629
|
+
crash. The claim on ``UpdateWorker`` keeps an admitted update run as the
|
|
2630
|
+
sole owner of its own restart.
|
|
2631
|
+
"""
|
|
2632
|
+
|
|
2633
|
+
def __init__(self, boot, *, worker_fn, exec_fn=os.execvp,
|
|
2634
|
+
sleep_fn=time.sleep, monotonic_fn=time.monotonic,
|
|
2635
|
+
log_fn=None, interval_s=_INSTALL_WATCH_INTERVAL_S,
|
|
2636
|
+
retry_delays=_INSTALL_RESTART_RETRY_DELAYS_S,
|
|
2637
|
+
flush_s=_INSTALL_RESTART_FLUSH_S):
|
|
2638
|
+
self._boot = boot
|
|
2639
|
+
self._worker_fn = worker_fn
|
|
2640
|
+
self._exec = exec_fn
|
|
2641
|
+
self._sleep = sleep_fn
|
|
2642
|
+
self._monotonic = monotonic_fn
|
|
2643
|
+
self._log = log_fn or eprint
|
|
2644
|
+
self.interval_s = interval_s
|
|
2645
|
+
self._retry_delays = tuple(retry_delays)
|
|
2646
|
+
self._flush_s = flush_s
|
|
2647
|
+
# identity -> (failed attempts, monotonic time of the next attempt)
|
|
2648
|
+
self._attempts: dict = {}
|
|
2649
|
+
self._stop = threading.Event()
|
|
2650
|
+
self._thread: "threading.Thread | None" = None
|
|
2651
|
+
|
|
2652
|
+
def _say(self, msg: str) -> None:
|
|
2653
|
+
# A lost log line, e.g. stderr on a closed pipe, must not count as a
|
|
2654
|
+
# failed restart attempt or end the watch thread.
|
|
2655
|
+
try:
|
|
2656
|
+
self._log(msg)
|
|
2657
|
+
except Exception: # noqa: BLE001
|
|
2658
|
+
pass
|
|
2659
|
+
|
|
2660
|
+
def find_candidate(self) -> "RestartCandidate | None":
|
|
2661
|
+
boot = self._boot
|
|
2662
|
+
if boot.version is None:
|
|
2663
|
+
return None
|
|
2664
|
+
root = _install_root_for_entrypoint(_cctally().ORIGINAL_ENTRYPOINT)
|
|
2665
|
+
if root is None:
|
|
2666
|
+
return None
|
|
2667
|
+
record = _read_install_success_record()
|
|
2668
|
+
if (record is not None and record.token != boot.record_token
|
|
2669
|
+
and _semver_gt(record.version, boot.version)
|
|
2670
|
+
and not _update_lock_is_held()
|
|
2671
|
+
and _read_changelog_version_at(root) == record.version):
|
|
2672
|
+
return RestartCandidate(record.version, "updater",
|
|
2673
|
+
(record.version, "updater", record.token))
|
|
2674
|
+
# Homebrew links the new keg only after installing it completely, so
|
|
2675
|
+
# the switch itself is the completion event.
|
|
2676
|
+
if root != boot.root and _install_layout(root) == "brew":
|
|
2677
|
+
version = _read_changelog_version_at(root)
|
|
2678
|
+
if version and _semver_gt(version, boot.version):
|
|
2679
|
+
return RestartCandidate(version, "brew",
|
|
2680
|
+
(version, "brew", str(root)))
|
|
2681
|
+
return None
|
|
2682
|
+
|
|
2683
|
+
def poll_once(self) -> str:
|
|
2684
|
+
"""One check. Returns ``"idle"``, ``"backoff"``, ``"exhausted"``,
|
|
2685
|
+
``"deferred"``, ``"stopped"``, ``"failed"`` or ``"exec"``."""
|
|
2686
|
+
candidate = self.find_candidate()
|
|
2687
|
+
if candidate is None:
|
|
2688
|
+
return "idle"
|
|
2689
|
+
now = self._monotonic()
|
|
2690
|
+
prior = self._attempts.get(candidate.identity)
|
|
2691
|
+
if prior is not None:
|
|
2692
|
+
failures, next_at = prior
|
|
2693
|
+
if failures > len(self._retry_delays):
|
|
2694
|
+
return "exhausted"
|
|
2695
|
+
if now < next_at:
|
|
2696
|
+
return "backoff"
|
|
2697
|
+
worker = self._worker_fn()
|
|
2698
|
+
if worker is None:
|
|
2699
|
+
return "deferred"
|
|
2700
|
+
c = _cctally()
|
|
2701
|
+
claimed = False
|
|
2702
|
+
try:
|
|
2703
|
+
entrypoint, argv = c._resolve_execvp_target()
|
|
2704
|
+
problem = c._execvp_target_problem(entrypoint, argv)
|
|
2705
|
+
if problem is None:
|
|
2706
|
+
again = self.find_candidate()
|
|
2707
|
+
if again is None or again.identity != candidate.identity:
|
|
2708
|
+
problem = "the install changed before the restart"
|
|
2709
|
+
if problem is not None:
|
|
2710
|
+
raise UpdateError(problem)
|
|
2711
|
+
if not worker.claim_automatic_restart(candidate.version):
|
|
2712
|
+
return "deferred"
|
|
2713
|
+
claimed = True
|
|
2714
|
+
self._say(f"[update] restarting dashboard: v{self._boot.version} "
|
|
2715
|
+
f"-> v{candidate.version} ({candidate.evidence} install)")
|
|
2716
|
+
# Lets a POST /api/update already refused under the claim finish
|
|
2717
|
+
# writing its 503 before the process image is replaced.
|
|
2718
|
+
self._sleep(self._flush_s)
|
|
2719
|
+
if self._stop.is_set():
|
|
2720
|
+
worker.release_automatic_restart()
|
|
2721
|
+
return "stopped"
|
|
2722
|
+
self._exec(entrypoint, argv)
|
|
2723
|
+
except Exception as exc: # noqa: BLE001 — a failed attempt keeps serving
|
|
2724
|
+
if claimed:
|
|
2725
|
+
worker.release_automatic_restart()
|
|
2726
|
+
failures = (prior[0] if prior else 0) + 1
|
|
2727
|
+
delay = (self._retry_delays[failures - 1]
|
|
2728
|
+
if failures <= len(self._retry_delays) else math.inf)
|
|
2729
|
+
self._attempts[candidate.identity] = (failures, now + delay)
|
|
2730
|
+
self._say(f"[update] automatic restart to v{candidate.version} "
|
|
2731
|
+
f"failed: {exc}")
|
|
2732
|
+
return "failed"
|
|
2733
|
+
return "exec"
|
|
2734
|
+
|
|
2735
|
+
def run(self) -> None:
|
|
2736
|
+
while not self._stop.wait(self.interval_s):
|
|
2737
|
+
try:
|
|
2738
|
+
self.poll_once()
|
|
2739
|
+
except Exception as exc: # noqa: BLE001 — the watch must not die
|
|
2740
|
+
self._say(f"[update] install watch poll failed: {exc!r}")
|
|
2741
|
+
|
|
2742
|
+
def start(self) -> "threading.Thread":
|
|
2743
|
+
thread = threading.Thread(target=self.run, name="cctally-install-watch",
|
|
2744
|
+
daemon=True)
|
|
2745
|
+
thread.start()
|
|
2746
|
+
self._thread = thread
|
|
2747
|
+
return thread
|
|
2748
|
+
|
|
2749
|
+
def stop(self, timeout: float = 2.0) -> None:
|
|
2750
|
+
self._stop.set()
|
|
2751
|
+
thread = self._thread
|
|
2752
|
+
if thread is not None and thread is not threading.current_thread():
|
|
2753
|
+
thread.join(timeout)
|
|
2754
|
+
|
|
2755
|
+
|
|
2350
2756
|
class _DashboardUpdateCheckThread(threading.Thread):
|
|
2351
2757
|
"""Dedicated update-check polling thread (spec §3.5).
|
|
2352
2758
|
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -107,13 +107,10 @@ def _import_pricing_kernel():
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
# ``DEFAULT_TIERED_THRESHOLD`` above is Anthropic's per-call >200K-tokens tier,
|
|
110
|
-
# READ FROM ``_lib_pricing`` rather than duplicated here
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
# swap, and ``_compute_entry_cache_dollars`` resolves it at CALL time — a
|
|
115
|
-
# default argument would bind it once at function-definition time, so the
|
|
116
|
-
# refresh would reach the name and never the calls.
|
|
110
|
+
# READ FROM ``_lib_pricing.TIERED_THRESHOLD`` rather than duplicated here, so
|
|
111
|
+
# the two cannot drift. ``_compute_entry_cache_dollars`` resolves it at CALL
|
|
112
|
+
# time instead of binding it as a default argument, so this module global is
|
|
113
|
+
# the one place the value is taken from.
|
|
117
114
|
# Callers may still override via the ``tiered_threshold`` kwarg.
|
|
118
115
|
|
|
119
116
|
|
|
@@ -384,9 +381,9 @@ def _compute_entry_cache_dollars(
|
|
|
384
381
|
warning for unknown models elsewhere.
|
|
385
382
|
|
|
386
383
|
``tiered_threshold=None`` resolves ``DEFAULT_TIERED_THRESHOLD`` HERE rather
|
|
387
|
-
than in the signature
|
|
388
|
-
function-definition time, so
|
|
389
|
-
|
|
384
|
+
than in the signature. A default argument is evaluated once, at
|
|
385
|
+
function-definition time, so a change to the module global would never
|
|
386
|
+
reach the calls.
|
|
390
387
|
"""
|
|
391
388
|
if tiered_threshold is None:
|
|
392
389
|
tiered_threshold = DEFAULT_TIERED_THRESHOLD
|
|
@@ -3314,11 +3314,8 @@ def _stored_filter_sql(
|
|
|
3314
3314
|
clauses.append(f"{alias}.project_key = ?")
|
|
3315
3315
|
params.append(project_key)
|
|
3316
3316
|
if undecodable_keys:
|
|
3317
|
-
ordered = sorted(undecodable_keys)
|
|
3318
|
-
placeholders = ",".join("?" for _ in ordered)
|
|
3319
3317
|
clauses.append(
|
|
3320
|
-
f"{alias}.conversation_key
|
|
3321
|
-
params.extend(ordered)
|
|
3318
|
+
f"cctally_codex_undecodable({alias}.conversation_key) = 0")
|
|
3322
3319
|
if model is not None:
|
|
3323
3320
|
# models_json is the writer's canonical JSON array. Searching for the
|
|
3324
3321
|
# complete JSON string literal is exact and does not require JSON1.
|
|
@@ -3347,10 +3344,49 @@ def _page_costs(
|
|
|
3347
3344
|
return totals
|
|
3348
3345
|
|
|
3349
3346
|
|
|
3347
|
+
@contextlib.contextmanager
|
|
3348
|
+
def _stored_undecodable_membership(
|
|
3349
|
+
conn: sqlite3.Connection, undecodable_keys: "frozenset[str]",
|
|
3350
|
+
):
|
|
3351
|
+
"""Install the stored page's request-bounded membership predicate.
|
|
3352
|
+
|
|
3353
|
+
Only one membership bit crosses the SQL boundary; the raw unscoped read's
|
|
3354
|
+
attribution values never do. Key cardinality consumes no host parameters,
|
|
3355
|
+
registration executes no SQL, and cleanup prevents a later request from
|
|
3356
|
+
seeing this request's frozen set.
|
|
3357
|
+
"""
|
|
3358
|
+
if not undecodable_keys:
|
|
3359
|
+
yield
|
|
3360
|
+
return
|
|
3361
|
+
frozen = frozenset(undecodable_keys)
|
|
3362
|
+
conn.create_function(
|
|
3363
|
+
"cctally_codex_undecodable", 1,
|
|
3364
|
+
lambda conversation_key: int(conversation_key in frozen),
|
|
3365
|
+
deterministic=True,
|
|
3366
|
+
)
|
|
3367
|
+
try:
|
|
3368
|
+
yield
|
|
3369
|
+
finally:
|
|
3370
|
+
conn.create_function("cctally_codex_undecodable", 1, None)
|
|
3371
|
+
|
|
3372
|
+
|
|
3350
3373
|
def _stored_browse_page(
|
|
3351
3374
|
conn: sqlite3.Connection, *, effective_speed: str,
|
|
3352
3375
|
project_key: str | None, model: str | None, limit: int,
|
|
3353
3376
|
cursor: str | None, undecodable_keys: "frozenset[str]" = frozenset(),
|
|
3377
|
+
):
|
|
3378
|
+
membership = undecodable_keys if project_key is not None else frozenset()
|
|
3379
|
+
with _stored_undecodable_membership(conn, membership):
|
|
3380
|
+
return _stored_browse_page_queries(
|
|
3381
|
+
conn, effective_speed=effective_speed, project_key=project_key,
|
|
3382
|
+
model=model, limit=limit, cursor=cursor,
|
|
3383
|
+
undecodable_keys=undecodable_keys)
|
|
3384
|
+
|
|
3385
|
+
|
|
3386
|
+
def _stored_browse_page_queries(
|
|
3387
|
+
conn: sqlite3.Connection, *, effective_speed: str,
|
|
3388
|
+
project_key: str | None, model: str | None, limit: int,
|
|
3389
|
+
cursor: str | None, undecodable_keys: "frozenset[str]" = frozenset(),
|
|
3354
3390
|
):
|
|
3355
3391
|
where_sql, filter_params = _stored_filter_sql(
|
|
3356
3392
|
"r", project_key, model, undecodable_keys)
|