cctally 1.99.1 → 1.101.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 +88 -0
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +2083 -356
- package/bin/_cctally_dashboard_envelope.py +115 -31
- package/bin/_cctally_dashboard_perf.py +433 -0
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +1072 -214
- package/bin/_cctally_db.py +30 -14
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_doctor.py +1331 -1148
- package/bin/_cctally_forecast.py +304 -96
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +70 -1
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +40 -21
- package/bin/_cctally_record.py +44 -3
- package/bin/_cctally_refresh.py +60 -10
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +53 -7
- package/bin/_cctally_tui.py +723 -232
- package/bin/_cctally_update.py +28 -22
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_dashboard_sources.py +237 -30
- package/bin/_lib_doctor.py +37 -0
- package/bin/_lib_forecast.py +12 -4
- package/bin/_lib_jsonl.py +4 -2
- package/bin/_lib_perf.py +132 -3
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_analytics.py +2 -2
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/_lib_tick_stats.py +538 -0
- package/bin/cctally +29 -7
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
- package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C5NBB2w9.js +0 -97
- package/dashboard/static/assets/index-hJP4wlIO.css +0 -1
package/bin/_cctally_project.py
CHANGED
|
@@ -24,6 +24,7 @@ import bisect
|
|
|
24
24
|
import datetime as dt
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
|
+
import sqlite3
|
|
27
28
|
import sys
|
|
28
29
|
|
|
29
30
|
from _cctally_core import (
|
|
@@ -35,6 +36,9 @@ from _cctally_core import (
|
|
|
35
36
|
)
|
|
36
37
|
from _lib_fmt import stable_sum
|
|
37
38
|
|
|
39
|
+
# #620 S1 D11: the one affordance shape every warning state renders.
|
|
40
|
+
import _lib_alert_scope
|
|
41
|
+
|
|
38
42
|
|
|
39
43
|
def _cctally():
|
|
40
44
|
"""Resolve the current `cctally` module at call-time (spec §3.1)."""
|
|
@@ -42,7 +46,8 @@ def _cctally():
|
|
|
42
46
|
|
|
43
47
|
|
|
44
48
|
def _load_week_snapshots(
|
|
45
|
-
since: dt.datetime, until: dt.datetime
|
|
49
|
+
since: dt.datetime, until: dt.datetime, *,
|
|
50
|
+
account_key: "str | None" = None,
|
|
46
51
|
) -> dict[dt.datetime, float]:
|
|
47
52
|
"""Return {week_start_utc -> max(weekly_percent)} for weeks intersecting
|
|
48
53
|
the [since, until] range.
|
|
@@ -68,20 +73,43 @@ def _load_week_snapshots(
|
|
|
68
73
|
helper apply (spec §4a, test S15). The floor compare uses `unixepoch()` on
|
|
69
74
|
both sides (mixed `Z` / `+00:00` offset spellings).
|
|
70
75
|
|
|
76
|
+
``account_key`` (#620 S1): scopes the read to one account's snapshots.
|
|
77
|
+
``None`` — the default and every unfiltered invocation — is the merged
|
|
78
|
+
all-accounts read, byte-identical to before. A real key is required under
|
|
79
|
+
``--account``: two accounts hold independent weekly quotas, so summing
|
|
80
|
+
another account's percentage into an account-filtered total reports a
|
|
81
|
+
figure that describes neither account. Percentages across accounts are
|
|
82
|
+
never summed for the same reason the dashboard never sums them.
|
|
83
|
+
|
|
71
84
|
Returns an empty dict if the stats DB has no relevant rows.
|
|
72
85
|
"""
|
|
86
|
+
acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
87
|
+
acct_params: tuple = () if account_key is None else (account_key,)
|
|
73
88
|
conn = open_db()
|
|
74
89
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
try:
|
|
91
|
+
cur = conn.execute(
|
|
92
|
+
"SELECT week_start_date, week_start_at, week_end_at, "
|
|
93
|
+
" captured_at_utc, weekly_percent "
|
|
94
|
+
"FROM weekly_usage_snapshots "
|
|
95
|
+
"WHERE week_start_at IS NOT NULL "
|
|
96
|
+
"AND week_end_at IS NOT NULL "
|
|
97
|
+
"AND datetime(week_start_at) < datetime(?) "
|
|
98
|
+
"AND datetime(week_end_at) > datetime(?)"
|
|
99
|
+
f"{acct_pred}",
|
|
100
|
+
(until.isoformat(), since.isoformat(), *acct_params),
|
|
101
|
+
)
|
|
102
|
+
except sqlite3.OperationalError:
|
|
103
|
+
# A stats.db predating the `account_key` column — an install
|
|
104
|
+
# whose migrations have not run, or a dev binary that refused
|
|
105
|
+
# the prod forward-migration (#142). `_projects_week_grid`
|
|
106
|
+
# carries the same guard for the same predicate.
|
|
107
|
+
#
|
|
108
|
+
# Withholding is the degradation, not dropping the predicate:
|
|
109
|
+
# retrying unscoped would sum another account's weekly
|
|
110
|
+
# percentage into an account-filtered total, which is the
|
|
111
|
+
# figure describing neither account that #620 S1 removed.
|
|
112
|
+
return {}
|
|
85
113
|
rows_in = []
|
|
86
114
|
for wsd, ws_iso, we_iso, cap_iso, pct in cur.fetchall():
|
|
87
115
|
if ws_iso is None or pct is None:
|
|
@@ -402,46 +430,115 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
402
430
|
now = _command_as_of()
|
|
403
431
|
conn = open_db()
|
|
404
432
|
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
433
|
+
# #341 --account: resolve the render filter (provider=claude; fail closed
|
|
434
|
+
# with exit 3 when the entry cache is unavailable). None = merged.
|
|
435
|
+
#
|
|
436
|
+
# #620 S1 D1: resolved BEFORE the subscription-week walk below, not after
|
|
437
|
+
# it. `_compute_subscription_weeks` takes an account context precisely so
|
|
438
|
+
# one account's resets cannot re-anchor another's week walk, and passing
|
|
439
|
+
# `None` while `--account` was in force bucketed one account's dollars
|
|
440
|
+
# against the merged boundary set — which, when two accounts reset on
|
|
441
|
+
# different weekdays, is neither account's. An invocation WITHOUT
|
|
442
|
+
# `--account` still passes `None` and keeps today's merged boundaries.
|
|
443
|
+
# Argument VALIDATION runs first. Task 4 moved `resolve_account_filter`
|
|
444
|
+
# ahead of the range resolution because `_compute_subscription_weeks`
|
|
445
|
+
# needs the account context to build the default window, and that
|
|
446
|
+
# reordering also moved which error a user sees when both are wrong: a
|
|
447
|
+
# malformed `--since` with an unavailable entry cache started exiting 3
|
|
448
|
+
# instead of 2. Exit codes are contract (`docs/cli-contract.md`), and a
|
|
449
|
+
# native-usage error outranks a staged environment failure because it is
|
|
450
|
+
# the one the user can act on. Only the parse moves back in front; the
|
|
451
|
+
# resolved account still precedes interval construction below.
|
|
409
452
|
source_range = getattr(args, "_source_analytics_range", None)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
elif args.since or args.until:
|
|
453
|
+
parsed_explicit_range = None
|
|
454
|
+
if source_range is None and (args.since or args.until):
|
|
413
455
|
parsed = c._parse_cli_date_range(args, now_utc=now)
|
|
414
456
|
if isinstance(parsed, int):
|
|
415
457
|
# Translate the ccusage-parity helper's exit 1 into project's own
|
|
416
458
|
# native usage code 2 — project is cctally-native, not a ccusage
|
|
417
459
|
# drop-in (docs/cli-contract.md; #279 S6 W2).
|
|
418
460
|
return 2
|
|
419
|
-
|
|
461
|
+
parsed_explicit_range = parsed
|
|
462
|
+
|
|
463
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
464
|
+
args, "claude", needs_cache=True)
|
|
465
|
+
if acct_exit is not None:
|
|
466
|
+
return acct_exit
|
|
467
|
+
|
|
468
|
+
# Resolve [since_dt, until_dt] in UTC. All-provider project dispatch
|
|
469
|
+
# resolves its calendar range once, then injects it here so Claude and
|
|
470
|
+
# Codex describe precisely the same interval. The normal Claude parser
|
|
471
|
+
# remains unchanged for every user-facing legacy invocation.
|
|
472
|
+
if source_range is not None:
|
|
473
|
+
since_dt, until_dt = source_range
|
|
474
|
+
elif parsed_explicit_range is not None:
|
|
475
|
+
since_dt, until_dt = parsed_explicit_range
|
|
420
476
|
since_dt = since_dt.astimezone(dt.timezone.utc)
|
|
421
477
|
until_dt = until_dt.astimezone(dt.timezone.utc)
|
|
422
478
|
else:
|
|
423
|
-
# Default to the current subscription week; --weeks N extends
|
|
479
|
+
# Default to the current subscription week; --weeks N extends
|
|
480
|
+
# backwards over the REAL intervals.
|
|
481
|
+
#
|
|
482
|
+
# #620 S1. `since_dt = cw_start - 7 * (weeks - 1)` was short of the
|
|
483
|
+
# truth by the accumulated shortfall of every drifted week in the
|
|
484
|
+
# window — one day per six-day week, several days over `--weeks 12`.
|
|
485
|
+
# `_compute_subscription_weeks` emits a leading extrapolated slice
|
|
486
|
+
# covering whatever `since_dt` it is given, so that deficit was not
|
|
487
|
+
# empty: every entry inside it was read and bucketed into a week the
|
|
488
|
+
# panel does not have, `_load_week_snapshots` admitted the prior real
|
|
489
|
+
# week (it selects every week OVERLAPPING the range) and summed its
|
|
490
|
+
# whole percentage into `totals.usedPercent`, and the extra week with
|
|
491
|
+
# no snapshot drove the rendered line "Used % unavailable for 1 week".
|
|
492
|
+
#
|
|
493
|
+
# The window is resolved the way the dashboard Projects panel
|
|
494
|
+
# resolves it: one walk over one shared probe range, locate the
|
|
495
|
+
# interval containing `now`, then take the `weeks` intervals ending
|
|
496
|
+
# there. `rangeStart` and `weeksInRange` move as a result, which is
|
|
497
|
+
# the point — a figure computed over the wrong population is what
|
|
498
|
+
# this correction removes.
|
|
499
|
+
weeks_back = args.weeks if args.weeks is not None else 1
|
|
500
|
+
probe_start, _probe_end = c.subscription_window_probe_range(
|
|
501
|
+
now, weeks_back,
|
|
502
|
+
)
|
|
424
503
|
# Widen by 1us so the emit loop fires when `now` is exactly at a reset
|
|
425
504
|
# boundary (zero-width [now, now] makes Case A's `current < range_end`
|
|
426
505
|
# false, which would otherwise wrongly fall through to the Monday
|
|
427
506
|
# fallback for non-Monday-reset accounts).
|
|
428
|
-
|
|
429
|
-
conn,
|
|
430
|
-
|
|
507
|
+
probe_weeks = c._compute_subscription_weeks(
|
|
508
|
+
conn, probe_start, now + dt.timedelta(microseconds=1),
|
|
509
|
+
config=config,
|
|
510
|
+
# #341/#620 S1: the resolved `--account` scope, or None for the
|
|
511
|
+
# merged (all-accounts) analytics read.
|
|
512
|
+
account_key=acct_key,
|
|
431
513
|
)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
514
|
+
probe_bounds: list[tuple[dt.datetime, dt.datetime]] = []
|
|
515
|
+
for sw in probe_weeks:
|
|
516
|
+
probe_bounds.append((
|
|
517
|
+
parse_iso_datetime(sw.start_ts, "week.start_ts").astimezone(
|
|
518
|
+
dt.timezone.utc),
|
|
519
|
+
parse_iso_datetime(sw.end_ts, "week.end_ts").astimezone(
|
|
520
|
+
dt.timezone.utc),
|
|
521
|
+
))
|
|
522
|
+
cw_start = None
|
|
523
|
+
for s_dt, e_dt in reversed(probe_bounds):
|
|
524
|
+
if s_dt <= now < e_dt:
|
|
525
|
+
cw_start = s_dt
|
|
526
|
+
window = (
|
|
527
|
+
c.subscription_window_ending_at(probe_bounds, cw_start, weeks_back)
|
|
528
|
+
if cw_start is not None else []
|
|
529
|
+
)
|
|
530
|
+
if window:
|
|
531
|
+
since_dt = window[0][0]
|
|
443
532
|
else:
|
|
444
|
-
|
|
533
|
+
# No interval covers `now` — the genuine no-anchor path. A
|
|
534
|
+
# Monday-anchored week stepped back in seven-day multiples is
|
|
535
|
+
# what that history actually looks like, and it is what the
|
|
536
|
+
# panel falls back to as well.
|
|
537
|
+
if cw_start is None:
|
|
538
|
+
cw_start = (now - dt.timedelta(days=now.weekday())).replace(
|
|
539
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
540
|
+
)
|
|
541
|
+
since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
|
|
445
542
|
until_dt = now
|
|
446
543
|
|
|
447
544
|
# Pre-compute subscription-week bounds for the query window so each entry
|
|
@@ -449,7 +546,9 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
449
546
|
# `_aggregate_weekly`'s bisect pattern (first-match-wins on overlap).
|
|
450
547
|
subweeks = c._compute_subscription_weeks(
|
|
451
548
|
conn, since_dt, until_dt, config=config,
|
|
452
|
-
|
|
549
|
+
# #341/#620 S1: the resolved `--account` scope, or None for the merged
|
|
550
|
+
# (all-accounts) analytics read.
|
|
551
|
+
account_key=acct_key,
|
|
453
552
|
)
|
|
454
553
|
parsed_bounds: list[tuple[dt.datetime, dt.datetime]] = []
|
|
455
554
|
for sw in subweeks:
|
|
@@ -495,13 +594,6 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
495
594
|
else:
|
|
496
595
|
scan_start, scan_end = since_dt, until_dt
|
|
497
596
|
|
|
498
|
-
# #341 --account: resolve the render filter (provider=claude; fail closed
|
|
499
|
-
# with exit 3 when the entry cache is unavailable). None = merged.
|
|
500
|
-
acct_key, acct_exit = c.resolve_account_filter(
|
|
501
|
-
args, "claude", needs_cache=True)
|
|
502
|
-
if acct_exit is not None:
|
|
503
|
-
return acct_exit
|
|
504
|
-
|
|
505
597
|
resolver_cache: dict[str, ProjectKey] = {}
|
|
506
598
|
buckets: dict[tuple[ProjectKey, dt.datetime], dict] = {}
|
|
507
599
|
total_cost_by_week: dict[dt.datetime, float] = {}
|
|
@@ -622,16 +714,32 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
622
714
|
buckets[bkey] = b
|
|
623
715
|
_accumulate_entry_into_bucket(b, entry, pre_computed_cost=entry_cost)
|
|
624
716
|
|
|
717
|
+
# The remediation moved OUT of these two sentences and into the shared
|
|
718
|
+
# affordance line below (#620 S1 D11), so the terminal states the problem
|
|
719
|
+
# once and the fix once. The `warnings` list the JSON payload carries is a
|
|
720
|
+
# separate wire surface and keeps its own wording unchanged.
|
|
625
721
|
if unknown_entry_count > 0:
|
|
626
722
|
eprint(
|
|
627
|
-
f"Warning: {unknown_entry_count} entries lacked project_path
|
|
628
|
-
f"run `cache-sync` to backfill."
|
|
723
|
+
f"Warning: {unknown_entry_count} entries lacked project_path."
|
|
629
724
|
)
|
|
630
725
|
if missing_sid_count > 0:
|
|
631
726
|
eprint(
|
|
632
727
|
f"Warning: {missing_sid_count} entries lacked session_files "
|
|
633
|
-
f"session_id
|
|
728
|
+
f"session_id."
|
|
634
729
|
)
|
|
730
|
+
# #620 S1 D11: state the remediation in the one shape every warning uses,
|
|
731
|
+
# scoped to the provider whose cache is short and the range that was read.
|
|
732
|
+
# The `warnings` list the JSON payload carries is deliberately untouched;
|
|
733
|
+
# it is a wire surface, and this is the terminal affordance.
|
|
734
|
+
resolved_source = getattr(args, "source", None) or "claude"
|
|
735
|
+
if unknown_entry_count > 0 or missing_sid_count > 0:
|
|
736
|
+
eprint(_lib_alert_scope.next_step_line(
|
|
737
|
+
f"cctally cache-sync --source {resolved_source}",
|
|
738
|
+
provider=resolved_source,
|
|
739
|
+
window_start=since_dt,
|
|
740
|
+
window_end=until_dt,
|
|
741
|
+
tz=getattr(args, "_resolved_tz", None),
|
|
742
|
+
))
|
|
635
743
|
|
|
636
744
|
# --- Attribution math (Task 5) -----------------------------------------
|
|
637
745
|
# Load per-week `weekly_percent` (max within window) for every week that
|
|
@@ -639,7 +747,7 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
639
747
|
# can surface `weeksMissingSnapshot` in the output — those weeks can't
|
|
640
748
|
# contribute to attributed %.
|
|
641
749
|
week_snapshots: dict[dt.datetime, float] = _load_week_snapshots(
|
|
642
|
-
since_dt, until_dt
|
|
750
|
+
since_dt, until_dt, account_key=acct_key,
|
|
643
751
|
)
|
|
644
752
|
|
|
645
753
|
# Set of every week the user asked about (from the computed SubWeek
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -253,14 +253,26 @@ PROJECTION_READ_SITE_ACTIONS: "dict[str, str]" = {
|
|
|
253
253
|
#: an unnamed caller reduces `gate_at_caller` to an assertion nothing tests, and
|
|
254
254
|
#: that is how the first version of this map came to claim a gating caller that
|
|
255
255
|
#: neither called the kernel nor called the gate.
|
|
256
|
+
#:
|
|
257
|
+
#: `_cctally_dashboard_sources.py::_codex_quota_reuse_identity` joined the list
|
|
258
|
+
#: with #583 S5. It derives `codex_stats_digest` when its caller cannot supply
|
|
259
|
+
#: one, which is the share path and the per-request cycle-detail route — neither
|
|
260
|
+
#: of which computes a build signature, so neither had already gated. It gates
|
|
261
|
+
#: and, uniquely among the named callers, returns `None` on
|
|
262
|
+
#: `QuotaProjectionIncomplete` rather than propagating: it produces a cache KEY,
|
|
263
|
+
#: and the fail-safe for an identity that cannot be established is a cold read.
|
|
264
|
+
#: The retry signal is not lost, because its callers reach a `gate`-classified
|
|
265
|
+
#: projection read within a few statements.
|
|
256
266
|
PROJECTION_GATE_CALLERS: "dict[str, tuple[str, ...]]" = {
|
|
257
267
|
"_lib_dashboard_sources.py::<module>::quota_projection_state": (
|
|
258
268
|
"_cctally_tui.py::_tui_build_source_bundle",
|
|
259
269
|
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
270
|
+
"_cctally_dashboard_sources.py::_codex_quota_reuse_identity",
|
|
260
271
|
),
|
|
261
272
|
"_lib_dashboard_sources.py::<module>::quota_window_blocks": (
|
|
262
273
|
"_cctally_tui.py::_tui_build_source_bundle",
|
|
263
274
|
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
275
|
+
"_cctally_dashboard_sources.py::_codex_quota_reuse_identity",
|
|
264
276
|
),
|
|
265
277
|
}
|
|
266
278
|
|
|
@@ -1908,13 +1920,14 @@ def load_codex_quota_observations(
|
|
|
1908
1920
|
because SQL compares seconds while ``physical_order_key`` compares full
|
|
1909
1921
|
datetimes and then breaks ties on the reset anchor and physical position.
|
|
1910
1922
|
|
|
1911
|
-
The residual
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1923
|
+
The residual differences are malformed or non-canonical instants. A row
|
|
1924
|
+
that survives SQLite's predicates but still fails Python parsing can hide
|
|
1925
|
+
an older valid capture of the same window. In the reverse direction,
|
|
1926
|
+
``datetime.fromisoformat`` accepts basic-format ISO instants such as
|
|
1927
|
+
``20260814T120000Z`` that SQLite's ``unixepoch`` rejects, so the SQL path
|
|
1928
|
+
drops that row and may let an older capture win. Neither shape is emitted
|
|
1929
|
+
by ``_utc_iso``; both are confined to a store carrying hand-written or
|
|
1930
|
+
corrupt quota rows.
|
|
1918
1931
|
"""
|
|
1919
1932
|
for name, value in (
|
|
1920
1933
|
("captured_at_or_after", captured_at_or_after), ("active_at", active_at),
|
|
@@ -2079,23 +2092,29 @@ def load_codex_quota_observations(
|
|
|
2079
2092
|
else:
|
|
2080
2093
|
sql += " AND unixepoch(captured_at_utc) >= unixepoch(?)"
|
|
2081
2094
|
params.append(_utc_iso(captured_at_or_after))
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2095
|
+
# ``latest_per_identity`` replaces the base query wholesale below, so
|
|
2096
|
+
# assembling its ORDER BY here would be dead work.
|
|
2097
|
+
if not latest_per_identity:
|
|
2098
|
+
if sql_bounded and max_rows is not None:
|
|
2099
|
+
if active_at is not None:
|
|
2100
|
+
sql += (
|
|
2101
|
+
" ORDER BY (unixepoch(resets_at_utc) > unixepoch(?)) DESC, "
|
|
2102
|
+
"unixepoch(captured_at_utc) DESC, unixepoch(resets_at_utc) DESC, "
|
|
2103
|
+
"source_path DESC, line_offset DESC"
|
|
2104
|
+
)
|
|
2105
|
+
params.append(_utc_iso(active_at))
|
|
2106
|
+
else:
|
|
2107
|
+
sql += (
|
|
2108
|
+
" ORDER BY unixepoch(captured_at_utc) DESC, "
|
|
2109
|
+
"unixepoch(resets_at_utc) DESC, source_path DESC, line_offset DESC"
|
|
2110
|
+
)
|
|
2111
|
+
sql += " LIMIT ?"
|
|
2112
|
+
params.append(max_rows)
|
|
2090
2113
|
else:
|
|
2091
2114
|
sql += (
|
|
2092
|
-
" ORDER BY
|
|
2093
|
-
"
|
|
2115
|
+
" ORDER BY source_root_key, captured_at_utc, resets_at_utc, "
|
|
2116
|
+
"source_path, line_offset"
|
|
2094
2117
|
)
|
|
2095
|
-
sql += " LIMIT ?"
|
|
2096
|
-
params.append(max_rows)
|
|
2097
|
-
else:
|
|
2098
|
-
sql += " ORDER BY source_root_key, captured_at_utc, resets_at_utc, source_path, line_offset"
|
|
2099
2118
|
# ONE SHARD PER GROUP (public #5), not one disjunction over all of them.
|
|
2100
2119
|
# Measured on a 211K-row / 608-group store: an OR over the five-member
|
|
2101
2120
|
# equality gives up and SCANs the table (2 groups 45.7ms, 3 groups
|
package/bin/_cctally_record.py
CHANGED
|
@@ -902,8 +902,13 @@ def maybe_record_milestone(
|
|
|
902
902
|
# Filter by reset_event_id so a credited week's
|
|
903
903
|
# alert payload reads the post-credit row, not a
|
|
904
904
|
# stale pre-credit row at the same (week, threshold).
|
|
905
|
+
# `week_start_at` comes from the row rather than the
|
|
906
|
+
# local variable for the same reason `cumulative_cost_usd`
|
|
907
|
+
# does: it is the value persisted on insert, so the alert
|
|
908
|
+
# states the week the milestone actually recorded.
|
|
905
909
|
row = conn.execute(
|
|
906
|
-
"SELECT cumulative_cost_usd
|
|
910
|
+
"SELECT cumulative_cost_usd, week_start_at "
|
|
911
|
+
"FROM percent_milestones "
|
|
907
912
|
"WHERE week_start_date = ? AND percent_threshold = ? "
|
|
908
913
|
" AND reset_event_id = ? AND account_key = ?",
|
|
909
914
|
(week_start_date, pct, reset_event_id, account_key),
|
|
@@ -916,6 +921,7 @@ def maybe_record_milestone(
|
|
|
916
921
|
threshold=pct,
|
|
917
922
|
crossed_at_utc=crossed_at,
|
|
918
923
|
week_start_date=week_start_date,
|
|
924
|
+
week_start_at=row["week_start_at"],
|
|
919
925
|
cumulative_cost_usd=cum,
|
|
920
926
|
dollars_per_percent=dpp,
|
|
921
927
|
account_key=account_key,
|
|
@@ -4172,6 +4178,8 @@ def cmd_record_usage(
|
|
|
4172
4178
|
args: argparse.Namespace, *,
|
|
4173
4179
|
ingest_mode: str = "authoritative",
|
|
4174
4180
|
writer: str = "record-usage",
|
|
4181
|
+
nudge_dashboard: bool = True,
|
|
4182
|
+
nudge_sink=None,
|
|
4175
4183
|
) -> int:
|
|
4176
4184
|
"""Record usage from the Claude Code status line rate_limits — DB journal
|
|
4177
4185
|
redesign reroute (Appendix A).
|
|
@@ -4187,7 +4195,23 @@ def cmd_record_usage(
|
|
|
4187
4195
|
observes its own write synchronously; "opportunistic" (hook-tick OAuth /
|
|
4188
4196
|
dedup ticks) skips a busy ingest lock and lets the winner consume the line.
|
|
4189
4197
|
``writer`` is the obs line ``src``. Returns 0 on a recorded/deduped tick, 2
|
|
4190
|
-
on an implausible weekly resets_at.
|
|
4198
|
+
on an implausible weekly resets_at.
|
|
4199
|
+
|
|
4200
|
+
``nudge_dashboard`` (#583 S2 §5.3) enqueues a rebuild on a locally running
|
|
4201
|
+
dashboard when — and only when — the ingest emitted events. Pass False
|
|
4202
|
+
from `_refresh_usage_inproc`, which runs inside the dashboard's own
|
|
4203
|
+
``sync_lock`` while servicing a ``refresh=1`` and whose caller
|
|
4204
|
+
``cmd_refresh_usage`` already nudges once itself.
|
|
4205
|
+
|
|
4206
|
+
``nudge_sink`` is for callers that hold a cross-process lock across this
|
|
4207
|
+
call. When supplied it is invoked INSTEAD of the nudge, and that caller
|
|
4208
|
+
fires the real nudge once its critical section has ended. The nudge is a
|
|
4209
|
+
loopback POST with a multi-second timeout, so a stalled nudge inside such a
|
|
4210
|
+
section stalls every other process contending on the same lock. There are
|
|
4211
|
+
two such locks and both supply a sink: ``_selected_state_lock`` (the OAuth
|
|
4212
|
+
refresh and the authoritative statusline publication) and the statusline
|
|
4213
|
+
persist flock (``_statusline_reduce_and_publish``, reached from the forked
|
|
4214
|
+
persist child and from the ``sync_for_test`` foreground path)."""
|
|
4191
4215
|
|
|
4192
4216
|
# ULP-noise sanitization is applied at the cmd_record_usage ingress
|
|
4193
4217
|
# boundary so every downstream consumer (HWM files, DB rows,
|
|
@@ -4310,7 +4334,24 @@ def cmd_record_usage(
|
|
|
4310
4334
|
account=obs_account))
|
|
4311
4335
|
# authoritative observes its own write synchronously; opportunistic skips a
|
|
4312
4336
|
# busy ingest lock and lets the current holder consume the appended line.
|
|
4313
|
-
_jr.run_stats_ingest(mode=ingest_mode)
|
|
4337
|
+
result = _jr.run_stats_ingest(mode=ingest_mode)
|
|
4338
|
+
# #583 S2 §5.3. Nudge only on a MATERIAL change. This runs at Claude
|
|
4339
|
+
# Code's status-line cadence, so an unconditional nudge would queue a
|
|
4340
|
+
# rebuild for work that changed nothing displayed. `consumed` is the wrong
|
|
4341
|
+
# signal — unchanged observations advance ingestion without changing
|
|
4342
|
+
# anything the dashboard shows — and `alerts` is the wrong signal, because
|
|
4343
|
+
# it covers only a subset of material events: a new 5-hour window changes
|
|
4344
|
+
# the dashboard without necessarily firing an alert. The nudge happens
|
|
4345
|
+
# after `run_stats_ingest` returns, which is already after post-commit
|
|
4346
|
+
# alert dispatch, so the alerts ordering is untouched.
|
|
4347
|
+
if (nudge_dashboard
|
|
4348
|
+
and getattr(result, "ran", False)
|
|
4349
|
+
and getattr(result, "error", None) is None
|
|
4350
|
+
and int(getattr(result, "events_emitted", 0)) > 0):
|
|
4351
|
+
# A caller holding the selected-state lock supplies a sink and fires
|
|
4352
|
+
# the real nudge after releasing it — a network call has no business
|
|
4353
|
+
# inside that critical section.
|
|
4354
|
+
(nudge_sink or _cctally()._nudge_dashboard_repaint)()
|
|
4314
4355
|
return 0
|
|
4315
4356
|
|
|
4316
4357
|
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -716,6 +716,11 @@ def _refresh_usage_inproc_locked(
|
|
|
716
716
|
*({"fiveHour"} if five_pct is not None else set()),
|
|
717
717
|
},
|
|
718
718
|
lock_held=True,
|
|
719
|
+
# #583 S2 §5.3: the dashboard calls this INSIDE its own `sync_lock`
|
|
720
|
+
# while servicing a `refresh=1`, so an inner nudge would enqueue a
|
|
721
|
+
# second rebuild for work already underway. `cmd_refresh_usage`
|
|
722
|
+
# already nudges once on its own after this returns.
|
|
723
|
+
nudge_dashboard=False,
|
|
719
724
|
)
|
|
720
725
|
if authoritative.status != "ok":
|
|
721
726
|
return _RefreshUsageResult(
|
|
@@ -761,6 +766,25 @@ def _refresh_usage_inproc_locked(
|
|
|
761
766
|
return _RefreshUsageResult(status="ok", payload=payload, warnings=warnings)
|
|
762
767
|
|
|
763
768
|
|
|
769
|
+
def _resolve_dashboard_api_token() -> "str | None":
|
|
770
|
+
"""Return the bearer token a running dashboard would require, if known.
|
|
771
|
+
|
|
772
|
+
#583 S2 §4. `_require_api_auth` admits an unauthenticated `/api/*` request
|
|
773
|
+
only when `cctally_api_token` is None, and the dashboard mints that token
|
|
774
|
+
with `secrets.token_urlsafe(32)` at startup for a non-loopback bind. It is
|
|
775
|
+
printed once ("dashboard: LAN access token: …") and placed in the startup
|
|
776
|
+
URL's fragment; it is never written to `config.json` and never persisted.
|
|
777
|
+
So there is NO configured token for a separate `refresh-usage` process to
|
|
778
|
+
read, and this environment variable is the only mechanism available today:
|
|
779
|
+
export the printed token and the nudge authenticates. Without it a
|
|
780
|
+
LAN-bound, token-configured dashboard answers 401 and the helper's
|
|
781
|
+
swallow-everything contract discards the nudge, which is a pre-existing
|
|
782
|
+
defect rather than a regression.
|
|
783
|
+
"""
|
|
784
|
+
token = (os.environ.get("CCTALLY_DASHBOARD_API_TOKEN") or "").strip()
|
|
785
|
+
return token or None
|
|
786
|
+
|
|
787
|
+
|
|
764
788
|
def _nudge_dashboard_repaint(port: int = 8789, timeout_seconds: float = 3.0) -> None:
|
|
765
789
|
"""Best-effort: tell a locally-running dashboard to rebuild+broadcast NOW.
|
|
766
790
|
|
|
@@ -776,20 +800,28 @@ def _nudge_dashboard_repaint(port: int = 8789, timeout_seconds: float = 3.0) ->
|
|
|
776
800
|
|
|
777
801
|
CSRF: the dashboard's _check_origin_csrf requires Origin/Host authority
|
|
778
802
|
parity. urllib auto-sets Host from the URL; we set Origin to the
|
|
779
|
-
byte-identical authority so the POST is accepted (not 403).
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
the
|
|
783
|
-
|
|
803
|
+
byte-identical authority so the POST is accepted (not 403).
|
|
804
|
+
|
|
805
|
+
#583 S2 §4. The URL carries `queue=1`, which marks this as a MACHINE
|
|
806
|
+
NUDGE: the handler enqueues it even when `sync_lock` is free, because this
|
|
807
|
+
fires at Claude Code's status-line cadence and a synchronous rebuild at
|
|
808
|
+
that frequency would reopen the #313 peg. The response is therefore a
|
|
809
|
+
202 that arrives as soon as the request is queued, not a 204 that waits
|
|
810
|
+
for a rebuild — the 3.0s timeout is now generous rather than tuned to a
|
|
811
|
+
server-side lock-wait. A bearer header is sent when a token is
|
|
812
|
+
resolvable; see `_resolve_dashboard_api_token`.
|
|
784
813
|
"""
|
|
785
814
|
# Whole body (incl. Request construction) inside the try so the
|
|
786
815
|
# "swallows EVERY error" contract holds structurally, not just because
|
|
787
816
|
# Request() happens to be total for the hardcoded loopback URL.
|
|
788
817
|
try:
|
|
789
|
-
url = f"http://127.0.0.1:{port}/api/sync?refresh=0"
|
|
818
|
+
url = f"http://127.0.0.1:{port}/api/sync?refresh=0&queue=1"
|
|
819
|
+
headers = {"Origin": f"http://127.0.0.1:{port}"}
|
|
820
|
+
token = _cctally()._resolve_dashboard_api_token()
|
|
821
|
+
if token:
|
|
822
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
790
823
|
req = urllib.request.Request(
|
|
791
|
-
url, data=b"", method="POST",
|
|
792
|
-
headers={"Origin": f"http://127.0.0.1:{port}"},
|
|
824
|
+
url, data=b"", method="POST", headers=headers,
|
|
793
825
|
)
|
|
794
826
|
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
|
|
795
827
|
resp.read()
|
|
@@ -922,16 +954,28 @@ def _hook_tick_oauth_refresh(
|
|
|
922
954
|
# its suppression conditions. A concurrent publisher can otherwise make
|
|
923
955
|
# this tick issue an unnecessary OAuth request between the initial gate
|
|
924
956
|
# and the request itself.
|
|
957
|
+
# #583 S2: the nudge is deferred out of the critical section. This frame
|
|
958
|
+
# owns the lock across the OAuth fetch AND the authoritative record, so
|
|
959
|
+
# `_authoritative_record_usage` cannot release it — the deferral has to
|
|
960
|
+
# reach the frame that acquired it. The nudge is a loopback POST with a
|
|
961
|
+
# multi-second timeout and this lock is an `fcntl.flock` every cctally
|
|
962
|
+
# process contends on, so one unresponsive listener would otherwise stall
|
|
963
|
+
# every other process's selected-state writes at status-line cadence.
|
|
964
|
+
deferred = []
|
|
925
965
|
try:
|
|
926
966
|
with c._selected_state_lock():
|
|
927
|
-
|
|
967
|
+
out = _hook_tick_oauth_refresh_locked(
|
|
928
968
|
c,
|
|
929
969
|
token=token,
|
|
930
970
|
timeout_seconds=timeout_seconds,
|
|
931
971
|
throttle_seconds=throttle_seconds,
|
|
972
|
+
nudge_sink=lambda: deferred.append(1),
|
|
932
973
|
)
|
|
933
974
|
except OSError:
|
|
934
975
|
return "err(record-usage=exc)", None
|
|
976
|
+
if deferred:
|
|
977
|
+
c._nudge_dashboard_repaint()
|
|
978
|
+
return out
|
|
935
979
|
|
|
936
980
|
|
|
937
981
|
def _hook_tick_oauth_refresh_locked(
|
|
@@ -940,8 +984,13 @@ def _hook_tick_oauth_refresh_locked(
|
|
|
940
984
|
token: str,
|
|
941
985
|
timeout_seconds: float,
|
|
942
986
|
throttle_seconds: float,
|
|
987
|
+
nudge_sink=None,
|
|
943
988
|
) -> tuple[str, dict | None]:
|
|
944
|
-
"""Automatic OAuth path with the selected-state lock already held.
|
|
989
|
+
"""Automatic OAuth path with the selected-state lock already held.
|
|
990
|
+
|
|
991
|
+
``nudge_sink`` records that a dashboard nudge is warranted; the caller
|
|
992
|
+
fires it after releasing the lock (#583 S2).
|
|
993
|
+
"""
|
|
945
994
|
# Backfill gate: an inflight/invalid tombstone bypasses only selected-age
|
|
946
995
|
# suppression so a later authoritative result can repair it. The normal
|
|
947
996
|
# throttle and 429 deadline continue to bound OAuth traffic.
|
|
@@ -997,6 +1046,7 @@ def _hook_tick_oauth_refresh_locked(
|
|
|
997
1046
|
record_args,
|
|
998
1047
|
{"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
|
|
999
1048
|
lock_held=True,
|
|
1049
|
+
nudge_sink=nudge_sink,
|
|
1000
1050
|
)
|
|
1001
1051
|
if authoritative.status != "ok":
|
|
1002
1052
|
reason = authoritative.reason or ""
|
package/bin/_cctally_share.py
CHANGED
|
@@ -949,7 +949,7 @@ def _build_forecast_snapshot(
|
|
|
949
949
|
projected_low_pct: float,
|
|
950
950
|
projected_high_pct: float,
|
|
951
951
|
days_remaining: float,
|
|
952
|
-
dollars_per_percent: float,
|
|
952
|
+
dollars_per_percent: "float | None",
|
|
953
953
|
dollars_per_percent_source: str,
|
|
954
954
|
low_conf: bool,
|
|
955
955
|
notes: tuple[str, ...] = (),
|
|
@@ -1038,6 +1038,13 @@ def _build_forecast_snapshot(
|
|
|
1038
1038
|
f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
|
|
1039
1039
|
)
|
|
1040
1040
|
dpp_source_label = dollars_per_percent_source.replace("_", " ")
|
|
1041
|
+
# #620 S1 D5: a withheld rate renders as the same `n/a` the report trend
|
|
1042
|
+
# table already uses for an absent $/1%. Forcing it through MoneyCell
|
|
1043
|
+
# would print $0.00, which is the fabrication this contract removes.
|
|
1044
|
+
dpp_cell = (
|
|
1045
|
+
_lib_share.TextCell("n/a") if dollars_per_percent is None
|
|
1046
|
+
else _lib_share.MoneyCell(float(dollars_per_percent))
|
|
1047
|
+
)
|
|
1041
1048
|
snap_rows = (
|
|
1042
1049
|
_lib_share.Row(cells={
|
|
1043
1050
|
"metric": _lib_share.TextCell("Current %"),
|
|
@@ -1053,7 +1060,7 @@ def _build_forecast_snapshot(
|
|
|
1053
1060
|
}),
|
|
1054
1061
|
_lib_share.Row(cells={
|
|
1055
1062
|
"metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
|
|
1056
|
-
"value":
|
|
1063
|
+
"value": dpp_cell,
|
|
1057
1064
|
}),
|
|
1058
1065
|
)
|
|
1059
1066
|
# Caller-provided `notes` (e.g., empty-data path's clearer message)
|