cctally 1.24.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 +5 -0
- package/bin/_cctally_alerts.py +22 -0
- package/bin/_cctally_config.py +79 -10
- package/bin/_cctally_core.py +76 -0
- package/bin/_cctally_dashboard.py +141 -24
- 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 +6 -1
- package/bin/_lib_alerts_payload.py +70 -0
- package/bin/cctally +14 -0
- package/dashboard/static/assets/index-C2F1_Mxt.js +18 -0
- package/dashboard/static/assets/{index-CsqqtRBB.css → index-D34qf0LE.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DwuW39Tv.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.25.0] - 2026-06-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Per-project weekly budgets with their own actual-spend alerts (a new `project_budget` alert axis).** Set a dollar budget for any repo with `cctally budget set 25 --project` (resolves the current directory's git-root) or `--project /abs/path`, clear it with `cctally budget unset --project`, and `cctally budget` now renders a per-project section below the global status (budget / spent / used% / verdict / `LOW CONF`, sorted by used% desc) — present even when no global `budget.weekly_usd` is set, additive in `--json` (a `projects[]` array, no schema bump) and in share-output (names anonymized unless `--reveal-projects`). Turn on push alerts (opt-in, default off) with `cctally config set budget.project_alerts_enabled true`: when a project crosses one of your `budget.alert_thresholds` of its own budget, `record-usage` fires one cross-platform notification per `(project, threshold)` per week with project-specific text (e.g. *"Project foo - $26.00 of $25.00 (104% of budget)"*), severity from the shared 3-tier model. Setting a project budget mid-week when you're already over a threshold records that crossing silently (no retroactive popup) and only later crossings fire, and a mid-week budget change never re-alerts an already-fired threshold. Preview the notification without any real config via `cctally alerts test --axis project-budget --threshold 100`. Thresholds reuse the global `budget.alert_thresholds`; projects are keyed by canonical git-root so same-basename repos stay distinct. In the local web dashboard, fired project alerts now show in the existing "Recent alerts" panel/modal with a distinct **PROJECT** chip (vs the global **BUDGET** chip) and the project basename + `$spent of $budget` context, the Settings overlay gains a **Per-project budget alerts** on/off toggle that persists `budget.project_alerts_enabled` (enabling it mid-week when already over latches the crossed thresholds without storming retroactive popups), and the Settings "Send test alert" picker can fire the synthetic project-budget example end-to-end; editing per-project budget *amounts* stays CLI-only.
|
|
12
|
+
|
|
8
13
|
## [1.24.0] - 2026-06-02
|
|
9
14
|
|
|
10
15
|
### Added
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -70,11 +70,13 @@ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
|
|
|
70
70
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
71
71
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
72
72
|
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
73
|
+
_alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
|
|
73
74
|
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
74
75
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
75
76
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
76
77
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
77
78
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
79
|
+
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
|
|
78
80
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
79
81
|
|
|
80
82
|
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
@@ -171,6 +173,8 @@ def _dispatch_alert_notification(
|
|
|
171
173
|
title, subtitle, body = _alert_text_five_hour(payload, tz)
|
|
172
174
|
elif axis == "budget":
|
|
173
175
|
title, subtitle, body = _alert_text_budget(payload, tz)
|
|
176
|
+
elif axis == "project_budget":
|
|
177
|
+
title, subtitle, body = _alert_text_project_budget(payload, tz)
|
|
174
178
|
elif axis == "projected":
|
|
175
179
|
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
176
180
|
else:
|
|
@@ -279,6 +283,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
279
283
|
axis = "weekly"
|
|
280
284
|
elif args.axis == "budget":
|
|
281
285
|
axis = "budget"
|
|
286
|
+
elif args.axis == "project-budget":
|
|
287
|
+
axis = "project_budget"
|
|
282
288
|
elif args.axis == "projected":
|
|
283
289
|
axis = "projected"
|
|
284
290
|
else:
|
|
@@ -313,6 +319,22 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
313
319
|
spent_usd=300.0 * threshold / 100.0,
|
|
314
320
|
consumption_pct=float(threshold),
|
|
315
321
|
)
|
|
322
|
+
elif axis == "project_budget":
|
|
323
|
+
# Synthetic per-project budget payload — NO DB writes (test/real
|
|
324
|
+
# divergence contract), NO real budget.projects entry required. A small
|
|
325
|
+
# $25 budget at $26 spent (104%) reads plausibly regardless of the
|
|
326
|
+
# --threshold (the body line shows the at-crossing snapshot the dashboard
|
|
327
|
+
# would render).
|
|
328
|
+
payload = _build_alert_payload_project_budget(
|
|
329
|
+
threshold=threshold,
|
|
330
|
+
crossed_at_utc=now_utc_iso(),
|
|
331
|
+
week_start_at=dt.date.today().isoformat(),
|
|
332
|
+
project="example-project",
|
|
333
|
+
project_key="/example/repos/example-project",
|
|
334
|
+
budget_usd=25.0,
|
|
335
|
+
spent_usd=26.0,
|
|
336
|
+
consumption_pct=104.0,
|
|
337
|
+
)
|
|
316
338
|
elif axis == "projected":
|
|
317
339
|
# Synthetic projected-pace payload — NO DB writes (test/real divergence
|
|
318
340
|
# contract). The metric discriminator picks the wiring; projected_value
|
package/bin/_cctally_config.py
CHANGED
|
@@ -310,6 +310,8 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
310
310
|
"budget.alerts_enabled",
|
|
311
311
|
"budget.alert_thresholds",
|
|
312
312
|
"budget.projected_enabled",
|
|
313
|
+
"budget.projects",
|
|
314
|
+
"budget.project_alerts_enabled",
|
|
313
315
|
)
|
|
314
316
|
|
|
315
317
|
|
|
@@ -499,6 +501,8 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
499
501
|
"budget.alerts_enabled",
|
|
500
502
|
"budget.alert_thresholds",
|
|
501
503
|
"budget.projected_enabled",
|
|
504
|
+
"budget.projects",
|
|
505
|
+
"budget.project_alerts_enabled",
|
|
502
506
|
):
|
|
503
507
|
inner = key.split(".", 1)[1]
|
|
504
508
|
# Read the validated, defaults-filled block. A corrupt block falls
|
|
@@ -513,6 +517,8 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
513
517
|
default = _BUDGET_DEFAULTS[inner]
|
|
514
518
|
if isinstance(default, list):
|
|
515
519
|
return list(default)
|
|
520
|
+
if isinstance(default, dict):
|
|
521
|
+
return dict(default)
|
|
516
522
|
return default
|
|
517
523
|
return None
|
|
518
524
|
|
|
@@ -522,11 +528,12 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
522
528
|
if key is not None and key not in ALLOWED_CONFIG_KEYS:
|
|
523
529
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
524
530
|
return 2
|
|
525
|
-
# `alerts.command_template` is JSON-shaped (a list of strings or null),
|
|
526
|
-
#
|
|
527
|
-
#
|
|
531
|
+
# `alerts.command_template` is JSON-shaped (a list of strings or null), and
|
|
532
|
+
# `budget.projects` is JSON-shaped (an object), so their real values
|
|
533
|
+
# (including None) must survive into the render layer — the generic
|
|
534
|
+
# None->"" coercion below would break the JSON shape / round-trip.
|
|
528
535
|
def _coerce(k: str, v: "object") -> "object":
|
|
529
|
-
if k
|
|
536
|
+
if k in ("alerts.command_template", "budget.projects"):
|
|
530
537
|
return v
|
|
531
538
|
return v if v is not None else ""
|
|
532
539
|
|
|
@@ -555,10 +562,11 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
555
562
|
for k, v in pairs:
|
|
556
563
|
# Preserve canonical bool stringification (true/false) so
|
|
557
564
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
558
|
-
if k
|
|
559
|
-
# JSON-encoded
|
|
560
|
-
#
|
|
561
|
-
#
|
|
565
|
+
if k in ("alerts.command_template", "budget.projects"):
|
|
566
|
+
# JSON-encoded so `config get` output round-trips through the
|
|
567
|
+
# matching `config set` branch (both JSON-parse their value).
|
|
568
|
+
# `alerts.command_template` is a list-of-strings|null;
|
|
569
|
+
# `budget.projects` is an object {git-root: usd}.
|
|
562
570
|
rendered = json.dumps(v)
|
|
563
571
|
elif isinstance(v, bool):
|
|
564
572
|
rendered = "true" if v else "false"
|
|
@@ -869,6 +877,8 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
869
877
|
"budget.alerts_enabled",
|
|
870
878
|
"budget.alert_thresholds",
|
|
871
879
|
"budget.projected_enabled",
|
|
880
|
+
"budget.projects",
|
|
881
|
+
"budget.project_alerts_enabled",
|
|
872
882
|
):
|
|
873
883
|
inner_key = key.split(".", 1)[1]
|
|
874
884
|
# Parse + normalize the raw value per key BEFORE acquiring the lock so
|
|
@@ -888,7 +898,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
888
898
|
f"null, got {raw!r}"
|
|
889
899
|
)
|
|
890
900
|
return 2
|
|
891
|
-
elif inner_key in (
|
|
901
|
+
elif inner_key in (
|
|
902
|
+
"alerts_enabled", "projected_enabled", "project_alerts_enabled"
|
|
903
|
+
):
|
|
892
904
|
lo = raw.strip().lower()
|
|
893
905
|
if lo in ("true", "yes", "on", "1"):
|
|
894
906
|
new_val = True
|
|
@@ -900,6 +912,42 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
900
912
|
f"got {raw!r}"
|
|
901
913
|
)
|
|
902
914
|
return 2
|
|
915
|
+
elif inner_key == "projects":
|
|
916
|
+
# `budget.projects` is a dict {git-root: usd}, which the plain
|
|
917
|
+
# number/bool/list leaves can't round-trip — JSON-parse it (mirrors
|
|
918
|
+
# the alerts.command_template branch). The per-value numeric rule is
|
|
919
|
+
# enforced by _get_budget_config under the lock below; here we only
|
|
920
|
+
# reject non-JSON / non-object shape.
|
|
921
|
+
try:
|
|
922
|
+
parsed_obj = json.loads(raw)
|
|
923
|
+
except (json.JSONDecodeError, ValueError):
|
|
924
|
+
eprint(
|
|
925
|
+
"cctally config: budget.projects must be a JSON object, "
|
|
926
|
+
f"got {raw!r}"
|
|
927
|
+
)
|
|
928
|
+
return 2
|
|
929
|
+
if not isinstance(parsed_obj, dict):
|
|
930
|
+
eprint("cctally config: budget.projects must be a JSON object")
|
|
931
|
+
return 2
|
|
932
|
+
# Canonicalize each project key to its resolved git-root, mirroring
|
|
933
|
+
# the `budget set --project` CLI path (`_resolve_project_budget_-
|
|
934
|
+
# target`). `_sum_cost_by_project` buckets spend under the realpath'd
|
|
935
|
+
# `ProjectKey.bucket_path`, so a `~`/relative/sub-dir/trailing-slash
|
|
936
|
+
# key stored verbatim would NEVER match → a permanent $0 row that
|
|
937
|
+
# silently never alerts. Resolving here makes the JSON-object surface
|
|
938
|
+
# match the per-project CLI surface. Non-string keys (impossible from
|
|
939
|
+
# json.loads, defensive) and the `__CWD__`-non-git None case fall
|
|
940
|
+
# back to the raw key for `_get_budget_config` to handle.
|
|
941
|
+
c = _cctally()
|
|
942
|
+
new_val = {
|
|
943
|
+
(
|
|
944
|
+
c._resolve_project_budget_target(pk)
|
|
945
|
+
if isinstance(pk, str)
|
|
946
|
+
else pk
|
|
947
|
+
)
|
|
948
|
+
or pk: pv
|
|
949
|
+
for pk, pv in parsed_obj.items()
|
|
950
|
+
}
|
|
903
951
|
else: # alert_thresholds — comma-separated int list (empty = silenced)
|
|
904
952
|
stripped = raw.strip()
|
|
905
953
|
parsed: "list[int]" = []
|
|
@@ -941,13 +989,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
941
989
|
# helper opens stats.db and must not nest under the config lock
|
|
942
990
|
# (fcntl.flock is per-fd; the helper has its own open_db locking).
|
|
943
991
|
c = _cctally()
|
|
944
|
-
|
|
992
|
+
# Gate each forward-only reconcile (spec §6.8) on the keys it actually
|
|
993
|
+
# consumes. Running unconditionally on an UNRELATED write — e.g. the
|
|
994
|
+
# global axis on `config set budget.projects`, or the per-project axis
|
|
995
|
+
# on `budget.weekly_usd` — would latch a currently-over-but-not-yet-
|
|
996
|
+
# dispatched threshold as already-alerted, permanently suppressing the
|
|
997
|
+
# next record-usage tick's dispatch. The global axis feeds on
|
|
998
|
+
# weekly_usd/alerts_enabled/alert_thresholds; the per-project axis on
|
|
999
|
+
# projects/project_alerts_enabled/alert_thresholds (alert_thresholds is
|
|
1000
|
+
# shared; projected_enabled belongs to neither reconcile). Both run
|
|
1001
|
+
# OUTSIDE config_writer_lock (each helper has its own open_db lock).
|
|
1002
|
+
if inner_key in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
|
|
1003
|
+
c._reconcile_budget_on_config_write(validated)
|
|
1004
|
+
if inner_key in (
|
|
1005
|
+
"projects", "project_alerts_enabled", "alert_thresholds"
|
|
1006
|
+
):
|
|
1007
|
+
c._reconcile_project_budget_milestones_on_write(validated)
|
|
945
1008
|
out_val = validated[inner_key]
|
|
946
1009
|
if getattr(args, "emit_json", False):
|
|
947
1010
|
print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
|
|
948
1011
|
else:
|
|
949
1012
|
if isinstance(out_val, bool):
|
|
950
1013
|
rendered = "true" if out_val else "false"
|
|
1014
|
+
elif inner_key == "projects":
|
|
1015
|
+
# JSON so `config get budget.projects` round-trips back through
|
|
1016
|
+
# this branch (str(dict) is not valid JSON).
|
|
1017
|
+
rendered = json.dumps(out_val)
|
|
951
1018
|
else:
|
|
952
1019
|
rendered = str(out_val)
|
|
953
1020
|
print(f"{key}={rendered}")
|
|
@@ -1070,6 +1137,8 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1070
1137
|
"budget.alerts_enabled",
|
|
1071
1138
|
"budget.alert_thresholds",
|
|
1072
1139
|
"budget.projected_enabled",
|
|
1140
|
+
"budget.projects",
|
|
1141
|
+
"budget.project_alerts_enabled",
|
|
1073
1142
|
):
|
|
1074
1143
|
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|
|
1075
1144
|
# unsetting weekly_usd keeps a customized alert_thresholds). If the
|
package/bin/_cctally_core.py
CHANGED
|
@@ -575,12 +575,16 @@ _BUDGET_DEFAULTS = {
|
|
|
575
575
|
"alerts_enabled": True, # "on when set"
|
|
576
576
|
"alert_thresholds": [90, 100],
|
|
577
577
|
"projected_enabled": False, # projected-pace opt-in (#121); default OFF
|
|
578
|
+
"projects": {}, # per-project weekly $ budgets, keyed by git-root
|
|
579
|
+
"project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
|
|
578
580
|
}
|
|
579
581
|
_BUDGET_CONFIG_VALID_KEYS = {
|
|
580
582
|
"weekly_usd",
|
|
581
583
|
"alerts_enabled",
|
|
582
584
|
"alert_thresholds",
|
|
583
585
|
"projected_enabled",
|
|
586
|
+
"projects",
|
|
587
|
+
"project_alerts_enabled",
|
|
584
588
|
}
|
|
585
589
|
|
|
586
590
|
|
|
@@ -593,6 +597,7 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
593
597
|
"""
|
|
594
598
|
out = dict(_BUDGET_DEFAULTS)
|
|
595
599
|
out["alert_thresholds"] = list(_BUDGET_DEFAULTS["alert_thresholds"])
|
|
600
|
+
out["projects"] = dict(_BUDGET_DEFAULTS["projects"])
|
|
596
601
|
block = cfg.get("budget") if isinstance(cfg, dict) else None
|
|
597
602
|
if block is None:
|
|
598
603
|
return out
|
|
@@ -648,6 +653,41 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
648
653
|
raise _BudgetConfigError("budget.projected_enabled must be a boolean")
|
|
649
654
|
out["projected_enabled"] = v
|
|
650
655
|
|
|
656
|
+
if "projects" in block:
|
|
657
|
+
v = block["projects"]
|
|
658
|
+
if not isinstance(v, dict):
|
|
659
|
+
raise _BudgetConfigError(
|
|
660
|
+
f"budget.projects must be an object, got {type(v).__name__}"
|
|
661
|
+
)
|
|
662
|
+
cleaned: "dict[str, float]" = {}
|
|
663
|
+
for proj_key, proj_val in v.items():
|
|
664
|
+
if not isinstance(proj_key, str):
|
|
665
|
+
raise _BudgetConfigError(
|
|
666
|
+
"budget.projects keys must be strings (canonical git-root paths)"
|
|
667
|
+
)
|
|
668
|
+
# Reuse the weekly_usd numeric rule per value: a non-bool finite
|
|
669
|
+
# number > 0 (bool is an int subclass, so reject it explicitly).
|
|
670
|
+
if isinstance(proj_val, bool) or not isinstance(proj_val, (int, float)):
|
|
671
|
+
raise _BudgetConfigError(
|
|
672
|
+
f"budget.projects values must be numbers, "
|
|
673
|
+
f"got {type(proj_val).__name__} for key {proj_key!r}"
|
|
674
|
+
)
|
|
675
|
+
if not math.isfinite(float(proj_val)) or float(proj_val) <= 0:
|
|
676
|
+
raise _BudgetConfigError(
|
|
677
|
+
f"budget.projects values must be finite numbers > 0, "
|
|
678
|
+
f"got {proj_val!r} for key {proj_key!r}"
|
|
679
|
+
)
|
|
680
|
+
cleaned[proj_key] = float(proj_val)
|
|
681
|
+
out["projects"] = cleaned
|
|
682
|
+
|
|
683
|
+
if "project_alerts_enabled" in block:
|
|
684
|
+
v = block["project_alerts_enabled"]
|
|
685
|
+
if not isinstance(v, bool):
|
|
686
|
+
raise _BudgetConfigError(
|
|
687
|
+
"budget.project_alerts_enabled must be a boolean"
|
|
688
|
+
)
|
|
689
|
+
out["project_alerts_enabled"] = v
|
|
690
|
+
|
|
651
691
|
return out
|
|
652
692
|
|
|
653
693
|
|
|
@@ -1127,6 +1167,42 @@ def open_db() -> sqlite3.Connection:
|
|
|
1127
1167
|
"""
|
|
1128
1168
|
)
|
|
1129
1169
|
|
|
1170
|
+
# ── project_budget_milestones (per-project equiv-$ budget crossings) ──────
|
|
1171
|
+
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
|
|
1172
|
+
# same posture as `budget_milestones` / `projected_milestones` (write-once,
|
|
1173
|
+
# forward-only, framework-untracked). `project_key` is the NEW dimension in
|
|
1174
|
+
# the UNIQUE key: each project crosses each threshold once per week,
|
|
1175
|
+
# independently of every other project (issue #19 / #121, spec §5.1). It
|
|
1176
|
+
# stores the canonical git-root (`ProjectKey.bucket_path`), matched by string
|
|
1177
|
+
# equality against each session entry's resolved git-root. `budget_usd`
|
|
1178
|
+
# snapshots the project's target AT crossing time so the dashboard renders
|
|
1179
|
+
# "$26 of $25" from the ROW, not from live config that may have changed since
|
|
1180
|
+
# (the Codex P0-4 lesson, already baked into `budget_milestones` /
|
|
1181
|
+
# `projected_milestones`). A mid-week quota reset re-anchors `week_start_at`
|
|
1182
|
+
# (new window → fresh rows under the UNIQUE key) — budget-pattern reset
|
|
1183
|
+
# handling, hence NO `reset_event_id` segment column. `alerted_at` is stamped
|
|
1184
|
+
# BEFORE dispatch (set-then-dispatch invariant); NULL = "recorded without
|
|
1185
|
+
# dispatch" (forward-only-from-set reconcile) OR "not yet dispatched", never
|
|
1186
|
+
# "delivery failed". Lives BEFORE the migration dispatcher: a plain CREATE on
|
|
1187
|
+
# a framework-untracked table never touches `schema_migrations`, so the
|
|
1188
|
+
# dispatcher's fresh-install snapshot is unaffected.
|
|
1189
|
+
conn.execute(
|
|
1190
|
+
"""
|
|
1191
|
+
CREATE TABLE IF NOT EXISTS project_budget_milestones (
|
|
1192
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1193
|
+
week_start_at TEXT NOT NULL,
|
|
1194
|
+
project_key TEXT NOT NULL, -- canonical git-root (bucket_path)
|
|
1195
|
+
threshold INTEGER NOT NULL,
|
|
1196
|
+
budget_usd REAL NOT NULL, -- project's target snapshotted AT crossing
|
|
1197
|
+
spent_usd REAL NOT NULL,
|
|
1198
|
+
consumption_pct REAL NOT NULL,
|
|
1199
|
+
crossed_at_utc TEXT NOT NULL,
|
|
1200
|
+
alerted_at TEXT,
|
|
1201
|
+
UNIQUE(week_start_at, project_key, threshold)
|
|
1202
|
+
)
|
|
1203
|
+
"""
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1130
1206
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1131
1207
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
1132
1208
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|
|
@@ -340,6 +340,12 @@ def _build_alert_payload_projected(*args, **kwargs):
|
|
|
340
340
|
return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
|
|
341
341
|
|
|
342
342
|
|
|
343
|
+
def _build_alert_payload_project_budget(*args, **kwargs):
|
|
344
|
+
return sys.modules["cctally"]._build_alert_payload_project_budget(
|
|
345
|
+
*args, **kwargs
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
343
349
|
def _dispatch_alert_notification(*args, **kwargs):
|
|
344
350
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
345
351
|
|
|
@@ -4252,6 +4258,72 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4252
4258
|
return out
|
|
4253
4259
|
|
|
4254
4260
|
|
|
4261
|
+
def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4262
|
+
# Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
|
|
4263
|
+
# crossings. Like the global budget axis, project-budget alerts re-anchor
|
|
4264
|
+
# ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
|
|
4265
|
+
# segment — the new window gets fresh rows under
|
|
4266
|
+
# ``UNIQUE(week_start_at, project_key, threshold)``. ``project_key`` is the
|
|
4267
|
+
# canonical git-root (``ProjectKey.bucket_path``); the human-readable chip
|
|
4268
|
+
# context carries the project BASENAME, resolved through the production
|
|
4269
|
+
# ``_resolve_project_key`` (git-root mode) so a moved/deleted repo still
|
|
4270
|
+
# renders its basename from the snapshotted path (no FS dependency on a
|
|
4271
|
+
# live ``.git``). ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
|
|
4272
|
+
# rendered FROM THE ROW (snapshotted at crossing), never live config that
|
|
4273
|
+
# may have changed since (Codex P0-4). The envelope id mirrors the dispatch
|
|
4274
|
+
# payload's ``project_budget:<week_start_at>:<project_key>:<threshold>``
|
|
4275
|
+
# shape (``_build_alert_payload_project_budget``).
|
|
4276
|
+
rows = conn.execute(
|
|
4277
|
+
f"""
|
|
4278
|
+
SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
|
|
4279
|
+
consumption_pct, crossed_at_utc, alerted_at
|
|
4280
|
+
FROM {descriptor.milestone_table}
|
|
4281
|
+
WHERE alerted_at IS NOT NULL
|
|
4282
|
+
ORDER BY alerted_at DESC
|
|
4283
|
+
LIMIT ?
|
|
4284
|
+
""",
|
|
4285
|
+
(limit,),
|
|
4286
|
+
).fetchall()
|
|
4287
|
+
c = _cctally()
|
|
4288
|
+
resolve = c._resolve_project_key
|
|
4289
|
+
resolver_cache: dict = {}
|
|
4290
|
+
# Collision-aware labels ACROSS the distinct project_keys in these rows so
|
|
4291
|
+
# two same-basename roots (/work/app + /personal/app) don't both render
|
|
4292
|
+
# "app" — byte-matching the live notification + budget table via the shared
|
|
4293
|
+
# `_project_disambiguate_labels`. Disambiguated across the ROWS (not live
|
|
4294
|
+
# config) to preserve the render-from-the-snapshotted-row invariant above.
|
|
4295
|
+
distinct_keys = sorted({r["project_key"] for r in rows})
|
|
4296
|
+
pkeys = [resolve(k, "git-root", resolver_cache) for k in distinct_keys]
|
|
4297
|
+
disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
|
|
4298
|
+
label_by_key = {
|
|
4299
|
+
distinct_keys[i]: disambig.get(i, pkeys[i].display_key)
|
|
4300
|
+
for i in range(len(distinct_keys))
|
|
4301
|
+
}
|
|
4302
|
+
out: list[dict] = []
|
|
4303
|
+
for r in rows:
|
|
4304
|
+
threshold = int(r["threshold"])
|
|
4305
|
+
project_key = r["project_key"]
|
|
4306
|
+
out.append({
|
|
4307
|
+
"id": (
|
|
4308
|
+
f"project_budget:{r['week_start_at']}:{project_key}:{threshold}"
|
|
4309
|
+
),
|
|
4310
|
+
"axis": descriptor.id,
|
|
4311
|
+
"threshold": threshold,
|
|
4312
|
+
"severity": severity_for(threshold),
|
|
4313
|
+
"crossed_at": r["crossed_at_utc"],
|
|
4314
|
+
"alerted_at": r["alerted_at"],
|
|
4315
|
+
"context": {
|
|
4316
|
+
"week_start_at": r["week_start_at"],
|
|
4317
|
+
"project": label_by_key.get(project_key, project_key),
|
|
4318
|
+
"project_key": project_key,
|
|
4319
|
+
"budget_usd": float(r["budget_usd"]),
|
|
4320
|
+
"spent_usd": float(r["spent_usd"]),
|
|
4321
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
4322
|
+
},
|
|
4323
|
+
})
|
|
4324
|
+
return out
|
|
4325
|
+
|
|
4326
|
+
|
|
4255
4327
|
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
4256
4328
|
# in what order; this table supplies the bespoke heterogeneous row-mapper.
|
|
4257
4329
|
_ENVELOPE_AXIS_MAPPERS = {
|
|
@@ -4259,6 +4331,7 @@ _ENVELOPE_AXIS_MAPPERS = {
|
|
|
4259
4331
|
"five_hour": _envelope_rows_five_hour,
|
|
4260
4332
|
"budget": _envelope_rows_budget,
|
|
4261
4333
|
"projected": _envelope_rows_projected,
|
|
4334
|
+
"project_budget": _envelope_rows_project_budget,
|
|
4262
4335
|
}
|
|
4263
4336
|
|
|
4264
4337
|
|
|
@@ -4269,19 +4342,20 @@ def _build_alerts_envelope_array(
|
|
|
4269
4342
|
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4270
4343
|
|
|
4271
4344
|
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
4272
|
-
``budget_milestones``,
|
|
4273
|
-
``alerted_at IS NOT NULL``,
|
|
4274
|
-
capped at ``limit`` (default 100).
|
|
4275
|
-
|
|
4276
|
-
100). Forward-only semantics: only
|
|
4277
|
-
get included; pre-deploy crossings
|
|
4278
|
-
invisible (spec §4.3).
|
|
4279
|
-
|
|
4280
|
-
All
|
|
4281
|
-
(``weekly`` / ``five_hour`` / ``budget`` / ``projected``
|
|
4282
|
-
The ``projected`` axis additionally
|
|
4283
|
-
(``weekly_pct`` | ``budget_usd``) so the
|
|
4284
|
-
metric-aware context renderer
|
|
4345
|
+
``budget_milestones``, ``projected_milestones``, and
|
|
4346
|
+
``project_budget_milestones`` rows with ``alerted_at IS NOT NULL``,
|
|
4347
|
+
ordered newest-first by ``alerted_at``, capped at ``limit`` (default 100).
|
|
4348
|
+
Single source of truth for both the dashboard panel (slices to 10
|
|
4349
|
+
client-side) and the modal (renders all 100). Forward-only semantics: only
|
|
4350
|
+
rows the alert-dispatch path stamped get included; pre-deploy crossings
|
|
4351
|
+
stay NULL and are intentionally invisible (spec §4.3).
|
|
4352
|
+
|
|
4353
|
+
All five axes share the same envelope schema; the ``axis`` field
|
|
4354
|
+
(``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
|
|
4355
|
+
``project_budget``) discriminates. The ``projected`` axis additionally
|
|
4356
|
+
carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``) so the
|
|
4357
|
+
frontend can pick its metric-aware context renderer; ``project_budget``
|
|
4358
|
+
carries the project basename + ``$spent of $budget`` in its context.
|
|
4285
4359
|
|
|
4286
4360
|
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
4287
4361
|
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
@@ -4702,7 +4776,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4702
4776
|
_budget_cfg = _get_budget_config(_cfg_for_alerts)
|
|
4703
4777
|
except _BudgetConfigError:
|
|
4704
4778
|
_budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
|
|
4705
|
-
"alert_thresholds": [], "projected_enabled": False
|
|
4779
|
+
"alert_thresholds": [], "projected_enabled": False,
|
|
4780
|
+
"projects": {}, "project_alerts_enabled": False}
|
|
4706
4781
|
alerts_settings = {
|
|
4707
4782
|
"enabled": _alerts_cfg["enabled"],
|
|
4708
4783
|
"weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
|
|
@@ -4714,6 +4789,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4714
4789
|
# from the validated getters' ``projected_enabled`` (default False).
|
|
4715
4790
|
"projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
|
|
4716
4791
|
"projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
|
|
4792
|
+
# Per-project budget alerts opt-in mirror (issue #19 / #121). Gates the
|
|
4793
|
+
# ``project_budget`` axis dispatch only (the display section always
|
|
4794
|
+
# renders configured projects). Sourced from the validated budget
|
|
4795
|
+
# getter's ``project_alerts_enabled`` (default False) — the frontend
|
|
4796
|
+
# SettingsOverlay seeds a single on/off toggle from it.
|
|
4797
|
+
"project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
|
|
4717
4798
|
# Alert-dispatch notifier mirror (Phase B). `notifier` is the
|
|
4718
4799
|
# validated backend selector ("auto"/"command"/etc.). The raw
|
|
4719
4800
|
# `command_template` is NEVER mirrored — it routinely holds secrets
|
|
@@ -5673,7 +5754,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5673
5754
|
budget_in = payload["budget"]
|
|
5674
5755
|
for leaf in (
|
|
5675
5756
|
"weekly_usd", "alerts_enabled", "alert_thresholds",
|
|
5676
|
-
"projected_enabled",
|
|
5757
|
+
"projected_enabled", "project_alerts_enabled",
|
|
5677
5758
|
):
|
|
5678
5759
|
if leaf in budget_in:
|
|
5679
5760
|
merged_budget[leaf] = budget_in[leaf]
|
|
@@ -5766,7 +5847,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5766
5847
|
# Runs AFTER save_config (config persisted first); best-effort —
|
|
5767
5848
|
# never breaks the 200 response. Config write already left the
|
|
5768
5849
|
# config_writer_lock, so the helper's open_db never nests.
|
|
5769
|
-
|
|
5850
|
+
#
|
|
5851
|
+
# Gate each axis on the touched leaves (parity with `config set`):
|
|
5852
|
+
# running on an unrelated leaf would latch a currently-over-but-
|
|
5853
|
+
# not-yet-dispatched threshold, permanently suppressing the next
|
|
5854
|
+
# tick's dispatch. The dashboard accepts no `projects` leaf (the
|
|
5855
|
+
# map is CLI-only), so the per-project axis keys on
|
|
5856
|
+
# project_alerts_enabled/alert_thresholds.
|
|
5857
|
+
budget_in = payload["budget"]
|
|
5858
|
+
touched = (
|
|
5859
|
+
set(budget_in.keys()) if isinstance(budget_in, dict) else set()
|
|
5860
|
+
)
|
|
5861
|
+
if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds"}:
|
|
5862
|
+
_cctally()._reconcile_budget_on_config_write(validated_budget)
|
|
5863
|
+
if touched & {"project_alerts_enabled", "alert_thresholds"}:
|
|
5864
|
+
_cctally()._reconcile_project_budget_milestones_on_write(
|
|
5865
|
+
validated_budget
|
|
5866
|
+
)
|
|
5770
5867
|
if update_check_validated is not None:
|
|
5771
5868
|
# Echo the full merged check block (cooked defaults included)
|
|
5772
5869
|
# so the SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5818,11 +5915,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5818
5915
|
dispatch status string in the JSON response.
|
|
5819
5916
|
|
|
5820
5917
|
Body (all fields optional): ``{"axis":
|
|
5821
|
-
"weekly"|"five_hour"|"budget"|"projected"
|
|
5822
|
-
"metric": "weekly_pct"|"budget_usd"}``. Defaults:
|
|
5823
|
-
threshold=90, metric="weekly_pct". ``metric`` is only
|
|
5824
|
-
the ``projected`` axis (mirrors the CLI ``alerts test
|
|
5825
|
-
projected --metric`` surface); it is ignored for the other
|
|
5918
|
+
"weekly"|"five_hour"|"budget"|"projected"|"project_budget",
|
|
5919
|
+
"threshold": 1..100, "metric": "weekly_pct"|"budget_usd"}``. Defaults:
|
|
5920
|
+
axis="weekly", threshold=90, metric="weekly_pct". ``metric`` is only
|
|
5921
|
+
consulted for the ``projected`` axis (mirrors the CLI ``alerts test
|
|
5922
|
+
--axis projected --metric`` surface); it is ignored for the other
|
|
5923
|
+
axes. The ``project_budget`` axis dispatches a synthetic example
|
|
5924
|
+
project ($26 of $25) — no real ``budget.projects`` entry required.
|
|
5826
5925
|
|
|
5827
5926
|
IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
|
|
5828
5927
|
in the JSON API to match the dispatch payload's internal axis
|
|
@@ -5861,12 +5960,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5861
5960
|
return
|
|
5862
5961
|
|
|
5863
5962
|
axis = body.get("axis", "weekly")
|
|
5864
|
-
if axis not in (
|
|
5963
|
+
if axis not in (
|
|
5964
|
+
"weekly", "five_hour", "budget", "projected", "project_budget",
|
|
5965
|
+
):
|
|
5865
5966
|
self._respond_json(
|
|
5866
5967
|
400,
|
|
5867
5968
|
{"error": (
|
|
5868
|
-
"axis must be 'weekly', 'five_hour', 'budget'
|
|
5869
|
-
f"'projected', got {axis!r}"
|
|
5969
|
+
"axis must be 'weekly', 'five_hour', 'budget', "
|
|
5970
|
+
f"'projected' or 'project_budget', got {axis!r}"
|
|
5870
5971
|
)},
|
|
5871
5972
|
)
|
|
5872
5973
|
return
|
|
@@ -5917,6 +6018,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5917
6018
|
spent_usd=300.0 * threshold / 100.0,
|
|
5918
6019
|
consumption_pct=float(threshold),
|
|
5919
6020
|
)
|
|
6021
|
+
elif axis == "project_budget":
|
|
6022
|
+
# Synthetic per-project budget payload — mirrors the CLI
|
|
6023
|
+
# ``alerts test --axis project_budget`` branch (NO DB writes,
|
|
6024
|
+
# test/real divergence contract). Uses a fixed example project
|
|
6025
|
+
# ($26 of $25 = 104%) so no real ``budget.projects`` entry is
|
|
6026
|
+
# required; ``project_key`` is a placeholder canonical path.
|
|
6027
|
+
payload = _build_alert_payload_project_budget(
|
|
6028
|
+
threshold=threshold,
|
|
6029
|
+
crossed_at_utc=now_utc_iso(),
|
|
6030
|
+
week_start_at=dt.date.today().isoformat(),
|
|
6031
|
+
project="example-project",
|
|
6032
|
+
project_key="/example/repos/example-project",
|
|
6033
|
+
budget_usd=25.0,
|
|
6034
|
+
spent_usd=26.0,
|
|
6035
|
+
consumption_pct=104.0,
|
|
6036
|
+
)
|
|
5920
6037
|
elif axis == "projected":
|
|
5921
6038
|
# Synthetic projected-pace payload — mirrors the CLI
|
|
5922
6039
|
# cmd_alerts_test projected branch (NO DB writes, test/real
|