cctally 1.116.1 → 1.117.2
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 +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +57 -3
- package/bin/_cctally_cache.py +5720 -885
- package/bin/_cctally_claude_attribution.py +997 -0
- package/bin/_cctally_completion.py +101 -28
- package/bin/_cctally_core.py +235 -12
- package/bin/_cctally_dashboard.py +263 -17
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +44 -0
- package/bin/_cctally_db.py +928 -80
- package/bin/_cctally_diagnosis_sources.py +78 -13
- package/bin/_cctally_doctor.py +211 -86
- package/bin/_cctally_five_hour.py +446 -22
- package/bin/_cctally_forecast.py +302 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_milestone_history.py +379 -91
- package/bin/_cctally_oauth_samples.py +185 -7
- package/bin/_cctally_parser.py +38 -0
- package/bin/_cctally_project.py +34 -8
- package/bin/_cctally_quarantine.py +821 -343
- package/bin/_cctally_quota.py +69 -4
- package/bin/_cctally_quota_calibration.py +677 -3
- package/bin/_cctally_quota_model.py +927 -127
- package/bin/_cctally_record.py +922 -445
- package/bin/_cctally_rederive.py +119 -15
- package/bin/_cctally_statusline.py +1048 -176
- package/bin/_cctally_store.py +544 -110
- package/bin/_cctally_tui.py +455 -42
- package/bin/_cctally_update.py +77 -14
- package/bin/_lib_account_protocol.py +227 -19
- package/bin/_lib_claude_attribution.py +421 -0
- package/bin/_lib_clone_copy.py +30 -0
- package/bin/_lib_codex_conversation_query.py +45 -9
- package/bin/_lib_codex_conversation_watch.py +72 -13
- package/bin/_lib_codex_rollout_container.py +234 -19
- package/bin/_lib_codex_worker_lifecycle.py +374 -0
- package/bin/_lib_credit.py +22 -13
- package/bin/_lib_dashboard_sources.py +257 -18
- package/bin/_lib_detector_access_registry.py +1335 -166
- package/bin/_lib_doctor.py +276 -92
- package/bin/_lib_ingest_frontier.py +269 -36
- package/bin/_lib_journal.py +273 -22
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_meter_rate_change.py +72 -0
- package/bin/_lib_outcome_quarantine.py +917 -554
- package/bin/_lib_provider_activity.py +24 -6
- package/bin/_lib_quota_calibration.py +57 -3
- package/bin/_lib_quota_copy.py +7 -2
- package/bin/_lib_quota_lineage.py +613 -0
- package/bin/_lib_quota_model.py +464 -33
- package/bin/_lib_rate_change_delivery.py +16 -0
- package/bin/_lib_reclaim_planner.py +6 -4
- package/bin/_lib_rederive.py +2046 -21
- package/bin/_lib_selector_state.py +131 -3
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_stale_proof.py +113 -4
- package/bin/_lib_statusline.py +72 -3
- package/bin/_lib_statusline_candidates.py +246 -42
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +419 -6
- package/bin/cctally +28 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
- package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +5 -1
- package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
- package/dashboard/static/assets/index-BKITk1CT.css +0 -1
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
"""Write-time Claude cost attribution glue (#944).
|
|
2
|
+
|
|
3
|
+
The pure rule and identity encoders live in ``_lib_claude_attribution``; this
|
|
4
|
+
module owns their cache.db and journal side (spec
|
|
5
|
+
``docs/superpowers/specs/2026-10-29-944-claude-write-time-cost-attribution-design.md``
|
|
6
|
+
sections 5.1 and 5.4-5.6):
|
|
7
|
+
|
|
8
|
+
* the two derived, journal-backed tables -- ``claude_account_timeline`` (one
|
|
9
|
+
row per admissible Claude ``record-usage`` observation) and
|
|
10
|
+
``claude_cost_decisions`` (one row per decision key a
|
|
11
|
+
``claude_cost_account`` op names) -- and their replay from the journal;
|
|
12
|
+
* timeline neighbour seeks and decision lookup;
|
|
13
|
+
* the fail-closed append of decision batches (journal first, then the
|
|
14
|
+
decision rows in the caller's transaction);
|
|
15
|
+
* alignment of an unlinked accounting row to the decision its identity
|
|
16
|
+
already holds, and the preserve-before-wipe export of unlinked rows.
|
|
17
|
+
|
|
18
|
+
Transactions: every function here runs its statements on the caller's
|
|
19
|
+
connection and never commits, except ``record_deferral``, which is its own
|
|
20
|
+
small transaction. Locks: the caller holds the Claude cache writer flock; the
|
|
21
|
+
only lock taken here is the journal's leaf ``journal.lock`` inside
|
|
22
|
+
``append_records``, released before it returns (the lock-order law).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import dataclasses
|
|
27
|
+
import datetime as dt
|
|
28
|
+
import json
|
|
29
|
+
import sqlite3
|
|
30
|
+
from typing import Sequence
|
|
31
|
+
|
|
32
|
+
import _cctally_core
|
|
33
|
+
import _lib_account_binding
|
|
34
|
+
import _lib_account_protocol
|
|
35
|
+
import _lib_claude_attribution as K
|
|
36
|
+
import _lib_journal
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
CURSOR_KEY = "claude_attribution_rehydrated_hw"
|
|
40
|
+
CONFLICTS_KEY = "claude_cost_decision_conflicts"
|
|
41
|
+
ALIGNED_KEY = "claude_cost_aligned_rows"
|
|
42
|
+
PRESERVATION_KEY = "claude_cost_preservation"
|
|
43
|
+
DEFERRAL_KEY = "claude_attribution_last_deferral"
|
|
44
|
+
#: The deferral reason a sync reports when the journal cannot be read or
|
|
45
|
+
#: written (the reason the Codex attribution replay already uses).
|
|
46
|
+
DEFERRAL_REASON = "attribution_rehydration"
|
|
47
|
+
MUTATION_SEQ_KEY = "session_entries_mutation_seq"
|
|
48
|
+
|
|
49
|
+
#: Decision batches stay well under the journal's 64 KiB line limit.
|
|
50
|
+
_BATCH_LINE_BUDGET = 48 * 1024
|
|
51
|
+
#: Rows one preserve chunk scans before it appends.
|
|
52
|
+
_PRESERVE_CHUNK = 5_000
|
|
53
|
+
_MAX_PRECISION_US = 1_000_000
|
|
54
|
+
|
|
55
|
+
_OBS_MARKERS = (b'"account_binding"', b'"record-usage"')
|
|
56
|
+
_DECISION_MARKER = b'"' + _lib_journal.CLAUDE_COST_ACCOUNT_KIND.encode() + b'"'
|
|
57
|
+
|
|
58
|
+
_PROJECTION_COLUMNS = ", ".join(K.PROJECTION_FIELDS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AttributionJournalUnavailable(Exception):
|
|
62
|
+
"""The journal could not be read or written, so no attribution decision
|
|
63
|
+
may be made durable. ``contended`` distinguishes lock contention (a
|
|
64
|
+
silent deferral) from a genuine failure."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message: str, *, contended: bool = False):
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
self.contended = contended
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclasses.dataclass(frozen=True)
|
|
72
|
+
class RecordIdentity:
|
|
73
|
+
"""The durable identity a cost record or cached row is decided under.
|
|
74
|
+
|
|
75
|
+
``k1`` names a logical ``(msg_id, req_id)``; ``p1`` a record lacking an id
|
|
76
|
+
by its exact raw bytes at ``(loc, offset)`` (``projection`` additionally
|
|
77
|
+
lets lookup fall back to a legacy decision at that location); ``l1`` a
|
|
78
|
+
cached row lacking an id by its legacy projection."""
|
|
79
|
+
|
|
80
|
+
form: str
|
|
81
|
+
key: str
|
|
82
|
+
msg_id: "str | None" = None
|
|
83
|
+
req_id: "str | None" = None
|
|
84
|
+
loc: "str | None" = None
|
|
85
|
+
offset: "int | None" = None
|
|
86
|
+
digest: "str | None" = None
|
|
87
|
+
projection: "list | None" = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclasses.dataclass(frozen=True)
|
|
91
|
+
class StoredDecision:
|
|
92
|
+
key: str
|
|
93
|
+
account_key: "str | None"
|
|
94
|
+
basis: str
|
|
95
|
+
reason: "str | None"
|
|
96
|
+
decided_at_utc: str
|
|
97
|
+
journal_pos: tuple
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclasses.dataclass(frozen=True)
|
|
101
|
+
class PreserveResult:
|
|
102
|
+
saved: int
|
|
103
|
+
aligned: int
|
|
104
|
+
exported: int
|
|
105
|
+
last_id: int
|
|
106
|
+
remaining: bool
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
# identities
|
|
111
|
+
# --------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def keyed_identity(msg_id: str, req_id: str) -> RecordIdentity:
|
|
114
|
+
return RecordIdentity(form=K.FORM_KEYED, key=K.keyed_key(msg_id, req_id),
|
|
115
|
+
msg_id=msg_id, req_id=req_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def record_identity(*, msg_id, req_id, source_path: str, byte_offset: int,
|
|
119
|
+
record_bytes: bytes, projection: list) -> RecordIdentity:
|
|
120
|
+
"""A cost record's identity: keyed when both ids are valid record ids
|
|
121
|
+
(``K.keyed_ids``), otherwise physical (exact raw bytes) with its
|
|
122
|
+
projection for the legacy fallback. ``record_bytes`` is read only for a
|
|
123
|
+
physical identity (the caller may pass None for a keyed record)."""
|
|
124
|
+
ids = K.keyed_ids(msg_id, req_id)
|
|
125
|
+
if ids is not None:
|
|
126
|
+
return keyed_identity(*ids)
|
|
127
|
+
loc = K.loc_for(source_path)
|
|
128
|
+
digest = K.record_sha256(record_bytes)
|
|
129
|
+
return RecordIdentity(
|
|
130
|
+
form=K.FORM_PHYSICAL,
|
|
131
|
+
key=K.physical_key_from_digest(loc, byte_offset, digest),
|
|
132
|
+
loc=loc, offset=int(byte_offset), digest=digest,
|
|
133
|
+
projection=list(projection))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def row_identity(row) -> RecordIdentity:
|
|
137
|
+
"""A cached accounting row's identity: keyed (both ids valid record ids,
|
|
138
|
+
``K.keyed_ids``), or the legacy projection of the row itself (an export
|
|
139
|
+
never reads transcript bytes). A row stored before #944 F1 with an empty
|
|
140
|
+
or non-string id therefore exports in legacy form, which is where the
|
|
141
|
+
re-read record's physical lookup falls back to."""
|
|
142
|
+
ids = K.keyed_ids(row["msg_id"], row["req_id"])
|
|
143
|
+
if ids is not None:
|
|
144
|
+
return keyed_identity(*ids)
|
|
145
|
+
projection = K.legacy_projection(row)
|
|
146
|
+
loc = K.loc_for(row["source_path"])
|
|
147
|
+
digest = K.projection_sha256(projection)
|
|
148
|
+
return RecordIdentity(
|
|
149
|
+
form=K.FORM_LEGACY,
|
|
150
|
+
key=K.legacy_key_from_digest(loc, row["line_offset"], digest),
|
|
151
|
+
loc=loc, offset=int(row["line_offset"]), digest=digest,
|
|
152
|
+
projection=projection)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# schema and cache_meta
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def ensure_claude_attribution_schema(conn) -> None:
|
|
160
|
+
import _cctally_db
|
|
161
|
+
_cctally_db._apply_claude_attribution_schema(conn)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def schema_present(conn) -> bool:
|
|
165
|
+
import _cctally_db
|
|
166
|
+
return _cctally_db.claude_attribution_schema_present(conn)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _meta_json(conn, key):
|
|
170
|
+
try:
|
|
171
|
+
row = conn.execute(
|
|
172
|
+
"SELECT value FROM cache_meta WHERE key=?", (key,)).fetchone()
|
|
173
|
+
except sqlite3.OperationalError:
|
|
174
|
+
return None
|
|
175
|
+
if row is None or row[0] is None:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(row[0])
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _meta_set(conn, key, value) -> None:
|
|
184
|
+
conn.execute(
|
|
185
|
+
"INSERT INTO cache_meta(key, value) VALUES(?, ?) "
|
|
186
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
187
|
+
(key, json.dumps(value, sort_keys=True, separators=(",", ":"))))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _now_iso() -> str:
|
|
191
|
+
return _cctally_core.now_utc_iso()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _bump_counter(conn, key, by: int, at) -> None:
|
|
195
|
+
current = _meta_json(conn, key)
|
|
196
|
+
count = 0
|
|
197
|
+
if isinstance(current, dict) and type(current.get("count")) is int:
|
|
198
|
+
count = current["count"]
|
|
199
|
+
_meta_set(conn, key, {"count": count + int(by), "last_at": at})
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def record_deferral(conn, reason: str) -> None:
|
|
203
|
+
"""Record ``claude_attribution_last_deferral`` in its own small
|
|
204
|
+
transaction. Call with no transaction of the caller open."""
|
|
205
|
+
_meta_set(conn, DEFERRAL_KEY, {"reason": reason, "at": _now_iso()})
|
|
206
|
+
conn.commit()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clear_deferral(conn) -> None:
|
|
210
|
+
"""Drop a recorded deferral, in the caller's transaction (a sync that
|
|
211
|
+
appended successfully)."""
|
|
212
|
+
conn.execute("DELETE FROM cache_meta WHERE key=?", (DEFERRAL_KEY,))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def deferral_recorded(conn) -> bool:
|
|
216
|
+
return _meta_json(conn, DEFERRAL_KEY) is not None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def last_deferral(conn):
|
|
220
|
+
"""``claude_attribution_last_deferral`` as recorded, or None."""
|
|
221
|
+
doc = _meta_json(conn, DEFERRAL_KEY)
|
|
222
|
+
return doc if isinstance(doc, dict) else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def read_preservation(conn):
|
|
226
|
+
"""Migration 050's ``claude_cost_preservation`` progress document
|
|
227
|
+
(``afterId, saved, total, lastError, lastErrorAt, lastChunkAt``), or None
|
|
228
|
+
before its first invocation."""
|
|
229
|
+
doc = _meta_json(conn, PRESERVATION_KEY)
|
|
230
|
+
return doc if isinstance(doc, dict) else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def write_preservation(conn, doc) -> None:
|
|
234
|
+
"""Store the progress document in the caller's transaction."""
|
|
235
|
+
_meta_set(conn, PRESERVATION_KEY, {
|
|
236
|
+
key: doc.get(key) for key in (
|
|
237
|
+
"afterId", "saved", "total", "lastError", "lastErrorAt",
|
|
238
|
+
"lastChunkAt")})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def aligned_rows(conn) -> int:
|
|
242
|
+
doc = _meta_json(conn, ALIGNED_KEY)
|
|
243
|
+
if isinstance(doc, dict) and type(doc.get("count")) is int:
|
|
244
|
+
return doc["count"]
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --------------------------------------------------------------------------
|
|
249
|
+
# replay
|
|
250
|
+
# --------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def _admission_fingerprint(boundary):
|
|
253
|
+
if boundary is None:
|
|
254
|
+
return None
|
|
255
|
+
revision = boundary.revision
|
|
256
|
+
return [boundary.segment, int(boundary.end_offset),
|
|
257
|
+
None if revision is None else revision.segment,
|
|
258
|
+
None if revision is None else int(revision.end_offset)]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _stored_cursor(conn, fingerprint):
|
|
262
|
+
"""The replay cursor, or ``None`` when it is absent, malformed, or was
|
|
263
|
+
written under another rule version or admission boundary (each of which
|
|
264
|
+
invalidates the derived tables)."""
|
|
265
|
+
doc = _meta_json(conn, CURSOR_KEY)
|
|
266
|
+
if not isinstance(doc, dict):
|
|
267
|
+
return None
|
|
268
|
+
if doc.get("rule") != K.RULE_VERSION or doc.get(
|
|
269
|
+
"admission") != fingerprint:
|
|
270
|
+
return None
|
|
271
|
+
segment, offset = doc.get("segment"), doc.get("offset")
|
|
272
|
+
if not isinstance(segment, str) or not segment or type(
|
|
273
|
+
offset) is not int or offset < 0:
|
|
274
|
+
return None
|
|
275
|
+
return (segment, offset)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _store_cursor(conn, hw, fingerprint) -> None:
|
|
279
|
+
_meta_set(conn, CURSOR_KEY, {
|
|
280
|
+
"segment": str(hw[0]), "offset": int(hw[1]),
|
|
281
|
+
"rule": K.RULE_VERSION, "admission": fingerprint})
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _clear_derived(conn) -> None:
|
|
285
|
+
conn.execute("DELETE FROM claude_cost_decisions")
|
|
286
|
+
conn.execute("DELETE FROM claude_account_timeline")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _is_claude_usage_obs(rec) -> bool:
|
|
290
|
+
return (rec.get("t") == "obs" and rec.get("provider") == "claude"
|
|
291
|
+
and rec.get("src") == "record-usage")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _observation_row(rec):
|
|
295
|
+
"""``(captured_at_utc, precision_us, account_key)`` of an admissible
|
|
296
|
+
timeline observation (spec 5.1), or ``None``."""
|
|
297
|
+
payload = rec.get("payload")
|
|
298
|
+
if not isinstance(payload, dict):
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
lo, hi = K.capture_interval(payload.get("captured_at"))
|
|
302
|
+
binding = _lib_account_binding.load(payload.get("account_binding"))
|
|
303
|
+
except (ValueError, TypeError, OverflowError, KeyError):
|
|
304
|
+
return None
|
|
305
|
+
account = K.normalize_binding_account(binding.account_key)
|
|
306
|
+
top = rec.get("account")
|
|
307
|
+
if top != account:
|
|
308
|
+
# An omitted top-level stamp is NULL; anything else must equal the
|
|
309
|
+
# binding's normalized account.
|
|
310
|
+
return None
|
|
311
|
+
return (K.iso_from_us(lo), hi - lo, account)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _apply_observation(conn, rec, segment, offset) -> None:
|
|
315
|
+
row = _observation_row(rec)
|
|
316
|
+
if row is None:
|
|
317
|
+
return
|
|
318
|
+
captured, precision, account = row
|
|
319
|
+
conn.execute(
|
|
320
|
+
"INSERT INTO claude_account_timeline (captured_at_utc, precision_us,"
|
|
321
|
+
" account_key, journal_segment, journal_offset) SELECT ?,?,?,?,? "
|
|
322
|
+
"WHERE NOT EXISTS (SELECT 1 FROM claude_account_timeline WHERE "
|
|
323
|
+
"captured_at_utc=? AND journal_segment=? AND journal_offset=?)",
|
|
324
|
+
(captured, precision, account, segment, int(offset),
|
|
325
|
+
captured, segment, int(offset)))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _item_values(item, payload):
|
|
329
|
+
"""``(decision_key, form, msg_id, req_id, loc, byte_offset, digest,
|
|
330
|
+
record_ts_utc, projection_json)`` of one validated item."""
|
|
331
|
+
form = item[0]
|
|
332
|
+
if form == K.FORM_KEYED:
|
|
333
|
+
ts = item[3] if len(item) == 4 else None
|
|
334
|
+
return (K.keyed_key(item[1], item[2]), form, item[1], item[2],
|
|
335
|
+
None, None, None, ts, None)
|
|
336
|
+
if form == K.FORM_PHYSICAL:
|
|
337
|
+
return (K.physical_key_from_digest(item[1], item[2], item[3]), form,
|
|
338
|
+
None, None, item[1], item[2], item[3], item[4], None)
|
|
339
|
+
return (K.legacy_key_from_digest(item[1], item[2], item[3]), form,
|
|
340
|
+
None, None, item[1], item[2], item[3], None,
|
|
341
|
+
K.projection_json(item[4]))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
_DECISION_INSERT = (
|
|
345
|
+
"INSERT INTO claude_cost_decisions (decision_key, form, msg_id, req_id,"
|
|
346
|
+
" loc, byte_offset, digest, account_key, basis, reason, rule,"
|
|
347
|
+
" decided_at_utc, record_ts_utc, projection_json, journal_segment,"
|
|
348
|
+
" journal_offset) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
|
349
|
+
"ON CONFLICT(decision_key) DO NOTHING")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _insert_decision_items(conn, payload, segment, offset) -> int:
|
|
353
|
+
"""Insert every item of one validated op first-wins; return how many
|
|
354
|
+
contradicted an existing decision for the same key (conflicts)."""
|
|
355
|
+
account = payload.get("account_key")
|
|
356
|
+
conflicts = 0
|
|
357
|
+
for item in payload["items"]:
|
|
358
|
+
(key, form, msg_id, req_id, loc, byte_offset, digest, ts,
|
|
359
|
+
projection) = _item_values(item, payload)
|
|
360
|
+
before = conn.total_changes
|
|
361
|
+
conn.execute(_DECISION_INSERT, (
|
|
362
|
+
key, form, msg_id, req_id, loc, byte_offset, digest, account,
|
|
363
|
+
payload["basis"], payload.get("reason"), payload["rule"],
|
|
364
|
+
payload["decided_at"], ts, projection, segment, int(offset)))
|
|
365
|
+
if conn.total_changes > before:
|
|
366
|
+
continue
|
|
367
|
+
prior = conn.execute(
|
|
368
|
+
"SELECT account_key FROM claude_cost_decisions "
|
|
369
|
+
"WHERE decision_key=?", (key,)).fetchone()
|
|
370
|
+
if prior is not None and prior[0] != account:
|
|
371
|
+
conflicts += 1
|
|
372
|
+
return conflicts
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _decision_payload(rec):
|
|
376
|
+
if rec.get("t") != "op":
|
|
377
|
+
return None
|
|
378
|
+
payload = rec.get("payload")
|
|
379
|
+
if not isinstance(payload, dict) or payload.get(
|
|
380
|
+
"kind") != _lib_journal.CLAUDE_COST_ACCOUNT_KIND:
|
|
381
|
+
return None
|
|
382
|
+
try:
|
|
383
|
+
_lib_journal.validate_claude_cost_account(payload)
|
|
384
|
+
except ValueError:
|
|
385
|
+
return None
|
|
386
|
+
return payload
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def rehydrate_claude_attribution(conn, *, authoritative: bool
|
|
390
|
+
) -> "tuple | None":
|
|
391
|
+
"""Replay the journal suffix past the Claude attribution cursor into
|
|
392
|
+
``claude_cost_decisions`` and ``claude_account_timeline``, in ONE
|
|
393
|
+
traversal. Returns the safe high-water the replay consumed --
|
|
394
|
+
``(segment, offset)``, or the current segment at offset 0 when no journal
|
|
395
|
+
exists -- which every decision the caller then appends records as its
|
|
396
|
+
``journal_hw`` (spec 5.4: the safe high-water the rule ran against, not
|
|
397
|
+
one sampled later, past another writer's appends). Returns None when the
|
|
398
|
+
journal could not be read (the caller rolls back and defers).
|
|
399
|
+
|
|
400
|
+
``authoritative`` clears both tables and replays from the journal start
|
|
401
|
+
(an explicit rebuild, the truncation escalation, a recreated cache). An
|
|
402
|
+
absent or invalid cursor, or one written under another rule version or
|
|
403
|
+
admission boundary, forces the same. Admission (``classify_admission``)
|
|
404
|
+
runs before interpretation. A malformed decision payload is skipped, never
|
|
405
|
+
applied. Conflicts -- a decision contradicting the first one recorded for
|
|
406
|
+
its key -- are a pure function of the journal: an authoritative replay
|
|
407
|
+
recounts them from zero, an incremental one adds to the count.
|
|
408
|
+
|
|
409
|
+
Runs on the caller's transaction and never commits; the cursor and the
|
|
410
|
+
rows it describes therefore commit together."""
|
|
411
|
+
import _cctally_journal as jr
|
|
412
|
+
try:
|
|
413
|
+
hw = jr.journal_safe_high_water()
|
|
414
|
+
boundary = None if hw is None else jr.find_account_bound_cutover()
|
|
415
|
+
except (OSError, jr.JournalError):
|
|
416
|
+
return None
|
|
417
|
+
fingerprint = _admission_fingerprint(boundary)
|
|
418
|
+
cursor = _stored_cursor(conn, fingerprint)
|
|
419
|
+
if cursor is None:
|
|
420
|
+
authoritative = True
|
|
421
|
+
if hw is None:
|
|
422
|
+
if authoritative:
|
|
423
|
+
# No journal: the truth is that nothing has been decided, and a
|
|
424
|
+
# cursor must never outlive the rows it describes.
|
|
425
|
+
_clear_derived(conn)
|
|
426
|
+
conn.execute("DELETE FROM cache_meta WHERE key=?", (CURSOR_KEY,))
|
|
427
|
+
conn.execute("DELETE FROM cache_meta WHERE key=?",
|
|
428
|
+
(CONFLICTS_KEY,))
|
|
429
|
+
return _empty_journal_high_water()
|
|
430
|
+
if authoritative:
|
|
431
|
+
_clear_derived(conn)
|
|
432
|
+
cursor = None
|
|
433
|
+
segments = jr.list_segments()
|
|
434
|
+
conflicts = 0
|
|
435
|
+
last_conflict_at = None
|
|
436
|
+
try:
|
|
437
|
+
for segment, offset, raw in jr.iter_range(cursor, hw, segments):
|
|
438
|
+
decision_hit = _DECISION_MARKER in raw
|
|
439
|
+
obs_hit = not decision_hit and all(
|
|
440
|
+
marker in raw for marker in _OBS_MARKERS)
|
|
441
|
+
if not (decision_hit or obs_hit):
|
|
442
|
+
continue
|
|
443
|
+
rec = _lib_journal.decode_line(raw)
|
|
444
|
+
if rec is None:
|
|
445
|
+
continue
|
|
446
|
+
if boundary is not None and not (
|
|
447
|
+
_lib_account_protocol.classify_admission(
|
|
448
|
+
rec, _lib_account_protocol.Coordinate(
|
|
449
|
+
segment, int(offset)), boundary).admitted):
|
|
450
|
+
continue
|
|
451
|
+
if decision_hit:
|
|
452
|
+
payload = _decision_payload(rec)
|
|
453
|
+
if payload is not None:
|
|
454
|
+
found = _insert_decision_items(
|
|
455
|
+
conn, payload, segment, offset)
|
|
456
|
+
if found:
|
|
457
|
+
conflicts += found
|
|
458
|
+
last_conflict_at = payload["decided_at"]
|
|
459
|
+
continue
|
|
460
|
+
if _is_claude_usage_obs(rec):
|
|
461
|
+
_apply_observation(conn, rec, segment, offset)
|
|
462
|
+
except OSError:
|
|
463
|
+
return None
|
|
464
|
+
if authoritative or cursor != (str(hw[0]), int(hw[1])):
|
|
465
|
+
_store_cursor(conn, hw, fingerprint)
|
|
466
|
+
if authoritative:
|
|
467
|
+
_meta_set(conn, CONFLICTS_KEY,
|
|
468
|
+
{"count": conflicts, "last_at": last_conflict_at})
|
|
469
|
+
elif conflicts:
|
|
470
|
+
_bump_counter(conn, CONFLICTS_KEY, conflicts, last_conflict_at)
|
|
471
|
+
return (str(hw[0]), int(hw[1]))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def decision_conflicts(conn) -> int:
|
|
475
|
+
doc = _meta_json(conn, CONFLICTS_KEY)
|
|
476
|
+
if isinstance(doc, dict) and type(doc.get("count")) is int:
|
|
477
|
+
return doc["count"]
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# --------------------------------------------------------------------------
|
|
482
|
+
# timeline neighbours
|
|
483
|
+
# --------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
def _neighbours_with_evidence(conn, rec):
|
|
486
|
+
rec_lo, rec_hi = int(rec[0]), int(rec[1])
|
|
487
|
+
groups = {}
|
|
488
|
+
|
|
489
|
+
def add(row):
|
|
490
|
+
captured, precision, account, segment, offset = row
|
|
491
|
+
lo = K.us_from_iso(captured)
|
|
492
|
+
key = (lo, int(precision))
|
|
493
|
+
entry = groups.setdefault(key, [set(), []])
|
|
494
|
+
entry[0].add(account)
|
|
495
|
+
entry[1].append([captured, segment, int(offset)])
|
|
496
|
+
return lo, lo + int(precision)
|
|
497
|
+
|
|
498
|
+
best = None # (hi, lo, precision) of the latest instant before R
|
|
499
|
+
for row in conn.execute(
|
|
500
|
+
"SELECT captured_at_utc, precision_us, account_key,"
|
|
501
|
+
" journal_segment, journal_offset FROM claude_account_timeline"
|
|
502
|
+
" WHERE captured_at_utc < ? ORDER BY captured_at_utc DESC",
|
|
503
|
+
(K.iso_from_us(rec_lo),)):
|
|
504
|
+
lo = K.us_from_iso(row[0])
|
|
505
|
+
if best is not None and lo + _MAX_PRECISION_US < best[0]:
|
|
506
|
+
# No instant this early can end later than the latest one found,
|
|
507
|
+
# nor reach into R.
|
|
508
|
+
break
|
|
509
|
+
lo, hi = add(row)
|
|
510
|
+
if hi <= rec_lo and (best is None or (hi, lo) > best[:2]):
|
|
511
|
+
best = (hi, lo, hi - lo)
|
|
512
|
+
after = None
|
|
513
|
+
for row in conn.execute(
|
|
514
|
+
"SELECT captured_at_utc, precision_us, account_key,"
|
|
515
|
+
" journal_segment, journal_offset FROM claude_account_timeline"
|
|
516
|
+
" WHERE captured_at_utc >= ? ORDER BY captured_at_utc ASC",
|
|
517
|
+
(K.iso_from_us(rec_lo),)):
|
|
518
|
+
lo = K.us_from_iso(row[0])
|
|
519
|
+
if after is not None and lo > after[0]:
|
|
520
|
+
break
|
|
521
|
+
lo, hi = add(row)
|
|
522
|
+
if lo >= rec_hi:
|
|
523
|
+
candidate = (lo, hi - lo)
|
|
524
|
+
if after is None or candidate < after:
|
|
525
|
+
after = candidate
|
|
526
|
+
|
|
527
|
+
def instant(key):
|
|
528
|
+
accounts, _evidence = groups[key]
|
|
529
|
+
return K.Instant(key[0], key[1], frozenset(accounts))
|
|
530
|
+
|
|
531
|
+
before = None if best is None else instant((best[1], best[2]))
|
|
532
|
+
overlapping = tuple(
|
|
533
|
+
instant(key) for key in sorted(groups)
|
|
534
|
+
if K.relation((key[0], key[0] + key[1]), (rec_lo, rec_hi))
|
|
535
|
+
== "overlapping")
|
|
536
|
+
after_instant = None if after is None else instant(after)
|
|
537
|
+
evidence = {key: value[1] for key, value in groups.items()}
|
|
538
|
+
return K.Neighbours(before, overlapping, after_instant), evidence
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def timeline_neighbours(conn, rec) -> K.Neighbours:
|
|
542
|
+
"""``Pb``, ``Ov`` and ``Sa`` of a record interval ``rec`` by indexed
|
|
543
|
+
seeks around it (spec 5.2). The timeline is never loaded whole: the scan
|
|
544
|
+
before ``R`` stops once no earlier instant can end later than the latest
|
|
545
|
+
one found, and the scan after stops past the earliest instant after
|
|
546
|
+
``R``."""
|
|
547
|
+
return _neighbours_with_evidence(conn, rec)[0]
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _supporting(evidence, neighbours) -> list:
|
|
551
|
+
out = []
|
|
552
|
+
instants = [neighbours.before, *neighbours.overlapping, neighbours.after]
|
|
553
|
+
for instant in instants:
|
|
554
|
+
if instant is None:
|
|
555
|
+
continue
|
|
556
|
+
out.extend(evidence.get((instant.lo_us, instant.precision_us), ()))
|
|
557
|
+
return out
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def neighbour_evidence(conn, rec, neighbours) -> list:
|
|
561
|
+
"""The ``[captured_at, segment, offset]`` entries supporting every instant
|
|
562
|
+
of ``neighbours`` (an agreement's evidence)."""
|
|
563
|
+
_found, evidence = _neighbours_with_evidence(conn, rec)
|
|
564
|
+
return _supporting(evidence, neighbours)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def timeline_neighbours_with_evidence(conn, rec):
|
|
568
|
+
"""``(neighbours, evidence)`` of ``rec`` from ONE pair of seeks: the
|
|
569
|
+
``timeline_neighbours`` result and the supporting entries of every
|
|
570
|
+
instant it names (what an agreement journals)."""
|
|
571
|
+
found, evidence = _neighbours_with_evidence(conn, rec)
|
|
572
|
+
return found, _supporting(evidence, found)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# --------------------------------------------------------------------------
|
|
576
|
+
# lookup
|
|
577
|
+
# --------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
_DECISION_COLUMNS = ("decision_key, account_key, basis, reason,"
|
|
580
|
+
" decided_at_utc, journal_segment, journal_offset")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _stored(row) -> StoredDecision:
|
|
584
|
+
return StoredDecision(key=row[0], account_key=row[1], basis=row[2],
|
|
585
|
+
reason=row[3], decided_at_utc=row[4],
|
|
586
|
+
journal_pos=(row[5], int(row[6])))
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def lookup_decision(conn, identity: RecordIdentity) -> "StoredDecision | None":
|
|
590
|
+
"""The decision ``identity`` holds, in the spec 5.3 lookup order: the
|
|
591
|
+
keyed form, otherwise the physical form (exact raw bytes), then a legacy
|
|
592
|
+
decision at the same ``(loc, offset)`` whose projection matches -- an
|
|
593
|
+
exact match before a recorded-unknown-split one, then earliest journal
|
|
594
|
+
position."""
|
|
595
|
+
if identity.form in (K.FORM_KEYED, K.FORM_PHYSICAL):
|
|
596
|
+
row = conn.execute(
|
|
597
|
+
f"SELECT {_DECISION_COLUMNS} FROM claude_cost_decisions "
|
|
598
|
+
"WHERE decision_key=?", (identity.key,)).fetchone()
|
|
599
|
+
if row is not None:
|
|
600
|
+
return _stored(row)
|
|
601
|
+
if identity.form == K.FORM_KEYED:
|
|
602
|
+
return None
|
|
603
|
+
if identity.projection is None or identity.loc is None:
|
|
604
|
+
return None
|
|
605
|
+
candidates = []
|
|
606
|
+
for row in conn.execute(
|
|
607
|
+
f"SELECT {_DECISION_COLUMNS}, projection_json FROM "
|
|
608
|
+
"claude_cost_decisions WHERE loc=? AND byte_offset=? AND form=?",
|
|
609
|
+
(identity.loc, int(identity.offset), K.FORM_LEGACY)):
|
|
610
|
+
try:
|
|
611
|
+
projection = json.loads(row[7])
|
|
612
|
+
except (TypeError, ValueError):
|
|
613
|
+
continue
|
|
614
|
+
kind = K.legacy_match(projection, list(identity.projection))
|
|
615
|
+
candidates.append((kind, (row[5], int(row[6])), row))
|
|
616
|
+
ordered = K.order_legacy_candidates(candidates)
|
|
617
|
+
return _stored(ordered[0][2]) if ordered else None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# --------------------------------------------------------------------------
|
|
621
|
+
# append
|
|
622
|
+
# --------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
def _item_for(identity: RecordIdentity, basis: str, ts):
|
|
625
|
+
if identity.form == K.FORM_KEYED:
|
|
626
|
+
if basis == K.BASIS_PRESERVED:
|
|
627
|
+
return ["k1", identity.msg_id, identity.req_id]
|
|
628
|
+
return ["k1", identity.msg_id, identity.req_id, ts]
|
|
629
|
+
if identity.form == K.FORM_PHYSICAL:
|
|
630
|
+
return ["p1", identity.loc, int(identity.offset), identity.digest, ts]
|
|
631
|
+
return ["l1", identity.loc, int(identity.offset), identity.digest,
|
|
632
|
+
list(identity.projection)]
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _capture_doc(capture: "K.ClosingCapture | None"):
|
|
636
|
+
if capture is None:
|
|
637
|
+
return None
|
|
638
|
+
doc = {"at": K.iso_from_us(capture.at_us)}
|
|
639
|
+
if capture.account_key is not None:
|
|
640
|
+
doc["account_key"] = capture.account_key
|
|
641
|
+
return doc
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def safe_high_water():
|
|
645
|
+
"""The journal's safe high-water, or the current segment at offset 0 when
|
|
646
|
+
no journal exists yet; ``AttributionJournalUnavailable`` when unreadable."""
|
|
647
|
+
import _cctally_journal as jr
|
|
648
|
+
try:
|
|
649
|
+
hw = jr.journal_safe_high_water()
|
|
650
|
+
except OSError as exc:
|
|
651
|
+
raise AttributionJournalUnavailable(
|
|
652
|
+
f"the journal could not be read: {exc}") from exc
|
|
653
|
+
if hw is None:
|
|
654
|
+
return _empty_journal_high_water()
|
|
655
|
+
return (str(hw[0]), int(hw[1]))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _empty_journal_high_water():
|
|
659
|
+
"""The high-water of a journal that does not exist yet: the current
|
|
660
|
+
segment at offset 0."""
|
|
661
|
+
return (_lib_journal.segment_name(dt.datetime.now(dt.timezone.utc)), 0)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _encoded_len(record) -> int:
|
|
665
|
+
return len(_lib_journal.encode_line(_lib_journal.prepare_record(record)))
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _json_len(value) -> int:
|
|
669
|
+
"""The UTF-8 length of ``value`` in the journal's canonical encoding,
|
|
670
|
+
which is also its length as an element of any list in a journal line."""
|
|
671
|
+
return len(_lib_journal._canonical_json(value).encode("utf-8"))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
#: What a line gains when its payload's ``evidence`` goes from absent to
|
|
675
|
+
#: present, beyond its entries and one comma each: ``,"evidence":[]`` less
|
|
676
|
+
#: the one comma the entries over-count (n entries need n-1 separators).
|
|
677
|
+
_EVIDENCE_KEY_LEN = len(',"evidence":[]') - 1
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _build_batches(groups, *, decided_at, journal_hw, closing_capture,
|
|
681
|
+
evidence):
|
|
682
|
+
"""One op per homogeneous group, split so every line stays under the
|
|
683
|
+
budget. Returns ``[(record, [(identity, item), ...]), ...]``.
|
|
684
|
+
|
|
685
|
+
The partition is the greedy rule: members are added in order, and one
|
|
686
|
+
that would take the encoded line over ``_BATCH_LINE_BUDGET`` starts the
|
|
687
|
+
next batch (a lone member is always emitted, even over budget). An
|
|
688
|
+
agreement batch carries the deduplicated union of its members' evidence
|
|
689
|
+
in first-seen order; other bases carry none.
|
|
690
|
+
|
|
691
|
+
Linear: each batch's line length is measured once from a one-item
|
|
692
|
+
skeleton and then grown by exact arithmetic (canonical JSON appends an
|
|
693
|
+
element as its own encoding plus one comma), so validation runs once per
|
|
694
|
+
emitted batch. Backstop: a built line whose length differs from the
|
|
695
|
+
prediction, or that is over budget with more than one member, makes the
|
|
696
|
+
group fall back to the exact greedy rebuild, so the budget can never be
|
|
697
|
+
exceeded silently and the output stays the greedy rule's."""
|
|
698
|
+
out = []
|
|
699
|
+
for (basis, reason, account, uses_capture), members in groups.items():
|
|
700
|
+
capture = _capture_doc(closing_capture) if uses_capture else None
|
|
701
|
+
agreement = basis == K.BASIS_AGREEMENT
|
|
702
|
+
|
|
703
|
+
def build(chunk, factory=_lib_journal.make_claude_cost_account):
|
|
704
|
+
chunk_evidence = []
|
|
705
|
+
seen = set()
|
|
706
|
+
for _identity, _item, item_evidence in chunk:
|
|
707
|
+
for entry in item_evidence:
|
|
708
|
+
marker = tuple(entry)
|
|
709
|
+
if marker not in seen:
|
|
710
|
+
seen.add(marker)
|
|
711
|
+
chunk_evidence.append(list(entry))
|
|
712
|
+
return factory(
|
|
713
|
+
basis=basis, reason=reason, account_key=account,
|
|
714
|
+
decided_at=decided_at, journal_hw=journal_hw,
|
|
715
|
+
closing_capture=capture,
|
|
716
|
+
evidence=chunk_evidence if agreement else [],
|
|
717
|
+
items=[item for _identity, item, _e in chunk])
|
|
718
|
+
|
|
719
|
+
group_out = _linear_batches(members, build, agreement)
|
|
720
|
+
if group_out is None:
|
|
721
|
+
group_out = _greedy_batches(members, build)
|
|
722
|
+
out.extend(group_out)
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _linear_batches(members, build, agreement):
|
|
727
|
+
"""The greedy partition of one group, sized by arithmetic; ``None`` when
|
|
728
|
+
a built line disagrees with its predicted length (the backstop)."""
|
|
729
|
+
budget = _BATCH_LINE_BUDGET
|
|
730
|
+
out = []
|
|
731
|
+
chunk, seen, length = [], set(), 0
|
|
732
|
+
|
|
733
|
+
def emit():
|
|
734
|
+
record = build(chunk)
|
|
735
|
+
actual = _encoded_len(record)
|
|
736
|
+
if actual != length or (len(chunk) > 1 and actual > budget):
|
|
737
|
+
return False
|
|
738
|
+
out.append((record, chunk))
|
|
739
|
+
return True
|
|
740
|
+
|
|
741
|
+
for member in members:
|
|
742
|
+
item_evidence = member[2] if agreement else ()
|
|
743
|
+
if chunk:
|
|
744
|
+
fresh, fresh_seen = [], set()
|
|
745
|
+
for entry in item_evidence:
|
|
746
|
+
marker = tuple(entry)
|
|
747
|
+
if marker not in seen and marker not in fresh_seen:
|
|
748
|
+
fresh_seen.add(marker)
|
|
749
|
+
fresh.append(entry)
|
|
750
|
+
grown = length + _json_len(member[1]) + 1
|
|
751
|
+
if fresh:
|
|
752
|
+
grown += sum(_json_len(list(entry)) + 1 for entry in fresh)
|
|
753
|
+
if not seen:
|
|
754
|
+
grown += _EVIDENCE_KEY_LEN
|
|
755
|
+
if grown <= budget:
|
|
756
|
+
chunk.append(member)
|
|
757
|
+
seen |= fresh_seen
|
|
758
|
+
length = grown
|
|
759
|
+
continue
|
|
760
|
+
if not emit():
|
|
761
|
+
return None
|
|
762
|
+
chunk = [member]
|
|
763
|
+
seen = {tuple(entry) for entry in item_evidence}
|
|
764
|
+
length = _encoded_len(build(
|
|
765
|
+
chunk, _lib_journal.claude_cost_account_op_unvalidated))
|
|
766
|
+
if chunk and not emit():
|
|
767
|
+
return None
|
|
768
|
+
return out
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _greedy_batches(members, build):
|
|
772
|
+
"""The exact greedy partition of one group, rebuilding the growing batch
|
|
773
|
+
per member (quadratic); only the backstop of ``_linear_batches``."""
|
|
774
|
+
out = []
|
|
775
|
+
chunk = []
|
|
776
|
+
for member in members:
|
|
777
|
+
chunk.append(member)
|
|
778
|
+
if len(chunk) > 1 and _encoded_len(build(chunk)) > (
|
|
779
|
+
_BATCH_LINE_BUDGET):
|
|
780
|
+
chunk.pop()
|
|
781
|
+
out.append((build(chunk), chunk))
|
|
782
|
+
chunk = [member]
|
|
783
|
+
if chunk:
|
|
784
|
+
out.append((build(chunk), chunk))
|
|
785
|
+
return out
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def append_decisions(conn, decisions: Sequence, *, journal_hw,
|
|
789
|
+
closing_capture: "K.ClosingCapture | None",
|
|
790
|
+
evidence: list) -> list:
|
|
791
|
+
"""Make ``decisions`` durable, journal first: append their
|
|
792
|
+
``claude_cost_account`` batches in ONE ``append_records`` call (fsync),
|
|
793
|
+
then insert them into ``claude_cost_decisions`` on the caller's open
|
|
794
|
+
transaction. Returns the ``StoredDecision`` each identity now holds, in
|
|
795
|
+
input order (first-wins: an existing decision for the same key is kept).
|
|
796
|
+
|
|
797
|
+
Each element is ``(identity, decision, evaluated_timestamp)`` or
|
|
798
|
+
``(identity, decision, evaluated_timestamp, item_evidence)``; the
|
|
799
|
+
timestamp is ``None`` for ``preserved_history``. ``evidence`` is the
|
|
800
|
+
supporting-observation list of every agreement lacking its own;
|
|
801
|
+
``closing_capture`` applies to decisions that used it. Batches group by
|
|
802
|
+
basis, reason, account and closing capture.
|
|
803
|
+
|
|
804
|
+
Raises ``AttributionJournalUnavailable`` when the append fails; nothing
|
|
805
|
+
is inserted then."""
|
|
806
|
+
if not decisions:
|
|
807
|
+
return []
|
|
808
|
+
import _cctally_journal as jr
|
|
809
|
+
decided_at = _now_iso()
|
|
810
|
+
groups = {}
|
|
811
|
+
order = []
|
|
812
|
+
for element in decisions:
|
|
813
|
+
identity, decision, ts = element[0], element[1], element[2]
|
|
814
|
+
item_evidence = element[3] if len(element) > 3 else evidence
|
|
815
|
+
if decision.basis != K.BASIS_AGREEMENT:
|
|
816
|
+
item_evidence = ()
|
|
817
|
+
group = (decision.basis, decision.reason, decision.account_key,
|
|
818
|
+
bool(decision.used_closing_capture))
|
|
819
|
+
groups.setdefault(group, []).append(
|
|
820
|
+
(identity, _item_for(identity, decision.basis, ts),
|
|
821
|
+
tuple(tuple(e) for e in (item_evidence or ()))))
|
|
822
|
+
order.append(identity.key)
|
|
823
|
+
hw = (str(journal_hw[0]), int(journal_hw[1]))
|
|
824
|
+
batches = _build_batches(groups, decided_at=decided_at, journal_hw=hw,
|
|
825
|
+
closing_capture=closing_capture,
|
|
826
|
+
evidence=evidence)
|
|
827
|
+
records = [_lib_journal.prepare_record(record) for record, _m in batches]
|
|
828
|
+
lengths = [len(_lib_journal.encode_line(record)) for record in records]
|
|
829
|
+
try:
|
|
830
|
+
segment, end = jr.append_records(records)
|
|
831
|
+
except (OSError, jr.JournalError) as exc:
|
|
832
|
+
raise AttributionJournalUnavailable(
|
|
833
|
+
f"the journal could not be written: {exc}") from exc
|
|
834
|
+
start = end - sum(lengths)
|
|
835
|
+
conflicts = 0
|
|
836
|
+
for (record, _members), length in zip(batches, lengths):
|
|
837
|
+
conflicts += _insert_decision_items(
|
|
838
|
+
conn, record["payload"], segment, start)
|
|
839
|
+
start += length
|
|
840
|
+
if conflicts:
|
|
841
|
+
_bump_counter(conn, CONFLICTS_KEY, conflicts, decided_at)
|
|
842
|
+
stored = {}
|
|
843
|
+
for key in order:
|
|
844
|
+
if key in stored:
|
|
845
|
+
continue
|
|
846
|
+
row = conn.execute(
|
|
847
|
+
f"SELECT {_DECISION_COLUMNS} FROM claude_cost_decisions "
|
|
848
|
+
"WHERE decision_key=?", (key,)).fetchone()
|
|
849
|
+
stored[key] = _stored(row)
|
|
850
|
+
return [stored[key] for key in order]
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
# --------------------------------------------------------------------------
|
|
854
|
+
# alignment and preservation
|
|
855
|
+
# --------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
def _bump_mutation_seq(conn) -> int:
|
|
858
|
+
"""The ``session_entries`` mutation counter, advanced exactly as an
|
|
859
|
+
in-place upsert advances it (``_cctally_cache._bump_mutation_seq``)."""
|
|
860
|
+
row = conn.execute(
|
|
861
|
+
"INSERT INTO cache_meta(key, value) VALUES (?, '1') "
|
|
862
|
+
"ON CONFLICT(key) DO UPDATE SET "
|
|
863
|
+
" value = CAST(cache_meta.value AS INTEGER) + 1 "
|
|
864
|
+
"RETURNING value", (MUTATION_SEQ_KEY,)).fetchone()
|
|
865
|
+
return int(row[0])
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def align_row(conn, row_id: int, decision: StoredDecision) -> bool:
|
|
869
|
+
"""Link an unlinked accounting row to the decision its identity already
|
|
870
|
+
holds, setting the row's account to the decision's (spec 5.4). An
|
|
871
|
+
account change sets the row's mutation signal and advances the mutation
|
|
872
|
+
counter exactly as a successful in-place upsert does, and is counted in
|
|
873
|
+
``claude_cost_aligned_rows``; a link alone changes no cost and no
|
|
874
|
+
account, so it advances nothing. Returns whether the account changed.
|
|
875
|
+
|
|
876
|
+
Accounts compare under the read rule (``K.preserved_stamp``): a literal
|
|
877
|
+
``unattributed`` or empty stamp already is the NULL account a decision
|
|
878
|
+
carries, so its row keeps its bytes. Rewriting them would change no
|
|
879
|
+
account but would drift a reviewed rederive's cache fingerprint."""
|
|
880
|
+
row = conn.execute(
|
|
881
|
+
"SELECT account_key FROM session_entries WHERE id=?",
|
|
882
|
+
(int(row_id),)).fetchone()
|
|
883
|
+
if row is None:
|
|
884
|
+
return False
|
|
885
|
+
changed = K.preserved_stamp(row[0]) != decision.account_key
|
|
886
|
+
if changed:
|
|
887
|
+
seq = _bump_mutation_seq(conn)
|
|
888
|
+
conn.execute(
|
|
889
|
+
"UPDATE session_entries SET account_key=?,"
|
|
890
|
+
" attribution_decision_key=?, mutation_seq=?,"
|
|
891
|
+
" mutation_min_ts=MIN(COALESCE(mutation_min_ts, timestamp_utc),"
|
|
892
|
+
" timestamp_utc) WHERE id=?",
|
|
893
|
+
(decision.account_key, decision.key, seq, int(row_id)))
|
|
894
|
+
_bump_counter(conn, ALIGNED_KEY, 1, _now_iso())
|
|
895
|
+
else:
|
|
896
|
+
conn.execute(
|
|
897
|
+
"UPDATE session_entries SET attribution_decision_key=? WHERE id=?",
|
|
898
|
+
(decision.key, int(row_id)))
|
|
899
|
+
return changed
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _unlinked_rows(conn, *, source_paths, after_id, limit):
|
|
903
|
+
sql = (f"SELECT id, source_path, line_offset, msg_id, req_id, account_key,"
|
|
904
|
+
f" {_PROJECTION_COLUMNS} FROM session_entries "
|
|
905
|
+
"WHERE attribution_decision_key IS NULL AND id > ?")
|
|
906
|
+
params = [int(after_id)]
|
|
907
|
+
if source_paths is not None:
|
|
908
|
+
sql += " AND source_path IN (%s)" % ",".join("?" * len(source_paths))
|
|
909
|
+
params.extend(source_paths)
|
|
910
|
+
sql += " ORDER BY id LIMIT ?"
|
|
911
|
+
params.append(int(limit))
|
|
912
|
+
cursor = conn.execute(sql, params)
|
|
913
|
+
names = [d[0] for d in cursor.description]
|
|
914
|
+
return [dict(zip(names, row)) for row in cursor.fetchall()]
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _unlinked_remaining(conn, *, source_paths, after_id) -> bool:
|
|
918
|
+
sql = ("SELECT 1 FROM session_entries WHERE attribution_decision_key "
|
|
919
|
+
"IS NULL AND id > ?")
|
|
920
|
+
params = [int(after_id)]
|
|
921
|
+
if source_paths is not None:
|
|
922
|
+
sql += " AND source_path IN (%s)" % ",".join("?" * len(source_paths))
|
|
923
|
+
params.extend(source_paths)
|
|
924
|
+
return conn.execute(sql + " LIMIT 1", params).fetchone() is not None
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def preserve_unlinked_rows(conn, *, journal_hw,
|
|
928
|
+
source_paths: "Sequence[str] | None" = None,
|
|
929
|
+
after_id: int = 0, limit: "int | None" = None
|
|
930
|
+
) -> PreserveResult:
|
|
931
|
+
"""Link every unlinked ``session_entries`` row (``id > after_id``, at
|
|
932
|
+
most ``limit``, optionally only those of ``source_paths``) in ``id``
|
|
933
|
+
order: a row whose identity already holds a decision is aligned to it;
|
|
934
|
+
every other row's CURRENT stamp (NULL included) is exported as a
|
|
935
|
+
``preserved_history`` decision -- keyed form for a row with both ids,
|
|
936
|
+
legacy projection form otherwise -- appended (fsync) before its link is
|
|
937
|
+
set. Never consults the kernel and never reads transcript bytes.
|
|
938
|
+
``journal_hw`` is the high-water the caller's preceding replay
|
|
939
|
+
(``rehydrate_claude_attribution``) consumed; every export records it.
|
|
940
|
+
|
|
941
|
+
Runs on the caller's transaction and never commits, so the caller
|
|
942
|
+
commits the links with whatever progress it records. Raises
|
|
943
|
+
``AttributionJournalUnavailable`` when an append fails; the caller rolls
|
|
944
|
+
back, and decisions already appended are recovered by the next replay."""
|
|
945
|
+
if source_paths is not None:
|
|
946
|
+
source_paths = list(source_paths)
|
|
947
|
+
if not source_paths:
|
|
948
|
+
return PreserveResult(0, 0, 0, int(after_id), False)
|
|
949
|
+
saved = aligned = exported = 0
|
|
950
|
+
last_id = int(after_id)
|
|
951
|
+
budget = None if limit is None else int(limit)
|
|
952
|
+
hw = (str(journal_hw[0]), int(journal_hw[1]))
|
|
953
|
+
while budget is None or budget > 0:
|
|
954
|
+
size = _PRESERVE_CHUNK if budget is None else min(
|
|
955
|
+
budget, _PRESERVE_CHUNK)
|
|
956
|
+
rows = _unlinked_rows(conn, source_paths=source_paths,
|
|
957
|
+
after_id=last_id, limit=size)
|
|
958
|
+
if not rows:
|
|
959
|
+
break
|
|
960
|
+
to_export = []
|
|
961
|
+
for row in rows:
|
|
962
|
+
identity = row_identity(row)
|
|
963
|
+
found = lookup_decision(conn, identity)
|
|
964
|
+
if found is not None:
|
|
965
|
+
align_row(conn, row["id"], found)
|
|
966
|
+
aligned += 1
|
|
967
|
+
else:
|
|
968
|
+
to_export.append((row, identity))
|
|
969
|
+
if to_export:
|
|
970
|
+
# One identity can appear once per chunk at most: every identity
|
|
971
|
+
# names at most one accounting row (spec 5.4).
|
|
972
|
+
# A literal ``unattributed`` stamp (read paths treat it as NULL),
|
|
973
|
+
# an empty or a non-string stamp is preserved as NULL: a decision
|
|
974
|
+
# never carries any of them. Its row keeps its bytes (align_row).
|
|
975
|
+
stored = append_decisions(
|
|
976
|
+
conn,
|
|
977
|
+
[(identity, K.Decision(
|
|
978
|
+
K.preserved_stamp(row["account_key"]),
|
|
979
|
+
K.BASIS_PRESERVED, None, False), None)
|
|
980
|
+
for row, identity in to_export],
|
|
981
|
+
journal_hw=hw, closing_capture=None, evidence=[])
|
|
982
|
+
for (row, _identity), decision in zip(to_export, stored):
|
|
983
|
+
if decision.account_key == K.preserved_stamp(
|
|
984
|
+
row["account_key"]):
|
|
985
|
+
conn.execute(
|
|
986
|
+
"UPDATE session_entries SET attribution_decision_key=?"
|
|
987
|
+
" WHERE id=?", (decision.key, row["id"]))
|
|
988
|
+
else:
|
|
989
|
+
align_row(conn, row["id"], decision)
|
|
990
|
+
exported += len(to_export)
|
|
991
|
+
saved += len(rows)
|
|
992
|
+
last_id = int(rows[-1]["id"])
|
|
993
|
+
if budget is not None:
|
|
994
|
+
budget -= len(rows)
|
|
995
|
+
remaining = _unlinked_remaining(conn, source_paths=source_paths,
|
|
996
|
+
after_id=last_id)
|
|
997
|
+
return PreserveResult(saved, aligned, exported, last_id, remaining)
|