cctally 1.23.0 → 1.25.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 +14 -0
- package/bin/_cctally_alerts.py +128 -24
- package/bin/_cctally_config.py +202 -11
- package/bin/_cctally_core.py +118 -0
- package/bin/_cctally_dashboard.py +193 -26
- package/bin/_cctally_forecast.py +480 -16
- package/bin/_cctally_milestones.py +146 -0
- package/bin/_cctally_parser.py +11 -4
- package/bin/_cctally_project.py +51 -0
- package/bin/_cctally_record.py +227 -1
- package/bin/_lib_alert_axes.py +21 -7
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +70 -0
- package/bin/cctally +19 -0
- package/dashboard/static/assets/index-C2F1_Mxt.js +18 -0
- package/dashboard/static/assets/{index-ZHOC14y-.css → index-D34qf0LE.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CXZDQrV3.js +0 -18
|
@@ -369,6 +369,52 @@ def insert_budget_milestone(
|
|
|
369
369
|
return int(cur.rowcount)
|
|
370
370
|
|
|
371
371
|
|
|
372
|
+
def insert_project_budget_milestone(
|
|
373
|
+
conn: sqlite3.Connection,
|
|
374
|
+
*,
|
|
375
|
+
week_start_at: str,
|
|
376
|
+
project_key: str,
|
|
377
|
+
threshold: int,
|
|
378
|
+
budget_usd: float,
|
|
379
|
+
spent_usd: float,
|
|
380
|
+
consumption_pct: float,
|
|
381
|
+
commit: bool = True,
|
|
382
|
+
) -> int:
|
|
383
|
+
"""INSERT OR IGNORE a per-project budget threshold crossing. Returns
|
|
384
|
+
``cur.rowcount`` (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a
|
|
385
|
+
pre-existing ``(week_start_at, project_key, threshold)`` row).
|
|
386
|
+
|
|
387
|
+
Mirrors :func:`insert_budget_milestone` EXACTLY, with ``project_key`` added
|
|
388
|
+
as the per-project dimension of the UNIQUE dedup key (spec §5.1) — each
|
|
389
|
+
project crosses each threshold once per week, independently. The rowcount
|
|
390
|
+
contract matches :func:`insert_percent_milestone` so the alert-fire predicate
|
|
391
|
+
(`if inserted == 1`) is race-safe without a follow-up SELECT. ``alerted_at``
|
|
392
|
+
is left NULL — the caller stamps it in the SAME transaction BEFORE
|
|
393
|
+
dispatching (set-then-dispatch invariant, CLAUDE.md Alerts gotcha).
|
|
394
|
+
``commit=False`` lets the caller bundle the INSERT with the follow-up
|
|
395
|
+
``alerted_at`` UPDATE in one transaction so a crash between them can't strand
|
|
396
|
+
``alerted_at`` NULL forever.
|
|
397
|
+
"""
|
|
398
|
+
cur = conn.execute(
|
|
399
|
+
"INSERT OR IGNORE INTO project_budget_milestones "
|
|
400
|
+
"(week_start_at, project_key, threshold, budget_usd, spent_usd, "
|
|
401
|
+
" consumption_pct, crossed_at_utc) "
|
|
402
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
403
|
+
(
|
|
404
|
+
week_start_at,
|
|
405
|
+
str(project_key),
|
|
406
|
+
int(threshold),
|
|
407
|
+
float(budget_usd),
|
|
408
|
+
float(spent_usd),
|
|
409
|
+
float(consumption_pct),
|
|
410
|
+
now_utc_iso(),
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
if commit:
|
|
414
|
+
conn.commit()
|
|
415
|
+
return int(cur.rowcount)
|
|
416
|
+
|
|
417
|
+
|
|
372
418
|
def insert_projected_milestone(
|
|
373
419
|
conn: sqlite3.Connection,
|
|
374
420
|
*,
|
|
@@ -499,3 +545,103 @@ def _reconcile_budget_on_config_write(validated_budget):
|
|
|
499
545
|
conn.close()
|
|
500
546
|
except Exception as exc: # best-effort; never fail the write
|
|
501
547
|
eprint(f"[budget-milestone] reconcile on set failed: {exc}")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _reconcile_project_budget_milestones_on_write(
|
|
551
|
+
validated_budget, touched_projects=None
|
|
552
|
+
):
|
|
553
|
+
"""Forward-only-from-write reconcile for PER-PROJECT budgets (spec §6.8).
|
|
554
|
+
|
|
555
|
+
Shared by all four per-project write surfaces: ``budget set --project`` /
|
|
556
|
+
``unset --project`` (call sites in ``_cmd_budget_set_project`` /
|
|
557
|
+
``_cmd_budget_unset_project``), ``config set budget.projects`` /
|
|
558
|
+
``config set budget.project_alerts_enabled`` (call site in
|
|
559
|
+
``_cmd_config_set``), and the dashboard ``project_alerts_enabled`` toggle
|
|
560
|
+
(POST /api/settings, Task 4). Mirrors :func:`_reconcile_budget_on_config_write`.
|
|
561
|
+
|
|
562
|
+
Mechanic: for each configured project, compute current-week spend (the
|
|
563
|
+
shared ``_sum_cost_by_project`` scan), and for each ALREADY-crossed
|
|
564
|
+
``(project, threshold)`` ``INSERT OR IGNORE`` a milestone with ``alerted_at``
|
|
565
|
+
stamped and **NO dispatch** — so setting a project budget mid-week (already
|
|
566
|
+
over) records the crossed thresholds as already-alerted without an
|
|
567
|
+
instant-popup; only LATER crossings fire via
|
|
568
|
+
:func:`maybe_record_project_budget_milestone`.
|
|
569
|
+
|
|
570
|
+
Dedup via ``UNIQUE(week_start_at, project_key, threshold)`` + the
|
|
571
|
+
``alerted_at IS NULL`` UPDATE guard, so a mid-week TARGET change never
|
|
572
|
+
re-stamps an already-alerted row (mirrors the global reconcile's
|
|
573
|
+
target-change semantics).
|
|
574
|
+
|
|
575
|
+
Gated: runs ONLY when per-project alerts are active (``projects`` non-empty
|
|
576
|
+
**and** ``project_alerts_enabled`` **and** ``alert_thresholds`` non-empty);
|
|
577
|
+
else records nothing. Best-effort — a stats.db failure never fails the config
|
|
578
|
+
write. Runs OUTSIDE any ``config_writer_lock`` (``open_db`` has its own
|
|
579
|
+
locking).
|
|
580
|
+
|
|
581
|
+
``touched_projects``: when not ``None``, reconcile ONLY these project keys.
|
|
582
|
+
The single-project CLI writes (``budget set/unset --project``) pass
|
|
583
|
+
``{root}`` so touching project A never latches a sibling project B's
|
|
584
|
+
already-crossed-but-not-yet-dispatched threshold — which would permanently
|
|
585
|
+
suppress B's real alert. ``None`` (config-set / dashboard toggle / wholesale
|
|
586
|
+
``budget.projects`` set) reconciles every configured project: the intended
|
|
587
|
+
"axis enabled / map redefined → suppress the retroactive storm for all
|
|
588
|
+
currently-over projects" semantics.
|
|
589
|
+
"""
|
|
590
|
+
projects = (validated_budget or {}).get("projects") or {}
|
|
591
|
+
thresholds = validated_budget.get("alert_thresholds") or []
|
|
592
|
+
if not (
|
|
593
|
+
projects
|
|
594
|
+
and validated_budget.get("project_alerts_enabled")
|
|
595
|
+
and thresholds
|
|
596
|
+
):
|
|
597
|
+
return
|
|
598
|
+
c = _cctally()
|
|
599
|
+
try:
|
|
600
|
+
conn = open_db()
|
|
601
|
+
try:
|
|
602
|
+
now_utc = _command_as_of()
|
|
603
|
+
window = c._resolve_current_budget_window(conn, now_utc)
|
|
604
|
+
if window is None:
|
|
605
|
+
return
|
|
606
|
+
week_start_at, _week_end_at = window
|
|
607
|
+
week_key = week_start_at.isoformat(timespec="seconds")
|
|
608
|
+
by_proj = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
|
|
609
|
+
items = (
|
|
610
|
+
projects.items()
|
|
611
|
+
if touched_projects is None
|
|
612
|
+
else [
|
|
613
|
+
(k, v) for k, v in projects.items()
|
|
614
|
+
if k in touched_projects
|
|
615
|
+
]
|
|
616
|
+
)
|
|
617
|
+
for project_key, target in items:
|
|
618
|
+
spent = float(by_proj.get(project_key, 0.0))
|
|
619
|
+
target = float(target)
|
|
620
|
+
consumption_pct = (
|
|
621
|
+
(spent / target * 100.0) if target > 0 else 0.0
|
|
622
|
+
)
|
|
623
|
+
for t in sorted(thresholds):
|
|
624
|
+
if consumption_pct + 1e-9 >= t:
|
|
625
|
+
insert_project_budget_milestone(
|
|
626
|
+
conn,
|
|
627
|
+
week_start_at=week_key,
|
|
628
|
+
project_key=project_key,
|
|
629
|
+
threshold=t,
|
|
630
|
+
budget_usd=target,
|
|
631
|
+
spent_usd=spent,
|
|
632
|
+
consumption_pct=consumption_pct,
|
|
633
|
+
commit=False,
|
|
634
|
+
)
|
|
635
|
+
conn.execute(
|
|
636
|
+
"UPDATE project_budget_milestones SET alerted_at = ? "
|
|
637
|
+
"WHERE week_start_at = ? AND project_key = ? "
|
|
638
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
639
|
+
(now_utc_iso(), week_key, project_key, t),
|
|
640
|
+
)
|
|
641
|
+
conn.commit()
|
|
642
|
+
finally:
|
|
643
|
+
conn.close()
|
|
644
|
+
except Exception as exc: # best-effort; never fail the write
|
|
645
|
+
eprint(
|
|
646
|
+
f"[project-budget-milestone] reconcile on write failed: {exc}"
|
|
647
|
+
)
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -1273,6 +1273,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1273
1273
|
cctally budget
|
|
1274
1274
|
cctally budget set 300
|
|
1275
1275
|
cctally budget unset
|
|
1276
|
+
cctally budget set 25 --project
|
|
1276
1277
|
cctally budget --json
|
|
1277
1278
|
cctally budget --format md
|
|
1278
1279
|
"""
|
|
@@ -1285,9 +1286,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1285
1286
|
bg.add_argument("--config", default=None,
|
|
1286
1287
|
help="Read status from this config file (read-only; "
|
|
1287
1288
|
"rejected on set/unset).")
|
|
1289
|
+
bg.add_argument(
|
|
1290
|
+
"--project", nargs="?", const="__CWD__", default=None,
|
|
1291
|
+
help="Set/unset a per-project budget for this git repo "
|
|
1292
|
+
"(bare = current directory's git-root; or pass a path).")
|
|
1288
1293
|
bg.add_argument("--reveal-projects", action="store_true", dest="reveal_projects",
|
|
1289
|
-
help="
|
|
1290
|
-
"(
|
|
1294
|
+
help="Show real project basenames in the per-project section "
|
|
1295
|
+
"of --format output (default anonymizes to project-1/…).")
|
|
1291
1296
|
bg.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
1292
1297
|
help="Display timezone: local, utc, or IANA name. "
|
|
1293
1298
|
"Overrides config display.tz for this call.")
|
|
@@ -2186,15 +2191,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2186
2191
|
cctally alerts test
|
|
2187
2192
|
cctally alerts test --axis five-hour --threshold 95
|
|
2188
2193
|
cctally alerts test --axis budget --threshold 100
|
|
2194
|
+
cctally alerts test --axis project-budget --threshold 100
|
|
2189
2195
|
cctally alerts test --axis projected --metric budget_usd
|
|
2190
2196
|
"""),
|
|
2191
2197
|
)
|
|
2192
2198
|
p_alerts_test.add_argument(
|
|
2193
2199
|
"--axis",
|
|
2194
|
-
choices=["weekly", "five-hour", "budget", "projected"],
|
|
2200
|
+
choices=["weekly", "five-hour", "budget", "project-budget", "projected"],
|
|
2195
2201
|
default="weekly",
|
|
2196
2202
|
help="Alert axis to simulate: weekly subscription window, 5h block, "
|
|
2197
|
-
"equiv-$ budget, or projected-pace
|
|
2203
|
+
"equiv-$ budget, per-project equiv-$ budget, or projected-pace "
|
|
2204
|
+
"(default: weekly).",
|
|
2198
2205
|
)
|
|
2199
2206
|
p_alerts_test.add_argument(
|
|
2200
2207
|
"--threshold",
|
package/bin/_cctally_project.py
CHANGED
|
@@ -81,6 +81,57 @@ def _load_week_snapshots(
|
|
|
81
81
|
conn.close()
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _sum_cost_by_project(
|
|
85
|
+
start: dt.datetime,
|
|
86
|
+
now: dt.datetime,
|
|
87
|
+
mode: str = "auto",
|
|
88
|
+
skip_sync: bool = False,
|
|
89
|
+
) -> dict[str, float]:
|
|
90
|
+
"""Return ``{canonical_git_root: spent_usd}`` over ``[start, now]``.
|
|
91
|
+
|
|
92
|
+
ONE scan over the joined session entries (the same iterator
|
|
93
|
+
``cmd_project`` walks), bucketed in Python by each entry's resolved
|
|
94
|
+
git-root (``_resolve_project_key`` — a filesystem ``.git`` walk, NOT a
|
|
95
|
+
SQL ``GROUP BY``), with per-entry cost computed via the same
|
|
96
|
+
``_calculate_entry_cost(model, usage, mode=...)`` path ``cmd_project``
|
|
97
|
+
uses (so pricing edits flow through uniformly). Keys are the resolved
|
|
98
|
+
``ProjectKey.bucket_path`` (the canonical git-root when a ``.git`` is
|
|
99
|
+
found, else the normalized path) — identical to how ``cmd_project``
|
|
100
|
+
keys its rows, so configured ``budget.projects`` keys match by string
|
|
101
|
+
equality.
|
|
102
|
+
|
|
103
|
+
Synthetic entries (Claude Code internal markers) are skipped, mirroring
|
|
104
|
+
``cmd_project`` / the other ``_JoinedClaudeEntry`` aggregators. A
|
|
105
|
+
configured project with no in-range entry simply never appears in the
|
|
106
|
+
returned map (the caller renders it as a ``$0`` row — spec §7.2).
|
|
107
|
+
|
|
108
|
+
Shared by the per-project budget display (§7.2, ``cmd_budget``) and the
|
|
109
|
+
alert-firing path (§6.4); ``skip_sync`` threads through to
|
|
110
|
+
``get_claude_session_entries`` so the record-tick caller can reuse a
|
|
111
|
+
cache already warmed earlier in the same tick.
|
|
112
|
+
"""
|
|
113
|
+
c = _cctally()
|
|
114
|
+
resolver_cache: dict[str, ProjectKey] = {}
|
|
115
|
+
out: dict[str, float] = {}
|
|
116
|
+
for entry in c.get_claude_session_entries(start, now, skip_sync=skip_sync):
|
|
117
|
+
if entry.model == "<synthetic>":
|
|
118
|
+
continue
|
|
119
|
+
cost = c._calculate_entry_cost(
|
|
120
|
+
entry.model,
|
|
121
|
+
{
|
|
122
|
+
"input_tokens": entry.input_tokens,
|
|
123
|
+
"output_tokens": entry.output_tokens,
|
|
124
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
125
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
126
|
+
},
|
|
127
|
+
mode=mode,
|
|
128
|
+
cost_usd=entry.cost_usd,
|
|
129
|
+
)
|
|
130
|
+
key = c._resolve_project_key(entry.project_path, "git-root", resolver_cache)
|
|
131
|
+
out[key.bucket_path] = out.get(key.bucket_path, 0.0) + cost
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
84
135
|
def _accumulate_entry_into_bucket(
|
|
85
136
|
b: dict,
|
|
86
137
|
entry: "_JoinedClaudeEntry",
|
package/bin/_cctally_record.py
CHANGED
|
@@ -265,6 +265,26 @@ def _build_alert_payload_budget(*args, **kwargs):
|
|
|
265
265
|
return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
|
|
266
266
|
|
|
267
267
|
|
|
268
|
+
def _build_alert_payload_project_budget(*args, **kwargs):
|
|
269
|
+
return sys.modules["cctally"]._build_alert_payload_project_budget(*args, **kwargs)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _sum_cost_by_project(*args, **kwargs):
|
|
273
|
+
return sys.modules["cctally"]._sum_cost_by_project(*args, **kwargs)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def insert_project_budget_milestone(*args, **kwargs):
|
|
277
|
+
return sys.modules["cctally"].insert_project_budget_milestone(*args, **kwargs)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _resolve_project_key(*args, **kwargs):
|
|
281
|
+
return sys.modules["cctally"]._resolve_project_key(*args, **kwargs)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _project_disambiguate_labels(*args, **kwargs):
|
|
285
|
+
return sys.modules["cctally"]._project_disambiguate_labels(*args, **kwargs)
|
|
286
|
+
|
|
287
|
+
|
|
268
288
|
def _get_budget_config(*args, **kwargs):
|
|
269
289
|
return sys.modules["cctally"]._get_budget_config(*args, **kwargs)
|
|
270
290
|
|
|
@@ -820,6 +840,180 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
820
840
|
eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
|
|
821
841
|
|
|
822
842
|
|
|
843
|
+
def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
|
|
844
|
+
"""Fire PER-PROJECT equiv-$ budget alerts on ACTUAL-spend threshold
|
|
845
|
+
crossings (spec §6 — called from ``cmd_record_usage`` alongside the
|
|
846
|
+
weekly-% / 5h-% / budget / projected milestone helpers). An independent
|
|
847
|
+
helper (its own ``load_config()`` / ``open_db()``), matching the existing
|
|
848
|
+
per-axis structure — NOT fused into ``maybe_record_budget_milestone``.
|
|
849
|
+
|
|
850
|
+
Gated, hot-path-cheap, pre-probed, set-then-dispatch, fire-once. Errors are
|
|
851
|
+
logged, not raised (the caller also wraps).
|
|
852
|
+
|
|
853
|
+
``saved`` is accepted for call-site symmetry with the sibling helpers but is
|
|
854
|
+
unused: each project's live spend is resolved from ``session_entries`` via
|
|
855
|
+
the shared ``_sum_cost_by_project`` scan, independent of the just-recorded
|
|
856
|
+
7d-% snapshot.
|
|
857
|
+
|
|
858
|
+
Invariants preserved byte-for-byte with the global budget path: gate-first,
|
|
859
|
+
pre-probe-before-the-cost-scan, ``rowcount==1`` race guard, set-then-dispatch.
|
|
860
|
+
The cost source is ``_sum_cost_by_project`` (NOT ``_sum_cost_for_range``): it
|
|
861
|
+
skips ``<synthetic>`` entries + buckets by canonical git-root, matching
|
|
862
|
+
``cmd_project`` and the per-project DISPLAY — so the firing path reconciles
|
|
863
|
+
exactly with the displayed ``consumption_pct``.
|
|
864
|
+
"""
|
|
865
|
+
# Gate FIRST (hot-path discipline): no per-project budget OR per-project
|
|
866
|
+
# alerts off → zero overhead for non-users. `load_config()` is safe outside
|
|
867
|
+
# any writer lock (atomic-rename). A malformed budget block is a quiet
|
|
868
|
+
# warn-once no-op (mirrors maybe_record_budget_milestone).
|
|
869
|
+
try:
|
|
870
|
+
budget_cfg = _get_budget_config(load_config())
|
|
871
|
+
except _BudgetConfigError as exc:
|
|
872
|
+
_warn_budget_bad_config_once(exc)
|
|
873
|
+
return
|
|
874
|
+
projects = budget_cfg.get("projects") or {}
|
|
875
|
+
if not projects or not budget_cfg.get("project_alerts_enabled"):
|
|
876
|
+
return
|
|
877
|
+
thresholds = budget_cfg["alert_thresholds"]
|
|
878
|
+
if not thresholds:
|
|
879
|
+
return
|
|
880
|
+
|
|
881
|
+
now_utc = _command_as_of()
|
|
882
|
+
pending_alerts: list[dict[str, Any]] = []
|
|
883
|
+
conn = open_db()
|
|
884
|
+
try:
|
|
885
|
+
window = _resolve_current_budget_window(conn, now_utc)
|
|
886
|
+
if window is None:
|
|
887
|
+
return # no resolvable week window yet
|
|
888
|
+
week_start_at, _week_end_at = window
|
|
889
|
+
week_key = week_start_at.isoformat(timespec="seconds")
|
|
890
|
+
|
|
891
|
+
# Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
|
|
892
|
+
# which configured (project, threshold) pairs are STILL un-recorded for
|
|
893
|
+
# this week? The cost scan is skipped ONLY when EVERY pair already has a
|
|
894
|
+
# row — so a partial prior run (some-but-not-all pairs) still scans for
|
|
895
|
+
# the remainder. The skip never owes a crossing: an un-recorded pair
|
|
896
|
+
# always forces the scan.
|
|
897
|
+
recorded = {
|
|
898
|
+
(str(r[0]), int(r[1]))
|
|
899
|
+
for r in conn.execute(
|
|
900
|
+
"SELECT project_key, threshold "
|
|
901
|
+
"FROM project_budget_milestones WHERE week_start_at = ?",
|
|
902
|
+
(week_key,),
|
|
903
|
+
)
|
|
904
|
+
}
|
|
905
|
+
sorted_thresholds = sorted(thresholds)
|
|
906
|
+
pending = [
|
|
907
|
+
(p, t)
|
|
908
|
+
for p in projects
|
|
909
|
+
for t in sorted_thresholds
|
|
910
|
+
if (p, t) not in recorded
|
|
911
|
+
]
|
|
912
|
+
if not pending:
|
|
913
|
+
return # nothing left to cross this week → skip the cost scan
|
|
914
|
+
|
|
915
|
+
# Resolve every configured key to its ProjectKey ONCE, then route the
|
|
916
|
+
# firing-path labels through the SAME collision-disambiguation primitive
|
|
917
|
+
# the display uses (`_build_project_budget_rows` → `cmd_project`'s table
|
|
918
|
+
# / share snapshot). A bare `display_key` is just the basename, so a
|
|
919
|
+
# uniquely-named project keeps its bare basename in the notification —
|
|
920
|
+
# byte-matching the table + dashboard chip — and only same-basename
|
|
921
|
+
# roots (`/work/app` + `/personal/app`) get the `(parent)` segment
|
|
922
|
+
# ("app (work)" / "app (personal)"). The configured set is small + the
|
|
923
|
+
# resolver caches, so this is near-free on the rare crossing tick.
|
|
924
|
+
resolver_cache: dict = {}
|
|
925
|
+
proj_keys = sorted(projects)
|
|
926
|
+
pkeys = [
|
|
927
|
+
_resolve_project_key(p, "git-root", resolver_cache)
|
|
928
|
+
for p in proj_keys
|
|
929
|
+
]
|
|
930
|
+
disambig = _project_disambiguate_labels(
|
|
931
|
+
[{"key": pk} for pk in pkeys]
|
|
932
|
+
)
|
|
933
|
+
label_by_key = {
|
|
934
|
+
proj_keys[i]: disambig.get(i, pkeys[i].display_key)
|
|
935
|
+
for i in range(len(proj_keys))
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
# ONE grouped scan over the week's session entries, bucketed by
|
|
939
|
+
# canonical git-root. skip_sync=False (self-sufficient): the global
|
|
940
|
+
# budget axis only warms the cache when `budget.weekly_usd` is set, so a
|
|
941
|
+
# project-only user (no global budget) reaches here with a cold cache on
|
|
942
|
+
# a no-5h-anchor tick — sync here or a crossing fires a tick late. The
|
|
943
|
+
# pre-probe above already gated this scan to the rare pending-crossing
|
|
944
|
+
# tick, so the self-sufficient sync is near-free.
|
|
945
|
+
by_proj = _sum_cost_by_project(
|
|
946
|
+
week_start_at, now_utc, mode="auto", skip_sync=False
|
|
947
|
+
)
|
|
948
|
+
for project_key, t in pending:
|
|
949
|
+
spent = float(by_proj.get(project_key, 0.0))
|
|
950
|
+
target = float(projects[project_key])
|
|
951
|
+
# +1e-9 snap-up: spent/target*100 can land one ULP below an integer
|
|
952
|
+
# threshold (CLAUDE.md float-floor gotcha).
|
|
953
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
954
|
+
if consumption_pct + 1e-9 >= t:
|
|
955
|
+
inserted = insert_project_budget_milestone(
|
|
956
|
+
conn,
|
|
957
|
+
week_start_at=week_key,
|
|
958
|
+
project_key=project_key,
|
|
959
|
+
threshold=t,
|
|
960
|
+
budget_usd=target,
|
|
961
|
+
spent_usd=spent,
|
|
962
|
+
consumption_pct=consumption_pct,
|
|
963
|
+
commit=False,
|
|
964
|
+
)
|
|
965
|
+
# Only the genuine-new-crossing winner (rowcount==1) dispatches;
|
|
966
|
+
# a racing record-usage instance gets rowcount==0 and skips.
|
|
967
|
+
if inserted == 1:
|
|
968
|
+
crossed_at = now_utc_iso()
|
|
969
|
+
# set-then-dispatch: alerted_at lands on the row BEFORE the
|
|
970
|
+
# Popen, sharing this transaction with the INSERT
|
|
971
|
+
# (commit=False). `alerted_at IS NULL` is write-once
|
|
972
|
+
# defense-in-depth.
|
|
973
|
+
conn.execute(
|
|
974
|
+
"UPDATE project_budget_milestones SET alerted_at = ? "
|
|
975
|
+
"WHERE week_start_at = ? AND project_key = ? "
|
|
976
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
977
|
+
(crossed_at, week_key, project_key, t),
|
|
978
|
+
)
|
|
979
|
+
# Label is collision-aware, byte-matching the display: a
|
|
980
|
+
# uniquely-named project notifies as its bare basename,
|
|
981
|
+
# only same-basename roots get the `(parent)` segment (the
|
|
982
|
+
# `label_by_key` map built above via the shared
|
|
983
|
+
# `_project_disambiguate_labels` primitive, spec §5.3).
|
|
984
|
+
project_label = label_by_key.get(
|
|
985
|
+
project_key, os.path.basename(project_key) or project_key
|
|
986
|
+
)
|
|
987
|
+
pending_alerts.append(_build_alert_payload_project_budget(
|
|
988
|
+
threshold=t,
|
|
989
|
+
crossed_at_utc=crossed_at,
|
|
990
|
+
week_start_at=week_key,
|
|
991
|
+
project=project_label,
|
|
992
|
+
project_key=project_key,
|
|
993
|
+
budget_usd=target,
|
|
994
|
+
spent_usd=spent,
|
|
995
|
+
consumption_pct=consumption_pct,
|
|
996
|
+
))
|
|
997
|
+
# Single commit: every INSERT + its alerted_at marker durable together.
|
|
998
|
+
conn.commit()
|
|
999
|
+
except Exception as exc:
|
|
1000
|
+
eprint(
|
|
1001
|
+
f"[project-budget-milestone] error recording project budget "
|
|
1002
|
+
f"milestone: {exc}"
|
|
1003
|
+
)
|
|
1004
|
+
finally:
|
|
1005
|
+
conn.close()
|
|
1006
|
+
|
|
1007
|
+
# Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
|
|
1008
|
+
# (set-then-dispatch invariant — one queue attempt per crossing, deduped on
|
|
1009
|
+
# the alerted_at column).
|
|
1010
|
+
for payload in pending_alerts:
|
|
1011
|
+
try:
|
|
1012
|
+
_dispatch_alert_notification(payload, mode="real")
|
|
1013
|
+
except Exception as dispatch_exc:
|
|
1014
|
+
eprint(f"[project-budget-alerts] dispatch failed: {dispatch_exc}")
|
|
1015
|
+
|
|
1016
|
+
|
|
823
1017
|
def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
824
1018
|
"""Compute the week-AVERAGE weekly-% projection for the current
|
|
825
1019
|
subscription week, snapshot-only (CHEAP — no cost SUM, no ``sync_cache``).
|
|
@@ -2486,6 +2680,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
2486
2680
|
).fetchone()
|
|
2487
2681
|
if latest_row is None:
|
|
2488
2682
|
return 0
|
|
2683
|
+
latest_saved = _saved_dict_from_usage_row(latest_row)
|
|
2489
2684
|
|
|
2490
2685
|
# Probe 1: do we owe a percent milestone? Snap up before
|
|
2491
2686
|
# floor (status-line API returns 0.N*100 which can fall
|
|
@@ -2599,7 +2794,6 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
2599
2794
|
heal_conn.close()
|
|
2600
2795
|
|
|
2601
2796
|
if need_milestone_heal or need_5h_heal:
|
|
2602
|
-
latest_saved = _saved_dict_from_usage_row(latest_row)
|
|
2603
2797
|
if need_milestone_heal:
|
|
2604
2798
|
try:
|
|
2605
2799
|
maybe_record_milestone(latest_saved)
|
|
@@ -2610,6 +2804,27 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
2610
2804
|
maybe_update_five_hour_block(latest_saved)
|
|
2611
2805
|
except Exception as exc:
|
|
2612
2806
|
eprint(f"[5h-block] self-heal error: {exc}")
|
|
2807
|
+
|
|
2808
|
+
# Dollar-decoupled axes (budget / project-budget / projected) heal on
|
|
2809
|
+
# EVERY dedup tick — USD spend can cross a $ threshold while the
|
|
2810
|
+
# weekly/5h percent is flat (so should_insert is False), and a prior
|
|
2811
|
+
# run may have died after insert_usage_snapshot but before the
|
|
2812
|
+
# post-insert axis block. Each helper gates first on config (a cheap
|
|
2813
|
+
# read for non-users) and pre-probes its recorded set before any cost
|
|
2814
|
+
# scan, so non-budget users pay ~nothing here. Order matches the
|
|
2815
|
+
# post-insert block so the projected budget_usd leg's skip_sync=True
|
|
2816
|
+
# cache-warming dependency on the actual-budget axis still holds.
|
|
2817
|
+
# [Dedup mustn't gate side effects]
|
|
2818
|
+
for _heal_fn, _heal_tag in (
|
|
2819
|
+
(maybe_record_budget_milestone, "budget-milestone"),
|
|
2820
|
+
(maybe_record_project_budget_milestone,
|
|
2821
|
+
"project-budget-milestone"),
|
|
2822
|
+
(maybe_record_projected_alert, "projected-alert"),
|
|
2823
|
+
):
|
|
2824
|
+
try:
|
|
2825
|
+
_heal_fn(latest_saved)
|
|
2826
|
+
except Exception as exc:
|
|
2827
|
+
eprint(f"[{_heal_tag}] self-heal error: {exc}")
|
|
2613
2828
|
except Exception as exc:
|
|
2614
2829
|
eprint(f"[record-usage] self-heal lookup failed: {exc}")
|
|
2615
2830
|
return 0
|
|
@@ -2652,6 +2867,17 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
2652
2867
|
except Exception as exc:
|
|
2653
2868
|
eprint(f"[budget-milestone] unexpected error: {exc}")
|
|
2654
2869
|
|
|
2870
|
+
# NEW: per-project equiv-$ budget alert firing (axis `project_budget`,
|
|
2871
|
+
# #19/#121). Runs AFTER the global budget axis, but the per-project scan is
|
|
2872
|
+
# self-sufficient — it passes skip_sync=False so a project-only user (no
|
|
2873
|
+
# global budget warming the cache) still resolves live spend on this tick.
|
|
2874
|
+
# Gated FIRST on a non-empty budget.projects + project_alerts_enabled — non-
|
|
2875
|
+
# users pay only one config read.
|
|
2876
|
+
try:
|
|
2877
|
+
maybe_record_project_budget_milestone(saved)
|
|
2878
|
+
except Exception as exc:
|
|
2879
|
+
eprint(f"[project-budget-milestone] unexpected error: {exc}")
|
|
2880
|
+
|
|
2655
2881
|
# NEW: projected-pace alert firing (axis `projected`, #121). Runs in its
|
|
2656
2882
|
# OWN detect-and-arm AFTER the weekly/5h/budget blocks; gated up front on
|
|
2657
2883
|
# (alerts.enabled && alerts.projected_enabled) ||
|
package/bin/_lib_alert_axes.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Single source of truth for axis *metadata* — id, chip/title labels (kept
|
|
4
4
|
byte-identical with dashboard/web/src/lib/alertAxis.ts), the milestone-table
|
|
5
5
|
name used by the dashboard envelope, and the axis-uniform severity policy
|
|
6
|
-
(
|
|
6
|
+
(info <90 / warn 90-99 / critical >=100). This kernel does NOT own the write/transaction path:
|
|
7
7
|
each axis keeps its own detect-and-arm code in bin/_cctally_record.py. The
|
|
8
8
|
descriptor is the metadata/render contract, not the write engine.
|
|
9
9
|
|
|
@@ -14,21 +14,30 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
|
|
17
|
-
# Severity
|
|
18
|
-
#
|
|
19
|
-
|
|
17
|
+
# Severity bands (Phase B): info < warn floor <= warn < critical floor <= critical.
|
|
18
|
+
# The top tier means "hit the ceiling" (100% weekly = rate-limited, budget 100% =
|
|
19
|
+
# over). Maps onto notify-send's low/normal/critical urgency levels.
|
|
20
|
+
_SEVERITY_WARN_FLOOR = 90
|
|
21
|
+
_SEVERITY_CRITICAL_FLOOR = 100
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def severity_for(threshold: int) -> str:
|
|
23
|
-
"""Map a crossed integer threshold to a severity
|
|
24
|
-
|
|
25
|
+
"""Map a crossed integer threshold to a 3-tier severity
|
|
26
|
+
('info' | 'warn' | 'critical'). Axis-uniform; the single authority kept
|
|
27
|
+
byte-identical with dashboard/web/src/lib/alertAxis.ts::alertSeverity."""
|
|
28
|
+
t = int(threshold)
|
|
29
|
+
if t >= _SEVERITY_CRITICAL_FLOOR:
|
|
30
|
+
return "critical"
|
|
31
|
+
if t >= _SEVERITY_WARN_FLOOR:
|
|
32
|
+
return "warn"
|
|
33
|
+
return "info"
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
@dataclass(frozen=True)
|
|
28
37
|
class AlertAxisDescriptor:
|
|
29
38
|
"""Axis-agnostic metadata shared by the record path + dashboard envelope."""
|
|
30
39
|
|
|
31
|
-
id: str # 'weekly' | 'five_hour' | 'budget' | 'projected'
|
|
40
|
+
id: str # 'weekly' | 'five_hour' | 'budget' | 'projected' | 'project_budget'
|
|
32
41
|
chip_label: str # SHOUT form, byte-identical with alertAxis.ts AXIS_CHIP_LABEL
|
|
33
42
|
title_label: str # sentence-case form, byte-identical with AXIS_TITLE_LABEL
|
|
34
43
|
milestone_table: str # SQLite table the dashboard envelope SELECTs from
|
|
@@ -39,6 +48,11 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
|
|
|
39
48
|
AlertAxisDescriptor("five_hour", "5H-BLOCK", "5h-block", "five_hour_milestones"),
|
|
40
49
|
AlertAxisDescriptor("budget", "BUDGET", "Budget", "budget_milestones"),
|
|
41
50
|
AlertAxisDescriptor("projected", "PROJECTED", "Projected", "projected_milestones"),
|
|
51
|
+
# Per-project weekly budget alerts (issue #19 / #121). Distinct "PROJECT"
|
|
52
|
+
# chip vs the global "BUDGET" chip; its own forward-only table.
|
|
53
|
+
AlertAxisDescriptor(
|
|
54
|
+
"project_budget", "PROJECT", "Project budget", "project_budget_milestones"
|
|
55
|
+
),
|
|
42
56
|
)
|
|
43
57
|
|
|
44
58
|
AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {d.id: d for d in AXIS_REGISTRY}
|