cctally 1.79.2 → 1.80.1

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.
@@ -22,6 +22,7 @@ import sys
22
22
  from dataclasses import dataclass
23
23
 
24
24
  from _cctally_core import (
25
+ _as_of_or_command,
25
26
  _budget_alerts_active,
26
27
  _canonicalize_optional_iso,
27
28
  _command_as_of,
@@ -126,11 +127,56 @@ def insert_cost_snapshot(
126
127
  cost_usd: float,
127
128
  mode: str,
128
129
  project: str | None,
130
+ *,
131
+ commit: bool = True,
132
+ as_of: "str | None" = None,
133
+ journal: "tuple | None" = None,
129
134
  ) -> int:
135
+ """Insert a ``weekly_cost_snapshots`` row and return its rowid.
136
+
137
+ Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
138
+ ``commit=False`` skips the inner ``conn.commit()`` so the ingester can bundle
139
+ this insert into its single cycle transaction; ``as_of`` (ISO-Z) overrides
140
+ the ``captured_at_utc`` wall-clock stamp with the record's capture time.
141
+ Both defaults keep legacy callers bit-identical.
142
+
143
+ Design A (DB journal redesign §5.3, Model-A ``weekly_cost_snapshot``):
144
+ ``journal=(ctx, id_base)`` routes the insert THROUGH ``emit_model_a`` — the
145
+ computed cost rides in the journaled ``columns`` so replay reads it back
146
+ verbatim and NEVER recomputes from provider JSONL (which Claude Code prunes).
147
+ ``id_base`` is the triggering record's logical id (the obs line id on the
148
+ record-usage path, the op line id on the sync-week op path); the evt id is
149
+ ``wcs:<id_base>:<week_start_date>``. Default ``None`` is today's bare insert
150
+ and must never append an evt. Returns the target rowid (fresh or converged
151
+ crash-replay) exactly as the direct path returns ``lastrowid``.
152
+ """
130
153
  start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
131
154
  end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
132
155
  range_start = parse_iso_datetime(range_start_iso, "rangeStartIso").isoformat(timespec="seconds")
133
156
  range_end = parse_iso_datetime(range_end_iso, "rangeEndIso").isoformat(timespec="seconds")
157
+ captured = as_of or now_utc_iso()
158
+ if journal is not None:
159
+ ctx, id_base = journal
160
+ import _cctally_journal
161
+ return _cctally_journal.emit_model_a(
162
+ ctx,
163
+ kind="weekly_cost_snapshot",
164
+ evt_id=f"wcs:{id_base}:{week_start.isoformat()}",
165
+ table="weekly_cost_snapshots",
166
+ columns={
167
+ "captured_at_utc": captured,
168
+ "week_start_date": week_start.isoformat(),
169
+ "week_end_date": week_end.isoformat(),
170
+ "week_start_at": start_at,
171
+ "week_end_at": end_at,
172
+ "range_start_iso": range_start,
173
+ "range_end_iso": range_end,
174
+ "cost_usd": cost_usd,
175
+ "mode": mode,
176
+ "project": project,
177
+ },
178
+ at=captured,
179
+ )
134
180
  cur = conn.execute(
135
181
  """
136
182
  INSERT INTO weekly_cost_snapshots
@@ -149,7 +195,7 @@ def insert_cost_snapshot(
149
195
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
150
196
  """,
151
197
  (
152
- now_utc_iso(),
198
+ captured,
153
199
  week_start.isoformat(),
154
200
  week_end.isoformat(),
155
201
  start_at,
@@ -161,7 +207,8 @@ def insert_cost_snapshot(
161
207
  project,
162
208
  ),
163
209
  )
164
- conn.commit()
210
+ if commit:
211
+ conn.commit()
165
212
  return int(cur.lastrowid)
166
213
 
167
214
 
@@ -257,9 +304,14 @@ def insert_percent_milestone(
257
304
  *,
258
305
  commit: bool = True,
259
306
  reset_event_id: int = 0,
307
+ as_of: "str | None" = None,
260
308
  ) -> int:
261
309
  """Insert a percent_milestones row idempotently.
262
310
 
311
+ ``as_of`` (DB journal redesign §5.2.3): overrides the ``captured_at_utc``
312
+ wall-clock stamp with the record's capture time when the ingester drives
313
+ this at fold time; ``None`` keeps the legacy ``now_utc_iso()`` behavior.
314
+
263
315
  Returns the SQLite rowcount: 1 on a genuinely new crossing, 0 if a row
264
316
  for (week_start_date, percent_threshold, reset_event_id) already exists.
265
317
  Race-safe under concurrent record-usage instances — aligns with the
@@ -309,7 +361,7 @@ def insert_percent_milestone(
309
361
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
310
362
  """,
311
363
  (
312
- now_utc_iso(),
364
+ as_of or now_utc_iso(),
313
365
  week_start_date,
314
366
  week_end_date,
315
367
  week_start_at,
@@ -339,6 +391,7 @@ def insert_budget_milestone(
339
391
  spent_usd: float,
340
392
  consumption_pct: float,
341
393
  commit: bool = True,
394
+ as_of: "str | None" = None,
342
395
  ) -> int:
343
396
  """INSERT OR IGNORE a budget threshold crossing into the unified vendor-tagged
344
397
  table (#143). Returns ``cur.rowcount`` (1 = genuinely new crossing, 0 =
@@ -374,7 +427,7 @@ def insert_budget_milestone(
374
427
  float(budget_usd),
375
428
  float(spent_usd),
376
429
  float(consumption_pct),
377
- now_utc_iso(),
430
+ as_of or now_utc_iso(),
378
431
  ),
379
432
  )
380
433
  if commit:
@@ -392,6 +445,7 @@ def insert_project_budget_milestone(
392
445
  spent_usd: float,
393
446
  consumption_pct: float,
394
447
  commit: bool = True,
448
+ as_of: "str | None" = None,
395
449
  ) -> int:
396
450
  """INSERT OR IGNORE a per-project budget threshold crossing. Returns
397
451
  ``cur.rowcount`` (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a
@@ -420,7 +474,7 @@ def insert_project_budget_milestone(
420
474
  float(budget_usd),
421
475
  float(spent_usd),
422
476
  float(consumption_pct),
423
- now_utc_iso(),
477
+ as_of or now_utc_iso(),
424
478
  ),
425
479
  )
426
480
  if commit:
@@ -438,6 +492,7 @@ def insert_projected_milestone(
438
492
  projected_value: float,
439
493
  denominator: float,
440
494
  commit: bool = True,
495
+ as_of: "str | None" = None,
441
496
  ) -> int:
442
497
  """INSERT OR IGNORE a projected-pace crossing. Returns ``cur.rowcount``
443
498
  (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
@@ -466,7 +521,7 @@ def insert_projected_milestone(
466
521
  int(threshold),
467
522
  float(projected_value),
468
523
  float(denominator),
469
- now_utc_iso(),
524
+ as_of or now_utc_iso(),
470
525
  ),
471
526
  )
472
527
  if commit:
@@ -562,6 +617,7 @@ def _budget_spend_for_vendor(conn, *, vendor, start_at, now_utc) -> float:
562
617
 
563
618
  def _reconcile_budget_milestones_on_set(
564
619
  conn, *, vendor, target, thresholds, now_utc, period, config=None, tz=None,
620
+ as_of=None, commit=True,
565
621
  ):
566
622
  """Forward-only-from-set reconcile for the budget axis (both vendors, #143):
567
623
  on `budget set`, every threshold ALREADY crossed for the current
@@ -605,6 +661,7 @@ def _reconcile_budget_milestones_on_set(
605
661
  spent_usd=spent,
606
662
  consumption_pct=consumption_pct,
607
663
  commit=False,
664
+ as_of=as_of,
608
665
  )
609
666
  # alerted_at UPDATE keys on the CONCRETE (vendor, period) (not the
610
667
  # wildcard): only the row we just inserted is stamped, never a
@@ -613,9 +670,10 @@ def _reconcile_budget_milestones_on_set(
613
670
  "UPDATE budget_milestones SET alerted_at = ? "
614
671
  "WHERE vendor = ? AND period_start_at = ? AND period = ? "
615
672
  " AND threshold = ? AND alerted_at IS NULL",
616
- (now_utc_iso(), vendor, period_key, period, t),
673
+ (as_of or now_utc_iso(), vendor, period_key, period, t),
617
674
  )
618
- conn.commit()
675
+ if commit:
676
+ conn.commit()
619
677
 
620
678
 
621
679
  def _resolve_codex_budget_period_window(period, now_utc, config, tz):
@@ -631,7 +689,8 @@ def _resolve_codex_budget_period_window(period, now_utc, config, tz):
631
689
 
632
690
 
633
691
  def _budget_crossings(
634
- conn, *, vendor, period_key, period=None, thresholds, target, spent, now_utc
692
+ conn, *, vendor, period_key, period=None, thresholds, target, spent, now_utc,
693
+ as_of=None,
635
694
  ):
636
695
  """Shared INSERT-and-arm core for the budget axis (both vendors, #143): for
637
696
  every STILL-pending threshold that's been crossed at ``spent``, ``INSERT OR
@@ -667,9 +726,10 @@ def _budget_crossings(
667
726
  spent_usd=spent,
668
727
  consumption_pct=consumption_pct,
669
728
  commit=False,
729
+ as_of=as_of,
670
730
  )
671
731
  if inserted == 1:
672
- crossed_at = now_utc_iso()
732
+ crossed_at = as_of or now_utc_iso()
673
733
  # alerted_at UPDATE keys on the CONCRETE (vendor, period) (#137 /
674
734
  # #143): only the row just inserted is stamped, never a pre-011
675
735
  # NULL-period sibling or another vendor's row.
@@ -683,13 +743,18 @@ def _budget_crossings(
683
743
  return fired
684
744
 
685
745
 
686
- def _reconcile_codex_budget_on_config_write(validated_budget):
746
+ def _reconcile_codex_budget_on_config_write(validated_budget, *, conn=None, as_of=None):
687
747
  """Forward-only reconcile shared by the Codex-budget config write paths
688
748
  (`budget set --vendor codex`, `config set budget.codex`). Gated +
689
749
  best-effort: a Codex budget with alerts off or no thresholds records
690
750
  nothing; a stats.db failure never fails the write. Runs OUTSIDE any
691
751
  config_writer_lock (open_db has its own locking). Mirrors
692
- :func:`_reconcile_budget_on_config_write`."""
752
+ :func:`_reconcile_budget_on_config_write`.
753
+
754
+ Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
755
+ ``conn`` runs the reconcile on the caller's connection (no internal
756
+ open/commit/close); ``as_of`` (ISO-Z) injects the capture time. Both
757
+ defaults keep the legacy own-connection, wall-clock behavior."""
693
758
  codex = (validated_budget or {}).get("codex")
694
759
  if not codex:
695
760
  return
@@ -697,57 +762,72 @@ def _reconcile_codex_budget_on_config_write(validated_budget):
697
762
  if not (codex.get("alerts_enabled") and codex.get("amount_usd") and thresholds):
698
763
  return
699
764
  c = _cctally()
765
+ own_conn = conn is None
700
766
  try:
701
767
  import argparse
702
768
  config = c.load_config()
703
769
  tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
704
- conn = open_db()
770
+ if own_conn:
771
+ conn = open_db()
705
772
  try:
706
773
  _reconcile_budget_milestones_on_set(
707
774
  conn,
708
775
  vendor="codex",
709
776
  target=codex["amount_usd"],
710
777
  thresholds=thresholds,
711
- now_utc=_command_as_of(),
778
+ now_utc=_as_of_or_command(as_of),
712
779
  period=codex["period"],
713
780
  config=config,
714
781
  tz=tz,
782
+ as_of=as_of,
783
+ commit=own_conn,
715
784
  )
716
785
  finally:
717
- conn.close()
786
+ if own_conn:
787
+ conn.close()
718
788
  except Exception as exc: # best-effort; never fail the write
719
789
  eprint(f"[codex-budget-milestone] reconcile on set failed: {exc}")
720
790
 
721
791
 
722
- def _reconcile_budget_on_config_write(validated_budget):
792
+ def _reconcile_budget_on_config_write(validated_budget, *, conn=None, as_of=None):
723
793
  """Forward-only reconcile shared by all three budget-config write
724
794
  paths (`budget set`, `config set budget.*`, dashboard POST
725
795
  /api/settings). Gated + best-effort: a budget with alerts off or no
726
796
  thresholds records nothing; a stats.db failure never fails the write.
727
- Runs OUTSIDE any config_writer_lock (open_db has its own locking)."""
797
+ Runs OUTSIDE any config_writer_lock (open_db has its own locking).
798
+
799
+ Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
800
+ ``conn`` runs the reconcile on the caller's connection (no internal
801
+ open/commit/close); ``as_of`` (ISO-Z) injects the capture time. Both
802
+ defaults keep the legacy own-connection, wall-clock behavior."""
728
803
  thresholds = validated_budget.get("alert_thresholds") or []
729
804
  if not (_budget_alerts_active(validated_budget) and thresholds):
730
805
  return
731
806
  c = _cctally()
732
807
  period = validated_budget.get("period", "subscription-week")
808
+ own_conn = conn is None
733
809
  try:
734
810
  import argparse
735
811
  config = c.load_config()
736
812
  tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
737
- conn = open_db()
813
+ if own_conn:
814
+ conn = open_db()
738
815
  try:
739
816
  _reconcile_budget_milestones_on_set(
740
817
  conn,
741
818
  vendor="claude",
742
819
  target=validated_budget["weekly_usd"],
743
820
  thresholds=thresholds,
744
- now_utc=_command_as_of(),
821
+ now_utc=_as_of_or_command(as_of),
745
822
  period=period,
746
823
  config=config,
747
824
  tz=tz,
825
+ as_of=as_of,
826
+ commit=own_conn,
748
827
  )
749
828
  finally:
750
- conn.close()
829
+ if own_conn:
830
+ conn.close()
751
831
  except Exception as exc: # best-effort; never fail the write
752
832
  eprint(f"[budget-milestone] reconcile on set failed: {exc}")
753
833
 
@@ -770,10 +850,15 @@ def _project_crossings(items, thresholds, by_proj):
770
850
 
771
851
 
772
852
  def _reconcile_project_budget_milestones_on_write(
773
- validated_budget, touched_projects=None
853
+ validated_budget, touched_projects=None, *, conn=None, as_of=None
774
854
  ):
775
855
  """Forward-only-from-write reconcile for PER-PROJECT budgets (spec §6.8).
776
856
 
857
+ Transaction-neutral / capture-time-pure seam (DB journal redesign §5.2.3):
858
+ ``conn`` runs the reconcile on the caller's connection (no internal
859
+ open/commit/close); ``as_of`` (ISO-Z) injects the capture time. Both
860
+ defaults keep the legacy own-connection, wall-clock behavior.
861
+
777
862
  Shared by all four per-project write surfaces: ``budget set --project`` /
778
863
  ``unset --project`` (call sites in ``_cmd_budget_set_project`` /
779
864
  ``_cmd_budget_unset_project``), ``config set budget.projects`` /
@@ -818,10 +903,12 @@ def _reconcile_project_budget_milestones_on_write(
818
903
  ):
819
904
  return
820
905
  c = _cctally()
906
+ own_conn = conn is None
821
907
  try:
822
- conn = open_db()
908
+ if own_conn:
909
+ conn = open_db()
823
910
  try:
824
- now_utc = _command_as_of()
911
+ now_utc = _as_of_or_command(as_of)
825
912
  window = c._resolve_current_budget_window(conn, now_utc)
826
913
  if window is None:
827
914
  return
@@ -852,16 +939,19 @@ def _reconcile_project_budget_milestones_on_write(
852
939
  spent_usd=spent,
853
940
  consumption_pct=consumption_pct,
854
941
  commit=False,
942
+ as_of=as_of,
855
943
  )
856
944
  conn.execute(
857
945
  "UPDATE project_budget_milestones SET alerted_at = ? "
858
946
  "WHERE week_start_at = ? AND project_key = ? "
859
947
  " AND threshold = ? AND alerted_at IS NULL",
860
- (now_utc_iso(), week_key, project_key, t),
948
+ (as_of or now_utc_iso(), week_key, project_key, t),
861
949
  )
862
- conn.commit()
950
+ if own_conn:
951
+ conn.commit()
863
952
  finally:
864
- conn.close()
953
+ if own_conn:
954
+ conn.close()
865
955
  except Exception as exc: # best-effort; never fail the write
866
956
  eprint(
867
957
  f"[project-budget-milestone] reconcile on write failed: {exc}"
@@ -2834,13 +2834,14 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2834
2834
  db_unskip.set_defaults(func=c.cmd_db_unskip)
2835
2835
  db_recover = db_sub.add_parser(
2836
2836
  "recover",
2837
- help="Revert a version-ahead DB to the known schema head (#145)",
2837
+ help="Revert a version-ahead cache.db to the known schema head (#145; "
2838
+ "--db stats is retired — use `db rebuild --db stats`)",
2838
2839
  )
2839
2840
  db_recover.add_argument(
2840
2841
  "--db",
2841
2842
  required=True,
2842
2843
  choices=("cache", "stats"),
2843
- help="Which DB to recover",
2844
+ help="Which DB to recover (stats is retired; rebuild it instead)",
2844
2845
  )
2845
2846
  db_recover.add_argument(
2846
2847
  "--yes",
@@ -2848,9 +2849,26 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2848
2849
  help="Required for --db stats (non-re-derivable; may need a re-record)",
2849
2850
  )
2850
2851
  db_recover.set_defaults(func=c.cmd_db_recover)
2852
+ db_rebuild = db_sub.add_parser(
2853
+ "rebuild",
2854
+ help="Rebuild stats.db from the append-only journal (disposable index)",
2855
+ )
2856
+ db_rebuild.add_argument(
2857
+ "--db",
2858
+ required=True,
2859
+ choices=("stats",),
2860
+ help="Database to rebuild (stats only; rebuild cache.db via cache-sync)",
2861
+ )
2862
+ db_rebuild.add_argument(
2863
+ "--json",
2864
+ action="store_true",
2865
+ help="Emit JSON to stdout",
2866
+ )
2867
+ db_rebuild.set_defaults(func=c.cmd_db_rebuild)
2851
2868
  db_repair = db_sub.add_parser(
2852
2869
  "repair",
2853
- help="Recover a malformed stats.db through a verified fresh copy",
2870
+ help="Recover a malformed pre-cutover stats.db through a verified fresh "
2871
+ "copy (transitional — post-cutover this folds into journal auto-heal)",
2854
2872
  )
2855
2873
  db_repair.add_argument(
2856
2874
  "--db",