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,1826 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Use case: sync the materialized corpus with the raw transcript corpus.
4
+
5
+ One pass = sweep → scan → plan → convert → write → advance watermark:
6
+
7
+ 0. Sweep ``.staging/``: it holds nothing durable, only scratch dirs for the
8
+ in-flight directory swap, so residue whose owning pid is GONE is crash
9
+ debris and is removed. An entry whose ``tmp-<pid>`` owner is still running
10
+ belongs to a concurrent pass and is left alone — the refresh script locks
11
+ per lane and a manual run locks nothing. Without this sweep,
12
+ ``*.tmp-<oldpid>`` and ``*.old-<pid>`` accumulate on the corpus
13
+ filesystem forever.
14
+ 1. Scan the source root (main transcripts + every side-file, per contract).
15
+ 2. Build a :class:`~atif_corpus.domain.sessions.MaterializationPlan` from
16
+ quiescence + the recorded watermark — the pure decision. A session the
17
+ watermark calls current but whose artifact dir is ABSENT is force-replanned:
18
+ the only way to reach that state is a kill inside the swap window, and the
19
+ watermark cannot express it because it records source mtimes and knows
20
+ nothing about the corpus dir.
21
+ 3. For each planned session, convert through the injected
22
+ :class:`~atif_corpus.domain.ports.ConverterPort` and write the four
23
+ artifacts into a TEMP session dir, then swap the whole dir into place
24
+ (directory rename) — readers can never observe a torn artifact SET,
25
+ only the complete old generation or the complete new one. Within the
26
+ temp dir, ``meta.json`` is still written LAST as belt-and-braces — meta
27
+ is the "this session's artifacts are complete" marker, and atif-duck
28
+ gates its readers on meta presence, so it must never exist beside a
29
+ partial artifact set.
30
+
31
+ With ``workers > 1`` this stage runs across a
32
+ :class:`concurrent.futures.ProcessPoolExecutor`: each worker builds its
33
+ own copy of the converter once (the injected instance is pickled into the
34
+ pool initializer) and runs the SAME :func:`_write_session` the serial
35
+ path runs, so the per-session staging dir and atomic swap stay the
36
+ crash-safety unit, only now the staging name carries the worker's pid.
37
+ Every worker outcome is folded back in PLAN order, so the report reads
38
+ the same regardless of which worker finished first. ``workers == 1`` is
39
+ the reference path and runs everything inline in this process.
40
+ partial artifact set. An optional
41
+ :class:`~atif_corpus.domain.ports.ArtifactProducer` runs between the
42
+ three JSON artifacts and ``meta.json`` and may add files to the same
43
+ temp dir (atif-cli plugs in atif-duck's typed columnar writer here), so
44
+ its files publish in the same swap and under the same marker; the keys
45
+ it returns land in ``meta.json`` beside the contract's own.
46
+ 4. Retain source-removed sessions: a corpus session dir whose main JSONL
47
+ vanished from the source scan is KEPT, forever. Claude Code and Codex
48
+ expire old transcripts, and the corpus is the only copy of those sessions
49
+ left, so deleting it would be deleting history. Its ``meta.json`` is
50
+ rewritten once with ``source_present: false`` and ``source_removed_at``
51
+ (this pass's ``materialized_at``); a session marked this pass is reported
52
+ in ``removed_session_ids``, and ``retained_count`` counts every session
53
+ kept without a source. If the source comes back, the session is stale
54
+ again (its watermark entries were dropped) and the next quiescent pass
55
+ re-converts it from the live file, which clears the mark. Three guards
56
+ keep the mark honest, the same three that used to guard deletion: a
57
+ session the scan could not STAT is never marked, only a genuine
58
+ ``FileNotFoundError`` counts, and such a session is reported in
59
+ ``unreadable_session_ids``; a pass that could not list a source directory
60
+ marks nothing; and when the scan found ZERO sessions while the corpus holds
61
+ some, that smells like a wrong ``source_root`` and the pass fails loud
62
+ (:class:`SuspiciousEmptyScanError`) instead of flagging the whole corpus.
63
+ 5. Sync the session sink, when one is injected
64
+ (:class:`~atif_corpus.domain.ports.SessionSink`; atif-cli plugs in the
65
+ DuckLake writer): every session published or marked this pass, plus any
66
+ left pending by an earlier pass, handed over in plan order, one batch per
67
+ call, in THIS process. Before the pass publishes anything the sessions it
68
+ may publish are recorded in ``sink_pending.json`` beside the watermark;
69
+ after the sink ran, only the ones it did not take stay there. A sink that
70
+ raises therefore leaves its sessions recorded, and the next pass hands
71
+ them over again, whether or not their sources moved.
72
+ 6. Advance ``watermark.json``: only sessions that materialized successfully
73
+ (or converted to nothing, see below) move their entries forward, so a
74
+ failed session stays stale and is retried next pass instead of being
75
+ silently forgotten.
76
+
77
+ Three more rules decide what a pass converts:
78
+
79
+ * Raw source archive. Every live conversion is handed ``<staging>/source/``
80
+ and writes a zstd copy of every source file there from the bytes it parsed
81
+ (the converter's verifying re-read, so no extra open of a transcript).
82
+ ``meta.json`` lists it under ``source_archive``, and it publishes in the same
83
+ directory swap as the artifacts it produced.
84
+ * Generation staleness. A session whose ``meta.json`` records a different
85
+ ``converter_schema`` (the converter-owned output version; the caller passes
86
+ it) or ``columnar_schema`` (when the caller expects one) than this pass would
87
+ stamp is stale even though no source byte moved. ``converter_version`` is the
88
+ release version and is provenance only, so a release that changes no output
89
+ re-converts nothing. A caller that passes no ``converter_schema`` gets
90
+ ``converter_version`` compared instead. A source-removed session that is
91
+ stale this way, and has an archive, is re-converted from its restored
92
+ archive; the archive itself carries over.
93
+ * Empty sessions. A converter that raises
94
+ :class:`~atif_corpus.domain.ports.EmptySourceError` has found a well-formed
95
+ transcript with nothing to convert. That is recorded in
96
+ ``empty_sessions.json`` with the generation it was checked under, its
97
+ watermark entries advance, and it is reported under ``empty_session_ids``
98
+ rather than ``failures``. A later write to the transcript (the watermark
99
+ moves) or a new converter schema (the generation moves) tries it again.
100
+
101
+ A failing session is recorded in the report and skipped — one broken
102
+ transcript must never abort a corpus sync.
103
+
104
+ Clock discipline: ``materialized_at`` / ``harbor_version`` /
105
+ ``converter_version`` are passed IN by the caller (atif-cli owns the wall
106
+ clock and the version pins); the only clocks read here are ``time.time_ns``
107
+ for the quiescence "now" when the caller does not supply one, and
108
+ ``time.perf_counter`` for durations (the per-session convert timer can be
109
+ swapped for another clock, ``convert_clock``, which is how a test pins the
110
+ report's sum without timing a real pool). The domain reads no clock at all. "Now"
111
+ is read AFTER the scan: read before it, a transcript written while a long
112
+ scan ran carries an mtime later than "now" and trips the future-mtime clock
113
+ warning for a host whose clock is fine.
114
+ """
115
+
116
+ from __future__ import annotations
117
+
118
+ import json
119
+ import multiprocessing
120
+ import os
121
+ import re
122
+ import shutil
123
+ import time
124
+ from concurrent.futures import ProcessPoolExecutor
125
+ from dataclasses import dataclass
126
+ from pathlib import Path
127
+ from typing import TYPE_CHECKING, Any, TypedDict
128
+
129
+ from loguru import logger
130
+
131
+ if TYPE_CHECKING:
132
+ from collections.abc import Callable, Collection, Mapping, Sequence
133
+
134
+ from atif_corpus.domain.generation import generation_matches
135
+ from atif_corpus.domain.layout import (
136
+ EDGES_FILENAME,
137
+ LOSS_REPORT_FILENAME,
138
+ META_FILENAME,
139
+ SESSION_EVENTS_FILENAME,
140
+ SOURCE_ARCHIVE_DIRNAME,
141
+ TRAJECTORY_FILENAME,
142
+ CorpusLayout,
143
+ stored_filename,
144
+ )
145
+ from atif_corpus.domain.ports import EmptySourceError
146
+ from atif_corpus.domain.sessions import (
147
+ MaterializationPlan,
148
+ QuiescencePolicy,
149
+ build_plan,
150
+ owns_path,
151
+ )
152
+ from atif_corpus.domain.source_layout import CLAUDE_CODE_LAYOUT, SourceLayout
153
+ from atif_corpus.infrastructure.atomic import (
154
+ replace_dir_atomic,
155
+ write_blob_atomic,
156
+ write_json_atomic,
157
+ write_json_zstd_atomic,
158
+ write_text_zstd_atomic,
159
+ )
160
+ from atif_corpus.infrastructure.scanner import scan_sources
161
+ from atif_corpus.infrastructure.source_archive import (
162
+ META_SOURCE_ARCHIVE_KEY,
163
+ archive_manifest,
164
+ restore_session_sources,
165
+ verify_archive_on_disk,
166
+ )
167
+
168
+ if TYPE_CHECKING:
169
+ from atif_corpus.domain.ports import (
170
+ ArtifactProducer,
171
+ BlobOutput,
172
+ ConverterPort,
173
+ SessionSink,
174
+ )
175
+ from atif_corpus.domain.sessions import SessionSource
176
+ from atif_corpus.infrastructure.scanner import SourceScan
177
+
178
+
179
+ class SuspiciousEmptyScanError(RuntimeError):
180
+ """The scan found zero sessions while the corpus holds materialized ones.
181
+
182
+ Almost always a wrong ``source_root`` (typo, unmounted disk, env var
183
+ pointing elsewhere) — proceeding would mark the ENTIRE corpus as
184
+ source-removed. Nothing is deleted any more, but a mass flip of every
185
+ session's ``source_present`` is still the wrong answer to a typo, so the
186
+ pass fails loud instead; an operator who really emptied the source tree
187
+ can point ``source_root`` at an empty directory on purpose and delete the
188
+ corpus by hand if that is what they want.
189
+ """
190
+
191
+
192
+ class CorpusAgentMismatchError(RuntimeError):
193
+ """The corpus at this root was materialized from a DIFFERENT agent.
194
+
195
+ One corpus holds one agent's sessions (``docs/CONTRACT.md``), and the
196
+ per-agent default roots keep that true without anyone thinking about it.
197
+ An EXPLICIT root defeats them: ``--corpus-root <claude corpus> --agent
198
+ codex``, or ``ATIF_SQL_CORPUS_ROOT`` left pointing at one corpus while the
199
+ agent moves, aims a Codex pass at a corpus full of Claude Code sessions.
200
+ No Codex scan will ever name any of them, so the pass would mark every one
201
+ source-removed and then start writing Codex sessions beside them.
202
+
203
+ So the corpus's own ``meta.agent`` is a DISCRIMINATOR, not just provenance:
204
+ it is read before any session is marked and before any write, and a
205
+ disagreement fails the pass with nothing touched.
206
+ """
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class MaterializationFailure:
211
+ """One session that failed to convert or write during a pass."""
212
+
213
+ #: The session that failed.
214
+ session_id: str
215
+ #: ``repr``-ish one-line description of what went wrong.
216
+ error: str
217
+
218
+
219
+ @dataclass(frozen=True, slots=True)
220
+ class MaterializationReport:
221
+ """What one materialization pass did, for logs and the CLI status line."""
222
+
223
+ #: Sessions whose artifacts were (re)written this pass, from a live source
224
+ #: or from a restored archive (the latter also in ``archive_session_ids``).
225
+ materialized_count: int
226
+ #: Sessions already current with their sources and generation; untouched.
227
+ up_to_date_count: int
228
+ #: Sessions still being written; deferred to a later pass.
229
+ skipped_live_count: int
230
+ #: Sessions that raised during convert/write; watermark NOT advanced.
231
+ failures: tuple[MaterializationFailure, ...]
232
+ #: Wall seconds for the whole pass (scan + plan + convert + write).
233
+ total_seconds: float
234
+ #: Seconds spent inside ``ConverterPort.convert`` calls, SUMMED over
235
+ #: sessions. With one worker this is a slice of ``total_seconds``; with
236
+ #: several it is the work the pool did in parallel and can exceed the wall
237
+ #: clock, which is the point of the pool.
238
+ convert_seconds: float
239
+ #: Sessions whose source vanished THIS pass. Their artifacts are KEPT and
240
+ #: their ``meta.json`` now says ``source_present: false``; nothing is
241
+ #: deleted. A session marked on an earlier pass is not repeated here; it is
242
+ #: counted in ``retained_count``.
243
+ removed_session_ids: tuple[str, ...] = ()
244
+ #: How many processes converted this pass: 1 for the inline reference
245
+ #: path, otherwise the pool size actually used (never more than the number
246
+ #: of sessions planned).
247
+ workers: int = 1
248
+ #: Wall seconds spent inside ``ArtifactProducer.produce`` calls (0.0 when
249
+ #: no producer was injected). Reported separately from ``convert_seconds``
250
+ #: because the extra artifacts are a cost the operator opted into and may
251
+ #: want to see on its own.
252
+ artifact_seconds: float = 0.0
253
+ #: Sessions the scan could not stat, so they appear in no other counter.
254
+ #:
255
+ #: A transient stat error resolves itself next pass. A PERMANENT one (a
256
+ #: side-file left at mode 000) starves the session forever: it never
257
+ #: materializes, is never up_to_date, is never skipped_live, and is
258
+ #: deliberately never marked source-removed. Without this field the pass
259
+ #: reports all zeroes and an operator sees a corpus that looks idle and
260
+ #: complete.
261
+ unreadable_session_ids: tuple[str, ...] = ()
262
+ #: Transcript names the scan REJECTED because the session id they carry
263
+ #: fails the boundary in :mod:`atif_corpus.domain.session_id`. The same
264
+ #: kind of silence as ``unreadable``: such a session is never materialized,
265
+ #: never up_to_date, never skipped_live, never failed, and never marked,
266
+ #: so it has to be reported here or it vanishes without a trace.
267
+ rejected_session_ids: tuple[str, ...] = ()
268
+ #: Sessions the converter found EMPTY this pass (no user or assistant
269
+ #: record). Not failures: they are recorded in ``empty_sessions.json`` and
270
+ #: their watermark advances, so they are tried again only once their
271
+ #: source moves or the converter version changes.
272
+ empty_session_ids: tuple[str, ...] = ()
273
+ #: Every session the corpus keeps without a source, after this pass
274
+ #: (``removed_session_ids`` included).
275
+ retained_count: int = 0
276
+ #: Source-removed sessions re-converted from their raw source archive this
277
+ #: pass because their recorded generation was stale (or ``force``).
278
+ archive_session_ids: tuple[str, ...] = ()
279
+ #: Sessions the session sink took this pass (0 without a sink).
280
+ sink_synced_count: int = 0
281
+ #: Sessions the sink still owes after this pass: its first failing batch
282
+ #: and every batch after it. Recorded in ``sink_pending.json``.
283
+ sink_pending_session_ids: tuple[str, ...] = ()
284
+ #: The sink's failure, one line, when it raised.
285
+ sink_error: str | None = None
286
+ #: Wall seconds spent inside the sink.
287
+ sink_seconds: float = 0.0
288
+
289
+ @property
290
+ def failed_count(self) -> int:
291
+ """Number of sessions that failed this pass."""
292
+ return len(self.failures)
293
+
294
+ @property
295
+ def sessions_removed(self) -> int:
296
+ """Number of sessions whose source vanished this pass (artifacts kept)."""
297
+ return len(self.removed_session_ids)
298
+
299
+ @property
300
+ def unreadable_count(self) -> int:
301
+ """Number of sessions the scan could not read this pass."""
302
+ return len(self.unreadable_session_ids)
303
+
304
+ @property
305
+ def rejected_count(self) -> int:
306
+ """Number of transcript names the scan rejected this pass."""
307
+ return len(self.rejected_session_ids)
308
+
309
+ @property
310
+ def empty_count(self) -> int:
311
+ """Number of sessions the converter found empty this pass."""
312
+ return len(self.empty_session_ids)
313
+
314
+ @property
315
+ def archive_count(self) -> int:
316
+ """Number of sessions re-converted from their source archive this pass."""
317
+ return len(self.archive_session_ids)
318
+
319
+
320
+ #: Sessions per ``SessionSink.sync_sessions`` call unless the caller says otherwise.
321
+ DEFAULT_SINK_BATCH_SIZE = 64
322
+
323
+
324
+ def read_sink_pending(path: Path) -> tuple[str, ...]:
325
+ """Load ``sink_pending.json``'s session ids; empty when absent or unreadable.
326
+
327
+ An unreadable file degrades to empty rather than failing the pass. The
328
+ sink's own recovery covers what that loses: it rebuilds a copy it cannot
329
+ trust, and ``atif-sql lake verify`` names any session that differs.
330
+ """
331
+ try:
332
+ raw = json.loads(path.read_text(encoding="utf-8"))
333
+ except FileNotFoundError:
334
+ return ()
335
+ except (OSError, ValueError) as exc:
336
+ logger.warning("materialize: ignoring unreadable {}: {}", path, exc)
337
+ return ()
338
+ ids = raw.get("session_ids") if isinstance(raw, dict) else None
339
+ if not isinstance(ids, list):
340
+ return ()
341
+ return tuple(str(sid) for sid in ids if isinstance(sid, str))
342
+
343
+
344
+ def _ordered_unique(*groups: Sequence[str]) -> list[str]:
345
+ seen: set[str] = set()
346
+ out: list[str] = []
347
+ for group in groups:
348
+ for item in group:
349
+ if item not in seen:
350
+ seen.add(item)
351
+ out.append(item)
352
+ return out
353
+
354
+
355
+ class _SinkLedger:
356
+ """``sink_pending.json`` bookkeeping for one pass (a no-op without a sink)."""
357
+
358
+ def __init__(self, layout: CorpusLayout, sink: SessionSink | None) -> None:
359
+ self.layout = layout
360
+ self.sink = sink
361
+ self.previous: tuple[str, ...] = (
362
+ read_sink_pending(layout.sink_pending_path) if sink is not None else ()
363
+ )
364
+ self._intended: list[str] = list(self.previous)
365
+
366
+ def intend(self, session_ids: Sequence[str]) -> None:
367
+ """Record ``session_ids`` as owed BEFORE anything publishes them."""
368
+ if self.sink is None or not session_ids:
369
+ return
370
+ grown = _ordered_unique(self._intended, session_ids)
371
+ if len(grown) == len(self._intended):
372
+ return
373
+ self._intended = grown
374
+ self._write(grown)
375
+
376
+ def _write(self, session_ids: Sequence[str]) -> None:
377
+ path = self.layout.sink_pending_path
378
+ if session_ids:
379
+ self.layout.corpus_root.mkdir(parents=True, exist_ok=True)
380
+ write_json_atomic(path, {"session_ids": list(session_ids)}, indent=2)
381
+ else:
382
+ path.unlink(missing_ok=True)
383
+
384
+ def sync(
385
+ self, published: Sequence[str], *, agent: str, batch_size: int
386
+ ) -> tuple[int, tuple[str, ...], str | None, float]:
387
+ """Hand ``published`` (plan order) and the previous pending ids to the sink.
388
+
389
+ Returns ``(synced, still_pending, error, seconds)``. Stops at the first
390
+ batch that raises: a sink that failed once this pass is likely to fail
391
+ again, and everything after it stays pending either way.
392
+ """
393
+ if self.sink is None:
394
+ return 0, (), None, 0.0
395
+ order = _ordered_unique(published, self.previous)
396
+ started = time.perf_counter()
397
+ synced = 0
398
+ error: str | None = None
399
+ pending: list[str] = []
400
+ for start in range(0, len(order), batch_size):
401
+ batch = order[start : start + batch_size]
402
+ if error is not None:
403
+ pending.extend(batch)
404
+ continue
405
+ try:
406
+ self.sink.sync_sessions(
407
+ corpus_root=self.layout.corpus_root, agent=agent, session_ids=batch
408
+ )
409
+ except Exception as exc: # noqa: BLE001 — recorded and retried, never fatal to the pass
410
+ error = f"{type(exc).__name__}: {exc}"
411
+ logger.warning(
412
+ "materialize: session sink failed on {} session(s); they stay pending: {}",
413
+ len(batch),
414
+ error,
415
+ )
416
+ pending.extend(batch)
417
+ continue
418
+ synced += len(batch)
419
+ if pending or self.layout.sink_pending_path.exists():
420
+ self._write(pending)
421
+ return synced, tuple(pending), error, time.perf_counter() - started
422
+
423
+
424
+ def read_watermark(path: Path) -> dict[str, int]:
425
+ """Load ``watermark.json`` (``{path: mtime_ns}``); empty on first run.
426
+
427
+ A corrupt watermark degrades to empty — the cost is one full
428
+ re-materialization pass, which is always safe, versus refusing to sync.
429
+ """
430
+ try:
431
+ raw = json.loads(path.read_text(encoding="utf-8"))
432
+ except FileNotFoundError:
433
+ return {}
434
+ except (OSError, json.JSONDecodeError):
435
+ logger.warning("watermark {} unreadable; treating corpus as unmaterialized", path)
436
+ return {}
437
+ if not isinstance(raw, dict):
438
+ logger.warning("watermark {} has wrong shape; treating corpus as unmaterialized", path)
439
+ return {}
440
+ try:
441
+ return {str(key): int(value) for key, value in raw.items()}
442
+ except (TypeError, ValueError):
443
+ # A non-numeric mtime is corruption of the same kind as unparseable
444
+ # JSON and takes the same exit: degrade, do not raise. Inside the
445
+ # comprehension the coercion is what `json.JSONDecodeError` above
446
+ # cannot see, and an uncaught ValueError here would kill both
447
+ # `materialize` and the read-only `status` that shares this reader.
448
+ logger.warning(
449
+ "watermark {} holds a non-numeric mtime; treating corpus as unmaterialized", path
450
+ )
451
+ return {}
452
+
453
+
454
+ def read_empty_sessions(path: Path) -> dict[str, dict[str, Any]]:
455
+ """Load ``empty_sessions.json`` (``{session_id: generation}``); empty when absent.
456
+
457
+ A corrupt file degrades to empty, like the watermark: the cost is one more
458
+ conversion attempt per empty session, never a lost session.
459
+ """
460
+ try:
461
+ raw = json.loads(path.read_text(encoding="utf-8"))
462
+ except FileNotFoundError:
463
+ return {}
464
+ except (OSError, json.JSONDecodeError):
465
+ logger.warning("empty-session record {} unreadable; re-checking every empty session", path)
466
+ return {}
467
+ if not isinstance(raw, dict):
468
+ logger.warning("empty-session record {} has wrong shape; re-checking them", path)
469
+ return {}
470
+ return {
471
+ str(session_id): dict(generation)
472
+ for session_id, generation in raw.items()
473
+ if isinstance(generation, dict)
474
+ }
475
+
476
+
477
+ #: The keys ``meta.json`` always carries (or may carry, for the last three),
478
+ #: owned by this use case. An artifact producer may add keys but never these:
479
+ #: a producer that could overwrite ``session_id`` or ``agent`` could silently
480
+ #: relabel a session, so a collision is an error.
481
+ _META_CONTRACT_KEYS: frozenset[str] = frozenset(
482
+ {
483
+ "session_id",
484
+ "source_mtime_ns",
485
+ "source_files",
486
+ "harbor_version",
487
+ "converter_version",
488
+ "converter_schema",
489
+ "materialized_at",
490
+ "agent",
491
+ "source_present",
492
+ "source_removed_at",
493
+ META_SOURCE_ARCHIVE_KEY,
494
+ }
495
+ )
496
+
497
+ #: The generation key this use case stamps when the caller names the converter's
498
+ #: output version, and the one it falls back to comparing when it doesn't.
499
+ _CONVERTER_SCHEMA_KEY = "converter_schema"
500
+ _CONVERTER_VERSION_KEY = "converter_version"
501
+
502
+
503
+ def _produce_extra_artifacts(
504
+ producer: ArtifactProducer | None,
505
+ staging: Path,
506
+ session_id: str,
507
+ trajectory: dict[str, Any],
508
+ ) -> tuple[dict[str, Any], float]:
509
+ """Run the optional producer in ``staging``; return (meta extras, seconds).
510
+
511
+ The extras are checked against :data:`_META_CONTRACT_KEYS` here, before
512
+ ``meta.json`` is assembled, so a colliding producer fails the session
513
+ with a clear message instead of publishing a relabeled one.
514
+ """
515
+ if producer is None:
516
+ return {}, 0.0
517
+ started = time.perf_counter()
518
+ extras = dict(producer.produce(staging, session_id=session_id, trajectory=trajectory))
519
+ elapsed = time.perf_counter() - started
520
+ collisions = sorted(_META_CONTRACT_KEYS & extras.keys())
521
+ if collisions:
522
+ msg = f"artifact producer returned meta keys reserved by the contract: {collisions}"
523
+ raise ValueError(msg)
524
+ return extras, elapsed
525
+
526
+
527
+ @dataclass(frozen=True, slots=True)
528
+ class _Job:
529
+ """One session to convert this pass, shaped to cross a process boundary.
530
+
531
+ A LIVE job converts ``session_jsonl`` from the source tree. An ARCHIVE job
532
+ (``session_jsonl is None``) restores the session's raw source archive into
533
+ staging and converts that; it exists for a source-removed session whose
534
+ generation is stale. ``source_meta`` holds the ``meta.json`` keys that
535
+ describe the source (mtime, files, presence), copied verbatim into the new
536
+ ``meta.json`` so an archive re-conversion keeps saying where the session
537
+ originally came from. ``source_dir`` is the main transcript's parent,
538
+ relative to the source root, recorded in the archive manifest.
539
+ """
540
+
541
+ session_id: str
542
+ source_meta: dict[str, Any]
543
+ source_dir: str
544
+ session_jsonl: str | None = None
545
+ #: The previous archive manifest, carried forward by an archive job. Plain
546
+ #: dicts rather than read-only mappings because a job is pickled into the
547
+ #: pool, and ``MappingProxyType`` does not pickle.
548
+ previous_archive: dict[str, Any] | None = None
549
+
550
+
551
+ def _live_job(session: SessionSource, source_root: Path) -> _Job:
552
+ main = Path(session.session_jsonl)
553
+ try:
554
+ source_dir = main.parent.relative_to(source_root).as_posix()
555
+ except ValueError:
556
+ # Only reachable with a scanner that yields paths outside its root;
557
+ # fall back to the one component the converters read.
558
+ source_dir = main.parent.name or "."
559
+ return _Job(
560
+ session_id=session.session_id,
561
+ session_jsonl=session.session_jsonl,
562
+ source_dir=source_dir,
563
+ source_meta={
564
+ "source_mtime_ns": session.newest_mtime_ns,
565
+ "source_files": list(session.source_files),
566
+ "source_present": True,
567
+ },
568
+ )
569
+
570
+
571
+ def _link_or_copy_tree(src: Path, dst: Path) -> None:
572
+ """Copy an immutable archive tree, hard-linking where the filesystem allows.
573
+
574
+ The staging dir and the live session dir share a filesystem by
575
+ construction, so a link costs no bytes. The previous generation still owns
576
+ its links until the swap, so a failed pass leaves it whole.
577
+ """
578
+
579
+ def _link(source: str, destination: str) -> None:
580
+ try:
581
+ os.link(source, destination)
582
+ except OSError:
583
+ shutil.copy2(source, destination)
584
+
585
+ shutil.copytree(src, dst, copy_function=_link)
586
+
587
+
588
+ def _store_blobs(layout: CorpusLayout, blobs: Sequence[BlobOutput]) -> int:
589
+ """Write a session's attachments into the shared blob store; return how many were new.
590
+
591
+ WHY A SHARED STORE, NOT THE SESSION DIR. A blob is named by its content
592
+ hash, so writing one is idempotent and order-free: two sessions (or two
593
+ pool workers) holding the same screenshot store it once, and nothing
594
+ about a blob ever needs the per-session swap's all-or-nothing guarantee.
595
+ Putting blobs in the session dir would store every shared image once per
596
+ session and copy it again on every re-conversion.
597
+
598
+ WHY BEFORE THE SWAP. Every blob a session references is on disk before
599
+ the session that references it publishes, so a reader can never hold a
600
+ placeholder whose bytes are missing. A pass that dies between the two
601
+ leaves only unreferenced blobs, which cost space and nothing else.
602
+ """
603
+ written = 0
604
+ for blob in blobs:
605
+ if write_blob_atomic(layout.blob_path(blob.sha256, blob.extension), blob.data):
606
+ written += 1
607
+ if blobs:
608
+ logger.debug("materialize: {} blob(s) referenced, {} newly stored", len(blobs), written)
609
+ return written
610
+
611
+
612
+ def _write_session(
613
+ layout: CorpusLayout,
614
+ job: _Job,
615
+ converter: ConverterPort,
616
+ *,
617
+ materialized_at: str,
618
+ harbor_version: str,
619
+ converter_version: str,
620
+ converter_schema: int | None = None,
621
+ agent: str,
622
+ artifact_producer: ArtifactProducer | None = None,
623
+ convert_clock: Callable[[], float] = time.perf_counter,
624
+ ) -> tuple[float, float]:
625
+ """Convert one session and write its artifacts; returns (convert, artifact) seconds.
626
+
627
+ All artifacts are written into a staging dir under
628
+ ``<corpus_root>/.staging/`` (outside every reader glob), then the whole
629
+ dir is swapped into ``sessions/<id>/`` via
630
+ :func:`~atif_corpus.infrastructure.atomic.replace_dir_atomic` — a crash
631
+ at any point before the swap leaves the previous generation fully intact
632
+ and internally consistent; readers never see a torn artifact set. A kill
633
+ INSIDE the swap window leaves the session dir missing rather than torn,
634
+ and :func:`_unmaterialized_session_ids` replans it next pass. Within
635
+ staging the write order stays source archive (written by the converter
636
+ while it verifies its input) → trajectory → loss_report → edges →
637
+ session_events → (producer's extra artifacts) → meta, ``meta.json`` last as
638
+ belt-and-braces (atif-duck gates its readers on meta presence), and each
639
+ artifact is fsynced before its rename so the ordering holds across power
640
+ loss too. The trajectory, edges and session events are stored
641
+ zstd-compressed (``<name>.zst``, see
642
+ :data:`~atif_corpus.domain.layout.COMPRESSED_ARTIFACT_FILENAMES`). The
643
+ producer sees the staged trajectory already on disk and the same dict in
644
+ memory; whatever it writes rides the same swap.
645
+ The session's attachments go to the shared blob store (outside the
646
+ staging dir, see :func:`_store_blobs`) before any of that, so every blob
647
+ a published session references is already on disk.
648
+
649
+ An archive job restores the previous archive into
650
+ ``.staging/<id>.src-<pid>/`` (swept like any other staging residue if the
651
+ pass dies), converts the restored main transcript, and carries the
652
+ previous archive forward by hard link rather than trusting a re-archive
653
+ of its own restore.
654
+ """
655
+ pid = os.getpid()
656
+ staging = layout.staging_dir / f"{job.session_id}.tmp-{pid}"
657
+ restore_root = layout.staging_dir / f"{job.session_id}.src-{pid}"
658
+ shutil.rmtree(staging, ignore_errors=True)
659
+ staging.mkdir(parents=True)
660
+ archive_dir = staging / SOURCE_ARCHIVE_DIRNAME
661
+ try:
662
+ archive_meta: dict[str, Any]
663
+ if job.session_jsonl is None:
664
+ shutil.rmtree(restore_root, ignore_errors=True)
665
+ session_jsonl = restore_session_sources(
666
+ layout.session_dir(job.session_id), restore_root
667
+ )
668
+ _link_or_copy_tree(layout.source_archive_dir(job.session_id), archive_dir)
669
+ convert_started = convert_clock()
670
+ output = converter.convert(session_jsonl)
671
+ convert_elapsed = convert_clock() - convert_started
672
+ archive_meta = {META_SOURCE_ARCHIVE_KEY: dict(job.previous_archive or {})}
673
+ else:
674
+ session_jsonl = Path(job.session_jsonl)
675
+ archive_dir.mkdir()
676
+ convert_started = convert_clock()
677
+ output = converter.convert(session_jsonl, archive_dir=archive_dir)
678
+ convert_elapsed = convert_clock() - convert_started
679
+ if output.source_archive:
680
+ verify_archive_on_disk(archive_dir, output.source_archive)
681
+ archive_meta = {
682
+ META_SOURCE_ARCHIVE_KEY: archive_manifest(
683
+ output.source_archive, source_dir=job.source_dir, main=session_jsonl.name
684
+ )
685
+ }
686
+ else:
687
+ # An adapter that archives nothing leaves no half-promise on disk.
688
+ shutil.rmtree(archive_dir, ignore_errors=True)
689
+ archive_meta = {}
690
+ _store_blobs(layout, output.blobs)
691
+ write_json_zstd_atomic(
692
+ staging / stored_filename(TRAJECTORY_FILENAME), output.trajectory_dict, compact=True
693
+ )
694
+ write_json_atomic(staging / LOSS_REPORT_FILENAME, output.loss_report_dict)
695
+ write_text_zstd_atomic(
696
+ staging / stored_filename(EDGES_FILENAME),
697
+ "".join(f"{line}\n" for line in output.edges_lines),
698
+ )
699
+ write_text_zstd_atomic(
700
+ staging / stored_filename(SESSION_EVENTS_FILENAME),
701
+ "".join(f"{line}\n" for line in output.events_lines),
702
+ )
703
+ extras, artifact_elapsed = _produce_extra_artifacts(
704
+ artifact_producer, staging, job.session_id, output.trajectory_dict
705
+ )
706
+ write_json_atomic(
707
+ staging / META_FILENAME,
708
+ {
709
+ "session_id": job.session_id,
710
+ "source_mtime_ns": job.source_meta.get("source_mtime_ns"),
711
+ "source_files": job.source_meta.get("source_files", []),
712
+ "harbor_version": harbor_version,
713
+ "converter_version": converter_version,
714
+ **({} if converter_schema is None else {_CONVERTER_SCHEMA_KEY: converter_schema}),
715
+ "materialized_at": materialized_at,
716
+ # WHICH agent wrote the transcript this artifact set came from.
717
+ # A corpus root holds one agent's sessions by construction (the
718
+ # slug derives from the source root), so this is provenance
719
+ # rather than a discriminator — but a corpus copied out of place
720
+ # keeps saying what it is, and an operator reading one
721
+ # meta.json does not have to infer the agent from a path shape.
722
+ "agent": agent,
723
+ **{
724
+ key: job.source_meta[key]
725
+ for key in ("source_present", "source_removed_at")
726
+ if key in job.source_meta
727
+ },
728
+ **archive_meta,
729
+ **extras,
730
+ },
731
+ )
732
+ layout.sessions_dir.mkdir(parents=True, exist_ok=True)
733
+ replace_dir_atomic(staging, layout.session_dir(job.session_id))
734
+ except BaseException:
735
+ shutil.rmtree(staging, ignore_errors=True)
736
+ raise
737
+ finally:
738
+ shutil.rmtree(restore_root, ignore_errors=True)
739
+ return convert_elapsed, artifact_elapsed
740
+
741
+
742
+ @dataclass(frozen=True, slots=True)
743
+ class _SessionOutcome:
744
+ """What one convert+write attempt produced, shaped to cross a process boundary.
745
+
746
+ A worker never lets the converter's exception escape: the executor would
747
+ pickle it, and an exception class whose ``__init__`` signature differs
748
+ from its ``args`` (``TrajectoryValidationError(list)`` is one) fails to
749
+ rebuild in the parent and surfaces as a pickling error in its place. The
750
+ worker formats the failure line exactly as the serial path does, so the
751
+ report carries the same text either way.
752
+ """
753
+
754
+ session_id: str
755
+ convert_seconds: float
756
+ error: str | None
757
+ artifact_seconds: float = 0.0
758
+ #: The converter raised :class:`EmptySourceError`: nothing to convert.
759
+ empty: bool = False
760
+
761
+
762
+ def _attempt_session(
763
+ layout: CorpusLayout,
764
+ job: _Job,
765
+ converter: ConverterPort,
766
+ *,
767
+ materialized_at: str,
768
+ harbor_version: str,
769
+ converter_version: str,
770
+ converter_schema: int | None = None,
771
+ agent: str,
772
+ artifact_producer: ArtifactProducer | None = None,
773
+ convert_clock: Callable[[], float] = time.perf_counter,
774
+ ) -> _SessionOutcome:
775
+ """Run :func:`_write_session` and fold any exception into the outcome."""
776
+ try:
777
+ elapsed, produced = _write_session(
778
+ layout,
779
+ job,
780
+ converter,
781
+ materialized_at=materialized_at,
782
+ harbor_version=harbor_version,
783
+ converter_version=converter_version,
784
+ converter_schema=converter_schema,
785
+ agent=agent,
786
+ artifact_producer=artifact_producer,
787
+ convert_clock=convert_clock,
788
+ )
789
+ except EmptySourceError:
790
+ return _SessionOutcome(
791
+ session_id=job.session_id, convert_seconds=0.0, error=None, empty=True
792
+ )
793
+ except Exception as error: # noqa: BLE001 — one bad session must not abort the sync
794
+ return _SessionOutcome(
795
+ session_id=job.session_id,
796
+ convert_seconds=0.0,
797
+ error=f"{type(error).__name__}: {error}",
798
+ )
799
+ return _SessionOutcome(
800
+ session_id=job.session_id,
801
+ convert_seconds=elapsed,
802
+ error=None,
803
+ artifact_seconds=produced,
804
+ )
805
+
806
+
807
+ #: The converter a pool worker builds once in :func:`_worker_init` and reuses
808
+ #: for every session it is handed. Module state because the executor gives a
809
+ #: task function nothing else to reach it through.
810
+ _worker_converter: ConverterPort | None = None
811
+ #: The artifact producer handed to the same worker, or ``None`` when none was injected.
812
+ _worker_artifact_producer: ArtifactProducer | None = None
813
+
814
+
815
+ def _worker_init(
816
+ converter: ConverterPort,
817
+ setup: Callable[[], None] | None,
818
+ artifact_producer: ArtifactProducer | None = None,
819
+ ) -> None:
820
+ """Pool initializer: run the caller's process setup, then adopt the converter.
821
+
822
+ ``converter`` arrives pickled from the parent — for the real adapter that
823
+ is a small spec (agent, flags) whose unpickling imports the converter
824
+ stack once per worker. ``setup`` is the composition root's hook for
825
+ per-process concerns the use case must not know about, such as
826
+ installing the same log sink the parent runs.
827
+ """
828
+ global _worker_converter, _worker_artifact_producer # noqa: PLW0603 — the executor offers no other channel
829
+ if setup is not None:
830
+ setup()
831
+ _worker_converter = converter
832
+ _worker_artifact_producer = artifact_producer
833
+
834
+
835
+ def _worker_attempt(
836
+ layout: CorpusLayout,
837
+ job: _Job,
838
+ *,
839
+ materialized_at: str,
840
+ harbor_version: str,
841
+ converter_version: str,
842
+ converter_schema: int | None = None,
843
+ agent: str,
844
+ convert_clock: Callable[[], float] = time.perf_counter,
845
+ ) -> _SessionOutcome:
846
+ """Pool task: convert and write one session with this worker's converter."""
847
+ if _worker_converter is None:
848
+ msg = "pool worker used before its initializer ran"
849
+ raise RuntimeError(msg)
850
+ return _attempt_session(
851
+ layout,
852
+ job,
853
+ _worker_converter,
854
+ materialized_at=materialized_at,
855
+ harbor_version=harbor_version,
856
+ converter_version=converter_version,
857
+ converter_schema=converter_schema,
858
+ agent=agent,
859
+ artifact_producer=_worker_artifact_producer,
860
+ convert_clock=convert_clock,
861
+ )
862
+
863
+
864
+ class _Provenance(TypedDict):
865
+ """What every session of one pass is stamped with, typed per key for ``**`` forwarding."""
866
+
867
+ materialized_at: str
868
+ harbor_version: str
869
+ converter_version: str
870
+ converter_schema: int | None
871
+ agent: str
872
+
873
+
874
+ def _attempt_sessions(
875
+ layout: CorpusLayout,
876
+ jobs: Sequence[_Job],
877
+ converter: ConverterPort,
878
+ *,
879
+ workers: int,
880
+ worker_setup: Callable[[], None] | None,
881
+ materialized_at: str,
882
+ harbor_version: str,
883
+ converter_version: str,
884
+ converter_schema: int | None = None,
885
+ agent: str,
886
+ artifact_producer: ArtifactProducer | None = None,
887
+ convert_clock: Callable[[], float] = time.perf_counter,
888
+ ) -> tuple[list[_SessionOutcome], int]:
889
+ """Convert and write every planned session; return outcomes in PLAN order.
890
+
891
+ ``workers == 1`` is the reference path: every session runs inline, in
892
+ order, in this process. Above that a spawn-context
893
+ :class:`~concurrent.futures.ProcessPoolExecutor` of
894
+ ``min(workers, len(jobs))`` processes does the same work, and the
895
+ outcomes are read back in submission order so completion order can never
896
+ reorder the report. A pass with a single planned session runs inline
897
+ whatever ``workers`` says: a pool of one buys nothing and costs a process
898
+ start plus a converter import.
899
+
900
+ ``spawn`` rather than the platform default, deliberately: a forked child
901
+ inherits whatever threads and locks the parent holds, which is the class
902
+ of bug that shows up once a month and never under a test. Each spawned
903
+ worker imports the converter once, and that cost is paid once per worker,
904
+ not per session.
905
+
906
+ The second value is the worker count actually used, for the report.
907
+ """
908
+ if workers < 1:
909
+ msg = f"workers must be >= 1, got {workers}"
910
+ raise ValueError(msg)
911
+ provenance: _Provenance = {
912
+ "materialized_at": materialized_at,
913
+ "harbor_version": harbor_version,
914
+ "converter_version": converter_version,
915
+ "converter_schema": converter_schema,
916
+ "agent": agent,
917
+ }
918
+ pool_size = min(workers, len(jobs))
919
+ if pool_size <= 1:
920
+ return [
921
+ _attempt_session(
922
+ layout,
923
+ job,
924
+ converter,
925
+ artifact_producer=artifact_producer,
926
+ convert_clock=convert_clock,
927
+ **provenance,
928
+ )
929
+ for job in jobs
930
+ ], 1
931
+ with ProcessPoolExecutor(
932
+ max_workers=pool_size,
933
+ mp_context=multiprocessing.get_context("spawn"),
934
+ initializer=_worker_init,
935
+ initargs=(converter, worker_setup, artifact_producer),
936
+ ) as pool:
937
+ futures = [
938
+ pool.submit(_worker_attempt, layout, job, convert_clock=convert_clock, **provenance)
939
+ for job in jobs
940
+ ]
941
+ return [future.result() for future in futures], pool_size
942
+
943
+
944
+ #: Every ``.tmp-<pid>`` / ``.old-<pid>`` / ``.src-<pid>`` marker a staging entry name carries.
945
+ _STAGING_PID_RE = re.compile(r"\.(?:tmp|old|src)-(\d+)")
946
+
947
+
948
+ def _staging_owner_pids(name: str) -> frozenset[int]:
949
+ """Pids named by a staging entry (``<sid>.tmp-<pid>[.old-<pid>]``, ``<sid>.src-<pid>``)."""
950
+ return frozenset(int(match) for match in _STAGING_PID_RE.findall(name))
951
+
952
+
953
+ def _is_live_pid(pid: int) -> bool:
954
+ """True when ``pid`` names a process that currently exists.
955
+
956
+ Only ``ProcessLookupError`` proves the pid is gone. Every other outcome —
957
+ signal delivered, or ``PermissionError`` because the process belongs to
958
+ another user — means it exists, and an unexpected ``OSError`` means the
959
+ probe learned nothing. All three answer "do not delete", which is the
960
+ direction that cannot destroy a running pass's scratch.
961
+ """
962
+ try:
963
+ os.kill(pid, 0)
964
+ except ProcessLookupError:
965
+ return False
966
+ except OSError:
967
+ return True
968
+ return True
969
+
970
+
971
+ def _sweep_staging(layout: CorpusLayout) -> None:
972
+ """Remove ``.staging/`` entries owned by pids that no longer exist.
973
+
974
+ ``.staging/`` is exclusively scratch for the in-flight directory swap and
975
+ for archive restores — nothing durable lives there and no reader globs it
976
+ — so an entry whose ``tmp-<pid>``/``old-<pid>``/``src-<pid>`` owner is GONE
977
+ is debris from a process that died mid-swap. The per-session cleanup in
978
+ :func:`_write_session` only matches the current pid, so without this sweep
979
+ ``*.tmp-<oldpid>`` and ``*.old-<pid>`` grow without bound on the corpus
980
+ filesystem.
981
+
982
+ A single writer is NOT the precondition. ``scripts/atif-sql-refresh.sh``
983
+ takes ``flock -n`` on a PER-LANE lock, and a manual
984
+ ``atif-sql materialize`` takes no lock at all, so a second pass can be
985
+ mid-swap while this one starts. Deleting its ``*.tmp-<pid>`` destroys live
986
+ work, so the pid probe gates every removal.
987
+
988
+ A pid can be recycled onto an unrelated process, which only makes the
989
+ sweep skip debris it could have removed — the next pass whose pid table
990
+ has moved on collects it. Erring that direction is deliberate.
991
+
992
+ Pool workers change nothing here. A worker stages under ITS pid, so a
993
+ concurrent pass's in-flight entries name pids that are alive and are kept,
994
+ and a pass that died takes its workers with it (a spawned worker exits
995
+ when the parent's queue closes), so its entries name pids that are gone
996
+ and are swept — the same two answers the single-process pass gets.
997
+ """
998
+ staging = layout.staging_dir
999
+ if not staging.is_dir():
1000
+ return
1001
+ for entry in sorted(staging.iterdir()):
1002
+ live = sorted(pid for pid in _staging_owner_pids(entry.name) if _is_live_pid(pid))
1003
+ if live:
1004
+ logger.debug(
1005
+ "materialize: keeping staging entry {} — pid(s) {} still running",
1006
+ entry,
1007
+ live,
1008
+ )
1009
+ continue
1010
+ if entry.is_dir() and not entry.is_symlink():
1011
+ shutil.rmtree(entry, ignore_errors=True)
1012
+ else:
1013
+ entry.unlink(missing_ok=True)
1014
+ logger.debug("materialize: swept crash residue {}", entry)
1015
+
1016
+
1017
+ def _unmaterialized_session_ids(
1018
+ layout: CorpusLayout,
1019
+ watermark: Mapping[str, int],
1020
+ sessions: Collection[SessionSource],
1021
+ known_empty: Collection[str] = (),
1022
+ ) -> frozenset[str]:
1023
+ """Scanned sessions the watermark calls current but whose artifact dir is gone.
1024
+
1025
+ Reachable one way: a pass was killed inside
1026
+ :func:`~atif_corpus.infrastructure.atomic.replace_dir_atomic`'s swap
1027
+ window, after the previous generation was renamed aside and before the
1028
+ new one landed. The watermark records SOURCE mtimes only, so on a
1029
+ ``--force`` pass over a non-stale session it still matches and every
1030
+ later pass reports ``up_to_date`` while the session dir stays missing.
1031
+ Force-replanning these is the recovery.
1032
+
1033
+ A session in ``known_empty`` has a current watermark and no dir BY DESIGN
1034
+ (it converted to nothing), so it is not one of these; without the
1035
+ exclusion every empty session would be retried every pass, which is the
1036
+ loop the empty record exists to stop.
1037
+
1038
+ Operand order is the whole cost of this function. The dir check is one
1039
+ ``stat``; the watermark check is a linear scan of every watermark entry,
1040
+ so testing it first makes the pass O(sessions x watermark_entries) rather
1041
+ than O(sessions), every pass, to flag nothing. The missing dir is the rare
1042
+ condition, so it gates the scan.
1043
+ """
1044
+ empty = set(known_empty)
1045
+ return frozenset(
1046
+ session.session_id
1047
+ for session in sessions
1048
+ if session.session_id not in empty
1049
+ and not layout.session_dir(session.session_id).is_dir()
1050
+ and any(owns_path(session.session_jsonl, path) for path in watermark)
1051
+ )
1052
+
1053
+
1054
+ def _read_meta(layout: CorpusLayout, session_id: str) -> dict[str, Any] | None:
1055
+ """One session's ``meta.json`` as a dict, or ``None`` if it cannot be read as one."""
1056
+ try:
1057
+ meta = json.loads(layout.meta_path(session_id).read_text(encoding="utf-8"))
1058
+ except (OSError, ValueError):
1059
+ return None
1060
+ return meta if isinstance(meta, dict) else None
1061
+
1062
+
1063
+ def _generation_stale_session_ids(
1064
+ layout: CorpusLayout,
1065
+ sessions: Collection[SessionSource],
1066
+ expected: Mapping[str, object],
1067
+ known_empty: Mapping[str, Mapping[str, object]],
1068
+ ) -> frozenset[str]:
1069
+ """Scanned sessions whose recorded generation differs from ``expected``.
1070
+
1071
+ One ``meta.json`` read per scanned session with an artifact dir. A meta
1072
+ that cannot be read is stale too: re-converting is what repairs it. A
1073
+ known-empty session compares its empty record instead, so a converter
1074
+ upgrade gets a chance at the transcripts the old one found empty. A
1075
+ session with neither is the watermark's business, not this function's.
1076
+ """
1077
+ stale: set[str] = set()
1078
+ for session in sessions:
1079
+ session_id = session.session_id
1080
+ if session_id in known_empty:
1081
+ if not generation_matches(known_empty[session_id], expected):
1082
+ stale.add(session_id)
1083
+ continue
1084
+ if not layout.session_dir(session_id).is_dir():
1085
+ continue
1086
+ meta = _read_meta(layout, session_id)
1087
+ if meta is None or not generation_matches(meta, expected):
1088
+ stale.add(session_id)
1089
+ return frozenset(stale)
1090
+
1091
+
1092
+ def _sessions_under_unlistable_dirs(
1093
+ scan: SourceScan,
1094
+ watermark: Mapping[str, int],
1095
+ *,
1096
+ source_root: Path,
1097
+ source_layout: SourceLayout,
1098
+ ) -> dict[str, str]:
1099
+ """``{session_id: main_jsonl}`` for sessions inside a dir that would not list.
1100
+
1101
+ A directory at mode 000 discovers NOTHING, so its sessions cannot reach
1102
+ :attr:`SourceScan.unreadable` — that map is keyed by a session the scan
1103
+ actually saw. Without this resolution every session under such a dir looks
1104
+ deleted and is marked source-removed, which is the same false verdict the
1105
+ per-session stat guard exists to prevent, one level up.
1106
+
1107
+ The watermark is the only record of which sessions lived there, so it
1108
+ supplies the ids, and
1109
+ :meth:`~atif_corpus.domain.source_layout.SourceLayout.session_from_watermark_path`
1110
+ resolves each recorded path by POSITION under the source root — never by
1111
+ stripping the unlistable prefix, which would yield a project or a date
1112
+ component when the unlistable dir is an intermediate one. That resolved id
1113
+ reaches operators through
1114
+ :attr:`MaterializationReport.unreadable_session_ids`.
1115
+ """
1116
+ if not scan.unlistable_dirs:
1117
+ return {}
1118
+ resolved: dict[str, str] = {}
1119
+ for directory in scan.unlistable_dirs:
1120
+ prefix = f"{directory.rstrip('/')}/"
1121
+ for path in watermark:
1122
+ if not path.startswith(prefix):
1123
+ continue
1124
+ session = source_layout.session_from_watermark_path(str(source_root), path)
1125
+ if session is None:
1126
+ continue
1127
+ session_id, main_jsonl = session
1128
+ resolved[session_id] = main_jsonl
1129
+ if resolved:
1130
+ logger.warning(
1131
+ "materialize: {} session(s) live under a directory that could not be "
1132
+ "listed this pass; keeping their artifacts and retaining their "
1133
+ "watermark entries",
1134
+ len(resolved),
1135
+ )
1136
+ return resolved
1137
+
1138
+
1139
+ #: How many existing session dirs to open looking for the corpus's agent. One
1140
+ #: is normally enough; a handful covers a corpus whose first dirs are mid-swap
1141
+ #: or unreadable, and the cap keeps the probe O(1) on a 3,700-session corpus.
1142
+ _AGENT_PROBE_LIMIT = 8
1143
+
1144
+
1145
+ def _corpus_agent(layout: CorpusLayout, session_dir_names: Sequence[str]) -> str | None:
1146
+ """The agent a materialized corpus says it holds, or ``None`` if it cannot say.
1147
+
1148
+ Reads at most :data:`_AGENT_PROBE_LIMIT` ``meta.json`` files and returns the
1149
+ first answer. A ``meta.json`` with NO ``agent`` key still answers
1150
+ ``claude-code``: the key landed with Codex support, and this workspace could
1151
+ not read a Codex rollout before then, so a corpus written without it holds
1152
+ Claude Code sessions. Unreadable or unparseable meta files are skipped
1153
+ rather than treated as an answer — a permission blip must not be able to
1154
+ relabel a corpus.
1155
+ """
1156
+ for name in list(session_dir_names)[:_AGENT_PROBE_LIMIT]:
1157
+ try:
1158
+ meta = json.loads(layout.meta_path(name).read_text(encoding="utf-8"))
1159
+ except (OSError, ValueError):
1160
+ continue
1161
+ if not isinstance(meta, dict):
1162
+ continue
1163
+ agent = meta.get("agent")
1164
+ if isinstance(agent, str) and agent:
1165
+ return agent
1166
+ return CLAUDE_CODE_LAYOUT.agent.value
1167
+ return None
1168
+
1169
+
1170
+ def _sourceless_session_ids(
1171
+ layout: CorpusLayout,
1172
+ scanned_session_ids: Collection[str],
1173
+ unreadable_session_ids: Collection[str],
1174
+ rejected_session_ids: Collection[str] = (),
1175
+ ) -> tuple[str, ...]:
1176
+ """Corpus session dirs with no source in this scan, excluding the two false alarms.
1177
+
1178
+ A session in ``unreadable_session_ids`` is NOT sourceless — the scan
1179
+ failed to stat it (EIO, ESTALE, a permission blip) rather than finding it
1180
+ deleted. A session in ``rejected_session_ids`` is not either: its source
1181
+ is present, the scan declined it by policy, and a corpus dir under that
1182
+ name (one an older version wrote) is left for the operator. Callers must
1183
+ run the suspicious-empty-scan guard first.
1184
+ """
1185
+ sessions_dir = layout.sessions_dir
1186
+ if not sessions_dir.is_dir():
1187
+ return ()
1188
+ sourceless: list[str] = []
1189
+ for session_dir in sorted(sessions_dir.iterdir()):
1190
+ if not session_dir.is_dir():
1191
+ continue
1192
+ session_id = session_dir.name
1193
+ if session_id in scanned_session_ids:
1194
+ continue
1195
+ if session_id in rejected_session_ids:
1196
+ logger.warning(
1197
+ "materialize: keeping session dir {!r} — its name fails the session id "
1198
+ "boundary, so this version neither writes nor reads it; remove it by hand",
1199
+ session_id,
1200
+ )
1201
+ continue
1202
+ if session_id in unreadable_session_ids:
1203
+ logger.warning(
1204
+ "materialize: keeping session {} — its sources could not be read this "
1205
+ "pass, which is not evidence they are gone",
1206
+ session_id,
1207
+ )
1208
+ continue
1209
+ sourceless.append(session_id)
1210
+ return tuple(sourceless)
1211
+
1212
+
1213
+ def _retain_sourceless_sessions(
1214
+ layout: CorpusLayout,
1215
+ sourceless_ids: Sequence[str],
1216
+ *,
1217
+ removed_at: str,
1218
+ mark: bool,
1219
+ before_mark: Callable[[Sequence[str]], None] | None = None,
1220
+ ) -> tuple[tuple[str, ...], dict[str, dict[str, Any]]]:
1221
+ """Keep every sourceless session; mark the ones not yet marked. Nothing is deleted.
1222
+
1223
+ Returns ``(newly_marked_ids, retained)`` where ``retained`` maps every
1224
+ session now recorded ``source_present: false`` to its ``meta.json``. The
1225
+ mark is a ``meta.json`` rewrite through the same atomic replace every
1226
+ artifact uses, so a reader sees the old meta or the new one and the
1227
+ session's other artifacts never move. With ``mark=False`` (a pass that
1228
+ could not list part of the source tree, so absence proves nothing) no meta
1229
+ is rewritten and only sessions marked on an earlier pass are returned.
1230
+
1231
+ A session whose ``meta.json`` cannot be read is left exactly as it is: no
1232
+ mark, and it is not reported as retained, because its provenance cannot be
1233
+ trusted enough to rewrite.
1234
+ """
1235
+ newly_marked: list[str] = []
1236
+ retained: dict[str, dict[str, Any]] = {}
1237
+ metas = {session_id: _read_meta(layout, session_id) for session_id in sourceless_ids}
1238
+ if mark and before_mark is not None:
1239
+ before_mark(
1240
+ [
1241
+ sid
1242
+ for sid, meta in metas.items()
1243
+ if meta is not None and meta.get("source_present") is not False
1244
+ ]
1245
+ )
1246
+ for session_id in sourceless_ids:
1247
+ meta = metas[session_id]
1248
+ if meta is None:
1249
+ logger.warning(
1250
+ "materialize: session {} has no source and an unreadable meta.json; "
1251
+ "leaving it untouched",
1252
+ session_id,
1253
+ )
1254
+ continue
1255
+ if meta.get("source_present") is not False:
1256
+ if not mark:
1257
+ continue
1258
+ meta = {**meta, "source_present": False, "source_removed_at": removed_at}
1259
+ write_json_atomic(layout.meta_path(session_id), meta)
1260
+ newly_marked.append(session_id)
1261
+ logger.info(
1262
+ "materialize: source of session {} vanished; artifacts kept, marked "
1263
+ "source_present=false",
1264
+ session_id,
1265
+ )
1266
+ retained[session_id] = meta
1267
+ return tuple(newly_marked), retained
1268
+
1269
+
1270
+ def _archive_jobs(
1271
+ retained: Mapping[str, Mapping[str, Any]],
1272
+ expected: Mapping[str, object],
1273
+ *,
1274
+ force: bool,
1275
+ wanted: Collection[str] | None,
1276
+ ) -> tuple[_Job, ...]:
1277
+ """Archive re-conversions for retained sessions whose generation is stale.
1278
+
1279
+ Only a session that HAS an archive can be re-converted; one retained
1280
+ before archives existed keeps its old artifacts, which is still better
1281
+ than the deletion it used to get. ``force`` re-converts every archived
1282
+ retained session, the same meaning it has for live ones.
1283
+ """
1284
+ jobs: list[_Job] = []
1285
+ for session_id in sorted(retained):
1286
+ if wanted is not None and session_id not in wanted:
1287
+ continue
1288
+ meta = retained[session_id]
1289
+ manifest = meta.get(META_SOURCE_ARCHIVE_KEY)
1290
+ if not isinstance(manifest, dict):
1291
+ continue
1292
+ if not force and generation_matches(meta, expected):
1293
+ continue
1294
+ source_meta = {
1295
+ key: meta[key]
1296
+ for key in ("source_mtime_ns", "source_files", "source_present", "source_removed_at")
1297
+ if key in meta
1298
+ }
1299
+ jobs.append(
1300
+ _Job(
1301
+ session_id=session_id,
1302
+ source_meta=source_meta,
1303
+ source_dir=str(manifest.get("source_dir", ".")),
1304
+ previous_archive=dict(manifest),
1305
+ )
1306
+ )
1307
+ return tuple(jobs)
1308
+
1309
+
1310
+ def _advance_watermark(
1311
+ previous: dict[str, int],
1312
+ scan: SourceScan,
1313
+ succeeded: tuple[SessionSource, ...],
1314
+ ) -> dict[str, int]:
1315
+ """The next ``watermark.json`` contents.
1316
+
1317
+ ONE retention rule for every entry, whether or not the pass was
1318
+ filtered: a path still on disk keeps its entry, and a path that VANISHED
1319
+ keeps its entry too unless the session owning it succeeded this pass or
1320
+ left the scan entirely. Succeeded sessions (empty ones included) then
1321
+ advance their entries; everything else is byte-identical to the previous
1322
+ pass.
1323
+
1324
+ Retaining a vanished path is what makes the retry work. Staleness is
1325
+ "recorded set != scanned set"
1326
+ (:func:`atif_corpus.domain.sessions._is_stale`), so the retained entry IS
1327
+ the signal. Drop it for a session that merely FAILED — or that a
1328
+ ``--sessions`` filter never planned — and the next pass sees recorded
1329
+ equal to scanned, classifies the session ``up_to_date`` forever, and the
1330
+ corpus serves a trajectory embedding the deleted file's records with no
1331
+ retry ever scheduled.
1332
+
1333
+ Two sessions sit outside the "still in the scan" test. One genuinely
1334
+ LEFT the scan: its artifacts are retained and marked source-removed, and
1335
+ its entries drop, so that if the source ever comes back the session reads
1336
+ as stale and is converted again from the live file. The other could not
1337
+ be READ (a stat failure, which is not evidence about what its sources
1338
+ are): its entries are retained wholesale, exactly as if it had failed.
1339
+ """
1340
+ current_paths = {path for session in scan.sessions for path in session.source_mtimes}
1341
+ succeeded_ids = {session.session_id for session in succeeded}
1342
+ unresolved_mains = [
1343
+ session.session_jsonl
1344
+ for session in scan.sessions
1345
+ if session.session_id not in succeeded_ids
1346
+ ]
1347
+ unresolved_mains.extend(scan.unreadable.values())
1348
+
1349
+ def _retain(path: str) -> bool:
1350
+ # The membership test carries almost every entry; only a genuinely
1351
+ # vanished path reaches the per-session scope scan.
1352
+ return path in current_paths or any(owns_path(main, path) for main in unresolved_mains)
1353
+
1354
+ advanced = {path: mtime for path, mtime in previous.items() if _retain(path)}
1355
+ for session in succeeded:
1356
+ advanced.update(session.source_mtimes)
1357
+ return advanced
1358
+
1359
+
1360
+ def _next_empty_record(
1361
+ previous: Mapping[str, Mapping[str, object]],
1362
+ *,
1363
+ keep_ids: Collection[str],
1364
+ attempted_ids: Collection[str],
1365
+ newly_empty: Collection[str],
1366
+ expected: Mapping[str, object],
1367
+ ) -> dict[str, dict[str, object]]:
1368
+ """The next ``empty_sessions.json``: drop what moved on, add what came back empty.
1369
+
1370
+ A recorded session keeps its entry while it is still around
1371
+ (``keep_ids``: scanned or merely unreadable) and was not attempted this
1372
+ pass. An attempt replaces the entry: empty again records the current
1373
+ generation, anything else (success or a real failure) removes it.
1374
+ """
1375
+ attempted = set(attempted_ids)
1376
+ keep = set(keep_ids)
1377
+ record = {
1378
+ session_id: dict(generation)
1379
+ for session_id, generation in previous.items()
1380
+ if session_id in keep and session_id not in attempted
1381
+ }
1382
+ for session_id in newly_empty:
1383
+ record[session_id] = dict(expected)
1384
+ return dict(sorted(record.items()))
1385
+
1386
+
1387
+ def expected_generation(
1388
+ converter_version: str,
1389
+ expected_meta: Mapping[str, object] | None = None,
1390
+ *,
1391
+ converter_schema: int | None = None,
1392
+ ) -> dict[str, object]:
1393
+ """The ``meta.json`` values a current artifact set must carry this pass.
1394
+
1395
+ ``converter_schema`` when the caller names one (atif-cli passes the
1396
+ converter's ``CONVERTER_SCHEMA_VERSION``), otherwise ``converter_version``;
1397
+ plus whatever the caller adds in ``expected_meta``. ``expected_meta`` may
1398
+ not contradict the stamped key: the value stamped and the value compared
1399
+ must be the same one.
1400
+ """
1401
+ extra = dict(expected_meta or {})
1402
+ key, value = (
1403
+ (_CONVERTER_VERSION_KEY, converter_version)
1404
+ if converter_schema is None
1405
+ else (_CONVERTER_SCHEMA_KEY, converter_schema)
1406
+ )
1407
+ if key in extra and extra[key] != value:
1408
+ msg = f"expected_meta may not carry a {key} different from the stamped one"
1409
+ raise ValueError(msg)
1410
+ return {**extra, key: value}
1411
+
1412
+
1413
+ @dataclass(frozen=True, slots=True)
1414
+ class PassPreview:
1415
+ """What a materialize pass would do right now, computed without writing anything.
1416
+
1417
+ ``atif-sql status`` reports this, so its staleness numbers are the ones the
1418
+ next pass would act on, generation staleness and archive re-conversions
1419
+ included.
1420
+ """
1421
+
1422
+ plan: MaterializationPlan
1423
+ #: Sessions kept without a source (marked, or about to be marked).
1424
+ retained_session_ids: tuple[str, ...] = ()
1425
+ #: Of those, the ones the next pass would re-convert from their archive.
1426
+ archive_session_ids: tuple[str, ...] = ()
1427
+ #: Sessions recorded as empty.
1428
+ empty_session_ids: tuple[str, ...] = ()
1429
+ #: Sessions whose recorded generation differs from the expected one.
1430
+ generation_stale_session_ids: tuple[str, ...] = ()
1431
+
1432
+
1433
+ def preview_pass(
1434
+ *,
1435
+ source_root: Path,
1436
+ corpus_root: Path,
1437
+ converter_version: str,
1438
+ expected_meta: Mapping[str, object] | None = None,
1439
+ converter_schema: int | None = None,
1440
+ quiesce_seconds: int = 300,
1441
+ source_layout: SourceLayout = CLAUDE_CODE_LAYOUT,
1442
+ now_ns: int | None = None,
1443
+ ) -> PassPreview:
1444
+ """Replay a pass's planning decisions read-only, for ``status``.
1445
+
1446
+ Same scan, same watermark, same empty record and same generation check as
1447
+ :func:`materialize`, with "now" read after the scan the same way. It marks
1448
+ nothing and converts nothing; a session the next pass would newly mark
1449
+ already counts as retained here.
1450
+ """
1451
+ layout = CorpusLayout(corpus_root=corpus_root)
1452
+ watermark = read_watermark(layout.watermark_path)
1453
+ empties = read_empty_sessions(layout.empty_sessions_path)
1454
+ scan = scan_sources(source_root, source_layout)
1455
+ scan = scan.with_unreadable(
1456
+ _sessions_under_unlistable_dirs(
1457
+ scan, watermark, source_root=source_root, source_layout=source_layout
1458
+ )
1459
+ )
1460
+ effective_now_ns = time.time_ns() if now_ns is None else now_ns
1461
+ expected = expected_generation(
1462
+ converter_version, expected_meta, converter_schema=converter_schema
1463
+ )
1464
+ stale_generation = _generation_stale_session_ids(layout, scan.sessions, expected, empties)
1465
+ plan = build_plan(
1466
+ scan.sessions,
1467
+ watermark=watermark,
1468
+ policy=QuiescencePolicy(quiesce_seconds=quiesce_seconds),
1469
+ now_ns=effective_now_ns,
1470
+ unmaterialized_session_ids=_unmaterialized_session_ids(
1471
+ layout, watermark, scan.sessions, empties
1472
+ ),
1473
+ generation_stale_session_ids=stale_generation,
1474
+ )
1475
+ sourceless = _sourceless_session_ids(
1476
+ layout,
1477
+ {s.session_id for s in scan.sessions},
1478
+ scan.unreadable_session_ids,
1479
+ scan.rejected_session_ids,
1480
+ )
1481
+ retained: dict[str, dict[str, Any]] = {}
1482
+ for session_id in sourceless:
1483
+ meta = _read_meta(layout, session_id)
1484
+ if meta is not None and (not scan.unlistable_dirs or meta.get("source_present") is False):
1485
+ retained[session_id] = meta
1486
+ archive = _archive_jobs(retained, expected, force=False, wanted=None)
1487
+ scanned_ids = {s.session_id for s in scan.sessions} | scan.unreadable_session_ids
1488
+ return PassPreview(
1489
+ plan=plan,
1490
+ retained_session_ids=tuple(sorted(retained)),
1491
+ archive_session_ids=tuple(job.session_id for job in archive),
1492
+ empty_session_ids=tuple(sorted(sid for sid in empties if sid in scanned_ids)),
1493
+ generation_stale_session_ids=tuple(sorted(stale_generation)),
1494
+ )
1495
+
1496
+
1497
+ def materialize(
1498
+ *,
1499
+ source_root: Path,
1500
+ corpus_root: Path,
1501
+ converter: ConverterPort,
1502
+ materialized_at: str,
1503
+ harbor_version: str,
1504
+ converter_version: str,
1505
+ quiesce_seconds: int = 300,
1506
+ force: bool = False,
1507
+ now_ns: int | None = None,
1508
+ session_ids: Collection[str] | None = None,
1509
+ source_layout: SourceLayout = CLAUDE_CODE_LAYOUT,
1510
+ workers: int = 1,
1511
+ worker_setup: Callable[[], None] | None = None,
1512
+ artifact_producer: ArtifactProducer | None = None,
1513
+ expected_meta: Mapping[str, object] | None = None,
1514
+ converter_schema: int | None = None,
1515
+ session_sink: SessionSink | None = None,
1516
+ sink_batch_size: int = DEFAULT_SINK_BATCH_SIZE,
1517
+ convert_clock: Callable[[], float] = time.perf_counter,
1518
+ ) -> MaterializationReport:
1519
+ """Run one materialization pass; see the module docstring for the shape.
1520
+
1521
+ Parameters
1522
+ ----------
1523
+ source_root
1524
+ Raw transcript corpus (``<config>/projects``).
1525
+ corpus_root
1526
+ Materialized corpus root (CONTRACT.md layout).
1527
+ converter
1528
+ The conversion port; atif-cli injects the real adapter, tests inject
1529
+ :class:`~atif_corpus.infrastructure.fake_converter.FakeConverter`.
1530
+ artifact_producer
1531
+ Optional :class:`~atif_corpus.domain.ports.ArtifactProducer` run per
1532
+ session inside the staged dir, between the contract JSON artifacts and
1533
+ ``meta.json``; its extra files publish in the same directory swap and
1534
+ its returned keys land in ``meta.json``. ``None`` (the default) writes
1535
+ exactly the contract artifacts plus the source archive.
1536
+ materialized_at
1537
+ ISO-8601 UTC instant to stamp into every ``meta.json`` this pass, and
1538
+ the ``source_removed_at`` of every session marked this pass.
1539
+ harbor_version, converter_version
1540
+ Version pins for provenance; the caller owns them.
1541
+ converter_schema
1542
+ The converter's own output version, stamped into ``meta.json`` as
1543
+ ``converter_schema``. A session recording a different one (or none) is
1544
+ stale. When omitted, ``converter_version`` is compared instead, so a
1545
+ caller without a schema number still re-converts on an upgrade.
1546
+ expected_meta
1547
+ Further ``meta.json`` values a current session must carry. A session
1548
+ recording anything else for one of these keys,
1549
+ or lacking one, is stale.
1550
+ quiesce_seconds
1551
+ Source-silence threshold; contract default 300.
1552
+ force
1553
+ Re-materialize every quiescent session regardless of the watermark
1554
+ and generation, and re-convert every archived source-removed session.
1555
+ now_ns
1556
+ Epoch-ns "now" for the quiescence check; defaults to ``time.time_ns()``
1557
+ read right AFTER the scan. Pass explicitly in tests to pin the decision.
1558
+ source_layout
1559
+ Which agent's transcript arrangement to discover under
1560
+ ``source_root``, and the agent name stamped into every ``meta.json``
1561
+ this pass writes. Defaults to Claude Code's shape, which is what every
1562
+ caller predating Codex support meant.
1563
+ session_ids
1564
+ Optional session-id filter (contract-compatible extension for
1565
+ ``atif-sql materialize --sessions``): when given, only these
1566
+ sessions are PLANNED this pass. No special watermark accounting is
1567
+ needed — an unplanned session simply never succeeds, and the one
1568
+ retention rule in :func:`_advance_watermark` already keeps every
1569
+ entry of every session that did not succeed. Source-removal marking
1570
+ keys off the FULL scan, so an unfiltered session is never marked just
1571
+ because it wasn't planned.
1572
+ workers
1573
+ Processes for the convert+write stage. ``1`` (the default) is the
1574
+ inline reference path. Above ``1``, a spawn-context process pool of
1575
+ at most this many workers converts the planned sessions, each worker
1576
+ holding its own copy of ``converter`` (which must therefore be
1577
+ picklable) and writing through the same per-session staging dir and
1578
+ atomic swap. Artifacts are byte-identical to the inline path; the
1579
+ report's ``convert_seconds`` is the per-session sum either way.
1580
+ convert_clock
1581
+ The clock each session's conversion is timed with (``time.perf_counter``
1582
+ by default). Read in whichever process converts, so on a pool it must
1583
+ be picklable, which a module-level function is.
1584
+ worker_setup
1585
+ Optional picklable callable each pool worker runs once before it
1586
+ builds its converter — the composition root's hook for per-process
1587
+ setup such as installing the log sink the parent uses. Ignored on
1588
+ the inline path.
1589
+ session_sink
1590
+ Optional :class:`~atif_corpus.domain.ports.SessionSink` handed every
1591
+ session this pass published or marked source-removed, plus any an
1592
+ earlier pass left in ``sink_pending.json``, after the swaps and in this
1593
+ process. A sink failure is reported (``sink_error``,
1594
+ ``sink_pending_session_ids``), never raised.
1595
+ sink_batch_size
1596
+ Sessions per ``sync_sessions`` call.
1597
+
1598
+ Raises
1599
+ ------
1600
+ SuspiciousEmptyScanError
1601
+ The scan found zero sessions while the corpus holds materialized
1602
+ ones — almost always a wrong ``source_root``. Nothing is touched.
1603
+ CorpusAgentMismatchError
1604
+ The corpus holds another agent's sessions. Nothing is touched.
1605
+ ValueError
1606
+ ``workers`` is below ``1``, ``sink_batch_size`` is below ``1``, or
1607
+ ``expected_meta`` contradicts the stamped converter key.
1608
+ """
1609
+ if sink_batch_size < 1:
1610
+ msg = f"sink_batch_size must be >= 1, got {sink_batch_size}"
1611
+ raise ValueError(msg)
1612
+ pass_started = time.perf_counter()
1613
+ expected = expected_generation(
1614
+ converter_version, expected_meta, converter_schema=converter_schema
1615
+ )
1616
+
1617
+ layout = CorpusLayout(corpus_root=corpus_root)
1618
+ _sweep_staging(layout)
1619
+ ledger = _SinkLedger(layout, session_sink)
1620
+ previous_watermark = read_watermark(layout.watermark_path)
1621
+ previous_empty = read_empty_sessions(layout.empty_sessions_path)
1622
+ scan = scan_sources(source_root, source_layout)
1623
+ # "Now" comes AFTER the scan: a transcript written while the scan ran must
1624
+ # not look like it was written in the future.
1625
+ effective_now_ns = time.time_ns() if now_ns is None else now_ns
1626
+ # A dir that would not list discovered no sessions, so its ids come from
1627
+ # the watermark; folding them in makes every downstream unreadable check
1628
+ # (source-removal marking, retention, the report) read one set.
1629
+ scan = scan.with_unreadable(
1630
+ _sessions_under_unlistable_dirs(
1631
+ scan,
1632
+ previous_watermark,
1633
+ source_root=source_root,
1634
+ source_layout=source_layout,
1635
+ )
1636
+ )
1637
+ sessions = scan.sessions
1638
+
1639
+ existing_session_dirs = (
1640
+ sorted(p.name for p in layout.sessions_dir.iterdir() if p.is_dir())
1641
+ if layout.sessions_dir.is_dir()
1642
+ else []
1643
+ )
1644
+ # Agent check FIRST: it is the one condition under which every existing
1645
+ # session dir is sourceless by construction, so it has to run before the
1646
+ # emptiness tripwire and before the first write.
1647
+ corpus_agent = _corpus_agent(layout, existing_session_dirs)
1648
+ if corpus_agent is not None and corpus_agent != source_layout.agent.value:
1649
+ msg = (
1650
+ f"the corpus at {corpus_root} holds {corpus_agent} sessions but this "
1651
+ f"pass is materializing {source_layout.agent.value} from {source_root} "
1652
+ f"— refusing: one corpus holds one agent, and continuing would mark "
1653
+ f"all {len(existing_session_dirs)} of them source-removed"
1654
+ )
1655
+ raise CorpusAgentMismatchError(msg)
1656
+ # Empty-scan guard: an empty scan over a non-empty corpus smells like a
1657
+ # wrong source_root. Fail loud instead of flagging every session, UNLESS an
1658
+ # unlistable directory already explains the emptiness: that is a diagnosed
1659
+ # permission problem, and failing the pass every ten minutes over it adds
1660
+ # nothing the warning did not already say.
1661
+ if not sessions and existing_session_dirs and not scan.unlistable_dirs:
1662
+ msg = (
1663
+ f"scan of {source_root} found 0 sessions but the corpus at "
1664
+ f"{corpus_root} holds {len(existing_session_dirs)} materialized "
1665
+ f"session(s) — refusing to mark them source-removed; check source_root"
1666
+ )
1667
+ raise SuspiciousEmptyScanError(msg)
1668
+ if scan.unlistable_dirs:
1669
+ # Marking needs a complete picture of what EXISTS, and a dir it could
1670
+ # not open means it does not have one. Watermark resolution names the
1671
+ # sessions recorded there, never one materialized before the watermark
1672
+ # covered it, so marking anything this pass risks flagging a session
1673
+ # whose sources are merely behind a closed door.
1674
+ logger.warning(
1675
+ "materialize: not marking any session source-removed — {} source "
1676
+ "director(ies) could not be listed, so absence is not evidence of deletion",
1677
+ len(scan.unlistable_dirs),
1678
+ )
1679
+ removed_ids, retained = _retain_sourceless_sessions(
1680
+ layout,
1681
+ _sourceless_session_ids(
1682
+ layout,
1683
+ {s.session_id for s in sessions},
1684
+ scan.unreadable_session_ids,
1685
+ scan.rejected_session_ids,
1686
+ ),
1687
+ removed_at=materialized_at,
1688
+ mark=not scan.unlistable_dirs,
1689
+ before_mark=ledger.intend,
1690
+ )
1691
+
1692
+ planned_sessions = sessions
1693
+ wanted = set(session_ids) if session_ids is not None else None
1694
+ if wanted is not None:
1695
+ planned_sessions = tuple(s for s in sessions if s.session_id in wanted)
1696
+ plan: MaterializationPlan = build_plan(
1697
+ planned_sessions,
1698
+ watermark=previous_watermark,
1699
+ policy=QuiescencePolicy(quiesce_seconds=quiesce_seconds),
1700
+ now_ns=effective_now_ns,
1701
+ force=force,
1702
+ unmaterialized_session_ids=_unmaterialized_session_ids(
1703
+ layout, previous_watermark, planned_sessions, previous_empty
1704
+ ),
1705
+ generation_stale_session_ids=_generation_stale_session_ids(
1706
+ layout, planned_sessions, expected, previous_empty
1707
+ ),
1708
+ )
1709
+
1710
+ live_jobs = tuple(_live_job(session, source_root) for session in plan.to_materialize)
1711
+ archive_jobs = _archive_jobs(retained, expected, force=force, wanted=wanted)
1712
+ ledger.intend([job.session_id for job in (*live_jobs, *archive_jobs)])
1713
+ outcomes, workers_used = _attempt_sessions(
1714
+ layout,
1715
+ (*live_jobs, *archive_jobs),
1716
+ converter,
1717
+ workers=workers,
1718
+ worker_setup=worker_setup,
1719
+ materialized_at=materialized_at,
1720
+ harbor_version=harbor_version,
1721
+ converter_version=converter_version,
1722
+ converter_schema=converter_schema,
1723
+ agent=source_layout.agent.value,
1724
+ artifact_producer=artifact_producer,
1725
+ convert_clock=convert_clock,
1726
+ )
1727
+ live_outcomes = outcomes[: len(live_jobs)]
1728
+ archive_outcomes = outcomes[len(live_jobs) :]
1729
+
1730
+ succeeded: list[SessionSource] = []
1731
+ empty: list[str] = []
1732
+ failures: list[MaterializationFailure] = []
1733
+ archived_ok: list[str] = []
1734
+ convert_seconds = 0.0
1735
+ artifact_seconds = 0.0
1736
+ for session, outcome in zip(plan.to_materialize, live_outcomes, strict=True):
1737
+ if outcome.error is not None:
1738
+ logger.warning("materialize: session {} failed: {}", session.session_id, outcome.error)
1739
+ failures.append(
1740
+ MaterializationFailure(session_id=session.session_id, error=outcome.error)
1741
+ )
1742
+ elif outcome.empty:
1743
+ logger.info("materialize: session {} has nothing to convert", session.session_id)
1744
+ empty.append(session.session_id)
1745
+ succeeded.append(session)
1746
+ else:
1747
+ convert_seconds += outcome.convert_seconds
1748
+ artifact_seconds += outcome.artifact_seconds
1749
+ succeeded.append(session)
1750
+ for job, outcome in zip(archive_jobs, archive_outcomes, strict=True):
1751
+ if outcome.error is not None or outcome.empty:
1752
+ error = outcome.error or "EmptySourceError: the restored archive held nothing"
1753
+ logger.warning(
1754
+ "materialize: re-converting session {} from its archive failed: {}",
1755
+ job.session_id,
1756
+ error,
1757
+ )
1758
+ failures.append(MaterializationFailure(session_id=job.session_id, error=error))
1759
+ else:
1760
+ convert_seconds += outcome.convert_seconds
1761
+ artifact_seconds += outcome.artifact_seconds
1762
+ archived_ok.append(job.session_id)
1763
+
1764
+ # Published this pass, in plan order: live sessions that wrote artifacts,
1765
+ # then archive re-conversions, then sessions newly marked source-removed
1766
+ # (their meta.json changed). Empty sessions published nothing.
1767
+ empty_ids = set(empty)
1768
+ published = [session.session_id for session in succeeded if session.session_id not in empty_ids]
1769
+ sink_synced, sink_pending, sink_error, sink_seconds = ledger.sync(
1770
+ [*published, *archived_ok, *removed_ids],
1771
+ agent=source_layout.agent.value,
1772
+ batch_size=sink_batch_size,
1773
+ )
1774
+
1775
+ layout.corpus_root.mkdir(parents=True, exist_ok=True)
1776
+ write_json_atomic(
1777
+ layout.watermark_path,
1778
+ _advance_watermark(previous_watermark, scan, tuple(succeeded)),
1779
+ )
1780
+ next_empty = _next_empty_record(
1781
+ previous_empty,
1782
+ keep_ids={s.session_id for s in sessions} | scan.unreadable_session_ids,
1783
+ attempted_ids={s.session_id for s in plan.to_materialize},
1784
+ newly_empty=empty,
1785
+ expected=expected,
1786
+ )
1787
+ if next_empty or layout.empty_sessions_path.exists():
1788
+ write_json_atomic(layout.empty_sessions_path, next_empty, indent=2)
1789
+
1790
+ materialized = len(succeeded) - len(empty) + len(archived_ok)
1791
+ report = MaterializationReport(
1792
+ materialized_count=materialized,
1793
+ up_to_date_count=len(plan.up_to_date),
1794
+ skipped_live_count=len(plan.skipped_live),
1795
+ failures=tuple(failures),
1796
+ total_seconds=time.perf_counter() - pass_started,
1797
+ convert_seconds=convert_seconds,
1798
+ artifact_seconds=artifact_seconds,
1799
+ removed_session_ids=removed_ids,
1800
+ unreadable_session_ids=tuple(sorted(scan.unreadable)),
1801
+ rejected_session_ids=scan.rejected_session_ids,
1802
+ workers=workers_used,
1803
+ empty_session_ids=tuple(empty),
1804
+ retained_count=len(retained),
1805
+ archive_session_ids=tuple(archived_ok),
1806
+ sink_synced_count=sink_synced,
1807
+ sink_pending_session_ids=sink_pending,
1808
+ sink_error=sink_error,
1809
+ sink_seconds=sink_seconds,
1810
+ )
1811
+ logger.info(
1812
+ "materialize: {} written ({} from archive), {} current, {} live, {} failed, "
1813
+ "{} empty, {} newly source-removed ({} retained), {} unreadable, {} rejected in {:.2f}s",
1814
+ report.materialized_count,
1815
+ report.archive_count,
1816
+ report.up_to_date_count,
1817
+ report.skipped_live_count,
1818
+ report.failed_count,
1819
+ report.empty_count,
1820
+ report.sessions_removed,
1821
+ report.retained_count,
1822
+ report.unreadable_count,
1823
+ report.rejected_count,
1824
+ report.total_seconds,
1825
+ )
1826
+ return report