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.
Files changed (49) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/bin/_cctally_alerts.py +13 -2
  3. package/bin/_cctally_cache.py +3 -1
  4. package/bin/_cctally_cache_report.py +103 -6
  5. package/bin/_cctally_dashboard.py +2083 -356
  6. package/bin/_cctally_dashboard_envelope.py +115 -31
  7. package/bin/_cctally_dashboard_perf.py +433 -0
  8. package/bin/_cctally_dashboard_share.py +101 -29
  9. package/bin/_cctally_dashboard_sources.py +1072 -214
  10. package/bin/_cctally_db.py +30 -14
  11. package/bin/_cctally_diff.py +20 -0
  12. package/bin/_cctally_doctor.py +1331 -1148
  13. package/bin/_cctally_forecast.py +304 -96
  14. package/bin/_cctally_milestone_history.py +10 -2
  15. package/bin/_cctally_parser.py +70 -1
  16. package/bin/_cctally_project.py +155 -47
  17. package/bin/_cctally_quota.py +40 -21
  18. package/bin/_cctally_record.py +44 -3
  19. package/bin/_cctally_refresh.py +60 -10
  20. package/bin/_cctally_share.py +9 -2
  21. package/bin/_cctally_source_analytics.py +40 -4
  22. package/bin/_cctally_statusline.py +53 -7
  23. package/bin/_cctally_tui.py +723 -232
  24. package/bin/_cctally_update.py +28 -22
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_cache_report.py +110 -1
  28. package/bin/_lib_codex_pools.py +20 -8
  29. package/bin/_lib_dashboard_sources.py +237 -30
  30. package/bin/_lib_doctor.py +37 -0
  31. package/bin/_lib_forecast.py +12 -4
  32. package/bin/_lib_jsonl.py +4 -2
  33. package/bin/_lib_perf.py +132 -3
  34. package/bin/_lib_pricing.py +8 -7
  35. package/bin/_lib_render.py +31 -3
  36. package/bin/_lib_share_templates.py +150 -55
  37. package/bin/_lib_snapshot_cache.py +71 -13
  38. package/bin/_lib_source_analytics.py +2 -2
  39. package/bin/_lib_source_identity.py +50 -2
  40. package/bin/_lib_subscription_weeks.py +65 -0
  41. package/bin/_lib_tick_stats.py +538 -0
  42. package/bin/cctally +29 -7
  43. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  44. package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
  45. package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
  46. package/dashboard/static/dashboard.html +2 -2
  47. package/package.json +4 -1
  48. package/dashboard/static/assets/index-C5NBB2w9.js +0 -97
  49. package/dashboard/static/assets/index-hJP4wlIO.css +0 -1
@@ -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
- cur = conn.execute(
76
- "SELECT week_start_date, week_start_at, week_end_at, "
77
- " captured_at_utc, weekly_percent "
78
- "FROM weekly_usage_snapshots "
79
- "WHERE week_start_at IS NOT NULL "
80
- "AND week_end_at IS NOT NULL "
81
- "AND datetime(week_start_at) < datetime(?) "
82
- "AND datetime(week_end_at) > datetime(?)",
83
- (until.isoformat(), since.isoformat()),
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
- # Resolve [since_dt, until_dt] in UTC. All-provider project dispatch
406
- # resolves its calendar range once, then injects it here so Claude and
407
- # Codex describe precisely the same interval. The normal Claude parser
408
- # remains unchanged for every user-facing legacy invocation.
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
- if source_range is not None:
411
- since_dt, until_dt = source_range
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
- since_dt, until_dt = parsed
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 backwards.
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
- current_weeks = c._compute_subscription_weeks(
429
- conn, now, now + dt.timedelta(microseconds=1), config=config,
430
- account_key=None, # #341: merged (all-accounts) analytics read
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
- if current_weeks:
433
- cw_start = parse_iso_datetime(
434
- current_weeks[0].start_ts, "week.start_ts"
435
- ).astimezone(dt.timezone.utc)
436
- else:
437
- # No snapshots available: fall back to a Monday-anchored week.
438
- cw_start = (now - dt.timedelta(days=now.weekday())).replace(
439
- hour=0, minute=0, second=0, microsecond=0
440
- )
441
- if args.weeks is not None:
442
- since_dt = cw_start - dt.timedelta(days=7 * (args.weeks - 1))
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
- since_dt = cw_start
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
- account_key=None, # #341: merged (all-accounts) analytics read
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 — run `cache-sync` to backfill."
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
@@ -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 difference is a malformed row. Rows whose required text is
1912
- blank, or whose capture or reset instant SQLite cannot read as a time, are
1913
- excluded in SQL exactly as the loop below excludes them; a row that survives
1914
- those predicates but still fails a Python parse can, on this path, hide an
1915
- older valid capture of the same window that the full load would have
1916
- reported. That trade is confined to a store already carrying corrupt quota
1917
- rows, and it is the only behavioural difference between the two paths.
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
- if sql_bounded and max_rows is not None:
2083
- if active_at is not None:
2084
- sql += (
2085
- " ORDER BY (unixepoch(resets_at_utc) > unixepoch(?)) DESC, "
2086
- "unixepoch(captured_at_utc) DESC, unixepoch(resets_at_utc) DESC, "
2087
- "source_path DESC, line_offset DESC"
2088
- )
2089
- params.append(_utc_iso(active_at))
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 unixepoch(captured_at_utc) DESC, "
2093
- "unixepoch(resets_at_utc) DESC, source_path DESC, line_offset DESC"
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
@@ -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 FROM percent_milestones "
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
 
@@ -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). The timeout
780
- is 3.0s — comfortably above the server's _DASHBOARD_SYNC_LOCK_TIMEOUT_
781
- SECONDS (2.0s) bounded lock-wait — so the client stays connected until
782
- the 204 lands and never leaves a broken-pipe log line in the dashboard's
783
- terminal. In the common case (lock free) it returns in single-digit ms.
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
- return _hook_tick_oauth_refresh_locked(
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 ""
@@ -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": _lib_share.MoneyCell(float(dollars_per_percent)),
1063
+ "value": dpp_cell,
1057
1064
  }),
1058
1065
  )
1059
1066
  # Caller-provided `notes` (e.g., empty-data path's clearer message)