cctally 1.98.0 → 1.99.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 +23 -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 +1501 -118
- 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 +65 -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 +508 -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-Bcbm-DNP.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_cache.py
CHANGED
|
@@ -98,6 +98,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
98
98
|
from __future__ import annotations
|
|
99
99
|
|
|
100
100
|
import argparse
|
|
101
|
+
import bisect
|
|
101
102
|
import contextlib
|
|
102
103
|
import datetime as dt
|
|
103
104
|
import fcntl
|
|
@@ -113,7 +114,7 @@ import sys
|
|
|
113
114
|
import tempfile
|
|
114
115
|
import time
|
|
115
116
|
from dataclasses import asdict, dataclass, field
|
|
116
|
-
from typing import Any, Callable, Iterator, NamedTuple
|
|
117
|
+
from typing import Any, Callable, Iterable, Iterator, NamedTuple
|
|
117
118
|
|
|
118
119
|
|
|
119
120
|
def _cctally():
|
|
@@ -1079,6 +1080,261 @@ def resolve_codex_file_account(
|
|
|
1079
1080
|
account_key=row[0], incarnation=int(row[1]), from_offset=int(row[2]))
|
|
1080
1081
|
|
|
1081
1082
|
|
|
1083
|
+
# --------------------------------------------------------------------------
|
|
1084
|
+
# #500 spec §6.1/§6.2 — the derived index over operator window attributions.
|
|
1085
|
+
#
|
|
1086
|
+
# The journal holds the truth (two op kinds); `codex_window_attributions` is a
|
|
1087
|
+
# disposable index over it, carried by the same cache leg that carries the
|
|
1088
|
+
# quota observations and the attribution map. A high-water cursor in
|
|
1089
|
+
# `cache_meta` records the journal position the table was materialized through,
|
|
1090
|
+
# so the ordinary sync reconciles only the tail.
|
|
1091
|
+
# --------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
#: `cache_meta` key for the derived table's journal high-water cursor.
|
|
1094
|
+
CODEX_WINDOW_ATTRIBUTION_CURSOR_KEY = "codex_window_attribution_cursor"
|
|
1095
|
+
|
|
1096
|
+
#: `cache_meta` key for the ATTRIBUTION REVISION — a counter advanced only when
|
|
1097
|
+
#: an attribution record actually lands or is tombstoned (#500 spec §8.3).
|
|
1098
|
+
#:
|
|
1099
|
+
#: Deliberately NOT the journal replay cursor beside it, and the two must never
|
|
1100
|
+
#: be collapsed. The cursor advances on all journal traffic, so a certificate
|
|
1101
|
+
#: keyed on it would be invalidated by every unrelated observation and the
|
|
1102
|
+
#: targeted quota projection would degrade into continuous re-projection. The
|
|
1103
|
+
#: revision that matters is the last attribution-CHANGING one.
|
|
1104
|
+
CODEX_WINDOW_ATTRIBUTION_REVISION_KEY = "codex_window_attribution_revision"
|
|
1105
|
+
|
|
1106
|
+
#: `cache_meta` key for the #416 attribution MAP's journal high-water cursor
|
|
1107
|
+
#: (`codex_file_accounts`). Named here beside its sibling rather than only as a
|
|
1108
|
+
#: local inside `sync_codex_cache`, because the no-journal authoritative branch
|
|
1109
|
+
#: of `rehydrate_codex_journal_families` must drop it in the same breath as the
|
|
1110
|
+
#: rows it describes (review round 2, finding R2-4) and cannot spell a literal
|
|
1111
|
+
#: that lives in another module's function body.
|
|
1112
|
+
CODEX_FILE_ACCOUNT_CURSOR_KEY = "codex_attribution_rehydrated_hw"
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def load_codex_window_attribution_cursor(
|
|
1116
|
+
conn: sqlite3.Connection,
|
|
1117
|
+
) -> "tuple[str, int] | None":
|
|
1118
|
+
"""The journal position `codex_window_attributions` was replayed through.
|
|
1119
|
+
|
|
1120
|
+
`None` means "never replayed", which is also what a malformed or
|
|
1121
|
+
unreadable value means: a cursor nobody can parse must not be trusted to
|
|
1122
|
+
skip journal bytes, and a from-zero replay is always sound because every
|
|
1123
|
+
apply is idempotent on its natural key.
|
|
1124
|
+
"""
|
|
1125
|
+
try:
|
|
1126
|
+
row = conn.execute(
|
|
1127
|
+
"SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
|
|
1128
|
+
(CODEX_WINDOW_ATTRIBUTION_CURSOR_KEY,),
|
|
1129
|
+
).fetchone()
|
|
1130
|
+
except sqlite3.DatabaseError:
|
|
1131
|
+
return None
|
|
1132
|
+
if not row or not row[0]:
|
|
1133
|
+
return None
|
|
1134
|
+
try:
|
|
1135
|
+
stored = json.loads(row[0])
|
|
1136
|
+
segment, offset = stored["high_water"]
|
|
1137
|
+
except (ValueError, TypeError, KeyError):
|
|
1138
|
+
return None
|
|
1139
|
+
if not isinstance(segment, str) or not segment:
|
|
1140
|
+
return None
|
|
1141
|
+
try:
|
|
1142
|
+
return (segment, int(offset))
|
|
1143
|
+
except (TypeError, ValueError):
|
|
1144
|
+
return None
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def store_codex_window_attribution_cursor(
|
|
1148
|
+
conn: sqlite3.Connection, high_water, *, at_utc: "str | None" = None,
|
|
1149
|
+
) -> None:
|
|
1150
|
+
"""Record the journal position the table is now materialized through.
|
|
1151
|
+
|
|
1152
|
+
Runs inside the caller's transaction, which is the one that applied the
|
|
1153
|
+
records it describes — the cursor and the rows must commit or roll back
|
|
1154
|
+
together, or a crash between them would skip a durable assertion forever.
|
|
1155
|
+
"""
|
|
1156
|
+
_set_cache_meta(
|
|
1157
|
+
conn,
|
|
1158
|
+
CODEX_WINDOW_ATTRIBUTION_CURSOR_KEY,
|
|
1159
|
+
json.dumps(
|
|
1160
|
+
{
|
|
1161
|
+
"high_water": [str(high_water[0]), int(high_water[1])],
|
|
1162
|
+
"at": at_utc or dt.datetime.now(
|
|
1163
|
+
dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1164
|
+
},
|
|
1165
|
+
separators=(",", ":"), sort_keys=True,
|
|
1166
|
+
),
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def codex_window_attribution_revision(conn: sqlite3.Connection) -> int:
|
|
1171
|
+
"""The current attribution revision; `0` when nothing has ever landed.
|
|
1172
|
+
|
|
1173
|
+
An unreadable or malformed value reads as `0`, which is the FAIL-SAFE
|
|
1174
|
+
direction here: a stored certificate carrying any other number then
|
|
1175
|
+
disagrees, so the projection is treated as uncertified and re-derived,
|
|
1176
|
+
rather than a stale projection being trusted.
|
|
1177
|
+
"""
|
|
1178
|
+
try:
|
|
1179
|
+
row = conn.execute(
|
|
1180
|
+
"SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
|
|
1181
|
+
(CODEX_WINDOW_ATTRIBUTION_REVISION_KEY,),
|
|
1182
|
+
).fetchone()
|
|
1183
|
+
except sqlite3.DatabaseError:
|
|
1184
|
+
return 0
|
|
1185
|
+
if not row or row[0] in (None, ""):
|
|
1186
|
+
return 0
|
|
1187
|
+
try:
|
|
1188
|
+
return int(row[0])
|
|
1189
|
+
except (TypeError, ValueError):
|
|
1190
|
+
return 0
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def bump_codex_window_attribution_revision(conn: sqlite3.Connection) -> int:
|
|
1194
|
+
"""Advance the revision inside the caller's OPEN transaction.
|
|
1195
|
+
|
|
1196
|
+
Runs in the same transaction as the rows it describes, for the reason the
|
|
1197
|
+
cursor beside it does: a revision that committed without its records would
|
|
1198
|
+
certify a projection computed against attributions nobody applied.
|
|
1199
|
+
"""
|
|
1200
|
+
nxt = codex_window_attribution_revision(conn) + 1
|
|
1201
|
+
_set_cache_meta(conn, CODEX_WINDOW_ATTRIBUTION_REVISION_KEY, str(nxt))
|
|
1202
|
+
return nxt
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def load_active_window_attributions(
|
|
1206
|
+
conn: sqlite3.Connection, *, source_root_keys=None,
|
|
1207
|
+
retracted_only: bool = False,
|
|
1208
|
+
) -> "tuple[dict, ...]":
|
|
1209
|
+
"""Every NON-RETRACTED assertion, oldest assertion first.
|
|
1210
|
+
|
|
1211
|
+
``retracted_only=True`` inverts exactly that one predicate and returns the
|
|
1212
|
+
tombstoned records instead (#500 §7.1). A retraction removes an assertion's
|
|
1213
|
+
EFFECT, not the record of it, and the spend axis needs the record: a stored
|
|
1214
|
+
stamp has no un-stamp path, so the reconciliation has to know which group's
|
|
1215
|
+
rows an assertion used to own before it can restore them. Every other rule
|
|
1216
|
+
below — weekly only, never the sentinel, undecodable witnesses skipped —
|
|
1217
|
+
applies unchanged, because a tombstoned record the overlay would have
|
|
1218
|
+
refused names no range worth visiting either.
|
|
1219
|
+
|
|
1220
|
+
`raw_resets_at_utc` comes back as a decoded `tuple[str, ...]`, not the
|
|
1221
|
+
stored JSON text: the group binding is an INTERSECTION against those
|
|
1222
|
+
witnesses and it happens in Python, because cardinality is dozens of rows
|
|
1223
|
+
and the tolerance-connected component cannot be expressed in SQL.
|
|
1224
|
+
|
|
1225
|
+
`source_root_keys` bounds the read to the roots a caller is about to
|
|
1226
|
+
resolve against. It is a bound on the ASSERTIONS loaded, never on the group
|
|
1227
|
+
evidence they are resolved against — spec §6.4.1 requires resolution to run
|
|
1228
|
+
against complete group evidence, and that is the caller's obligation.
|
|
1229
|
+
|
|
1230
|
+
A row whose stored witness JSON cannot be decoded is SKIPPED rather than
|
|
1231
|
+
raising: it can bind to no group, so it is dormant, which is exactly the
|
|
1232
|
+
honest outcome for an assertion nobody can match.
|
|
1233
|
+
|
|
1234
|
+
Two predicates repeat rules the BUILDERS already enforce, and that
|
|
1235
|
+
duplication is the point (review finding F8). The builders bind the WRITE
|
|
1236
|
+
path; this read is what the overlay trusts, and the replay path can land a
|
|
1237
|
+
row the builders never minted — a hand-edited journal line, or a record
|
|
1238
|
+
written before a rule existed. Only account-level weekly quota is
|
|
1239
|
+
attributable, and "the operator asserted nobody" is not a fact, so neither
|
|
1240
|
+
shape may reach the overlay. The existing index
|
|
1241
|
+
`idx_codex_window_attributions_root(source_root_key, window_minutes)`
|
|
1242
|
+
already anticipates the first. Task 2's overlay re-checks the model-scoped
|
|
1243
|
+
axis independently; this is defense in depth, not the only check.
|
|
1244
|
+
"""
|
|
1245
|
+
sql = (
|
|
1246
|
+
"SELECT op_id, account_key, source_root_key, logical_limit_key, "
|
|
1247
|
+
" observed_slot, window_minutes, raw_resets_at_utc, "
|
|
1248
|
+
" canonical_resets_at_utc, asserted_at_utc "
|
|
1249
|
+
"FROM codex_window_attributions "
|
|
1250
|
+
"WHERE retracted_by_op_id IS "
|
|
1251
|
+
+ ("NOT NULL " if retracted_only else "NULL ")
|
|
1252
|
+
+ " AND window_minutes = ? "
|
|
1253
|
+
" AND account_key <> ?"
|
|
1254
|
+
)
|
|
1255
|
+
params: list = [
|
|
1256
|
+
_lib_codex_account_adoption.ACCOUNT_WEEKLY_WINDOW_MINUTES,
|
|
1257
|
+
_lib_codex_account_adoption.UNATTRIBUTED_SENTINEL,
|
|
1258
|
+
]
|
|
1259
|
+
if source_root_keys is not None:
|
|
1260
|
+
keys = sorted({str(k) for k in source_root_keys})
|
|
1261
|
+
if not keys:
|
|
1262
|
+
return ()
|
|
1263
|
+
sql += " AND source_root_key IN (%s)" % ",".join("?" * len(keys))
|
|
1264
|
+
params.extend(keys)
|
|
1265
|
+
sql += " ORDER BY asserted_at_utc ASC, op_id ASC"
|
|
1266
|
+
rows = []
|
|
1267
|
+
for row in conn.execute(sql, params):
|
|
1268
|
+
try:
|
|
1269
|
+
witnesses = json.loads(row[6])
|
|
1270
|
+
except (ValueError, TypeError):
|
|
1271
|
+
continue
|
|
1272
|
+
if not isinstance(witnesses, list) or not witnesses:
|
|
1273
|
+
continue
|
|
1274
|
+
rows.append({
|
|
1275
|
+
"op_id": row[0],
|
|
1276
|
+
"account_key": row[1],
|
|
1277
|
+
"source_root_key": row[2],
|
|
1278
|
+
"logical_limit_key": row[3],
|
|
1279
|
+
"observed_slot": row[4],
|
|
1280
|
+
"window_minutes": int(row[5]),
|
|
1281
|
+
"raw_resets_at_utc": tuple(str(w) for w in witnesses),
|
|
1282
|
+
"canonical_resets_at_utc": row[7],
|
|
1283
|
+
"asserted_at_utc": row[8],
|
|
1284
|
+
})
|
|
1285
|
+
return tuple(rows)
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def rehydrate_codex_window_attributions(
|
|
1289
|
+
conn: sqlite3.Connection, *, authoritative: bool = False,
|
|
1290
|
+
) -> "tuple[int, int]":
|
|
1291
|
+
"""Replay journaled window-attribution ops into an OPEN cache.db
|
|
1292
|
+
connection and advance the cursor; return
|
|
1293
|
+
`(assertion_rows_landed, structurally_invalid_records_skipped)`
|
|
1294
|
+
(spec §6.3).
|
|
1295
|
+
|
|
1296
|
+
The skip count is RETURNED rather than reported here, the rule
|
|
1297
|
+
`_report_file_account_conflicts` states and #500 review finding F2 requires:
|
|
1298
|
+
this runs inside the caller's transaction, and that caller rolls back on
|
|
1299
|
+
failure, so a line printed from in here would describe a skip on a cycle
|
|
1300
|
+
that is about to be retried. Every caller must pass it to
|
|
1301
|
+
`_cctally_journal._report_window_attribution_skips` after its commit.
|
|
1302
|
+
|
|
1303
|
+
The caller owns the flocks, the transaction and the commit — this only runs
|
|
1304
|
+
the idempotent statements, so it can sit inside `sync_codex_cache`'s already
|
|
1305
|
+
locked phases without inverting the lock order. Since review finding F4 the
|
|
1306
|
+
traversal itself belongs to `rehydrate_codex_journal_families`, so a sync
|
|
1307
|
+
that rehydrates BOTH journal-derived Codex families reads the journal once.
|
|
1308
|
+
|
|
1309
|
+
`authoritative=True` is the clear-then-replay form used under
|
|
1310
|
+
`cache-sync --rebuild`, mirroring `rehydrate_codex_file_accounts`: it makes
|
|
1311
|
+
the replay a convergence operator rather than an inserter, so a row that has
|
|
1312
|
+
drifted away from the journal — including a `retracted_by_op_id` stamp that
|
|
1313
|
+
should no longer stand — is corrected instead of preserved by the
|
|
1314
|
+
`DO NOTHING` conflict clause. It is lossless because the journal is
|
|
1315
|
+
append-only and is the only source this table ever had.
|
|
1316
|
+
|
|
1317
|
+
Otherwise it is a DELTA replay from the stored cursor. A cursor is never a
|
|
1318
|
+
one-shot "already rehydrated" marker: the command that appends these ops can
|
|
1319
|
+
die between its append and its cache commit, and only a cursor makes the
|
|
1320
|
+
retry replay the record it never materialized.
|
|
1321
|
+
"""
|
|
1322
|
+
import _cctally_journal as _jr
|
|
1323
|
+
|
|
1324
|
+
result = _jr.rehydrate_codex_journal_families(
|
|
1325
|
+
conn,
|
|
1326
|
+
authoritative=authoritative,
|
|
1327
|
+
window_attribution_since=(
|
|
1328
|
+
None if authoritative
|
|
1329
|
+
else load_codex_window_attribution_cursor(conn)),
|
|
1330
|
+
want_file_accounts=False,
|
|
1331
|
+
want_window_attributions=True,
|
|
1332
|
+
caller="rehydrate_codex_window_attributions(authoritative=True)",
|
|
1333
|
+
)
|
|
1334
|
+
return (result.window_attributions_applied,
|
|
1335
|
+
result.window_attributions_skipped)
|
|
1336
|
+
|
|
1337
|
+
|
|
1082
1338
|
# --------------------------------------------------------------------------
|
|
1083
1339
|
# #416 spec §4.1/§4.2 — the canonical reset anchor, resolved at INGEST.
|
|
1084
1340
|
#
|
|
@@ -1788,6 +2044,43 @@ def _delete_codex_file_derived_rows(
|
|
|
1788
2044
|
_invalidate_codex_journal_coverage_certificate(conn)
|
|
1789
2045
|
|
|
1790
2046
|
|
|
2047
|
+
def _prune_codex_accounting_change_log(
|
|
2048
|
+
conn: sqlite3.Connection,
|
|
2049
|
+
*,
|
|
2050
|
+
retain_sequences: int = 50_000,
|
|
2051
|
+
) -> int:
|
|
2052
|
+
"""Bound #582's ledger while preserving cold-fallback gap detection.
|
|
2053
|
+
|
|
2054
|
+
The in-process dashboard cursor is intentionally not durable. Retaining a
|
|
2055
|
+
generous sequence tail serves ordinary ticks; a process older than that
|
|
2056
|
+
tail observes a non-contiguous first sequence and safely cold-loads.
|
|
2057
|
+
"""
|
|
2058
|
+
if retain_sequences < 1:
|
|
2059
|
+
raise ValueError("retain_sequences must be positive")
|
|
2060
|
+
try:
|
|
2061
|
+
row = conn.execute(
|
|
2062
|
+
"SELECT value FROM cache_meta "
|
|
2063
|
+
"WHERE key='codex_accounting_mutation_seq'"
|
|
2064
|
+
).fetchone()
|
|
2065
|
+
current = 0 if row is None else int(row[0])
|
|
2066
|
+
cutoff = current - retain_sequences
|
|
2067
|
+
if cutoff <= 0:
|
|
2068
|
+
return 0
|
|
2069
|
+
if conn.execute(
|
|
2070
|
+
"SELECT 1 FROM codex_accounting_change_log "
|
|
2071
|
+
"WHERE mutation_seq <= ? LIMIT 1",
|
|
2072
|
+
(cutoff,),
|
|
2073
|
+
).fetchone() is None:
|
|
2074
|
+
return 0
|
|
2075
|
+
cursor = conn.execute(
|
|
2076
|
+
"DELETE FROM codex_accounting_change_log WHERE mutation_seq <= ?",
|
|
2077
|
+
(cutoff,),
|
|
2078
|
+
)
|
|
2079
|
+
return max(0, int(cursor.rowcount))
|
|
2080
|
+
except (sqlite3.Error, TypeError, ValueError):
|
|
2081
|
+
return 0
|
|
2082
|
+
|
|
2083
|
+
|
|
1791
2084
|
def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
|
|
1792
2085
|
"""Clear every re-derivable Codex row family and report whether state changed.
|
|
1793
2086
|
|
|
@@ -1804,6 +2097,14 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
|
|
|
1804
2097
|
account switch. Wiping it is the defect. It is re-derivable only from the
|
|
1805
2098
|
journal, and ``sync_codex_cache`` rehydrates it from there immediately after
|
|
1806
2099
|
this call.
|
|
2100
|
+
|
|
2101
|
+
``codex_window_attributions`` (#500 spec §6.3) is protected for the same
|
|
2102
|
+
reason and must not be added either. It holds the operator's durable
|
|
2103
|
+
assertions about which account owns a recorded quota window; no rollout byte
|
|
2104
|
+
carries that fact, so a clear that did not replay would erase it outright.
|
|
2105
|
+
``sync_codex_cache`` rehydrates it from the journal — AUTHORITATIVELY under
|
|
2106
|
+
``--rebuild`` — immediately after this call, in the same position the
|
|
2107
|
+
attribution map's rehydration occupies and BEFORE the rollout walk.
|
|
1807
2108
|
"""
|
|
1808
2109
|
state_changed = any(
|
|
1809
2110
|
conn.execute(query).fetchone() is not None
|
|
@@ -1825,7 +2126,39 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
|
|
|
1825
2126
|
f"WHERE key='{_lib_cache_coverage.PROGRESS_KEY}' LIMIT 1",
|
|
1826
2127
|
)
|
|
1827
2128
|
)
|
|
1828
|
-
|
|
2129
|
+
# #582: a whole-provider clear is one cache invalidation, not N path
|
|
2130
|
+
# tombstones. Suppress the row trigger while deleting, then publish one
|
|
2131
|
+
# durable full marker under the same transaction. Older schemas have no
|
|
2132
|
+
# ledger and safely ignore the extra cache_meta writes.
|
|
2133
|
+
_has_accounting_ledger = conn.execute(
|
|
2134
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
2135
|
+
"AND name='codex_accounting_change_log'"
|
|
2136
|
+
).fetchone() is not None
|
|
2137
|
+
if _has_accounting_ledger:
|
|
2138
|
+
conn.execute(
|
|
2139
|
+
"INSERT OR REPLACE INTO cache_meta(key, value) VALUES "
|
|
2140
|
+
"('codex_accounting_bulk_clear', '1')"
|
|
2141
|
+
)
|
|
2142
|
+
try:
|
|
2143
|
+
conn.execute("DELETE FROM codex_session_entries")
|
|
2144
|
+
finally:
|
|
2145
|
+
if _has_accounting_ledger:
|
|
2146
|
+
conn.execute(
|
|
2147
|
+
"DELETE FROM cache_meta WHERE key='codex_accounting_bulk_clear'"
|
|
2148
|
+
)
|
|
2149
|
+
if _has_accounting_ledger and state_changed:
|
|
2150
|
+
conn.execute(
|
|
2151
|
+
"INSERT INTO cache_meta(key, value) VALUES "
|
|
2152
|
+
"('codex_accounting_mutation_seq', '1') "
|
|
2153
|
+
"ON CONFLICT(key) DO UPDATE "
|
|
2154
|
+
"SET value=CAST(value AS INTEGER) + 1"
|
|
2155
|
+
)
|
|
2156
|
+
conn.execute(
|
|
2157
|
+
"INSERT INTO codex_accounting_change_log "
|
|
2158
|
+
"(mutation_seq, change_kind) "
|
|
2159
|
+
"SELECT CAST(value AS INTEGER), 'full' FROM cache_meta "
|
|
2160
|
+
"WHERE key='codex_accounting_mutation_seq'"
|
|
2161
|
+
)
|
|
1829
2162
|
conn.execute("DELETE FROM quota_window_snapshots WHERE source = 'codex'")
|
|
1830
2163
|
conn.execute("DELETE FROM codex_conversation_threads")
|
|
1831
2164
|
conn.execute("DELETE FROM codex_conversation_events")
|
|
@@ -1857,6 +2190,21 @@ COVERAGE_CACHE_FAMILIES: "tuple[str, ...]" = (
|
|
|
1857
2190
|
"quota_window_snapshots",
|
|
1858
2191
|
"codex_file_accounts",
|
|
1859
2192
|
"codex_file_incarnations",
|
|
2193
|
+
# #500 spec §6.2. The certificate's promise is "every journal record in this
|
|
2194
|
+
# prefix is materialized", and a `codex_window_attribution` op materializes
|
|
2195
|
+
# HERE. Leaving the family out would let a certificate certify a prefix
|
|
2196
|
+
# containing an assertion nobody applied — and a rebuild would then trust it
|
|
2197
|
+
# and skip the replay, publishing a projection with `incomplete = 0` that
|
|
2198
|
+
# silently omits the operator's attribution.
|
|
2199
|
+
#
|
|
2200
|
+
# Membership does NOT route a typed replay failure into
|
|
2201
|
+
# `stats_quota_projection_state.incomplete` (review finding F7): this tuple
|
|
2202
|
+
# has no RUNTIME consumer at all — every reader of it is a test. That
|
|
2203
|
+
# routing is the `except CodexWindowAttributionReplayFailed` handler in
|
|
2204
|
+
# `_cctally_journal._run_bounded_recovery`. What membership really does is
|
|
2205
|
+
# force every writer of this table into `COVERAGE_WRITER_ACTIONS` below,
|
|
2206
|
+
# which the static inventory guard enforces.
|
|
2207
|
+
"codex_window_attributions",
|
|
1860
2208
|
)
|
|
1861
2209
|
|
|
1862
2210
|
#: Every path that mutates or materializes those families, mapped to the ONE
|
|
@@ -1928,31 +2276,40 @@ COVERAGE_WRITER_ACTIONS: "dict[str, str]" = {
|
|
|
1928
2276
|
# transaction owner above them, not here.
|
|
1929
2277
|
"_cctally_cache.record_codex_file_account": "preserve",
|
|
1930
2278
|
"_cctally_cache.set_codex_file_incarnation": "preserve",
|
|
1931
|
-
# The
|
|
1932
|
-
# natural key, no deletes, and the certificate decision belongs to the
|
|
2279
|
+
# The three journal-to-cache appliers. Same reasoning: INSERT OR IGNORE on
|
|
2280
|
+
# the natural key, no deletes, and the certificate decision belongs to the
|
|
1933
2281
|
# composite that owns their transaction.
|
|
1934
2282
|
"_cctally_journal._apply_quota_records": "preserve",
|
|
1935
2283
|
"_cctally_journal._apply_file_account_records": "preserve",
|
|
2284
|
+
"_cctally_journal._apply_window_attribution_records": "preserve",
|
|
2285
|
+
# The ONE fused journal-to-cache rehydration for both journal-derived Codex
|
|
2286
|
+
# families (#500 review finding F4). `rehydrate_codex_file_accounts` and
|
|
2287
|
+
# `_cctally_cache.rehydrate_codex_window_attributions` are now thin wrappers
|
|
2288
|
+
# over it and hold no DML of their own, which is why this key replaced both
|
|
2289
|
+
# of theirs — the scanner found the writes here, and the inventory names
|
|
2290
|
+
# where the writes ARE.
|
|
2291
|
+
#
|
|
1936
2292
|
# Two branches, and only the additive one leaves the covered families
|
|
1937
|
-
# untouched. `authoritative=False` replays journaled decisions
|
|
1938
|
-
#
|
|
1939
|
-
#
|
|
2293
|
+
# untouched. `authoritative=False` replays journaled decisions and
|
|
2294
|
+
# assertions with idempotent upserts and writes no quota row at all, so the
|
|
2295
|
+
# coverage statement is unaffected.
|
|
1940
2296
|
#
|
|
1941
|
-
# `authoritative=True` runs `DELETE FROM codex_file_accounts
|
|
1942
|
-
#
|
|
1943
|
-
#
|
|
1944
|
-
#
|
|
2297
|
+
# `authoritative=True` runs `DELETE FROM codex_file_accounts` and
|
|
2298
|
+
# `DELETE FROM codex_window_attributions`, both COVERED families.
|
|
2299
|
+
# `preserve` holds there for a reason that lives in the caller rather than
|
|
2300
|
+
# in this function, so it is written down here instead of left as an
|
|
2301
|
+
# ordering nobody stated: `sync_codex_cache` passes
|
|
1945
2302
|
# `authoritative=bool(rebuild)`, and that same `rebuild` flag already ran
|
|
1946
2303
|
# `_clear_codex_derived_rows` — which invalidates the certificate and the
|
|
1947
2304
|
# progress record — and committed, before this call. The certificate is
|
|
1948
|
-
# therefore already gone when the
|
|
1949
|
-
# re-derives
|
|
2305
|
+
# therefore already gone when the deletes run, and the clear-then-replay
|
|
2306
|
+
# re-derives both families from `since=None` to the journal high water. A
|
|
1950
2307
|
# second `authoritative=True` caller, or a reordering inside
|
|
1951
2308
|
# `sync_codex_cache`, would break that silently, and the static scanner
|
|
1952
2309
|
# cannot catch it because this key is already in the inventory with a green
|
|
1953
|
-
# label.
|
|
1954
|
-
#
|
|
1955
|
-
"_cctally_journal.
|
|
2310
|
+
# label. The function therefore checks the invariant itself and raises
|
|
2311
|
+
# `CoverageInvariantViolation` rather than relying on it.
|
|
2312
|
+
"_cctally_journal.rehydrate_codex_journal_families": "preserve",
|
|
1956
2313
|
# Spec §4.3's migrations row, enumerated rather than named — and enumerated
|
|
1957
2314
|
# by the action each one TAKES, not by the action the spec's one-line row
|
|
1958
2315
|
# assumed. Only `_024` deletes rows the journal still retains, and it is the
|
|
@@ -2820,8 +3177,7 @@ def _write_codex_file_batch(
|
|
|
2820
3177
|
)
|
|
2821
3178
|
rows_changed = 0
|
|
2822
3179
|
if accounting_rows:
|
|
2823
|
-
|
|
2824
|
-
conn.executemany(
|
|
3180
|
+
cursor = conn.executemany(
|
|
2825
3181
|
"""INSERT OR IGNORE INTO codex_session_entries
|
|
2826
3182
|
(source_path, line_offset, timestamp_utc, session_id, model,
|
|
2827
3183
|
input_tokens, cached_input_tokens, output_tokens,
|
|
@@ -2830,7 +3186,10 @@ def _write_codex_file_batch(
|
|
|
2830
3186
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
2831
3187
|
accounting_rows,
|
|
2832
3188
|
)
|
|
2833
|
-
|
|
3189
|
+
# `total_changes` includes #582's accounting-ledger trigger writes;
|
|
3190
|
+
# cursor.rowcount is the top-level accounting-row count this metric
|
|
3191
|
+
# has always promised.
|
|
3192
|
+
rows_changed = max(0, int(cursor.rowcount))
|
|
2834
3193
|
if quota_rows:
|
|
2835
3194
|
if anchor_resolver is not None:
|
|
2836
3195
|
anchor_resolver.apply_pending_merges()
|
|
@@ -2961,9 +3320,9 @@ class IngestStats:
|
|
|
2961
3320
|
# Count of session_entries rows written by this sync — both genuinely-
|
|
2962
3321
|
# new INSERTs and ccusage-parity ON CONFLICT DO UPDATE replacements
|
|
2963
3322
|
# (the dedup tiebreaker swaps a streaming-intermediate row for the
|
|
2964
|
-
# post-stream finalization). SQLite
|
|
2965
|
-
#
|
|
2966
|
-
#
|
|
3323
|
+
# post-stream finalization). SQLite cursor rowcount increments on both, so
|
|
3324
|
+
# this field is "rows changed", not "rows newly inserted", while excluding
|
|
3325
|
+
# trigger-maintained ledger rows. Pre-dedup builds used INSERT OR IGNORE where
|
|
2967
3326
|
# conflicts did NOT bump the counter; the name change preserves the
|
|
2968
3327
|
# observability metric without misrepresenting UPSERT updates as
|
|
2969
3328
|
# new inserts.
|
|
@@ -3088,6 +3447,11 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
|
|
|
3088
3447
|
parent = os.path.basename(os.path.dirname(source_path))
|
|
3089
3448
|
cwd = _decode_escaped_cwd(parent)
|
|
3090
3449
|
|
|
3450
|
+
metadata_changed = existing is not None and (
|
|
3451
|
+
(existing[0] is None and session_id is not None)
|
|
3452
|
+
or (existing[1] is None and cwd is not None)
|
|
3453
|
+
)
|
|
3454
|
+
|
|
3091
3455
|
now_iso = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
3092
3456
|
conn.execute(
|
|
3093
3457
|
"""
|
|
@@ -3101,6 +3465,21 @@ def _ensure_session_files_row(conn: sqlite3.Connection, source_path: str) -> Non
|
|
|
3101
3465
|
""",
|
|
3102
3466
|
(source_path, now_iso, session_id, cwd),
|
|
3103
3467
|
)
|
|
3468
|
+
if metadata_changed:
|
|
3469
|
+
# `session_files` is part of every project/session aggregate row. A
|
|
3470
|
+
# NULL-to-known lazy backfill changes the joined accounting identity
|
|
3471
|
+
# without inserting a `session_entries` row, so stamp every retained
|
|
3472
|
+
# row for this file with the same durable mutation signal used by cost
|
|
3473
|
+
# finalizations. Snapshot caches then invalidate from their existing
|
|
3474
|
+
# O(1) counter instead of scanning all session-file metadata each tick.
|
|
3475
|
+
sync_seq = _bump_mutation_seq(conn)
|
|
3476
|
+
conn.execute(
|
|
3477
|
+
"UPDATE session_entries "
|
|
3478
|
+
"SET mutation_seq = ?, "
|
|
3479
|
+
" mutation_min_ts = COALESCE(mutation_min_ts, timestamp_utc) "
|
|
3480
|
+
"WHERE source_path = ?",
|
|
3481
|
+
(sync_seq, source_path),
|
|
3482
|
+
)
|
|
3104
3483
|
# Commit per-call so the write lock is released before the caller's
|
|
3105
3484
|
# subsequent JSONL read+parse. Leaving the implicit transaction open
|
|
3106
3485
|
# across the per-file loop would both hold a writer lock across reads
|
|
@@ -6318,7 +6697,7 @@ def sync_codex_cache(
|
|
|
6318
6697
|
#
|
|
6319
6698
|
# The cursor keeps the Claude-only case cheap too — once it equals the
|
|
6320
6699
|
# high-water, the replay reads no bytes and writes nothing.
|
|
6321
|
-
_ATTR_CURSOR_KEY =
|
|
6700
|
+
_ATTR_CURSOR_KEY = CODEX_FILE_ACCOUNT_CURSOR_KEY
|
|
6322
6701
|
try:
|
|
6323
6702
|
_cursor_row = conn.execute(
|
|
6324
6703
|
"SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
|
|
@@ -6333,8 +6712,33 @@ def sync_codex_cache(
|
|
|
6333
6712
|
_since = (_seg, int(_off))
|
|
6334
6713
|
try:
|
|
6335
6714
|
import _cctally_journal as _jr
|
|
6336
|
-
|
|
6337
|
-
|
|
6715
|
+
# #500 spec §6.3: the operator's window attributions are rehydrated
|
|
6716
|
+
# in the SAME transaction and the same position as the attribution
|
|
6717
|
+
# map, and for the same reason — `_clear_codex_derived_rows`
|
|
6718
|
+
# deliberately leaves the table standing, so the only thing that can
|
|
6719
|
+
# converge it after a rebuild is a replay from the journal. Ordered
|
|
6720
|
+
# BEFORE the rollout walk so every durable assertion visible to this
|
|
6721
|
+
# locked walk is already in place. It keeps its own cursor, distinct
|
|
6722
|
+
# from the attribution map's.
|
|
6723
|
+
#
|
|
6724
|
+
# ONE fused traversal (review finding F4). Two independent passes
|
|
6725
|
+
# streamed the whole journal twice while the global cache-writer
|
|
6726
|
+
# flock and the Codex provider flock were both held, which is the
|
|
6727
|
+
# `database is locked` trigger #297 documents.
|
|
6728
|
+
_rehydration = _jr.rehydrate_codex_journal_families(
|
|
6729
|
+
conn,
|
|
6730
|
+
authoritative=bool(rebuild),
|
|
6731
|
+
file_account_since=_since,
|
|
6732
|
+
window_attribution_since=(
|
|
6733
|
+
None if rebuild
|
|
6734
|
+
else load_codex_window_attribution_cursor(conn)),
|
|
6735
|
+
caller="sync_codex_cache(rebuild=True)",
|
|
6736
|
+
)
|
|
6737
|
+
restored = _rehydration.file_accounts_applied
|
|
6738
|
+
_applied_hw = _rehydration.file_accounts_high_water
|
|
6739
|
+
_declined = _rehydration.file_accounts_declined
|
|
6740
|
+
_attributed = _rehydration.window_attributions_applied
|
|
6741
|
+
_attr_skipped = _rehydration.window_attributions_skipped
|
|
6338
6742
|
_new_cursor = (
|
|
6339
6743
|
None if _applied_hw is None
|
|
6340
6744
|
else f"{_applied_hw[0]}:{_applied_hw[1]}")
|
|
@@ -6352,7 +6756,12 @@ def sync_codex_cache(
|
|
|
6352
6756
|
# bytes), and `rebuild` covers the authoritative DELETE, which
|
|
6353
6757
|
# happens even when there is nothing to replay. Getting this wrong
|
|
6354
6758
|
# strands an open transaction across the whole walk.
|
|
6355
|
-
|
|
6759
|
+
# `conn.in_transaction` is the precise witness — it is true iff
|
|
6760
|
+
# uncommitted DML is pending — and it covers #500's rehydration too
|
|
6761
|
+
# without the caller having to model that function's write
|
|
6762
|
+
# conditions. Getting this wrong strands an open transaction across
|
|
6763
|
+
# the whole walk.
|
|
6764
|
+
if rebuild or _new_cursor != _cursor_text or conn.in_transaction:
|
|
6356
6765
|
conn.commit()
|
|
6357
6766
|
# Both reports come AFTER the commit (closeout review C5): the
|
|
6358
6767
|
# `except` below rolls back, so anything printed before it would
|
|
@@ -6361,13 +6770,23 @@ def sync_codex_cache(
|
|
|
6361
6770
|
eprint(
|
|
6362
6771
|
"[cache-sync] rehydrated "
|
|
6363
6772
|
f"{restored} Codex attribution decision(s) from the journal")
|
|
6773
|
+
if _attributed:
|
|
6774
|
+
eprint(
|
|
6775
|
+
"[cache-sync] rehydrated "
|
|
6776
|
+
f"{_attributed} Codex window attribution(s) from the journal")
|
|
6364
6777
|
_jr._report_file_account_conflicts(_declined)
|
|
6778
|
+
_jr._report_window_attribution_skips(_attr_skipped)
|
|
6365
6779
|
except Exception as exc:
|
|
6366
6780
|
conn.rollback()
|
|
6367
6781
|
stats.deferred_reason = "attribution_rehydration"
|
|
6782
|
+
# Both journal-derived Codex families are rehydrated by the one
|
|
6783
|
+
# fused pass above, so this message names both (review finding F7):
|
|
6784
|
+
# a failure here is just as likely to be a window attribution as an
|
|
6785
|
+
# attribution decision, and a reader who saw only "decisions" would
|
|
6786
|
+
# look in the wrong place.
|
|
6368
6787
|
eprint(
|
|
6369
|
-
"[cache-sync] could not rehydrate Codex attribution "
|
|
6370
|
-
f"
|
|
6788
|
+
"[cache-sync] could not rehydrate Codex attribution decisions "
|
|
6789
|
+
f"or window attributions: {exc}; deferring the Codex walk")
|
|
6371
6790
|
return stats
|
|
6372
6791
|
|
|
6373
6792
|
# Pure read (glob + is_file only); safe to run before the SELECT and
|
|
@@ -7461,6 +7880,33 @@ def sync_codex_cache(
|
|
|
7461
7880
|
# boundary, never to a best-effort local except.
|
|
7462
7881
|
raise
|
|
7463
7882
|
eprint(f"[cache-sync] could not adopt Codex window spend: {exc}")
|
|
7883
|
+
# #500 §7.1: the STANDING half of operator attribution. The condition it
|
|
7884
|
+
# repairs is created by ordinary ingest, not only by an operator command
|
|
7885
|
+
# — native evidence arriving for an attributed group, a second assertion,
|
|
7886
|
+
# a component split, a window re-materializing as model-scoped — so it
|
|
7887
|
+
# has to run wherever ingest runs, not only where `account attribute`
|
|
7888
|
+
# does. Ordered AFTER the pass above so the reconciliation has the last
|
|
7889
|
+
# word on any row that pass could also have touched, and inside the same
|
|
7890
|
+
# flocks for the same reason: the observation evidence and the accounting
|
|
7891
|
+
# rows it restores are one committed generation. Costs a store with no
|
|
7892
|
+
# attribution records one indexed read; best-effort, like the pass above.
|
|
7893
|
+
try:
|
|
7894
|
+
restored, readopted = reconcile_codex_window_attribution_spend(conn)
|
|
7895
|
+
conn.commit()
|
|
7896
|
+
if restored:
|
|
7897
|
+
eprint(f"[cache-sync] restored {restored} Codex row(s) whose "
|
|
7898
|
+
"operator attribution no longer resolves; "
|
|
7899
|
+
f"re-attributed {readopted}")
|
|
7900
|
+
except sqlite3.DatabaseError as exc:
|
|
7901
|
+
conn.rollback()
|
|
7902
|
+
if _cctally_db_sib._is_sqlite_corruption_error(exc):
|
|
7903
|
+
raise
|
|
7904
|
+
eprint("[cache-sync] could not reconcile Codex window "
|
|
7905
|
+
f"attribution spend: {exc}")
|
|
7906
|
+
# #582: keep only a generous mutation-sequence tail. A dashboard that
|
|
7907
|
+
# falls behind it detects the gap and cold-loads, preserving truth.
|
|
7908
|
+
if _prune_codex_accounting_change_log(conn):
|
|
7909
|
+
conn.commit()
|
|
7464
7910
|
# Codex creates/extends cache.db sidecars independently of Claude's
|
|
7465
7911
|
# sync path. Harden them while both cache flocks are still held and
|
|
7466
7912
|
# after all Codex writes, before the optional checkpoint can rotate a
|
|
@@ -7570,9 +8016,20 @@ def apply_codex_window_spend_adoption(
|
|
|
7570
8016
|
conn: sqlite3.Connection,
|
|
7571
8017
|
*,
|
|
7572
8018
|
touched: "dict[str, tuple[dt.datetime, dt.datetime]] | None" = None,
|
|
8019
|
+
strict: bool = False,
|
|
7573
8020
|
) -> int:
|
|
7574
8021
|
"""Stamp window-derived attribution onto unattributed Codex spend.
|
|
7575
8022
|
|
|
8023
|
+
``strict`` (#500 spec §8.2) propagates every failure instead of converting a
|
|
8024
|
+
schema or loader database error into a successful zero-row result. The
|
|
8025
|
+
default stays best-effort because this pass's ordinary caller is a sync that
|
|
8026
|
+
must not fail a whole ingest over an adoption problem, and the stamp is fully
|
|
8027
|
+
re-derivable on the next one. Inside ``account attribute``'s transaction that
|
|
8028
|
+
forgiveness is wrong in a specific and silent way: the journal op lands, the
|
|
8029
|
+
percentage axis moves, adoption swallows the failure, and the command reports
|
|
8030
|
+
success with the spend axis untouched — which is exactly the all-or-nothing
|
|
8031
|
+
apply it promised not to break. Only that command passes ``strict=True``.
|
|
8032
|
+
|
|
7576
8033
|
The I/O half of ``_lib_codex_account_adoption``: read the folded window
|
|
7577
8034
|
evidence and the candidate rows, hand both to the pure kernel, write back the
|
|
7578
8035
|
plan it returns. Cache-only by construction — the window's identified
|
|
@@ -7636,8 +8093,19 @@ def apply_codex_window_spend_adoption(
|
|
|
7636
8093
|
"PRAGMA table_info(codex_session_entries)")
|
|
7637
8094
|
}
|
|
7638
8095
|
except sqlite3.DatabaseError:
|
|
8096
|
+
if strict:
|
|
8097
|
+
raise
|
|
7639
8098
|
return 0
|
|
7640
8099
|
if not {"account_key", "source_root_key", "timestamp_utc"} <= columns:
|
|
8100
|
+
if strict:
|
|
8101
|
+
# A dropped or pre-#341 table returns NO rows from `table_info`
|
|
8102
|
+
# rather than raising, so the strict caller has to be told in the
|
|
8103
|
+
# one currency it can act on. Reported as a database error because
|
|
8104
|
+
# that is what it is: the schema this pass writes to is not there.
|
|
8105
|
+
raise sqlite3.OperationalError(
|
|
8106
|
+
"codex_session_entries is missing the columns window spend "
|
|
8107
|
+
"adoption requires (account_key, source_root_key, "
|
|
8108
|
+
"timestamp_utc)")
|
|
7641
8109
|
return 0
|
|
7642
8110
|
|
|
7643
8111
|
try:
|
|
@@ -7646,6 +8114,8 @@ def apply_codex_window_spend_adoption(
|
|
|
7646
8114
|
canonical_resets_between=reset_bounds,
|
|
7647
8115
|
)
|
|
7648
8116
|
except sqlite3.DatabaseError:
|
|
8117
|
+
if strict:
|
|
8118
|
+
raise
|
|
7649
8119
|
return 0
|
|
7650
8120
|
|
|
7651
8121
|
# Group on the SAME key the observation fold groups on
|
|
@@ -7733,15 +8203,328 @@ def apply_codex_window_spend_adoption(
|
|
|
7733
8203
|
plan = adopt.build_spend_adoption_plan(windows, candidates)
|
|
7734
8204
|
if not plan:
|
|
7735
8205
|
return 0
|
|
7736
|
-
|
|
7737
|
-
conn.executemany(
|
|
8206
|
+
cursor = conn.executemany(
|
|
7738
8207
|
"UPDATE codex_session_entries SET account_key = ? "
|
|
7739
8208
|
" WHERE id = ? AND (account_key IS NULL OR account_key = '' "
|
|
7740
8209
|
" OR account_key = ?)",
|
|
7741
8210
|
[(stamp.account_key, stamp.entry_id, _lib_accounts.UNATTRIBUTED)
|
|
7742
8211
|
for stamp in plan],
|
|
7743
8212
|
)
|
|
7744
|
-
|
|
8213
|
+
# Trigger-maintained change ledgers are intentionally excluded from this
|
|
8214
|
+
# public semantic row count.
|
|
8215
|
+
return max(0, int(cursor.rowcount))
|
|
8216
|
+
|
|
8217
|
+
|
|
8218
|
+
# --------------------------------------------------------------------------
|
|
8219
|
+
# #500 §7.1 — suppression must un-stamp spend, not merely stop attributing
|
|
8220
|
+
# --------------------------------------------------------------------------
|
|
8221
|
+
#
|
|
8222
|
+
# The precedence table suppresses an assertion at fold time whenever the world
|
|
8223
|
+
# changes under it. On the PERCENTAGE axis that is sufficient, because the
|
|
8224
|
+
# overlay is re-derived from scratch on every load: stop applying the assertion
|
|
8225
|
+
# and the percentage reverts by construction.
|
|
8226
|
+
#
|
|
8227
|
+
# The SPEND axis does not behave that way. `codex_session_entries.account_key`
|
|
8228
|
+
# is a stored stamp, and the adoption kernel never revisits an already-identified
|
|
8229
|
+
# row — it skips every non-sentinel candidate, and the surrounding SQL only
|
|
8230
|
+
# selects rows that are NULL, empty or the sentinel. There is no un-stamp path,
|
|
8231
|
+
# so without this the two axes disagree permanently after native evidence
|
|
8232
|
+
# arrives, with no error and no operator action that would reveal it.
|
|
8233
|
+
#
|
|
8234
|
+
# This reconciliation is STATELESS by design. The spec sketched recording, per
|
|
8235
|
+
# assertion, the ownership it last applied; that state would have to survive an
|
|
8236
|
+
# authoritative rehydrate of the assertions table (which deletes and replays it)
|
|
8237
|
+
# while the spend stamps it describes survive independently, and the two going
|
|
8238
|
+
# out of step is a worse failure than the one being fixed. Restoring to the
|
|
8239
|
+
# per-file baseline and re-running the bounded adoption pass converges to the
|
|
8240
|
+
# same answer from the durable inputs alone, and is idempotent because a row
|
|
8241
|
+
# already at its correct owner is not rewritten.
|
|
8242
|
+
|
|
8243
|
+
|
|
8244
|
+
def codex_file_key_for_entry_path(
|
|
8245
|
+
source_root_key: str, source_path: str,
|
|
8246
|
+
) -> str:
|
|
8247
|
+
"""The durable file identity behind a stored ``source_path``.
|
|
8248
|
+
|
|
8249
|
+
``codex_session_entries`` retains the first configured WALK spelling, while
|
|
8250
|
+
the attribution map is keyed on ``(root, canonical physical path)``, so
|
|
8251
|
+
recovering the identity means re-running the same canonicalization the walk
|
|
8252
|
+
ran (``codex_file_identity`` over ``CodexDiscoveredFile.physical_path``).
|
|
8253
|
+
"""
|
|
8254
|
+
from _lib_source_identity import codex_file_key
|
|
8255
|
+
|
|
8256
|
+
return codex_file_key(
|
|
8257
|
+
str(source_root_key),
|
|
8258
|
+
str(_canonical_codex_path(pathlib.Path(str(source_path)))),
|
|
8259
|
+
)
|
|
8260
|
+
|
|
8261
|
+
|
|
8262
|
+
class _CodexFileBaselineResolver:
|
|
8263
|
+
"""Per-row ``codex_file_accounts`` baselines, memoised per file.
|
|
8264
|
+
|
|
8265
|
+
``(covered, account_key)`` semantics are ``codex_account_for_offset``'s:
|
|
8266
|
+
``(True, None)`` is the stably-absent DECISION and ``(False, None)`` means
|
|
8267
|
+
no decision covers those bytes at all. Both restore to ``NULL``, which is
|
|
8268
|
+
what makes the row adoptable again; the distinction matters to the caller
|
|
8269
|
+
only as documentation of why.
|
|
8270
|
+
"""
|
|
8271
|
+
|
|
8272
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
8273
|
+
self._conn = conn
|
|
8274
|
+
self._ranges: "dict[tuple[str, str], list[tuple[int, str | None]]]" = {}
|
|
8275
|
+
|
|
8276
|
+
def baseline(
|
|
8277
|
+
self, source_root_key: str, source_path: str, line_offset: int,
|
|
8278
|
+
) -> "str | None":
|
|
8279
|
+
key = (str(source_root_key), str(source_path))
|
|
8280
|
+
ranges = self._ranges.get(key)
|
|
8281
|
+
if ranges is None:
|
|
8282
|
+
identity = codex_file_key_for_entry_path(*key)
|
|
8283
|
+
incarnation = codex_file_incarnation(self._conn, identity)
|
|
8284
|
+
# Resolved at the file's CURRENT incarnation, never by path alone:
|
|
8285
|
+
# a truncation resets the file to offset zero, so an older
|
|
8286
|
+
# incarnation's ranges must not cover reused offsets.
|
|
8287
|
+
ranges = self._ranges[key] = load_codex_file_account_ranges(
|
|
8288
|
+
self._conn, identity, incarnation)
|
|
8289
|
+
return codex_account_for_offset(ranges, int(line_offset))[1]
|
|
8290
|
+
|
|
8291
|
+
|
|
8292
|
+
def _codex_span_index(
|
|
8293
|
+
spans: "Iterable[tuple[dt.datetime, dt.datetime]]",
|
|
8294
|
+
) -> "tuple[list[dt.datetime], list[dt.datetime]]":
|
|
8295
|
+
"""Index half-open spans for O(log n) containment.
|
|
8296
|
+
|
|
8297
|
+
Sorted starts plus the PREFIX MAXIMUM of the ends. Spans overlap — a
|
|
8298
|
+
straddling weekly window and its neighbour share hours — so "some span
|
|
8299
|
+
starting at or before ``t`` also ends after ``t``" is not answerable from
|
|
8300
|
+
the immediately preceding span alone; the running maximum end over the
|
|
8301
|
+
whole prefix is, and it is what keeps the lookup a single ``bisect``.
|
|
8302
|
+
"""
|
|
8303
|
+
ordered = sorted(spans)
|
|
8304
|
+
starts = [start for start, _end in ordered]
|
|
8305
|
+
prefix_max_end: "list[dt.datetime]" = []
|
|
8306
|
+
running: "dt.datetime | None" = None
|
|
8307
|
+
for _start, end in ordered:
|
|
8308
|
+
running = end if running is None or end > running else running
|
|
8309
|
+
prefix_max_end.append(running)
|
|
8310
|
+
return starts, prefix_max_end
|
|
8311
|
+
|
|
8312
|
+
|
|
8313
|
+
def _codex_span_covers(index, instant: dt.datetime) -> bool:
|
|
8314
|
+
"""True iff some indexed span half-open-contains ``instant``."""
|
|
8315
|
+
starts, prefix_max_end = index
|
|
8316
|
+
position = bisect.bisect_right(starts, instant)
|
|
8317
|
+
return position > 0 and prefix_max_end[position - 1] > instant
|
|
8318
|
+
|
|
8319
|
+
|
|
8320
|
+
def reconcile_codex_window_attribution_spend(
|
|
8321
|
+
conn: sqlite3.Connection, *, strict: bool = False,
|
|
8322
|
+
) -> "tuple[int, int]":
|
|
8323
|
+
"""Restore stranded attribution stamps, then re-adopt; ``(restored, adopted)``.
|
|
8324
|
+
|
|
8325
|
+
Standing, not one-shot: it runs at the end of every Codex sync and inside
|
|
8326
|
+
``account attribute``'s own transaction, because the condition it repairs is
|
|
8327
|
+
created by ORDINARY ingest — native evidence arriving for a group an operator
|
|
8328
|
+
had attributed, a second assertion appearing, a component splitting, a
|
|
8329
|
+
re-materialized window turning out to be model-scoped — and by retraction
|
|
8330
|
+
alike. Whichever of those happened, the effect is the same: a spend row is
|
|
8331
|
+
stamped to an account the current resolution no longer names.
|
|
8332
|
+
|
|
8333
|
+
The restore is scoped in three ways, and each one bounds the blast radius of
|
|
8334
|
+
a mistake:
|
|
8335
|
+
|
|
8336
|
+
* to the nominal ranges of groups some attribution record names, currently
|
|
8337
|
+
or at assertion time;
|
|
8338
|
+
* to rows whose stamp is an account some attribution record ASSERTED, so a
|
|
8339
|
+
per-file decision the operator never touched is never a candidate;
|
|
8340
|
+
* to rows whose stamp is not already an account the CURRENT WORLD can
|
|
8341
|
+
justify for a group containing that instant — a resolved assertion's
|
|
8342
|
+
owner, or a group's own native evidence — which is what makes a second
|
|
8343
|
+
run write nothing.
|
|
8344
|
+
|
|
8345
|
+
Costs a store with no attribution records exactly one indexed read.
|
|
8346
|
+
|
|
8347
|
+
``strict`` propagates failures for ``account attribute``'s all-or-nothing
|
|
8348
|
+
apply; the default swallows them the way the sync-time adoption pass does,
|
|
8349
|
+
because everything here is re-derivable on the next sync.
|
|
8350
|
+
"""
|
|
8351
|
+
from _cctally_quota import resolve_codex_window_attributions_with_evidence
|
|
8352
|
+
|
|
8353
|
+
try:
|
|
8354
|
+
active = load_active_window_attributions(conn)
|
|
8355
|
+
retracted = load_active_window_attributions(conn, retracted_only=True)
|
|
8356
|
+
except sqlite3.DatabaseError:
|
|
8357
|
+
if strict:
|
|
8358
|
+
raise
|
|
8359
|
+
return (0, 0)
|
|
8360
|
+
records = (*active, *retracted)
|
|
8361
|
+
if not records:
|
|
8362
|
+
return (0, 0)
|
|
8363
|
+
|
|
8364
|
+
roots = sorted({str(record["source_root_key"]) for record in records})
|
|
8365
|
+
try:
|
|
8366
|
+
_resolutions, ownership, groups = (
|
|
8367
|
+
resolve_codex_window_attributions_with_evidence(
|
|
8368
|
+
conn, source_root_keys=roots, include_retracted=True))
|
|
8369
|
+
except sqlite3.DatabaseError:
|
|
8370
|
+
if strict:
|
|
8371
|
+
raise
|
|
8372
|
+
return (0, 0)
|
|
8373
|
+
|
|
8374
|
+
asserted_keys = sorted({str(record["account_key"]) for record in records})
|
|
8375
|
+
week = _CODEX_ACCOUNT_WEEK
|
|
8376
|
+
|
|
8377
|
+
# The ranges to visit: every group an assertion currently owns, plus every
|
|
8378
|
+
# group any record named at assertion time. The second half is what reaches
|
|
8379
|
+
# a retraction, a newly dormant assertion and a component split — all three
|
|
8380
|
+
# move the rows OUT of a currently-resolved group, so a currently-resolved
|
|
8381
|
+
# set alone would never look at them again.
|
|
8382
|
+
ranges: "dict[str, list[tuple[dt.datetime, dt.datetime]]]" = {}
|
|
8383
|
+
# Two deliberate loosenesses in `justified`, recorded because they are
|
|
8384
|
+
# KNOWN and neither is worth restructuring this map for:
|
|
8385
|
+
#
|
|
8386
|
+
# 1. The predicate is "some covering group justifies this account", while
|
|
8387
|
+
# the adoption fold's rule is "the UNION of identified accounts across
|
|
8388
|
+
# every claiming window is exactly one". The two agree wherever one
|
|
8389
|
+
# account claims an instant and disagree where two do: the fold declines
|
|
8390
|
+
# to stamp, this map still justifies keeping a stamp already there. The
|
|
8391
|
+
# error is one-directional — it retains a stamp the operator's own
|
|
8392
|
+
# assertion put on a row the fold now declines to re-derive — and the
|
|
8393
|
+
# alternative, evaluating the union per instant, would cost a per-row
|
|
8394
|
+
# fold rather than a bisect.
|
|
8395
|
+
# 2. It is keyed on `(root, account)` with no group axes, so a
|
|
8396
|
+
# justification earned by one group covers a same-account stamp inside
|
|
8397
|
+
# any other group whose span contains that instant. Weekly spans on one
|
|
8398
|
+
# root overlap by construction, so this is reachable rather than
|
|
8399
|
+
# theoretical; it is also the same one-directional retention.
|
|
8400
|
+
justified: "dict[tuple[str, str], list[tuple[dt.datetime, dt.datetime]]]" = {}
|
|
8401
|
+
for group_key, account_key in ownership.items():
|
|
8402
|
+
root_key, reset = str(group_key[1]), group_key[5]
|
|
8403
|
+
ranges.setdefault(root_key, []).append((reset - week, reset))
|
|
8404
|
+
justified.setdefault((root_key, str(account_key)), []).append(
|
|
8405
|
+
(reset - week, reset))
|
|
8406
|
+
# A group's own NATIVE accounts justify a stamp exactly as ownership does:
|
|
8407
|
+
# the fold adopts every unattributed member of such a group into that
|
|
8408
|
+
# account, so a spend row already carrying it is at the answer the current
|
|
8409
|
+
# world gives. Reading it from the loaded GROUPS rather than from a
|
|
8410
|
+
# SUPPRESSED_NATIVE resolution also covers the dormant and split shapes,
|
|
8411
|
+
# whose resolutions name no group at all.
|
|
8412
|
+
#
|
|
8413
|
+
# Without this the reconciliation churns forever whenever a group resolving
|
|
8414
|
+
# SUPPRESSED_NATIVE names an account the operator also asserted elsewhere —
|
|
8415
|
+
# which is ordinary, not exotic: the row is restored to a different per-file
|
|
8416
|
+
# baseline and immediately re-adopted to the same account, on every sync,
|
|
8417
|
+
# each time printing a line claiming an attribution no longer resolves. The
|
|
8418
|
+
# end state was right; the write behaviour was not idempotent.
|
|
8419
|
+
#
|
|
8420
|
+
# An OUT-OF-SCOPE group justifies nothing, though. The kernel files a
|
|
8421
|
+
# model-scoped group as SUPPRESSED_MODEL_SCOPED — it "neither stamps nor
|
|
8422
|
+
# blocks" — so it can never be the source of a stamp, and letting it
|
|
8423
|
+
# authorize keeping one is one-directional damage: the span below is a whole
|
|
8424
|
+
# week, so a single Spark-labelled capture naming the account the operator
|
|
8425
|
+
# asserted covers every stamped row in that week and a retraction over it
|
|
8426
|
+
# silently accomplishes nothing. Such a group reaches this loop because it
|
|
8427
|
+
# shares the assertion's four stored axes and its anchor while interpreting
|
|
8428
|
+
# to a different limit key. `SUPPRESSED_NATIVE` is unaffected: that shape is
|
|
8429
|
+
# in-scope by construction.
|
|
8430
|
+
for group in groups:
|
|
8431
|
+
if not group.in_scope:
|
|
8432
|
+
continue
|
|
8433
|
+
reset = group.group_key[5]
|
|
8434
|
+
for account_key in group.identified_accounts:
|
|
8435
|
+
justified.setdefault(
|
|
8436
|
+
(group.source_root_key, str(account_key)), []).append(
|
|
8437
|
+
(reset - week, reset))
|
|
8438
|
+
for record in records:
|
|
8439
|
+
reset = _parse_anchor_iso(record["canonical_resets_at_utc"])
|
|
8440
|
+
if reset is None:
|
|
8441
|
+
continue
|
|
8442
|
+
ranges.setdefault(str(record["source_root_key"]), []).append(
|
|
8443
|
+
(reset - week, reset))
|
|
8444
|
+
if not ranges:
|
|
8445
|
+
return (0, 0)
|
|
8446
|
+
|
|
8447
|
+
# Both membership questions are asked once per candidate row, and the SQL
|
|
8448
|
+
# range is the union of a root's spans, so on a whole-history attribution
|
|
8449
|
+
# that is every stamped row against every span. Indexing them turns two
|
|
8450
|
+
# linear scans into two binary searches: measured read-only on the
|
|
8451
|
+
# maintainer's store at the §9.1 shape (58,806 stamped rows, 180 spans, 90
|
|
8452
|
+
# justifications), the filter alone went from 358.8 ms to 30.3 ms — per
|
|
8453
|
+
# `sync_codex_cache`, inside both cache flocks.
|
|
8454
|
+
span_index = {
|
|
8455
|
+
root_key: _codex_span_index(spans)
|
|
8456
|
+
for root_key, spans in ranges.items()
|
|
8457
|
+
}
|
|
8458
|
+
justified_index = {
|
|
8459
|
+
key: _codex_span_index(spans) for key, spans in justified.items()
|
|
8460
|
+
}
|
|
8461
|
+
|
|
8462
|
+
resolver = _CodexFileBaselineResolver(conn)
|
|
8463
|
+
restored = 0
|
|
8464
|
+
try:
|
|
8465
|
+
for root_key, spans in sorted(ranges.items()):
|
|
8466
|
+
low = min(span[0] for span in spans)
|
|
8467
|
+
high = max(span[1] for span in spans)
|
|
8468
|
+
updates: "list[tuple[str | None, int]]" = []
|
|
8469
|
+
for row in conn.execute(
|
|
8470
|
+
"SELECT id, source_path, line_offset, timestamp_utc, "
|
|
8471
|
+
" account_key "
|
|
8472
|
+
" FROM codex_session_entries "
|
|
8473
|
+
" WHERE source_root_key = ? "
|
|
8474
|
+
" AND account_key IN ("
|
|
8475
|
+
+ ",".join("?" * len(asserted_keys)) + ") "
|
|
8476
|
+
" AND unixepoch(timestamp_utc) >= unixepoch(?) "
|
|
8477
|
+
" AND unixepoch(timestamp_utc) <= unixepoch(?)",
|
|
8478
|
+
(root_key, *asserted_keys,
|
|
8479
|
+
_codex_anchor_iso(low), _codex_anchor_iso(high)),
|
|
8480
|
+
):
|
|
8481
|
+
account_key = str(row[4])
|
|
8482
|
+
timestamp = _parse_anchor_iso(row[3])
|
|
8483
|
+
if timestamp is None:
|
|
8484
|
+
continue
|
|
8485
|
+
if not _codex_span_covers(span_index[root_key], timestamp):
|
|
8486
|
+
continue
|
|
8487
|
+
owner = justified_index.get((root_key, account_key))
|
|
8488
|
+
if owner is not None and _codex_span_covers(owner, timestamp):
|
|
8489
|
+
continue
|
|
8490
|
+
try:
|
|
8491
|
+
baseline = resolver.baseline(root_key, row[1], row[2])
|
|
8492
|
+
except (ValueError, OSError):
|
|
8493
|
+
# `codex_file_key` refuses a blank path and `pathlib`
|
|
8494
|
+
# refuses a NUL byte; neither is a `sqlite3.DatabaseError`,
|
|
8495
|
+
# so an unrecoverable row would have failed the WHOLE Codex
|
|
8496
|
+
# sync rather than degrading. A row whose durable file
|
|
8497
|
+
# identity cannot be recovered has no baseline to restore
|
|
8498
|
+
# to, so it is skipped — the same rule the quota loader
|
|
8499
|
+
# applies window-by-window rather than suppressing valid
|
|
8500
|
+
# ones. `strict` still propagates, because the command's
|
|
8501
|
+
# all-or-nothing apply must not report success over it.
|
|
8502
|
+
if strict:
|
|
8503
|
+
raise
|
|
8504
|
+
continue
|
|
8505
|
+
if baseline == account_key:
|
|
8506
|
+
continue
|
|
8507
|
+
updates.append((baseline, int(row[0])))
|
|
8508
|
+
if updates:
|
|
8509
|
+
cursor = conn.executemany(
|
|
8510
|
+
"UPDATE codex_session_entries SET account_key = ? "
|
|
8511
|
+
" WHERE id = ?",
|
|
8512
|
+
updates,
|
|
8513
|
+
)
|
|
8514
|
+
restored += max(0, int(cursor.rowcount))
|
|
8515
|
+
except sqlite3.DatabaseError:
|
|
8516
|
+
if strict:
|
|
8517
|
+
raise
|
|
8518
|
+
return (0, 0)
|
|
8519
|
+
|
|
8520
|
+
touched = {
|
|
8521
|
+
root_key: (min(span[0] for span in spans),
|
|
8522
|
+
max(span[1] for span in spans))
|
|
8523
|
+
for root_key, spans in ranges.items()
|
|
8524
|
+
}
|
|
8525
|
+
adopted = apply_codex_window_spend_adoption(
|
|
8526
|
+
conn, touched=touched, strict=strict)
|
|
8527
|
+
return (restored, adopted)
|
|
7745
8528
|
|
|
7746
8529
|
|
|
7747
8530
|
def iter_codex_entries(
|
|
@@ -8596,6 +9379,21 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
8596
9379
|
conn.execute("PRAGMA journal_mode").fetchone()[0]
|
|
8597
9380
|
).lower()
|
|
8598
9381
|
|
|
9382
|
+
# #566: the refusal must precede the DDL it exists to prevent. The
|
|
9383
|
+
# dispatcher's #142 guard runs inside
|
|
9384
|
+
# `_run_pending_cache_migrations_under_writer_lock`, which this path
|
|
9385
|
+
# reaches only after `apply_policy`, `_apply_cache_schema` and the
|
|
9386
|
+
# `last_total_tokens` ALTER-plus-purge. A dev-checkout binary pointed at
|
|
9387
|
+
# the real prod dir therefore modified the production schema and only
|
|
9388
|
+
# then refused. Evaluated here, under both flocks and after the gates
|
|
9389
|
+
# were re-read, nothing persistent has been written yet.
|
|
9390
|
+
try:
|
|
9391
|
+
_cctally_db_sib._refuse_prod_migration_before_schema_write(
|
|
9392
|
+
conn, _CACHE_MIGRATIONS, "cache.db")
|
|
9393
|
+
except _cctally_db_sib.ProdMigrationRefused:
|
|
9394
|
+
conn.close()
|
|
9395
|
+
raise
|
|
9396
|
+
|
|
8599
9397
|
if not schema_current or journal_mode != "wal":
|
|
8600
9398
|
_cctally_store.apply_policy(conn, "cache")
|
|
8601
9399
|
else:
|