cctally 1.8.1 → 1.9.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 +20 -0
- package/bin/_cctally_dashboard.py +158 -98
- package/bin/_cctally_setup.py +248 -1
- package/bin/_cctally_tui.py +156 -31
- package/bin/_cctally_update.py +29 -5
- package/bin/_lib_changelog.py +44 -0
- package/bin/_lib_semver.py +1 -1
- package/bin/_lib_share_templates.py +4 -2
- package/bin/_lib_view_models.py +784 -0
- package/bin/cctally +153 -1508
- package/dashboard/static/assets/{index-CfXu9Fx_.js → index-cWE5HB8O.js} +2 -2
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +2 -3
- package/bin/_cctally_release.py +0 -751
- package/bin/cctally-release +0 -3
package/bin/_cctally_setup.py
CHANGED
|
@@ -242,10 +242,22 @@ def _setup_local_bin_dir() -> pathlib.Path:
|
|
|
242
242
|
@dataclasses.dataclass
|
|
243
243
|
class _SetupSymlinkResult:
|
|
244
244
|
name: str
|
|
245
|
-
status: str # "created" | "already" | "replaced" | "failed"
|
|
245
|
+
status: str # "created" | "already" | "replaced" | "failed" | "removed-stale"
|
|
246
246
|
detail: str = ""
|
|
247
247
|
|
|
248
248
|
|
|
249
|
+
# Symlink names cctally USED to install but no longer does. We keep
|
|
250
|
+
# cleaning them up for one major-version's worth of upgraders so
|
|
251
|
+
# `~/.local/bin/` doesn't accumulate dangling symlinks pointing at
|
|
252
|
+
# scripts the current cctally checkout doesn't ship. Removal is
|
|
253
|
+
# conservative: we only unlink symlinks whose `readlink()` target's
|
|
254
|
+
# basename matches the stale name — a user's hand-rolled symlink with
|
|
255
|
+
# the same name pointing elsewhere is left alone.
|
|
256
|
+
_SETUP_STALE_SYMLINK_NAMES = (
|
|
257
|
+
"cctally-release", # Removed in v1.9.0; release tooling went private.
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
249
261
|
def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
|
|
250
262
|
"""Resolve the symlink target for a given PATH-name.
|
|
251
263
|
|
|
@@ -326,6 +338,148 @@ def _setup_create_symlinks(
|
|
|
326
338
|
return results
|
|
327
339
|
|
|
328
340
|
|
|
341
|
+
def _setup_cleanup_stale_symlinks(
|
|
342
|
+
dst_dir: pathlib.Path,
|
|
343
|
+
) -> list[_SetupSymlinkResult]:
|
|
344
|
+
"""Unlink stale `cctally-*` symlinks left over from prior versions.
|
|
345
|
+
|
|
346
|
+
For each entry in :data:`_SETUP_STALE_SYMLINK_NAMES`, removes the
|
|
347
|
+
matching symlink in ``dst_dir`` when its readlink target basename
|
|
348
|
+
matches the stale name AND the target points at a "retired" cctally
|
|
349
|
+
install — meaning either (a) the target is *dangling* (no longer
|
|
350
|
+
resolves to a real file, e.g. an old checkout that was deleted) OR
|
|
351
|
+
(b) the target lives under a *foreign* cctally install root
|
|
352
|
+
(Homebrew keg, npm ``node_modules/cctally/``), i.e. an install root
|
|
353
|
+
other than the one currently running ``cctally setup``.
|
|
354
|
+
|
|
355
|
+
The foreign-root clause is what handles the common upgrade path:
|
|
356
|
+
after ``brew upgrade cctally`` or ``npm i -g cctally@<new>``, the
|
|
357
|
+
*prior* keg / module dir often lingers on disk until ``brew
|
|
358
|
+
cleanup`` (or the next npm install GC), so a legacy
|
|
359
|
+
``~/.local/bin/cctally-release`` symlink from a pre-v1.9.0 ``cctally
|
|
360
|
+
setup`` still resolves to an existing file under the *old* install
|
|
361
|
+
root. The dangling-only predicate would skip it, leaving the retired
|
|
362
|
+
command on PATH; the foreign-root check retires it instead.
|
|
363
|
+
|
|
364
|
+
Both clauses still preserve the maintainer's intentional manual
|
|
365
|
+
link to the *current* checkout's still-shipped retired tooling
|
|
366
|
+
(e.g. ``~/.local/bin/cctally-release ->
|
|
367
|
+
<cctally-dev>/bin/cctally-release``) — that target is neither
|
|
368
|
+
dangling nor foreign, so it's left alone. Likewise a hand-rolled
|
|
369
|
+
link pointing somewhere unrelated (``~/scripts/...``) survives.
|
|
370
|
+
|
|
371
|
+
Returns a list of :class:`_SetupSymlinkResult` entries for the
|
|
372
|
+
actions taken (so callers can fold them into install output).
|
|
373
|
+
"""
|
|
374
|
+
results: list[_SetupSymlinkResult] = []
|
|
375
|
+
repo_root = _setup_resolve_repo_root()
|
|
376
|
+
for name in _SETUP_STALE_SYMLINK_NAMES:
|
|
377
|
+
dst = dst_dir / name
|
|
378
|
+
if not _setup_symlink_is_retired(dst, name, repo_root):
|
|
379
|
+
continue
|
|
380
|
+
try:
|
|
381
|
+
dst.unlink()
|
|
382
|
+
results.append(
|
|
383
|
+
_SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
|
|
384
|
+
)
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
results.append(
|
|
387
|
+
_SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
|
|
388
|
+
)
|
|
389
|
+
return results
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# Path tokens that identify a "foreign" cctally install root — i.e. a
|
|
393
|
+
# directory tree managed by a different distribution channel (or a
|
|
394
|
+
# different version of the same channel) than the one currently running
|
|
395
|
+
# ``cctally setup``. A symlink whose target sits under one of these is
|
|
396
|
+
# almost certainly a legacy auto-installed link from a prior version
|
|
397
|
+
# whose install root the current run does NOT manage. Pre-resolved (no
|
|
398
|
+
# trailing slash) so substring matching works on either UNIX or
|
|
399
|
+
# resolved forms.
|
|
400
|
+
_SETUP_FOREIGN_INSTALL_ROOT_TOKENS = (
|
|
401
|
+
# Homebrew keeps every installed version under
|
|
402
|
+
# ``<prefix>/Cellar/cctally/<version>/``. A target inside any of
|
|
403
|
+
# these directories points at a brew install — either the current
|
|
404
|
+
# one (when ``cctally setup`` runs from a brew install — possible
|
|
405
|
+
# but rare) or an older keg still on disk pending ``brew cleanup``.
|
|
406
|
+
"/Cellar/cctally/",
|
|
407
|
+
# npm globals land at ``<prefix>/lib/node_modules/cctally/``; npm
|
|
408
|
+
# locals at ``<project>/node_modules/cctally/``. Either way the
|
|
409
|
+
# ``/node_modules/cctally/`` segment is the discriminator. pnpm and
|
|
410
|
+
# yarn variants keep the segment, so this catches them too.
|
|
411
|
+
"/node_modules/cctally/",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _setup_symlink_is_retired(
|
|
416
|
+
dst: pathlib.Path, name: str, repo_root: pathlib.Path,
|
|
417
|
+
) -> bool:
|
|
418
|
+
"""Detection predicate for retired auto-symlinks at install time.
|
|
419
|
+
|
|
420
|
+
Returns True iff ``dst`` is a symlink, its readlink target basename
|
|
421
|
+
matches ``name``, AND the target points at a "retired" cctally
|
|
422
|
+
install — i.e. one the current ``cctally setup`` run does not
|
|
423
|
+
manage. Two retirement classes:
|
|
424
|
+
|
|
425
|
+
1. **Dangling target** — readlink target does not resolve to an
|
|
426
|
+
existing filesystem entry. Covers the deleted-checkout case.
|
|
427
|
+
2. **Foreign install root** — target path contains one of
|
|
428
|
+
:data:`_SETUP_FOREIGN_INSTALL_ROOT_TOKENS` (``/Cellar/cctally/``
|
|
429
|
+
or ``/node_modules/cctally/``) AND that token does NOT also
|
|
430
|
+
appear in the current ``repo_root``. The second clause is what
|
|
431
|
+
keeps the (rare) case of running ``cctally setup`` *from* an
|
|
432
|
+
npm/brew install correctly — a symlink at the same install root
|
|
433
|
+
is NOT foreign, so it's left to the active-symlinks loop in
|
|
434
|
+
:func:`_setup_install` / :func:`_setup_uninstall`.
|
|
435
|
+
|
|
436
|
+
Targets that exist but live outside any recognized install root
|
|
437
|
+
(e.g. a maintainer's manual link to ``<checkout>/bin/cctally-release``
|
|
438
|
+
or a hand-rolled link at ``~/scripts/cctally-release``) are
|
|
439
|
+
preserved — they're either explicit operator setups or genuine
|
|
440
|
+
user-managed scripts that happen to share the name.
|
|
441
|
+
|
|
442
|
+
Shared by the cleanup site (``_setup_cleanup_stale_symlinks``) and
|
|
443
|
+
the read-only detection site (``_setup_detect_stale_symlinks``) so
|
|
444
|
+
``--status`` and ``setup`` agree on what they call "stale".
|
|
445
|
+
"""
|
|
446
|
+
if not dst.is_symlink():
|
|
447
|
+
return False
|
|
448
|
+
try:
|
|
449
|
+
target = os.readlink(dst)
|
|
450
|
+
except OSError:
|
|
451
|
+
return False
|
|
452
|
+
if pathlib.Path(target).name != name:
|
|
453
|
+
# User-managed symlink that happens to share the name; leave alone.
|
|
454
|
+
return False
|
|
455
|
+
# Resolve target relative to the symlink's parent so relative
|
|
456
|
+
# readlinks (rare for cctally setup-installed links, but possible
|
|
457
|
+
# for hand-rolled ones) classify correctly. Use lexists()-style
|
|
458
|
+
# check via Path.exists() — broken links return False, which is
|
|
459
|
+
# the "dangling, treat as stale" branch we want.
|
|
460
|
+
target_path = pathlib.Path(target)
|
|
461
|
+
if not target_path.is_absolute():
|
|
462
|
+
target_path = dst.parent / target_path
|
|
463
|
+
try:
|
|
464
|
+
target_exists = target_path.exists()
|
|
465
|
+
except OSError:
|
|
466
|
+
# Permission errors etc. — be conservative and don't remove.
|
|
467
|
+
return False
|
|
468
|
+
if not target_exists:
|
|
469
|
+
return True
|
|
470
|
+
# Target exists — retire only if it lives under a *foreign* install
|
|
471
|
+
# root (a different brew keg / npm module dir than the one running
|
|
472
|
+
# this setup). Compare against ``repo_root`` so a setup running
|
|
473
|
+
# *from* an npm/brew install doesn't classify its own siblings as
|
|
474
|
+
# foreign — that's the active-symlinks loop's job.
|
|
475
|
+
target_str = str(target_path)
|
|
476
|
+
repo_root_str = str(repo_root)
|
|
477
|
+
for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
|
|
478
|
+
if token in target_str and token not in repo_root_str:
|
|
479
|
+
return True
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
|
|
329
483
|
def _setup_path_includes_local_bin() -> bool:
|
|
330
484
|
local_bin = str(_setup_local_bin_dir())
|
|
331
485
|
return local_bin in os.environ.get("PATH", "").split(os.pathsep)
|
|
@@ -816,12 +970,38 @@ def _setup_compute_symlink_state(
|
|
|
816
970
|
return out
|
|
817
971
|
|
|
818
972
|
|
|
973
|
+
def _setup_detect_stale_symlinks(dst_dir: pathlib.Path) -> list[str]:
|
|
974
|
+
"""Return names of stale-but-still-present cctally symlinks in ``dst_dir``.
|
|
975
|
+
|
|
976
|
+
Mirrors :func:`_setup_cleanup_stale_symlinks` detection (without
|
|
977
|
+
removing): a symlink is "stale" when its name appears in
|
|
978
|
+
:data:`_SETUP_STALE_SYMLINK_NAMES`, its readlink target basename
|
|
979
|
+
matches that name, AND the target is "retired" — either dangling
|
|
980
|
+
OR pointing at a foreign cctally install root (see
|
|
981
|
+
:func:`_setup_symlink_is_retired`). Routing through the same
|
|
982
|
+
predicate keeps ``--status`` and ``setup`` in lockstep so a
|
|
983
|
+
manually-maintained link to a still-shipped retired tool
|
|
984
|
+
(``~/.local/bin/cctally-release -> <checkout>/bin/cctally-release``)
|
|
985
|
+
is neither reported as stale nor removed, while a legacy link left
|
|
986
|
+
over from a pre-v1.9.0 brew/npm install (target still resolves into
|
|
987
|
+
the old keg / ``node_modules`` tree) IS retired.
|
|
988
|
+
"""
|
|
989
|
+
found: list[str] = []
|
|
990
|
+
repo_root = _setup_resolve_repo_root()
|
|
991
|
+
for name in _SETUP_STALE_SYMLINK_NAMES:
|
|
992
|
+
dst = dst_dir / name
|
|
993
|
+
if _setup_symlink_is_retired(dst, name, repo_root):
|
|
994
|
+
found.append(name)
|
|
995
|
+
return found
|
|
996
|
+
|
|
997
|
+
|
|
819
998
|
def _setup_status(args: argparse.Namespace) -> int:
|
|
820
999
|
c = _cctally()
|
|
821
1000
|
repo_root = _setup_resolve_repo_root()
|
|
822
1001
|
dst_dir = _setup_local_bin_dir()
|
|
823
1002
|
sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
|
|
824
1003
|
sym_ok = sum(1 for _, s in sym_state if s == "ok")
|
|
1004
|
+
stale_syms = _setup_detect_stale_symlinks(dst_dir)
|
|
825
1005
|
on_path = _setup_path_includes_local_bin()
|
|
826
1006
|
try:
|
|
827
1007
|
settings = c._load_claude_settings()
|
|
@@ -842,6 +1022,7 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
842
1022
|
"install": {
|
|
843
1023
|
"symlinks_present": sym_ok,
|
|
844
1024
|
"symlinks_total": len(c.SETUP_SYMLINK_NAMES),
|
|
1025
|
+
"symlinks_stale": stale_syms,
|
|
845
1026
|
"path_includes": on_path,
|
|
846
1027
|
},
|
|
847
1028
|
"hooks": {ev: hook_counts[ev] for ev in c.SETUP_HOOK_EVENTS},
|
|
@@ -869,6 +1050,11 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
869
1050
|
out.append("Install")
|
|
870
1051
|
sym_marker = "✓" if sym_ok == len(c.SETUP_SYMLINK_NAMES) else "✗"
|
|
871
1052
|
out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} present at {dst_dir}/ {sym_marker}")
|
|
1053
|
+
if stale_syms:
|
|
1054
|
+
out.append(
|
|
1055
|
+
f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
|
|
1056
|
+
)
|
|
1057
|
+
out.append(" run `cctally setup` to remove")
|
|
872
1058
|
out.append(f" PATH includes {'yes' if on_path else 'no'} "
|
|
873
1059
|
f"{'✓' if on_path else '⚠'}")
|
|
874
1060
|
out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
|
|
@@ -950,6 +1136,50 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
950
1136
|
sym_removed += 1
|
|
951
1137
|
except OSError as exc:
|
|
952
1138
|
eprint(f"setup: failed to remove {dst}: {exc}")
|
|
1139
|
+
# Also clean up legacy symlinks that older cctally versions used to
|
|
1140
|
+
# install but the current version no longer manages (see
|
|
1141
|
+
# :data:`_SETUP_STALE_SYMLINK_NAMES`). Without this loop, an
|
|
1142
|
+
# upgrader who ran ``cctally setup`` on a prior version and now runs
|
|
1143
|
+
# ``cctally setup --uninstall`` would keep e.g.
|
|
1144
|
+
# ``~/.local/bin/cctally-release`` on PATH.
|
|
1145
|
+
#
|
|
1146
|
+
# Two removal predicates compose here:
|
|
1147
|
+
# 1. ``target == _setup_resolve_symlink_source(repo_root, name)``
|
|
1148
|
+
# — the legacy auto-installed link points at *this* install
|
|
1149
|
+
# root's binary (the common case for git-checkout upgraders
|
|
1150
|
+
# who ran ``cctally setup`` on a prior version, then ``git
|
|
1151
|
+
# pull``ed; symmetric with how the active-symlink loop above
|
|
1152
|
+
# treats user-pointed `cctally` itself).
|
|
1153
|
+
# 2. ``_setup_symlink_is_retired`` — target is dangling OR lives
|
|
1154
|
+
# under a *foreign* cctally install root (old brew keg /
|
|
1155
|
+
# ``node_modules/cctally/`` left behind by ``brew upgrade``
|
|
1156
|
+
# or ``npm i -g cctally@<new>``). Without this, the common
|
|
1157
|
+
# brew/npm upgrade-then-uninstall path leaves the legacy
|
|
1158
|
+
# symlink in place because the prior keg's binary still
|
|
1159
|
+
# exists on disk (``target != expected``).
|
|
1160
|
+
#
|
|
1161
|
+
# A maintainer's hand-rolled link pointing somewhere unrelated
|
|
1162
|
+
# (``~/scripts/...``) is preserved by both predicates.
|
|
1163
|
+
for name in _SETUP_STALE_SYMLINK_NAMES:
|
|
1164
|
+
dst = dst_dir / name
|
|
1165
|
+
if not dst.is_symlink():
|
|
1166
|
+
continue
|
|
1167
|
+
try:
|
|
1168
|
+
target = pathlib.Path(os.readlink(dst))
|
|
1169
|
+
except OSError:
|
|
1170
|
+
continue
|
|
1171
|
+
expected = _setup_resolve_symlink_source(repo_root, name)
|
|
1172
|
+
should_remove = (
|
|
1173
|
+
target == expected
|
|
1174
|
+
or _setup_symlink_is_retired(dst, name, repo_root)
|
|
1175
|
+
)
|
|
1176
|
+
if not should_remove:
|
|
1177
|
+
continue
|
|
1178
|
+
try:
|
|
1179
|
+
dst.unlink()
|
|
1180
|
+
sym_removed += 1
|
|
1181
|
+
except OSError as exc:
|
|
1182
|
+
eprint(f"setup: failed to remove {dst}: {exc}")
|
|
953
1183
|
out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
|
|
954
1184
|
|
|
955
1185
|
legacy = _setup_detect_legacy_snippet()
|
|
@@ -1336,6 +1566,13 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1336
1566
|
eprint(f"setup: {exc}")
|
|
1337
1567
|
return 1
|
|
1338
1568
|
|
|
1569
|
+
# Clean up symlinks left behind by prior cctally versions whose
|
|
1570
|
+
# subcommand surface has changed (e.g. v1.9.0 retired
|
|
1571
|
+
# `cctally-release` when release tooling went private). Only unlinks
|
|
1572
|
+
# symlinks whose target's basename matches the stale name; never
|
|
1573
|
+
# disturbs a user's hand-rolled symlink sharing the name.
|
|
1574
|
+
stale_results = _setup_cleanup_stale_symlinks(dst_dir)
|
|
1575
|
+
|
|
1339
1576
|
sym_results = _setup_create_symlinks(repo_root, dst_dir)
|
|
1340
1577
|
failed = [r for r in sym_results if r.status == "failed"]
|
|
1341
1578
|
if failed:
|
|
@@ -1354,6 +1591,16 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1354
1591
|
detail_parts.append(f"{repl_count} re-pointed")
|
|
1355
1592
|
detail = ", ".join(detail_parts) or "no changes"
|
|
1356
1593
|
out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
|
|
1594
|
+
removed_stale = [r for r in stale_results if r.status == "removed-stale"]
|
|
1595
|
+
failed_stale = [r for r in stale_results if r.status == "failed"]
|
|
1596
|
+
if removed_stale:
|
|
1597
|
+
out.append(
|
|
1598
|
+
"✓ Cleaned up stale symlink(s) from prior version: "
|
|
1599
|
+
+ ", ".join(r.name for r in removed_stale)
|
|
1600
|
+
)
|
|
1601
|
+
for r in failed_stale:
|
|
1602
|
+
out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
|
|
1603
|
+
warnings += 1
|
|
1357
1604
|
|
|
1358
1605
|
if not _setup_path_includes_local_bin():
|
|
1359
1606
|
warnings += 1
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -329,6 +329,10 @@ def _dashboard_build_blocks_panel(*args, **kwargs):
|
|
|
329
329
|
return sys.modules["cctally"]._dashboard_build_blocks_panel(*args, **kwargs)
|
|
330
330
|
|
|
331
331
|
|
|
332
|
+
def _dashboard_build_blocks_view(*args, **kwargs):
|
|
333
|
+
return sys.modules["cctally"]._dashboard_build_blocks_view(*args, **kwargs)
|
|
334
|
+
|
|
335
|
+
|
|
332
336
|
def _dashboard_build_daily_panel(*args, **kwargs):
|
|
333
337
|
return sys.modules["cctally"]._dashboard_build_daily_panel(*args, **kwargs)
|
|
334
338
|
|
|
@@ -822,27 +826,15 @@ from _lib_view_models import ( # noqa: E402
|
|
|
822
826
|
)
|
|
823
827
|
|
|
824
828
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
"""
|
|
835
|
-
start_at: str # ISO-8601 UTC
|
|
836
|
-
end_at: str # ISO-8601 UTC, start_at + 5h
|
|
837
|
-
anchor: str # 'recorded' | 'heuristic'
|
|
838
|
-
is_active: bool # now_utc < end_at AND entries_count > 0
|
|
839
|
-
cost_usd: float
|
|
840
|
-
models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
|
|
841
|
-
label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
# DailyPanelRow + TuiSessionRow moved to bin/_lib_view_models.py — re-export.
|
|
845
|
-
from _lib_view_models import DailyPanelRow, TuiSessionRow # noqa: E402
|
|
829
|
+
# BlocksPanelRow + DailyPanelRow + TuiSessionRow moved to
|
|
830
|
+
# bin/_lib_view_models.py — re-exported here so historical
|
|
831
|
+
# ``from _cctally_tui import BlocksPanelRow`` (or ``ns["BlocksPanelRow"]``
|
|
832
|
+
# direct-dict reads in tests) keep resolving.
|
|
833
|
+
from _lib_view_models import ( # noqa: E402
|
|
834
|
+
BlocksPanelRow,
|
|
835
|
+
DailyPanelRow,
|
|
836
|
+
TuiSessionRow,
|
|
837
|
+
)
|
|
846
838
|
|
|
847
839
|
|
|
848
840
|
@dataclass
|
|
@@ -1043,7 +1035,32 @@ class DataSnapshot:
|
|
|
1043
1035
|
monthly_total_tokens: int = 0
|
|
1044
1036
|
weekly_total_cost_usd: float = 0.0
|
|
1045
1037
|
weekly_total_tokens: int = 0
|
|
1038
|
+
# Blocks domain (issue #56). ``BlocksPanelRow`` doesn't carry token
|
|
1039
|
+
# columns, so the cost total alone preserves the structural
|
|
1040
|
+
# ``total === sum(visible rows).cost_usd`` invariant; ``blocks_total_tokens``
|
|
1041
|
+
# is sourced from the same ``BlocksView`` build so both scalars
|
|
1042
|
+
# come from a single typed pass.
|
|
1043
|
+
blocks_total_cost_usd: float = 0.0
|
|
1044
|
+
blocks_total_tokens: int = 0
|
|
1046
1045
|
trend_avg_dollars_per_pct: float | None = None
|
|
1046
|
+
# Trend modal median (issue #59). Sourced from
|
|
1047
|
+
# ``build_trend_view``'s ``median_dpp_non_current_4w`` field — the
|
|
1048
|
+
# last-4-non-current dpp median TrendModal.tsx used to compute
|
|
1049
|
+
# client-side. Populated by the sync thread off the 12-row history
|
|
1050
|
+
# build (NOT the 8-row panel build); the dashboard envelope adapter
|
|
1051
|
+
# emits this as ``trend.history_median_dpp``. ``None`` for fixture
|
|
1052
|
+
# modules that construct ``DataSnapshot`` positionally without
|
|
1053
|
+
# going through ``_tui_build_snapshot``; the React modal keeps a
|
|
1054
|
+
# client-side fallback for that case.
|
|
1055
|
+
trend_history_median_dpp: float | None = None
|
|
1056
|
+
# Forecast domain (issue #57). ``ForecastView`` wraps
|
|
1057
|
+
# ``ForecastOutput`` and surfaces the per-method projection /
|
|
1058
|
+
# verdict / header-routing / budget fields the dashboard envelope
|
|
1059
|
+
# adapter used to re-derive inline. Field is ``None`` for fixture
|
|
1060
|
+
# modules that construct ``DataSnapshot`` directly without going
|
|
1061
|
+
# through ``_tui_build_snapshot``; the envelope adapter falls
|
|
1062
|
+
# back to the legacy inline routing in that case.
|
|
1063
|
+
forecast_view: Any | None = None
|
|
1047
1064
|
|
|
1048
1065
|
@classmethod
|
|
1049
1066
|
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
@@ -1426,11 +1443,32 @@ def _tui_build_forecast(
|
|
|
1426
1443
|
*,
|
|
1427
1444
|
skip_sync: bool = False,
|
|
1428
1445
|
):
|
|
1429
|
-
"""
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1446
|
+
"""Build the TUI/dashboard sync-thread forecast.
|
|
1447
|
+
|
|
1448
|
+
Issue #57: routes through ``build_forecast_view`` (the kernel-pattern
|
|
1449
|
+
wrapper) and unwraps to a ``ForecastOutput`` for backward-compat with
|
|
1450
|
+
every existing ``snap.forecast`` consumer (TUI panels, envelope
|
|
1451
|
+
adapter, share builder). Use ``_tui_build_forecast_view`` when the
|
|
1452
|
+
full view is needed (e.g. ``snap.forecast_view`` population).
|
|
1453
|
+
"""
|
|
1454
|
+
view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
|
|
1455
|
+
return view.output if view is not None else None
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _tui_build_forecast_view(
|
|
1459
|
+
conn: sqlite3.Connection,
|
|
1460
|
+
now_utc: dt.datetime,
|
|
1461
|
+
*,
|
|
1462
|
+
skip_sync: bool = False,
|
|
1463
|
+
):
|
|
1464
|
+
"""Build the ``ForecastView`` (issue #57). Returns ``None`` only on
|
|
1465
|
+
error in callers — the empty-state View is constructed by the
|
|
1466
|
+
builder itself with ``output=None`` + ``verdict="LOW CONF"``.
|
|
1467
|
+
"""
|
|
1468
|
+
c = _cctally()
|
|
1469
|
+
return c.build_forecast_view(
|
|
1470
|
+
conn, now_utc=now_utc, targets=(100, 90), skip_sync=skip_sync,
|
|
1471
|
+
)
|
|
1434
1472
|
|
|
1435
1473
|
|
|
1436
1474
|
def _tui_build_trend(
|
|
@@ -1470,9 +1508,46 @@ def _tui_build_weekly_history(
|
|
|
1470
1508
|
than parameterising the call site keeps the snapshot fields
|
|
1471
1509
|
semantically distinct (panel data vs. modal data) and avoids
|
|
1472
1510
|
accidental cross-contamination.
|
|
1511
|
+
|
|
1512
|
+
Issue #59: list-returning shim kept for back-compat with the
|
|
1513
|
+
public re-export at ``bin/cctally:13871``. New callers (the sync
|
|
1514
|
+
thread populating ``snap.weekly_history`` + the modal-median
|
|
1515
|
+
scalar) should prefer ``_tui_build_weekly_history_view`` so they
|
|
1516
|
+
pick up the pre-computed ``median_dpp_non_current_4w`` scalar
|
|
1517
|
+
without re-deriving.
|
|
1518
|
+
"""
|
|
1519
|
+
return list(
|
|
1520
|
+
_tui_build_weekly_history_view(
|
|
1521
|
+
conn, now_utc, skip_sync=skip_sync, count=count,
|
|
1522
|
+
display_tz=display_tz,
|
|
1523
|
+
).rows
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _tui_build_weekly_history_view(
|
|
1528
|
+
conn: sqlite3.Connection,
|
|
1529
|
+
now_utc: dt.datetime,
|
|
1530
|
+
*,
|
|
1531
|
+
skip_sync: bool = False, # noqa: ARG001 — unused today, kept for API symmetry
|
|
1532
|
+
count: int = 12,
|
|
1533
|
+
display_tz: "ZoneInfo | None" = None,
|
|
1534
|
+
):
|
|
1535
|
+
"""Build the full ``TrendView`` for the dashboard Trend modal
|
|
1536
|
+
(issue #59).
|
|
1537
|
+
|
|
1538
|
+
Wraps ``build_trend_view`` with the 12-row default the modal
|
|
1539
|
+
consumes. The returned ``TrendView`` carries the
|
|
1540
|
+
``median_dpp_non_current_4w`` pre-computed scalar so the sync
|
|
1541
|
+
thread can populate ``DataSnapshot.trend_history_median_dpp``
|
|
1542
|
+
without re-running the median derivation client-side. The 8-row
|
|
1543
|
+
panel call (``_tui_build_trend``) goes through the same
|
|
1544
|
+
``build_trend_view`` kernel; both builds carry their own median
|
|
1545
|
+
field but only the 12-row build's value reaches the envelope
|
|
1546
|
+
(``trend.history_median_dpp``).
|
|
1473
1547
|
"""
|
|
1474
|
-
|
|
1475
|
-
|
|
1548
|
+
c = _cctally()
|
|
1549
|
+
return c.build_trend_view(
|
|
1550
|
+
conn, now_utc=now_utc, n=max(1, count), display_tz=display_tz,
|
|
1476
1551
|
)
|
|
1477
1552
|
|
|
1478
1553
|
|
|
@@ -1651,8 +1726,14 @@ def _tui_build_snapshot(
|
|
|
1651
1726
|
cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
|
|
1652
1727
|
except Exception as exc:
|
|
1653
1728
|
errors.append(f"current-week: {exc}")
|
|
1729
|
+
fc_view = None
|
|
1654
1730
|
try:
|
|
1655
|
-
|
|
1731
|
+
# Issue #57: build the ForecastView once so we capture both
|
|
1732
|
+
# the legacy ``ForecastOutput`` (for ``snap.forecast``, which
|
|
1733
|
+
# the many TUI panel consumers still read) and the surface
|
|
1734
|
+
# fields the envelope adapter used to re-derive inline.
|
|
1735
|
+
fc_view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
|
|
1736
|
+
fc = fc_view.output if fc_view is not None else None
|
|
1656
1737
|
except Exception as exc:
|
|
1657
1738
|
errors.append(f"forecast: {exc}")
|
|
1658
1739
|
# Trend: source from build_trend_view so we capture the 3-sample
|
|
@@ -1685,10 +1766,19 @@ def _tui_build_snapshot(
|
|
|
1685
1766
|
milestones = _tui_build_percent_milestones(conn)
|
|
1686
1767
|
except Exception as exc:
|
|
1687
1768
|
errors.append(f"milestones: {exc}")
|
|
1769
|
+
history: list = []
|
|
1770
|
+
history_median_dpp: "float | None" = None
|
|
1688
1771
|
try:
|
|
1689
|
-
|
|
1772
|
+
# Issue #59: build the full TrendView so we capture the
|
|
1773
|
+
# pre-computed 4-week-median-non-current scalar alongside
|
|
1774
|
+
# the row list; the dashboard envelope adapter surfaces
|
|
1775
|
+
# the scalar as ``trend.history_median_dpp`` so
|
|
1776
|
+
# TrendModal.tsx stops re-deriving it client-side.
|
|
1777
|
+
history_view = _tui_build_weekly_history_view(
|
|
1690
1778
|
conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
|
|
1691
1779
|
)
|
|
1780
|
+
history = list(history_view.rows)
|
|
1781
|
+
history_median_dpp = history_view.median_dpp_non_current_4w
|
|
1692
1782
|
except Exception as exc:
|
|
1693
1783
|
errors.append(f"weekly-history: {exc}")
|
|
1694
1784
|
# ---- v2.1 additions: dashboard Weekly / Monthly panels ----
|
|
@@ -1742,15 +1832,25 @@ def _tui_build_snapshot(
|
|
|
1742
1832
|
except Exception as exc:
|
|
1743
1833
|
errors.append(f"monthly-periods: {exc}")
|
|
1744
1834
|
# ---- v2.2 additions: dashboard Blocks / Daily panels ----
|
|
1835
|
+
# Issue #56: build the BlocksView once and read both rows
|
|
1836
|
+
# (presentation) and totals (envelope scalars) from the same
|
|
1837
|
+
# pass. ``_dashboard_build_blocks_view`` is the view-returning
|
|
1838
|
+
# counterpart to ``_dashboard_build_blocks_panel`` (which is
|
|
1839
|
+
# kept as a thin shim for monkeypatch surfaces).
|
|
1840
|
+
blocks_total_cost_usd = 0.0
|
|
1841
|
+
blocks_total_tokens = 0
|
|
1745
1842
|
try:
|
|
1746
1843
|
if cw is not None:
|
|
1747
|
-
|
|
1844
|
+
_blocks_view = _dashboard_build_blocks_view(
|
|
1748
1845
|
conn, now_utc,
|
|
1749
1846
|
week_start_at=cw.week_start_at,
|
|
1750
1847
|
week_end_at=cw.week_end_at,
|
|
1751
1848
|
skip_sync=skip_sync,
|
|
1752
1849
|
display_tz=_build_display_tz,
|
|
1753
1850
|
)
|
|
1851
|
+
blocks_panel = list(_blocks_view.rows)
|
|
1852
|
+
blocks_total_cost_usd = _blocks_view.total_cost_usd
|
|
1853
|
+
blocks_total_tokens = _blocks_view.total_tokens
|
|
1754
1854
|
except Exception as exc:
|
|
1755
1855
|
errors.append(f"blocks-panel: {exc}")
|
|
1756
1856
|
# Sync-thread view-model totals (Bundle 1 / spec §6.6):
|
|
@@ -1818,7 +1918,11 @@ def _tui_build_snapshot(
|
|
|
1818
1918
|
monthly_total_tokens=monthly_total_tokens,
|
|
1819
1919
|
weekly_total_cost_usd=weekly_total_cost_usd,
|
|
1820
1920
|
weekly_total_tokens=weekly_total_tokens,
|
|
1921
|
+
blocks_total_cost_usd=blocks_total_cost_usd,
|
|
1922
|
+
blocks_total_tokens=blocks_total_tokens,
|
|
1821
1923
|
trend_avg_dollars_per_pct=trend_avg_dpp,
|
|
1924
|
+
trend_history_median_dpp=history_median_dpp,
|
|
1925
|
+
forecast_view=fc_view,
|
|
1822
1926
|
)
|
|
1823
1927
|
finally:
|
|
1824
1928
|
conn.close()
|
|
@@ -2218,6 +2322,16 @@ class _TuiSyncThread:
|
|
|
2218
2322
|
self._ref.set(snap)
|
|
2219
2323
|
except Exception as exc:
|
|
2220
2324
|
# Don't crash the thread on unexpected errors — surface in UI.
|
|
2325
|
+
# Carry every additive view-model scalar through verbatim so
|
|
2326
|
+
# the prior frame's panel rows and their envelope totals stay
|
|
2327
|
+
# consistent. Bundle 1 / #56 / #57 / #59 each added envelope
|
|
2328
|
+
# scalars the React panels now trust over a client-side
|
|
2329
|
+
# ``rows.reduce``; without preserving them here, a sync crash
|
|
2330
|
+
# leaves populated rows next to a ``$0.00`` footer (the
|
|
2331
|
+
# dataclass defaults kick in for any field not explicitly
|
|
2332
|
+
# passed). The structural-equality invariant
|
|
2333
|
+
# ``total === sum(visible rows).cost_usd`` must survive a
|
|
2334
|
+
# crash recovery, not just the happy path.
|
|
2221
2335
|
prev = self._ref.get()
|
|
2222
2336
|
self._ref.set(DataSnapshot(
|
|
2223
2337
|
current_week=prev.current_week,
|
|
@@ -2233,6 +2347,17 @@ class _TuiSyncThread:
|
|
|
2233
2347
|
monthly_periods=prev.monthly_periods,
|
|
2234
2348
|
blocks_panel=prev.blocks_panel,
|
|
2235
2349
|
daily_panel=prev.daily_panel,
|
|
2350
|
+
daily_total_cost_usd=prev.daily_total_cost_usd,
|
|
2351
|
+
daily_total_tokens=prev.daily_total_tokens,
|
|
2352
|
+
monthly_total_cost_usd=prev.monthly_total_cost_usd,
|
|
2353
|
+
monthly_total_tokens=prev.monthly_total_tokens,
|
|
2354
|
+
weekly_total_cost_usd=prev.weekly_total_cost_usd,
|
|
2355
|
+
weekly_total_tokens=prev.weekly_total_tokens,
|
|
2356
|
+
blocks_total_cost_usd=prev.blocks_total_cost_usd,
|
|
2357
|
+
blocks_total_tokens=prev.blocks_total_tokens,
|
|
2358
|
+
trend_avg_dollars_per_pct=prev.trend_avg_dollars_per_pct,
|
|
2359
|
+
trend_history_median_dpp=prev.trend_history_median_dpp,
|
|
2360
|
+
forecast_view=prev.forecast_view,
|
|
2236
2361
|
))
|
|
2237
2362
|
# Wait up to interval, or until forced.
|
|
2238
2363
|
for _ in range(int(max(1, self._interval * 10))):
|
package/bin/_cctally_update.py
CHANGED
|
@@ -116,10 +116,16 @@ What stays in bin/cctally:
|
|
|
116
116
|
write surface in cmd_dashboard works unchanged; moved code
|
|
117
117
|
reads via ``c.X``.
|
|
118
118
|
- ``eprint``, ``_now_utc`` (used by moved code via shim/accessor),
|
|
119
|
-
``_release_read_latest_release_version`` (
|
|
120
|
-
|
|
119
|
+
``_release_read_latest_release_version`` (impl moved to
|
|
120
|
+
``_lib_changelog._read_latest_changelog_version``; cctally
|
|
121
|
+
re-exports the historical name. The shim below routes through
|
|
122
|
+
``sys.modules['cctally']`` so test ``monkeypatch.setitem(ns, ...)``
|
|
123
|
+
on the historical name still overrides the 5 internal callers
|
|
124
|
+
here),
|
|
121
125
|
``_release_parse_semver`` / ``_release_semver_sort_key`` (lives
|
|
122
|
-
in ``_lib_semver
|
|
126
|
+
in ``_lib_semver``; shims below route directly to that module —
|
|
127
|
+
the cctally re-exports were removed in the release-command split
|
|
128
|
+
privatization),
|
|
123
129
|
``load_config`` (lives in ``_cctally_config``; re-exported),
|
|
124
130
|
``_BANNER_SUPPRESSED_COMMANDS`` (lives in ``_cctally_db``;
|
|
125
131
|
re-exported by cctally — composed with the update-only
|
|
@@ -210,17 +216,35 @@ def load_config(*args, **kwargs):
|
|
|
210
216
|
|
|
211
217
|
|
|
212
218
|
def _release_read_latest_release_version(*args, **kwargs):
|
|
219
|
+
"""Back-compat shim. The implementation now lives in
|
|
220
|
+
``bin/_lib_changelog._read_latest_changelog_version``; ``bin/cctally``
|
|
221
|
+
re-exports it under the historical name. This shim routes through
|
|
222
|
+
the ``cctally`` namespace (not directly to ``_lib_changelog``) so
|
|
223
|
+
that ``monkeypatch.setitem(ns, "_release_read_latest_release_version", ...)``
|
|
224
|
+
in tests/test_update.py + tests/test_release_internals.py overrides
|
|
225
|
+
the 5+ internal callers in this module. New code should call
|
|
226
|
+
``_lib_changelog._read_latest_changelog_version`` directly.
|
|
227
|
+
"""
|
|
213
228
|
return sys.modules["cctally"]._release_read_latest_release_version(
|
|
214
229
|
*args, **kwargs
|
|
215
230
|
)
|
|
216
231
|
|
|
217
232
|
|
|
233
|
+
# The semver shims now resolve via ``_lib_semver`` directly — the
|
|
234
|
+
# cctally re-exports (``_release_parse_semver`` / ``_release_semver_sort_key``)
|
|
235
|
+
# were removed in the release-command split privatization. Kept as
|
|
236
|
+
# call-time shims (vs. module-top ``from _lib_semver import ...``) so
|
|
237
|
+
# tests can still ``monkeypatch.setitem(ns, "_release_parse_semver", ...)``
|
|
238
|
+
# on this module's namespace if needed; the bare-name lookup inside this
|
|
239
|
+
# module resolves here, not at the import site.
|
|
218
240
|
def _release_parse_semver(*args, **kwargs):
|
|
219
|
-
|
|
241
|
+
import _lib_semver
|
|
242
|
+
return _lib_semver._release_parse_semver(*args, **kwargs)
|
|
220
243
|
|
|
221
244
|
|
|
222
245
|
def _release_semver_sort_key(*args, **kwargs):
|
|
223
|
-
|
|
246
|
+
import _lib_semver
|
|
247
|
+
return _lib_semver._release_semver_sort_key(*args, **kwargs)
|
|
224
248
|
|
|
225
249
|
|
|
226
250
|
def _normalize_alerts_enabled_value(*args, **kwargs):
|