cctally 1.109.0 → 1.111.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 +52 -0
- package/README.md +6 -6
- package/bin/_cctally_cache.py +62 -13
- package/bin/_cctally_core.py +8 -1
- package/bin/_cctally_dashboard.py +422 -195
- package/bin/_cctally_dashboard_conversation.py +231 -40
- package/bin/_cctally_dashboard_envelope.py +48 -32
- package/bin/_cctally_dashboard_sources.py +503 -109
- package/bin/_cctally_db.py +44 -0
- package/bin/_cctally_diagnosis_sources.py +181 -15
- package/bin/_cctally_doctor.py +15 -3
- package/bin/_cctally_forecast.py +25 -12
- package/bin/_cctally_journal.py +59 -32
- package/bin/_cctally_milestone_history.py +65 -9
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_record.py +50 -12
- package/bin/_cctally_rederive.py +899 -30
- package/bin/_cctally_source_analytics.py +567 -133
- package/bin/_cctally_transcript.py +27 -2
- package/bin/_cctally_tui.py +39 -6
- package/bin/_cctally_update.py +420 -14
- package/bin/_lib_cache_report.py +7 -10
- package/bin/_lib_codex_conversation_query.py +286 -63
- package/bin/_lib_codex_metadata.py +207 -0
- package/bin/_lib_conversation_query.py +254 -37
- package/bin/_lib_doctor.py +16 -2
- package/bin/_lib_pricing.py +80 -276
- package/bin/_lib_rederive.py +903 -2
- package/bin/_lib_view_models.py +5 -0
- package/bin/cctally +18 -5
- package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
- package/dashboard/static/assets/{DoctorModal-BZGXOlc2.js → DoctorModal-DFY2iPDf.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-KbO_KWa7.js → ProjectsDrillPanel-DAv39uSo.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BrOPLVRL.js → SourceDetailModal-DSAWo2Fp.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CYgEFAi4.js → UpdateModal-R_9dOVnh.js} +3 -3
- package/dashboard/static/assets/index-COhxe3vU.css +1 -0
- package/dashboard/static/assets/index-DdU62sov.js +13 -0
- package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
- package/dashboard/static/assets/{useKeymap-DQx_A1MB.js → useKeymap-CW7Up0Ay.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +2 -1
- package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +0 -72
- package/dashboard/static/assets/ModalRoot-C_fvugTl.js +0 -1
- package/dashboard/static/assets/index-Cj_l7T7u.css +0 -1
- package/dashboard/static/assets/index-DLVwACUM.js +0 -13
- package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +0 -9
|
@@ -116,6 +116,18 @@ def _cmd_transcript_export(args) -> int:
|
|
|
116
116
|
return 0
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
#: The M5 refusal, verbatim. Exit 3 is the code `docs/commands/transcript.md`
|
|
120
|
+
#: already uses for an attribution cache that cannot be established.
|
|
121
|
+
_ANON_UNAVAILABLE_MSG = (
|
|
122
|
+
"transcript: anonymized export is unavailable: {count} Codex project "
|
|
123
|
+
"path(s) could not be read; run cctally cache-sync --source codex --rebuild"
|
|
124
|
+
)
|
|
125
|
+
_ANON_AMBIGUOUS_MSG = (
|
|
126
|
+
"transcript: anonymized export is unavailable: {count} Codex project "
|
|
127
|
+
"path(s) have ambiguous account provenance; use --raw or omit --account"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
119
131
|
def _cmd_transcript_export_qualified(
|
|
120
132
|
c, args, session_id, scope, raw, output, speed_arg) -> int:
|
|
121
133
|
"""Qualified (``v1.``) export via the neutral dispatch layer (§4.1). Anonymized
|
|
@@ -157,10 +169,23 @@ def _cmd_transcript_export_qualified(
|
|
|
157
169
|
if not raw:
|
|
158
170
|
cq = c._load_sibling("_lib_conversation_query")
|
|
159
171
|
anon = c._load_sibling("_lib_conversation_anon")
|
|
160
|
-
|
|
172
|
+
result = cq.build_anon_plan_for_sources(
|
|
161
173
|
conn, home_dir=os.path.expanduser("~"),
|
|
162
174
|
sources={cref.source})
|
|
163
|
-
|
|
175
|
+
# #850 M5. A Codex project path the store cannot read cannot become
|
|
176
|
+
# a scrub token, and such a path can appear in ANY transcript, so
|
|
177
|
+
# the anonymized export refuses while any of them is undecodable
|
|
178
|
+
# rather than emitting bytes that only look scrubbed. `--raw` and
|
|
179
|
+
# `search` escape, and a Claude-only plan never reaches this.
|
|
180
|
+
if result.undecodable_cwd_rows:
|
|
181
|
+
eprint(_ANON_UNAVAILABLE_MSG.format(
|
|
182
|
+
count=result.undecodable_cwd_rows))
|
|
183
|
+
return 3
|
|
184
|
+
if result.ambiguous_cwd_rows:
|
|
185
|
+
eprint(_ANON_AMBIGUOUS_MSG.format(
|
|
186
|
+
count=result.ambiguous_cwd_rows))
|
|
187
|
+
return 3
|
|
188
|
+
md = anon.scrub_text(md, result.plan)
|
|
164
189
|
finally:
|
|
165
190
|
conn.close()
|
|
166
191
|
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -1676,7 +1676,12 @@ def _tui_build_current_week(
|
|
|
1676
1676
|
Returns None when no current-week usage snapshot exists.
|
|
1677
1677
|
"""
|
|
1678
1678
|
# #769 S11 (#824): ONE read, held-inclusive, split per axis below.
|
|
1679
|
-
|
|
1679
|
+
# Retain the owner of the exact non-held weekly sample this card displays,
|
|
1680
|
+
# including when one real account coexists with unattributed history. R8
|
|
1681
|
+
# controls decoration, not the physical population used for computation.
|
|
1682
|
+
# Cost and forecast retain their existing merged semantics.
|
|
1683
|
+
fetched = _fetch_current_week_snapshots(
|
|
1684
|
+
conn, now_utc, include_held=True, include_account=True)
|
|
1680
1685
|
if fetched is None:
|
|
1681
1686
|
return None
|
|
1682
1687
|
week_start_at, week_end_at, observations = fetched
|
|
@@ -1690,9 +1695,10 @@ def _tui_build_current_week(
|
|
|
1690
1695
|
if not observations:
|
|
1691
1696
|
return None
|
|
1692
1697
|
# Held-inclusive sample shape: (captured_at_utc, weekly_percent,
|
|
1693
|
-
# five_hour_percent, weekly_observation_held).
|
|
1694
|
-
#
|
|
1695
|
-
#
|
|
1698
|
+
# five_hour_percent, weekly_observation_held[, account_key]). The optional
|
|
1699
|
+
# account key identifies the displayed weekly sample on a decorated store.
|
|
1700
|
+
# The helper does not surface five_hour_resets_at, so do a targeted lookup
|
|
1701
|
+
# below for the freshest non-NULL reset timestamp on the current week.
|
|
1696
1702
|
#
|
|
1697
1703
|
# #769 S11 (#824). This card publishes BOTH axes, and the dashboard
|
|
1698
1704
|
# envelope reads `used_pct` and `latest_snapshot_at` straight off it for
|
|
@@ -1707,7 +1713,11 @@ def _tui_build_current_week(
|
|
|
1707
1713
|
return None
|
|
1708
1714
|
latest = weekly_samples[-1]
|
|
1709
1715
|
used_pct = float(latest[1])
|
|
1710
|
-
|
|
1716
|
+
weekly_account = latest[4]
|
|
1717
|
+
# Five-hour readings may be newer held observations, but must remain on
|
|
1718
|
+
# the account that supplied the displayed weekly reading.
|
|
1719
|
+
five_hour_samples = [s for s in observations if s[4] == weekly_account]
|
|
1720
|
+
five_hr_raw = five_hour_samples[-1][2]
|
|
1711
1721
|
five_hr_pct = float(five_hr_raw) if five_hr_raw is not None else None
|
|
1712
1722
|
# #556 S1 §3.3: one walk yields both halves. The range is whatever
|
|
1713
1723
|
# `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
|
|
@@ -1753,8 +1763,9 @@ def _tui_build_current_week(
|
|
|
1753
1763
|
f"SELECT five_hour_resets_at FROM weekly_usage_snapshots "
|
|
1754
1764
|
f"WHERE week_start_at IN ({placeholders}) "
|
|
1755
1765
|
f" AND five_hour_resets_at IS NOT NULL "
|
|
1766
|
+
f" AND account_key = ? "
|
|
1756
1767
|
f"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
1757
|
-
tuple(matching_ws_texts),
|
|
1768
|
+
tuple(matching_ws_texts) + (weekly_account,),
|
|
1758
1769
|
).fetchone()
|
|
1759
1770
|
if reset_row is not None:
|
|
1760
1771
|
try:
|
|
@@ -1797,6 +1808,7 @@ def _tui_build_current_week(
|
|
|
1797
1808
|
freshness_age=freshness_age,
|
|
1798
1809
|
five_hour_block=_select_current_block_for_envelope(
|
|
1799
1810
|
conn, current_used_pct=used_pct, now_utc=now_utc,
|
|
1811
|
+
account_key=weekly_account,
|
|
1800
1812
|
),
|
|
1801
1813
|
total_tokens=total_tokens,
|
|
1802
1814
|
)
|
|
@@ -3553,6 +3565,20 @@ def _tui_retain_refused_partial(
|
|
|
3553
3565
|
_PARTIAL_RETRY_STATE, provider=provider,
|
|
3554
3566
|
)
|
|
3555
3567
|
return False
|
|
3568
|
+
# #846 §4.6. THE CARRIER DECIDES RETENTION, and it decides before the
|
|
3569
|
+
# warnings are consulted. This leg derived the retry cause from
|
|
3570
|
+
# `prior.warnings` alone, and a transient qualified-read failure publishes
|
|
3571
|
+
# the warning code `codex_metadata_incomplete`, which the retry kernel
|
|
3572
|
+
# normalizes to the retainable cause `metadata_incomplete` — so the second
|
|
3573
|
+
# tick retained the transient generation for up to `PARTIAL_RETRY_INTERVAL`
|
|
3574
|
+
# while the chip promised a retry that never came. No reuse or retention
|
|
3575
|
+
# gate may retain a retryable carrier.
|
|
3576
|
+
health = getattr(prior, "metadata_health", None)
|
|
3577
|
+
if isinstance(health, Mapping) and bool(health.get("retryable")):
|
|
3578
|
+
_PARTIAL_RETRY_STATE = clear_partial_retry(
|
|
3579
|
+
_PARTIAL_RETRY_STATE, provider=provider,
|
|
3580
|
+
)
|
|
3581
|
+
return False
|
|
3556
3582
|
if (
|
|
3557
3583
|
prior.freshness != "fresh"
|
|
3558
3584
|
or prior.data is None
|
|
@@ -4337,6 +4363,13 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
|
|
|
4337
4363
|
# one by reading the path the first fix did not reach.
|
|
4338
4364
|
health = state.metadata_health
|
|
4339
4365
|
if health is not None and bool(health.get("retryable")):
|
|
4366
|
+
# #846 §4.6: refuse AND clear any armed key, so the tick after the
|
|
4367
|
+
# fault clears rebuilds rather than finding a key still inside its
|
|
4368
|
+
# deadline.
|
|
4369
|
+
global _PARTIAL_RETRY_STATE
|
|
4370
|
+
_PARTIAL_RETRY_STATE = clear_partial_retry(
|
|
4371
|
+
_PARTIAL_RETRY_STATE, provider=source,
|
|
4372
|
+
)
|
|
4340
4373
|
return False
|
|
4341
4374
|
return True
|
|
4342
4375
|
|
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
|
|