atif-sql 0.2.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.
Files changed (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,426 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Read the raw records of a session, main JSONL plus every side-file, in one pass.
4
+
5
+ The single raw-record reader the converter, the census, the edges emitter and
6
+ the enrichment pass share. Discovery mirrors the contract: rglob over the
7
+ session's sibling dir filtered to ``*.jsonl`` (``*.meta.json`` excluded by the
8
+ suffix filter), which includes workflow-nested
9
+ ``subagents/workflows/wf_*/agent-*.jsonl``. File reads only: no harbor, no
10
+ env, no network.
11
+
12
+ ONE READ, ONE PARSE. :func:`load_session` opens each discovered file once and
13
+ streams its bytes through a digest on the way to the JSON parser, so the
14
+ fingerprint it records is the fingerprint of exactly the bytes that were
15
+ parsed. The parsed records are handed to the converter AND to the audit
16
+ (census, edges, enrichment), so the three artifacts cannot describe different
17
+ bytes: they come from one list. Before this pass existed the converter and the
18
+ audit each parsed the files and the snapshot hashed them separately, three
19
+ hash passes and two parses of a 135 MB session.
20
+
21
+ SIDECARS. Claude Code writes one ``agent-<id>.meta.json`` beside each subagent
22
+ transcript, recording the subagent's type, its description and the
23
+ ``toolUseId`` of the Task/Agent call that spawned it. They are not JSONL and
24
+ carry no records, so they stay out of :attr:`LoadedSession.records_by_file`
25
+ (the census and edges count records, and a sidecar is not one). The same pass
26
+ reads each one once, fingerprints it, and parses it into
27
+ :attr:`LoadedSession.sidecars`, and the snapshot re-check covers them too, so a
28
+ sidecar rewritten mid-conversion refuses the session exactly as a transcript
29
+ would.
30
+
31
+ A :class:`SessionSnapshot` names a fixed set of source files and carries the
32
+ change evidence for each, an mtime/size/content-digest fingerprint, and
33
+ NOTHING else. Claude Code appends to a live session at any moment, so the
34
+ snapshot is re-checked with :func:`mutated_files` once the artifacts are
35
+ built; an unchanged fingerprint set is the evidence that the session held
36
+ still for the whole window, including while it was being read. The digest is
37
+ what catches a same-length rewrite inside one mtime tick, so the re-check
38
+ hashes the bytes again rather than trusting the stat pair.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import hashlib
44
+ import io
45
+ import json
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, override
49
+
50
+ from loguru import logger
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Buffer, Callable, Iterable
54
+
55
+ from atif_converter.infrastructure.source_archive import SourceArchiveWriter
56
+
57
+
58
+ def discover_session_files(session_jsonl: Path) -> list[Path]:
59
+ """The main session JSONL plus every ``*.jsonl`` under its sibling dir.
60
+
61
+ Deterministic order: main file first, then sorted side-files.
62
+ """
63
+ files = [session_jsonl]
64
+ side_dir = session_jsonl.parent / session_jsonl.stem
65
+ if side_dir.is_dir():
66
+ files.extend(sorted(side_dir.rglob("*.jsonl")))
67
+ return files
68
+
69
+
70
+ def discover_sidecar_files(session_jsonl: Path) -> list[Path]:
71
+ """Every subagent ``agent-*.meta.json`` under the session's sibling dir, sorted.
72
+
73
+ Workflow-nested sidecars (``subagents/workflows/wf_*/agent-*.meta.json``)
74
+ included, as the transcripts they describe are.
75
+ """
76
+ side_dir = session_jsonl.parent / session_jsonl.stem
77
+ if not side_dir.is_dir():
78
+ return []
79
+ return sorted(side_dir.rglob("agent-*.meta.json"))
80
+
81
+
82
+ class FileFingerprint(NamedTuple):
83
+ """Change evidence for one source file: mtime, size, and content digest.
84
+
85
+ Each field covers a case the others miss.
86
+
87
+ Size catches the append that mtime misses: two appends inside one
88
+ filesystem timestamp tick share an ``st_mtime_ns``, and a session resuming
89
+ mid-conversion is exactly that fast, but an append always changes size.
90
+
91
+ mtime catches most in-place edits that leave the length alone.
92
+
93
+ The digest catches what neither does: a same-length rewrite landing inside
94
+ one mtime tick. That is not hypothetical here: on the xfs volume this runs
95
+ on, 45 consecutive same-size rewrites were absorbed into a single
96
+ ``st_mtime_ns``, so ``{"uuid": "aaa"}`` becoming ``{"uuid": "bbb"}`` is
97
+ invisible to the stat pair. Different bytes behind an identical stat is
98
+ precisely the state this snapshot exists to refuse, so the bytes are read.
99
+ """
100
+
101
+ mtime_ns: int
102
+ size: int
103
+ content_hash: bytes
104
+
105
+
106
+ class _Digest(Protocol):
107
+ """The two methods of a ``hashlib`` object this module uses."""
108
+
109
+ def update(self, data: Buffer, /) -> None: ...
110
+
111
+ def digest(self) -> bytes: ...
112
+
113
+
114
+ def _new_digest() -> _Digest:
115
+ """A fresh content digest.
116
+
117
+ sha256 rather than blake2b: the digest is never persisted or compared
118
+ across hosts, so only its speed matters, and sha256 has hardware
119
+ instructions on current x86 (SHA-NI) and arm64 (SHA2) while blake2b does
120
+ not. Measured on the 76 MB benchmark session: 1.6 GB/s against 0.9 GB/s.
121
+ """
122
+ return hashlib.sha256()
123
+
124
+
125
+ #: Digest chunk size; the transcripts are streamed rather than read whole.
126
+ _HASH_CHUNK_BYTES = 1024 * 1024
127
+
128
+
129
+ class _DigestingReader(io.RawIOBase):
130
+ """A raw byte stream that folds every byte it hands out into a digest.
131
+
132
+ Sits between the file and the text decoder so the one read that feeds the
133
+ JSON parser also produces the content fingerprint. The digest is complete
134
+ once the reader has been drained, which iterating the wrapping text handle
135
+ to its end guarantees.
136
+ """
137
+
138
+ def __init__(self, raw: io.RawIOBase, digest: _Digest) -> None:
139
+ super().__init__()
140
+ self._raw = raw
141
+ self._digest = digest
142
+
143
+ @override
144
+ def readable(self) -> bool:
145
+ return True
146
+
147
+ @override
148
+ def readinto(self, buffer: Buffer, /) -> int | None:
149
+ count = self._raw.readinto(buffer)
150
+ if count is None:
151
+ return None
152
+ if count:
153
+ self._digest.update(memoryview(buffer)[:count])
154
+ return int(count)
155
+
156
+ @override
157
+ def close(self) -> None:
158
+ self._raw.close()
159
+ super().close()
160
+
161
+
162
+ def _content_hash(path: Path, tee: Callable[[bytes], object] | None = None) -> bytes:
163
+ """Digest of ``path``'s bytes; each chunk also goes to ``tee`` when one is given."""
164
+ digest = _new_digest()
165
+ with path.open("rb") as handle:
166
+ for chunk in iter(lambda: handle.read(_HASH_CHUNK_BYTES), b""):
167
+ digest.update(chunk)
168
+ if tee is not None:
169
+ tee(chunk)
170
+ return digest.digest()
171
+
172
+
173
+ def _fingerprint(path: Path, tee: Callable[[bytes], object] | None = None) -> FileFingerprint:
174
+ stat = path.stat()
175
+ return FileFingerprint(
176
+ mtime_ns=stat.st_mtime_ns,
177
+ size=stat.st_size,
178
+ content_hash=_content_hash(path, tee),
179
+ )
180
+
181
+
182
+ def parse_jsonl_records(lines: Iterable[str], path: Path) -> list[Any]:
183
+ """Parse JSONL lines the way harbor's reading loop does.
184
+
185
+ Blank lines are skipped; a line that is not JSON is logged at debug and
186
+ skipped rather than failing the session. A line that IS JSON but not an
187
+ object is kept as-is, as harbor keeps it (the converter fails on it later,
188
+ in the same place harbor fails); the audit readers filter to objects.
189
+
190
+ Iterating a text handle splits on newlines only, where ``str.splitlines``
191
+ would also split on U+2028 / U+2029 / form feed, which are legal unescaped
192
+ inside a JSON string. Callers pass the handle, never ``read().splitlines()``.
193
+ """
194
+ records: list[Any] = []
195
+ for line in lines:
196
+ stripped = line.strip()
197
+ if not stripped:
198
+ continue
199
+ try:
200
+ records.append(json.loads(stripped))
201
+ except json.JSONDecodeError as exc:
202
+ logger.debug("Skipping malformed JSONL line in {}: {}", path, exc)
203
+ return records
204
+
205
+
206
+ def _read_and_fingerprint(path: Path) -> tuple[FileFingerprint, list[Any]]:
207
+ """One pass over ``path``: stat, then hash and parse the same bytes.
208
+
209
+ The stat is taken BEFORE the read so an append that lands between the two
210
+ still shows as movement when the post-conversion re-check compares stats;
211
+ the digest covers the bytes the parser actually saw.
212
+ """
213
+ stat = path.stat()
214
+ digest = _new_digest()
215
+ with (
216
+ path.open("rb", buffering=0) as raw,
217
+ io.TextIOWrapper(
218
+ io.BufferedReader(_DigestingReader(raw, digest), _HASH_CHUNK_BYTES),
219
+ encoding="utf-8",
220
+ ) as handle,
221
+ ):
222
+ records = parse_jsonl_records(handle, path)
223
+ fingerprint = FileFingerprint(
224
+ mtime_ns=stat.st_mtime_ns,
225
+ size=stat.st_size,
226
+ content_hash=digest.digest(),
227
+ )
228
+ return fingerprint, records
229
+
230
+
231
+ def _read_sidecar(path: Path) -> tuple[FileFingerprint, Any]:
232
+ """One pass over a small JSON sidecar: stat, then hash and parse the same bytes.
233
+
234
+ A sidecar that is not valid UTF-8 JSON parses to ``None`` (logged at debug)
235
+ rather than failing the session: it only ever adds linkage, and the
236
+ subagent's transcript converts without it.
237
+ """
238
+ stat = path.stat()
239
+ data = path.read_bytes()
240
+ digest = _new_digest()
241
+ digest.update(data)
242
+ try:
243
+ value: Any = json.loads(data.decode("utf-8"))
244
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
245
+ logger.debug("Skipping unreadable sidecar {}: {}", path, exc)
246
+ value = None
247
+ fingerprint = FileFingerprint(
248
+ mtime_ns=stat.st_mtime_ns,
249
+ size=stat.st_size,
250
+ content_hash=digest.digest(),
251
+ )
252
+ return fingerprint, value
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class SessionSnapshot:
257
+ """The fixed file list of one session plus each file's change evidence.
258
+
259
+ Holds no records. A :class:`LoadedSession` pairs one with the records
260
+ parsed from those same bytes; :func:`read_snapshot_records` re-parses
261
+ exactly the snapshot's file list for a caller that holds only the snapshot.
262
+ """
263
+
264
+ session_jsonl: Path
265
+
266
+ #: Fingerprint per discovered file, sampled by the read that parsed it (or
267
+ #: by :func:`take_session_snapshot`, which reads for the digest alone).
268
+ fingerprints: dict[Path, FileFingerprint]
269
+
270
+ #: Fingerprint per discovered ``agent-*.meta.json`` sidecar. Kept apart
271
+ #: from :attr:`fingerprints` because :attr:`files` is the RECORD-bearing
272
+ #: file list every JSONL reader walks, and a sidecar holds no records.
273
+ sidecar_fingerprints: dict[Path, FileFingerprint] = field(default_factory=dict)
274
+
275
+ @property
276
+ def files(self) -> tuple[Path, ...]:
277
+ """Every discovered file, main first then sorted side-files."""
278
+ return tuple(self.fingerprints)
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class LoadedSession:
283
+ """One session's snapshot plus every record parsed from those same bytes.
284
+
285
+ ``records_by_file`` keeps each file's records in file order under the
286
+ file's path, main first then sorted side-files, so the converter can
287
+ address the main transcript and the staged side-files by name while the
288
+ audit walks every record of every file. Values are whatever JSON the lines
289
+ held, objects or not, as harbor keeps them; :meth:`record_pairs` is the
290
+ objects-only view the census, edges and enrichment consume.
291
+ """
292
+
293
+ snapshot: SessionSnapshot
294
+ records_by_file: dict[Path, list[Any]]
295
+
296
+ #: Each ``agent-*.meta.json`` sidecar's parsed JSON (``None`` when it did
297
+ #: not parse), keyed by path, sorted. Read in the same pass as the records.
298
+ sidecars: dict[Path, Any] = field(default_factory=dict)
299
+
300
+ @property
301
+ def main_records(self) -> list[Any]:
302
+ """The main transcript's records, in file order."""
303
+ return self.records_by_file[self.snapshot.session_jsonl]
304
+
305
+ def record_pairs(self) -> list[tuple[dict[str, Any], str]]:
306
+ """Every OBJECT record with its source file, in file order.
307
+
308
+ ``source_file`` is relative to the session JSONL's parent directory,
309
+ the contract's edge field. Non-object JSON values are dropped here, as
310
+ the audit never counted them.
311
+ """
312
+ base = self.snapshot.session_jsonl.parent
313
+ pairs: list[tuple[dict[str, Any], str]] = []
314
+ for path, records in self.records_by_file.items():
315
+ source_file = path.relative_to(base).as_posix()
316
+ pairs.extend((record, source_file) for record in records if isinstance(record, dict))
317
+ return pairs
318
+
319
+
320
+ def load_session(session_jsonl: Path) -> LoadedSession:
321
+ """Read every discovered file once: fingerprint and parse in a single pass.
322
+
323
+ Raises
324
+ ------
325
+ OSError: a discovered file cannot be opened or read.
326
+ UnicodeDecodeError: a file is not UTF-8.
327
+ """
328
+ fingerprints: dict[Path, FileFingerprint] = {}
329
+ records_by_file: dict[Path, list[Any]] = {}
330
+ for path in discover_session_files(session_jsonl):
331
+ fingerprints[path], records_by_file[path] = _read_and_fingerprint(path)
332
+ sidecar_fingerprints: dict[Path, FileFingerprint] = {}
333
+ sidecars: dict[Path, Any] = {}
334
+ for path in discover_sidecar_files(session_jsonl):
335
+ sidecar_fingerprints[path], sidecars[path] = _read_sidecar(path)
336
+ return LoadedSession(
337
+ snapshot=SessionSnapshot(
338
+ session_jsonl=session_jsonl,
339
+ fingerprints=fingerprints,
340
+ sidecar_fingerprints=sidecar_fingerprints,
341
+ ),
342
+ records_by_file=records_by_file,
343
+ sidecars=sidecars,
344
+ )
345
+
346
+
347
+ def take_session_snapshot(session_jsonl: Path) -> SessionSnapshot:
348
+ """Fingerprint every discovered file; holds digests, never parsed records.
349
+
350
+ The use cases go through :func:`load_session`, which fingerprints as a
351
+ by-product of the read. This is for a caller that wants the change
352
+ evidence alone.
353
+ """
354
+ return SessionSnapshot(
355
+ session_jsonl=session_jsonl,
356
+ fingerprints={path: _fingerprint(path) for path in discover_session_files(session_jsonl)},
357
+ sidecar_fingerprints={
358
+ path: _fingerprint(path) for path in discover_sidecar_files(session_jsonl)
359
+ },
360
+ )
361
+
362
+
363
+ def read_snapshot_records(snapshot: SessionSnapshot) -> list[tuple[dict[str, Any], str]]:
364
+ """Parse every object record of exactly the snapshot's file list.
365
+
366
+ Returns ``(record, source_file)`` pairs in file order, ``source_file``
367
+ relative to the session JSONL's parent directory (the contract's edge
368
+ field). Malformed lines are skipped (same policy as harbor itself). Files
369
+ added since the snapshot are NOT read: the snapshot's list is the whole
370
+ input, and :func:`mutated_files` reports the newcomer afterwards.
371
+ """
372
+ base = snapshot.session_jsonl.parent
373
+ pairs: list[tuple[dict[str, Any], str]] = []
374
+ for path in snapshot.files:
375
+ source_file = path.relative_to(base).as_posix()
376
+ with path.open(encoding="utf-8") as handle:
377
+ records = parse_jsonl_records(handle, path)
378
+ pairs.extend((record, source_file) for record in records if isinstance(record, dict))
379
+ return pairs
380
+
381
+
382
+ def mutated_files(
383
+ snapshot: SessionSnapshot,
384
+ *,
385
+ archive: SourceArchiveWriter | None = None,
386
+ ) -> tuple[Path, ...]:
387
+ """Files that changed, vanished, or appeared since the snapshot was taken.
388
+
389
+ Re-stats AND re-hashes every file: the digest is the half of the evidence
390
+ that catches a same-length rewrite inside one mtime tick. A newly APPEARED
391
+ side-file counts: it carries records the snapshot never saw, so artifacts
392
+ derived from the snapshot are already incomplete.
393
+
394
+ With ``archive``, every byte this re-hash reads is also streamed into the
395
+ session's source archive, so archiving costs no extra open. The archive is
396
+ only meaningful when the result is empty: an unchanged digest is what proves
397
+ the archived bytes are the bytes that were parsed. A caller that gets a
398
+ non-empty result must discard it.
399
+ """
400
+ moved: list[Path] = []
401
+ for path, fingerprint in (
402
+ *snapshot.fingerprints.items(),
403
+ *snapshot.sidecar_fingerprints.items(),
404
+ ):
405
+ try:
406
+ if archive is None:
407
+ current = _fingerprint(path)
408
+ else:
409
+ with archive.member(path) as sink:
410
+ current = _fingerprint(path, sink)
411
+ archive.record(path, size=current.size, sha256=current.content_hash)
412
+ if current != fingerprint:
413
+ moved.append(path)
414
+ except OSError:
415
+ moved.append(path)
416
+ moved.extend(
417
+ path
418
+ for path in discover_session_files(snapshot.session_jsonl)
419
+ if path not in snapshot.fingerprints
420
+ )
421
+ moved.extend(
422
+ path
423
+ for path in discover_sidecar_files(snapshot.session_jsonl)
424
+ if path not in snapshot.sidecar_fingerprints
425
+ )
426
+ return tuple(moved)
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Write a zstd-compressed copy of a session's raw source files while they are verified.
4
+
5
+ The corpus keeps every session's artifacts after the source transcript is gone,
6
+ and a future converter can only re-run on a session whose raw bytes survived.
7
+ So each successful conversion also leaves an ARCHIVE: one ``<relative path>.zst``
8
+ per source file, relative to the main transcript's parent directory, in a
9
+ directory the caller names.
10
+
11
+ WHERE THE BYTES COME FROM. The transcripts are read once for parsing (see
12
+ :mod:`atif_converter.infrastructure.raw_records`), and the second, verifying
13
+ read re-hashes every byte to prove nothing moved. The archive rides that second
14
+ read: :func:`~atif_converter.infrastructure.raw_records.mutated_files` hands each
15
+ chunk it hashes to :meth:`SourceArchiveWriter.member` as well. No file is opened
16
+ a third time, and the digest that vouches for the archived bytes is the one the
17
+ snapshot check compares against the parse's digest, so an archived transcript is
18
+ byte-for-byte the input the artifacts were built from. If anything moved, the
19
+ conversion raises and the caller throws the half-written archive away.
20
+
21
+ The ``agent-*.meta.json`` sidecars are parsed in the same pass as the
22
+ transcripts and re-hashed by the same verifying read, so they ride it too. The
23
+ other files under the session's side directory (the ``tool-results/`` spill
24
+ files, workflow state) are not parsed by any converter today, so
25
+ :meth:`SourceArchiveWriter.archive_side_files` reads each of them once, here.
26
+ They are archived anyway because a future converter may want them and the
27
+ originals expire with the transcript.
28
+
29
+ Level 3 is deliberate: measured on a 76 MB transcript, level 3 compresses at
30
+ about 300 MB/s to 2.8x and level 19 reaches 2.9x at 5 MB/s. The archive sits on
31
+ the conversion hot path, so the extra 3% is not worth sixty times the CPU.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import os
38
+ from contextlib import contextmanager
39
+ from dataclasses import dataclass
40
+ from typing import TYPE_CHECKING, BinaryIO
41
+
42
+ import zstandard
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Callable, Collection, Generator
46
+ from pathlib import Path
47
+
48
+ #: zstd level for every archived file; see the module docstring.
49
+ ARCHIVE_ZSTD_LEVEL = 3
50
+
51
+ #: Suffix appended to each archived file's relative path.
52
+ ARCHIVE_SUFFIX = ".zst"
53
+
54
+ #: Archive files hold raw transcript bytes, so they get the transcripts' own
55
+ #: owner-only mode rather than whatever the umask allows.
56
+ _ARCHIVE_FILE_MODE = 0o600
57
+
58
+ #: Read size for side files, the same 1 MiB chunk the transcript reader uses.
59
+ _CHUNK_BYTES = 1024 * 1024
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ArchivedSourceFile:
64
+ """One archived file: where it came from, and what its uncompressed bytes were."""
65
+
66
+ #: POSIX path relative to the main transcript's parent directory.
67
+ relative_path: str
68
+ #: Uncompressed size in bytes.
69
+ size: int
70
+ #: Hex sha256 of the uncompressed bytes.
71
+ sha256: str
72
+
73
+
74
+ class SourceArchiveWriter:
75
+ """Collects one session's archive under ``archive_dir``.
76
+
77
+ ``base`` is the main transcript's parent directory; every member's name is
78
+ its path relative to ``base``, so the archive restores to the same shape
79
+ (``<session>.jsonl`` beside ``<session>/subagents/...``) the converter
80
+ discovers side files from.
81
+ """
82
+
83
+ def __init__(self, archive_dir: Path, *, base: Path) -> None:
84
+ self.archive_dir = archive_dir
85
+ self.base = base
86
+ self._files: dict[str, ArchivedSourceFile] = {}
87
+
88
+ @property
89
+ def files(self) -> tuple[ArchivedSourceFile, ...]:
90
+ """Every archived file, sorted by relative path."""
91
+ return tuple(self._files[name] for name in sorted(self._files))
92
+
93
+ def _relative(self, path: Path) -> str:
94
+ return path.relative_to(self.base).as_posix()
95
+
96
+ def _open_member(self, relative_path: str) -> BinaryIO:
97
+ target = self.archive_dir / f"{relative_path}{ARCHIVE_SUFFIX}"
98
+ target.parent.mkdir(parents=True, exist_ok=True)
99
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _ARCHIVE_FILE_MODE)
100
+ return os.fdopen(fd, "wb")
101
+
102
+ @contextmanager
103
+ def member(self, path: Path) -> Generator[Callable[[bytes], object]]:
104
+ """Stream one source file into the archive; yields the chunk sink.
105
+
106
+ The caller hashes the same chunks itself and reports the digest through
107
+ :meth:`record`, which is how the verifying read's digest, not a second
108
+ one, vouches for the archived bytes.
109
+ """
110
+ with (
111
+ self._open_member(self._relative(path)) as raw,
112
+ zstandard.ZstdCompressor(level=ARCHIVE_ZSTD_LEVEL).stream_writer(
113
+ raw, closefd=False
114
+ ) as compressed,
115
+ ):
116
+ yield compressed.write
117
+
118
+ def record(self, path: Path, *, size: int, sha256: bytes) -> None:
119
+ """Note the uncompressed size and digest of a member written through :meth:`member`."""
120
+ relative = self._relative(path)
121
+ self._files[relative] = ArchivedSourceFile(
122
+ relative_path=relative, size=size, sha256=sha256.hex()
123
+ )
124
+
125
+ def archive_side_files(self, session_jsonl: Path, *, already: Collection[Path]) -> None:
126
+ """Archive every other regular file under the session's side directory.
127
+
128
+ ``already`` holds the files the verifying read archived (the
129
+ transcripts and the parsed sidecars); they are skipped. Symlinks are skipped too: following one would copy bytes from
130
+ outside the session into its archive.
131
+ """
132
+ side_dir = session_jsonl.parent / session_jsonl.stem
133
+ if not side_dir.is_dir() or side_dir.is_symlink():
134
+ return
135
+ skip = set(already)
136
+ for path in sorted(side_dir.rglob("*")):
137
+ if path in skip or path.is_symlink() or not path.is_file():
138
+ continue
139
+ digest = hashlib.sha256()
140
+ size = 0
141
+ with self.member(path) as sink, path.open("rb") as handle:
142
+ for chunk in iter(lambda: handle.read(_CHUNK_BYTES), b""):
143
+ digest.update(chunk)
144
+ sink(chunk)
145
+ size += len(chunk)
146
+ self.record(path, size=size, sha256=digest.digest())
147
+
148
+
149
+ __all__ = [
150
+ "ARCHIVE_SUFFIX",
151
+ "ARCHIVE_ZSTD_LEVEL",
152
+ "ArchivedSourceFile",
153
+ "SourceArchiveWriter",
154
+ ]
File without changes
@@ -0,0 +1,13 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """atif-corpus: corpus materialization for ATIF trajectories.
4
+
5
+ Owns CONTRACT.md §Materialized corpus layout — session discovery over the
6
+ raw transcript corpus, per-session watermarks (convert only what changed),
7
+ quiescence detection (skip sessions still being written), and atomic
8
+ artifact writes. Hexagonal: ``domain`` (pure decisions) < ``infrastructure``
9
+ (fs/env effects) < ``application`` (the materialize use case). Conversion
10
+ happens behind :class:`atif_corpus.domain.ports.ConverterPort`; atif-cli
11
+ adapts the real converter (independence contract: this package never
12
+ imports atif-converter or atif-duck).
13
+ """
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Application layer: the materialize use case.
4
+
5
+ Orchestrates domain decisions (plan) with infrastructure effects (scan,
6
+ convert, write) behind the :class:`atif_corpus.domain.ports.ConverterPort`.
7
+ """