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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|