cctally 1.98.0 → 1.99.1
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 +31 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +375 -19
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1510 -120
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +81 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +513 -47
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +26 -1
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +236 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +12 -0
- package/dashboard/static/assets/index-C5NBB2w9.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CC8TTZUC.css +0 -1
- package/dashboard/static/assets/index-CChXFhs_.js +0 -97
package/bin/_cctally_account.py
CHANGED
|
@@ -324,6 +324,8 @@ def cmd_account(args: argparse.Namespace) -> int:
|
|
|
324
324
|
return _cmd_account_show(args)
|
|
325
325
|
if action == "label":
|
|
326
326
|
return _cmd_account_label(args)
|
|
327
|
+
if action == "attribute":
|
|
328
|
+
return _cmd_account_attribute(args)
|
|
327
329
|
eprint("account: unknown action")
|
|
328
330
|
return 2
|
|
329
331
|
|
|
@@ -528,3 +530,926 @@ def _cmd_account_label(args: argparse.Namespace) -> int:
|
|
|
528
530
|
_jr.run_stats_ingest(mode="authoritative")
|
|
529
531
|
print(f"Labeled {key[:8]} -> {label}")
|
|
530
532
|
return 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# --------------------------------------------------------------------------
|
|
536
|
+
# #500 — `cctally account attribute`: operator attribution of recorded Codex
|
|
537
|
+
# quota windows and spend.
|
|
538
|
+
#
|
|
539
|
+
# Spec: docs/superpowers/specs/2026-08-14-500-codex-window-attribution-design.md
|
|
540
|
+
#
|
|
541
|
+
# The command is a PLANNER plus an apply sequence. Planning decides what to
|
|
542
|
+
# record and refuses loudly; the fold-time overlay (`_lib_codex_window_
|
|
543
|
+
# attribution`, applied inside `load_codex_quota_observations`) re-decides what
|
|
544
|
+
# to APPLY on every load and suppresses quietly. The two evaluation points are
|
|
545
|
+
# deliberately separate: ingest keeps running, and a group that was cleanly
|
|
546
|
+
# unattributed at assertion time may later acquire native evidence.
|
|
547
|
+
#
|
|
548
|
+
# Nothing here writes `quota_window_snapshots`. The journal holds the truth, a
|
|
549
|
+
# derived cache table indexes it, and both axes follow from one insertion point.
|
|
550
|
+
# --------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
#: Refusal codes, sorted into this order wherever more than one applies, so the
|
|
553
|
+
#: envelope is stable and an operator reads the same first cause every run.
|
|
554
|
+
#:
|
|
555
|
+
#: `not_weekly` and `model_scoped` share a DISPOSITION and a remedy, and they
|
|
556
|
+
#: are still two codes here, because this surface names a CAUSE per window
|
|
557
|
+
#: rather than a decision. Measured on the maintainer's store, one whole-era
|
|
558
|
+
#: range reaches 513 five-hour windows against 26 separate model pools, and an
|
|
559
|
+
#: operator reading `refusalCodes: ["model_scoped"]` beside a 5-hour reset
|
|
560
|
+
#: concludes the `_lib_codex_pools` classifier is wrong about it. The fold-time
|
|
561
|
+
#: outcome deliberately stays ONE value (`_lib_codex_window_attribution
|
|
562
|
+
#: .SUPPRESSED_MODEL_SCOPED`): that surface names a suppression decision, and
|
|
563
|
+
#: nothing acts on the two differently there.
|
|
564
|
+
_REFUSAL_ORDER = (
|
|
565
|
+
"partial_group",
|
|
566
|
+
"not_weekly",
|
|
567
|
+
"model_scoped",
|
|
568
|
+
"native_account_conflict",
|
|
569
|
+
"assertion_conflict",
|
|
570
|
+
"spend_account_conflict",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
#: The refusals that BLOCK an all-or-nothing apply, which is not every refusal.
|
|
574
|
+
#:
|
|
575
|
+
#: Each of these names a real disagreement about a window the operator could
|
|
576
|
+
#: legitimately have meant, so applying the rest would leave the era they asked
|
|
577
|
+
#: for half-attributed. `not_weekly` and `model_scoped` are deliberately NOT
|
|
578
|
+
#: among them: a 5-hour window or a separate model pool can NEVER be account
|
|
579
|
+
#: weekly quota, so it was never a candidate, and the operator did not ask for
|
|
580
|
+
#: it — a time range selected it. Measured read-only against the maintainer's
|
|
581
|
+
#: store on 2026-08-15, a single `--since 2025-12-01 --until 2026-07-29` selects
|
|
582
|
+
#: 605 groups of which 539 are out of scope (513 five-hour windows and 26 Spark
|
|
583
|
+
#: pools) and 66 are attributable; letting the 539 block would refuse every run
|
|
584
|
+
#: an operator could ever make, which is the literal reading of the spec's
|
|
585
|
+
#: precedence table and is dead on arrival. Such a group is still REPORTED as
|
|
586
|
+
#: refused with its code, which is what AC4's "refused at plan time" asks for
|
|
587
|
+
#: and what keeps the exclusion visible rather than silent.
|
|
588
|
+
_BLOCKING_REFUSALS = frozenset({
|
|
589
|
+
"partial_group",
|
|
590
|
+
"native_account_conflict",
|
|
591
|
+
"assertion_conflict",
|
|
592
|
+
"spend_account_conflict",
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class _AttributeUsage(ValueError):
|
|
597
|
+
"""A native-usage error: printed on stderr, exit 2."""
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _attribute_parse_instant(text: object, flag: str) -> "dt.datetime":
|
|
601
|
+
"""A timezone-aware ISO instant, normalized to UTC.
|
|
602
|
+
|
|
603
|
+
Naive input is refused rather than assumed to be UTC or local. The selector
|
|
604
|
+
is a half-open `[since, until)` range and partial-group refusal depends on
|
|
605
|
+
exactly which side of it a boundary falls, so an ambiguous instant would
|
|
606
|
+
silently change which groups are whole.
|
|
607
|
+
"""
|
|
608
|
+
import datetime as dt
|
|
609
|
+
|
|
610
|
+
raw = str(text or "").strip()
|
|
611
|
+
if not raw:
|
|
612
|
+
raise _AttributeUsage(f"{flag} requires an ISO instant")
|
|
613
|
+
try:
|
|
614
|
+
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
615
|
+
except ValueError:
|
|
616
|
+
raise _AttributeUsage(
|
|
617
|
+
f"{flag}: {raw!r} is not an ISO-8601 instant") from None
|
|
618
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
619
|
+
raise _AttributeUsage(
|
|
620
|
+
f"{flag}: {raw!r} is naive; a timezone-aware instant is required "
|
|
621
|
+
"(for example 2026-01-01T00:00:00Z)")
|
|
622
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _attribute_resolve_account(conn, ref: str) -> str:
|
|
626
|
+
"""Resolve `<ref>` to a REAL Codex account key, or raise `_AttributeUsage`.
|
|
627
|
+
|
|
628
|
+
The literal `unattributed` resolves everywhere else a ref is accepted and is
|
|
629
|
+
rejected here, because attributing data TO the sentinel is not a fact an
|
|
630
|
+
operator can assert — the sentinel means "nobody could determine this".
|
|
631
|
+
"""
|
|
632
|
+
try:
|
|
633
|
+
key = _lib_accounts.resolve_account_ref(conn, ref)
|
|
634
|
+
except _lib_accounts.AccountRefError as exc:
|
|
635
|
+
eprint(f"account attribute: ref {ref!r} is ambiguous or unknown")
|
|
636
|
+
print_ref_candidates(conn, exc.candidates)
|
|
637
|
+
raise _AttributeUsage("unresolvable ref") from None
|
|
638
|
+
if key == _lib_accounts.UNATTRIBUTED:
|
|
639
|
+
raise _AttributeUsage(
|
|
640
|
+
"account attribute: the literal 'unattributed' names the "
|
|
641
|
+
"\"account could not be determined\" bucket, so it cannot be the "
|
|
642
|
+
"SUBJECT of an attribution")
|
|
643
|
+
row = conn.execute(
|
|
644
|
+
"SELECT provider FROM accounts WHERE account_key = ?", (key,)
|
|
645
|
+
).fetchone()
|
|
646
|
+
provider = (row[0] if row is not None else None) or ""
|
|
647
|
+
if provider != "codex":
|
|
648
|
+
raise _AttributeUsage(
|
|
649
|
+
f"account attribute: {ref!r} resolves to a {provider or 'unknown'} "
|
|
650
|
+
"account; this command attributes codex data only (Claude legacy "
|
|
651
|
+
"history keeps its accounts_cutover answer)")
|
|
652
|
+
return key
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _attribute_witness(value) -> str:
|
|
656
|
+
"""One spelling for one reset instant, applied to BOTH sides of the binding.
|
|
657
|
+
|
|
658
|
+
The same normalizer the overlay applies to a group's stored resets and to an
|
|
659
|
+
assertion's stored witnesses, so a `Z` witness and a `+00:00` group member
|
|
660
|
+
intersect. Taken from the pure ledger leaf rather than respelled.
|
|
661
|
+
"""
|
|
662
|
+
import _lib_quota_ledger
|
|
663
|
+
|
|
664
|
+
return _lib_quota_ledger.normalize_reset(value)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class _AttributeGroup:
|
|
668
|
+
"""One planned physical window group, or one planned assertion record."""
|
|
669
|
+
|
|
670
|
+
__slots__ = (
|
|
671
|
+
"group_key", "source_root_key", "logical_limit_key", "observed_slot",
|
|
672
|
+
"window_minutes", "canonical_resets_at", "raw_resets", "disposition",
|
|
673
|
+
"native_accounts", "assertion_accounts", "observation_count",
|
|
674
|
+
"spend_candidate_count", "refusal_codes", "op_ids",
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
def __init__(self, **kwargs):
|
|
678
|
+
for name in self.__slots__:
|
|
679
|
+
setattr(self, name, kwargs.get(name))
|
|
680
|
+
|
|
681
|
+
@property
|
|
682
|
+
def blocks(self) -> bool:
|
|
683
|
+
"""Whether this group's refusal stops the whole apply."""
|
|
684
|
+
return bool(set(self.refusal_codes) & _BLOCKING_REFUSALS)
|
|
685
|
+
|
|
686
|
+
@property
|
|
687
|
+
def fingerprint(self) -> tuple:
|
|
688
|
+
"""Everything the apply revalidation compares.
|
|
689
|
+
|
|
690
|
+
Spec §8: the preview was computed without locks, so the plan is
|
|
691
|
+
re-derived under them and compared before anything is written. This is
|
|
692
|
+
the comparison — the group's witnesses, the accounts present, the
|
|
693
|
+
active assertions and the spend counts.
|
|
694
|
+
"""
|
|
695
|
+
return (
|
|
696
|
+
tuple(self.group_key), tuple(self.raw_resets),
|
|
697
|
+
tuple(self.native_accounts), tuple(self.assertion_accounts),
|
|
698
|
+
int(self.observation_count), int(self.spend_candidate_count),
|
|
699
|
+
str(self.disposition), tuple(self.refusal_codes),
|
|
700
|
+
tuple(self.op_ids or ()),
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
def to_json(self) -> dict:
|
|
704
|
+
return {
|
|
705
|
+
"group": {
|
|
706
|
+
"sourceRootKey": self.source_root_key,
|
|
707
|
+
"logicalLimitKey": self.logical_limit_key,
|
|
708
|
+
"observedSlot": self.observed_slot,
|
|
709
|
+
"windowMinutes": int(self.window_minutes),
|
|
710
|
+
"canonicalResetsAtUtc": self.canonical_resets_at,
|
|
711
|
+
"rawResetsAtUtc": list(self.raw_resets),
|
|
712
|
+
},
|
|
713
|
+
"disposition": self.disposition,
|
|
714
|
+
"nativeAccountKeys": list(self.native_accounts),
|
|
715
|
+
"assertionAccountKeys": list(self.assertion_accounts),
|
|
716
|
+
"observationCount": int(self.observation_count),
|
|
717
|
+
"spendCandidateCount": int(self.spend_candidate_count),
|
|
718
|
+
"refusalCodes": list(self.refusal_codes),
|
|
719
|
+
"assertionOpIds": list(self.op_ids or ()),
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _attribute_spend_index(conn, roots) -> dict:
|
|
724
|
+
"""Per root, the accounting rows sorted by instant, for O(log n) lookups.
|
|
725
|
+
|
|
726
|
+
One query per root rather than one per group: a whole-history plan can name
|
|
727
|
+
dozens of groups, and the alternative is a full scan each time.
|
|
728
|
+
"""
|
|
729
|
+
index: dict = {}
|
|
730
|
+
for root in sorted(roots):
|
|
731
|
+
rows = []
|
|
732
|
+
try:
|
|
733
|
+
cursor = conn.execute(
|
|
734
|
+
"SELECT timestamp_utc, account_key FROM codex_session_entries "
|
|
735
|
+
" WHERE source_root_key = ? ORDER BY timestamp_utc", (root,))
|
|
736
|
+
except sqlite3.DatabaseError:
|
|
737
|
+
index[root] = ([], [])
|
|
738
|
+
continue
|
|
739
|
+
for timestamp, account in cursor:
|
|
740
|
+
parsed = _attribute_parse_stored_instant(timestamp)
|
|
741
|
+
if parsed is None:
|
|
742
|
+
continue
|
|
743
|
+
rows.append((parsed, account))
|
|
744
|
+
rows.sort(key=lambda item: item[0])
|
|
745
|
+
index[root] = ([item[0] for item in rows], [item[1] for item in rows])
|
|
746
|
+
return index
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _attribute_parse_stored_instant(value):
|
|
750
|
+
import datetime as dt
|
|
751
|
+
|
|
752
|
+
if not isinstance(value, str) or not value.strip():
|
|
753
|
+
return None
|
|
754
|
+
text = value.strip()
|
|
755
|
+
if text.endswith("Z"):
|
|
756
|
+
text = text[:-1] + "+00:00"
|
|
757
|
+
try:
|
|
758
|
+
parsed = dt.datetime.fromisoformat(text)
|
|
759
|
+
except ValueError:
|
|
760
|
+
return None
|
|
761
|
+
if parsed.tzinfo is None:
|
|
762
|
+
return parsed.replace(tzinfo=dt.timezone.utc)
|
|
763
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _attribute_spend_slice(index, root, start, end):
|
|
767
|
+
"""The `[start, end)` accounting rows of one root, as account keys."""
|
|
768
|
+
import bisect
|
|
769
|
+
|
|
770
|
+
instants, accounts = index.get(root, ([], []))
|
|
771
|
+
low = bisect.bisect_left(instants, start)
|
|
772
|
+
high = bisect.bisect_left(instants, end)
|
|
773
|
+
return accounts[low:high]
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _attribute_plan_groups(conn, *, account_key, since, until):
|
|
777
|
+
"""Plan the ATTRIBUTE mode: which physical window groups the range names.
|
|
778
|
+
|
|
779
|
+
The range selects OBSERVATIONS. A group every one of whose observations
|
|
780
|
+
falls inside it is whole and may be attributed; a group the range covers
|
|
781
|
+
only partially is refused rather than split, and its whole extent is
|
|
782
|
+
reported so the operator can widen the range instead of guessing at it.
|
|
783
|
+
"""
|
|
784
|
+
import datetime as dt
|
|
785
|
+
|
|
786
|
+
import _cctally_quota as _quota
|
|
787
|
+
import _lib_codex_pools as _pools
|
|
788
|
+
import _lib_codex_window_attribution as _wa
|
|
789
|
+
|
|
790
|
+
observations = _quota.load_codex_quota_observations(cache_conn=conn)
|
|
791
|
+
_resolutions, ownership = _quota.resolve_codex_window_attributions(conn)
|
|
792
|
+
|
|
793
|
+
buckets: dict = {}
|
|
794
|
+
for observation in observations:
|
|
795
|
+
identity = observation.identity
|
|
796
|
+
anchor = observation.canonical_resets_at
|
|
797
|
+
key = (identity.source, identity.source_root_key,
|
|
798
|
+
identity.logical_limit_key, identity.observed_slot,
|
|
799
|
+
identity.window_minutes, anchor)
|
|
800
|
+
bucket = buckets.get(key)
|
|
801
|
+
if bucket is None:
|
|
802
|
+
bucket = buckets[key] = {
|
|
803
|
+
"members": [], "accounts": set(), "witnesses": set(),
|
|
804
|
+
"model_scoped": False,
|
|
805
|
+
}
|
|
806
|
+
bucket["members"].append(observation)
|
|
807
|
+
if identity.account_key != _wa.UNATTRIBUTED_SENTINEL:
|
|
808
|
+
bucket["accounts"].add(identity.account_key)
|
|
809
|
+
bucket["witnesses"].add(_attribute_witness(
|
|
810
|
+
observation.resets_at.astimezone(dt.timezone.utc).isoformat()))
|
|
811
|
+
# `limit_name` sits outside identity equality, so one group's members
|
|
812
|
+
# can disagree about it; ANY Spark evidence demotes the whole group out
|
|
813
|
+
# of account weekly quota (#373), matching the fold and the spend pass.
|
|
814
|
+
if _pools.is_model_scoped_codex_quota(
|
|
815
|
+
identity.logical_limit_key, identity.limit_name):
|
|
816
|
+
bucket["model_scoped"] = True
|
|
817
|
+
|
|
818
|
+
selected_keys = [
|
|
819
|
+
key for key, bucket in buckets.items()
|
|
820
|
+
if any(since <= member.captured_at < until
|
|
821
|
+
for member in bucket["members"])
|
|
822
|
+
]
|
|
823
|
+
roots = {key[1] for key in selected_keys}
|
|
824
|
+
spend_index = _attribute_spend_index(conn, roots)
|
|
825
|
+
|
|
826
|
+
planned = []
|
|
827
|
+
for key in sorted(selected_keys, key=lambda item: (item[5], str(item))):
|
|
828
|
+
bucket = buckets[key]
|
|
829
|
+
members = bucket["members"]
|
|
830
|
+
owner = ownership.get(key)
|
|
831
|
+
# The overlay stamps an unattributed observation only where the group
|
|
832
|
+
# carries NO native real account, so subtracting the resolved owner
|
|
833
|
+
# recovers the pre-overlay native population exactly.
|
|
834
|
+
native = sorted(bucket["accounts"] - ({owner} if owner else set()))
|
|
835
|
+
asserted = sorted({owner} if owner else set())
|
|
836
|
+
anchor = key[5]
|
|
837
|
+
window_minutes = int(key[4])
|
|
838
|
+
start = anchor - dt.timedelta(minutes=window_minutes)
|
|
839
|
+
spend = _attribute_spend_slice(spend_index, key[1], start, anchor)
|
|
840
|
+
spend_candidates = sum(
|
|
841
|
+
1 for account in spend
|
|
842
|
+
if not account or account == _wa.UNATTRIBUTED_SENTINEL)
|
|
843
|
+
spend_conflicts = sorted({
|
|
844
|
+
account for account in spend
|
|
845
|
+
if account and account != _wa.UNATTRIBUTED_SENTINEL
|
|
846
|
+
and account != account_key
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
codes = []
|
|
850
|
+
if not all(since <= member.captured_at < until for member in members):
|
|
851
|
+
codes.append("partial_group")
|
|
852
|
+
if window_minutes != _wa.ACCOUNT_WEEKLY_WINDOW_MINUTES:
|
|
853
|
+
codes.append("not_weekly")
|
|
854
|
+
if bucket["model_scoped"]:
|
|
855
|
+
codes.append("model_scoped")
|
|
856
|
+
if native and native != [account_key]:
|
|
857
|
+
codes.append("native_account_conflict")
|
|
858
|
+
if asserted and asserted != [account_key]:
|
|
859
|
+
codes.append("assertion_conflict")
|
|
860
|
+
if spend_conflicts:
|
|
861
|
+
codes.append("spend_account_conflict")
|
|
862
|
+
|
|
863
|
+
if codes:
|
|
864
|
+
disposition = "refused"
|
|
865
|
+
elif native == [account_key] or asserted == [account_key]:
|
|
866
|
+
# Already true, by native evidence or by an assertion already on
|
|
867
|
+
# record. Idempotency falls out of this branch rather than needing a
|
|
868
|
+
# dedup check: a second identical apply plans every group here.
|
|
869
|
+
disposition = "noop"
|
|
870
|
+
else:
|
|
871
|
+
disposition = "eligible"
|
|
872
|
+
|
|
873
|
+
planned.append(_AttributeGroup(
|
|
874
|
+
group_key=key, source_root_key=key[1], logical_limit_key=key[2],
|
|
875
|
+
observed_slot=key[3], window_minutes=window_minutes,
|
|
876
|
+
canonical_resets_at=anchor.isoformat(),
|
|
877
|
+
raw_resets=tuple(sorted(bucket["witnesses"])),
|
|
878
|
+
disposition=disposition, native_accounts=tuple(native),
|
|
879
|
+
assertion_accounts=tuple(asserted),
|
|
880
|
+
observation_count=len(members),
|
|
881
|
+
spend_candidate_count=spend_candidates,
|
|
882
|
+
refusal_codes=tuple(
|
|
883
|
+
code for code in _REFUSAL_ORDER if code in codes),
|
|
884
|
+
op_ids=(),
|
|
885
|
+
))
|
|
886
|
+
return planned
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _attribute_plan_retractions(conn, *, account_key, since, until):
|
|
890
|
+
"""Plan the RETRACT mode, over the durable assertion records (spec §4.1).
|
|
891
|
+
|
|
892
|
+
Retraction cannot use the attribute selector. An assertion is allowed to be
|
|
893
|
+
DORMANT — its witnesses match no current group — and the operator is told to
|
|
894
|
+
clear a dormant or split assertion by retracting it. A dormant assertion has
|
|
895
|
+
no current observation for an observation-range selector to find, so an
|
|
896
|
+
observation-based `--retract` could never reach the one case it most needs
|
|
897
|
+
to reach.
|
|
898
|
+
"""
|
|
899
|
+
import datetime as dt
|
|
900
|
+
|
|
901
|
+
import _cctally_cache as _cache_mod
|
|
902
|
+
import _cctally_quota as _quota
|
|
903
|
+
|
|
904
|
+
records = _cache_mod.load_active_window_attributions(conn)
|
|
905
|
+
resolutions, _ownership = _quota.resolve_codex_window_attributions(conn)
|
|
906
|
+
outcome = {str(r.op_id): str(r.outcome) for r in resolutions}
|
|
907
|
+
|
|
908
|
+
planned = []
|
|
909
|
+
for record in records:
|
|
910
|
+
if str(record["account_key"]) != account_key:
|
|
911
|
+
continue
|
|
912
|
+
anchor = _attribute_parse_stored_instant(
|
|
913
|
+
record.get("canonical_resets_at_utc"))
|
|
914
|
+
# The selector is the record's stored assertion-time WINDOW — the
|
|
915
|
+
# nominal `[anchor - window, anchor)` interval — matched by OVERLAP, not
|
|
916
|
+
# its reset instant. Two reasons, and the second is the one that would
|
|
917
|
+
# bite an operator. Attribute mode selects OBSERVATIONS, which sit up to
|
|
918
|
+
# a week before the reset they witness, so an instant match would make
|
|
919
|
+
# the very range that recorded an assertion fail to retract it. And a
|
|
920
|
+
# retraction is a corrective action the operator previews before
|
|
921
|
+
# applying, so reaching one record too many is cheap where reaching one
|
|
922
|
+
# too few is the failure §4.1 exists to prevent.
|
|
923
|
+
#
|
|
924
|
+
# The assertion timestamp is the tiebreak for a record whose window is
|
|
925
|
+
# no longer resolvable at all, which is exactly the record a retraction
|
|
926
|
+
# most needs to be able to reach.
|
|
927
|
+
if anchor is not None:
|
|
928
|
+
start = anchor - dt.timedelta(minutes=int(record["window_minutes"]))
|
|
929
|
+
if not (start < until and since < anchor):
|
|
930
|
+
continue
|
|
931
|
+
else:
|
|
932
|
+
asserted = _attribute_parse_stored_instant(
|
|
933
|
+
record.get("asserted_at_utc"))
|
|
934
|
+
if asserted is None or not (since <= asserted < until):
|
|
935
|
+
continue
|
|
936
|
+
op_id = str(record["op_id"])
|
|
937
|
+
planned.append(_AttributeGroup(
|
|
938
|
+
group_key=("codex", str(record["source_root_key"]),
|
|
939
|
+
str(record["logical_limit_key"]),
|
|
940
|
+
str(record["observed_slot"]),
|
|
941
|
+
int(record["window_minutes"]),
|
|
942
|
+
record.get("canonical_resets_at_utc")),
|
|
943
|
+
source_root_key=str(record["source_root_key"]),
|
|
944
|
+
logical_limit_key=str(record["logical_limit_key"]),
|
|
945
|
+
observed_slot=str(record["observed_slot"]),
|
|
946
|
+
window_minutes=int(record["window_minutes"]),
|
|
947
|
+
canonical_resets_at=record.get("canonical_resets_at_utc"),
|
|
948
|
+
raw_resets=tuple(str(v) for v in record["raw_resets_at_utc"]),
|
|
949
|
+
disposition=outcome.get(op_id, "dormant"),
|
|
950
|
+
native_accounts=(), assertion_accounts=(account_key,),
|
|
951
|
+
observation_count=0, spend_candidate_count=0,
|
|
952
|
+
refusal_codes=(), op_ids=(op_id,),
|
|
953
|
+
))
|
|
954
|
+
planned.sort(key=lambda group: (str(group.canonical_resets_at or ""),
|
|
955
|
+
group.op_ids[0]))
|
|
956
|
+
return planned
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _attribute_records(planned, *, account_key, mode, at):
|
|
960
|
+
"""The journal ops one apply appends — ONE PER GROUP, never a whole range.
|
|
961
|
+
|
|
962
|
+
The journal enforces a 65,536-byte line limit, and per-group lines also make
|
|
963
|
+
a partial append recoverable as a complete prefix.
|
|
964
|
+
"""
|
|
965
|
+
import _lib_journal as _lj
|
|
966
|
+
|
|
967
|
+
records = []
|
|
968
|
+
for group in planned:
|
|
969
|
+
# `codex_window_attributions.canonical_resets_at_utc` is nullable per
|
|
970
|
+
# its DDL, and the journal is append-only, so `str(None)` would write
|
|
971
|
+
# the literal "None" into a segment nothing can ever rewrite. The field
|
|
972
|
+
# is audit-only and never matched on, so the record's first raw witness
|
|
973
|
+
# is the honest stand-in; when there is not even one, the builder's
|
|
974
|
+
# non-empty guard rejects the record rather than accepting a placeholder.
|
|
975
|
+
canonical = group.canonical_resets_at
|
|
976
|
+
if not isinstance(canonical, str) or not canonical.strip():
|
|
977
|
+
canonical = next(iter(group.raw_resets or ()), None)
|
|
978
|
+
common = dict(
|
|
979
|
+
account_key=account_key,
|
|
980
|
+
source_root_key=group.source_root_key,
|
|
981
|
+
logical_limit_key=group.logical_limit_key,
|
|
982
|
+
observed_slot=group.observed_slot,
|
|
983
|
+
window_minutes=int(group.window_minutes),
|
|
984
|
+
raw_resets_at_utc=list(group.raw_resets),
|
|
985
|
+
canonical_resets_at_utc=canonical,
|
|
986
|
+
)
|
|
987
|
+
if mode == "retract":
|
|
988
|
+
records.append(_lj.make_codex_window_attribution_retract(
|
|
989
|
+
at=at, retracted_assertion_ids=list(group.op_ids), **common))
|
|
990
|
+
else:
|
|
991
|
+
records.append(_lj.make_codex_window_attribution(at=at, **common))
|
|
992
|
+
return records
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _attribute_plan(conn, *, account_key, mode, since, until):
|
|
996
|
+
if mode == "retract":
|
|
997
|
+
return _attribute_plan_retractions(
|
|
998
|
+
conn, account_key=account_key, since=since, until=until)
|
|
999
|
+
return _attribute_plan_groups(
|
|
1000
|
+
conn, account_key=account_key, since=since, until=until)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _attribute_status(planned, *, mode, applied: bool) -> str:
|
|
1004
|
+
if not planned:
|
|
1005
|
+
return "empty"
|
|
1006
|
+
if any(group.blocks for group in planned):
|
|
1007
|
+
return "refused"
|
|
1008
|
+
if applied:
|
|
1009
|
+
return "applied"
|
|
1010
|
+
if mode != "retract" and not any(
|
|
1011
|
+
group.disposition == "eligible" for group in planned):
|
|
1012
|
+
# Nothing left to record. That covers both a second identical apply
|
|
1013
|
+
# (every group already asserted) and a range that reached only
|
|
1014
|
+
# out-of-scope windows — neither is a refusal, and reporting the second
|
|
1015
|
+
# as one would tell an operator to fix something no operator can fix.
|
|
1016
|
+
# The summary tells the two apart.
|
|
1017
|
+
return "noop"
|
|
1018
|
+
return "preview"
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _attribute_payload(*, status, mode, account_key, label, since, until,
|
|
1022
|
+
planned, actions, errors, until_specified=True) -> dict:
|
|
1023
|
+
selected = len(planned)
|
|
1024
|
+
eligible = sum(1 for g in planned if g.disposition == "eligible")
|
|
1025
|
+
noop = sum(1 for g in planned if g.disposition == "noop")
|
|
1026
|
+
refused = sum(1 for g in planned if g.disposition == "refused")
|
|
1027
|
+
blocking = sum(1 for g in planned if g.blocks)
|
|
1028
|
+
if mode == "retract":
|
|
1029
|
+
# Every matched record is retractable; the disposition column carries
|
|
1030
|
+
# the record's CURRENT resolution state instead (resolved / dormant /
|
|
1031
|
+
# split / suppressed_*), which is what §4.1 asks the preview to show.
|
|
1032
|
+
eligible, noop = selected, 0
|
|
1033
|
+
return {
|
|
1034
|
+
"status": status,
|
|
1035
|
+
"mode": mode,
|
|
1036
|
+
"source": "codex",
|
|
1037
|
+
"account": ({"accountKey": account_key, "accountLabel": label}
|
|
1038
|
+
if account_key else None),
|
|
1039
|
+
# `until` is the RESOLVED exclusive end, never null on a run that got
|
|
1040
|
+
# far enough to resolve one, so a consumer can reproduce the selection
|
|
1041
|
+
# the command actually made. `untilSpecified` is what preserves the
|
|
1042
|
+
# other fact — whether the operator named that instant or the command
|
|
1043
|
+
# defaulted it to the run's own "now".
|
|
1044
|
+
"selector": {"since": since, "until": until,
|
|
1045
|
+
"untilSpecified": bool(until_specified)},
|
|
1046
|
+
"summary": {
|
|
1047
|
+
"selectedGroups": selected, "eligibleGroups": eligible,
|
|
1048
|
+
"noOpGroups": noop, "refusedGroups": refused,
|
|
1049
|
+
"blockingRefusedGroups": blocking,
|
|
1050
|
+
},
|
|
1051
|
+
"groups": [group.to_json() for group in planned],
|
|
1052
|
+
"actions": dict(actions),
|
|
1053
|
+
"errors": list(errors),
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
_ATTRIBUTE_NO_ACTIONS = {
|
|
1058
|
+
"journalOpsAppended": 0, "quotaGroupsUpdated": 0, "spendRowsUpdated": 0,
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
#: Refused rows the human render prints before it summarizes the rest — the cap
|
|
1063
|
+
#: `five-hour-blocks` already applies to an unfiltered listing, for the same
|
|
1064
|
+
#: reason. A whole-era range selects 605 groups on the maintainer's store and
|
|
1065
|
+
#: refuses 539 of them as out of scope, and one row each buries the handful
|
|
1066
|
+
#: that actually need a decision.
|
|
1067
|
+
_ATTRIBUTE_REFUSED_ROW_CAP = 50
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _attribute_render(payload, *, requested_apply: bool = False) -> None:
|
|
1071
|
+
mode = payload["mode"]
|
|
1072
|
+
account = payload["account"] or {}
|
|
1073
|
+
selector = payload["selector"]
|
|
1074
|
+
print(
|
|
1075
|
+
f"account attribute ({mode}) — codex / "
|
|
1076
|
+
f"{account.get('accountLabel') or account.get('accountKey') or '-'}")
|
|
1077
|
+
# The resolved instant, annotated when the command supplied it, so this
|
|
1078
|
+
# render and the `--json` envelope describe the same range.
|
|
1079
|
+
until_note = "" if selector.get("untilSpecified", True) else " (now)"
|
|
1080
|
+
print(f"range: [{selector['since']}, {selector['until']}){until_note}")
|
|
1081
|
+
if not payload["groups"]:
|
|
1082
|
+
print("No window group matches the selector; nothing to do.")
|
|
1083
|
+
return
|
|
1084
|
+
headers = (["WINDOW RESET", "SLOT", "OBS", "SPEND", "STATE", "DETAIL"]
|
|
1085
|
+
if mode != "retract" else
|
|
1086
|
+
["WINDOW RESET", "SLOT", "ASSERTION", "STATE", "DETAIL"])
|
|
1087
|
+
rows = []
|
|
1088
|
+
shown_refused = hidden_refused = 0
|
|
1089
|
+
for group in payload["groups"]:
|
|
1090
|
+
if group["disposition"] == "refused":
|
|
1091
|
+
if shown_refused >= _ATTRIBUTE_REFUSED_ROW_CAP:
|
|
1092
|
+
hidden_refused += 1
|
|
1093
|
+
continue
|
|
1094
|
+
shown_refused += 1
|
|
1095
|
+
window = group["group"]
|
|
1096
|
+
detail = ", ".join(group["refusalCodes"]) or ", ".join(
|
|
1097
|
+
group["nativeAccountKeys"] or group["assertionAccountKeys"])[:16]
|
|
1098
|
+
if mode == "retract":
|
|
1099
|
+
rows.append([
|
|
1100
|
+
str(window["canonicalResetsAtUtc"] or "-"),
|
|
1101
|
+
window["observedSlot"],
|
|
1102
|
+
(group["assertionOpIds"] or ["-"])[0][:16],
|
|
1103
|
+
group["disposition"], detail or "-",
|
|
1104
|
+
])
|
|
1105
|
+
else:
|
|
1106
|
+
rows.append([
|
|
1107
|
+
str(window["canonicalResetsAtUtc"]), window["observedSlot"],
|
|
1108
|
+
str(group["observationCount"]), str(group["spendCandidateCount"]),
|
|
1109
|
+
group["disposition"], detail or "-",
|
|
1110
|
+
])
|
|
1111
|
+
print(_render_table(headers, rows))
|
|
1112
|
+
if hidden_refused:
|
|
1113
|
+
print(f"… and {hidden_refused} more refused window(s) not shown "
|
|
1114
|
+
f"(--json lists every one).")
|
|
1115
|
+
summary = payload["summary"]
|
|
1116
|
+
line = (
|
|
1117
|
+
f"{summary['selectedGroups']} selected, "
|
|
1118
|
+
f"{summary['eligibleGroups']} eligible, {summary['noOpGroups']} no-op, "
|
|
1119
|
+
f"{summary['refusedGroups']} refused")
|
|
1120
|
+
blocking = int(summary.get("blockingRefusedGroups") or 0)
|
|
1121
|
+
if blocking:
|
|
1122
|
+
# Without this an operator cannot tell a refusal that stops the run from
|
|
1123
|
+
# an out-of-scope window that was merely skipped, except by reading
|
|
1124
|
+
# every row's codes.
|
|
1125
|
+
line += f" ({blocking} blocking)"
|
|
1126
|
+
print(line)
|
|
1127
|
+
actions = payload["actions"]
|
|
1128
|
+
if payload["status"] in ("applied", "recovered"):
|
|
1129
|
+
print(
|
|
1130
|
+
f"applied: {actions['journalOpsAppended']} journal op(s), "
|
|
1131
|
+
f"{actions['quotaGroupsUpdated']} window group(s), "
|
|
1132
|
+
f"{actions['spendRowsUpdated']} spend row(s)")
|
|
1133
|
+
print(
|
|
1134
|
+
"The percentage axis converges on this run; a projection reached "
|
|
1135
|
+
"from a Codex hook tick instead defers a whole-history pass to the "
|
|
1136
|
+
"detached verifier and lands on a following tick.")
|
|
1137
|
+
elif payload["status"] == "refused":
|
|
1138
|
+
if requested_apply:
|
|
1139
|
+
print("Nothing was written: apply is all-or-nothing and a group "
|
|
1140
|
+
"refused.")
|
|
1141
|
+
else:
|
|
1142
|
+
# A preview was never going to write anything, so reporting that it
|
|
1143
|
+
# did not is no news; what the operator needs is what an apply WOULD
|
|
1144
|
+
# do with this range.
|
|
1145
|
+
print("Nothing would be written: apply is all-or-nothing and a "
|
|
1146
|
+
"group refused. Resolve or exclude it, then re-run with "
|
|
1147
|
+
"--yes.")
|
|
1148
|
+
elif payload["status"] != "empty":
|
|
1149
|
+
print("Preview only — nothing was written. Re-run with --yes to apply.")
|
|
1150
|
+
for error in payload["errors"]:
|
|
1151
|
+
eprint(f"account attribute: {error['message']}")
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _attribute_recovered(previous, current, account_key) -> bool:
|
|
1155
|
+
"""Whether the difference between two plans is a `recordedPending` recovery.
|
|
1156
|
+
|
|
1157
|
+
§8.5: if the append succeeds and a later step fails, the journal holds the
|
|
1158
|
+
truth and the derived state does not. On the rerun the tail replay lands the
|
|
1159
|
+
records, so every group this plan wanted to assert now reads as a no-op for
|
|
1160
|
+
exactly this account. That is a RECOVERY, not drift, and it must be
|
|
1161
|
+
recognised before the generic drift check refuses it.
|
|
1162
|
+
"""
|
|
1163
|
+
before = {tuple(g.group_key): g for g in previous
|
|
1164
|
+
if g.disposition == "eligible"}
|
|
1165
|
+
if not before:
|
|
1166
|
+
return False
|
|
1167
|
+
after = {tuple(g.group_key): g for g in current}
|
|
1168
|
+
for key, _group in before.items():
|
|
1169
|
+
landed = after.get(key)
|
|
1170
|
+
if landed is None or landed.disposition != "noop":
|
|
1171
|
+
return False
|
|
1172
|
+
if landed.assertion_accounts != (account_key,):
|
|
1173
|
+
return False
|
|
1174
|
+
return True
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _attribute_tail_pending(conn) -> bool:
|
|
1178
|
+
"""Whether an earlier run recorded attribution the stats side never got.
|
|
1179
|
+
|
|
1180
|
+
§8.5 promises that a rerun after a failure anywhere past the append
|
|
1181
|
+
"completes the cache and stats steps, reports `recovered`, and exits 0". The
|
|
1182
|
+
window this predicate exists for is the one where the journal append AND the
|
|
1183
|
+
cache transaction both succeeded and the stats step did not: the derived
|
|
1184
|
+
table already carries the assertions, so every group plans as a no-op, and
|
|
1185
|
+
the `--yes` short-circuit would exit 0 reporting `noop` while stats.db still
|
|
1186
|
+
lacked the attribution. Retract mode reaches the same state through `empty`.
|
|
1187
|
+
|
|
1188
|
+
The predicate is the projection certificate's attribution revision against
|
|
1189
|
+
the derived table's live one — the same comparison
|
|
1190
|
+
`load_codex_quota_projection_certificate` already fails closed on (§8.3).
|
|
1191
|
+
A store that has never asserted anything reads 0 on both sides, so an
|
|
1192
|
+
ordinarily idempotent second apply still short-circuits and still never
|
|
1193
|
+
takes the lock set, which is what AC9 pins.
|
|
1194
|
+
"""
|
|
1195
|
+
import _cctally_cache as _cache_mod
|
|
1196
|
+
import _cctally_quota as _quota
|
|
1197
|
+
|
|
1198
|
+
try:
|
|
1199
|
+
live = int(_cache_mod.codex_window_attribution_revision(conn))
|
|
1200
|
+
stamped = int(_quota._certificate_attribution_revision(
|
|
1201
|
+
_quota._codex_quota_projection_certificate_payload(conn)))
|
|
1202
|
+
except (sqlite3.DatabaseError, TypeError, ValueError):
|
|
1203
|
+
# A cache too old to carry either value has no attribution to finish.
|
|
1204
|
+
return False
|
|
1205
|
+
return stamped != live
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _attribute_apply(planned, *, account_key, mode, since, until, at,
|
|
1209
|
+
completion=False):
|
|
1210
|
+
"""Record the plan, move both axes, and re-project — spec §8.
|
|
1211
|
+
|
|
1212
|
+
Locks, in the one order that preserves the repository lock-order law:
|
|
1213
|
+
stats maintenance exclusive, cache maintenance shared, the journal ingest
|
|
1214
|
+
lock exclusive, the global cache writer flock exclusive, the Codex provider
|
|
1215
|
+
flock exclusive. `release_cache_flocks()` then drops the last two so every
|
|
1216
|
+
cache write is committed and unlocked before the stats transaction opens.
|
|
1217
|
+
|
|
1218
|
+
Returns `(status, actions, errors, planned)`.
|
|
1219
|
+
"""
|
|
1220
|
+
import _cctally_cache as _cache_mod
|
|
1221
|
+
import _cctally_journal as _jr
|
|
1222
|
+
import _cctally_quota as _quota
|
|
1223
|
+
import _cctally_rederive as _rd
|
|
1224
|
+
|
|
1225
|
+
actions = dict(_ATTRIBUTE_NO_ACTIONS)
|
|
1226
|
+
appended = False
|
|
1227
|
+
try:
|
|
1228
|
+
with _rd.codex_attribution_apply_locks() as owner:
|
|
1229
|
+
# Opened INSIDE the lock set, which is only safe because the schema
|
|
1230
|
+
# and every pending migration are already applied: `_cmd_account_
|
|
1231
|
+
# attribute` opens the cache for the preview before this runs, and
|
|
1232
|
+
# `apply_db_rederive` does the same before taking the same locks. A
|
|
1233
|
+
# future caller reaching this without that guarantee would run a
|
|
1234
|
+
# migration under five held flocks.
|
|
1235
|
+
conn = _cache_mod.open_cache_db()
|
|
1236
|
+
try:
|
|
1237
|
+
# Reconcile the derived table's tail from the journal FIRST, so
|
|
1238
|
+
# a record this command appended on a previous run and never
|
|
1239
|
+
# materialized is visible to the revalidation below.
|
|
1240
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1241
|
+
_landed, skipped = _cache_mod.rehydrate_codex_window_attributions(
|
|
1242
|
+
conn)
|
|
1243
|
+
conn.commit()
|
|
1244
|
+
if skipped:
|
|
1245
|
+
_jr._report_window_attribution_skips(skipped)
|
|
1246
|
+
|
|
1247
|
+
fresh = _attribute_plan(
|
|
1248
|
+
conn, account_key=account_key, mode=mode,
|
|
1249
|
+
since=since, until=until)
|
|
1250
|
+
recovered = (
|
|
1251
|
+
mode != "retract"
|
|
1252
|
+
and _attribute_recovered(planned, fresh, account_key))
|
|
1253
|
+
if not recovered:
|
|
1254
|
+
if ([g.fingerprint for g in fresh]
|
|
1255
|
+
!= [g.fingerprint for g in planned]):
|
|
1256
|
+
return ("conflict", actions, [{
|
|
1257
|
+
"code": "plan_drift",
|
|
1258
|
+
"message": (
|
|
1259
|
+
"the store changed before the apply lock was "
|
|
1260
|
+
"acquired; rerun `cctally account attribute` "
|
|
1261
|
+
"for a fresh preview"),
|
|
1262
|
+
}], fresh)
|
|
1263
|
+
if any(g.blocks for g in fresh):
|
|
1264
|
+
return ("refused", actions, [], fresh)
|
|
1265
|
+
|
|
1266
|
+
targets = [g for g in fresh if g.disposition == "eligible"] \
|
|
1267
|
+
if mode != "retract" else list(fresh)
|
|
1268
|
+
records = _attribute_records(
|
|
1269
|
+
targets, account_key=account_key, mode=mode, at=at)
|
|
1270
|
+
if records:
|
|
1271
|
+
_jr.append_records(
|
|
1272
|
+
records, expected_high_water=_jr.journal_high_water())
|
|
1273
|
+
appended = True
|
|
1274
|
+
actions["journalOpsAppended"] = len(records)
|
|
1275
|
+
|
|
1276
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
1277
|
+
_landed, skipped = _cache_mod.rehydrate_codex_window_attributions(
|
|
1278
|
+
conn)
|
|
1279
|
+
restored, adopted = (
|
|
1280
|
+
_cache_mod.reconcile_codex_window_attribution_spend(
|
|
1281
|
+
conn, strict=True))
|
|
1282
|
+
# Verify the applied record set against the plan we locked,
|
|
1283
|
+
# BEFORE the commit: a silent partial application must abort the
|
|
1284
|
+
# transaction rather than be reported as success (spec §8.2).
|
|
1285
|
+
active = {
|
|
1286
|
+
str(row["op_id"])
|
|
1287
|
+
for row in _cache_mod.load_active_window_attributions(conn)
|
|
1288
|
+
if str(row["account_key"]) == account_key
|
|
1289
|
+
}
|
|
1290
|
+
expected = {str(rec["id"]) for rec in records}
|
|
1291
|
+
if mode == "retract":
|
|
1292
|
+
# The TARGETED assertion ids, never the retraction op ids.
|
|
1293
|
+
# A retraction inserts no row of its own — it only stamps
|
|
1294
|
+
# `retracted_by_op_id` on the assertions it names — so
|
|
1295
|
+
# intersecting the retraction ids with the active set is
|
|
1296
|
+
# empty by construction and proves nothing, which is not
|
|
1297
|
+
# what §8.2 asks for.
|
|
1298
|
+
targeted = {
|
|
1299
|
+
op_id for group in targets
|
|
1300
|
+
for op_id in (group.op_ids or ())}
|
|
1301
|
+
if targeted & active:
|
|
1302
|
+
raise sqlite3.DatabaseError(
|
|
1303
|
+
"retraction did not tombstone every named assertion")
|
|
1304
|
+
elif not expected <= active:
|
|
1305
|
+
raise sqlite3.DatabaseError(
|
|
1306
|
+
"the attribution records did not all materialize")
|
|
1307
|
+
conn.commit()
|
|
1308
|
+
except BaseException:
|
|
1309
|
+
try:
|
|
1310
|
+
conn.rollback()
|
|
1311
|
+
except sqlite3.Error:
|
|
1312
|
+
pass
|
|
1313
|
+
raise
|
|
1314
|
+
finally:
|
|
1315
|
+
conn.close()
|
|
1316
|
+
owner.release_cache_flocks()
|
|
1317
|
+
if skipped:
|
|
1318
|
+
_jr._report_window_attribution_skips(skipped)
|
|
1319
|
+
actions["spendRowsUpdated"] = int(restored) + int(adopted)
|
|
1320
|
+
actions["quotaGroupsUpdated"] = len(targets)
|
|
1321
|
+
# Still holding stats maintenance and the ingest lock: consume the
|
|
1322
|
+
# appended prefix into stats.db without reacquiring either.
|
|
1323
|
+
_jr.run_stats_ingest(mode="authoritative", locks_held=True)
|
|
1324
|
+
except _rd.RederiveBusy as exc:
|
|
1325
|
+
return ("busy", actions, [{"code": "busy", "message": str(exc)}], planned)
|
|
1326
|
+
except Exception as exc: # noqa: BLE001 - reported, never swallowed
|
|
1327
|
+
if appended:
|
|
1328
|
+
return ("recordedPending", actions, [{
|
|
1329
|
+
"code": "recordedPending",
|
|
1330
|
+
"message": (
|
|
1331
|
+
f"the journal records were appended but a later step "
|
|
1332
|
+
f"failed ({exc}); rerun the same command to finish"),
|
|
1333
|
+
}], planned)
|
|
1334
|
+
return ("error", actions, [
|
|
1335
|
+
{"code": "apply_failed", "message": str(exc)}], planned)
|
|
1336
|
+
|
|
1337
|
+
# Outside the lock set, because this takes its own. No alert-eligible roots
|
|
1338
|
+
# are passed, which is what suppresses historical milestone dispatch while
|
|
1339
|
+
# still re-anchoring terminal state (spec §8.4).
|
|
1340
|
+
#
|
|
1341
|
+
# It opens its own stats transaction over the whole history and can fail on
|
|
1342
|
+
# its own, and by this point the journal append, the cache transaction and
|
|
1343
|
+
# the stats ingest have all succeeded. Uncaught, that failure would reach
|
|
1344
|
+
# the operator as a traceback with no envelope at all, when the state is
|
|
1345
|
+
# exactly the one `recordedPending` describes: recorded, not finished. The
|
|
1346
|
+
# rerun completes it, because the certificate's attribution revision is
|
|
1347
|
+
# still behind the derived table's (`_attribute_tail_pending`).
|
|
1348
|
+
try:
|
|
1349
|
+
_quota.reconcile_codex_quota_projection()
|
|
1350
|
+
except Exception as exc: # noqa: BLE001 - reported, never swallowed
|
|
1351
|
+
return ("recordedPending", actions, [{
|
|
1352
|
+
"code": "recordedPending",
|
|
1353
|
+
"message": (
|
|
1354
|
+
f"the attribution was recorded and applied but the quota "
|
|
1355
|
+
f"projection did not finish ({exc}); rerun the same command "
|
|
1356
|
+
f"to complete it"),
|
|
1357
|
+
}], fresh)
|
|
1358
|
+
status = ("recovered" if ((recovered or completion) and not appended)
|
|
1359
|
+
else "applied")
|
|
1360
|
+
return (status, actions, [], fresh)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _cmd_account_attribute(args: argparse.Namespace) -> int:
|
|
1364
|
+
"""`cctally account attribute` — preview by default, `--yes` applies.
|
|
1365
|
+
|
|
1366
|
+
Exit codes follow the staged-command convention of `db rederive` and
|
|
1367
|
+
`db journal-repair`: 0 for a preview, an empty selection, a no-op and a
|
|
1368
|
+
successful apply; 2 for every validation failure and every refusal; 3 for
|
|
1369
|
+
operational failure, including `recordedPending`.
|
|
1370
|
+
"""
|
|
1371
|
+
import _cctally_cache as _cache_mod
|
|
1372
|
+
|
|
1373
|
+
emit_json = bool(getattr(args, "emit_json", False))
|
|
1374
|
+
mode = "retract" if getattr(args, "retract", False) else "attribute"
|
|
1375
|
+
now = _cctally_core._command_as_of()
|
|
1376
|
+
since_text = str(getattr(args, "since", "") or "")
|
|
1377
|
+
until_text = getattr(args, "until", None)
|
|
1378
|
+
|
|
1379
|
+
try:
|
|
1380
|
+
since = _attribute_parse_instant(since_text, "--since")
|
|
1381
|
+
until = (_attribute_parse_instant(until_text, "--until")
|
|
1382
|
+
if until_text else now)
|
|
1383
|
+
if until <= since:
|
|
1384
|
+
raise _AttributeUsage(
|
|
1385
|
+
"account attribute: --until must be strictly after --since; "
|
|
1386
|
+
"the range is half-open [since, until)")
|
|
1387
|
+
conn = _cctally_core.open_db()
|
|
1388
|
+
try:
|
|
1389
|
+
account_key = _attribute_resolve_account(
|
|
1390
|
+
conn, getattr(args, "ref", None))
|
|
1391
|
+
label = display_account_label(conn, account_key)
|
|
1392
|
+
finally:
|
|
1393
|
+
conn.close()
|
|
1394
|
+
except _AttributeUsage as exc:
|
|
1395
|
+
message = str(exc)
|
|
1396
|
+
if message != "unresolvable ref":
|
|
1397
|
+
eprint(message)
|
|
1398
|
+
if emit_json:
|
|
1399
|
+
print(json.dumps(_cctally().stamp_schema_version(
|
|
1400
|
+
_attribute_payload(
|
|
1401
|
+
status="error", mode=mode, account_key=None, label=None,
|
|
1402
|
+
since=since_text,
|
|
1403
|
+
until=(str(until_text) if until_text else None),
|
|
1404
|
+
until_specified=bool(until_text),
|
|
1405
|
+
planned=[], actions=_ATTRIBUTE_NO_ACTIONS,
|
|
1406
|
+
errors=[{"code": "usage", "message": message}]))))
|
|
1407
|
+
return 2
|
|
1408
|
+
|
|
1409
|
+
since_iso = since.isoformat().replace("+00:00", "Z")
|
|
1410
|
+
# Always the RESOLVED end, so a `--json` consumer can reproduce the exact
|
|
1411
|
+
# selection; `untilSpecified` carries whether the operator named it.
|
|
1412
|
+
until_iso = until.isoformat().replace("+00:00", "Z")
|
|
1413
|
+
|
|
1414
|
+
requested_apply = bool(getattr(args, "yes", False))
|
|
1415
|
+
cache = _cache_mod.open_cache_db()
|
|
1416
|
+
try:
|
|
1417
|
+
planned = _attribute_plan(
|
|
1418
|
+
cache, account_key=account_key, mode=mode, since=since, until=until)
|
|
1419
|
+
completion = requested_apply and _attribute_tail_pending(cache)
|
|
1420
|
+
finally:
|
|
1421
|
+
cache.close()
|
|
1422
|
+
|
|
1423
|
+
actions = dict(_ATTRIBUTE_NO_ACTIONS)
|
|
1424
|
+
errors: list = []
|
|
1425
|
+
status = _attribute_status(planned, mode=mode, applied=False)
|
|
1426
|
+
# An apply with nothing to record short-circuits BEFORE the lock set. That
|
|
1427
|
+
# is what makes a second identical apply append zero journal lines and
|
|
1428
|
+
# leave the high-water unchanged (spec §8.5) rather than taking five flocks
|
|
1429
|
+
# to discover the same thing.
|
|
1430
|
+
#
|
|
1431
|
+
# `completion` is the one exception, and it is what keeps §8.5's recovery
|
|
1432
|
+
# promise honest: a run whose append and cache transaction landed and whose
|
|
1433
|
+
# stats step did not plans every group as a no-op, so the short-circuit
|
|
1434
|
+
# alone would report `noop`, exit 0, and leave the attribution missing from
|
|
1435
|
+
# stats.db forever.
|
|
1436
|
+
if requested_apply and status != "refused" and (
|
|
1437
|
+
status == "preview" or completion):
|
|
1438
|
+
at = now.isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
1439
|
+
status, actions, errors, planned = _attribute_apply(
|
|
1440
|
+
planned, account_key=account_key, mode=mode,
|
|
1441
|
+
since=since, until=until, at=at, completion=completion)
|
|
1442
|
+
|
|
1443
|
+
payload = _attribute_payload(
|
|
1444
|
+
status=status, mode=mode, account_key=account_key, label=label,
|
|
1445
|
+
since=since_iso, until=until_iso, until_specified=bool(until_text),
|
|
1446
|
+
planned=planned, actions=actions, errors=errors)
|
|
1447
|
+
if emit_json:
|
|
1448
|
+
print(json.dumps(_cctally().stamp_schema_version(payload)))
|
|
1449
|
+
else:
|
|
1450
|
+
_attribute_render(payload, requested_apply=requested_apply)
|
|
1451
|
+
if status in ("refused", "conflict"):
|
|
1452
|
+
return 2
|
|
1453
|
+
if status in ("recordedPending", "error", "busy"):
|
|
1454
|
+
return 3
|
|
1455
|
+
return 0
|