@arbiterforge/ca-pi 0.15.6 → 0.15.8
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/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +16 -0
- package/plugins/ca-pi/hooks/_gitlib.py +8 -2
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_ledgerlib.py +1022 -355
- package/plugins/ca-pi/hooks/_segmentslib.py +64 -4
- package/plugins/ca-pi/hooks/_subagentslib.py +30 -64
- package/plugins/ca-pi/hooks/_usagelib.py +484 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/statusline.py +40 -24
- package/plugins/ca-pi/package.json +1 -1
|
@@ -1,39 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# codeArbiter — cost/token ledger subsystem for the statusline (extracted T-12).
|
|
3
3
|
#
|
|
4
|
-
# Owns the user-level
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
4
|
+
# Owns the user-level Claude accounting the statusline renders (spec
|
|
5
|
+
# claude-statusline-accounting-integrity): a bounded, starvation-free scanner over
|
|
6
|
+
# the parent transcript AND every delegated `<session>/subagents/*.jsonl`
|
|
7
|
+
# transcript. It is the ONE JSONL parser on the render path. Around it sit
|
|
8
|
+
# per-session v2 state under ~/.codearbiter/ledger.json.sessions/, incrementally
|
|
9
|
+
# maintained Session/Today totals with structured coverage, the separately kept
|
|
10
|
+
# host-reported estimate, and parent-only burn samples for the sparkline. What a
|
|
11
|
+
# usage record MEANS (identity, reconciliation, pricing) is owned by _usagelib;
|
|
12
|
+
# this module persists, schedules and aggregates. No rendering concern (no ANSI,
|
|
13
|
+
# no box drawing), so the accounting is unit-testable in isolation.
|
|
10
14
|
#
|
|
11
15
|
# Design principles (mirroring _metricslib.py / _taskboardlib.py):
|
|
12
16
|
# - Stdlib only; no third-party imports ever — runs on stock Python.
|
|
13
17
|
# - Zero side effects at import time: no git calls, no file I/O.
|
|
14
|
-
# -
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# -
|
|
18
|
+
# - ledger_update(), persist_sess_start() and pi_ledger_update() are the only
|
|
19
|
+
# filesystem entry points; everything else is pure or a private bounded
|
|
20
|
+
# persistence helper.
|
|
21
|
+
# - Bounds defer work, never discard it: an offset never advances past bytes
|
|
22
|
+
# that were not consumed, and backlog shows as `catching_up`.
|
|
23
|
+
# - Steady-state cost never grows with history. A no-change render reads only
|
|
24
|
+
# compact summaries; request evidence is loaded only for new records.
|
|
25
|
+
# - Never raise on malformed user input — every reader degrades to safe blanks,
|
|
26
|
+
# and uncertainty surfaces as coverage reasons, never as a fabricated figure.
|
|
18
27
|
#
|
|
19
28
|
# Public API:
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# _totals(models) -> dict {in,out,cost} display totals for a model map
|
|
26
|
-
# ledger_update(data, sid) -> tuple (rec, session_totals, today_totals)
|
|
29
|
+
# ledger_path() -> str resolved ledger anchor path (env-overridable)
|
|
30
|
+
# ledger_update(data, sid) -> tuple (summary, session, today); session/today carry
|
|
31
|
+
# in/out tokens, exact picodollars `pd`, display
|
|
32
|
+
# `cost`, coverage `state`, `reasons`, `stale`
|
|
33
|
+
# persist_sess_start(sid, value) -> bool cache a resolved session-start epoch
|
|
27
34
|
# pi_ledger_path() -> str separate user-global Pi ledger path
|
|
28
35
|
# pi_ledger_update(session_key, scan_start, scan_end, facts, path=None) -> dict
|
|
29
36
|
# bounded Pi session/day snapshot
|
|
30
|
-
# burn_samples(rec) -> list[float] recent per-
|
|
37
|
+
# burn_samples(rec) -> list[float] recent per-request (parent-only) burn values
|
|
31
38
|
|
|
39
|
+
import base64
|
|
32
40
|
import hashlib
|
|
33
41
|
import json
|
|
34
42
|
import math
|
|
35
43
|
import os
|
|
36
44
|
import re
|
|
45
|
+
import shutil
|
|
37
46
|
import stat
|
|
38
47
|
import sys
|
|
39
48
|
import time
|
|
@@ -56,11 +65,17 @@ from _hooklib import LOCK_WAIT # noqa: E402
|
|
|
56
65
|
# stays importable for this module's call sites and the test suite. No cycle
|
|
57
66
|
# risk — _fmtlib imports only _colorlib, never _ledgerlib.
|
|
58
67
|
from _fmtlib import parse_iso # noqa: E402,F401
|
|
68
|
+
# Usage semantics (normalization, identity, reconciliation, pricing) have ONE
|
|
69
|
+
# owner; this module only persists, schedules and aggregates what it returns.
|
|
70
|
+
import _usagelib # noqa: E402
|
|
71
|
+
try: # presentation label for child transcripts, derived by the one parser here
|
|
72
|
+
from _subagentslib import sub_label as _sub_label # noqa: E402
|
|
73
|
+
except Exception: # pragma: no cover — a label is never worth breaking accounting
|
|
74
|
+
_sub_label = None
|
|
59
75
|
|
|
60
76
|
# Tunables (module constants; mirrored from the original inline statusline block).
|
|
61
77
|
SESSION_TTL = 36 * 3600 # prune sessions older than ~1.5 days
|
|
62
78
|
BURN_RING = 40 # recent per-call token-burn samples kept for the sparkline
|
|
63
|
-
TX_MAX_NEW_LINES = 20000 # hot-path bound: transcript lines parsed per render
|
|
64
79
|
# `last_ts` exists only to keep a live record inside SESSION_TTL. Stamping it on
|
|
65
80
|
# EVERY render made every render a writing render (#392) — the statusline runs in
|
|
66
81
|
# a fresh process per refresh, so that was two atomic replacements per refresh
|
|
@@ -96,19 +111,6 @@ PI_TOTAL_KEYS = frozenset({
|
|
|
96
111
|
PI_FACT_KEYS = frozenset({"position", "timestamp"}) | PI_TOTAL_KEYS
|
|
97
112
|
PI_STATUSES = frozenset({"ok", "invalid", "corrupt", "lock_failed", "write_failed"})
|
|
98
113
|
|
|
99
|
-
# API list prices, USD per 1M tokens (captured 2026-06-10 from Anthropic's
|
|
100
|
-
# pricing pages). Used ONLY to estimate the pay-as-you-go API-equivalent cost of
|
|
101
|
-
# this session's REAL tokens — the bar labels it "api≈"; it is not a bill.
|
|
102
|
-
# Per model family: (input, output, cache_write_5m, cache_write_1h, cache_read).
|
|
103
|
-
# Cache multipliers are the standard ones: write 1.25x/2x input, read 0.1x.
|
|
104
|
-
API_PRICES = {
|
|
105
|
-
"fable": (10.0, 50.0, 12.50, 20.0, 1.00),
|
|
106
|
-
"opus": (5.0, 25.0, 6.25, 10.0, 0.50),
|
|
107
|
-
"sonnet": (3.0, 15.0, 3.75, 6.0, 0.30),
|
|
108
|
-
"haiku": (1.0, 5.0, 1.25, 2.0, 0.10),
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
114
|
# --------------------------------------------------------------------------- coercion
|
|
113
115
|
def num(x, default=0.0):
|
|
114
116
|
"""Coerce any host value to float; tolerate strings, None, and containers."""
|
|
@@ -136,29 +138,7 @@ def get(d, *path, default=None):
|
|
|
136
138
|
return cur
|
|
137
139
|
|
|
138
140
|
|
|
139
|
-
# ---------------------------------------------------------------------------
|
|
140
|
-
def price_for(model):
|
|
141
|
-
ml = str(model).lower()
|
|
142
|
-
for fam, p in API_PRICES.items():
|
|
143
|
-
if fam in ml:
|
|
144
|
-
return p
|
|
145
|
-
return API_PRICES["sonnet"] # reasonable mid default for an unrecognized model
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def api_cost(tok):
|
|
149
|
-
"""Estimated pay-as-you-go API cost (USD) for accumulated per-model tokens."""
|
|
150
|
-
total = 0.0
|
|
151
|
-
for model, t in (tok or {}).items():
|
|
152
|
-
if not isinstance(t, dict):
|
|
153
|
-
continue
|
|
154
|
-
pin, pout, p5, p1, pr = price_for(model)
|
|
155
|
-
total += (num(t.get("in")) * pin + num(t.get("out")) * pout
|
|
156
|
-
+ num(t.get("c5")) * p5 + num(t.get("c1")) * p1
|
|
157
|
-
+ num(t.get("cr")) * pr) / 1e6
|
|
158
|
-
return total
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# --------------------------------------------------------------------------- ledger
|
|
141
|
+
# --------------------------------------------------------------------------- ledger files
|
|
162
142
|
def ledger_path():
|
|
163
143
|
return os.environ.get("CODEARBITER_LEDGER") or \
|
|
164
144
|
os.path.join(os.path.expanduser("~"), ".codearbiter", "ledger.json")
|
|
@@ -195,6 +175,13 @@ def _atomic_json(path, value):
|
|
|
195
175
|
pass
|
|
196
176
|
|
|
197
177
|
|
|
178
|
+
def _remove(path):
|
|
179
|
+
try:
|
|
180
|
+
os.remove(path)
|
|
181
|
+
except OSError:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
198
185
|
def _session_dir(path):
|
|
199
186
|
return f"{path}.sessions"
|
|
200
187
|
|
|
@@ -204,6 +191,8 @@ def _session_key(sid):
|
|
|
204
191
|
|
|
205
192
|
|
|
206
193
|
def _session_file(path, sid):
|
|
194
|
+
"""The LEGACY (v1) per-session shard. v2 never writes it; it is read once to
|
|
195
|
+
seed a migrating session and otherwise only stat-ed for pruning."""
|
|
207
196
|
return os.path.join(_session_dir(path), f"{_session_key(sid)}.json")
|
|
208
197
|
|
|
209
198
|
|
|
@@ -211,332 +200,1013 @@ def _start_file(path, sid):
|
|
|
211
200
|
return os.path.join(_session_dir(path), f"{_session_key(sid)}.start.json")
|
|
212
201
|
|
|
213
202
|
|
|
214
|
-
def
|
|
215
|
-
|
|
203
|
+
def _acct_file(path, sid):
|
|
204
|
+
return os.path.join(_session_dir(path), f"{_session_key(sid)}.acct.json")
|
|
216
205
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
"""
|
|
225
|
-
led = _read_json(path, {})
|
|
226
|
-
legacy = led.get("sessions") if isinstance(led, dict) else None
|
|
227
|
-
if not isinstance(legacy, dict):
|
|
228
|
-
legacy = None
|
|
229
|
-
sessions = {} if legacy is None else {
|
|
230
|
-
sid: dict(rec) for sid, rec in legacy.items() if isinstance(rec, dict)}
|
|
231
|
-
directory = _session_dir(path)
|
|
232
|
-
try:
|
|
233
|
-
names = os.listdir(directory)
|
|
234
|
-
except OSError:
|
|
235
|
-
names = []
|
|
236
|
-
for name in names:
|
|
237
|
-
if not name.endswith(".json") or name.endswith(".start.json"):
|
|
238
|
-
continue
|
|
239
|
-
enumerated = os.path.join(directory, name)
|
|
240
|
-
item = _read_json(enumerated)
|
|
241
|
-
if not isinstance(item, dict) or not isinstance(item.get("rec"), dict):
|
|
242
|
-
try:
|
|
243
|
-
os.remove(enumerated)
|
|
244
|
-
except OSError:
|
|
245
|
-
pass
|
|
246
|
-
continue
|
|
247
|
-
sid = str(item.get("sid"))
|
|
248
|
-
if os.path.basename(_session_file(path, sid)) != name:
|
|
249
|
-
try:
|
|
250
|
-
os.remove(enumerated)
|
|
251
|
-
except OSError:
|
|
252
|
-
pass
|
|
253
|
-
continue
|
|
254
|
-
rec = item["rec"]
|
|
255
|
-
if time.time() - num(rec.get("last_ts")) > SESSION_TTL:
|
|
256
|
-
for stale in (enumerated, _start_file(path, sid)):
|
|
257
|
-
try:
|
|
258
|
-
os.remove(stale)
|
|
259
|
-
except OSError:
|
|
260
|
-
pass
|
|
261
|
-
sessions.pop(sid, None)
|
|
262
|
-
continue
|
|
263
|
-
sessions[sid] = rec
|
|
264
|
-
for name in names:
|
|
265
|
-
if not name.endswith(".start.json"):
|
|
266
|
-
continue
|
|
267
|
-
enumerated = os.path.join(directory, name)
|
|
268
|
-
item = _read_json(enumerated)
|
|
269
|
-
if not isinstance(item, dict):
|
|
270
|
-
try:
|
|
271
|
-
os.remove(enumerated)
|
|
272
|
-
except OSError:
|
|
273
|
-
pass
|
|
274
|
-
continue
|
|
275
|
-
sid = str(item.get("sid"))
|
|
276
|
-
start = num(item.get("sess_start"), None)
|
|
277
|
-
valid_name = os.path.basename(_start_file(path, sid)) == name
|
|
278
|
-
if valid_name and sid in sessions and start is not None:
|
|
279
|
-
sessions[sid]["sess_start"] = float(start)
|
|
280
|
-
else:
|
|
281
|
-
try:
|
|
282
|
-
os.remove(enumerated)
|
|
283
|
-
except OSError:
|
|
284
|
-
pass
|
|
285
|
-
now = time.time()
|
|
286
|
-
sessions = {sid: rec for sid, rec in sessions.items()
|
|
287
|
-
if isinstance(rec, dict)
|
|
288
|
-
and now - num(rec.get("last_ts")) <= SESSION_TTL}
|
|
289
|
-
return sessions, legacy
|
|
206
|
+
|
|
207
|
+
def _hot_file(path, sid):
|
|
208
|
+
return os.path.join(_session_dir(path), f"{_session_key(sid)}.hot.json")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ev_dir(path, sid):
|
|
212
|
+
return os.path.join(_session_dir(path), f"{_session_key(sid)}.ev")
|
|
290
213
|
|
|
291
214
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
215
|
+
# --------------------------------------------------------------------------- v2 accounting (spec
|
|
216
|
+
# claude-statusline-accounting-integrity). One compact SUMMARY per session is read
|
|
217
|
+
# on every render; request EVIDENCE lives in a bounded hot file plus hash-prefix
|
|
218
|
+
# partitions that split before they exceed PART_MAX, and is loaded only for the
|
|
219
|
+
# identities a render's new records touch. Derived totals are maintained by
|
|
220
|
+
# subtract-old / add-new, so steady-state work never grows with history.
|
|
221
|
+
ACCT_SCHEMA = "codearbiter.claude-accounting/v2"
|
|
222
|
+
ACCT_BYTE_BUDGET = 8 * 1024 * 1024 # transcript bytes read per render, all sources
|
|
223
|
+
ACCT_RECORD_BUDGET = 50000 # complete JSONL lines consumed per render
|
|
224
|
+
ACCT_SOURCE_BUDGET = 16 # sources opened per render
|
|
225
|
+
MAX_RECORD_BYTES = 4 * 1024 * 1024 # per-line ceiling (~14x the largest observed line)
|
|
226
|
+
DISCOVERY_BOUND = 4096 # child transcripts discovered per session
|
|
227
|
+
HOT_MAX = 256 # hot evidence entries before eviction to cold
|
|
228
|
+
PART_MAX = 256 # entries per cold partition before it splits
|
|
229
|
+
UI_LOCK_WAIT = 0.02 # render-path try-lock budget (seconds)
|
|
230
|
+
FP_WINDOW = 512 # head/tail fingerprint window (bytes)
|
|
231
|
+
UNPROVEN_KEEP = 8 # byte positions of unproven records kept per source
|
|
232
|
+
_LEGACY_SHARD = re.compile(r"[0-9a-f]{64}\.json\Z")
|
|
233
|
+
_HEX = "0123456789abcdef"
|
|
295
234
|
|
|
296
235
|
|
|
297
|
-
|
|
298
|
-
|
|
236
|
+
class _Inconsistent(Exception):
|
|
237
|
+
"""Evidence on disk does not match the summary that should own it."""
|
|
299
238
|
|
|
300
239
|
|
|
301
|
-
def
|
|
302
|
-
"""Local calendar
|
|
303
|
-
|
|
304
|
-
crosses midnight splits correctly across days)."""
|
|
305
|
-
e = parse_iso(ts) if isinstance(ts, str) else None
|
|
306
|
-
if e is None:
|
|
307
|
-
return datetime.now().strftime("%Y-%m-%d")
|
|
240
|
+
def _local_day(ts):
|
|
241
|
+
"""Local calendar day (YYYY-MM-DD) of a trustworthy transcript timestamp."""
|
|
242
|
+
e = parse_iso(ts)
|
|
308
243
|
try:
|
|
309
244
|
return datetime.fromtimestamp(e).strftime("%Y-%m-%d")
|
|
310
|
-
except (OSError, OverflowError, ValueError):
|
|
311
|
-
return
|
|
245
|
+
except (TypeError, OSError, OverflowError, ValueError):
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _today():
|
|
250
|
+
return datetime.now().strftime("%Y-%m-%d")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _order_sources(ids):
|
|
254
|
+
"""Deterministic child scheduling order (a test seam for order independence)."""
|
|
255
|
+
return sorted(ids)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _ident_hash(key):
|
|
259
|
+
return hashlib.sha256(key.encode("utf-8", "replace")).hexdigest()
|
|
312
260
|
|
|
313
261
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
262
|
+
# Per-partition membership filter (Bloom), carried in the hot file so a render
|
|
263
|
+
# that ingests brand-new identities can prove "not in this cold partition"
|
|
264
|
+
# without reading it. A filter only ever answers "definitely absent" or "maybe
|
|
265
|
+
# present"; a missing, malformed or undersized filter is "maybe present" and
|
|
266
|
+
# falls back to the partition read, because a false "absent" would silently
|
|
267
|
+
# double-count a fork replay (never fold unreadable into absent).
|
|
268
|
+
FILTER_BITS_PER_KEY = 10
|
|
269
|
+
FILTER_PROBES = 7
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _filter_probes(key, m):
|
|
273
|
+
d = hashlib.sha256(b"filter:" + key.encode("utf-8", "replace")).digest()
|
|
274
|
+
return [int.from_bytes(d[4 * i:4 * i + 4], "big") % m for i in range(FILTER_PROBES)]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _filter_build(keys):
|
|
278
|
+
keys = list(keys)
|
|
279
|
+
m = max(64, FILTER_BITS_PER_KEY * len(keys))
|
|
280
|
+
m += -m % 8
|
|
281
|
+
bits = bytearray(m // 8)
|
|
282
|
+
for key in keys:
|
|
283
|
+
for b in _filter_probes(key, m):
|
|
284
|
+
bits[b >> 3] |= 1 << (b & 7)
|
|
285
|
+
return [m, base64.b64encode(bytes(bits)).decode("ascii")]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _filter_absent(flt, key):
|
|
289
|
+
"""True only when `flt` is well-formed and proves `key` absent."""
|
|
290
|
+
try:
|
|
291
|
+
m, enc = flt
|
|
292
|
+
if isinstance(m, bool) or not isinstance(m, int) or m <= 0 or m % 8 or not isinstance(enc, str):
|
|
293
|
+
return False
|
|
294
|
+
bits = base64.b64decode(enc.encode("ascii"), validate=True)
|
|
295
|
+
if len(bits) * 8 != m:
|
|
296
|
+
return False
|
|
297
|
+
except (TypeError, ValueError, UnicodeError):
|
|
321
298
|
return False
|
|
299
|
+
return any(not bits[b >> 3] & (1 << (b & 7)) for b in _filter_probes(key, m))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _fresh_summary(sid, now):
|
|
303
|
+
return {"v": 2, "sid": sid, "first_ts": now, "last_ts": now, "reg": _usagelib.REGISTRY_VERSION,
|
|
304
|
+
"seq": 0, "hot_seq": 0, "parts": {}, "mid_only": 0, "src": {}, "cursor": "",
|
|
305
|
+
"tot": {"in": 0, "out": 0, "pd": 0}, "rc": {}, "days": {}, "unk_time": 0,
|
|
306
|
+
"host": {"latest": None, "max": None, "anom": False, "missing": False, "seen": False},
|
|
307
|
+
"burn": [], "flags": {}, "cov": {}}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _summary_valid(rec, sid):
|
|
322
311
|
try:
|
|
323
|
-
|
|
324
|
-
|
|
312
|
+
return (rec.get("v") == 2 and rec.get("sid") == sid
|
|
313
|
+
and isinstance(rec.get("src"), dict) and isinstance(rec.get("tot"), dict)
|
|
314
|
+
and isinstance(rec.get("days"), dict) and isinstance(rec.get("parts"), dict)
|
|
315
|
+
and isinstance(rec.get("host"), dict) and isinstance(rec.get("burn"), list)
|
|
316
|
+
and isinstance(rec.get("rc"), dict) and isinstance(rec.get("seq"), int))
|
|
317
|
+
except AttributeError:
|
|
325
318
|
return False
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class _Store:
|
|
322
|
+
"""Evidence for one session: a bounded LRU hot map plus cold hash-prefix
|
|
323
|
+
partitions. Every file carries the summary sequence number it was written
|
|
324
|
+
under; a mismatch on load means a torn commit and raises _Inconsistent."""
|
|
325
|
+
|
|
326
|
+
def __init__(self, path, sid, rec):
|
|
327
|
+
self.path, self.sid, self.rec = path, sid, rec
|
|
328
|
+
self.hot = None
|
|
329
|
+
self.hot_dirty = False
|
|
330
|
+
self.parts = {}
|
|
331
|
+
self.parts_dirty = set()
|
|
332
|
+
self.grown = set() # partitions whose key set grew: refilter
|
|
333
|
+
self.obsolete = []
|
|
334
|
+
|
|
335
|
+
def _part_file(self, prefix):
|
|
336
|
+
return os.path.join(_ev_dir(self.path, self.sid), f"p{prefix}.json")
|
|
337
|
+
|
|
338
|
+
def _load_hot(self):
|
|
339
|
+
if self.hot is None:
|
|
340
|
+
want = self.rec.get("hot_seq", 0)
|
|
341
|
+
if want:
|
|
342
|
+
v = _read_json(_hot_file(self.path, self.sid))
|
|
343
|
+
if not (isinstance(v, dict) and v.get("seq") == want
|
|
344
|
+
and isinstance(v.get("ev"), dict) and isinstance(v.get("order"), list)):
|
|
345
|
+
raise _Inconsistent
|
|
346
|
+
flt = v.get("f")
|
|
347
|
+
self.hot = {"ev": v["ev"], "order": [k for k in v["order"] if k in v["ev"]],
|
|
348
|
+
"f": dict(flt) if isinstance(flt, dict) else {}}
|
|
349
|
+
else:
|
|
350
|
+
self.hot = {"ev": {}, "order": [], "f": {}}
|
|
351
|
+
return self.hot
|
|
352
|
+
|
|
353
|
+
def _leaf(self, h):
|
|
354
|
+
parts = self.rec["parts"]
|
|
355
|
+
for k in range(len(h) + 1):
|
|
356
|
+
if h[:k] in parts:
|
|
357
|
+
return h[:k]
|
|
358
|
+
raise _Inconsistent
|
|
359
|
+
|
|
360
|
+
def _load_part(self, prefix):
|
|
361
|
+
if prefix not in self.parts:
|
|
362
|
+
want = self.rec["parts"].get(prefix, 0)
|
|
363
|
+
if not want:
|
|
364
|
+
self.parts[prefix] = {}
|
|
365
|
+
else:
|
|
366
|
+
v = _read_json(self._part_file(prefix))
|
|
367
|
+
if not (isinstance(v, dict) and v.get("seq") == want and isinstance(v.get("ev"), dict)):
|
|
368
|
+
raise _Inconsistent
|
|
369
|
+
self.parts[prefix] = v["ev"]
|
|
370
|
+
return self.parts[prefix]
|
|
371
|
+
|
|
372
|
+
def get(self, key):
|
|
373
|
+
"""(entry, location) where location is "hot" or a partition prefix."""
|
|
374
|
+
hot = self._load_hot()
|
|
375
|
+
if key in hot["ev"]:
|
|
376
|
+
return hot["ev"][key], "hot"
|
|
377
|
+
if self.rec["parts"]:
|
|
378
|
+
prefix = self._leaf(_ident_hash(key))
|
|
379
|
+
if prefix not in self.parts and (
|
|
380
|
+
not self.rec["parts"].get(prefix)
|
|
381
|
+
or _filter_absent(hot["f"].get(prefix), key)):
|
|
382
|
+
return None, None
|
|
383
|
+
part = self._load_part(prefix)
|
|
384
|
+
if key in part:
|
|
385
|
+
return part[key], prefix
|
|
386
|
+
return None, None
|
|
387
|
+
|
|
388
|
+
def put(self, key, entry, where):
|
|
389
|
+
if where is None or where == "hot":
|
|
390
|
+
hot = self._load_hot()
|
|
391
|
+
if key in hot["ev"]:
|
|
392
|
+
hot["order"].remove(key)
|
|
393
|
+
hot["ev"][key] = entry
|
|
394
|
+
hot["order"].append(key)
|
|
395
|
+
self.hot_dirty = True
|
|
396
|
+
else:
|
|
397
|
+
part = self._load_part(where)
|
|
398
|
+
if key not in part:
|
|
399
|
+
self.grown.add(where) # keep the filter a superset
|
|
400
|
+
part[key] = entry
|
|
401
|
+
self.parts_dirty.add(where)
|
|
402
|
+
|
|
403
|
+
def delete(self, key, where):
|
|
404
|
+
if where == "hot":
|
|
405
|
+
hot = self._load_hot()
|
|
406
|
+
hot["ev"].pop(key, None)
|
|
407
|
+
if key in hot["order"]:
|
|
408
|
+
hot["order"].remove(key)
|
|
409
|
+
self.hot_dirty = True
|
|
410
|
+
elif where is not None:
|
|
411
|
+
self._load_part(where).pop(key, None)
|
|
412
|
+
self.parts_dirty.add(where)
|
|
413
|
+
|
|
414
|
+
def evict(self):
|
|
415
|
+
hot = self.hot
|
|
416
|
+
if hot is None or len(hot["ev"]) <= HOT_MAX:
|
|
417
|
+
return
|
|
418
|
+
if not self.rec["parts"]:
|
|
419
|
+
self.rec["parts"][""] = 0
|
|
420
|
+
n = len(hot["ev"]) - HOT_MAX // 2
|
|
421
|
+
victims, hot["order"] = hot["order"][:n], hot["order"][n:]
|
|
422
|
+
for key in victims:
|
|
423
|
+
entry = hot["ev"].pop(key)
|
|
424
|
+
prefix = self._leaf(_ident_hash(key))
|
|
425
|
+
self._load_part(prefix)[key] = entry
|
|
426
|
+
self.parts_dirty.add(prefix)
|
|
427
|
+
self.grown.add(prefix)
|
|
428
|
+
self.hot_dirty = True
|
|
429
|
+
for prefix in list(self.parts_dirty):
|
|
430
|
+
self._split(prefix)
|
|
431
|
+
|
|
432
|
+
def _split(self, prefix):
|
|
433
|
+
part = self._load_part(prefix)
|
|
434
|
+
if len(part) <= PART_MAX or len(prefix) >= 64:
|
|
435
|
+
return
|
|
436
|
+
if self.rec["parts"].get(prefix):
|
|
437
|
+
self.obsolete.append(self._part_file(prefix))
|
|
438
|
+
del self.rec["parts"][prefix]
|
|
439
|
+
del self.parts[prefix]
|
|
440
|
+
self.parts_dirty.discard(prefix)
|
|
441
|
+
children = {prefix + c: {} for c in _HEX}
|
|
442
|
+
for key, entry in part.items():
|
|
443
|
+
children[prefix + _ident_hash(key)[len(prefix)]][key] = entry
|
|
444
|
+
for child, entries in children.items():
|
|
445
|
+
self.rec["parts"][child] = 0
|
|
446
|
+
self.parts[child] = entries
|
|
447
|
+
self.parts_dirty.add(child)
|
|
448
|
+
self.grown.add(child)
|
|
449
|
+
for child in children:
|
|
450
|
+
self._split(child)
|
|
451
|
+
|
|
452
|
+
def all_locations(self):
|
|
453
|
+
"""Every (key, entry, location) — a full walk, only for rebuilds."""
|
|
454
|
+
hot = self._load_hot()
|
|
455
|
+
for key in list(hot["ev"]):
|
|
456
|
+
yield key, hot["ev"][key], "hot"
|
|
457
|
+
for prefix in list(self.rec["parts"]):
|
|
458
|
+
part = self._load_part(prefix)
|
|
459
|
+
for key in list(part):
|
|
460
|
+
yield key, part[key], prefix
|
|
461
|
+
|
|
462
|
+
def flush(self, seq):
|
|
463
|
+
"""Write dirty evidence under `seq`; False on any failure (the caller
|
|
464
|
+
must then NOT commit the summary)."""
|
|
465
|
+
for prefix in sorted(self.parts_dirty):
|
|
466
|
+
if prefix not in self.rec["parts"]:
|
|
467
|
+
continue
|
|
468
|
+
entries = self.parts.get(prefix, {})
|
|
469
|
+
if entries:
|
|
470
|
+
if not _atomic_json(self._part_file(prefix), {"seq": seq, "ev": entries}):
|
|
471
|
+
return False
|
|
472
|
+
self.rec["parts"][prefix] = seq
|
|
473
|
+
else:
|
|
474
|
+
if self.rec["parts"].get(prefix):
|
|
475
|
+
self.obsolete.append(self._part_file(prefix))
|
|
476
|
+
self.rec["parts"][prefix] = 0
|
|
477
|
+
if self.grown:
|
|
478
|
+
hot = self._load_hot()
|
|
479
|
+
for prefix in self.grown:
|
|
480
|
+
if self.parts.get(prefix):
|
|
481
|
+
hot["f"][prefix] = _filter_build(self.parts[prefix])
|
|
482
|
+
self.hot_dirty = True
|
|
483
|
+
if self.hot_dirty:
|
|
484
|
+
hot = self._load_hot()
|
|
485
|
+
live = {p: f for p, f in hot["f"].items() if self.rec["parts"].get(p)}
|
|
486
|
+
if not _atomic_json(_hot_file(self.path, self.sid),
|
|
487
|
+
{"seq": seq, "ev": hot["ev"], "order": hot["order"], "f": live}):
|
|
488
|
+
return False
|
|
489
|
+
self.rec["hot_seq"] = seq
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _bump(counts, reason, sign):
|
|
494
|
+
n = counts.get(reason, 0) + sign
|
|
495
|
+
if n:
|
|
496
|
+
counts[reason] = n
|
|
497
|
+
else:
|
|
498
|
+
counts.pop(reason, None)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _contrib(entry):
|
|
502
|
+
"""Priced contribution of one request's accepted evidence."""
|
|
503
|
+
facts = entry["s"]
|
|
504
|
+
acc = None
|
|
505
|
+
for src in sorted(facts):
|
|
506
|
+
acc = _usagelib.merge(acc, facts[src])
|
|
507
|
+
p = _usagelib.price(acc)
|
|
508
|
+
return {"o": "p" if "p" in facts else min(facts), "day": _usagelib.event_day(acc),
|
|
509
|
+
"days": _usagelib.days_seen(acc), "in": p["in"], "out": p["out"], "pd": p["pd"],
|
|
510
|
+
"r": sorted(p["reasons"])}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _apply(rec, c, sign):
|
|
514
|
+
"""Add (sign=+1) or remove (sign=-1) one contribution from every derived index."""
|
|
515
|
+
tot = rec["tot"]
|
|
516
|
+
tot["in"] += sign * c["in"]
|
|
517
|
+
tot["out"] += sign * c["out"]
|
|
518
|
+
tot["pd"] += sign * c["pd"]
|
|
519
|
+
for r in c["r"]:
|
|
520
|
+
_bump(rec["rc"], r, sign)
|
|
521
|
+
if c["day"]:
|
|
522
|
+
day = rec["days"].setdefault(c["day"], {"in": 0, "out": 0, "pd": 0, "rc": {}})
|
|
523
|
+
day["in"] += sign * c["in"]
|
|
524
|
+
day["out"] += sign * c["out"]
|
|
525
|
+
day["pd"] += sign * c["pd"]
|
|
526
|
+
for r in c["r"]:
|
|
527
|
+
_bump(day["rc"], r, sign)
|
|
528
|
+
if len(c["days"]) > 1:
|
|
529
|
+
for d in c["days"]:
|
|
530
|
+
other = rec["days"].setdefault(d, {"in": 0, "out": 0, "pd": 0, "rc": {}})
|
|
531
|
+
_bump(other["rc"], "conflicting_event_day", sign)
|
|
532
|
+
for d in set(c["days"]) | {c["day"]}:
|
|
533
|
+
b = rec["days"].get(d)
|
|
534
|
+
if b is not None and not (b["in"] or b["out"] or b["pd"] or b["rc"]):
|
|
535
|
+
del rec["days"][d]
|
|
536
|
+
else:
|
|
537
|
+
rec["unk_time"] += sign
|
|
538
|
+
src = rec["src"].get(c["o"])
|
|
539
|
+
if src is not None:
|
|
540
|
+
src["tok"][0] += sign * c["in"]
|
|
541
|
+
src["tok"][1] += sign * c["out"]
|
|
542
|
+
src["pd"] += sign * c["pd"]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _burn_touch(rec, key, old, new):
|
|
546
|
+
"""Parent-only burn (spec AC-24): one sample per parent-owned request, in
|
|
547
|
+
order of first acceptance, updated as later snapshots grow it."""
|
|
548
|
+
if new is None or new["o"] != "p":
|
|
549
|
+
return
|
|
550
|
+
tag = _ident_hash(key)[:12]
|
|
551
|
+
value = new["in"] + new["out"]
|
|
552
|
+
for item in rec["burn"]:
|
|
553
|
+
if item[0] == tag:
|
|
554
|
+
item[1] = value
|
|
555
|
+
return
|
|
556
|
+
if old is None or old["o"] != "p":
|
|
557
|
+
rec["burn"].append([tag, value])
|
|
558
|
+
if len(rec["burn"]) > BURN_RING:
|
|
559
|
+
rec["burn"] = rec["burn"][-BURN_RING:]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _find_by_mid(store, mid_key):
|
|
563
|
+
"""(rid key, entry, location) of the request whose stored message id is
|
|
564
|
+
`mid_key`. A full evidence walk: it runs only for a message-ID-only record
|
|
565
|
+
whose alias is not yet known, which the observed corpus never produced for
|
|
566
|
+
billable usage (spec observation 5)."""
|
|
567
|
+
for key, entry, where in list(store.all_locations()):
|
|
568
|
+
if entry.get("m") == mid_key and "s" in entry:
|
|
569
|
+
return key, entry, where
|
|
570
|
+
return None, None, None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _ingest(store, rec, src, key, alias, fact):
|
|
574
|
+
"""Merge one source's fact into its request's evidence and move the derived
|
|
575
|
+
indices by exactly (new contribution - old contribution).
|
|
576
|
+
|
|
577
|
+
Identity is `rid:` when a request id exists. The record's message id is kept
|
|
578
|
+
inside that entry (`m`), so a message-ID-only representation of the same
|
|
579
|
+
request resolves to it in either arrival order without an eager alias entry
|
|
580
|
+
per request (spec D-5)."""
|
|
581
|
+
entry, where = store.get(key)
|
|
582
|
+
if entry is not None and "a" in entry: # message-ID-only record of an aliased request
|
|
583
|
+
key = entry["a"]
|
|
584
|
+
entry, where = store.get(key)
|
|
585
|
+
elif entry is None and key.startswith("mid:"):
|
|
586
|
+
found, fentry, fwhere = _find_by_mid(store, key)
|
|
587
|
+
if found is not None:
|
|
588
|
+
store.put(key, {"a": found}, "hot") # remember: no second walk
|
|
589
|
+
key, entry, where = found, fentry, fwhere
|
|
590
|
+
old = _contrib(entry) if entry is not None and entry.get("s") else None
|
|
591
|
+
if entry is None:
|
|
592
|
+
entry, where = {"s": {}}, None
|
|
593
|
+
folded = False
|
|
594
|
+
if alias and rec.get("mid_only", 0) > 0:
|
|
595
|
+
aentry, awhere = store.get(alias)
|
|
596
|
+
if aentry is not None and aentry.get("s"):
|
|
597
|
+
_apply(rec, _contrib(aentry), -1) # earlier mid-only evidence joins
|
|
598
|
+
for s2, f2 in aentry["s"].items():
|
|
599
|
+
entry["s"][s2] = _usagelib.merge(entry["s"].get(s2), f2)
|
|
600
|
+
rec["mid_only"] -= 1
|
|
601
|
+
store.put(alias, {"a": key}, awhere)
|
|
602
|
+
folded = True
|
|
603
|
+
if alias and entry.get("m") != alias:
|
|
604
|
+
entry["m"] = alias
|
|
605
|
+
folded = folded or old is not None
|
|
606
|
+
prev = entry["s"].get(src)
|
|
607
|
+
merged = _usagelib.merge(prev, fact)
|
|
608
|
+
if not folded and old is not None and merged == prev:
|
|
338
609
|
return False
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
610
|
+
entry["s"][src] = merged
|
|
611
|
+
if old is None and not folded and key.startswith("mid:"):
|
|
612
|
+
rec["mid_only"] = rec.get("mid_only", 0) + 1
|
|
613
|
+
new = _contrib(entry)
|
|
614
|
+
if old is not None:
|
|
615
|
+
_apply(rec, old, -1)
|
|
616
|
+
_apply(rec, new, +1)
|
|
617
|
+
_burn_touch(rec, key, old, new)
|
|
618
|
+
store.put(key, entry, where)
|
|
619
|
+
return True
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _drop_source(store, rec, src_id):
|
|
623
|
+
"""Remove every trace of one source generation (replacement/truncation).
|
|
624
|
+
A full evidence walk — permitted only for this rebuild case (spec D-21)."""
|
|
625
|
+
for key, entry, where in list(store.all_locations()):
|
|
626
|
+
if "s" not in entry or src_id not in entry["s"]:
|
|
627
|
+
continue
|
|
628
|
+
_apply(rec, _contrib(entry), -1)
|
|
629
|
+
del entry["s"][src_id]
|
|
630
|
+
if entry["s"]:
|
|
631
|
+
new = _contrib(entry)
|
|
632
|
+
_apply(rec, new, +1)
|
|
633
|
+
store.put(key, entry, where)
|
|
634
|
+
else:
|
|
635
|
+
store.delete(key, where)
|
|
636
|
+
if key.startswith("mid:"):
|
|
637
|
+
rec["mid_only"] = max(0, rec.get("mid_only", 0) - 1)
|
|
638
|
+
if src_id == "p":
|
|
639
|
+
rec["burn"] = []
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _reprice(store, rec):
|
|
643
|
+
"""Recompute every derived index from evidence (registry version change)."""
|
|
644
|
+
rec["tot"] = {"in": 0, "out": 0, "pd": 0}
|
|
645
|
+
rec["rc"], rec["days"], rec["unk_time"] = {}, {}, 0
|
|
646
|
+
for s in rec["src"].values():
|
|
647
|
+
s["tok"], s["pd"] = [0, 0], 0
|
|
648
|
+
for _key, entry, _where in store.all_locations():
|
|
649
|
+
if "s" in entry:
|
|
650
|
+
_apply(rec, _contrib(entry), +1)
|
|
651
|
+
rec["reg"] = _usagelib.REGISTRY_VERSION
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# --------------------------------------------------------------------------- scanning
|
|
655
|
+
def _stat_key(st):
|
|
656
|
+
return [st.st_size, st.st_mtime_ns, getattr(st, "st_ino", 0) or 0]
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _digest(data):
|
|
660
|
+
return hashlib.sha256(data).hexdigest()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _new_source(kind, locator):
|
|
664
|
+
return {"k": kind, "path": locator, "off": 0, "stat": None, "size": 0, "fp": None,
|
|
665
|
+
"seen": None, "skip": None, "life": "live", "label": None, "label_done": kind == "p",
|
|
666
|
+
"models": [], "tok": [0, 0], "pd": 0, "mt": 0, "unp": 0, "unpp": [], "mal": 0,
|
|
667
|
+
"ovr": 0}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _reset_source(s):
|
|
671
|
+
keep = {k: s[k] for k in ("k", "path")}
|
|
672
|
+
s.clear()
|
|
673
|
+
s.update(_new_source(keep["k"], keep["path"]))
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _verify(f, s, st_key):
|
|
677
|
+
"""True when the stored consumed region is still this file's content."""
|
|
678
|
+
off = s["off"]
|
|
679
|
+
if off == 0 or s["fp"] is None:
|
|
680
|
+
return True
|
|
681
|
+
size = st_key[0]
|
|
682
|
+
if size < off:
|
|
344
683
|
return False
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
684
|
+
old = s.get("seen") or [0, 0, 0]
|
|
685
|
+
head_len, head, tail = s["fp"]
|
|
686
|
+
grew_in_place = old[2] == st_key[2] and size > old[0]
|
|
687
|
+
if not grew_in_place:
|
|
688
|
+
f.seek(0)
|
|
689
|
+
if _digest(f.read(head_len)) != head:
|
|
690
|
+
return False
|
|
691
|
+
start = max(0, off - FP_WINDOW)
|
|
692
|
+
f.seek(start)
|
|
693
|
+
return _digest(f.read(off - start)) == tail
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
class _Scan:
|
|
697
|
+
"""Result of scanning one source within a render's remaining budget."""
|
|
698
|
+
|
|
699
|
+
def __init__(self):
|
|
700
|
+
self.facts, self.bytes, self.records = [], 0, 0
|
|
701
|
+
self.unp, self.unpp, self.mal, self.ovr = 0, [], 0, 0
|
|
702
|
+
self.label, self.models, self.t0 = None, [], None
|
|
703
|
+
self.stop = "budget"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _scan(s, f, byte_budget, record_budget, sid_kind):
|
|
707
|
+
"""Consume complete records from s["off"] of the open file `f` within the
|
|
708
|
+
budgets. Never advances past bytes it did not consume; an oversized line is
|
|
709
|
+
passed deterministically across renders via the persisted `skip` line start."""
|
|
710
|
+
out = _Scan()
|
|
711
|
+
pos = s["off"]
|
|
712
|
+
consumed_tail = b""
|
|
713
|
+
f.seek(pos)
|
|
714
|
+
while out.bytes < byte_budget and out.records < record_budget:
|
|
715
|
+
if s["skip"] is not None:
|
|
716
|
+
line_start = s["skip"]
|
|
717
|
+
line_tail = b""
|
|
718
|
+
done = False
|
|
719
|
+
while out.bytes < byte_budget:
|
|
720
|
+
chunk = f.read(min(65536, byte_budget - out.bytes))
|
|
721
|
+
if not chunk:
|
|
722
|
+
break
|
|
723
|
+
out.bytes += len(chunk)
|
|
724
|
+
nl = chunk.find(b"\n")
|
|
725
|
+
if nl < 0:
|
|
726
|
+
pos += len(chunk)
|
|
727
|
+
line_tail = (line_tail + chunk)[-_usagelib.TAIL_WINDOW:]
|
|
728
|
+
continue
|
|
729
|
+
pos += nl + 1
|
|
730
|
+
line_tail = (line_tail + chunk[:nl + 1])[-_usagelib.TAIL_WINDOW:]
|
|
731
|
+
done = True
|
|
732
|
+
break
|
|
733
|
+
if not done:
|
|
734
|
+
s["off"] = pos
|
|
735
|
+
out.stop = "skip"
|
|
736
|
+
break
|
|
737
|
+
want = min(_usagelib.TAIL_WINDOW, pos - line_start)
|
|
738
|
+
if len(line_tail) < want:
|
|
739
|
+
f.seek(pos - want)
|
|
740
|
+
line_tail = f.read(want)
|
|
741
|
+
out.bytes += want
|
|
742
|
+
if _usagelib.usage_bearing(line_tail):
|
|
743
|
+
out.ovr += 1
|
|
744
|
+
s["skip"] = None
|
|
745
|
+
s["off"] = pos
|
|
746
|
+
out.records += 1
|
|
747
|
+
consumed_tail = line_tail[-FP_WINDOW:]
|
|
748
|
+
f.seek(pos)
|
|
356
749
|
continue
|
|
357
|
-
|
|
358
|
-
|
|
750
|
+
remaining = byte_budget - out.bytes
|
|
751
|
+
limit = min(MAX_RECORD_BYTES, remaining) + 1
|
|
752
|
+
line = f.readline(limit)
|
|
753
|
+
out.bytes += len(line)
|
|
754
|
+
if not line:
|
|
755
|
+
out.stop = "eof"
|
|
359
756
|
break
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
757
|
+
if line.endswith(b"\n"):
|
|
758
|
+
pos += len(line)
|
|
759
|
+
out.records += 1
|
|
760
|
+
consumed_tail = (consumed_tail + line)[-FP_WINDOW:]
|
|
761
|
+
_consume(line, pos - len(line), out, s, sid_kind)
|
|
762
|
+
s["off"] = pos
|
|
363
763
|
continue
|
|
364
|
-
if
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
764
|
+
if len(line) < limit:
|
|
765
|
+
out.stop = "partial" # writer-flushed partial line: wait
|
|
766
|
+
break
|
|
767
|
+
if limit - 1 >= MAX_RECORD_BYTES:
|
|
768
|
+
s["skip"] = pos # oversized: pass it without decoding
|
|
769
|
+
pos += len(line)
|
|
770
|
+
s["off"] = pos
|
|
369
771
|
continue
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if
|
|
375
|
-
|
|
376
|
-
|
|
772
|
+
break # fits the ceiling, not this render's budget
|
|
773
|
+
# Fingerprint the consumed region for replacement detection (spec D-15).
|
|
774
|
+
if s["off"]:
|
|
775
|
+
head_len = min(FP_WINDOW, s["off"])
|
|
776
|
+
if s["fp"] is None or s["fp"][0] < head_len:
|
|
777
|
+
f.seek(0)
|
|
778
|
+
head = _digest(f.read(head_len))
|
|
377
779
|
else:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if e is not None: # earliest message ts = true session start
|
|
386
|
-
t0 = rec.get("t0")
|
|
387
|
-
if not isinstance(t0, (int, float)) or e < t0:
|
|
388
|
-
rec["t0"] = e
|
|
389
|
-
# Dedupe by requestId: the transcript logs each API call multiple times
|
|
390
|
-
# (streaming/replay), so UPSERT each request's final usage exactly once
|
|
391
|
-
# instead of summing every line, which 2-3x over-counts BOTH tokens and cost.
|
|
392
|
-
key = o.get("requestId") or m.get("id") or f"_p{int(off) + parsed}"
|
|
393
|
-
is_new = key not in rec["reqs"]
|
|
394
|
-
rec["reqs"][key] = {"d": _msg_date(ts), "m": model,
|
|
395
|
-
"in": i, "cr": cr, "c5": c5, "c1": c1, "out": out}
|
|
396
|
-
if is_new:
|
|
397
|
-
rec["burn"].append(i + c5 + c1 + out) # fresh input + output (cache reads excluded)
|
|
398
|
-
if len(rec["burn"]) > BURN_RING:
|
|
399
|
-
rec["burn"] = rec["burn"][-BURN_RING:]
|
|
400
|
-
rec["tx_off"] = new_off
|
|
401
|
-
return True
|
|
780
|
+
head_len, head = s["fp"][0], s["fp"][1]
|
|
781
|
+
want = min(FP_WINDOW, s["off"])
|
|
782
|
+
if len(consumed_tail) < want:
|
|
783
|
+
f.seek(s["off"] - want)
|
|
784
|
+
consumed_tail = f.read(want)
|
|
785
|
+
s["fp"] = [head_len, head, _digest(consumed_tail[-want:])]
|
|
786
|
+
return out
|
|
402
787
|
|
|
403
788
|
|
|
404
|
-
def
|
|
405
|
-
""
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if
|
|
789
|
+
def _consume(line, start, out, s, kind):
|
|
790
|
+
if not s["label_done"] and (b'"role":"user"' in line or b'"role": "user"' in line):
|
|
791
|
+
try:
|
|
792
|
+
o = json.loads(line)
|
|
793
|
+
except ValueError:
|
|
794
|
+
o = None
|
|
795
|
+
msg = o.get("message") if isinstance(o, dict) else None
|
|
796
|
+
if isinstance(msg, dict) and msg.get("role") == "user":
|
|
797
|
+
s["label_done"] = True
|
|
798
|
+
if _sub_label is not None:
|
|
799
|
+
label = safe(_sub_label, msg.get("content"))
|
|
800
|
+
s["label"] = label or None
|
|
801
|
+
if not _usagelib.usage_bearing(line):
|
|
802
|
+
return
|
|
803
|
+
try:
|
|
804
|
+
o = json.loads(line)
|
|
805
|
+
except ValueError:
|
|
806
|
+
out.mal += 1
|
|
807
|
+
return
|
|
808
|
+
n = _usagelib.normalize(o, _local_day)
|
|
809
|
+
if n["kind"] == "ignore":
|
|
810
|
+
return
|
|
811
|
+
if n["kind"] == "malformed":
|
|
812
|
+
out.mal += 1
|
|
813
|
+
return
|
|
814
|
+
if n["id"] is None:
|
|
815
|
+
out.unp += 1
|
|
816
|
+
out.unpp.append(start)
|
|
817
|
+
return
|
|
818
|
+
out.facts.append((n["id"], n["alias"], n["fact"]))
|
|
819
|
+
if kind == "p":
|
|
820
|
+
t = n["fact"]["t"]
|
|
821
|
+
if t is not None and (out.t0 is None or t < out.t0):
|
|
822
|
+
out.t0 = t
|
|
823
|
+
else:
|
|
824
|
+
model = o["message"].get("model")
|
|
825
|
+
if isinstance(model, str) and model.strip() and model not in out.models:
|
|
826
|
+
out.models.append(model.strip())
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# --------------------------------------------------------------------------- one render
|
|
830
|
+
def _children_dir(tx):
|
|
831
|
+
return os.path.join(os.path.dirname(tx), os.path.splitext(os.path.basename(tx))[0],
|
|
832
|
+
"subagents")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _discover(rec, tx):
|
|
836
|
+
"""{source id: (path, stat key, mtime)} for the parent and up to
|
|
837
|
+
DISCOVERY_BOUND children, plus whether listing failed / hit the bound."""
|
|
838
|
+
found, failed, bounded = {}, False, False
|
|
839
|
+
try:
|
|
840
|
+
st = os.stat(tx)
|
|
841
|
+
found["p"] = (tx, _stat_key(st), st.st_mtime)
|
|
842
|
+
except OSError:
|
|
843
|
+
pass
|
|
844
|
+
sub = _children_dir(tx)
|
|
845
|
+
count = 0
|
|
846
|
+
try:
|
|
847
|
+
with os.scandir(sub) as it:
|
|
848
|
+
for entry in it:
|
|
849
|
+
if not entry.name.endswith(".jsonl"):
|
|
850
|
+
continue
|
|
851
|
+
if count >= DISCOVERY_BOUND:
|
|
852
|
+
bounded = True
|
|
853
|
+
break
|
|
854
|
+
count += 1
|
|
855
|
+
try:
|
|
856
|
+
st = entry.stat()
|
|
857
|
+
except OSError:
|
|
858
|
+
continue
|
|
859
|
+
found["c:" + entry.name] = (entry.path, _stat_key(st), st.st_mtime)
|
|
860
|
+
except FileNotFoundError:
|
|
861
|
+
pass
|
|
862
|
+
except OSError:
|
|
863
|
+
failed = True
|
|
864
|
+
return found, failed, bounded
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _needs_work(s, stat_key):
|
|
868
|
+
"""A source needs scanning when it changed since it was last caught up. A
|
|
869
|
+
source with deferred work (budget backlog, or an oversized line in progress)
|
|
870
|
+
never records a caught-up stat, so it always compares unequal."""
|
|
871
|
+
return s["stat"] != stat_key
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _account(store, rec, tx):
|
|
875
|
+
"""Scan changed sources within budgets and fold them into the summary."""
|
|
876
|
+
flags = {"list_failed": False, "bound": False, "catching": False}
|
|
877
|
+
if rec["reg"] != _usagelib.REGISTRY_VERSION:
|
|
878
|
+
_reprice(store, rec)
|
|
879
|
+
found, flags["list_failed"], flags["bound"] = _discover(rec, tx)
|
|
880
|
+
for sid_, (path, st_key, mtime) in found.items():
|
|
881
|
+
if sid_ not in rec["src"]:
|
|
882
|
+
rec["src"][sid_] = _new_source("p" if sid_ == "p" else "c",
|
|
883
|
+
path if sid_ == "p" else sid_[2:])
|
|
884
|
+
s = rec["src"][sid_]
|
|
885
|
+
s["life"] = "live"
|
|
886
|
+
s["mt"] = mtime
|
|
887
|
+
if not flags["list_failed"]:
|
|
888
|
+
for sid_, s in rec["src"].items():
|
|
889
|
+
if sid_ not in found:
|
|
890
|
+
s["life"] = "gone"
|
|
891
|
+
elif "p" not in found and "p" in rec["src"]:
|
|
892
|
+
rec["src"]["p"]["life"] = "gone"
|
|
893
|
+
work = [k for k, (_p, st_key, _m) in found.items() if _needs_work(rec["src"][k], st_key)]
|
|
894
|
+
children = [k for k in work if k != "p"]
|
|
895
|
+
ordered = _order_sources(children)
|
|
896
|
+
if rec.get("cursor") and ordered:
|
|
897
|
+
after = [k for k in ordered if k > rec["cursor"]]
|
|
898
|
+
ordered = after + [k for k in ordered if k <= rec["cursor"]] if after else ordered
|
|
899
|
+
plan = (["p"] if "p" in work else []) + ordered
|
|
900
|
+
bytes_left, records_left = ACCT_BYTE_BUDGET, ACCT_RECORD_BUDGET
|
|
901
|
+
opened = 0
|
|
902
|
+
for sid_ in plan:
|
|
903
|
+
if opened >= ACCT_SOURCE_BUDGET or bytes_left <= 0 or records_left <= 0:
|
|
904
|
+
break
|
|
905
|
+
path, st_key, _m = found[sid_]
|
|
906
|
+
s = rec["src"][sid_]
|
|
907
|
+
share_b, share_r = bytes_left, records_left
|
|
908
|
+
if sid_ == "p" and children:
|
|
909
|
+
share_b, share_r = max(1, bytes_left // 2), max(1, records_left // 2)
|
|
910
|
+
opened += 1
|
|
911
|
+
try:
|
|
912
|
+
with open(path, "rb") as f: # one handle: verify, then scan
|
|
913
|
+
if not _verify(f, s, st_key):
|
|
914
|
+
_drop_source(store, rec, sid_)
|
|
915
|
+
_reset_source(s)
|
|
916
|
+
result = _scan(s, f, share_b, share_r, s["k"])
|
|
917
|
+
except OSError:
|
|
412
918
|
continue
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
for
|
|
416
|
-
|
|
919
|
+
bytes_left -= result.bytes
|
|
920
|
+
records_left -= result.records
|
|
921
|
+
for key, alias, fact in result.facts:
|
|
922
|
+
_ingest(store, rec, sid_, key, alias, fact)
|
|
923
|
+
store.evict()
|
|
924
|
+
s["unp"] += result.unp
|
|
925
|
+
s["unpp"] = (s["unpp"] + result.unpp)[-UNPROVEN_KEEP:]
|
|
926
|
+
s["mal"] += result.mal
|
|
927
|
+
s["ovr"] += result.ovr
|
|
928
|
+
for m in result.models:
|
|
929
|
+
if m not in s["models"] and len(s["models"]) < 3:
|
|
930
|
+
s["models"].append(m)
|
|
931
|
+
if result.t0 is not None and (rec.get("t0") is None or result.t0 < rec["t0"]):
|
|
932
|
+
rec["t0"] = result.t0
|
|
933
|
+
s["size"] = st_key[0]
|
|
934
|
+
s["seen"] = st_key
|
|
935
|
+
caught_up = s["skip"] is None and (result.stop in ("eof", "partial")
|
|
936
|
+
or s["off"] >= st_key[0])
|
|
937
|
+
s["stat"] = st_key if caught_up else None
|
|
938
|
+
if sid_ != "p":
|
|
939
|
+
rec["cursor"] = sid_
|
|
940
|
+
for sid_, (_p, st_key, _m) in found.items():
|
|
941
|
+
if _needs_work(rec["src"][sid_], st_key):
|
|
942
|
+
flags["catching"] = True
|
|
943
|
+
return flags
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _session_reasons(rec, flags):
|
|
947
|
+
reasons = set(rec["rc"])
|
|
948
|
+
srcs = rec["src"].values()
|
|
949
|
+
if any(s["unp"] for s in srcs):
|
|
950
|
+
reasons.add("unproven_identity")
|
|
951
|
+
if any(s["mal"] for s in srcs):
|
|
952
|
+
reasons.add("malformed_record")
|
|
953
|
+
if any(s["ovr"] for s in srcs):
|
|
954
|
+
reasons.add("oversized_record")
|
|
955
|
+
if flags.get("bound"):
|
|
956
|
+
reasons.add("discovery_bound")
|
|
957
|
+
if any(s["life"] == "gone" and (s["stat"] is None or s["skip"] is not None)
|
|
958
|
+
for s in srcs):
|
|
959
|
+
reasons.add("source_vanished_with_backlog")
|
|
960
|
+
return reasons
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _session_state(rec, flags, reasons, has_parent):
|
|
964
|
+
if not has_parent and not rec["src"]:
|
|
965
|
+
return "unavailable"
|
|
966
|
+
if flags.get("catching") or flags.get("list_failed"):
|
|
967
|
+
return "catching_up"
|
|
968
|
+
return "partial" if reasons else "complete"
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _update_host(rec, data):
|
|
972
|
+
host = rec["host"]
|
|
973
|
+
v = get(data, "cost", "total_cost_usd") if isinstance(data, dict) else None
|
|
974
|
+
valid = (isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
975
|
+
and math.isfinite(v) and v >= 0)
|
|
976
|
+
if valid:
|
|
977
|
+
v = float(v)
|
|
978
|
+
host["latest"] = v
|
|
979
|
+
host["max"] = v if host["max"] is None else max(host["max"], v)
|
|
980
|
+
host["anom"] = v < host["max"]
|
|
981
|
+
host["missing"] = False
|
|
982
|
+
host["seen"] = True
|
|
983
|
+
elif host["seen"]:
|
|
984
|
+
host["missing"] = True
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _usd(pd):
|
|
988
|
+
return pd / 1e12
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _scope(totals, state, reasons, stale):
|
|
992
|
+
return {"in": float(totals["in"]), "out": float(totals["out"]), "pd": int(totals["pd"]),
|
|
993
|
+
"cost": _usd(totals["pd"]), "state": state, "reasons": sorted(reasons),
|
|
994
|
+
"stale": stale}
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _unavailable(stale=False):
|
|
998
|
+
out = _scope({"in": 0, "out": 0, "pd": 0}, "unavailable", (), stale)
|
|
999
|
+
out.update(host=None, host_delta=None)
|
|
417
1000
|
return out
|
|
418
1001
|
|
|
419
1002
|
|
|
420
|
-
def
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1003
|
+
def _session_output(rec, stale):
|
|
1004
|
+
cov = rec.get("cov") or {}
|
|
1005
|
+
out = _scope(rec["tot"], cov.get("state", "unavailable"), cov.get("reasons", []), stale)
|
|
1006
|
+
out["host"] = rec["host"].get("max")
|
|
1007
|
+
out["host_delta"] = None
|
|
1008
|
+
if out["state"] == "complete" and out["host"] is not None:
|
|
1009
|
+
out["host_delta"] = out["host"] - out["cost"]
|
|
1010
|
+
return out
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _today_output(path, sid, rec, stale, prune):
|
|
1014
|
+
"""Today across every live session's COMPACT summary (never its evidence)."""
|
|
1015
|
+
today = _today()
|
|
1016
|
+
now = time.time()
|
|
1017
|
+
totals = {"in": 0, "out": 0, "pd": 0}
|
|
1018
|
+
reasons, catching, any_data = set(), False, False
|
|
1019
|
+
own_key = _session_key(sid)
|
|
1020
|
+
summaries = []
|
|
1021
|
+
if rec is not None:
|
|
1022
|
+
summaries.append(rec)
|
|
1023
|
+
directory = _session_dir(path)
|
|
1024
|
+
try:
|
|
1025
|
+
names = os.listdir(directory)
|
|
1026
|
+
except OSError:
|
|
1027
|
+
names = []
|
|
1028
|
+
acct_keys = {n[:-10] for n in names if n.endswith(".acct.json")}
|
|
1029
|
+
for name in names:
|
|
1030
|
+
full = os.path.join(directory, name)
|
|
1031
|
+
if name.endswith(".acct.json"):
|
|
1032
|
+
if name[:-10] == own_key:
|
|
1033
|
+
continue
|
|
1034
|
+
payload = _read_json(full)
|
|
1035
|
+
other = payload.get("rec") if isinstance(payload, dict) else None
|
|
1036
|
+
if not (isinstance(other, dict) and _summary_valid(other, payload.get("sid"))):
|
|
1037
|
+
if prune:
|
|
1038
|
+
_remove(full)
|
|
1039
|
+
continue
|
|
1040
|
+
if now - num(other.get("last_ts")) > SESSION_TTL:
|
|
1041
|
+
if prune:
|
|
1042
|
+
_prune_session(path, name[:-10])
|
|
1043
|
+
continue
|
|
1044
|
+
summaries.append(other)
|
|
1045
|
+
elif _LEGACY_SHARD.match(name) and name[:-5] != own_key:
|
|
1046
|
+
try:
|
|
1047
|
+
mtime = os.stat(full).st_mtime
|
|
1048
|
+
except OSError:
|
|
1049
|
+
continue
|
|
1050
|
+
if now - mtime > SESSION_TTL:
|
|
1051
|
+
if prune:
|
|
1052
|
+
_remove(full)
|
|
1053
|
+
if name[:-5] not in acct_keys:
|
|
1054
|
+
_remove(os.path.join(directory, name[:-5] + ".start.json"))
|
|
1055
|
+
elif now - mtime <= 86400 and name[:-5] not in acct_keys:
|
|
1056
|
+
reasons.add("unmigrated_session")
|
|
1057
|
+
for s in summaries:
|
|
1058
|
+
bucket = s["days"].get(today)
|
|
1059
|
+
cov = s.get("cov") or {}
|
|
1060
|
+
active = bucket is not None or s.get("last_day") == today
|
|
1061
|
+
if bucket:
|
|
1062
|
+
any_data = True
|
|
1063
|
+
totals["in"] += bucket["in"]
|
|
1064
|
+
totals["out"] += bucket["out"]
|
|
1065
|
+
totals["pd"] += bucket["pd"]
|
|
1066
|
+
reasons.update(bucket["rc"])
|
|
1067
|
+
if active:
|
|
1068
|
+
if s.get("unk_time"):
|
|
1069
|
+
reasons.add("unknown_event_time")
|
|
1070
|
+
reasons.update(cov.get("src_reasons", []))
|
|
1071
|
+
if cov.get("state") == "catching_up":
|
|
1072
|
+
catching = True
|
|
1073
|
+
if catching:
|
|
1074
|
+
state = "catching_up"
|
|
1075
|
+
elif reasons:
|
|
1076
|
+
state = "partial"
|
|
1077
|
+
elif rec is None and not any_data:
|
|
1078
|
+
state = "unavailable"
|
|
1079
|
+
else:
|
|
1080
|
+
state = "complete"
|
|
1081
|
+
return _scope(totals, state, reasons, stale)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _prune_session(path, key):
|
|
1085
|
+
directory = _session_dir(path)
|
|
1086
|
+
for suffix in (".acct.json", ".hot.json", ".start.json", ".json"):
|
|
1087
|
+
_remove(os.path.join(directory, key + suffix))
|
|
1088
|
+
shutil.rmtree(os.path.join(directory, key + ".ev"), ignore_errors=True)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _load_summary(path, sid):
|
|
1092
|
+
payload = _read_json(_acct_file(path, sid))
|
|
1093
|
+
if (isinstance(payload, dict) and payload.get("schema") == ACCT_SCHEMA
|
|
1094
|
+
and payload.get("sid") == sid and isinstance(payload.get("rec"), dict)
|
|
1095
|
+
and _summary_valid(payload["rec"], sid)):
|
|
1096
|
+
return payload["rec"]
|
|
1097
|
+
return None
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _seed_summary(path, sid, now, keep=None):
|
|
1101
|
+
"""A fresh summary; carries session metadata over from a discarded v2 summary
|
|
1102
|
+
(`keep`) or, once, from the legacy v1 shard — never its cost (spec D-16)."""
|
|
1103
|
+
rec = _fresh_summary(sid, now)
|
|
1104
|
+
if keep:
|
|
1105
|
+
for k in ("first_ts", "sess_start", "t0"):
|
|
1106
|
+
if keep.get(k) is not None:
|
|
1107
|
+
rec[k] = keep[k]
|
|
1108
|
+
if isinstance(keep.get("host"), dict):
|
|
1109
|
+
rec["host"] = keep["host"]
|
|
1110
|
+
return rec
|
|
1111
|
+
legacy = _read_json(_session_file(path, sid))
|
|
1112
|
+
old = legacy.get("rec") if isinstance(legacy, dict) else None
|
|
1113
|
+
if isinstance(old, dict):
|
|
1114
|
+
first = num(old.get("first_ts"), None)
|
|
1115
|
+
if first is not None and math.isfinite(first) and 0 < first <= now:
|
|
1116
|
+
rec["first_ts"] = first
|
|
1117
|
+
hc = old.get("host_cost")
|
|
1118
|
+
if isinstance(hc, (int, float)) and not isinstance(hc, bool) and math.isfinite(hc) and hc > 0:
|
|
1119
|
+
rec["host"].update(max=float(hc), seen=True)
|
|
1120
|
+
return rec
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _discard_evidence(path, sid):
|
|
1124
|
+
_remove(_hot_file(path, sid))
|
|
1125
|
+
shutil.rmtree(_ev_dir(path, sid), ignore_errors=True)
|
|
431
1126
|
|
|
432
1127
|
|
|
433
1128
|
def ledger_update(data, sid):
|
|
434
|
-
|
|
1129
|
+
"""One render's accounting: returns (summary, session, today). Session/today
|
|
1130
|
+
carry in/out tokens, exact picodollars (`pd`), display `cost`, a coverage
|
|
1131
|
+
`state` (complete | catching_up | partial | unavailable), `reasons`, and
|
|
1132
|
+
`stale` (True when this render could not take the lock and is showing the
|
|
1133
|
+
last committed snapshot instead — never fabricated zeros)."""
|
|
435
1134
|
if not sid:
|
|
436
|
-
return {},
|
|
1135
|
+
return {}, _unavailable(), _unavailable()
|
|
437
1136
|
path = ledger_path()
|
|
438
|
-
lock = _acquire_lock(path)
|
|
1137
|
+
lock = _acquire_lock(path, UI_LOCK_WAIT)
|
|
439
1138
|
if lock is None:
|
|
440
|
-
return
|
|
1139
|
+
return _stale_view(path, sid)
|
|
441
1140
|
try:
|
|
442
1141
|
return _ledger_update_unlocked(data, sid, path)
|
|
443
1142
|
finally:
|
|
444
1143
|
_release_lock(lock)
|
|
445
1144
|
|
|
446
1145
|
|
|
1146
|
+
def _stale_view(path, sid):
|
|
1147
|
+
rec = _load_summary(path, sid)
|
|
1148
|
+
if rec is None:
|
|
1149
|
+
return {}, _unavailable(stale=True), _unavailable(stale=True)
|
|
1150
|
+
return rec, _session_output(rec, True), _today_output(path, sid, rec, True, prune=False)
|
|
1151
|
+
|
|
1152
|
+
|
|
447
1153
|
def _ledger_update_unlocked(data, sid, path):
|
|
448
|
-
"""Read-modify-write the per-session ledger. Accumulate the session's TRUE token
|
|
449
|
-
COUNTS by tailing its transcript (deduped per requestId), take the COST from the
|
|
450
|
-
host's cost.total_cost_usd, and return (session record, this-session totals,
|
|
451
|
-
today's totals across sessions). Best-effort; safe blanks on any failure.
|
|
452
|
-
|
|
453
|
-
The statusline re-renders in a fresh process on every refresh, so this is the
|
|
454
|
-
product's hottest filesystem path: each write below is skipped unless it would
|
|
455
|
-
actually change bytes on disk (#392). The on-disk FORMAT is unchanged — the
|
|
456
|
-
per-session shard and the whole-ledger compatibility snapshot are both still
|
|
457
|
-
written exactly as before, just no longer unconditionally."""
|
|
458
|
-
blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
|
|
459
1154
|
now = time.time()
|
|
460
|
-
today = datetime.now().strftime("%Y-%m-%d")
|
|
461
|
-
|
|
462
|
-
sessions, legacy = _merge_sessions(path)
|
|
463
|
-
|
|
464
|
-
rec = sessions.get(sid)
|
|
465
|
-
dirty = not isinstance(rec, dict)
|
|
466
|
-
if dirty:
|
|
467
|
-
rec = {}
|
|
468
|
-
stored_ts = num(rec.get("last_ts"))
|
|
469
|
-
if "first_ts" not in rec:
|
|
470
|
-
rec["first_ts"] = now
|
|
471
|
-
dirty = True
|
|
472
|
-
if rec.get("last_day") != today:
|
|
473
|
-
rec["last_day"] = today
|
|
474
|
-
dirty = True
|
|
475
|
-
host_cost = num(get(data, "cost", "total_cost_usd"))
|
|
476
|
-
if rec.get("host_cost") != host_cost:
|
|
477
|
-
rec["host_cost"] = host_cost
|
|
478
|
-
dirty = True
|
|
479
|
-
|
|
480
1155
|
tx = data.get("transcript_path") if isinstance(data, dict) else None
|
|
481
|
-
if
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1156
|
+
tx = tx if isinstance(tx, str) and tx else None
|
|
1157
|
+
rec = _load_summary(path, sid)
|
|
1158
|
+
on_disk = json.dumps(rec, sort_keys=True) if rec is not None else None
|
|
1159
|
+
if rec is None:
|
|
1160
|
+
stale_disk = os.path.exists(_acct_file(path, sid))
|
|
1161
|
+
_discard_evidence(path, sid)
|
|
1162
|
+
if stale_disk:
|
|
1163
|
+
_remove(_acct_file(path, sid))
|
|
1164
|
+
rec = _seed_summary(path, sid, now)
|
|
1165
|
+
if "sess_start" not in rec:
|
|
1166
|
+
start = _read_json(_start_file(path, sid))
|
|
1167
|
+
value = num(start.get("sess_start"), None) if isinstance(start, dict) else None
|
|
1168
|
+
if value is not None and math.isfinite(value):
|
|
1169
|
+
rec["sess_start"] = float(value)
|
|
1170
|
+
if tx and rec.get("ppath") not in (None, tx):
|
|
1171
|
+
_discard_evidence(path, sid)
|
|
1172
|
+
rec = _seed_summary(path, sid, now, keep=rec)
|
|
1173
|
+
_update_host(rec, data)
|
|
1174
|
+
flags = {"catching": False, "list_failed": False, "bound": False}
|
|
1175
|
+
store = _Store(path, sid, rec)
|
|
1176
|
+
if tx:
|
|
1177
|
+
rec["ppath"] = tx
|
|
1178
|
+
try:
|
|
1179
|
+
flags = _account(store, rec, tx)
|
|
1180
|
+
except _Inconsistent:
|
|
1181
|
+
_discard_evidence(path, sid)
|
|
1182
|
+
rec = _seed_summary(path, sid, now, keep=rec)
|
|
1183
|
+
rec["ppath"] = tx
|
|
1184
|
+
store = _Store(path, sid, rec)
|
|
1185
|
+
flags = _account(store, rec, tx)
|
|
1186
|
+
reasons = _session_reasons(rec, flags)
|
|
1187
|
+
state = _session_state(rec, flags, reasons, tx is not None)
|
|
1188
|
+
src_reasons = sorted(r for r in reasons if r not in rec["rc"])
|
|
1189
|
+
rec["cov"] = {"state": state, "reasons": sorted(reasons), "src_reasons": src_reasons}
|
|
1190
|
+
if rec["days"].get(_today()) is not None or rec.get("last_day") is None:
|
|
1191
|
+
rec["last_day"] = _today()
|
|
1192
|
+
changed = on_disk is None or json.dumps(rec, sort_keys=True) != on_disk
|
|
1193
|
+
if changed or now - num(rec.get("last_ts")) >= LEDGER_HEARTBEAT:
|
|
499
1194
|
rec["last_ts"] = now
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
# session therefore cannot replace this record with an older snapshot. The
|
|
509
|
-
# merged map above already reflects every shard read under this same lock, so
|
|
510
|
-
# there is nothing a re-read could learn — no concurrent writer can have run.
|
|
511
|
-
if dirty:
|
|
512
|
-
_atomic_json(_session_file(path, sid), {"sid": sid, "rec": rec})
|
|
513
|
-
if sessions != legacy:
|
|
514
|
-
# Compatibility/readability cache; shards are truth. Rewritten only when
|
|
515
|
-
# it has drifted from the merged truth — which still covers TTL pruning,
|
|
516
|
-
# a new session, and a legacy-only record that a shard has superseded.
|
|
517
|
-
_write_snapshot(path, sessions)
|
|
518
|
-
|
|
519
|
-
# Today = each session's TODAY bucket (tokens whose transcript timestamp falls
|
|
520
|
-
# on the current local day), summed across sessions — not whole-session totals.
|
|
521
|
-
day = dict(blank)
|
|
522
|
-
for v in sessions.values():
|
|
523
|
-
if not isinstance(v, dict):
|
|
524
|
-
continue
|
|
525
|
-
t = v.get("today") if isinstance(v.get("today"), dict) else None
|
|
526
|
-
if t and t.get("date") == today: # tokens: true per-calendar-day buckets
|
|
527
|
-
day["in"] += num(t.get("in"))
|
|
528
|
-
day["out"] += num(t.get("out"))
|
|
529
|
-
if v.get("last_day") == today: # cost: host per-session total, day-attributed
|
|
530
|
-
day["cost"] += num(v.get("host_cost"))
|
|
531
|
-
return rec, sess, day
|
|
1195
|
+
seq = rec["seq"] + 1
|
|
1196
|
+
if store.flush(seq):
|
|
1197
|
+
rec["seq"] = seq
|
|
1198
|
+
if _atomic_json(_acct_file(path, sid), {"schema": ACCT_SCHEMA, "sid": sid,
|
|
1199
|
+
"seq": seq, "rec": rec}):
|
|
1200
|
+
for obsolete in store.obsolete:
|
|
1201
|
+
_remove(obsolete)
|
|
1202
|
+
return rec, _session_output(rec, False), _today_output(path, sid, rec, False, prune=True)
|
|
532
1203
|
|
|
533
1204
|
|
|
534
1205
|
def persist_sess_start(sid, value):
|
|
535
|
-
"""Write a resolved wall-clock session-start epoch
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
break a render. Returns True iff the ledger was written."""
|
|
1206
|
+
"""Write a resolved wall-clock session-start epoch beside the session's ledger
|
|
1207
|
+
summary so later renders read it instead of re-scanning the host's session
|
|
1208
|
+
metadata directory. Best-effort and idempotent; never breaks a render.
|
|
1209
|
+
Returns True iff the start file was written."""
|
|
540
1210
|
if not sid or not value:
|
|
541
1211
|
return False
|
|
542
1212
|
path = ledger_path()
|
|
@@ -550,23 +1220,16 @@ def persist_sess_start(sid, value):
|
|
|
550
1220
|
|
|
551
1221
|
|
|
552
1222
|
def _persist_sess_start_unlocked(sid, value, path):
|
|
553
|
-
|
|
554
|
-
rec
|
|
555
|
-
if not isinstance(rec, dict):
|
|
1223
|
+
rec = _load_summary(path, sid)
|
|
1224
|
+
if rec is None:
|
|
556
1225
|
return False
|
|
557
1226
|
if num(rec.get("sess_start"), None) == float(value):
|
|
558
|
-
return False # already cached — nothing to do
|
|
559
|
-
# Cache metadata has its own file, so it cannot overwrite a concurrently
|
|
560
|
-
# refreshed token/cost shard for the same session.
|
|
561
|
-
if not _atomic_json(_start_file(path, sid),
|
|
562
|
-
{"sid": sid, "sess_start": float(value)}):
|
|
563
1227
|
return False
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
return True
|
|
1228
|
+
current = _read_json(_start_file(path, sid))
|
|
1229
|
+
if isinstance(current, dict) and num(current.get("sess_start"), None) == float(value):
|
|
1230
|
+
return False
|
|
1231
|
+
# Its own file, so it can never overwrite a concurrently refreshed summary.
|
|
1232
|
+
return _atomic_json(_start_file(path, sid), {"sid": sid, "sess_start": float(value)})
|
|
570
1233
|
|
|
571
1234
|
|
|
572
1235
|
# --------------------------------------------------------------------------- Pi usage ledger
|
|
@@ -1027,9 +1690,13 @@ def _pi_ledger_update_unlocked(path, session_key, scan_start, scan_end, facts):
|
|
|
1027
1690
|
|
|
1028
1691
|
|
|
1029
1692
|
def burn_samples(rec):
|
|
1030
|
-
"""Recent per-
|
|
1031
|
-
|
|
1032
|
-
estimate. Returns [] when there is too little data to draw a line. The
|
|
1033
|
-
turns this list into a colored sparkline; this lib stays render-free."""
|
|
1034
|
-
|
|
1035
|
-
|
|
1693
|
+
"""Recent per-request token-burn values (most-recent window) for the sparkline:
|
|
1694
|
+
one sample per accepted PARENT request (spec AC-24), not a time-extrapolated
|
|
1695
|
+
estimate. Returns [] when there is too little data to draw a line. The
|
|
1696
|
+
statusline turns this list into a colored sparkline; this lib stays render-free."""
|
|
1697
|
+
out = []
|
|
1698
|
+
for item in (rec.get("burn") or []) if isinstance(rec, dict) else []:
|
|
1699
|
+
v = item[1] if isinstance(item, list) and len(item) == 2 else item
|
|
1700
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
1701
|
+
out.append(float(v))
|
|
1702
|
+
return out[-24:] if len(out) >= 2 else []
|