cctally 1.68.0 → 1.69.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 +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_config.py
CHANGED
|
@@ -75,6 +75,8 @@ from _cctally_core import (
|
|
|
75
75
|
_AlertsConfigError,
|
|
76
76
|
_get_budget_config,
|
|
77
77
|
_BudgetConfigError,
|
|
78
|
+
_validate_codex_budget_block,
|
|
79
|
+
CODEX_BUDGET_LEAVES,
|
|
78
80
|
)
|
|
79
81
|
from _lib_display_tz import normalize_display_tz_value
|
|
80
82
|
|
|
@@ -300,6 +302,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
300
302
|
"alerts.projected_enabled",
|
|
301
303
|
"alerts.notifier",
|
|
302
304
|
"alerts.command_template",
|
|
305
|
+
"alerts.quota",
|
|
303
306
|
"dashboard.bind",
|
|
304
307
|
"dashboard.expose_transcripts",
|
|
305
308
|
"dashboard.cache_failure_markers",
|
|
@@ -318,10 +321,236 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
318
321
|
"budget.projects",
|
|
319
322
|
"budget.project_alerts_enabled",
|
|
320
323
|
"budget.codex",
|
|
324
|
+
"budget.codex.amount_usd",
|
|
325
|
+
"budget.codex.period",
|
|
326
|
+
"budget.codex.alerts_enabled",
|
|
327
|
+
"budget.codex.alert_thresholds",
|
|
328
|
+
"budget.codex.projected_enabled",
|
|
321
329
|
"telemetry.enabled",
|
|
322
330
|
)
|
|
323
331
|
|
|
324
332
|
|
|
333
|
+
_CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _config_codex_leaf_value(config: dict, key: str) -> object:
|
|
337
|
+
"""Read one nested Codex budget leaf with its public default.
|
|
338
|
+
|
|
339
|
+
``config get`` is deliberately forgiving of a hand-edited corrupt block,
|
|
340
|
+
just like the existing whole-object ``budget.codex`` read: it reports the
|
|
341
|
+
defaults without repairing the file. Mutations use the strict setter below.
|
|
342
|
+
"""
|
|
343
|
+
if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
344
|
+
raise ValueError(f"not a Codex budget leaf: {key!r}")
|
|
345
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
346
|
+
if leaf not in CODEX_BUDGET_LEAVES:
|
|
347
|
+
raise ValueError(f"unknown Codex budget leaf: {key!r}")
|
|
348
|
+
try:
|
|
349
|
+
block = _get_budget_config(config)["codex"]
|
|
350
|
+
except _BudgetConfigError:
|
|
351
|
+
block = None
|
|
352
|
+
if block is None:
|
|
353
|
+
defaults = {
|
|
354
|
+
"amount_usd": None,
|
|
355
|
+
"period": "calendar-month",
|
|
356
|
+
"alerts_enabled": False,
|
|
357
|
+
"alert_thresholds": [90, 100],
|
|
358
|
+
"projected_enabled": False,
|
|
359
|
+
}
|
|
360
|
+
value = defaults[leaf]
|
|
361
|
+
else:
|
|
362
|
+
value = block[leaf]
|
|
363
|
+
return list(value) if isinstance(value, list) else value
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
|
|
367
|
+
"""Parse exactly the command-line wire format for one Codex leaf."""
|
|
368
|
+
if leaf == "amount_usd":
|
|
369
|
+
try:
|
|
370
|
+
return float(raw_value)
|
|
371
|
+
except ValueError as exc:
|
|
372
|
+
raise _BudgetConfigError(
|
|
373
|
+
"budget.codex.amount_usd must be a finite number > 0"
|
|
374
|
+
) from exc
|
|
375
|
+
if leaf == "period":
|
|
376
|
+
return raw_value.strip()
|
|
377
|
+
if leaf in {"alerts_enabled", "projected_enabled"}:
|
|
378
|
+
normalized = raw_value.strip().lower()
|
|
379
|
+
if normalized in {"true", "yes", "on", "1"}:
|
|
380
|
+
return True
|
|
381
|
+
if normalized in {"false", "no", "off", "0"}:
|
|
382
|
+
return False
|
|
383
|
+
raise _BudgetConfigError(f"budget.codex.{leaf} must be a boolean")
|
|
384
|
+
if leaf == "alert_thresholds":
|
|
385
|
+
if not raw_value.strip():
|
|
386
|
+
return []
|
|
387
|
+
parsed: list[int] = []
|
|
388
|
+
for part in raw_value.split(","):
|
|
389
|
+
try:
|
|
390
|
+
parsed.append(int(part.strip(), 10))
|
|
391
|
+
except ValueError as exc:
|
|
392
|
+
raise _BudgetConfigError(
|
|
393
|
+
"budget.codex.alert_thresholds must be a comma-separated "
|
|
394
|
+
"list of integers"
|
|
395
|
+
) from exc
|
|
396
|
+
return parsed
|
|
397
|
+
raise _BudgetConfigError(f"unknown Codex budget leaf {leaf!r}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
|
|
401
|
+
"""Return the canonical prospective nested Codex budget block.
|
|
402
|
+
|
|
403
|
+
The caller owns the writer lock and persistence. This pure merge helper
|
|
404
|
+
validates existing state before mutating it, so malformed user state never
|
|
405
|
+
becomes a partially repaired write.
|
|
406
|
+
"""
|
|
407
|
+
if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
408
|
+
raise _BudgetConfigError(f"unknown Codex budget leaf {key!r}")
|
|
409
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
410
|
+
if leaf not in CODEX_BUDGET_LEAVES:
|
|
411
|
+
raise _BudgetConfigError(f"unknown Codex budget leaf {key!r}")
|
|
412
|
+
budget = config.get("budget")
|
|
413
|
+
if budget is not None and not isinstance(budget, dict):
|
|
414
|
+
raise _BudgetConfigError("budget must be an object")
|
|
415
|
+
existing = (budget or {}).get("codex")
|
|
416
|
+
if existing is None:
|
|
417
|
+
if leaf != "amount_usd":
|
|
418
|
+
raise _BudgetConfigError(
|
|
419
|
+
"budget.codex.amount_usd must be configured before setting "
|
|
420
|
+
f"budget.codex.{leaf}"
|
|
421
|
+
)
|
|
422
|
+
prospective: dict = {}
|
|
423
|
+
else:
|
|
424
|
+
if not isinstance(existing, dict):
|
|
425
|
+
raise _BudgetConfigError(
|
|
426
|
+
f"budget.codex must be an object or null, got {type(existing).__name__}"
|
|
427
|
+
)
|
|
428
|
+
# Strictly validate first: malformed existing data must abort without
|
|
429
|
+
# mutation, while valid unknown siblings retain the validator's
|
|
430
|
+
# existing warn-and-drop behavior when the prospective block is built.
|
|
431
|
+
validated_existing = _validate_codex_budget_block(existing)
|
|
432
|
+
assert validated_existing is not None
|
|
433
|
+
prospective = dict(validated_existing)
|
|
434
|
+
prospective[leaf] = _parse_codex_budget_leaf_value(leaf, raw_value)
|
|
435
|
+
validated = _validate_codex_budget_block(prospective)
|
|
436
|
+
assert validated is not None
|
|
437
|
+
return validated
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ``alerts.quota`` is an additive nested object owned by the provider-neutral
|
|
441
|
+
# quota projection. Keep the existing core alert reader byte-compatible for
|
|
442
|
+
# Claude axes while making this now-known child exempt from its unknown-key
|
|
443
|
+
# warning path.
|
|
444
|
+
_cctally_core._ALERTS_CONFIG_VALID_KEYS.add("quota")
|
|
445
|
+
|
|
446
|
+
_QUOTA_ALERT_KEYS = {
|
|
447
|
+
"enabled", "actual_thresholds", "projected_thresholds", "rules",
|
|
448
|
+
}
|
|
449
|
+
_QUOTA_RULE_KEYS = {
|
|
450
|
+
"source", "source_root_key", "logical_limit_key",
|
|
451
|
+
"actual_thresholds", "projected_thresholds",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _quota_alert_error(message: str) -> None:
|
|
456
|
+
raise _cctally_core._AlertsConfigError(message)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _validate_quota_thresholds(name: str, value: object) -> list[int]:
|
|
460
|
+
"""Validate a quota list; unlike legacy axis lists, [] deliberately silences."""
|
|
461
|
+
if not isinstance(value, list):
|
|
462
|
+
_quota_alert_error(f"alerts.quota.{name} must be a list of integers")
|
|
463
|
+
result: list[int] = []
|
|
464
|
+
prior = 0
|
|
465
|
+
for item in value:
|
|
466
|
+
if not isinstance(item, int) or isinstance(item, bool):
|
|
467
|
+
_quota_alert_error(
|
|
468
|
+
f"alerts.quota.{name} items must be integers, got "
|
|
469
|
+
f"{type(item).__name__}: {item!r}"
|
|
470
|
+
)
|
|
471
|
+
if not 1 <= item <= 100:
|
|
472
|
+
_quota_alert_error(
|
|
473
|
+
f"alerts.quota.{name} items must be in [1, 100], got {item}"
|
|
474
|
+
)
|
|
475
|
+
if item <= prior:
|
|
476
|
+
_quota_alert_error(f"alerts.quota.{name} must be strictly increasing")
|
|
477
|
+
result.append(item)
|
|
478
|
+
prior = item
|
|
479
|
+
return result
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _get_quota_alerts_config(cfg: "dict | None") -> dict:
|
|
483
|
+
"""Return the strict, defaults-filled ``alerts.quota`` configuration."""
|
|
484
|
+
alerts = (cfg or {}).get("alerts", {})
|
|
485
|
+
if alerts is None:
|
|
486
|
+
alerts = {}
|
|
487
|
+
if not isinstance(alerts, dict):
|
|
488
|
+
_quota_alert_error("alerts must be an object")
|
|
489
|
+
quota = alerts.get("quota", {})
|
|
490
|
+
if quota is None or not isinstance(quota, dict):
|
|
491
|
+
_quota_alert_error("alerts.quota must be an object")
|
|
492
|
+
for key in quota:
|
|
493
|
+
if key not in _QUOTA_ALERT_KEYS:
|
|
494
|
+
print(
|
|
495
|
+
f"warning: ignoring unknown alerts.quota config key: {key}",
|
|
496
|
+
file=sys.stderr,
|
|
497
|
+
)
|
|
498
|
+
enabled = quota.get("enabled", False)
|
|
499
|
+
if not isinstance(enabled, bool):
|
|
500
|
+
_quota_alert_error(
|
|
501
|
+
"alerts.quota.enabled must be a JSON boolean, got "
|
|
502
|
+
f"{type(enabled).__name__}: {enabled!r}"
|
|
503
|
+
)
|
|
504
|
+
actual = _validate_quota_thresholds(
|
|
505
|
+
"actual_thresholds", quota.get("actual_thresholds", [90, 95])
|
|
506
|
+
)
|
|
507
|
+
projected = _validate_quota_thresholds(
|
|
508
|
+
"projected_thresholds", quota.get("projected_thresholds", [])
|
|
509
|
+
)
|
|
510
|
+
raw_rules = quota.get("rules", [])
|
|
511
|
+
if not isinstance(raw_rules, list):
|
|
512
|
+
_quota_alert_error("alerts.quota.rules must be a list")
|
|
513
|
+
rules: list[dict] = []
|
|
514
|
+
seen: set[tuple[str, str, str]] = set()
|
|
515
|
+
for index, raw_rule in enumerate(raw_rules):
|
|
516
|
+
prefix = f"alerts.quota.rules[{index}]"
|
|
517
|
+
if not isinstance(raw_rule, dict):
|
|
518
|
+
_quota_alert_error(f"{prefix} must be an object")
|
|
519
|
+
if set(raw_rule) != _QUOTA_RULE_KEYS:
|
|
520
|
+
_quota_alert_error(
|
|
521
|
+
f"{prefix} must contain exactly {sorted(_QUOTA_RULE_KEYS)}"
|
|
522
|
+
)
|
|
523
|
+
normalized: dict[str, object] = {}
|
|
524
|
+
for key in ("source", "source_root_key", "logical_limit_key"):
|
|
525
|
+
value = raw_rule[key]
|
|
526
|
+
if not isinstance(value, str) or not value:
|
|
527
|
+
_quota_alert_error(f"{prefix}.{key} must be a non-empty string")
|
|
528
|
+
normalized[key] = value
|
|
529
|
+
normalized["actual_thresholds"] = _validate_quota_thresholds(
|
|
530
|
+
f"rules[{index}].actual_thresholds", raw_rule["actual_thresholds"]
|
|
531
|
+
)
|
|
532
|
+
normalized["projected_thresholds"] = _validate_quota_thresholds(
|
|
533
|
+
f"rules[{index}].projected_thresholds", raw_rule["projected_thresholds"]
|
|
534
|
+
)
|
|
535
|
+
identity = (
|
|
536
|
+
str(normalized["source"]), str(normalized["source_root_key"]),
|
|
537
|
+
str(normalized["logical_limit_key"]),
|
|
538
|
+
)
|
|
539
|
+
if identity in seen:
|
|
540
|
+
_quota_alert_error(
|
|
541
|
+
"alerts.quota.rules must be unique by source, source_root_key, "
|
|
542
|
+
"and logical_limit_key"
|
|
543
|
+
)
|
|
544
|
+
seen.add(identity)
|
|
545
|
+
rules.append(normalized)
|
|
546
|
+
return {
|
|
547
|
+
"enabled": enabled,
|
|
548
|
+
"actual_thresholds": actual,
|
|
549
|
+
"projected_thresholds": projected,
|
|
550
|
+
"rules": rules,
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
325
554
|
# === statusline config validators (issue #86 Session G) ===================
|
|
326
555
|
|
|
327
556
|
_STATUSLINE_VBR_VALUES = ("off", "emoji", "text", "emoji-text")
|
|
@@ -459,6 +688,11 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
459
688
|
return _get_alerts_config(config)["command_template"]
|
|
460
689
|
except c._AlertsConfigError:
|
|
461
690
|
return None
|
|
691
|
+
if key == "alerts.quota":
|
|
692
|
+
try:
|
|
693
|
+
return _get_quota_alerts_config(config)
|
|
694
|
+
except c._AlertsConfigError:
|
|
695
|
+
return _get_quota_alerts_config({})
|
|
462
696
|
if key == "dashboard.bind":
|
|
463
697
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
464
698
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -580,6 +814,8 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
580
814
|
return c._validate_update_check_ttl_hours_value(stored)
|
|
581
815
|
except ValueError:
|
|
582
816
|
return c.UPDATE_DEFAULT_TTL_HOURS
|
|
817
|
+
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
818
|
+
return _config_codex_leaf_value(config, key)
|
|
583
819
|
if key in (
|
|
584
820
|
"statusline.visual_burn_rate",
|
|
585
821
|
"statusline.cost_source",
|
|
@@ -649,7 +885,10 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
649
885
|
# (including None) must survive into the render layer — the generic
|
|
650
886
|
# None->"" coercion below would break the JSON shape / round-trip.
|
|
651
887
|
def _coerce(k: str, v: "object") -> "object":
|
|
652
|
-
if k in (
|
|
888
|
+
if k in (
|
|
889
|
+
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
890
|
+
"budget.codex",
|
|
891
|
+
) or k.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
653
892
|
return v
|
|
654
893
|
return v if v is not None else ""
|
|
655
894
|
|
|
@@ -667,7 +906,19 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
667
906
|
# a flat tail (`{"update": {"check.enabled": ...}}`) and
|
|
668
907
|
# diverged from `config set --json` / on-disk shape.
|
|
669
908
|
out: "dict[str, object]" = {}
|
|
909
|
+
nested_parents = {
|
|
910
|
+
candidate
|
|
911
|
+
for candidate, _value in pairs
|
|
912
|
+
if any(child.startswith(candidate + ".") for child, _value in pairs)
|
|
913
|
+
}
|
|
670
914
|
for k, v in pairs:
|
|
915
|
+
# A bulk read includes both `budget.codex` (null when not
|
|
916
|
+
# configured) and its additive `budget.codex.*` leaves. A scalar
|
|
917
|
+
# parent cannot coexist with those children in JSON, so let the
|
|
918
|
+
# specific leaves build their object. Direct `config get
|
|
919
|
+
# budget.codex --json` has no descendant pair and remains null.
|
|
920
|
+
if k in nested_parents and not isinstance(v, dict):
|
|
921
|
+
continue
|
|
671
922
|
segments = k.split(".")
|
|
672
923
|
node: dict = out
|
|
673
924
|
for seg in segments[:-1]:
|
|
@@ -679,7 +930,8 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
679
930
|
# Preserve canonical bool stringification (true/false) so
|
|
680
931
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
681
932
|
if k in (
|
|
682
|
-
"alerts.command_template", "
|
|
933
|
+
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
934
|
+
"budget.codex",
|
|
683
935
|
):
|
|
684
936
|
# JSON-encoded so `config get` output round-trips through the
|
|
685
937
|
# matching `config set` branch (both JSON-parse their value).
|
|
@@ -687,6 +939,8 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
687
939
|
# `budget.projects` is an object {git-root: usd};
|
|
688
940
|
# `budget.codex` is an object|null (the no-budget sentinel).
|
|
689
941
|
rendered = json.dumps(v)
|
|
942
|
+
elif k.startswith(_CODEX_BUDGET_LEAF_PREFIX) and v is None:
|
|
943
|
+
rendered = "null"
|
|
690
944
|
elif isinstance(v, bool):
|
|
691
945
|
rendered = "true" if v else "false"
|
|
692
946
|
elif isinstance(v, list):
|
|
@@ -705,6 +959,36 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
705
959
|
if key not in ALLOWED_CONFIG_KEYS:
|
|
706
960
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
707
961
|
return 2
|
|
962
|
+
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
963
|
+
try:
|
|
964
|
+
with config_writer_lock():
|
|
965
|
+
config = _load_config_unlocked()
|
|
966
|
+
configured = _set_codex_budget_leaf(config, key, raw)
|
|
967
|
+
budget = config.get("budget")
|
|
968
|
+
if budget is not None and not isinstance(budget, dict):
|
|
969
|
+
raise _BudgetConfigError("budget must be an object")
|
|
970
|
+
block = dict(budget or {})
|
|
971
|
+
block["codex"] = configured
|
|
972
|
+
config["budget"] = block
|
|
973
|
+
save_config(config)
|
|
974
|
+
except _BudgetConfigError as exc:
|
|
975
|
+
eprint(f"cctally config: {exc}")
|
|
976
|
+
return 2
|
|
977
|
+
# Deliberately outside the config lock: this helper opens and locks DBs.
|
|
978
|
+
c._reconcile_codex_budget_on_config_write({"codex": configured})
|
|
979
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
980
|
+
value = configured[leaf]
|
|
981
|
+
if getattr(args, "emit_json", False):
|
|
982
|
+
print(json.dumps({"budget": {"codex": {leaf: value}}}, indent=2))
|
|
983
|
+
else:
|
|
984
|
+
if isinstance(value, bool):
|
|
985
|
+
rendered = "true" if value else "false"
|
|
986
|
+
elif isinstance(value, list):
|
|
987
|
+
rendered = ",".join(str(item) for item in value)
|
|
988
|
+
else:
|
|
989
|
+
rendered = str(value)
|
|
990
|
+
print(f"{key}={rendered}")
|
|
991
|
+
return 0
|
|
708
992
|
if key == "display.tz":
|
|
709
993
|
try:
|
|
710
994
|
canonical = normalize_display_tz_value(raw)
|
|
@@ -878,6 +1162,44 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
878
1162
|
else:
|
|
879
1163
|
print(f"alerts.command_template={json.dumps(parsed)}")
|
|
880
1164
|
return 0
|
|
1165
|
+
if key == "alerts.quota":
|
|
1166
|
+
try:
|
|
1167
|
+
parsed = json.loads(raw)
|
|
1168
|
+
except (ValueError, TypeError) as exc:
|
|
1169
|
+
print(
|
|
1170
|
+
f"cctally: alerts.quota must be a JSON object: {exc}",
|
|
1171
|
+
file=sys.stderr,
|
|
1172
|
+
)
|
|
1173
|
+
return 2
|
|
1174
|
+
if not isinstance(parsed, dict):
|
|
1175
|
+
print("cctally: alerts.quota must be a JSON object", file=sys.stderr)
|
|
1176
|
+
return 2
|
|
1177
|
+
with config_writer_lock():
|
|
1178
|
+
config = _load_config_unlocked()
|
|
1179
|
+
existing_alerts = config.get("alerts")
|
|
1180
|
+
if existing_alerts is not None and not isinstance(existing_alerts, dict):
|
|
1181
|
+
print(
|
|
1182
|
+
"cctally: alerts config error: alerts must be an object",
|
|
1183
|
+
file=sys.stderr,
|
|
1184
|
+
)
|
|
1185
|
+
return 2
|
|
1186
|
+
alerts_block = dict(existing_alerts or {})
|
|
1187
|
+
alerts_block["quota"] = parsed
|
|
1188
|
+
candidate = {**config, "alerts": alerts_block}
|
|
1189
|
+
try:
|
|
1190
|
+
_get_alerts_config(candidate)
|
|
1191
|
+
normalized = _get_quota_alerts_config(candidate)
|
|
1192
|
+
except _AlertsConfigError as exc:
|
|
1193
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
1194
|
+
return 2
|
|
1195
|
+
alerts_block["quota"] = normalized
|
|
1196
|
+
config["alerts"] = alerts_block
|
|
1197
|
+
save_config(config)
|
|
1198
|
+
if getattr(args, "emit_json", False):
|
|
1199
|
+
print(json.dumps({"alerts": {"quota": normalized}}, indent=2))
|
|
1200
|
+
else:
|
|
1201
|
+
print(f"alerts.quota={json.dumps(normalized)}")
|
|
1202
|
+
return 0
|
|
881
1203
|
if key == "dashboard.bind":
|
|
882
1204
|
# Validation rejects whitespace / empty / non-string up front;
|
|
883
1205
|
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
@@ -1350,6 +1672,49 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1350
1672
|
if key not in ALLOWED_CONFIG_KEYS:
|
|
1351
1673
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
1352
1674
|
return 2
|
|
1675
|
+
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
1676
|
+
c = _cctally()
|
|
1677
|
+
configured: dict | None = None
|
|
1678
|
+
try:
|
|
1679
|
+
with config_writer_lock():
|
|
1680
|
+
config = _load_config_unlocked()
|
|
1681
|
+
budget = config.get("budget")
|
|
1682
|
+
if budget is not None and not isinstance(budget, dict):
|
|
1683
|
+
raise _BudgetConfigError("budget must be an object")
|
|
1684
|
+
existing = (budget or {}).get("codex")
|
|
1685
|
+
if existing is None:
|
|
1686
|
+
return 0
|
|
1687
|
+
if not isinstance(existing, dict):
|
|
1688
|
+
raise _BudgetConfigError(
|
|
1689
|
+
f"budget.codex must be an object or null, got {type(existing).__name__}"
|
|
1690
|
+
)
|
|
1691
|
+
validated_existing = _validate_codex_budget_block(existing)
|
|
1692
|
+
assert validated_existing is not None
|
|
1693
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
1694
|
+
block = dict(budget or {})
|
|
1695
|
+
if leaf == "amount_usd":
|
|
1696
|
+
block.pop("codex", None)
|
|
1697
|
+
if block:
|
|
1698
|
+
config["budget"] = block
|
|
1699
|
+
else:
|
|
1700
|
+
config.pop("budget", None)
|
|
1701
|
+
save_config(config)
|
|
1702
|
+
return 0
|
|
1703
|
+
prospective = dict(validated_existing)
|
|
1704
|
+
prospective.pop(leaf, None)
|
|
1705
|
+
configured = _validate_codex_budget_block(prospective)
|
|
1706
|
+
assert configured is not None
|
|
1707
|
+
block["codex"] = configured
|
|
1708
|
+
config["budget"] = block
|
|
1709
|
+
save_config(config)
|
|
1710
|
+
except _BudgetConfigError as exc:
|
|
1711
|
+
eprint(f"cctally config: {exc}")
|
|
1712
|
+
return 2
|
|
1713
|
+
# An optional leaf leaves a configured block, even when it already
|
|
1714
|
+
# held its default; the forward-only state must be reconciled once.
|
|
1715
|
+
assert configured is not None
|
|
1716
|
+
c._reconcile_codex_budget_on_config_write({"codex": configured})
|
|
1717
|
+
return 0
|
|
1353
1718
|
if key == "display.tz":
|
|
1354
1719
|
with config_writer_lock():
|
|
1355
1720
|
config = _load_config_unlocked()
|
|
@@ -1366,6 +1731,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1366
1731
|
"alerts.projected_enabled",
|
|
1367
1732
|
"alerts.notifier",
|
|
1368
1733
|
"alerts.command_template",
|
|
1734
|
+
"alerts.quota",
|
|
1369
1735
|
):
|
|
1370
1736
|
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
1371
1737
|
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|
package/bin/_cctally_core.py
CHANGED
|
@@ -65,6 +65,8 @@ def _init_paths_from_env() -> None:
|
|
|
65
65
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
66
66
|
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
67
67
|
global HOOK_TICK_THROTTLE_PATH, HOOK_TICK_THROTTLE_LOCK_PATH
|
|
68
|
+
global STATUSLINE_OBSERVE_MARKER_PATH, STATUSLINE_PERSIST_LOCK_PATH
|
|
69
|
+
global OAUTH_BACKOFF_MARKER_PATH, OAUTH_BACKOFF_COUNT_PATH
|
|
68
70
|
global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
|
|
69
71
|
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
70
72
|
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
@@ -119,6 +121,31 @@ def _init_paths_from_env() -> None:
|
|
|
119
121
|
HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
|
|
120
122
|
HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
|
|
121
123
|
|
|
124
|
+
# Statusline usage-persistence markers + lock (spec 2026-07-17
|
|
125
|
+
# usage-statusline-fallback). The statusline is the PRIMARY automatic
|
|
126
|
+
# writer of weekly/5h usage snapshots; these gate it.
|
|
127
|
+
# - STATUSLINE_OBSERVE_MARKER_PATH: liveness marker (mtime-based,
|
|
128
|
+
# like HOOK_TICK_THROTTLE_PATH) — touched after every statusline
|
|
129
|
+
# persist that fed valid 7d input through cmd_record_usage, INCLUDING
|
|
130
|
+
# a dedup no-op. Represents "the statusline is alive and feeding".
|
|
131
|
+
# The statusline persist throttle AND the OAuth backfill gate both
|
|
132
|
+
# key off this, NOT snapshot age (cmd_record_usage dedups unchanged
|
|
133
|
+
# percentages without refreshing captured_at, so snapshot age keeps
|
|
134
|
+
# growing while the statusline is actively feeding).
|
|
135
|
+
# - STATUSLINE_PERSIST_LOCK_PATH: cross-process flock serializing the
|
|
136
|
+
# detached persist feeder so a multi-session render herd yields at
|
|
137
|
+
# most one snapshot per throttle window.
|
|
138
|
+
# - OAUTH_BACKOFF_MARKER_PATH: shared 429 cooldown. Stores an ABSOLUTE
|
|
139
|
+
# epoch deadline in the file CONTENT (never the mtime — future-dating
|
|
140
|
+
# an mtime would corrupt HOOK_TICK_THROTTLE_PATH's age reading).
|
|
141
|
+
STATUSLINE_OBSERVE_MARKER_PATH = APP_DIR / "statusline-observe.last"
|
|
142
|
+
STATUSLINE_PERSIST_LOCK_PATH = APP_DIR / "statusline-persist.lock"
|
|
143
|
+
OAUTH_BACKOFF_MARKER_PATH = APP_DIR / "oauth-backoff.until"
|
|
144
|
+
# Consecutive-429 counter (text int) driving the headerless exponential
|
|
145
|
+
# backoff (base * 2**count). Separate from the deadline marker so the
|
|
146
|
+
# deadline file stays a single parseable float.
|
|
147
|
+
OAUTH_BACKOFF_COUNT_PATH = APP_DIR / "oauth-backoff.count"
|
|
148
|
+
|
|
122
149
|
UPDATE_STATE_PATH = APP_DIR / "update-state.json"
|
|
123
150
|
UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
|
|
124
151
|
UPDATE_LOCK_PATH = APP_DIR / "update.lock"
|
|
@@ -210,6 +237,24 @@ def _real_prod_data_dir() -> pathlib.Path:
|
|
|
210
237
|
return home / ".local" / "share" / "cctally"
|
|
211
238
|
|
|
212
239
|
|
|
240
|
+
# === Statusline-persist / OAuth-backfill tunables ==========================
|
|
241
|
+
# Internal (no config UI — YAGNI, spec §Out of scope); test injection only.
|
|
242
|
+
# Spec 2026-07-17-usage-statusline-fallback-design.
|
|
243
|
+
#
|
|
244
|
+
# STATUSLINE_PERSIST_THROTTLE_SECONDS: min seconds between statusline
|
|
245
|
+
# persist attempts (keyed off STATUSLINE_OBSERVE_MARKER_PATH liveness).
|
|
246
|
+
# OAUTH_BACKFILL_STALE_SECONDS: the OAuth poll only backfills once the
|
|
247
|
+
# observation marker is at least this stale (i.e. the statusline has
|
|
248
|
+
# NOT fed recently). Strictly greater than the persist throttle so the
|
|
249
|
+
# statusline is the primary writer and OAuth only covers its absence.
|
|
250
|
+
# OAUTH_BACKOFF_BASE_SECONDS / OAUTH_BACKOFF_CAP_SECONDS: the headerless
|
|
251
|
+
# exponential 429 backoff (base * 2**consecutive_429, capped).
|
|
252
|
+
STATUSLINE_PERSIST_THROTTLE_SECONDS = 60.0
|
|
253
|
+
OAUTH_BACKFILL_STALE_SECONDS = 300.0
|
|
254
|
+
OAUTH_BACKOFF_BASE_SECONDS = 60.0
|
|
255
|
+
OAUTH_BACKOFF_CAP_SECONDS = 3600.0
|
|
256
|
+
|
|
257
|
+
|
|
213
258
|
_init_paths_from_env()
|
|
214
259
|
|
|
215
260
|
|
|
@@ -681,6 +726,10 @@ def _validate_positive_budget_amount(v: object, label: str) -> float:
|
|
|
681
726
|
# are reused by the parser (`--period` choices) and the config layer.
|
|
682
727
|
BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
|
|
683
728
|
CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
|
|
729
|
+
CODEX_BUDGET_LEAVES = (
|
|
730
|
+
"amount_usd", "period", "alerts_enabled", "alert_thresholds",
|
|
731
|
+
"projected_enabled",
|
|
732
|
+
)
|
|
684
733
|
_BUDGET_DEFAULTS = {
|
|
685
734
|
"weekly_usd": None, # None = no budget (default)
|
|
686
735
|
"alerts_enabled": True, # "on when set"
|
|
@@ -843,12 +892,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
843
892
|
f"budget.codex must be an object or null, got {type(v).__name__}"
|
|
844
893
|
)
|
|
845
894
|
# warn-and-ignore unknown sub-keys (forward compat, like the parent block)
|
|
846
|
-
_codex_valid = {
|
|
847
|
-
"amount_usd", "period", "alerts_enabled", "alert_thresholds",
|
|
848
|
-
"projected_enabled",
|
|
849
|
-
}
|
|
850
895
|
for k in v.keys():
|
|
851
|
-
if k not in
|
|
896
|
+
if k not in CODEX_BUDGET_LEAVES:
|
|
852
897
|
print(
|
|
853
898
|
f"warning: ignoring unknown budget.codex config key: {k}",
|
|
854
899
|
file=sys.stderr,
|
|
@@ -914,6 +959,118 @@ def _budget_alerts_active(budget_cfg: dict) -> bool:
|
|
|
914
959
|
# === DB primitive ===================================================
|
|
915
960
|
|
|
916
961
|
|
|
962
|
+
def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
963
|
+
"""Create the current durable provider-neutral quota projection schema.
|
|
964
|
+
|
|
965
|
+
The physical observations remain in cache.db. These tables are the
|
|
966
|
+
idempotent interpreted index consumed by the Codex quota adapter; migration
|
|
967
|
+
013 calls this same helper for old databases while ``open_db`` calls it for
|
|
968
|
+
fresh installs before the migration dispatcher stamps the migration.
|
|
969
|
+
"""
|
|
970
|
+
conn.executescript(
|
|
971
|
+
"""
|
|
972
|
+
CREATE TABLE IF NOT EXISTS quota_window_blocks (
|
|
973
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
974
|
+
source TEXT NOT NULL,
|
|
975
|
+
source_root_key TEXT NOT NULL,
|
|
976
|
+
logical_limit_key TEXT NOT NULL,
|
|
977
|
+
observed_slot TEXT NOT NULL,
|
|
978
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
979
|
+
limit_id TEXT,
|
|
980
|
+
limit_name TEXT,
|
|
981
|
+
resets_at_utc TEXT NOT NULL,
|
|
982
|
+
nominal_start_at_utc TEXT NOT NULL,
|
|
983
|
+
first_observed_at_utc TEXT NOT NULL,
|
|
984
|
+
last_observed_at_utc TEXT NOT NULL,
|
|
985
|
+
first_percent REAL NOT NULL,
|
|
986
|
+
current_percent REAL NOT NULL,
|
|
987
|
+
last_source_path TEXT NOT NULL,
|
|
988
|
+
last_line_offset INTEGER NOT NULL,
|
|
989
|
+
generation TEXT NOT NULL,
|
|
990
|
+
orphaned_at TEXT,
|
|
991
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
992
|
+
window_minutes, resets_at_utc)
|
|
993
|
+
);
|
|
994
|
+
CREATE INDEX IF NOT EXISTS idx_quota_blocks_active
|
|
995
|
+
ON quota_window_blocks(source, source_root_key, orphaned_at,
|
|
996
|
+
logical_limit_key, observed_slot,
|
|
997
|
+
window_minutes, resets_at_utc);
|
|
998
|
+
|
|
999
|
+
CREATE TABLE IF NOT EXISTS quota_percent_milestones (
|
|
1000
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1001
|
+
source TEXT NOT NULL,
|
|
1002
|
+
source_root_key TEXT NOT NULL,
|
|
1003
|
+
logical_limit_key TEXT NOT NULL,
|
|
1004
|
+
observed_slot TEXT NOT NULL,
|
|
1005
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1006
|
+
resets_at_utc TEXT NOT NULL,
|
|
1007
|
+
percent_threshold INTEGER NOT NULL CHECK(percent_threshold BETWEEN 1 AND 100),
|
|
1008
|
+
captured_at_utc TEXT NOT NULL,
|
|
1009
|
+
source_path TEXT NOT NULL,
|
|
1010
|
+
line_offset INTEGER NOT NULL,
|
|
1011
|
+
high_water_percent INTEGER NOT NULL CHECK(high_water_percent BETWEEN 1 AND 100),
|
|
1012
|
+
generation TEXT NOT NULL,
|
|
1013
|
+
orphaned_at TEXT,
|
|
1014
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
1015
|
+
window_minutes, resets_at_utc, percent_threshold)
|
|
1016
|
+
);
|
|
1017
|
+
CREATE INDEX IF NOT EXISTS idx_quota_milestones_active
|
|
1018
|
+
ON quota_percent_milestones(source, source_root_key, orphaned_at,
|
|
1019
|
+
logical_limit_key, observed_slot,
|
|
1020
|
+
window_minutes, resets_at_utc,
|
|
1021
|
+
percent_threshold);
|
|
1022
|
+
|
|
1023
|
+
CREATE TABLE IF NOT EXISTS quota_threshold_events (
|
|
1024
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1025
|
+
source TEXT NOT NULL,
|
|
1026
|
+
source_root_key TEXT NOT NULL,
|
|
1027
|
+
logical_limit_key TEXT NOT NULL,
|
|
1028
|
+
observed_slot TEXT NOT NULL,
|
|
1029
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1030
|
+
resets_at_utc TEXT NOT NULL,
|
|
1031
|
+
threshold INTEGER NOT NULL CHECK(threshold BETWEEN 1 AND 100),
|
|
1032
|
+
qualifying_kind TEXT NOT NULL CHECK(qualifying_kind IN ('actual','projected')),
|
|
1033
|
+
qualifying_percent REAL,
|
|
1034
|
+
projected_percent REAL,
|
|
1035
|
+
severity TEXT NOT NULL,
|
|
1036
|
+
created_at_utc TEXT NOT NULL,
|
|
1037
|
+
disposition TEXT NOT NULL CHECK(disposition IN ('alerted','suppressed_backfill')),
|
|
1038
|
+
alerted_at TEXT,
|
|
1039
|
+
suppressed_at TEXT,
|
|
1040
|
+
orphaned_at TEXT,
|
|
1041
|
+
CHECK((disposition = 'alerted' AND alerted_at IS NOT NULL AND suppressed_at IS NULL)
|
|
1042
|
+
OR (disposition = 'suppressed_backfill' AND suppressed_at IS NOT NULL AND alerted_at IS NULL)),
|
|
1043
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
1044
|
+
window_minutes, resets_at_utc, threshold)
|
|
1045
|
+
);
|
|
1046
|
+
CREATE INDEX IF NOT EXISTS idx_quota_threshold_events_active
|
|
1047
|
+
ON quota_threshold_events(source, source_root_key, orphaned_at,
|
|
1048
|
+
logical_limit_key, observed_slot,
|
|
1049
|
+
window_minutes, resets_at_utc, threshold);
|
|
1050
|
+
|
|
1051
|
+
CREATE TABLE IF NOT EXISTS quota_projection_state (
|
|
1052
|
+
source_root_key TEXT PRIMARY KEY,
|
|
1053
|
+
generation TEXT NOT NULL,
|
|
1054
|
+
physical_signature TEXT NOT NULL,
|
|
1055
|
+
completed_at_utc TEXT NOT NULL
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
CREATE TABLE IF NOT EXISTS quota_alert_arming (
|
|
1059
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1060
|
+
source TEXT NOT NULL,
|
|
1061
|
+
source_root_key TEXT NOT NULL,
|
|
1062
|
+
logical_limit_key TEXT NOT NULL,
|
|
1063
|
+
observed_slot TEXT NOT NULL,
|
|
1064
|
+
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1065
|
+
rule_fingerprint TEXT NOT NULL,
|
|
1066
|
+
activated_at_utc TEXT NOT NULL,
|
|
1067
|
+
UNIQUE(source, source_root_key, logical_limit_key, observed_slot,
|
|
1068
|
+
window_minutes)
|
|
1069
|
+
);
|
|
1070
|
+
"""
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
917
1074
|
def open_db() -> sqlite3.Connection:
|
|
918
1075
|
c = _cctally()
|
|
919
1076
|
# Spec §2.6 carve-out: open_db reaches the migration framework
|
|
@@ -1474,6 +1631,12 @@ def open_db() -> sqlite3.Connection:
|
|
|
1474
1631
|
"""
|
|
1475
1632
|
)
|
|
1476
1633
|
|
|
1634
|
+
# Stats migration 013 owns durable quota interpretation. Keep the current
|
|
1635
|
+
# schema in the fresh-install path before the dispatcher, exactly like the
|
|
1636
|
+
# existing live CREATE tables; its handler calls this same idempotent helper
|
|
1637
|
+
# for an older stats.db and the dispatcher central-stamps on clean return.
|
|
1638
|
+
_apply_quota_projection_schema(conn)
|
|
1639
|
+
|
|
1477
1640
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1478
1641
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
1479
1642
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|