cctally 1.27.1 → 1.29.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 +37 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +355 -31
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +204 -42
- package/bin/_cctally_dashboard.py +510 -61
- package/bin/_cctally_db.py +756 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +252 -47
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +380 -133
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +12 -2
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +177 -0
- package/bin/_lib_conversation_query.py +620 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_jsonl.py +69 -50
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +29 -2
- package/dashboard/static/assets/index-BGaWg6ys.js +47 -0
- package/dashboard/static/assets/{index-D34qf0LE.css → index-BqQ5xdX0.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
|
@@ -331,32 +331,45 @@ def insert_percent_milestone(
|
|
|
331
331
|
def insert_budget_milestone(
|
|
332
332
|
conn: sqlite3.Connection,
|
|
333
333
|
*,
|
|
334
|
-
|
|
334
|
+
vendor: str,
|
|
335
|
+
period_start_at: str,
|
|
336
|
+
period: "str | None" = None,
|
|
335
337
|
threshold: int,
|
|
336
338
|
budget_usd: float,
|
|
337
339
|
spent_usd: float,
|
|
338
340
|
consumption_pct: float,
|
|
339
341
|
commit: bool = True,
|
|
340
342
|
) -> int:
|
|
341
|
-
"""INSERT OR IGNORE a budget threshold crossing
|
|
342
|
-
(1 = genuinely new crossing, 0 =
|
|
343
|
-
``(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
the
|
|
349
|
-
|
|
350
|
-
|
|
343
|
+
"""INSERT OR IGNORE a budget threshold crossing into the unified vendor-tagged
|
|
344
|
+
table (#143). Returns ``cur.rowcount`` (1 = genuinely new crossing, 0 =
|
|
345
|
+
INSERT OR IGNORE no-op on a pre-existing ``(vendor, period_start_at, period,
|
|
346
|
+
threshold)`` row).
|
|
347
|
+
|
|
348
|
+
The merged ``budget_milestones`` table (migration 012) carries a ``vendor``
|
|
349
|
+
column (``'claude'``|``'codex'``) and the renamed ``period_start_at`` key
|
|
350
|
+
(the Claude subscription-week start OR the Codex calendar-period start —
|
|
351
|
+
Codex has no Anthropic subscription week, spec §6). Mirrors
|
|
352
|
+
:func:`insert_percent_milestone`'s rowcount contract so the alert-fire
|
|
353
|
+
predicate (`if inserted == 1`) is race-safe without a follow-up SELECT.
|
|
354
|
+
``period`` (#137) is the configured period noun at crossing
|
|
355
|
+
('calendar-week'|'calendar-month'|'subscription-week'); it discriminates the
|
|
356
|
+
UNIQUE key so calendar-week and calendar-month windows that share a start
|
|
357
|
+
instant don't collide. A NULL ``period`` is the pre-011 "unknown" sentinel
|
|
358
|
+
(only seeded migration rows carry it). ``alerted_at`` is left NULL — the
|
|
359
|
+
caller stamps it in the SAME transaction BEFORE dispatching (set-then-dispatch
|
|
360
|
+
invariant, CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle
|
|
361
|
+
the INSERT with the follow-up ``alerted_at`` UPDATE in one transaction so a
|
|
351
362
|
crash between them can't strand ``alerted_at`` NULL forever.
|
|
352
363
|
"""
|
|
353
364
|
cur = conn.execute(
|
|
354
365
|
"INSERT OR IGNORE INTO budget_milestones "
|
|
355
|
-
"(
|
|
356
|
-
" crossed_at_utc) "
|
|
357
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
366
|
+
"(vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
|
|
367
|
+
" consumption_pct, crossed_at_utc) "
|
|
368
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
358
369
|
(
|
|
359
|
-
|
|
370
|
+
str(vendor),
|
|
371
|
+
period_start_at,
|
|
372
|
+
period,
|
|
360
373
|
int(threshold),
|
|
361
374
|
float(budget_usd),
|
|
362
375
|
float(spent_usd),
|
|
@@ -419,6 +432,7 @@ def insert_projected_milestone(
|
|
|
419
432
|
conn: sqlite3.Connection,
|
|
420
433
|
*,
|
|
421
434
|
week_start_at: str,
|
|
435
|
+
period: "str | None" = None,
|
|
422
436
|
metric: str,
|
|
423
437
|
threshold: int,
|
|
424
438
|
projected_value: float,
|
|
@@ -427,23 +441,27 @@ def insert_projected_milestone(
|
|
|
427
441
|
) -> int:
|
|
428
442
|
"""INSERT OR IGNORE a projected-pace crossing. Returns ``cur.rowcount``
|
|
429
443
|
(1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
|
|
430
|
-
``(week_start_at, metric, threshold)`` row).
|
|
444
|
+
``(week_start_at, period, metric, threshold)`` row).
|
|
431
445
|
|
|
432
446
|
Mirrors :func:`insert_budget_milestone`'s rowcount contract so the
|
|
433
447
|
alert-fire predicate (`if inserted == 1`) is race-safe without a follow-up
|
|
434
|
-
SELECT. ``
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
448
|
+
SELECT. ``period`` (#137) is the configured period at crossing — for the
|
|
449
|
+
``weekly_pct`` leg it is 'subscription-week', for ``budget_usd`` the Claude
|
|
450
|
+
configured period, for ``codex_budget_usd`` the Codex configured period;
|
|
451
|
+
NULL is the pre-011 unknown sentinel. ``alerted_at`` is left NULL — the
|
|
452
|
+
caller stamps it in the SAME transaction BEFORE dispatching (set-then-
|
|
453
|
+
dispatch invariant, CLAUDE.md Alerts gotcha). ``commit=False`` lets the
|
|
454
|
+
caller bundle the INSERT with the follow-up ``alerted_at`` UPDATE in one
|
|
455
|
+
transaction so a crash between them can't strand ``alerted_at`` NULL forever.
|
|
439
456
|
"""
|
|
440
457
|
cur = conn.execute(
|
|
441
458
|
"INSERT OR IGNORE INTO projected_milestones "
|
|
442
|
-
"(week_start_at, metric, threshold, projected_value,
|
|
443
|
-
" crossed_at_utc) "
|
|
444
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
459
|
+
"(week_start_at, period, metric, threshold, projected_value, "
|
|
460
|
+
" denominator, crossed_at_utc) "
|
|
461
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
445
462
|
(
|
|
446
463
|
week_start_at,
|
|
464
|
+
period,
|
|
447
465
|
str(metric),
|
|
448
466
|
int(threshold),
|
|
449
467
|
float(projected_value),
|
|
@@ -460,69 +478,247 @@ def _projected_levels_already_latched(
|
|
|
460
478
|
conn: sqlite3.Connection,
|
|
461
479
|
*,
|
|
462
480
|
week_start_at: str,
|
|
481
|
+
period: "str | None" = None,
|
|
463
482
|
metric: str,
|
|
464
483
|
levels: "tuple[int, ...]",
|
|
465
484
|
) -> bool:
|
|
466
485
|
"""True iff EVERY level in ``levels`` already has a row for
|
|
467
|
-
``(week_start_at, metric)``.
|
|
486
|
+
``(week_start_at, period, metric)``.
|
|
468
487
|
|
|
469
488
|
Cheap indexed SELECT used as the pre-probe gate BEFORE any projection math
|
|
470
489
|
/ cost work ([Pre-probe before sync_cache]). Empty ``levels`` → True
|
|
471
490
|
(nothing owed). When False, at least one level is still un-recorded and the
|
|
472
491
|
caller must do the projection. Mirrors the per-week pre-probe SELECT in
|
|
473
492
|
:func:`maybe_record_budget_milestone`.
|
|
493
|
+
|
|
494
|
+
The ``period IS NULL`` arm (#137) means a pre-011 NULL-period row for the
|
|
495
|
+
current window counts as latched — so an upgrading user never re-fires a
|
|
496
|
+
spurious projected alert against a historical crossing.
|
|
474
497
|
"""
|
|
475
498
|
if not levels:
|
|
476
499
|
return True
|
|
477
500
|
rows = conn.execute(
|
|
478
501
|
"SELECT threshold FROM projected_milestones "
|
|
479
|
-
"WHERE week_start_at = ? AND
|
|
480
|
-
|
|
502
|
+
"WHERE week_start_at = ? AND (period = ? OR period IS NULL) "
|
|
503
|
+
" AND metric = ?",
|
|
504
|
+
(week_start_at, period, str(metric)),
|
|
481
505
|
).fetchall()
|
|
482
506
|
have = {int(r[0]) for r in rows}
|
|
483
507
|
return all(int(level) in have for level in levels)
|
|
484
508
|
|
|
485
509
|
|
|
486
|
-
def
|
|
487
|
-
"""
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
guard on the UPDATE (so an existing alerted row is never re-stamped).
|
|
496
|
-
"""
|
|
510
|
+
def _resolve_claude_budget_window(conn, now_utc, *, period, config, tz):
|
|
511
|
+
"""Resolve the Claude budget's ``(start_utc, end_utc)`` window for the
|
|
512
|
+
configured ``period`` (calendar-period-codex-budgets generalization, spec
|
|
513
|
+
§6). Subscription-week → the existing ``_resolve_current_budget_window``
|
|
514
|
+
(snapshot-anchored; may return ``None`` when no usage snapshot has landed
|
|
515
|
+
yet). Calendar period → the pure ``_resolve_calendar_window`` (derived purely
|
|
516
|
+
from ``now`` + the period; NEVER ``None``). The dedup key column is now
|
|
517
|
+
``period_start_at`` (#143) — it carries the resolved PERIOD-start instant
|
|
518
|
+
(subscription-week OR calendar period-start)."""
|
|
497
519
|
c = _cctally()
|
|
498
|
-
|
|
520
|
+
if period == "subscription-week":
|
|
521
|
+
return c._resolve_current_budget_window(conn, now_utc)
|
|
522
|
+
return c._resolve_calendar_window(period, now_utc, config, tz)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _resolve_budget_window(conn, *, vendor, now_utc, period, config, tz):
|
|
526
|
+
"""Resolve the budget period-start instant for ``vendor`` (#143). CHEAP — does
|
|
527
|
+
NO cost SUM, preserving the pre-probe-before-spend hot path (spec §4.2): the
|
|
528
|
+
firing paths resolve this cheap window, pre-probe which thresholds are already
|
|
529
|
+
latched, and skip the cost SUM entirely when nothing is pending.
|
|
530
|
+
|
|
531
|
+
Dispatches to the per-vendor window primitive:
|
|
532
|
+
* claude → :func:`_resolve_claude_budget_window` (snapshot-anchored for
|
|
533
|
+
subscription-week → may be ``None`` pre-snapshot; calendar period → the
|
|
534
|
+
pure calendar window, never ``None``).
|
|
535
|
+
* codex → :func:`_resolve_codex_budget_period_window` (pure calendar window;
|
|
536
|
+
never ``None``).
|
|
537
|
+
|
|
538
|
+
Returns the period-start ``datetime`` or ``None`` (claude subscription-week
|
|
539
|
+
pre-snapshot)."""
|
|
540
|
+
if vendor == "claude":
|
|
541
|
+
window = _resolve_claude_budget_window(
|
|
542
|
+
conn, now_utc, period=period, config=config, tz=tz
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
window = _resolve_codex_budget_period_window(period, now_utc, config, tz)
|
|
499
546
|
if window is None:
|
|
547
|
+
return None
|
|
548
|
+
start_at, _end_at = window
|
|
549
|
+
return start_at
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _budget_spend_for_vendor(conn, *, vendor, start_at, now_utc) -> float:
|
|
553
|
+
"""Spend over ``[start_at, now]`` for ``vendor`` (#143) — the COSTLY leg,
|
|
554
|
+
called only after the pre-probe finds pending thresholds (spec §4.2). claude
|
|
555
|
+
routes through the Claude cost SUM (``mode="auto"``); codex through the Codex
|
|
556
|
+
cost SUM."""
|
|
557
|
+
c = _cctally()
|
|
558
|
+
if vendor == "claude":
|
|
559
|
+
return c._sum_cost_for_range(start_at, now_utc, mode="auto")
|
|
560
|
+
return c._sum_codex_cost_for_range(start_at, now_utc)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _reconcile_budget_milestones_on_set(
|
|
564
|
+
conn, *, vendor, target, thresholds, now_utc, period, config=None, tz=None,
|
|
565
|
+
):
|
|
566
|
+
"""Forward-only-from-set reconcile for the budget axis (both vendors, #143):
|
|
567
|
+
on `budget set`, every threshold ALREADY crossed for the current
|
|
568
|
+
window/period is recorded with ``alerted_at`` SET but WITHOUT dispatch — so
|
|
569
|
+
setting a budget when you're already at 95% does NOT instant-popup. Thresholds
|
|
570
|
+
not yet crossed get NO row, so they fire later via the firing path
|
|
571
|
+
(:func:`maybe_record_budget_milestone` / :func:`maybe_record_codex_budget_milestone`).
|
|
572
|
+
|
|
573
|
+
A mid-window target change re-runs this; thresholds already alerted stay
|
|
574
|
+
deduped via UNIQUE(vendor, period_start_at, period, threshold) + the
|
|
575
|
+
``alerted_at IS NULL`` guard on the UPDATE (so an existing alerted row is never
|
|
576
|
+
re-stamped).
|
|
577
|
+
|
|
578
|
+
Cold path — no pre-probe; resolves the cheap window then computes spend right
|
|
579
|
+
after (spec §4.2 ordering). ``vendor`` selects the window + spend dispatcher;
|
|
580
|
+
claude subscription-week may resolve ``None`` pre-snapshot (early return).
|
|
581
|
+
Keeps its own stamp-no-dispatch tail (distinct from :func:`_budget_crossings`,
|
|
582
|
+
which dispatches) — that asymmetry is intrinsic, not duplication.
|
|
583
|
+
"""
|
|
584
|
+
start_at = _resolve_budget_window(
|
|
585
|
+
conn, vendor=vendor, now_utc=now_utc, period=period, config=config, tz=tz
|
|
586
|
+
)
|
|
587
|
+
if start_at is None:
|
|
500
588
|
return
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
#
|
|
589
|
+
period_key = start_at.isoformat(timespec="seconds")
|
|
590
|
+
spent = _budget_spend_for_vendor(
|
|
591
|
+
conn, vendor=vendor, start_at=start_at, now_utc=now_utc
|
|
592
|
+
)
|
|
593
|
+
# target > 0 guaranteed by the caller (validated weekly_usd / amount_usd);
|
|
594
|
+
# the else is belt-and-suspenders.
|
|
506
595
|
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
507
596
|
for t in sorted(thresholds):
|
|
508
597
|
if consumption_pct + 1e-9 >= t:
|
|
509
598
|
insert_budget_milestone(
|
|
510
599
|
conn,
|
|
511
|
-
|
|
600
|
+
vendor=vendor,
|
|
601
|
+
period_start_at=period_key,
|
|
602
|
+
period=period,
|
|
512
603
|
threshold=t,
|
|
513
604
|
budget_usd=target,
|
|
514
605
|
spent_usd=spent,
|
|
515
606
|
consumption_pct=consumption_pct,
|
|
516
607
|
commit=False,
|
|
517
608
|
)
|
|
609
|
+
# alerted_at UPDATE keys on the CONCRETE (vendor, period) (not the
|
|
610
|
+
# wildcard): only the row we just inserted is stamped, never a
|
|
611
|
+
# pre-011 NULL-period sibling (#137) or another vendor's row (#143).
|
|
518
612
|
conn.execute(
|
|
519
613
|
"UPDATE budget_milestones SET alerted_at = ? "
|
|
520
|
-
"WHERE
|
|
521
|
-
|
|
614
|
+
"WHERE vendor = ? AND period_start_at = ? AND period = ? "
|
|
615
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
616
|
+
(now_utc_iso(), vendor, period_key, period, t),
|
|
522
617
|
)
|
|
523
618
|
conn.commit()
|
|
524
619
|
|
|
525
620
|
|
|
621
|
+
def _resolve_codex_budget_period_window(period, now_utc, config, tz):
|
|
622
|
+
"""Resolve the Codex budget's ``(start_utc, end_utc)`` calendar window via
|
|
623
|
+
the forecast layer's ``_resolve_calendar_window`` (which routes to the pure
|
|
624
|
+
``calendar_month_window`` / ``calendar_week_window`` kernel functions). The
|
|
625
|
+
Codex axis NEVER touches ``weekly_usage_snapshots`` (no Anthropic week), so
|
|
626
|
+
the window comes purely from ``now`` + the configured calendar period.
|
|
627
|
+
``period`` is canonical (calendar-week / calendar-month — the validator
|
|
628
|
+
forbids subscription-week for Codex)."""
|
|
629
|
+
c = _cctally()
|
|
630
|
+
return c._resolve_calendar_window(period, now_utc, config, tz)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _budget_crossings(
|
|
634
|
+
conn, *, vendor, period_key, period=None, thresholds, target, spent, now_utc
|
|
635
|
+
):
|
|
636
|
+
"""Shared INSERT-and-arm core for the budget axis (both vendors, #143): for
|
|
637
|
+
every STILL-pending threshold that's been crossed at ``spent``, ``INSERT OR
|
|
638
|
+
IGNORE`` a milestone (commit=False) into the unified vendor-tagged table and —
|
|
639
|
+
on the genuine-new-crossing winner (rowcount==1) — stamp ``alerted_at`` in the
|
|
640
|
+
SAME transaction (set-then-dispatch), returning the list of crossings the
|
|
641
|
+
caller must dispatch.
|
|
642
|
+
|
|
643
|
+
Pure of config/window resolution: both firing sites (record-usage +
|
|
644
|
+
opportunistic ``cctally budget``), for either vendor, feed the
|
|
645
|
+
already-resolved ``vendor`` / ``period_key`` / ``target`` / ``spent`` here, so
|
|
646
|
+
the crossing arithmetic + the set-then-dispatch invariant live in ONE place.
|
|
647
|
+
Does NOT commit — the caller owns the single durable commit that bundles every
|
|
648
|
+
INSERT with its ``alerted_at`` UPDATE. Applies the +1e-9 float-floor snap
|
|
649
|
+
(CLAUDE.md gotcha). Returns ``[(threshold, crossed_at, spent, target,
|
|
650
|
+
consumption_pct), ...]`` for the rowcount==1 winners only.
|
|
651
|
+
|
|
652
|
+
Forward-only / fire-once is enforced by INSERT OR IGNORE's rowcount on the
|
|
653
|
+
UNIQUE(vendor, period_start_at, period, threshold) key; a racing record-usage
|
|
654
|
+
instance OR an already-recorded threshold gets rowcount==0 and is skipped
|
|
655
|
+
([Dedup mustn't gate side effects])."""
|
|
656
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
657
|
+
fired: "list" = []
|
|
658
|
+
for t in sorted(thresholds):
|
|
659
|
+
if consumption_pct + 1e-9 >= t:
|
|
660
|
+
inserted = insert_budget_milestone(
|
|
661
|
+
conn,
|
|
662
|
+
vendor=vendor,
|
|
663
|
+
period_start_at=period_key,
|
|
664
|
+
period=period,
|
|
665
|
+
threshold=t,
|
|
666
|
+
budget_usd=target,
|
|
667
|
+
spent_usd=spent,
|
|
668
|
+
consumption_pct=consumption_pct,
|
|
669
|
+
commit=False,
|
|
670
|
+
)
|
|
671
|
+
if inserted == 1:
|
|
672
|
+
crossed_at = now_utc_iso()
|
|
673
|
+
# alerted_at UPDATE keys on the CONCRETE (vendor, period) (#137 /
|
|
674
|
+
# #143): only the row just inserted is stamped, never a pre-011
|
|
675
|
+
# NULL-period sibling or another vendor's row.
|
|
676
|
+
conn.execute(
|
|
677
|
+
"UPDATE budget_milestones SET alerted_at = ? "
|
|
678
|
+
"WHERE vendor = ? AND period_start_at = ? AND period = ? "
|
|
679
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
680
|
+
(crossed_at, vendor, period_key, period, t),
|
|
681
|
+
)
|
|
682
|
+
fired.append((t, crossed_at, spent, target, consumption_pct))
|
|
683
|
+
return fired
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _reconcile_codex_budget_on_config_write(validated_budget):
|
|
687
|
+
"""Forward-only reconcile shared by the Codex-budget config write paths
|
|
688
|
+
(`budget set --vendor codex`, `config set budget.codex`). Gated +
|
|
689
|
+
best-effort: a Codex budget with alerts off or no thresholds records
|
|
690
|
+
nothing; a stats.db failure never fails the write. Runs OUTSIDE any
|
|
691
|
+
config_writer_lock (open_db has its own locking). Mirrors
|
|
692
|
+
:func:`_reconcile_budget_on_config_write`."""
|
|
693
|
+
codex = (validated_budget or {}).get("codex")
|
|
694
|
+
if not codex:
|
|
695
|
+
return
|
|
696
|
+
thresholds = codex.get("alert_thresholds") or []
|
|
697
|
+
if not (codex.get("alerts_enabled") and codex.get("amount_usd") and thresholds):
|
|
698
|
+
return
|
|
699
|
+
c = _cctally()
|
|
700
|
+
try:
|
|
701
|
+
import argparse
|
|
702
|
+
config = c.load_config()
|
|
703
|
+
tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
|
|
704
|
+
conn = open_db()
|
|
705
|
+
try:
|
|
706
|
+
_reconcile_budget_milestones_on_set(
|
|
707
|
+
conn,
|
|
708
|
+
vendor="codex",
|
|
709
|
+
target=codex["amount_usd"],
|
|
710
|
+
thresholds=thresholds,
|
|
711
|
+
now_utc=_command_as_of(),
|
|
712
|
+
period=codex["period"],
|
|
713
|
+
config=config,
|
|
714
|
+
tz=tz,
|
|
715
|
+
)
|
|
716
|
+
finally:
|
|
717
|
+
conn.close()
|
|
718
|
+
except Exception as exc: # best-effort; never fail the write
|
|
719
|
+
eprint(f"[codex-budget-milestone] reconcile on set failed: {exc}")
|
|
720
|
+
|
|
721
|
+
|
|
526
722
|
def _reconcile_budget_on_config_write(validated_budget):
|
|
527
723
|
"""Forward-only reconcile shared by all three budget-config write
|
|
528
724
|
paths (`budget set`, `config set budget.*`, dashboard POST
|
|
@@ -532,14 +728,23 @@ def _reconcile_budget_on_config_write(validated_budget):
|
|
|
532
728
|
thresholds = validated_budget.get("alert_thresholds") or []
|
|
533
729
|
if not (_budget_alerts_active(validated_budget) and thresholds):
|
|
534
730
|
return
|
|
731
|
+
c = _cctally()
|
|
732
|
+
period = validated_budget.get("period", "subscription-week")
|
|
535
733
|
try:
|
|
734
|
+
import argparse
|
|
735
|
+
config = c.load_config()
|
|
736
|
+
tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
|
|
536
737
|
conn = open_db()
|
|
537
738
|
try:
|
|
538
739
|
_reconcile_budget_milestones_on_set(
|
|
539
740
|
conn,
|
|
741
|
+
vendor="claude",
|
|
540
742
|
target=validated_budget["weekly_usd"],
|
|
541
743
|
thresholds=thresholds,
|
|
542
744
|
now_utc=_command_as_of(),
|
|
745
|
+
period=period,
|
|
746
|
+
config=config,
|
|
747
|
+
tz=tz,
|
|
543
748
|
)
|
|
544
749
|
finally:
|
|
545
750
|
conn.close()
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -1290,6 +1290,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1290
1290
|
"--project", nargs="?", const="__CWD__", default=None,
|
|
1291
1291
|
help="Set/unset a per-project budget for this git repo "
|
|
1292
1292
|
"(bare = current directory's git-root; or pass a path).")
|
|
1293
|
+
bg.add_argument(
|
|
1294
|
+
"--vendor", choices=["claude", "codex"], default="claude",
|
|
1295
|
+
help="Which vendor budget to set/unset (default claude). Codex "
|
|
1296
|
+
"budgets are calendar-period only.")
|
|
1297
|
+
bg.add_argument(
|
|
1298
|
+
"--period",
|
|
1299
|
+
# Accept both canonical and short spellings; the command handler
|
|
1300
|
+
# normalizes short->canonical and rejects `--vendor codex
|
|
1301
|
+
# --period subscription-week` (Codex has no Anthropic week). The choices
|
|
1302
|
+
# are single-sourced from `_BUDGET_PERIOD_CHOICES` (derived from the same
|
|
1303
|
+
# short→canonical map the normalizer uses), so they can't drift from the
|
|
1304
|
+
# handler (code-review #5).
|
|
1305
|
+
choices=c._BUDGET_PERIOD_CHOICES,
|
|
1306
|
+
default=None,
|
|
1307
|
+
help="Budget period: subscription-week (claude only) / calendar-week "
|
|
1308
|
+
"/ calendar-month. Default: preserve the stored period, else the "
|
|
1309
|
+
"per-vendor default (claude=subscription-week, codex="
|
|
1310
|
+
"calendar-month).")
|
|
1293
1311
|
bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
1294
1312
|
help="Show real project basenames in the per-project section "
|
|
1295
1313
|
"of --format output (default anonymizes to project-1/…).")
|
|
@@ -2192,16 +2210,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2192
2210
|
cctally alerts test --axis five-hour --threshold 95
|
|
2193
2211
|
cctally alerts test --axis budget --threshold 100
|
|
2194
2212
|
cctally alerts test --axis project-budget --threshold 100
|
|
2213
|
+
cctally alerts test --axis codex-budget --threshold 100
|
|
2195
2214
|
cctally alerts test --axis projected --metric budget_usd
|
|
2215
|
+
cctally alerts test --axis projected --metric codex_budget_usd
|
|
2196
2216
|
"""),
|
|
2197
2217
|
)
|
|
2198
2218
|
p_alerts_test.add_argument(
|
|
2199
2219
|
"--axis",
|
|
2200
|
-
choices=[
|
|
2220
|
+
choices=[
|
|
2221
|
+
"weekly", "five-hour", "budget", "project-budget", "codex-budget",
|
|
2222
|
+
"projected",
|
|
2223
|
+
],
|
|
2201
2224
|
default="weekly",
|
|
2202
2225
|
help="Alert axis to simulate: weekly subscription window, 5h block, "
|
|
2203
|
-
"equiv-$ budget, per-project equiv-$ budget, or
|
|
2204
|
-
"(default: weekly).",
|
|
2226
|
+
"equiv-$ budget, per-project equiv-$ budget, Codex budget, or "
|
|
2227
|
+
"projected-pace (default: weekly).",
|
|
2205
2228
|
)
|
|
2206
2229
|
p_alerts_test.add_argument(
|
|
2207
2230
|
"--threshold",
|
|
@@ -2211,7 +2234,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2211
2234
|
)
|
|
2212
2235
|
p_alerts_test.add_argument(
|
|
2213
2236
|
"--metric",
|
|
2214
|
-
choices=["weekly_pct", "budget_usd"],
|
|
2237
|
+
choices=["weekly_pct", "budget_usd", "codex_budget_usd"],
|
|
2215
2238
|
default="weekly_pct",
|
|
2216
2239
|
help="For --axis projected: which projected metric to preview "
|
|
2217
2240
|
"(default: weekly_pct).",
|
|
@@ -2334,6 +2357,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2334
2357
|
)
|
|
2335
2358
|
db_unskip.set_defaults(func=c.cmd_db_unskip)
|
|
2336
2359
|
|
|
2360
|
+
db_recover = db_sub.add_parser(
|
|
2361
|
+
"recover",
|
|
2362
|
+
help="Revert a version-ahead DB to the known schema head (#145)",
|
|
2363
|
+
)
|
|
2364
|
+
db_recover.add_argument(
|
|
2365
|
+
"--db",
|
|
2366
|
+
required=True,
|
|
2367
|
+
choices=("cache", "stats"),
|
|
2368
|
+
help="Which DB to recover",
|
|
2369
|
+
)
|
|
2370
|
+
db_recover.add_argument(
|
|
2371
|
+
"--yes",
|
|
2372
|
+
action="store_true",
|
|
2373
|
+
help="Required for --db stats (non-re-derivable; may need a re-record)",
|
|
2374
|
+
)
|
|
2375
|
+
db_recover.set_defaults(func=c.cmd_db_recover)
|
|
2376
|
+
|
|
2337
2377
|
# ─── doctor (Diagnostics) ───────────────────────────────────────────
|
|
2338
2378
|
doctor_p = sub.add_parser(
|
|
2339
2379
|
"doctor",
|