cctally 1.114.0 → 1.115.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 +25 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4394 -279
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +178 -2
- package/bin/_cctally_dashboard.py +2 -2
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +316 -5
- package/bin/_cctally_doctor.py +436 -27
- package/bin/_cctally_five_hour.py +82 -31
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +3884 -112
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +26 -4
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +677 -149
- package/bin/_cctally_rederive.py +297 -32
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +6137 -517
- package/bin/_cctally_store.py +287 -48
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +57 -53
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +452 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +29 -302
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_rederive.py +94 -5
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/cctally +23 -1
- package/package.json +14 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Codex rollout containers (#968 spec 5.1, 5.4).
|
|
2
|
+
|
|
3
|
+
A dependency-light leaf: it imports only the standard library and nothing from
|
|
4
|
+
cache, journal, configuration or CLI modules. It owns
|
|
5
|
+
|
|
6
|
+
* logical normalization (``X.jsonl.zst`` -> ``X.jsonl``) — every persisted key is
|
|
7
|
+
the logical path, whatever container holds the bytes;
|
|
8
|
+
* container selection (plain wins, else the ``.zst`` twin, else none);
|
|
9
|
+
* the call-time decoder chain (stdlib ``compression.zstd`` on 3.14+, then
|
|
10
|
+
``backports.zstd``, then a typed unavailable result);
|
|
11
|
+
* a decoded binary stream whose ``tell``/``seek`` speak DECODED offsets, so a
|
|
12
|
+
compressed rollout yields exactly the offsets its plain twin would;
|
|
13
|
+
* container fingerprints, full-stream validation and the content anchor chain.
|
|
14
|
+
|
|
15
|
+
JSONL semantics stay in ``_lib_jsonl``; its readers receive these streams.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import stat as _stat
|
|
25
|
+
from typing import Callable, NamedTuple, Optional, Union
|
|
26
|
+
|
|
27
|
+
PLAIN = "plain"
|
|
28
|
+
ZSTD = "zstd"
|
|
29
|
+
PLAIN_SUFFIX = ".jsonl"
|
|
30
|
+
ZSTD_SUFFIX = ".jsonl.zst"
|
|
31
|
+
DECODE_CHUNK = 1 << 20
|
|
32
|
+
READ_CHUNK = 64 << 10
|
|
33
|
+
ANCHOR_VERSION = 1
|
|
34
|
+
_ANCHOR_SEED = hashlib.sha256(b"cctally-codex-content-anchor-v1\0").digest()
|
|
35
|
+
|
|
36
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
37
|
+
StopPredicate = Optional[Callable[[], bool]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ZstdBackend(NamedTuple):
|
|
41
|
+
name: str
|
|
42
|
+
module: object
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ZstdUnavailable(NamedTuple):
|
|
46
|
+
reason: str = "decoder-unavailable"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
UNAVAILABLE = ZstdUnavailable()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_zstd_backend() -> "ZstdBackend | ZstdUnavailable":
|
|
53
|
+
"""Resolve the decoder NOW (U1). Never cached; never reads an env switch.
|
|
54
|
+
|
|
55
|
+
Tests replace this module attribute with ``lambda: UNAVAILABLE``; every
|
|
56
|
+
caller reaches it through the module, so the replacement is honored.
|
|
57
|
+
Production code has no flag that does the same.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
from compression import zstd as module # Python 3.14+
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
return ZstdBackend("stdlib", module)
|
|
65
|
+
try:
|
|
66
|
+
from backports import zstd as module # type: ignore[no-redef]
|
|
67
|
+
except ImportError:
|
|
68
|
+
return UNAVAILABLE
|
|
69
|
+
return ZstdBackend("backports.zstd", module)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def decoder_capability() -> str:
|
|
73
|
+
backend = resolve_zstd_backend()
|
|
74
|
+
return backend.name if isinstance(backend, ZstdBackend) else "unavailable"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ContainerError(OSError):
|
|
78
|
+
"""A container could not be read; ``category`` is bounded and privacy-safe.
|
|
79
|
+
|
|
80
|
+
Subclassing OSError keeps every existing ``except OSError`` per-file
|
|
81
|
+
failure path (keep the accepted state, retry later) correct by default;
|
|
82
|
+
callers that must tell a missing decoder from a missing file test for this
|
|
83
|
+
class FIRST.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
CATEGORIES = frozenset({
|
|
87
|
+
"decoder-unavailable", "decode-error", "read-error", "unstable-stat"})
|
|
88
|
+
|
|
89
|
+
def __init__(self, category: str):
|
|
90
|
+
if category not in self.CATEGORIES:
|
|
91
|
+
raise ValueError(f"unknown container error category {category!r}")
|
|
92
|
+
super().__init__(category)
|
|
93
|
+
self.category = category
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ContainerReadInterrupted(Exception):
|
|
97
|
+
"""The caller's stop predicate fired inside a read or seek.
|
|
98
|
+
|
|
99
|
+
Nothing partial was returned: the stream position is unchanged for a seek,
|
|
100
|
+
and no line was handed out for a read.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def logical_path(p: PathLike):
|
|
105
|
+
"""Remove ONE terminal ``.zst`` from a ``*.jsonl.zst`` name; idempotent."""
|
|
106
|
+
if isinstance(p, pathlib.PurePath):
|
|
107
|
+
name = p.name
|
|
108
|
+
return p.with_name(name[:-4]) if name.endswith(ZSTD_SUFFIX) else p
|
|
109
|
+
text = os.fspath(p)
|
|
110
|
+
return text[:-4] if text.endswith(ZSTD_SUFFIX) else text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_container_name(name: str) -> bool:
|
|
114
|
+
return name.endswith(PLAIN_SUFFIX) or name.endswith(ZSTD_SUFFIX)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def zstd_twin(logical: PathLike) -> pathlib.Path:
|
|
118
|
+
return pathlib.Path(os.fspath(logical_path(logical)) + ".zst")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ContainerFingerprint(NamedTuple):
|
|
122
|
+
kind: str
|
|
123
|
+
path: str
|
|
124
|
+
size: int
|
|
125
|
+
mtime_ns: int
|
|
126
|
+
device: int
|
|
127
|
+
inode: int
|
|
128
|
+
|
|
129
|
+
def watch_sig(self) -> tuple:
|
|
130
|
+
"""The live-tail signature: plain keeps today's size; zstd binds the
|
|
131
|
+
container's identity, device included, exactly as the zstd generation
|
|
132
|
+
token and every compressed container-unchanged check do (review P3-2,
|
|
133
|
+
round-2 N9), so a remounted container is observed as a change once."""
|
|
134
|
+
if self.kind == PLAIN:
|
|
135
|
+
return (PLAIN, int(self.size))
|
|
136
|
+
return (ZSTD, int(self.size), int(self.mtime_ns), int(self.device),
|
|
137
|
+
int(self.inode))
|
|
138
|
+
|
|
139
|
+
def to_json(self) -> str:
|
|
140
|
+
return json.dumps(list(self), separators=(",", ":"))
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_json(cls, text) -> "ContainerFingerprint | None":
|
|
144
|
+
try:
|
|
145
|
+
kind, path, size, mtime_ns, device, inode = json.loads(text)
|
|
146
|
+
if kind not in (PLAIN, ZSTD):
|
|
147
|
+
return None
|
|
148
|
+
return cls(str(kind), str(path), int(size), int(mtime_ns),
|
|
149
|
+
int(device), int(inode))
|
|
150
|
+
except (TypeError, ValueError):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _regular_stat(path: PathLike):
|
|
155
|
+
try:
|
|
156
|
+
st = os.stat(path)
|
|
157
|
+
except OSError:
|
|
158
|
+
return None
|
|
159
|
+
return st if _stat.S_ISREG(st.st_mode) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fingerprint_of(path: PathLike, kind: str) -> "ContainerFingerprint | None":
|
|
163
|
+
st = _regular_stat(path)
|
|
164
|
+
if st is None:
|
|
165
|
+
return None
|
|
166
|
+
return ContainerFingerprint(kind, os.fspath(path), int(st.st_size),
|
|
167
|
+
int(st.st_mtime_ns), int(st.st_dev),
|
|
168
|
+
int(st.st_ino))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SelectedContainer(NamedTuple):
|
|
172
|
+
logical: pathlib.Path
|
|
173
|
+
kind: str
|
|
174
|
+
path: pathlib.Path
|
|
175
|
+
fingerprint: ContainerFingerprint
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def select_container(logical: PathLike) -> "SelectedContainer | None":
|
|
179
|
+
"""Plain when it exists, otherwise the ``.zst`` twin, otherwise None (5.2)."""
|
|
180
|
+
lp = pathlib.Path(logical_path(os.fspath(logical)))
|
|
181
|
+
fp = fingerprint_of(lp, PLAIN)
|
|
182
|
+
if fp is not None:
|
|
183
|
+
return SelectedContainer(lp, PLAIN, lp, fp)
|
|
184
|
+
twin = zstd_twin(lp)
|
|
185
|
+
fp = fingerprint_of(twin, ZSTD)
|
|
186
|
+
if fp is not None:
|
|
187
|
+
return SelectedContainer(lp, ZSTD, twin, fp)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def observe_watch_sig(logical: PathLike) -> "tuple | None":
|
|
192
|
+
selected = select_container(logical)
|
|
193
|
+
return None if selected is None else selected.fingerprint.watch_sig()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class DecodedZstdStream:
|
|
197
|
+
"""A binary reader over one zstd container that speaks DECODED offsets.
|
|
198
|
+
|
|
199
|
+
``_buf`` holds decoded bytes ``[_buf_start, _raw_pos)``. ``readline`` keeps
|
|
200
|
+
every byte from the start of the line it returned, so a torn-line rewind
|
|
201
|
+
(``seek(line_start)``) is served from memory. A forward seek decodes and
|
|
202
|
+
discards bounded chunks; a backward seek beyond the window restarts the
|
|
203
|
+
decoder. The stop predicate is consulted between chunks inside large reads
|
|
204
|
+
and seeks, and a stop raises ``ContainerReadInterrupted`` without moving
|
|
205
|
+
the position.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, path: PathLike, backend: ZstdBackend, *,
|
|
209
|
+
should_stop: StopPredicate = None):
|
|
210
|
+
self._path = os.fspath(path)
|
|
211
|
+
self._backend = backend
|
|
212
|
+
self._should_stop = should_stop
|
|
213
|
+
self._raw = None
|
|
214
|
+
self._closed = False
|
|
215
|
+
self._open_raw()
|
|
216
|
+
self._pos = 0
|
|
217
|
+
|
|
218
|
+
# -- plumbing ---------------------------------------------------------
|
|
219
|
+
def _open_raw(self) -> None:
|
|
220
|
+
if self._raw is not None:
|
|
221
|
+
self._raw.close()
|
|
222
|
+
try:
|
|
223
|
+
self._raw = self._backend.module.ZstdFile(self._path, "rb")
|
|
224
|
+
except OSError:
|
|
225
|
+
raise ContainerError("read-error") from None
|
|
226
|
+
self._raw_pos = 0
|
|
227
|
+
self._buf = bytearray()
|
|
228
|
+
self._buf_start = 0
|
|
229
|
+
self._eof = False
|
|
230
|
+
|
|
231
|
+
def _check_stop(self) -> None:
|
|
232
|
+
if self._should_stop is not None and self._should_stop():
|
|
233
|
+
raise ContainerReadInterrupted()
|
|
234
|
+
|
|
235
|
+
def _fill(self, size: int) -> int:
|
|
236
|
+
if self._eof:
|
|
237
|
+
return 0
|
|
238
|
+
self._check_stop()
|
|
239
|
+
decode_errors = (EOFError,)
|
|
240
|
+
zstd_error = getattr(self._backend.module, "ZstdError", None)
|
|
241
|
+
if isinstance(zstd_error, type):
|
|
242
|
+
decode_errors = (EOFError, zstd_error)
|
|
243
|
+
try:
|
|
244
|
+
chunk = self._raw.read(min(size, DECODE_CHUNK))
|
|
245
|
+
except decode_errors:
|
|
246
|
+
raise ContainerError("decode-error") from None
|
|
247
|
+
except OSError:
|
|
248
|
+
raise ContainerError("read-error") from None
|
|
249
|
+
if not chunk:
|
|
250
|
+
self._eof = True
|
|
251
|
+
return 0
|
|
252
|
+
self._buf += chunk
|
|
253
|
+
self._raw_pos += len(chunk)
|
|
254
|
+
return len(chunk)
|
|
255
|
+
|
|
256
|
+
def _drop_before(self, keep_from: int) -> None:
|
|
257
|
+
keep_from = min(keep_from, self._raw_pos)
|
|
258
|
+
if keep_from > self._buf_start:
|
|
259
|
+
del self._buf[: keep_from - self._buf_start]
|
|
260
|
+
self._buf_start = keep_from
|
|
261
|
+
|
|
262
|
+
# -- file protocol ----------------------------------------------------
|
|
263
|
+
def readable(self) -> bool:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
def seekable(self) -> bool:
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
def tell(self) -> int:
|
|
270
|
+
return self._pos
|
|
271
|
+
|
|
272
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
273
|
+
if whence == io.SEEK_CUR:
|
|
274
|
+
offset = self._pos + offset
|
|
275
|
+
elif whence != io.SEEK_SET:
|
|
276
|
+
raise io.UnsupportedOperation("decoded streams seek from the start")
|
|
277
|
+
if offset < 0:
|
|
278
|
+
raise ValueError("negative seek position")
|
|
279
|
+
saved = (self._pos,)
|
|
280
|
+
if offset < self._buf_start:
|
|
281
|
+
self._open_raw()
|
|
282
|
+
if offset > self._raw_pos:
|
|
283
|
+
self._buf = bytearray()
|
|
284
|
+
self._buf_start = self._raw_pos
|
|
285
|
+
while self._raw_pos < offset:
|
|
286
|
+
try:
|
|
287
|
+
got = self._fill(min(DECODE_CHUNK, offset - self._raw_pos))
|
|
288
|
+
except ContainerReadInterrupted:
|
|
289
|
+
if saved[0] < self._buf_start:
|
|
290
|
+
self._open_raw()
|
|
291
|
+
self._pos = saved[0]
|
|
292
|
+
raise
|
|
293
|
+
if got == 0:
|
|
294
|
+
break
|
|
295
|
+
self._buf = bytearray()
|
|
296
|
+
self._buf_start = self._raw_pos
|
|
297
|
+
self._pos = offset
|
|
298
|
+
return self._pos
|
|
299
|
+
|
|
300
|
+
def _prepare_read(self) -> None:
|
|
301
|
+
# An interrupted discard may have restarted the decoder while keeping
|
|
302
|
+
# the caller's old decoded position. Materialize it only when needed.
|
|
303
|
+
if self._pos > self._raw_pos and not self._eof:
|
|
304
|
+
self.seek(self._pos)
|
|
305
|
+
|
|
306
|
+
def read(self, size: int = -1) -> bytes:
|
|
307
|
+
if size == 0:
|
|
308
|
+
return b""
|
|
309
|
+
self._prepare_read()
|
|
310
|
+
if self._pos >= self._raw_pos and self._eof:
|
|
311
|
+
return b""
|
|
312
|
+
start = self._pos - self._buf_start
|
|
313
|
+
while size < 0 or len(self._buf) - start < size:
|
|
314
|
+
want = DECODE_CHUNK if size < 0 else max(
|
|
315
|
+
READ_CHUNK, size - (len(self._buf) - start))
|
|
316
|
+
if self._fill(want) == 0:
|
|
317
|
+
break
|
|
318
|
+
end = len(self._buf) if size < 0 else min(len(self._buf), start + size)
|
|
319
|
+
data = bytes(self._buf[start:end])
|
|
320
|
+
self._pos += len(data)
|
|
321
|
+
self._drop_before(self._pos)
|
|
322
|
+
return data
|
|
323
|
+
|
|
324
|
+
def readline(self, limit: int = -1) -> bytes:
|
|
325
|
+
self._prepare_read()
|
|
326
|
+
if self._pos >= self._raw_pos and self._eof:
|
|
327
|
+
return b""
|
|
328
|
+
start = self._pos - self._buf_start
|
|
329
|
+
search_from = start
|
|
330
|
+
bounded = limit is not None and limit >= 0
|
|
331
|
+
while True:
|
|
332
|
+
idx = self._buf.find(b"\n", search_from)
|
|
333
|
+
if idx >= 0:
|
|
334
|
+
end = idx + 1
|
|
335
|
+
if bounded and end - start > limit:
|
|
336
|
+
end = start + limit
|
|
337
|
+
break
|
|
338
|
+
if bounded and len(self._buf) - start >= limit:
|
|
339
|
+
end = start + limit
|
|
340
|
+
break
|
|
341
|
+
search_from = len(self._buf)
|
|
342
|
+
if self._fill(READ_CHUNK) == 0:
|
|
343
|
+
end = len(self._buf)
|
|
344
|
+
break
|
|
345
|
+
line = bytes(self._buf[start:end])
|
|
346
|
+
# Retain from the start of THIS line so a torn-line rewind stays local.
|
|
347
|
+
self._drop_before(self._pos)
|
|
348
|
+
self._pos += len(line)
|
|
349
|
+
return line
|
|
350
|
+
|
|
351
|
+
def close(self) -> None:
|
|
352
|
+
if not self._closed and self._raw is not None:
|
|
353
|
+
self._raw.close()
|
|
354
|
+
self._closed = True
|
|
355
|
+
|
|
356
|
+
def __enter__(self):
|
|
357
|
+
return self
|
|
358
|
+
|
|
359
|
+
def __exit__(self, *exc) -> None:
|
|
360
|
+
self.close()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def open_decoded(container: SelectedContainer, *, should_stop: StopPredicate = None):
|
|
364
|
+
"""Open the selected container as a binary stream with decoded offsets."""
|
|
365
|
+
if container.kind == PLAIN:
|
|
366
|
+
return open(container.path, "rb")
|
|
367
|
+
backend = resolve_zstd_backend()
|
|
368
|
+
if not isinstance(backend, ZstdBackend):
|
|
369
|
+
raise ContainerError("decoder-unavailable")
|
|
370
|
+
return DecodedZstdStream(container.path, backend, should_stop=should_stop)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class StreamValidation(NamedTuple):
|
|
374
|
+
ok: bool
|
|
375
|
+
decoded_length: "int | None"
|
|
376
|
+
complete_line_end: "int | None"
|
|
377
|
+
error: "str | None"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def validate_full_stream(container: SelectedContainer, *,
|
|
381
|
+
should_stop: StopPredicate = None) -> StreamValidation:
|
|
382
|
+
"""Decode the whole stream to frame completion (5.1).
|
|
383
|
+
|
|
384
|
+
Reports the decoded length, the complete-line end (offset after the last
|
|
385
|
+
newline, 0 when none) and any decoder error. A container whose stat moved
|
|
386
|
+
during the pass is ``unstable-stat``. ``ContainerReadInterrupted``
|
|
387
|
+
propagates to the caller, which treats it as a budget stop.
|
|
388
|
+
"""
|
|
389
|
+
total = 0
|
|
390
|
+
last_line_end = 0
|
|
391
|
+
try:
|
|
392
|
+
with open_decoded(container, should_stop=should_stop) as stream:
|
|
393
|
+
while True:
|
|
394
|
+
if should_stop is not None and should_stop():
|
|
395
|
+
raise ContainerReadInterrupted()
|
|
396
|
+
chunk = stream.read(DECODE_CHUNK)
|
|
397
|
+
if not chunk:
|
|
398
|
+
break
|
|
399
|
+
idx = chunk.rfind(b"\n")
|
|
400
|
+
if idx >= 0:
|
|
401
|
+
last_line_end = total + idx + 1
|
|
402
|
+
total += len(chunk)
|
|
403
|
+
except ContainerError as exc:
|
|
404
|
+
return StreamValidation(False, None, None, exc.category)
|
|
405
|
+
except OSError:
|
|
406
|
+
return StreamValidation(False, None, None, "read-error")
|
|
407
|
+
after = fingerprint_of(container.path, container.kind)
|
|
408
|
+
if after != container.fingerprint:
|
|
409
|
+
return StreamValidation(False, None, None, "unstable-stat")
|
|
410
|
+
return StreamValidation(True, total, last_line_end, None)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def anchor_seed() -> bytes:
|
|
414
|
+
return _ANCHOR_SEED
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def anchor_step(prev: bytes, line: bytes) -> bytes:
|
|
418
|
+
"""anchor_n = SHA-256(anchor_{n-1} || SHA-256(line_n)) (5.4)."""
|
|
419
|
+
return hashlib.sha256(prev + hashlib.sha256(line).digest()).digest()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def anchor_over(stream, *, start_offset: int, start_anchor: bytes,
|
|
423
|
+
end_offset: int) -> "tuple[bytes, int]":
|
|
424
|
+
"""Fold every complete line in ``[start_offset, end_offset)``.
|
|
425
|
+
|
|
426
|
+
Stops at a line that is not newline-terminated or would cross
|
|
427
|
+
``end_offset``; returns ``(anchor, offset reached)``.
|
|
428
|
+
"""
|
|
429
|
+
stream.seek(start_offset)
|
|
430
|
+
anchor = start_anchor
|
|
431
|
+
offset = start_offset
|
|
432
|
+
while offset < end_offset:
|
|
433
|
+
line = stream.readline()
|
|
434
|
+
if not line or not line.endswith(b"\n") or offset + len(line) > end_offset:
|
|
435
|
+
break
|
|
436
|
+
anchor = anchor_step(anchor, line)
|
|
437
|
+
offset += len(line)
|
|
438
|
+
return anchor, offset
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class LineAnchorRecorder:
|
|
442
|
+
"""Wrap an ingest stream and fold each complete, sequential line.
|
|
443
|
+
|
|
444
|
+
``_lib_jsonl``'s fused iterator reads strictly sequentially and only ever
|
|
445
|
+
rewinds to the start of a line it rejected (torn tail or stop point), so
|
|
446
|
+
folding a complete line exactly when it starts at the folded frontier gives
|
|
447
|
+
the chain over every complete raw line — malformed and non-emitting lines
|
|
448
|
+
included — without storing per-line digests. ``start_anchor=None`` means
|
|
449
|
+
the cursor carries no resumable anchor (a legacy row); ``result`` is then
|
|
450
|
+
always None.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
def __init__(self, stream, *, start_offset: int,
|
|
454
|
+
start_anchor: "bytes | None"):
|
|
455
|
+
self._s = stream
|
|
456
|
+
self._anchor = start_anchor
|
|
457
|
+
self._folded_to = start_offset
|
|
458
|
+
|
|
459
|
+
def tell(self) -> int:
|
|
460
|
+
return self._s.tell()
|
|
461
|
+
|
|
462
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
463
|
+
return self._s.seek(offset, whence)
|
|
464
|
+
|
|
465
|
+
def read(self, size: int = -1) -> bytes:
|
|
466
|
+
return self._s.read(size)
|
|
467
|
+
|
|
468
|
+
def readline(self, limit: int = -1) -> bytes:
|
|
469
|
+
start = self._s.tell()
|
|
470
|
+
line = self._s.readline(limit)
|
|
471
|
+
if (self._anchor is not None and line.endswith(b"\n")
|
|
472
|
+
and start == self._folded_to):
|
|
473
|
+
self._anchor = anchor_step(self._anchor, line)
|
|
474
|
+
self._folded_to = start + len(line)
|
|
475
|
+
return line
|
|
476
|
+
|
|
477
|
+
def result(self, final_offset: int) -> "bytes | None":
|
|
478
|
+
if self._anchor is None or final_offset != self._folded_to:
|
|
479
|
+
return None
|
|
480
|
+
return self._anchor
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def generation_token(fingerprint: ContainerFingerprint,
|
|
484
|
+
decoded_length: "int | None") -> str:
|
|
485
|
+
"""Bind a container fingerprint and its validated content evidence (5.11).
|
|
486
|
+
|
|
487
|
+
A plain file keeps one generation while it grows in place (appends change
|
|
488
|
+
the scan target, not the generation). A zstd token binds the whole
|
|
489
|
+
selected fingerprint (5.1, 5.11): device, inode, size and mtime, plus the
|
|
490
|
+
validated decoded length, so any change to one of them, a remount that
|
|
491
|
+
renumbers st_dev included, is a new generation. That is stricter than the
|
|
492
|
+
#769 S6 rule (inode decides, device corroborates), which 5.5 permits.
|
|
493
|
+
|
|
494
|
+
The plain token stays inode-only on purpose. An unchanged plain file is
|
|
495
|
+
never re-read, so its cursor's stored device is never refreshed; a device
|
|
496
|
+
in the plain token would make every retained plain file look changed
|
|
497
|
+
after a remount and strand the membership scan and the seal (5.11, U6).
|
|
498
|
+
"""
|
|
499
|
+
if fingerprint.kind == PLAIN:
|
|
500
|
+
return f"plain:{fingerprint.inode}"
|
|
501
|
+
return (f"zstd:{fingerprint.device}:{fingerprint.inode}:"
|
|
502
|
+
f"{fingerprint.size}:{fingerprint.mtime_ns}:{decoded_length}")
|
|
@@ -779,6 +779,9 @@ def neutral_events_preflight(conn: sqlite3.Connection, ref: str) -> dict:
|
|
|
779
779
|
return {"status": "normalization_pending",
|
|
780
780
|
"conversation_key": cref.conversation_key}
|
|
781
781
|
if not q.codex_conversation_exists(conn, cref.conversation_key):
|
|
782
|
+
if q.codex_publication_pending(conn, cref.conversation_key):
|
|
783
|
+
return {"status": "normalization_pending",
|
|
784
|
+
"conversation_key": cref.conversation_key}
|
|
782
785
|
return {"status": "not_found",
|
|
783
786
|
"conversation_key": cref.conversation_key}
|
|
784
787
|
return {"status": "ok", "conversation_key": cref.conversation_key,
|
|
@@ -3295,11 +3295,19 @@ def _scoped_codex_anon_paths(conn, account_key: str):
|
|
|
3295
3295
|
raise RuntimeError("Codex raw threads table missing for scoped anonymization")
|
|
3296
3296
|
return set(), 0, 0
|
|
3297
3297
|
ambiguous = 0
|
|
3298
|
-
|
|
3298
|
+
# Threads can live in the last-good TEMP view, whose schema says nothing
|
|
3299
|
+
# about the physical accounting roots. Resolve roots independently and
|
|
3300
|
+
# bypass account-scope TEMP shadows; the provenance checks below still
|
|
3301
|
+
# decide which of those paths this account may use. A missing/unreadable
|
|
3302
|
+
# physical roots table propagates as a refusal, never a partial vocabulary.
|
|
3303
|
+
roots_table = (
|
|
3304
|
+
"main.codex_source_roots" if conn.execute(
|
|
3305
|
+
"SELECT 1 FROM main.sqlite_master WHERE type='table' "
|
|
3306
|
+
"AND name='codex_source_roots'").fetchone() is not None
|
|
3307
|
+
else "cache_db.codex_source_roots")
|
|
3299
3308
|
root_paths: dict[str, str] = {}
|
|
3300
3309
|
for root_key, root_path in conn.execute(
|
|
3301
|
-
"SELECT source_root_key, canonical_root_path FROM "
|
|
3302
|
-
+ thread_schema + ".codex_source_roots"
|
|
3310
|
+
"SELECT source_root_key, canonical_root_path FROM " + roots_table
|
|
3303
3311
|
):
|
|
3304
3312
|
if isinstance(root_key, str) and isinstance(root_path, str):
|
|
3305
3313
|
if root_path and os.path.isabs(root_path):
|