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
package/bin/_cctally_config.py
CHANGED
|
@@ -301,6 +301,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
301
301
|
"alerts.notifier",
|
|
302
302
|
"alerts.command_template",
|
|
303
303
|
"dashboard.bind",
|
|
304
|
+
"dashboard.expose_transcripts",
|
|
304
305
|
"update.check.enabled",
|
|
305
306
|
"update.check.ttl_hours",
|
|
306
307
|
"statusline.visual_burn_rate",
|
|
@@ -310,8 +311,10 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
310
311
|
"budget.alerts_enabled",
|
|
311
312
|
"budget.alert_thresholds",
|
|
312
313
|
"budget.projected_enabled",
|
|
314
|
+
"budget.period",
|
|
313
315
|
"budget.projects",
|
|
314
316
|
"budget.project_alerts_enabled",
|
|
317
|
+
"budget.codex",
|
|
315
318
|
)
|
|
316
319
|
|
|
317
320
|
|
|
@@ -449,6 +452,32 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
449
452
|
# Hand-edited junk: surface the default rather than the bad value;
|
|
450
453
|
# `cmd_dashboard` warns at server-start when it hits the same path.
|
|
451
454
|
return "loopback"
|
|
455
|
+
if key == "dashboard.expose_transcripts":
|
|
456
|
+
# Boolean opt-in (Plan 2, spec §5). Default False — transcript
|
|
457
|
+
# endpoints are served only over loopback unless this is true (LAN
|
|
458
|
+
# exposure). A hand-edited junk value surfaces the default, mirroring
|
|
459
|
+
# dashboard.bind.
|
|
460
|
+
block = config.get("dashboard") if isinstance(config, dict) else None
|
|
461
|
+
if not isinstance(block, dict):
|
|
462
|
+
block = {}
|
|
463
|
+
stored = block.get("expose_transcripts")
|
|
464
|
+
if stored is None:
|
|
465
|
+
return False
|
|
466
|
+
# Config stores a JSON bool; the shared string-normalizer
|
|
467
|
+
# (_normalize_alerts_enabled_value) only tolerates str spellings,
|
|
468
|
+
# so short-circuit a real bool here rather than re-forking it.
|
|
469
|
+
if isinstance(stored, bool):
|
|
470
|
+
return stored
|
|
471
|
+
# Only str spellings are normalizable. Any other JSON scalar/container
|
|
472
|
+
# (int/float/list/dict) must surface the default — NOT crash: the shared
|
|
473
|
+
# normalizer does ``(raw or "").strip()``, which raises AttributeError
|
|
474
|
+
# (uncaught by ``except ValueError``) on e.g. a hand-edited bare ``1``.
|
|
475
|
+
if isinstance(stored, str):
|
|
476
|
+
try:
|
|
477
|
+
return c._normalize_alerts_enabled_value(stored)
|
|
478
|
+
except ValueError:
|
|
479
|
+
return False
|
|
480
|
+
return False
|
|
452
481
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
453
482
|
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
454
483
|
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
@@ -501,8 +530,10 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
501
530
|
"budget.alerts_enabled",
|
|
502
531
|
"budget.alert_thresholds",
|
|
503
532
|
"budget.projected_enabled",
|
|
533
|
+
"budget.period",
|
|
504
534
|
"budget.projects",
|
|
505
535
|
"budget.project_alerts_enabled",
|
|
536
|
+
"budget.codex",
|
|
506
537
|
):
|
|
507
538
|
inner = key.split(".", 1)[1]
|
|
508
539
|
# Read the validated, defaults-filled block. A corrupt block falls
|
|
@@ -533,7 +564,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
533
564
|
# (including None) must survive into the render layer — the generic
|
|
534
565
|
# None->"" coercion below would break the JSON shape / round-trip.
|
|
535
566
|
def _coerce(k: str, v: "object") -> "object":
|
|
536
|
-
if k in ("alerts.command_template", "budget.projects"):
|
|
567
|
+
if k in ("alerts.command_template", "budget.projects", "budget.codex"):
|
|
537
568
|
return v
|
|
538
569
|
return v if v is not None else ""
|
|
539
570
|
|
|
@@ -562,11 +593,14 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
562
593
|
for k, v in pairs:
|
|
563
594
|
# Preserve canonical bool stringification (true/false) so
|
|
564
595
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
565
|
-
if k in (
|
|
596
|
+
if k in (
|
|
597
|
+
"alerts.command_template", "budget.projects", "budget.codex"
|
|
598
|
+
):
|
|
566
599
|
# JSON-encoded so `config get` output round-trips through the
|
|
567
600
|
# matching `config set` branch (both JSON-parse their value).
|
|
568
601
|
# `alerts.command_template` is a list-of-strings|null;
|
|
569
|
-
# `budget.projects` is an object {git-root: usd}
|
|
602
|
+
# `budget.projects` is an object {git-root: usd};
|
|
603
|
+
# `budget.codex` is an object|null (the no-budget sentinel).
|
|
570
604
|
rendered = json.dumps(v)
|
|
571
605
|
elif isinstance(v, bool):
|
|
572
606
|
rendered = "true" if v else "false"
|
|
@@ -787,6 +821,49 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
787
821
|
else:
|
|
788
822
|
print(f"dashboard.bind={canonical}")
|
|
789
823
|
return 0
|
|
824
|
+
if key == "dashboard.expose_transcripts":
|
|
825
|
+
# Same read-modify-write posture as dashboard.bind: validate first,
|
|
826
|
+
# then write under config_writer_lock with _load_config_unlocked
|
|
827
|
+
# (calling load_config inside the writer-lock self-deadlocks per the
|
|
828
|
+
# CLAUDE.md gotcha — fcntl.flock is per-fd, not per-process). Preserves
|
|
829
|
+
# a sibling dashboard.bind in the same parent block.
|
|
830
|
+
# Reuse the shared bool-normalizer (DRY with alerts.enabled); it
|
|
831
|
+
# hardcodes "alerts.enabled" in its ValueError text, so catch +
|
|
832
|
+
# re-message with the actual key name (mirrors alerts.projected_enabled).
|
|
833
|
+
try:
|
|
834
|
+
canonical = c._normalize_alerts_enabled_value(raw)
|
|
835
|
+
except ValueError:
|
|
836
|
+
print(
|
|
837
|
+
f"cctally: invalid boolean value for dashboard.expose_transcripts: "
|
|
838
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
839
|
+
file=sys.stderr,
|
|
840
|
+
)
|
|
841
|
+
return 2
|
|
842
|
+
with config_writer_lock():
|
|
843
|
+
config = _load_config_unlocked()
|
|
844
|
+
existing = config.get("dashboard")
|
|
845
|
+
if existing is not None and not isinstance(existing, dict):
|
|
846
|
+
print(
|
|
847
|
+
"cctally: dashboard config error: dashboard must be an object",
|
|
848
|
+
file=sys.stderr,
|
|
849
|
+
)
|
|
850
|
+
return 2
|
|
851
|
+
block = dict(existing or {})
|
|
852
|
+
block["expose_transcripts"] = canonical
|
|
853
|
+
config["dashboard"] = block
|
|
854
|
+
save_config(config)
|
|
855
|
+
if getattr(args, "emit_json", False):
|
|
856
|
+
print(
|
|
857
|
+
json.dumps(
|
|
858
|
+
{"dashboard": {"expose_transcripts": canonical}}, indent=2
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
else:
|
|
862
|
+
print(
|
|
863
|
+
f"dashboard.expose_transcripts="
|
|
864
|
+
f"{'true' if canonical else 'false'}"
|
|
865
|
+
)
|
|
866
|
+
return 0
|
|
790
867
|
if key in (
|
|
791
868
|
"statusline.visual_burn_rate",
|
|
792
869
|
"statusline.cost_source",
|
|
@@ -877,8 +954,10 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
877
954
|
"budget.alerts_enabled",
|
|
878
955
|
"budget.alert_thresholds",
|
|
879
956
|
"budget.projected_enabled",
|
|
957
|
+
"budget.period",
|
|
880
958
|
"budget.projects",
|
|
881
959
|
"budget.project_alerts_enabled",
|
|
960
|
+
"budget.codex",
|
|
882
961
|
):
|
|
883
962
|
inner_key = key.split(".", 1)[1]
|
|
884
963
|
# Parse + normalize the raw value per key BEFORE acquiring the lock so
|
|
@@ -912,6 +991,36 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
912
991
|
f"got {raw!r}"
|
|
913
992
|
)
|
|
914
993
|
return 2
|
|
994
|
+
elif inner_key == "period":
|
|
995
|
+
# `budget.period` is a plain string leaf. The enum check lives in
|
|
996
|
+
# _get_budget_config under the lock below (so a bad value is a
|
|
997
|
+
# clean exit-2 with the canonical message); here we just pass the
|
|
998
|
+
# raw token through.
|
|
999
|
+
new_val = raw.strip()
|
|
1000
|
+
elif inner_key == "codex":
|
|
1001
|
+
# `budget.codex` is a nested object (or null = no Codex budget),
|
|
1002
|
+
# which the plain leaves can't round-trip — JSON-parse it (mirrors
|
|
1003
|
+
# the budget.projects branch). The shape/period/amount rules are
|
|
1004
|
+
# enforced by _get_budget_config under the lock below; here we only
|
|
1005
|
+
# reject non-JSON and coerce the null sentinel.
|
|
1006
|
+
if raw.strip().lower() in {"null", "none"}:
|
|
1007
|
+
new_val = None
|
|
1008
|
+
else:
|
|
1009
|
+
try:
|
|
1010
|
+
parsed_codex = json.loads(raw)
|
|
1011
|
+
except (json.JSONDecodeError, ValueError):
|
|
1012
|
+
eprint(
|
|
1013
|
+
"cctally config: budget.codex must be a JSON object or "
|
|
1014
|
+
f"null, got {raw!r}"
|
|
1015
|
+
)
|
|
1016
|
+
return 2
|
|
1017
|
+
if parsed_codex is not None and not isinstance(parsed_codex, dict):
|
|
1018
|
+
eprint(
|
|
1019
|
+
"cctally config: budget.codex must be a JSON object or "
|
|
1020
|
+
"null"
|
|
1021
|
+
)
|
|
1022
|
+
return 2
|
|
1023
|
+
new_val = parsed_codex
|
|
915
1024
|
elif inner_key == "projects":
|
|
916
1025
|
# `budget.projects` is a dict {git-root: usd}, which the plain
|
|
917
1026
|
# number/bool/list leaves can't round-trip — JSON-parse it (mirrors
|
|
@@ -995,25 +1104,41 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
995
1104
|
# on `budget.weekly_usd` — would latch a currently-over-but-not-yet-
|
|
996
1105
|
# dispatched threshold as already-alerted, permanently suppressing the
|
|
997
1106
|
# next record-usage tick's dispatch. The global axis feeds on
|
|
998
|
-
# weekly_usd/alerts_enabled/alert_thresholds; the per-project axis
|
|
999
|
-
# projects/project_alerts_enabled/alert_thresholds (alert_thresholds
|
|
1000
|
-
# shared; projected_enabled belongs to neither reconcile).
|
|
1001
|
-
#
|
|
1002
|
-
|
|
1107
|
+
# weekly_usd/alerts_enabled/alert_thresholds/period; the per-project axis
|
|
1108
|
+
# on projects/project_alerts_enabled/alert_thresholds (alert_thresholds
|
|
1109
|
+
# is shared; projected_enabled belongs to neither reconcile). `period` is
|
|
1110
|
+
# in the global set because changing it re-keys the milestone window
|
|
1111
|
+
# (calendar period-start instant vs subscription-week); without the
|
|
1112
|
+
# reconcile, switching period while already over a threshold would
|
|
1113
|
+
# instant-popup on the next record-usage tick — the exact case the
|
|
1114
|
+
# forward-only-from-set reconcile prevents (`budget set --period` already
|
|
1115
|
+
# reconciles via the same helper). Both run OUTSIDE config_writer_lock
|
|
1116
|
+
# (each helper has its own open_db lock).
|
|
1117
|
+
if inner_key in (
|
|
1118
|
+
"weekly_usd", "alerts_enabled", "alert_thresholds", "period"
|
|
1119
|
+
):
|
|
1003
1120
|
c._reconcile_budget_on_config_write(validated)
|
|
1004
1121
|
if inner_key in (
|
|
1005
1122
|
"projects", "project_alerts_enabled", "alert_thresholds"
|
|
1006
1123
|
):
|
|
1007
1124
|
c._reconcile_project_budget_milestones_on_write(validated)
|
|
1125
|
+
# Codex budget axis (spec §6): the nested budget.codex block is set
|
|
1126
|
+
# wholesale via `config set budget.codex '<json>'`, so the only key that
|
|
1127
|
+
# touches it is `codex` itself. Gated on the codex block carrying
|
|
1128
|
+
# alerts_enabled + thresholds (the helper re-checks); records nothing
|
|
1129
|
+
# otherwise.
|
|
1130
|
+
if inner_key == "codex":
|
|
1131
|
+
c._reconcile_codex_budget_on_config_write(validated)
|
|
1008
1132
|
out_val = validated[inner_key]
|
|
1009
1133
|
if getattr(args, "emit_json", False):
|
|
1010
1134
|
print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
|
|
1011
1135
|
else:
|
|
1012
1136
|
if isinstance(out_val, bool):
|
|
1013
1137
|
rendered = "true" if out_val else "false"
|
|
1014
|
-
elif inner_key
|
|
1015
|
-
# JSON so `config get budget.projects` round-trips back
|
|
1016
|
-
# this branch (str(dict) is not valid JSON
|
|
1138
|
+
elif inner_key in ("projects", "codex"):
|
|
1139
|
+
# JSON so `config get budget.{projects,codex}` round-trips back
|
|
1140
|
+
# through this branch (str(dict)/None is not valid JSON; the
|
|
1141
|
+
# codex no-budget sentinel renders as `null`).
|
|
1017
1142
|
rendered = json.dumps(out_val)
|
|
1018
1143
|
else:
|
|
1019
1144
|
rendered = str(out_val)
|
|
@@ -1096,6 +1221,21 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1096
1221
|
save_config(config)
|
|
1097
1222
|
# idempotent: silent on missing key
|
|
1098
1223
|
return 0
|
|
1224
|
+
if key == "dashboard.expose_transcripts":
|
|
1225
|
+
# Mirror the dashboard.bind unset branch: drop only the
|
|
1226
|
+
# expose_transcripts leaf; if the dashboard block ends up empty, drop
|
|
1227
|
+
# the parent too so config.json stays tidy. A sibling dashboard.bind
|
|
1228
|
+
# survives.
|
|
1229
|
+
with config_writer_lock():
|
|
1230
|
+
config = _load_config_unlocked()
|
|
1231
|
+
block = config.get("dashboard")
|
|
1232
|
+
if isinstance(block, dict) and "expose_transcripts" in block:
|
|
1233
|
+
del block["expose_transcripts"]
|
|
1234
|
+
if not block:
|
|
1235
|
+
config.pop("dashboard", None)
|
|
1236
|
+
save_config(config)
|
|
1237
|
+
# idempotent: silent on missing key
|
|
1238
|
+
return 0
|
|
1099
1239
|
if key in (
|
|
1100
1240
|
"statusline.visual_burn_rate",
|
|
1101
1241
|
"statusline.cost_source",
|
|
@@ -1137,8 +1277,10 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1137
1277
|
"budget.alerts_enabled",
|
|
1138
1278
|
"budget.alert_thresholds",
|
|
1139
1279
|
"budget.projected_enabled",
|
|
1280
|
+
"budget.period",
|
|
1140
1281
|
"budget.projects",
|
|
1141
1282
|
"budget.project_alerts_enabled",
|
|
1283
|
+
"budget.codex",
|
|
1142
1284
|
):
|
|
1143
1285
|
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|
|
1144
1286
|
# unsetting weekly_usd keeps a customized alert_thresholds). If the
|
package/bin/_cctally_core.py
CHANGED
|
@@ -160,6 +160,24 @@ def _is_dev_checkout() -> bool:
|
|
|
160
160
|
return (_repo_root() / ".git").exists()
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
def _real_prod_data_dir() -> pathlib.Path:
|
|
164
|
+
"""The REAL user's prod data dir (~/.local/share/cctally), resolved from
|
|
165
|
+
the password database rather than $HOME so it is immune to a faked HOME.
|
|
166
|
+
|
|
167
|
+
The prod-migration guard (bin/_cctally_db.py, issue #142) compares the
|
|
168
|
+
connection's DB directory against this to tell a fake-HOME test 'prod'
|
|
169
|
+
(e.g. a golden harness's /tmp/scratch/.local/share/cctally) apart from
|
|
170
|
+
the actual prod dir. Monkeypatchable seam: tests point it at a tmp dir to
|
|
171
|
+
exercise the guard's fire path without touching real prod. Falls back to
|
|
172
|
+
Path.home() only if `pwd` is unavailable (cctally targets Unix only)."""
|
|
173
|
+
try:
|
|
174
|
+
import pwd
|
|
175
|
+
home = pathlib.Path(pwd.getpwuid(os.getuid()).pw_dir)
|
|
176
|
+
except Exception:
|
|
177
|
+
home = pathlib.Path.home()
|
|
178
|
+
return home / ".local" / "share" / "cctally"
|
|
179
|
+
|
|
180
|
+
|
|
163
181
|
_init_paths_from_env()
|
|
164
182
|
|
|
165
183
|
|
|
@@ -418,6 +436,16 @@ def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[d
|
|
|
418
436
|
def ensure_dirs() -> None:
|
|
419
437
|
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
420
438
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
# cache.db holds plaintext conversation prose at rest (Plan 2, spec §5), so
|
|
440
|
+
# the data dir must be 0700. Hardening it here in the shared primitive means
|
|
441
|
+
# a stats-first cold start — open_db() materializing APP_DIR before any
|
|
442
|
+
# cache.db open (e.g. record-usage) — is covered, not only the
|
|
443
|
+
# open_cache_db backstop (which keeps its own chmod). Best-effort and
|
|
444
|
+
# idempotent: swallow OSError + continue (issue #150).
|
|
445
|
+
try:
|
|
446
|
+
os.chmod(APP_DIR, 0o700)
|
|
447
|
+
except OSError as exc:
|
|
448
|
+
eprint(f"[core] could not chmod data dir 0700 ({exc}); continuing")
|
|
421
449
|
|
|
422
450
|
|
|
423
451
|
# === Alerts validation cluster ======================================
|
|
@@ -570,21 +598,49 @@ class _BudgetConfigError(ValueError):
|
|
|
570
598
|
"""Raised by _get_budget_config on an invalid budget block."""
|
|
571
599
|
|
|
572
600
|
|
|
601
|
+
def _validate_positive_budget_amount(v: object, label: str) -> float:
|
|
602
|
+
"""Validate a budget *amount* value: a non-bool finite number > 0.
|
|
603
|
+
|
|
604
|
+
Single-sources the rule shared by ``budget.weekly_usd``,
|
|
605
|
+
``budget.codex.amount_usd``, and each ``budget.projects`` value (code-review
|
|
606
|
+
#5). ``bool`` is an ``int`` subclass, so it's rejected explicitly. ``label``
|
|
607
|
+
is the human field name used in the raised message (e.g.
|
|
608
|
+
``"budget.weekly_usd"``). Null handling stays at the call site — this helper
|
|
609
|
+
only validates a value the caller has already decided must be a number.
|
|
610
|
+
"""
|
|
611
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
612
|
+
raise _BudgetConfigError(f"{label} must be a number")
|
|
613
|
+
if not math.isfinite(float(v)) or float(v) <= 0:
|
|
614
|
+
raise _BudgetConfigError(f"{label} must be a finite number > 0")
|
|
615
|
+
return float(v)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# Per-vendor budget period enums (calendar-period + Codex budgets feature).
|
|
619
|
+
# Claude budgets may use any of the three (default subscription-week, the
|
|
620
|
+
# existing reset-aware behavior); Codex budgets may NOT use subscription-week
|
|
621
|
+
# (it's an Anthropic-only concept), so Codex defaults to calendar-month. These
|
|
622
|
+
# are reused by the parser (`--period` choices) and the config layer.
|
|
623
|
+
BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
|
|
624
|
+
CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
|
|
573
625
|
_BUDGET_DEFAULTS = {
|
|
574
626
|
"weekly_usd": None, # None = no budget (default)
|
|
575
627
|
"alerts_enabled": True, # "on when set"
|
|
576
628
|
"alert_thresholds": [90, 100],
|
|
577
629
|
"projected_enabled": False, # projected-pace opt-in (#121); default OFF
|
|
630
|
+
"period": "subscription-week", # Claude period; default = existing behavior
|
|
578
631
|
"projects": {}, # per-project weekly $ budgets, keyed by git-root
|
|
579
632
|
"project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
|
|
633
|
+
"codex": None, # None = no Codex budget (nested block when set)
|
|
580
634
|
}
|
|
581
635
|
_BUDGET_CONFIG_VALID_KEYS = {
|
|
582
636
|
"weekly_usd",
|
|
583
637
|
"alerts_enabled",
|
|
584
638
|
"alert_thresholds",
|
|
585
639
|
"projected_enabled",
|
|
640
|
+
"period",
|
|
586
641
|
"projects",
|
|
587
642
|
"project_alerts_enabled",
|
|
643
|
+
"codex",
|
|
588
644
|
}
|
|
589
645
|
|
|
590
646
|
|
|
@@ -631,21 +687,18 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
631
687
|
out["alerts_enabled"] = v
|
|
632
688
|
|
|
633
689
|
if "alert_thresholds" in block:
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
)
|
|
647
|
-
cleaned.append(t)
|
|
648
|
-
out["alert_thresholds"] = sorted(set(cleaned)) # empty list allowed (silenced)
|
|
690
|
+
out["alert_thresholds"] = _validate_budget_thresholds(
|
|
691
|
+
block["alert_thresholds"], "budget.alert_thresholds"
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if "period" in block:
|
|
695
|
+
v = block["period"]
|
|
696
|
+
if not isinstance(v, str) or v not in BUDGET_PERIODS:
|
|
697
|
+
raise _BudgetConfigError(
|
|
698
|
+
"budget.period must be one of "
|
|
699
|
+
f"{', '.join(BUDGET_PERIODS)}, got {v!r}"
|
|
700
|
+
)
|
|
701
|
+
out["period"] = v
|
|
649
702
|
|
|
650
703
|
if "projected_enabled" in block:
|
|
651
704
|
v = block["projected_enabled"]
|
|
@@ -688,6 +741,107 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
688
741
|
)
|
|
689
742
|
out["project_alerts_enabled"] = v
|
|
690
743
|
|
|
744
|
+
if "codex" in block:
|
|
745
|
+
out["codex"] = _validate_codex_budget_block(block["codex"])
|
|
746
|
+
|
|
747
|
+
return out
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _validate_budget_thresholds(v: object, label: str) -> "list[int]":
|
|
751
|
+
"""Validate + canonicalize a budget alert-thresholds list.
|
|
752
|
+
|
|
753
|
+
Shared by the top-level ``budget.alert_thresholds`` and the nested
|
|
754
|
+
``budget.codex.alert_thresholds`` leaves. Entries must be ints in [1, 100]
|
|
755
|
+
(bool is an int subclass and is rejected). Returns a sorted, deduped list;
|
|
756
|
+
an empty list is allowed (alerts silenced).
|
|
757
|
+
"""
|
|
758
|
+
if not isinstance(v, list):
|
|
759
|
+
raise _BudgetConfigError(f"{label} must be a list of ints")
|
|
760
|
+
cleaned: "list[int]" = []
|
|
761
|
+
for t in v:
|
|
762
|
+
if isinstance(t, bool) or not isinstance(t, int):
|
|
763
|
+
raise _BudgetConfigError(f"{label} entries must be integers")
|
|
764
|
+
if t < 1 or t > 100:
|
|
765
|
+
raise _BudgetConfigError(f"{label} entries must be in [1, 100]")
|
|
766
|
+
cleaned.append(t)
|
|
767
|
+
return sorted(set(cleaned)) # empty list allowed (silenced)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
771
|
+
"""Validate the nested ``budget.codex`` block (Codex per-vendor budget).
|
|
772
|
+
|
|
773
|
+
``None`` is the no-Codex-budget sentinel. When set, it's an object with a
|
|
774
|
+
finite ``amount_usd`` > 0, a ``period`` in CODEX_BUDGET_PERIODS (NOT
|
|
775
|
+
subscription-week — Anthropic-only), ``alerts_enabled`` bool (default
|
|
776
|
+
False — opt-in, like every alert axis), ``alert_thresholds`` validated like
|
|
777
|
+
the top-level budget thresholds (default [90, 100]), and
|
|
778
|
+
``projected_enabled`` bool (default False). Returns a defaults-filled copy.
|
|
779
|
+
"""
|
|
780
|
+
if v is None:
|
|
781
|
+
return None
|
|
782
|
+
if not isinstance(v, dict):
|
|
783
|
+
raise _BudgetConfigError(
|
|
784
|
+
f"budget.codex must be an object or null, got {type(v).__name__}"
|
|
785
|
+
)
|
|
786
|
+
# warn-and-ignore unknown sub-keys (forward compat, like the parent block)
|
|
787
|
+
_codex_valid = {
|
|
788
|
+
"amount_usd", "period", "alerts_enabled", "alert_thresholds",
|
|
789
|
+
"projected_enabled",
|
|
790
|
+
}
|
|
791
|
+
for k in v.keys():
|
|
792
|
+
if k not in _codex_valid:
|
|
793
|
+
print(
|
|
794
|
+
f"warning: ignoring unknown budget.codex config key: {k}",
|
|
795
|
+
file=sys.stderr,
|
|
796
|
+
)
|
|
797
|
+
out: "dict" = {
|
|
798
|
+
"amount_usd": None,
|
|
799
|
+
"period": "calendar-month", # Codex default (NO subscription-week)
|
|
800
|
+
"alerts_enabled": False, # opt-in, like every alert axis
|
|
801
|
+
"alert_thresholds": [90, 100],
|
|
802
|
+
"projected_enabled": False,
|
|
803
|
+
}
|
|
804
|
+
# amount_usd — required (a Codex block must define a budget) finite > 0.
|
|
805
|
+
# Shares the positive-amount rule with weekly_usd / projects via the helper;
|
|
806
|
+
# the message form ("must be a number" / "must be a finite number > 0") is
|
|
807
|
+
# byte-identical to the prior inline checks (code-review #5).
|
|
808
|
+
if "amount_usd" not in v:
|
|
809
|
+
raise _BudgetConfigError("budget.codex.amount_usd is required")
|
|
810
|
+
out["amount_usd"] = _validate_positive_budget_amount(
|
|
811
|
+
v["amount_usd"], "budget.codex.amount_usd"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
if "period" in v:
|
|
815
|
+
p = v["period"]
|
|
816
|
+
if not isinstance(p, str) or p not in CODEX_BUDGET_PERIODS:
|
|
817
|
+
raise _BudgetConfigError(
|
|
818
|
+
"budget.codex.period must be one of "
|
|
819
|
+
f"{', '.join(CODEX_BUDGET_PERIODS)} (NOT subscription-week), "
|
|
820
|
+
f"got {p!r}"
|
|
821
|
+
)
|
|
822
|
+
out["period"] = p
|
|
823
|
+
|
|
824
|
+
if "alerts_enabled" in v:
|
|
825
|
+
ae = v["alerts_enabled"]
|
|
826
|
+
if not isinstance(ae, bool):
|
|
827
|
+
raise _BudgetConfigError(
|
|
828
|
+
"budget.codex.alerts_enabled must be a boolean"
|
|
829
|
+
)
|
|
830
|
+
out["alerts_enabled"] = ae
|
|
831
|
+
|
|
832
|
+
if "alert_thresholds" in v:
|
|
833
|
+
out["alert_thresholds"] = _validate_budget_thresholds(
|
|
834
|
+
v["alert_thresholds"], "budget.codex.alert_thresholds"
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
if "projected_enabled" in v:
|
|
838
|
+
pe = v["projected_enabled"]
|
|
839
|
+
if not isinstance(pe, bool):
|
|
840
|
+
raise _BudgetConfigError(
|
|
841
|
+
"budget.codex.projected_enabled must be a boolean"
|
|
842
|
+
)
|
|
843
|
+
out["projected_enabled"] = pe
|
|
844
|
+
|
|
691
845
|
return out
|
|
692
846
|
|
|
693
847
|
|
|
@@ -1101,44 +1255,50 @@ def open_db() -> sqlite3.Connection:
|
|
|
1101
1255
|
)
|
|
1102
1256
|
|
|
1103
1257
|
# ── budget_milestones (equiv-$ budget threshold crossings — issue #19) ──
|
|
1104
|
-
#
|
|
1105
|
-
# exact posture of `five_hour_milestones` (write-once, forward-only). A
|
|
1258
|
+
# Write-once, forward-only (the exact posture of `five_hour_milestones`). A
|
|
1106
1259
|
# mid-week quota reset re-anchors `week_start_at` (see
|
|
1107
1260
|
# `_resolve_current_budget_window`), so the new window naturally gets
|
|
1108
|
-
# fresh rows under UNIQUE(week_start_at, threshold) — no
|
|
1109
|
-
# segment column needed (unlike the percent/5h tables).
|
|
1110
|
-
# stores the effective/re-anchored ISO string from the
|
|
1111
|
-
# (`isoformat(timespec="seconds")`); the resolver's
|
|
1112
|
-
# returns a HOST-LOCAL tz-aware datetime, so this
|
|
1113
|
-
# host's UTC offset (e.g. `…T07:00:00-07:00`) —
|
|
1114
|
-
# portable across hosts, same posture as
|
|
1115
|
-
# Firing + reconcile + the dashboard
|
|
1116
|
-
# string on a given host, so the
|
|
1117
|
-
#
|
|
1118
|
-
#
|
|
1119
|
-
#
|
|
1120
|
-
#
|
|
1121
|
-
#
|
|
1122
|
-
#
|
|
1261
|
+
# fresh rows under UNIQUE(week_start_at, period, threshold) — no
|
|
1262
|
+
# `reset_event_id` segment column needed (unlike the percent/5h tables).
|
|
1263
|
+
# `week_start_at` stores the effective/re-anchored ISO string from the
|
|
1264
|
+
# resolver (`isoformat(timespec="seconds")`); the resolver's
|
|
1265
|
+
# `parse_iso_datetime` returns a HOST-LOCAL tz-aware datetime, so this
|
|
1266
|
+
# dedup key carries the host's UTC offset (e.g. `…T07:00:00-07:00`) —
|
|
1267
|
+
# host-consistent, NOT portable across hosts, same posture as
|
|
1268
|
+
# `five_hour_blocks.block_start_at`. Firing + reconcile + the dashboard
|
|
1269
|
+
# envelope all read/write the identical string on a given host, so the
|
|
1270
|
+
# UNIQUE dedup is exact. `alerted_at` is stamped BEFORE the osascript Popen
|
|
1271
|
+
# (set-then-dispatch invariant); NULL = "recorded without dispatch" (the
|
|
1272
|
+
# forward-only-from-set reconcile path) OR "not yet dispatched", never
|
|
1273
|
+
# "delivery failed".
|
|
1274
|
+
# Unified vendor-tagged table (#143): one row per (vendor, period_start_at,
|
|
1275
|
+
# period, threshold). `vendor` ∈ 'claude'|'codex'. `period_start_at` is the
|
|
1276
|
+
# resolved period-window start instant (subscription-week OR calendar
|
|
1277
|
+
# period-start). `period` is the configured period at crossing; NULL = pre-012
|
|
1278
|
+
# unknown. Owned by migration 012_unify_budget_milestones_vendor (merge of the
|
|
1279
|
+
# former budget_milestones + codex_budget_milestones). The Codex table is NO
|
|
1280
|
+
# LONGER live-created here — migration 012 drops it and this CREATE must not
|
|
1281
|
+
# resurrect it; migration 011 is hardened to skip it when absent (#143).
|
|
1123
1282
|
conn.execute(
|
|
1124
1283
|
"""
|
|
1125
1284
|
CREATE TABLE IF NOT EXISTS budget_milestones (
|
|
1126
1285
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1127
|
-
|
|
1286
|
+
vendor TEXT NOT NULL,
|
|
1287
|
+
period_start_at TEXT NOT NULL,
|
|
1288
|
+
period TEXT,
|
|
1128
1289
|
threshold INTEGER NOT NULL,
|
|
1129
1290
|
budget_usd REAL NOT NULL,
|
|
1130
1291
|
spent_usd REAL NOT NULL,
|
|
1131
1292
|
consumption_pct REAL NOT NULL,
|
|
1132
1293
|
crossed_at_utc TEXT NOT NULL,
|
|
1133
1294
|
alerted_at TEXT,
|
|
1134
|
-
UNIQUE(
|
|
1295
|
+
UNIQUE(vendor, period_start_at, period, threshold)
|
|
1135
1296
|
)
|
|
1136
1297
|
"""
|
|
1137
1298
|
)
|
|
1138
1299
|
|
|
1139
1300
|
# ── projected_milestones (week-average-pace projection crossings — #121) ──
|
|
1140
|
-
#
|
|
1141
|
-
# posture as `budget_milestones` (write-once, forward-only, no
|
|
1301
|
+
# Write-once, forward-only — same posture as `budget_milestones` (no
|
|
1142
1302
|
# `reset_event_id` segment column). Two metrics share the table, keyed by
|
|
1143
1303
|
# `metric` ('weekly_pct' | 'budget_usd'); a level fires once the
|
|
1144
1304
|
# WEEK-AVERAGE projection (not the displayed high-end verdict) crosses
|
|
@@ -1149,20 +1309,22 @@ def open_db() -> sqlite3.Connection:
|
|
|
1149
1309
|
# `week_start_at` (new window → fresh rows under the UNIQUE key), the
|
|
1150
1310
|
# budget-pattern reset handling — hence NO `reset_event_id` column.
|
|
1151
1311
|
# `alerted_at` is stamped BEFORE the osascript Popen (set-then-dispatch).
|
|
1152
|
-
#
|
|
1153
|
-
#
|
|
1312
|
+
# Schema owned by migration 011_budget_milestone_period_keys (the `period`
|
|
1313
|
+
# column + the period-inclusive UNIQUE; see _cctally_db.py). `period` is
|
|
1314
|
+
# NULL for pre-011 rows.
|
|
1154
1315
|
conn.execute(
|
|
1155
1316
|
"""
|
|
1156
1317
|
CREATE TABLE IF NOT EXISTS projected_milestones (
|
|
1157
1318
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1158
|
-
week_start_at TEXT NOT NULL,
|
|
1159
|
-
|
|
1319
|
+
week_start_at TEXT NOT NULL, -- period-start instant (subscription-week OR calendar period-start; back-compat name)
|
|
1320
|
+
period TEXT, -- configured period at crossing; NULL = pre-011 unknown (migration 011)
|
|
1321
|
+
metric TEXT NOT NULL, -- 'weekly_pct' | 'budget_usd' | 'codex_budget_usd'
|
|
1160
1322
|
threshold INTEGER NOT NULL, -- 90 | 100
|
|
1161
1323
|
projected_value REAL NOT NULL,
|
|
1162
|
-
denominator REAL NOT NULL, -- target_usd (budget) | 100.0 (weekly)
|
|
1324
|
+
denominator REAL NOT NULL, -- target_usd (budget / codex_budget) | 100.0 (weekly)
|
|
1163
1325
|
crossed_at_utc TEXT NOT NULL,
|
|
1164
1326
|
alerted_at TEXT,
|
|
1165
|
-
UNIQUE(week_start_at, metric, threshold)
|
|
1327
|
+
UNIQUE(week_start_at, period, metric, threshold)
|
|
1166
1328
|
)
|
|
1167
1329
|
"""
|
|
1168
1330
|
)
|