cctally 1.82.0 → 1.83.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 +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"""Scratch rederivation adapter for #372 Task B.
|
|
2
|
+
|
|
3
|
+
This eager sibling replays retained Claude observations and relevant operator
|
|
4
|
+
records through the current ingest hooks on a disposable stats index. Derived
|
|
5
|
+
events are captured in memory; the durable journal, source cache, config,
|
|
6
|
+
projection files, provider state, and alert dispatcher are never mutated.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import copy
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import fcntl
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import signal
|
|
20
|
+
import sqlite3
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import _cctally_core
|
|
27
|
+
import _cctally_journal as _journal
|
|
28
|
+
import _cctally_record as _record
|
|
29
|
+
import _lib_journal
|
|
30
|
+
import _lib_json_envelope
|
|
31
|
+
import _lib_rederive
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RederiveBusy(RuntimeError):
|
|
35
|
+
"""The stable-view/apply lock set could not be acquired in time."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RederiveApplyError(RuntimeError):
|
|
39
|
+
"""An operational apply stage failed after a concrete preview existed."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, stage, preview, batch_id, cause):
|
|
42
|
+
super().__init__(f"{stage} failed: {cause}")
|
|
43
|
+
self.stage = stage
|
|
44
|
+
self.preview = preview
|
|
45
|
+
self.batch_id = batch_id
|
|
46
|
+
self.cause = cause
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class RederivePreview:
|
|
51
|
+
plan: _lib_rederive.RederivePlan
|
|
52
|
+
records: tuple[dict, ...]
|
|
53
|
+
journal_high_water: "tuple[str, int] | None"
|
|
54
|
+
record_ends: tuple[tuple[str, int], ...]
|
|
55
|
+
generated_at: str
|
|
56
|
+
batch_id: "str | None"
|
|
57
|
+
incomplete_batch: bool
|
|
58
|
+
latest_completed_batch: "str | None"
|
|
59
|
+
latest_completed_high_water: "tuple[str, int] | None"
|
|
60
|
+
recovery_required: bool
|
|
61
|
+
# #374: the quarantined same-revision groups this plan will resolve by
|
|
62
|
+
# forcing a revision advance. Additive; empty on a clean journal.
|
|
63
|
+
journal_conflicts: tuple = ()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class RederiveCommandResult:
|
|
68
|
+
preview: RederivePreview
|
|
69
|
+
status: str
|
|
70
|
+
batch_id: "str | None"
|
|
71
|
+
rebuild: "object | None"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_REDERIVE_LOCK_TIMEOUT_SECONDS = 5.0
|
|
75
|
+
_REDERIVE_CRASH_HOOK = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _canonical_bytes(value) -> bytes:
|
|
79
|
+
return json.dumps(
|
|
80
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
81
|
+
).encode("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fingerprint(value) -> str:
|
|
85
|
+
return "sha256:" + hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
|
|
89
|
+
names = {
|
|
90
|
+
row[0] for row in cache_conn.execute(
|
|
91
|
+
"SELECT name FROM sqlite_master WHERE type='table'")
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
name: {
|
|
95
|
+
row[1] for row in cache_conn.execute(f"PRAGMA table_info({name})")
|
|
96
|
+
}
|
|
97
|
+
for name in names
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cache_fingerprint(cache_conn: sqlite3.Connection) -> str:
|
|
102
|
+
"""Hash every cost-bearing Claude cache row and its project metadata."""
|
|
103
|
+
entry_columns = (
|
|
104
|
+
"source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
|
|
105
|
+
"output_tokens", "cache_create_tokens", "cache_read_tokens",
|
|
106
|
+
"cache_create_1h_tokens", "cost_usd_raw", "speed", "account_key",
|
|
107
|
+
)
|
|
108
|
+
file_columns = ("path", "session_id", "project_path")
|
|
109
|
+
entries = [
|
|
110
|
+
list(row) for row in cache_conn.execute(
|
|
111
|
+
"SELECT " + ",".join(entry_columns)
|
|
112
|
+
+ " FROM session_entries ORDER BY source_path, line_offset")
|
|
113
|
+
]
|
|
114
|
+
files = [
|
|
115
|
+
list(row) for row in cache_conn.execute(
|
|
116
|
+
"SELECT " + ",".join(file_columns)
|
|
117
|
+
+ " FROM session_files ORDER BY path")
|
|
118
|
+
]
|
|
119
|
+
return _fingerprint({"sessionEntries": entries, "sessionFiles": files})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_cache_rows(cache_conn: sqlite3.Connection,
|
|
123
|
+
raw_records: list[dict]) -> None:
|
|
124
|
+
missing_metadata = cache_conn.execute(
|
|
125
|
+
"SELECT COUNT(*) FROM session_entries se "
|
|
126
|
+
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
127
|
+
"WHERE sf.path IS NULL"
|
|
128
|
+
).fetchone()[0]
|
|
129
|
+
if missing_metadata:
|
|
130
|
+
raise _lib_rederive.RederiveDataGap(
|
|
131
|
+
"cache.db session_files metadata missing for "
|
|
132
|
+
f"{missing_metadata} session_entries row(s)"
|
|
133
|
+
)
|
|
134
|
+
unknown_split = cache_conn.execute(
|
|
135
|
+
"SELECT COUNT(*) FROM session_entries "
|
|
136
|
+
"WHERE cache_create_tokens > 0 AND cache_create_1h_tokens IS NULL"
|
|
137
|
+
).fetchone()[0]
|
|
138
|
+
if unknown_split:
|
|
139
|
+
raise _lib_rederive.RederiveDataGap(
|
|
140
|
+
"cache.db cache_create_1h_tokens missing for "
|
|
141
|
+
f"{unknown_split} cache-write row(s)"
|
|
142
|
+
)
|
|
143
|
+
import _lib_accounts
|
|
144
|
+
positive_accounts = {
|
|
145
|
+
record.get("account") or _lib_accounts.UNATTRIBUTED
|
|
146
|
+
for record in raw_records
|
|
147
|
+
if record.get("t") == "obs"
|
|
148
|
+
and record.get("provider") == "claude"
|
|
149
|
+
and float((record.get("payload") or {}).get("weekly_percent") or 0) > 0
|
|
150
|
+
}
|
|
151
|
+
for account_key in sorted(positive_accounts):
|
|
152
|
+
if account_key == _lib_accounts.UNATTRIBUTED:
|
|
153
|
+
row = cache_conn.execute(
|
|
154
|
+
"SELECT 1 FROM session_entries "
|
|
155
|
+
"WHERE account_key IS NULL OR account_key = ? LIMIT 1",
|
|
156
|
+
(_lib_accounts.UNATTRIBUTED,),
|
|
157
|
+
).fetchone()
|
|
158
|
+
else:
|
|
159
|
+
row = cache_conn.execute(
|
|
160
|
+
"SELECT 1 FROM session_entries WHERE account_key = ? LIMIT 1",
|
|
161
|
+
(account_key,),
|
|
162
|
+
).fetchone()
|
|
163
|
+
if row is None:
|
|
164
|
+
raise _lib_rederive.RederiveDataGap(
|
|
165
|
+
"cache.db has no Claude session_entries for positive usage "
|
|
166
|
+
f"account {account_key}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _joined_entries(cache_conn, range_start, range_end, *,
|
|
171
|
+
project=None, account_key=None):
|
|
172
|
+
cache = sys.modules["_cctally_cache"]
|
|
173
|
+
start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
|
|
174
|
+
end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
|
|
175
|
+
sql = (
|
|
176
|
+
"SELECT se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
|
|
177
|
+
"se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
|
|
178
|
+
"sf.session_id, sf.project_path, se.cost_usd_raw, se.speed, "
|
|
179
|
+
"se.cache_create_1h_tokens "
|
|
180
|
+
"FROM session_entries se "
|
|
181
|
+
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
182
|
+
"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
|
|
183
|
+
)
|
|
184
|
+
params = [start_iso, end_iso]
|
|
185
|
+
if project is not None:
|
|
186
|
+
escaped = (
|
|
187
|
+
project.replace("\\", r"\\").replace("%", r"\%").replace("_", r"\_")
|
|
188
|
+
)
|
|
189
|
+
sql += r" AND se.source_path LIKE ? ESCAPE '\'"
|
|
190
|
+
params.append(f"%/projects/{escaped}/%")
|
|
191
|
+
if account_key is not None:
|
|
192
|
+
import _lib_accounts
|
|
193
|
+
if account_key == _lib_accounts.UNATTRIBUTED:
|
|
194
|
+
sql += " AND (se.account_key IS NULL OR se.account_key = ?)"
|
|
195
|
+
params.append(_lib_accounts.UNATTRIBUTED)
|
|
196
|
+
else:
|
|
197
|
+
sql += " AND se.account_key = ?"
|
|
198
|
+
params.append(account_key)
|
|
199
|
+
sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
|
|
200
|
+
out = []
|
|
201
|
+
for row in cache_conn.execute(sql, params):
|
|
202
|
+
out.append(cache._JoinedClaudeEntry(
|
|
203
|
+
timestamp=dt.datetime.fromisoformat(row[0]),
|
|
204
|
+
model=row[1],
|
|
205
|
+
input_tokens=int(row[2] or 0),
|
|
206
|
+
output_tokens=int(row[3] or 0),
|
|
207
|
+
cache_creation_tokens=int(row[4] or 0),
|
|
208
|
+
cache_read_tokens=int(row[5] or 0),
|
|
209
|
+
source_path=row[6],
|
|
210
|
+
session_id=row[7],
|
|
211
|
+
project_path=row[8],
|
|
212
|
+
cost_usd=row[9],
|
|
213
|
+
usage_extra=({"speed": row[10]} if row[10] else None),
|
|
214
|
+
cache_1h_tokens=(None if row[11] is None else int(row[11])),
|
|
215
|
+
))
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@contextlib.contextmanager
|
|
220
|
+
def _scratch_read_adapters(cache_conn: sqlite3.Connection):
|
|
221
|
+
"""Temporarily route current cost/config readers to stable supplied inputs."""
|
|
222
|
+
cctally = sys.modules["cctally"]
|
|
223
|
+
cache = sys.modules["_cctally_cache"]
|
|
224
|
+
replacements = {
|
|
225
|
+
"get_entries": lambda start, end, *, project=None, skip_sync=False,
|
|
226
|
+
account_key=None: cache.iter_entries(
|
|
227
|
+
cache_conn, start, end, project=project, account_key=account_key),
|
|
228
|
+
"get_claude_session_entries": (
|
|
229
|
+
lambda start, end, *, project=None, skip_sync=False, account_key=None:
|
|
230
|
+
_joined_entries(
|
|
231
|
+
cache_conn, start, end, project=project, account_key=account_key)
|
|
232
|
+
),
|
|
233
|
+
# No historical alert/budget config exists in the journal. The family
|
|
234
|
+
# registry classifies those latches as re-materialized projections.
|
|
235
|
+
"load_config": lambda *args, **kwargs: {},
|
|
236
|
+
}
|
|
237
|
+
prior = {name: getattr(cctally, name) for name in replacements}
|
|
238
|
+
try:
|
|
239
|
+
for name, value in replacements.items():
|
|
240
|
+
setattr(cctally, name, value)
|
|
241
|
+
yield
|
|
242
|
+
finally:
|
|
243
|
+
for name, value in prior.items():
|
|
244
|
+
setattr(cctally, name, value)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _rederivable_raw_records(records: list[dict]) -> list[dict]:
|
|
248
|
+
out = []
|
|
249
|
+
for record in records:
|
|
250
|
+
if record.get("t") == "obs":
|
|
251
|
+
if (
|
|
252
|
+
record.get("provider") == "claude"
|
|
253
|
+
and record.get("src") in _record._CLAUDE_OBS_SRCS
|
|
254
|
+
):
|
|
255
|
+
out.append(record)
|
|
256
|
+
continue
|
|
257
|
+
if record.get("t") != "op":
|
|
258
|
+
continue
|
|
259
|
+
kind = (record.get("payload") or {}).get("kind")
|
|
260
|
+
if kind in {"weekly_credit_floor", "account_observe",
|
|
261
|
+
"account_label", "accounts_cutover", "sync_week"}:
|
|
262
|
+
out.append(record)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _normalize_legacy_accounts(records: list[dict]) -> None:
|
|
267
|
+
import _lib_accounts
|
|
268
|
+
|
|
269
|
+
cutover_values = {
|
|
270
|
+
(record.get("payload") or {}).get("claude_legacy_account")
|
|
271
|
+
for record in records
|
|
272
|
+
if record.get("t") == "op"
|
|
273
|
+
and (record.get("payload") or {}).get("kind") == "accounts_cutover"
|
|
274
|
+
}
|
|
275
|
+
if None in cutover_values:
|
|
276
|
+
raise _lib_rederive.RederiveConflict(
|
|
277
|
+
"accounts_cutover is missing claude_legacy_account"
|
|
278
|
+
)
|
|
279
|
+
if len(cutover_values) > 1:
|
|
280
|
+
raise _lib_rederive.RederiveConflict(
|
|
281
|
+
"conflicting accounts_cutover Claude account values"
|
|
282
|
+
)
|
|
283
|
+
cutover_claude = (
|
|
284
|
+
next(iter(cutover_values))
|
|
285
|
+
if cutover_values
|
|
286
|
+
else _lib_accounts.UNATTRIBUTED
|
|
287
|
+
)
|
|
288
|
+
for record in records:
|
|
289
|
+
_journal._normalize_legacy_account_stamp(record, cutover_claude)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _derive_desired_events(records: list[dict], cache_conn: sqlite3.Connection,
|
|
293
|
+
scratch_dir: Path) -> list[dict]:
|
|
294
|
+
# #386: this replays the whole ingest pipeline into a PRIVATE scratch index
|
|
295
|
+
# and is reached from `db rederive`'s PREVIEW, which takes no locks by
|
|
296
|
+
# design (its contract is zero persistent writes to the live family). The
|
|
297
|
+
# connection is authorizer-armed like every other `open_db` handle, so the
|
|
298
|
+
# scratch replay has to declare itself sanctioned — otherwise a write-free
|
|
299
|
+
# preview is denied for writing to its own temp file. The scope is entered
|
|
300
|
+
# around the replay only; nothing here touches DB_PATH.
|
|
301
|
+
import _cctally_store
|
|
302
|
+
|
|
303
|
+
scratch_path = scratch_dir / "stats.rederive.db"
|
|
304
|
+
with _cctally_store.stats_write_scope("rederive-derive"):
|
|
305
|
+
return _derive_desired_events_into(
|
|
306
|
+
records, cache_conn, str(scratch_path))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _derive_desired_events_into(records, cache_conn, scratch_path) -> list[dict]:
|
|
310
|
+
conn = _cctally_core.open_db(_target_path=str(scratch_path))
|
|
311
|
+
events: list[dict] = []
|
|
312
|
+
projection_state: dict = {}
|
|
313
|
+
hooks = (
|
|
314
|
+
_journal._pipeline_op_fold,
|
|
315
|
+
_record._pipeline_claude_usage,
|
|
316
|
+
_record._pipeline_record_credit,
|
|
317
|
+
_record._pipeline_sync_week,
|
|
318
|
+
)
|
|
319
|
+
try:
|
|
320
|
+
with _scratch_read_adapters(cache_conn):
|
|
321
|
+
for record in _rederivable_raw_records(records):
|
|
322
|
+
ctx = _journal.IngestContext(
|
|
323
|
+
conn=conn,
|
|
324
|
+
batch=[record],
|
|
325
|
+
config={},
|
|
326
|
+
event_sink=events,
|
|
327
|
+
projection_writes=False,
|
|
328
|
+
projection_state=projection_state,
|
|
329
|
+
)
|
|
330
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
331
|
+
try:
|
|
332
|
+
for hook in hooks:
|
|
333
|
+
hook(ctx, record)
|
|
334
|
+
_journal._harvest(ctx)
|
|
335
|
+
conn.commit()
|
|
336
|
+
except BaseException:
|
|
337
|
+
conn.rollback()
|
|
338
|
+
raise
|
|
339
|
+
return events
|
|
340
|
+
finally:
|
|
341
|
+
conn.close()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def plan_claude_usage(
|
|
345
|
+
records,
|
|
346
|
+
*,
|
|
347
|
+
cache_conn: sqlite3.Connection,
|
|
348
|
+
journal_high_water: "tuple[str, int] | None",
|
|
349
|
+
protocol_prefix_evidence=(),
|
|
350
|
+
):
|
|
351
|
+
"""Produce a deterministic Task-A-compatible plan without durable writes.
|
|
352
|
+
|
|
353
|
+
``records`` must be the canonical journal prefix ending at
|
|
354
|
+
``journal_high_water``. ``cache_conn`` must be a caller-held stable SQLite
|
|
355
|
+
read view. Task C owns lock/snapshot orchestration; Task B owns the pure
|
|
356
|
+
replay and comparison contract.
|
|
357
|
+
"""
|
|
358
|
+
records = copy.deepcopy(list(records))
|
|
359
|
+
_normalize_legacy_accounts(records)
|
|
360
|
+
report = _lib_rederive.validate_family_registry(
|
|
361
|
+
evt_kinds=set(_journal._EVT_SPECS),
|
|
362
|
+
op_kinds=(
|
|
363
|
+
set(_journal.FOLD_APPLIERS)
|
|
364
|
+
| set(_journal._ACCOUNTS_MACHINERY_KINDS)
|
|
365
|
+
| {"sync_week"}
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
if report.unclassified_evt_kinds or report.unclassified_op_kinds:
|
|
369
|
+
raise _lib_rederive.RederiveConflict(
|
|
370
|
+
"unclassified journal kind(s): evt="
|
|
371
|
+
+ ",".join(report.unclassified_evt_kinds)
|
|
372
|
+
+ " op=" + ",".join(report.unclassified_op_kinds)
|
|
373
|
+
)
|
|
374
|
+
_lib_rederive.validate_claude_cache_contract(_cache_contract(cache_conn))
|
|
375
|
+
raw_records = _rederivable_raw_records(records)
|
|
376
|
+
_validate_cache_rows(cache_conn, raw_records)
|
|
377
|
+
cache_fingerprint = _cache_fingerprint(cache_conn)
|
|
378
|
+
config_fingerprint = _fingerprint({
|
|
379
|
+
"historicalConfig": "not-retained",
|
|
380
|
+
"projectionPolicy": "retire-and-rematerialize",
|
|
381
|
+
})
|
|
382
|
+
selection = _lib_journal.resolve_effective_events(
|
|
383
|
+
records,
|
|
384
|
+
protocol_prefix_evidence=protocol_prefix_evidence,
|
|
385
|
+
)
|
|
386
|
+
tainted = [
|
|
387
|
+
*selection.protocol_violations,
|
|
388
|
+
*selection.acknowledged_protocol_violations,
|
|
389
|
+
]
|
|
390
|
+
if tainted:
|
|
391
|
+
summary = ", ".join(
|
|
392
|
+
f"{violation.batch_id}:{violation.kind}"
|
|
393
|
+
for violation in tainted[:10]
|
|
394
|
+
)
|
|
395
|
+
raise _lib_rederive.RederiveConflict(
|
|
396
|
+
"journal contains tainted correction batch(es): " + summary
|
|
397
|
+
)
|
|
398
|
+
with tempfile.TemporaryDirectory(prefix="cctally-rederive-") as tmp:
|
|
399
|
+
desired = _derive_desired_events(records, cache_conn, Path(tmp))
|
|
400
|
+
return _lib_rederive.build_claude_usage_plan(
|
|
401
|
+
selection=selection,
|
|
402
|
+
desired_events=desired,
|
|
403
|
+
journal_high_water=journal_high_water,
|
|
404
|
+
cache_fingerprint=cache_fingerprint,
|
|
405
|
+
config_fingerprint=config_fingerprint,
|
|
406
|
+
conflicted_event_ids=owned_conflicted_event_ids(selection),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def owned_conflicted_event_ids(selection) -> frozenset:
|
|
411
|
+
"""The quarantined same-revision groups (#374) this family owns.
|
|
412
|
+
|
|
413
|
+
Scoped two ways: the selector already filters `conflicts` to the WINNING
|
|
414
|
+
revision (a group a completed rev-1 batch superseded is resolved, not
|
|
415
|
+
outstanding), and this filters to events `claude-usage` re-derives — a
|
|
416
|
+
conflict in a retained family (`quota_alert_arming`) or an unknown kind must
|
|
417
|
+
force no action, because a correction this family cannot re-derive would be
|
|
418
|
+
a fabrication."""
|
|
419
|
+
owned = set()
|
|
420
|
+
for conflict in getattr(selection, "conflicts", ()):
|
|
421
|
+
selected = selection.by_id.get(conflict.event_id)
|
|
422
|
+
if selected is None or selected.record is None:
|
|
423
|
+
continue
|
|
424
|
+
if _lib_rederive._is_owned_event(selected.record):
|
|
425
|
+
owned.add(conflict.event_id)
|
|
426
|
+
return frozenset(owned)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def read_rederive_journal_prefix(
|
|
430
|
+
high_water: "tuple[str, int] | None" = None,
|
|
431
|
+
) -> tuple[list[dict], "tuple[str, int] | None", list[tuple[str, int]]]:
|
|
432
|
+
"""Read and strictly decode one canonical journal prefix."""
|
|
433
|
+
if high_water is None:
|
|
434
|
+
high_water = _journal.journal_high_water()
|
|
435
|
+
if high_water is None:
|
|
436
|
+
return [], None, []
|
|
437
|
+
records: list[dict] = []
|
|
438
|
+
record_ends: list[tuple[str, int]] = []
|
|
439
|
+
malformed = 0
|
|
440
|
+
for segment, offset, raw in _journal._read_range(None, high_water):
|
|
441
|
+
record = _lib_journal.decode_line(raw)
|
|
442
|
+
if record is None:
|
|
443
|
+
malformed += 1
|
|
444
|
+
continue
|
|
445
|
+
records.append(record)
|
|
446
|
+
record_ends.append((segment, offset + len(raw) + 1))
|
|
447
|
+
if malformed:
|
|
448
|
+
raise _lib_rederive.RederiveConflict(
|
|
449
|
+
f"journal prefix contains {malformed} malformed line(s)"
|
|
450
|
+
)
|
|
451
|
+
return records, high_water, record_ends
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _protocol_prefix_evidence(records, record_ends):
|
|
455
|
+
evidence = []
|
|
456
|
+
prior_high_water = None
|
|
457
|
+
for record, record_end in zip(records, record_ends):
|
|
458
|
+
_journal._capture_protocol_prefix_evidence(
|
|
459
|
+
record,
|
|
460
|
+
prior_high_water,
|
|
461
|
+
evidence,
|
|
462
|
+
)
|
|
463
|
+
prior_high_water = record_end
|
|
464
|
+
return tuple(evidence)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _read_only_journal_high_water() -> "tuple[str, int] | None":
|
|
468
|
+
"""Capture an append-only prefix without creating a coordination file."""
|
|
469
|
+
segments = _journal.list_segments()
|
|
470
|
+
if not segments:
|
|
471
|
+
return None
|
|
472
|
+
latest = segments[-1]
|
|
473
|
+
return (latest, os.path.getsize(_cctally_core.JOURNAL_DIR / latest))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _batch_id_for_plan(plan: _lib_rederive.RederivePlan) -> "str | None":
|
|
477
|
+
if not plan.actions:
|
|
478
|
+
return None
|
|
479
|
+
body = {
|
|
480
|
+
"family": plan.family,
|
|
481
|
+
"actions": [
|
|
482
|
+
action.to_correction_action() for action in plan.actions
|
|
483
|
+
],
|
|
484
|
+
}
|
|
485
|
+
digest = hashlib.sha256(_canonical_bytes(body)).hexdigest()
|
|
486
|
+
return f"rederive:{plan.family}:{digest}"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _iso_now() -> str:
|
|
490
|
+
return (
|
|
491
|
+
dt.datetime.now(dt.timezone.utc)
|
|
492
|
+
.replace(microsecond=0)
|
|
493
|
+
.isoformat()
|
|
494
|
+
.replace("+00:00", "Z")
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@contextlib.contextmanager
|
|
499
|
+
def _open_sqlite_snapshot(path: Path, *, prefix: str):
|
|
500
|
+
"""Yield a query-only SQLite family copy without touching the source."""
|
|
501
|
+
with tempfile.TemporaryDirectory(
|
|
502
|
+
prefix=prefix
|
|
503
|
+
) as tmp:
|
|
504
|
+
snapshot = Path(tmp) / path.name
|
|
505
|
+
wal = path.with_name(path.name + "-wal")
|
|
506
|
+
# Copy the append-only WAL prefix before the main file. If a checkpoint
|
|
507
|
+
# races this read, a main-file identity change makes us retry rather
|
|
508
|
+
# than combining generations. SQLite validates the copied WAL frames.
|
|
509
|
+
for attempt in range(3):
|
|
510
|
+
before = path.stat()
|
|
511
|
+
snapshot_wal = snapshot.with_name(snapshot.name + "-wal")
|
|
512
|
+
snapshot_wal.unlink(missing_ok=True)
|
|
513
|
+
try:
|
|
514
|
+
wal_size = wal.stat().st_size
|
|
515
|
+
with wal.open("rb") as source, snapshot_wal.open("wb") as target:
|
|
516
|
+
shutil.copyfileobj(source, target, length=1024 * 1024)
|
|
517
|
+
target.truncate(wal_size)
|
|
518
|
+
except FileNotFoundError:
|
|
519
|
+
snapshot_wal.unlink(missing_ok=True)
|
|
520
|
+
shutil.copyfile(path, snapshot)
|
|
521
|
+
after = path.stat()
|
|
522
|
+
if (
|
|
523
|
+
before.st_ino,
|
|
524
|
+
before.st_size,
|
|
525
|
+
before.st_mtime_ns,
|
|
526
|
+
) == (
|
|
527
|
+
after.st_ino,
|
|
528
|
+
after.st_size,
|
|
529
|
+
after.st_mtime_ns,
|
|
530
|
+
):
|
|
531
|
+
break
|
|
532
|
+
if attempt == 2:
|
|
533
|
+
raise RederiveBusy(
|
|
534
|
+
"cache.db changed during the read-only preview snapshot; "
|
|
535
|
+
"retry shortly"
|
|
536
|
+
)
|
|
537
|
+
conn = sqlite3.connect(snapshot)
|
|
538
|
+
try:
|
|
539
|
+
conn.execute("PRAGMA query_only=ON")
|
|
540
|
+
conn.execute("BEGIN")
|
|
541
|
+
yield conn
|
|
542
|
+
finally:
|
|
543
|
+
conn.close()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@contextlib.contextmanager
|
|
547
|
+
def _open_cache_read_view():
|
|
548
|
+
"""Yield a stable cache snapshot without touching source WAL sidecars."""
|
|
549
|
+
path = _cctally_core.CACHE_DB_PATH
|
|
550
|
+
if not path.exists():
|
|
551
|
+
raise _lib_rederive.RederiveDataGap(
|
|
552
|
+
f"missing cache.db at {path}"
|
|
553
|
+
)
|
|
554
|
+
with _open_sqlite_snapshot(
|
|
555
|
+
path,
|
|
556
|
+
prefix="cctally-rederive-cache-",
|
|
557
|
+
) as conn:
|
|
558
|
+
yield conn
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _latest_completed_family_batch(
|
|
562
|
+
records: list[dict],
|
|
563
|
+
record_ends: list[tuple[str, int]],
|
|
564
|
+
completed: frozenset[str],
|
|
565
|
+
family: str,
|
|
566
|
+
) -> tuple["str | None", "tuple[str, int] | None"]:
|
|
567
|
+
latest_id = None
|
|
568
|
+
latest_high_water = None
|
|
569
|
+
for record, record_end in zip(records, record_ends):
|
|
570
|
+
if (
|
|
571
|
+
record.get("t") == "correction_batch"
|
|
572
|
+
and record.get("phase") == "commit"
|
|
573
|
+
and record.get("family") == family
|
|
574
|
+
and record.get("id") in completed
|
|
575
|
+
):
|
|
576
|
+
latest_id = record["id"]
|
|
577
|
+
latest_high_water = record_end
|
|
578
|
+
return latest_id, latest_high_water
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _preview_from_snapshot(
|
|
582
|
+
family: str,
|
|
583
|
+
*,
|
|
584
|
+
journal_high_water: "tuple[str, int] | None" = None,
|
|
585
|
+
) -> RederivePreview:
|
|
586
|
+
if family != _lib_rederive.FAMILY:
|
|
587
|
+
raise _lib_rederive.RederiveConflict(
|
|
588
|
+
f"unsupported rederive family: {family}"
|
|
589
|
+
)
|
|
590
|
+
if journal_high_water is None:
|
|
591
|
+
journal_high_water = _read_only_journal_high_water()
|
|
592
|
+
if journal_high_water is None:
|
|
593
|
+
records, high_water, record_ends = [], None, []
|
|
594
|
+
else:
|
|
595
|
+
records, high_water, record_ends = read_rederive_journal_prefix(
|
|
596
|
+
journal_high_water
|
|
597
|
+
)
|
|
598
|
+
protocol_evidence = _protocol_prefix_evidence(records, record_ends)
|
|
599
|
+
with _open_cache_read_view() as cache:
|
|
600
|
+
plan = plan_claude_usage(
|
|
601
|
+
records,
|
|
602
|
+
cache_conn=cache,
|
|
603
|
+
journal_high_water=high_water,
|
|
604
|
+
protocol_prefix_evidence=protocol_evidence,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
selection = _lib_journal.resolve_effective_events(
|
|
608
|
+
records,
|
|
609
|
+
protocol_prefix_evidence=protocol_evidence,
|
|
610
|
+
)
|
|
611
|
+
batch_id = _batch_id_for_plan(plan)
|
|
612
|
+
generated_at = _iso_now()
|
|
613
|
+
incomplete = False
|
|
614
|
+
if batch_id is not None:
|
|
615
|
+
begins = [
|
|
616
|
+
record for record in records
|
|
617
|
+
if record.get("t") == "correction_batch"
|
|
618
|
+
and record.get("phase") == "begin"
|
|
619
|
+
and record.get("id") == batch_id
|
|
620
|
+
]
|
|
621
|
+
if begins:
|
|
622
|
+
generated_at = str(begins[0]["at"])
|
|
623
|
+
incomplete = batch_id not in selection.completed_batches
|
|
624
|
+
latest_id, latest_high_water = _latest_completed_family_batch(
|
|
625
|
+
records,
|
|
626
|
+
record_ends,
|
|
627
|
+
selection.completed_batches,
|
|
628
|
+
family,
|
|
629
|
+
)
|
|
630
|
+
recovery_required = (
|
|
631
|
+
latest_id is not None and not _stats_has_batch(latest_id)
|
|
632
|
+
)
|
|
633
|
+
owned_conflicts = owned_conflicted_event_ids(selection)
|
|
634
|
+
journal_conflicts = tuple(
|
|
635
|
+
conflict for conflict in selection.conflicts
|
|
636
|
+
if conflict.event_id in owned_conflicts
|
|
637
|
+
)
|
|
638
|
+
return RederivePreview(
|
|
639
|
+
journal_conflicts=journal_conflicts,
|
|
640
|
+
plan=plan,
|
|
641
|
+
records=tuple(records),
|
|
642
|
+
journal_high_water=high_water,
|
|
643
|
+
record_ends=tuple(record_ends),
|
|
644
|
+
generated_at=generated_at,
|
|
645
|
+
batch_id=batch_id,
|
|
646
|
+
incomplete_batch=incomplete,
|
|
647
|
+
latest_completed_batch=latest_id,
|
|
648
|
+
latest_completed_high_water=latest_high_water,
|
|
649
|
+
recovery_required=recovery_required,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@contextlib.contextmanager
|
|
654
|
+
def _rederive_locks(*, apply: bool, timeout: float):
|
|
655
|
+
"""Acquire the stable-view locks in the repository's total order."""
|
|
656
|
+
from _lib_cache_writer_lock import (
|
|
657
|
+
acquire_ordered_flocks,
|
|
658
|
+
release_cache_writer_flocks,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
662
|
+
locks = [
|
|
663
|
+
(
|
|
664
|
+
_cctally_core.STATS_LOCK_MAINTENANCE_PATH,
|
|
665
|
+
fcntl.LOCK_EX if apply else fcntl.LOCK_SH,
|
|
666
|
+
),
|
|
667
|
+
(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
|
|
668
|
+
]
|
|
669
|
+
if apply:
|
|
670
|
+
locks.append((_cctally_core.JOURNAL_INGEST_LOCK_PATH, fcntl.LOCK_EX))
|
|
671
|
+
locks.append((_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX))
|
|
672
|
+
held = acquire_ordered_flocks(locks, timeout=timeout)
|
|
673
|
+
if held is None:
|
|
674
|
+
raise RederiveBusy(
|
|
675
|
+
"another database sync or maintenance operation holds the "
|
|
676
|
+
"rederive lock set; retry shortly"
|
|
677
|
+
)
|
|
678
|
+
# #386: record the stats maintenance hold so a nested live `open_db()` does
|
|
679
|
+
# not request SHARED on a second fd of this same file and self-deadlock, and
|
|
680
|
+
# (when applying) declare the sanctioned write regime + the ingest hold so a
|
|
681
|
+
# heal reached from in here recognises itself as the serialized writer.
|
|
682
|
+
import _cctally_store
|
|
683
|
+
|
|
684
|
+
_cctally_core.note_stats_maintenance_acquired()
|
|
685
|
+
try:
|
|
686
|
+
with _cctally_store.stats_write_scope("rederive", ingest_lock=apply):
|
|
687
|
+
yield
|
|
688
|
+
finally:
|
|
689
|
+
_cctally_core.note_stats_maintenance_released()
|
|
690
|
+
release_cache_writer_flocks(held)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def preview_db_rederive(
|
|
694
|
+
family: str,
|
|
695
|
+
*,
|
|
696
|
+
lock_timeout: float = _REDERIVE_LOCK_TIMEOUT_SECONDS,
|
|
697
|
+
) -> RederivePreview:
|
|
698
|
+
# Preview has a literal zero-persistent-write contract. A fixed append-only
|
|
699
|
+
# journal prefix and a read-only SQLite transaction are stable inputs
|
|
700
|
+
# without creating any coordination files.
|
|
701
|
+
del lock_timeout
|
|
702
|
+
return _preview_from_snapshot(family)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _stats_has_batch(batch_id: str) -> bool:
|
|
706
|
+
path = _cctally_core.DB_PATH
|
|
707
|
+
if not path.exists():
|
|
708
|
+
return False
|
|
709
|
+
try:
|
|
710
|
+
with _open_sqlite_snapshot(
|
|
711
|
+
path,
|
|
712
|
+
prefix="cctally-rederive-stats-",
|
|
713
|
+
) as conn:
|
|
714
|
+
row = conn.execute(
|
|
715
|
+
"SELECT 1 FROM journal_effective_events "
|
|
716
|
+
"WHERE batch_id = ? LIMIT 1",
|
|
717
|
+
(batch_id,),
|
|
718
|
+
).fetchone()
|
|
719
|
+
return row is not None
|
|
720
|
+
except (OSError, sqlite3.Error):
|
|
721
|
+
return False
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _call_crash_hook(stage: str) -> None:
|
|
725
|
+
if _REDERIVE_CRASH_HOOK is not None:
|
|
726
|
+
_REDERIVE_CRASH_HOOK(stage)
|
|
727
|
+
if (
|
|
728
|
+
os.environ.get("CCTALLY_REDERIVE_TEST_MODE") == "1"
|
|
729
|
+
and os.environ.get("CCTALLY_REDERIVE_TEST_CRASH_STAGE") == stage
|
|
730
|
+
):
|
|
731
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def apply_db_rederive(
|
|
735
|
+
family: str,
|
|
736
|
+
*,
|
|
737
|
+
lock_timeout: float = _REDERIVE_LOCK_TIMEOUT_SECONDS,
|
|
738
|
+
) -> RederiveCommandResult:
|
|
739
|
+
with _rederive_locks(apply=True, timeout=lock_timeout):
|
|
740
|
+
preview = _preview_from_snapshot(family)
|
|
741
|
+
plan = preview.plan
|
|
742
|
+
recovering_prior = (
|
|
743
|
+
preview.latest_completed_batch is not None
|
|
744
|
+
and not _stats_has_batch(preview.latest_completed_batch)
|
|
745
|
+
)
|
|
746
|
+
if plan.actions:
|
|
747
|
+
assert preview.batch_id is not None
|
|
748
|
+
batch = _lib_journal.make_correction_batch(
|
|
749
|
+
batch_id=preview.batch_id,
|
|
750
|
+
family=family,
|
|
751
|
+
at=preview.generated_at,
|
|
752
|
+
actions=plan.to_correction_actions(),
|
|
753
|
+
)
|
|
754
|
+
try:
|
|
755
|
+
batch_high_water = _journal.append_records(
|
|
756
|
+
batch,
|
|
757
|
+
expected_high_water=preview.journal_high_water,
|
|
758
|
+
line_hook=lambda index: _call_crash_hook(
|
|
759
|
+
f"after-batch-line-{index}"
|
|
760
|
+
),
|
|
761
|
+
)
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
raise RederiveApplyError(
|
|
764
|
+
"correction append", preview, preview.batch_id, exc
|
|
765
|
+
) from exc
|
|
766
|
+
try:
|
|
767
|
+
_call_crash_hook("after-batch-commit")
|
|
768
|
+
result = _journal.rebuild_stats_index(
|
|
769
|
+
high_water=batch_high_water,
|
|
770
|
+
update_quota_cache=False,
|
|
771
|
+
before_swap=lambda: _call_crash_hook(
|
|
772
|
+
"before-rebuild-swap"
|
|
773
|
+
),
|
|
774
|
+
)
|
|
775
|
+
_call_crash_hook("after-rebuild")
|
|
776
|
+
except Exception as exc:
|
|
777
|
+
raise RederiveApplyError(
|
|
778
|
+
"stats rebuild", preview, preview.batch_id, exc
|
|
779
|
+
) from exc
|
|
780
|
+
status = (
|
|
781
|
+
"recovered"
|
|
782
|
+
if preview.incomplete_batch or recovering_prior
|
|
783
|
+
else "applied"
|
|
784
|
+
)
|
|
785
|
+
return RederiveCommandResult(
|
|
786
|
+
preview=preview,
|
|
787
|
+
status=status,
|
|
788
|
+
batch_id=preview.batch_id,
|
|
789
|
+
rebuild=result,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
latest = preview.latest_completed_batch
|
|
793
|
+
if latest is not None and not _stats_has_batch(latest):
|
|
794
|
+
if preview.latest_completed_high_water is None:
|
|
795
|
+
raise RederiveBusy(
|
|
796
|
+
f"completed correction batch {latest} has no commit high-water"
|
|
797
|
+
)
|
|
798
|
+
try:
|
|
799
|
+
result = _journal.rebuild_stats_index(
|
|
800
|
+
high_water=preview.latest_completed_high_water,
|
|
801
|
+
update_quota_cache=False,
|
|
802
|
+
before_swap=lambda: _call_crash_hook(
|
|
803
|
+
"before-rebuild-swap"
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
_call_crash_hook("after-rebuild")
|
|
807
|
+
except Exception as exc:
|
|
808
|
+
raise RederiveApplyError(
|
|
809
|
+
"stats recovery", preview, latest, exc
|
|
810
|
+
) from exc
|
|
811
|
+
return RederiveCommandResult(
|
|
812
|
+
preview=preview,
|
|
813
|
+
status="recovered",
|
|
814
|
+
batch_id=latest,
|
|
815
|
+
rebuild=result,
|
|
816
|
+
)
|
|
817
|
+
return RederiveCommandResult(
|
|
818
|
+
preview=preview,
|
|
819
|
+
status="no-op",
|
|
820
|
+
batch_id=latest,
|
|
821
|
+
rebuild=None,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _high_water_dict(high_water):
|
|
826
|
+
if high_water is None:
|
|
827
|
+
return None
|
|
828
|
+
return {"segment": high_water[0], "offset": high_water[1]}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _rebuild_dict(result):
|
|
832
|
+
if result is None:
|
|
833
|
+
return None
|
|
834
|
+
return {
|
|
835
|
+
"segmentsRead": result.segments_read,
|
|
836
|
+
"linesFolded": result.lines_folded,
|
|
837
|
+
"malformed": result.malformed,
|
|
838
|
+
"durationSeconds": round(result.duration_s, 3),
|
|
839
|
+
"rowsByTable": result.rows_by_table,
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _command_payload(
|
|
844
|
+
*,
|
|
845
|
+
status: str,
|
|
846
|
+
preview: "RederivePreview | None" = None,
|
|
847
|
+
batch_id: "str | None" = None,
|
|
848
|
+
rebuild=None,
|
|
849
|
+
conflicts=(),
|
|
850
|
+
data_gaps=(),
|
|
851
|
+
errors=(),
|
|
852
|
+
family: str = _lib_rederive.FAMILY,
|
|
853
|
+
journal_high_water=None,
|
|
854
|
+
):
|
|
855
|
+
plan = None if preview is None else preview.plan
|
|
856
|
+
counts = (
|
|
857
|
+
{"retain": 0, "supersede": 0, "tombstone": 0, "add": 0}
|
|
858
|
+
if plan is None else dict(plan.counts)
|
|
859
|
+
)
|
|
860
|
+
body = {
|
|
861
|
+
"status": status,
|
|
862
|
+
"family": family,
|
|
863
|
+
"journalHighWater": (
|
|
864
|
+
_high_water_dict(journal_high_water)
|
|
865
|
+
if preview is None
|
|
866
|
+
else _high_water_dict(preview.journal_high_water)
|
|
867
|
+
),
|
|
868
|
+
"batchId": batch_id,
|
|
869
|
+
"planHash": None if plan is None else plan.plan_hash,
|
|
870
|
+
"actionCounts": counts,
|
|
871
|
+
# `conflicts` is the LEGACY key and keeps its meaning: command-validation
|
|
872
|
+
# failure messages (unsupported family, prod guard, structural journal
|
|
873
|
+
# protocol errors). #374's quarantined same-revision GROUPS ride the new
|
|
874
|
+
# `journalConflicts` key — never overload the old one.
|
|
875
|
+
"conflicts": list(conflicts),
|
|
876
|
+
"journalConflicts": [
|
|
877
|
+
conflict.to_dict()
|
|
878
|
+
for conflict in (() if preview is None else preview.journal_conflicts)
|
|
879
|
+
],
|
|
880
|
+
"dataGaps": list(data_gaps),
|
|
881
|
+
"errors": list(errors),
|
|
882
|
+
"rebuild": _rebuild_dict(rebuild),
|
|
883
|
+
"noOp": status == "no-op",
|
|
884
|
+
}
|
|
885
|
+
return _lib_json_envelope.stamp_schema_version(body, version=1)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _emit_command_payload(payload: dict, *, as_json: bool) -> None:
|
|
889
|
+
if as_json:
|
|
890
|
+
print(json.dumps(payload, separators=(",", ":")))
|
|
891
|
+
return
|
|
892
|
+
status = payload["status"]
|
|
893
|
+
if status == "preview":
|
|
894
|
+
actions = sum(
|
|
895
|
+
payload["actionCounts"][name]
|
|
896
|
+
for name in ("supersede", "tombstone", "add")
|
|
897
|
+
)
|
|
898
|
+
if actions == 0 and payload["batchId"] is not None:
|
|
899
|
+
print(
|
|
900
|
+
f"cctally: rederive preview for {payload['family']} — "
|
|
901
|
+
f"completed batch {payload['batchId']} needs stats.db recovery; "
|
|
902
|
+
"no changes written."
|
|
903
|
+
)
|
|
904
|
+
else:
|
|
905
|
+
print(
|
|
906
|
+
f"cctally: rederive preview for {payload['family']} — "
|
|
907
|
+
f"{actions} correction action(s); no changes written."
|
|
908
|
+
)
|
|
909
|
+
elif status == "applied":
|
|
910
|
+
print(
|
|
911
|
+
f"cctally: applied {payload['family']} correction batch "
|
|
912
|
+
f"{payload['batchId']} and rebuilt stats.db."
|
|
913
|
+
)
|
|
914
|
+
elif status == "recovered":
|
|
915
|
+
print(
|
|
916
|
+
f"cctally: recovered {payload['family']} correction batch "
|
|
917
|
+
f"{payload['batchId']} and rebuilt stats.db."
|
|
918
|
+
)
|
|
919
|
+
elif status == "no-op":
|
|
920
|
+
print(
|
|
921
|
+
f"cctally: {payload['family']} is already current; "
|
|
922
|
+
"no correction batch was appended."
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def cmd_db_rederive(args) -> int:
|
|
927
|
+
"""Preview or apply one audited Claude-usage correction plan."""
|
|
928
|
+
family = str(getattr(args, "family", ""))
|
|
929
|
+
as_json = bool(getattr(args, "json", False))
|
|
930
|
+
apply = bool(getattr(args, "yes", False))
|
|
931
|
+
try:
|
|
932
|
+
journal_high_water = _read_only_journal_high_water()
|
|
933
|
+
except OSError as exc:
|
|
934
|
+
payload = _command_payload(
|
|
935
|
+
status="failed",
|
|
936
|
+
family=family,
|
|
937
|
+
errors=(str(exc),),
|
|
938
|
+
)
|
|
939
|
+
if as_json:
|
|
940
|
+
_emit_command_payload(payload, as_json=True)
|
|
941
|
+
else:
|
|
942
|
+
print(f"cctally: db rederive failed: {exc}", file=sys.stderr)
|
|
943
|
+
return 3
|
|
944
|
+
if family != _lib_rederive.FAMILY:
|
|
945
|
+
message = f"unsupported rederive family: {family}"
|
|
946
|
+
payload = _command_payload(
|
|
947
|
+
status="conflict",
|
|
948
|
+
family=family,
|
|
949
|
+
journal_high_water=journal_high_water,
|
|
950
|
+
conflicts=(message,),
|
|
951
|
+
)
|
|
952
|
+
if as_json:
|
|
953
|
+
_emit_command_payload(payload, as_json=True)
|
|
954
|
+
else:
|
|
955
|
+
print(f"cctally: db rederive: {message}", file=sys.stderr)
|
|
956
|
+
return 2
|
|
957
|
+
if apply:
|
|
958
|
+
import _cctally_db
|
|
959
|
+
|
|
960
|
+
if _cctally_db._would_block_prod_stats(_cctally_core.DB_PATH):
|
|
961
|
+
message = (
|
|
962
|
+
"refusing to rederive the prod stats.db "
|
|
963
|
+
"(~/.local/share/cctally) from a dev checkout; run the "
|
|
964
|
+
"installed binary or set CCTALLY_ALLOW_PROD_MIGRATION=1"
|
|
965
|
+
)
|
|
966
|
+
payload = _command_payload(
|
|
967
|
+
status="conflict",
|
|
968
|
+
family=family,
|
|
969
|
+
journal_high_water=journal_high_water,
|
|
970
|
+
conflicts=(message,),
|
|
971
|
+
)
|
|
972
|
+
if as_json:
|
|
973
|
+
_emit_command_payload(payload, as_json=True)
|
|
974
|
+
else:
|
|
975
|
+
print(f"cctally: db rederive: {message}", file=sys.stderr)
|
|
976
|
+
return 2
|
|
977
|
+
try:
|
|
978
|
+
if apply:
|
|
979
|
+
result = apply_db_rederive(family)
|
|
980
|
+
payload = _command_payload(
|
|
981
|
+
status=result.status,
|
|
982
|
+
preview=result.preview,
|
|
983
|
+
batch_id=result.batch_id,
|
|
984
|
+
rebuild=result.rebuild,
|
|
985
|
+
)
|
|
986
|
+
else:
|
|
987
|
+
preview = preview_db_rederive(family)
|
|
988
|
+
status = (
|
|
989
|
+
"preview"
|
|
990
|
+
if preview.plan.actions or preview.recovery_required
|
|
991
|
+
else "no-op"
|
|
992
|
+
)
|
|
993
|
+
payload = _command_payload(
|
|
994
|
+
status=status,
|
|
995
|
+
preview=preview,
|
|
996
|
+
batch_id=(
|
|
997
|
+
preview.latest_completed_batch
|
|
998
|
+
if status == "no-op" or preview.recovery_required
|
|
999
|
+
else preview.batch_id
|
|
1000
|
+
),
|
|
1001
|
+
)
|
|
1002
|
+
except _lib_rederive.RederiveDataGap as exc:
|
|
1003
|
+
payload = _command_payload(
|
|
1004
|
+
status="missing-source",
|
|
1005
|
+
family=family,
|
|
1006
|
+
journal_high_water=journal_high_water,
|
|
1007
|
+
data_gaps=(str(exc),),
|
|
1008
|
+
)
|
|
1009
|
+
if as_json:
|
|
1010
|
+
_emit_command_payload(payload, as_json=True)
|
|
1011
|
+
else:
|
|
1012
|
+
print(f"cctally: db rederive missing source: {exc}", file=sys.stderr)
|
|
1013
|
+
return 2
|
|
1014
|
+
except (_lib_rederive.RederiveConflict,
|
|
1015
|
+
_lib_journal.JournalProtocolError) as exc:
|
|
1016
|
+
payload = _command_payload(
|
|
1017
|
+
status="conflict",
|
|
1018
|
+
family=family,
|
|
1019
|
+
journal_high_water=journal_high_water,
|
|
1020
|
+
conflicts=(str(exc),),
|
|
1021
|
+
)
|
|
1022
|
+
if as_json:
|
|
1023
|
+
_emit_command_payload(payload, as_json=True)
|
|
1024
|
+
else:
|
|
1025
|
+
print(f"cctally: db rederive conflict: {exc}", file=sys.stderr)
|
|
1026
|
+
return 2
|
|
1027
|
+
except RederiveApplyError as exc:
|
|
1028
|
+
payload = _command_payload(
|
|
1029
|
+
status="failed",
|
|
1030
|
+
preview=exc.preview,
|
|
1031
|
+
batch_id=exc.batch_id,
|
|
1032
|
+
errors=(str(exc),),
|
|
1033
|
+
)
|
|
1034
|
+
if as_json:
|
|
1035
|
+
_emit_command_payload(payload, as_json=True)
|
|
1036
|
+
else:
|
|
1037
|
+
print(f"cctally: db rederive failed: {exc}", file=sys.stderr)
|
|
1038
|
+
return 3
|
|
1039
|
+
except (RederiveBusy, _journal.JournalError, sqlite3.Error, OSError) as exc:
|
|
1040
|
+
payload = _command_payload(
|
|
1041
|
+
status="failed",
|
|
1042
|
+
family=family,
|
|
1043
|
+
journal_high_water=journal_high_water,
|
|
1044
|
+
errors=(str(exc),),
|
|
1045
|
+
)
|
|
1046
|
+
if as_json:
|
|
1047
|
+
_emit_command_payload(payload, as_json=True)
|
|
1048
|
+
else:
|
|
1049
|
+
print(f"cctally: db rederive failed: {exc}", file=sys.stderr)
|
|
1050
|
+
return 3
|
|
1051
|
+
_emit_command_payload(payload, as_json=as_json)
|
|
1052
|
+
return 0
|