coding-agent-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,439 @@
1
+ """Exact-session Codex rollout collector: bounded reads of one verified rollout file for telemetry.
2
+
3
+ The collector reads ``CODEX_HOME`` from the request's effective environment. It reports a usage change through
4
+ ``on_change`` and never emits events itself.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import stat as stat_module
14
+ from collections.abc import Callable, Mapping
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ._common import as_int
19
+ from ._usage import TokenCounters
20
+
21
+ log = logging.getLogger("coding_agent_cli.drivers.codex")
22
+
23
+
24
+ class CodexRolloutCollector:
25
+ """Bounded exact-session Codex rollout collector."""
26
+
27
+ MAX_DISCOVERY_ENTRIES = 50
28
+ MAX_DISCOVERY_RETRIES = 3
29
+ MAX_DISCOVERY_DEPTH = 3
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ cwd: Path,
35
+ env: Mapping[str, str],
36
+ session_id: str = "",
37
+ counters: TokenCounters,
38
+ on_change: Callable[[], None] | None = None,
39
+ ) -> None:
40
+ self.cwd = cwd
41
+ self.env = env
42
+ self.session_id = session_id.strip()
43
+ self.counters = counters
44
+ self.on_change = on_change
45
+ self.session_file: Path | None = None
46
+ self.file_verified = False
47
+ self.file_dev: int | None = None
48
+ self.file_ino: int | None = None
49
+ self.header_verified = False
50
+ self.has_verified_context = False
51
+ self.pending_context: tuple[int, int | None] | None = None
52
+ self.budget_exhausted = False
53
+ self.offset = 0
54
+ self.partial_buffer = ""
55
+ self.model_name: str | None = None
56
+ self.is_compacted = False
57
+ self.max_bytes_per_poll = 65536
58
+ self.max_total_bytes = 1048576
59
+ self.total_bytes_read = 0
60
+ self.missing_discovery_retries = 0
61
+
62
+ def set_session_id(self, session_id: str) -> None:
63
+ sid = session_id.strip()
64
+ if sid and sid != self.session_id:
65
+ if self.session_id:
66
+ self._emit_invalidation()
67
+ self.session_id = sid
68
+ self._reset_file_state()
69
+ self.total_bytes_read = 0
70
+ self.missing_discovery_retries = 0
71
+ self.is_compacted = False
72
+
73
+ def _reset_file_state(self) -> None:
74
+ self.session_file = None
75
+ self.file_verified = False
76
+ self.file_dev = None
77
+ self.file_ino = None
78
+ self.header_verified = False
79
+ self.has_verified_context = False
80
+ self.pending_context = None
81
+ self.budget_exhausted = False
82
+ self.offset = 0
83
+ self.partial_buffer = ""
84
+
85
+ def _changed(self) -> None:
86
+ if self.on_change is not None:
87
+ self.on_change()
88
+
89
+ def _emit_invalidation(self, is_compaction: bool = False) -> None:
90
+ self.pending_context = None
91
+ self.has_verified_context = False
92
+ self.counters.clear_context()
93
+ if is_compaction:
94
+ self.is_compacted = True
95
+ self._changed()
96
+
97
+ def _find_exact_file(self) -> Path | None:
98
+ if not self.session_id or not _SESSION_ID_RE.fullmatch(self.session_id):
99
+ return None
100
+ if self.missing_discovery_retries >= self.MAX_DISCOVERY_RETRIES:
101
+ return None
102
+
103
+ sessions_dir = Path(self.env.get("CODEX_HOME") or Path.home() / ".codex") / "sessions"
104
+ if not sessions_dir.is_dir() or sessions_dir.is_symlink():
105
+ self.missing_discovery_retries += 1
106
+ return None
107
+
108
+ try:
109
+ resolved_sessions_dir = sessions_dir.resolve()
110
+ except OSError:
111
+ self.missing_discovery_retries += 1
112
+ return None
113
+
114
+ found_file = self._scan_sessions(sessions_dir, resolved_sessions_dir)
115
+ if found_file is None:
116
+ self.missing_discovery_retries += 1
117
+ return found_file
118
+
119
+ def _scan_sessions(self, sessions_dir: Path, resolved_sessions_dir: Path) -> Path | None:
120
+ entries_inspected = 0
121
+ dirs_to_visit = [(sessions_dir, 0)]
122
+
123
+ while dirs_to_visit and entries_inspected < self.MAX_DISCOVERY_ENTRIES:
124
+ current_dir, depth = dirs_to_visit.pop(0)
125
+ inspected, files, subdirs = self._read_dir(current_dir, self.MAX_DISCOVERY_ENTRIES - entries_inspected)
126
+ entries_inspected += inspected
127
+ for file_path in files:
128
+ if self._verify_rollout_file(file_path, resolved_sessions_dir):
129
+ return file_path
130
+ if depth < self.MAX_DISCOVERY_DEPTH:
131
+ dirs_to_visit.extend((subdir, depth + 1) for subdir in subdirs)
132
+ return None
133
+
134
+ def _read_dir(self, directory: Path, budget: int) -> tuple[int, list[Path], list[Path]]:
135
+ """Inspect up to ``budget`` entries. Return the count and the candidate rollout files and subdirectories."""
136
+ inspected = 0
137
+ files: list[Path] = []
138
+ subdirs: list[Path] = []
139
+ try:
140
+ with os.scandir(directory) as it:
141
+ for entry in it:
142
+ inspected += 1
143
+ if inspected >= budget:
144
+ break
145
+ kind = _entry_kind(entry)
146
+ if kind == "dir":
147
+ subdirs.append(Path(entry.path))
148
+ elif kind == "file" and entry.name.endswith(".jsonl") and self.session_id in entry.name:
149
+ files.append(Path(entry.path))
150
+ except OSError:
151
+ pass
152
+ return inspected, files, subdirs
153
+
154
+ def _verify_rollout_file(self, file_path: Path, resolved_sessions_dir: Path) -> bool:
155
+ """Accept ``file_path`` only when its header names this session and cwd. Record its identity."""
156
+ try:
157
+ if file_path.is_symlink() or not file_path.resolve().is_relative_to(resolved_sessions_dir):
158
+ return False
159
+ stat = file_path.stat()
160
+ if stat.st_size < 10:
161
+ return False
162
+ first = _read_first_line(file_path, stat)
163
+ if first is None or not first[1]:
164
+ return False
165
+ opened, first_line = first
166
+ if not self._header_matches(json.loads(first_line)):
167
+ return False
168
+ except (OSError, json.JSONDecodeError):
169
+ return False
170
+ self.file_dev = opened.st_dev
171
+ self.file_ino = opened.st_ino
172
+ return True
173
+
174
+ def _identity_matches(self, payload: dict[str, Any]) -> bool:
175
+ return str(payload.get("id") or "").strip() == self.session_id and _same_path(payload.get("cwd"), self.cwd)
176
+
177
+ def _header_matches(self, header: Any) -> bool:
178
+ if not isinstance(header, dict) or header.get("type") != "session_meta":
179
+ return False
180
+ payload = header.get("payload")
181
+ return isinstance(payload, dict) and self._identity_matches(payload)
182
+
183
+ def _invalidate_and_reset(self) -> None:
184
+ self._emit_invalidation()
185
+ self._reset_file_state()
186
+
187
+ def poll(self) -> None:
188
+ try:
189
+ self._poll_internal()
190
+ except Exception:
191
+ log.warning("codex: rollout poll error", exc_info=True)
192
+
193
+ def _poll_internal(self) -> None:
194
+ if not self.session_id:
195
+ return
196
+ if self.session_file is None and not self._discover_session_file():
197
+ return
198
+ if not self.file_verified:
199
+ return
200
+
201
+ stat = self._current_stat()
202
+ if stat is None:
203
+ return
204
+ self._apply_size_change(stat)
205
+
206
+ if stat.st_size == self.offset and not self.partial_buffer:
207
+ return
208
+
209
+ if self.total_bytes_read >= self.max_total_bytes:
210
+ self._exhaust_budget()
211
+ return
212
+
213
+ read_size = min(self.max_bytes_per_poll, self.max_total_bytes - self.total_bytes_read)
214
+ if read_size <= 0:
215
+ return
216
+
217
+ chunk = self._read_chunk(read_size)
218
+ if not chunk:
219
+ return
220
+
221
+ self.offset += len(chunk)
222
+ self.total_bytes_read += len(chunk)
223
+ self._process_chunk(chunk)
224
+ self._finish_read(stat)
225
+
226
+ def _discover_session_file(self) -> bool:
227
+ self.session_file = self._find_exact_file()
228
+ if self.session_file is None:
229
+ return False
230
+ try:
231
+ st = self.session_file.stat()
232
+ if self.file_dev is None or self.file_ino is None:
233
+ self.file_dev = st.st_dev
234
+ self.file_ino = st.st_ino
235
+ self.file_verified = True
236
+ except OSError:
237
+ self._reset_file_state()
238
+ return False
239
+ return True
240
+
241
+ def _current_stat(self) -> os.stat_result | None:
242
+ """Stat the session file. A missing or replaced file invalidates context and resets state."""
243
+ if self.session_file is None:
244
+ return None
245
+ try:
246
+ stat = self.session_file.stat()
247
+ except OSError:
248
+ self._invalidate_and_reset()
249
+ return None
250
+ if (
251
+ self.file_dev is not None
252
+ and self.file_ino is not None
253
+ and (stat.st_dev, stat.st_ino) != (self.file_dev, self.file_ino)
254
+ ):
255
+ self._invalidate_and_reset()
256
+ return None
257
+ return stat
258
+
259
+ def _apply_size_change(self, stat: os.stat_result) -> None:
260
+ if stat.st_size < self.offset:
261
+ self._emit_invalidation()
262
+ self.header_verified = False
263
+ self.offset = 0
264
+ self.partial_buffer = ""
265
+ elif stat.st_size > self.offset and self.has_verified_context:
266
+ self._emit_invalidation()
267
+
268
+ def _exhaust_budget(self) -> None:
269
+ if not self.budget_exhausted:
270
+ self._emit_invalidation()
271
+ self.budget_exhausted = True
272
+
273
+ def _read_chunk(self, read_size: int) -> bytes:
274
+ """Read the next chunk. An empty result means nothing to process, with state reset on failure."""
275
+ if self.session_file is None:
276
+ return b""
277
+ try:
278
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
279
+ fd = os.open(self.session_file, flags)
280
+ try:
281
+ opened = os.fstat(fd)
282
+ if not stat_module.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != (
283
+ self.file_dev,
284
+ self.file_ino,
285
+ ):
286
+ self._invalidate_and_reset()
287
+ return b""
288
+ with os.fdopen(fd, "rb") as f:
289
+ fd = -1
290
+ if not self.header_verified and not self._verify_header(f):
291
+ return b""
292
+ f.seek(self.offset)
293
+ return f.read(read_size)
294
+ finally:
295
+ if fd >= 0:
296
+ os.close(fd)
297
+ except OSError:
298
+ self._invalidate_and_reset()
299
+ return b""
300
+
301
+ def _verify_header(self, f: Any) -> bool:
302
+ try:
303
+ self.header_verified = self._header_matches(json.loads(f.readline(4096)))
304
+ except (json.JSONDecodeError, UnicodeDecodeError):
305
+ self.header_verified = False
306
+ if not self.header_verified:
307
+ self._invalidate_and_reset()
308
+ return self.header_verified
309
+
310
+ def _process_chunk(self, chunk: bytes) -> None:
311
+ text = self.partial_buffer + chunk.decode("utf-8", errors="replace")
312
+ lines = text.split("\n")
313
+ self.partial_buffer = lines.pop()
314
+
315
+ for line in lines:
316
+ line = line.strip()
317
+ if not line:
318
+ continue
319
+ try:
320
+ record = json.loads(line)
321
+ except json.JSONDecodeError:
322
+ continue
323
+ if isinstance(record, dict):
324
+ self._process_record(record)
325
+
326
+ def _finish_read(self, stat: os.stat_result) -> None:
327
+ if self.offset >= stat.st_size and not self.partial_buffer and self.pending_context is not None:
328
+ used, window = self.pending_context
329
+ self.counters.record_context(used=used, window=window)
330
+ self.has_verified_context = True
331
+ self._changed()
332
+ self.pending_context = None
333
+
334
+ if self.total_bytes_read >= self.max_total_bytes and stat.st_size > self.offset:
335
+ self._exhaust_budget()
336
+
337
+ def _process_record(self, record: dict[str, Any]) -> None:
338
+ rec_type = record.get("type", "")
339
+ payload = record.get("payload")
340
+
341
+ if rec_type == "session_meta":
342
+ if isinstance(payload, dict) and not self._identity_matches(payload):
343
+ self.file_verified = False
344
+ return
345
+
346
+ if not self.file_verified:
347
+ return
348
+
349
+ if rec_type == "event_msg" and isinstance(payload, dict):
350
+ self._process_event_msg(payload)
351
+ elif rec_type == "context_compacted":
352
+ self._emit_invalidation(is_compaction=True)
353
+
354
+ def _process_event_msg(self, payload: dict[str, Any]) -> None:
355
+ p_type = payload.get("type", "")
356
+ if p_type == "token_count":
357
+ self._record_token_count(payload)
358
+ elif p_type == "context_compacted":
359
+ self._emit_invalidation(is_compaction=True)
360
+ elif p_type in ("session_configured", "model_reroute"):
361
+ self._record_model(payload)
362
+
363
+ def _record_token_count(self, payload: dict[str, Any]) -> None:
364
+ info = payload.get("info")
365
+ if not isinstance(info, dict):
366
+ return
367
+ usage = info.get("last_token_usage")
368
+ window = as_int(info.get("model_context_window"))
369
+ if not isinstance(usage, dict):
370
+ return
371
+ input_tokens = as_int(usage.get("input_tokens"))
372
+ output_tokens = as_int(usage.get("output_tokens"))
373
+ reasoning_tokens = as_int(usage.get("reasoning_output_tokens"))
374
+ if input_tokens is None or output_tokens is None or reasoning_tokens is None:
375
+ return
376
+ used = input_tokens + output_tokens - reasoning_tokens
377
+ if used >= 0:
378
+ self.pending_context = (used, window)
379
+
380
+ def _record_model(self, payload: dict[str, Any]) -> None:
381
+ model = payload.get("model") or payload.get("to_model")
382
+ if not model:
383
+ return
384
+ model_str = str(model)
385
+ if self.model_name and self.model_name != model_str:
386
+ self._emit_invalidation(is_compaction=False)
387
+ self.model_name = model_str
388
+
389
+
390
+ def _entry_kind(entry: os.DirEntry[str]) -> str:
391
+ """Return "dir" or "file" for a plain entry, "" for a symlink, other entry, or stat failure."""
392
+ try:
393
+ if entry.is_symlink():
394
+ return ""
395
+ if entry.is_dir(follow_symlinks=False):
396
+ return "dir"
397
+ return "file" if entry.is_file(follow_symlinks=False) else ""
398
+ except OSError:
399
+ return ""
400
+
401
+
402
+ def _read_first_line(path: Path, expected: os.stat_result) -> tuple[os.stat_result, str] | None:
403
+ """Open ``path`` without following links and return its stat and first line, or None on an identity mismatch."""
404
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
405
+ fd = os.open(path, flags)
406
+ try:
407
+ opened = os.fstat(fd)
408
+ if not stat_module.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino) != (
409
+ expected.st_dev,
410
+ expected.st_ino,
411
+ ):
412
+ return None
413
+ with os.fdopen(fd, "r", encoding="utf-8", errors="replace") as f:
414
+ fd = -1
415
+ return opened, f.readline(4096)
416
+ finally:
417
+ if fd >= 0:
418
+ os.close(fd)
419
+
420
+
421
+ _SESSION_ID_RE = re.compile(
422
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
423
+ re.IGNORECASE,
424
+ )
425
+
426
+
427
+ def _same_path(left: Any, right: Path) -> bool:
428
+ if not isinstance(left, str) or not left.strip():
429
+ return False
430
+ try:
431
+ return Path(left).resolve() == right.resolve()
432
+ except (OSError, ValueError, TypeError):
433
+ try:
434
+ return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(os.fspath(right)))
435
+ except (OSError, ValueError, TypeError):
436
+ return False
437
+
438
+
439
+ __all__ = ["CodexRolloutCollector"]