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
atif_cli/cron.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""The ``atif-sql cron`` subcommand group: install (print-only) + status.
|
|
4
|
+
|
|
5
|
+
Companion surface for ``scripts/atif-sql-refresh.sh``. Two commands:
|
|
6
|
+
|
|
7
|
+
* ``cron install`` prints the crontab block for the lanes and NEVER
|
|
8
|
+
writes the crontab (CONTRACT-V2 §Cron: "no silent crontab writes") — the
|
|
9
|
+
human pastes it after checking ``crontab -l``.
|
|
10
|
+
* ``cron status`` reports, per lane: whether the flock is currently held
|
|
11
|
+
(and by which pid), the last completed run parsed from the refresh log,
|
|
12
|
+
and the last skip. Plus the log tail.
|
|
13
|
+
|
|
14
|
+
Lean by construction: stdlib + cyclopts only, no atif_* imports beyond
|
|
15
|
+
:mod:`atif_cli.output` — this module loads eagerly with ``atif_cli.app``
|
|
16
|
+
(sub-apps must exist at registration time) and therefore sits on the fast
|
|
17
|
+
path pinned by the lean-import test.
|
|
18
|
+
|
|
19
|
+
The log/lock parsing is pure (:func:`parse_last_runs`, :func:`lock_state`
|
|
20
|
+
takes injected probe results) so the unit tests never need a live cron.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Annotated
|
|
29
|
+
|
|
30
|
+
import cyclopts
|
|
31
|
+
|
|
32
|
+
from atif_cli.errors import EXIT_CODES
|
|
33
|
+
from atif_cli.output import OutputFormat, emit_json, resolve_format
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Iterable
|
|
37
|
+
|
|
38
|
+
cron_app = cyclopts.App(
|
|
39
|
+
name="cron",
|
|
40
|
+
help="Inspect and (manually) install the atif-sql refresh cron lanes.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
#: The lanes and their crontab schedules — the single source the
|
|
44
|
+
#: ``install`` block is rendered from. Cadence rationale lives in the
|
|
45
|
+
#: refresh script's header (materialize is the cheap incremental lane; llm
|
|
46
|
+
#: is the one that spends; compact is the nightly lake maintenance).
|
|
47
|
+
LANES: tuple[tuple[str, str], ...] = (
|
|
48
|
+
("materialize", "*/10 * * * *"),
|
|
49
|
+
("llm", "20 10 * * *"),
|
|
50
|
+
("compact", "40 3 * * *"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Lanes cut from the refresh script, with the date. The script still accepts
|
|
54
|
+
#: each name and exits 0 with a one-line "lane removed" log, so a crontab
|
|
55
|
+
#: line nobody deleted yet stays quiet; ``cron install`` names them so the
|
|
56
|
+
#: human deletes the line.
|
|
57
|
+
REMOVED_LANES: tuple[tuple[str, str], ...] = (("structural", "2026-09-27"),)
|
|
58
|
+
|
|
59
|
+
#: ``date -Is`` prefix on every refresh-log line (fixed-offset ISO-8601).
|
|
60
|
+
_TS = r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})"
|
|
61
|
+
_COMPLETE_RE = re.compile(_TS + r" refresh complete \(mode=(?P<mode>[a-z]+), exit=(?P<exit>\d+)\)")
|
|
62
|
+
_SKIP_RE = re.compile(_TS + r" skip\[(?P<mode>[a-z]+)\]:")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class LaneRun:
|
|
67
|
+
"""One parsed lane event: the run's ISO timestamp plus its exit code."""
|
|
68
|
+
|
|
69
|
+
timestamp: str
|
|
70
|
+
exit_code: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class LaneStatus:
|
|
75
|
+
"""Everything ``cron status`` reports for one lane."""
|
|
76
|
+
|
|
77
|
+
lane: str
|
|
78
|
+
schedule: str
|
|
79
|
+
lock_held: bool
|
|
80
|
+
holder_pid: int | None
|
|
81
|
+
last_complete: LaneRun | None
|
|
82
|
+
last_skip: str | None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def crontab_block(script: Path, log: Path) -> str:
|
|
86
|
+
"""Render the crontab block for ``script`` logging to ``log``.
|
|
87
|
+
|
|
88
|
+
Pure. One line per lane, schedules from :data:`LANES`, stdout+stderr
|
|
89
|
+
appended to the cron-side log (the script keeps its own structured log
|
|
90
|
+
under ``scripts/.run/`` regardless).
|
|
91
|
+
"""
|
|
92
|
+
width = max(len(schedule) for _, schedule in LANES)
|
|
93
|
+
lines = [f"{schedule:<{width}} {script} {lane} >> {log} 2>&1" for lane, schedule in LANES]
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_last_runs(lines: Iterable[str]) -> dict[str, dict[str, LaneRun | str]]:
|
|
98
|
+
"""Extract per-lane last-completion and last-skip events from log lines.
|
|
99
|
+
|
|
100
|
+
Pure. Later lines win (the log is append-only, so iteration order IS
|
|
101
|
+
chronological order). Returns ``{lane: {"complete": LaneRun, "skip": ts}}``
|
|
102
|
+
with keys present only when the event was seen.
|
|
103
|
+
"""
|
|
104
|
+
out: dict[str, dict[str, LaneRun | str]] = {}
|
|
105
|
+
for line in lines:
|
|
106
|
+
if match := _COMPLETE_RE.match(line):
|
|
107
|
+
out.setdefault(match["mode"], {})["complete"] = LaneRun(
|
|
108
|
+
timestamp=match["ts"], exit_code=int(match["exit"])
|
|
109
|
+
)
|
|
110
|
+
elif match := _SKIP_RE.match(line):
|
|
111
|
+
out.setdefault(match["mode"], {})["skip"] = match["ts"]
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def lock_state(*, probe_acquired: bool, pidfile_text: str | None) -> tuple[bool, int | None]:
|
|
116
|
+
"""Classify one lane's lock: ``(held, holder_pid)``.
|
|
117
|
+
|
|
118
|
+
Pure — the caller injects the nonblocking-flock probe result and the
|
|
119
|
+
pidfile's text. ``probe_acquired=True`` means WE got the lock, so no run
|
|
120
|
+
holds it. The pid is reported only for a held lock; a stale pidfile next
|
|
121
|
+
to a free lock is normal (the last run's pid, not a holder).
|
|
122
|
+
"""
|
|
123
|
+
if probe_acquired:
|
|
124
|
+
return (False, None)
|
|
125
|
+
pid: int | None = None
|
|
126
|
+
if pidfile_text is not None:
|
|
127
|
+
stripped = pidfile_text.strip()
|
|
128
|
+
if stripped.isdigit():
|
|
129
|
+
pid = int(stripped)
|
|
130
|
+
return (True, pid)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _probe_lock(lock_path: Path) -> bool:
|
|
134
|
+
"""Try to acquire ``lock_path`` nonblocking; True = acquired (lane idle).
|
|
135
|
+
|
|
136
|
+
The kernel drops the flock when the fd closes, so a successful probe
|
|
137
|
+
perturbs nothing. An absent lock file means no run ever started — also
|
|
138
|
+
"acquired" for classification purposes.
|
|
139
|
+
"""
|
|
140
|
+
import fcntl
|
|
141
|
+
|
|
142
|
+
if not lock_path.exists():
|
|
143
|
+
return True
|
|
144
|
+
with lock_path.open("w") as handle:
|
|
145
|
+
try:
|
|
146
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
147
|
+
except OSError:
|
|
148
|
+
return False
|
|
149
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _scripts_dir() -> Path | None:
|
|
154
|
+
"""Locate ``scripts/atif-sql-refresh.sh`` by walking up from this file.
|
|
155
|
+
|
|
156
|
+
Works for the editable install this workspace runs (site-packages paths
|
|
157
|
+
resolve back into ``packages/atif-cli/src``). Returns ``None`` when the
|
|
158
|
+
tree isn't reachable — e.g. a future ``uv tool install`` — in which case
|
|
159
|
+
the commands require the explicit ``--script`` flag.
|
|
160
|
+
"""
|
|
161
|
+
for parent in Path(__file__).resolve().parents:
|
|
162
|
+
candidate = parent / "scripts" / "atif-sql-refresh.sh"
|
|
163
|
+
if candidate.is_file():
|
|
164
|
+
return candidate.parent
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_script(script: Path | None) -> Path:
|
|
169
|
+
"""Resolve the refresh-script path from the flag or by discovery."""
|
|
170
|
+
if script is not None:
|
|
171
|
+
return script
|
|
172
|
+
scripts_dir = _scripts_dir()
|
|
173
|
+
if scripts_dir is None:
|
|
174
|
+
import sys
|
|
175
|
+
|
|
176
|
+
print(
|
|
177
|
+
"error: cannot locate scripts/atif-sql-refresh.sh from this install; "
|
|
178
|
+
"pass --script explicitly",
|
|
179
|
+
file=sys.stderr,
|
|
180
|
+
)
|
|
181
|
+
raise SystemExit(EXIT_CODES["invalid_input"])
|
|
182
|
+
return scripts_dir / "atif-sql-refresh.sh"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cron_app.command
|
|
186
|
+
def install(*, script: Path | None = None) -> None:
|
|
187
|
+
"""Print the crontab block for the refresh lanes — never write it.
|
|
188
|
+
|
|
189
|
+
CONTRACT-V2 §Cron: no silent crontab writes. Check ``crontab -l`` for an
|
|
190
|
+
existing block, then paste this one via ``crontab -e``.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
script
|
|
195
|
+
Path to ``atif-sql-refresh.sh``; default: discovered from the repo
|
|
196
|
+
this CLI is (editably) installed from.
|
|
197
|
+
"""
|
|
198
|
+
resolved = _resolve_script(script)
|
|
199
|
+
log = resolved.parent / ".run" / "atif-sql-refresh.cron.log"
|
|
200
|
+
print("# atif-sql refresh lanes — paste into `crontab -e` (check `crontab -l` first)")
|
|
201
|
+
for lane, removed_on in REMOVED_LANES:
|
|
202
|
+
print(f"# the {lane} lane was removed {removed_on}: delete any `{lane}` line")
|
|
203
|
+
print(crontab_block(resolved, log))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cron_app.command
|
|
207
|
+
def status(
|
|
208
|
+
*,
|
|
209
|
+
script: Path | None = None,
|
|
210
|
+
tail: int = 10,
|
|
211
|
+
fmt: Annotated[OutputFormat, cyclopts.Parameter(name="--format")] = OutputFormat.AUTO,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Report each lane's lock holder + last run/skip parsed from the log.
|
|
214
|
+
|
|
215
|
+
Read-only: the lock probe acquires-and-releases nonblocking, perturbing
|
|
216
|
+
no running lane.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
script
|
|
221
|
+
Path to ``atif-sql-refresh.sh`` (its ``.run/`` sibling holds the
|
|
222
|
+
locks and log); default: discovered from the repo.
|
|
223
|
+
tail
|
|
224
|
+
How many trailing log lines to include (0 disables).
|
|
225
|
+
fmt
|
|
226
|
+
``auto`` = human lines on a TTY, JSON on a pipe.
|
|
227
|
+
"""
|
|
228
|
+
resolved = _resolve_script(script)
|
|
229
|
+
run_dir = resolved.parent / ".run"
|
|
230
|
+
log_path = run_dir / "atif-sql-refresh.log"
|
|
231
|
+
log_lines = log_path.read_text().splitlines() if log_path.is_file() else []
|
|
232
|
+
runs = parse_last_runs(log_lines)
|
|
233
|
+
|
|
234
|
+
lanes: list[LaneStatus] = []
|
|
235
|
+
for lane, schedule in LANES:
|
|
236
|
+
pidfile = run_dir / f"atif-sql-refresh-{lane}.pid"
|
|
237
|
+
held, pid = lock_state(
|
|
238
|
+
probe_acquired=_probe_lock(run_dir / f"atif-sql-refresh-{lane}.lock"),
|
|
239
|
+
pidfile_text=pidfile.read_text() if pidfile.is_file() else None,
|
|
240
|
+
)
|
|
241
|
+
lane_runs = runs.get(lane, {})
|
|
242
|
+
complete = lane_runs.get("complete")
|
|
243
|
+
skip = lane_runs.get("skip")
|
|
244
|
+
lanes.append(
|
|
245
|
+
LaneStatus(
|
|
246
|
+
lane=lane,
|
|
247
|
+
schedule=schedule,
|
|
248
|
+
lock_held=held,
|
|
249
|
+
holder_pid=pid,
|
|
250
|
+
last_complete=complete if isinstance(complete, LaneRun) else None,
|
|
251
|
+
last_skip=skip if isinstance(skip, str) else None,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
tail_lines = log_lines[-tail:] if tail > 0 else []
|
|
256
|
+
if resolve_format(fmt) is OutputFormat.TABLE:
|
|
257
|
+
for lane_status in lanes:
|
|
258
|
+
lock = (
|
|
259
|
+
f"RUNNING (pid {lane_status.holder_pid or '?'})"
|
|
260
|
+
if lane_status.lock_held
|
|
261
|
+
else "idle"
|
|
262
|
+
)
|
|
263
|
+
last = (
|
|
264
|
+
f"{lane_status.last_complete.timestamp} exit={lane_status.last_complete.exit_code}"
|
|
265
|
+
if lane_status.last_complete
|
|
266
|
+
else "never"
|
|
267
|
+
)
|
|
268
|
+
print(f"{lane_status.lane:<12} [{lane_status.schedule:<12}] {lock:<20} last: {last}")
|
|
269
|
+
if lane_status.last_skip:
|
|
270
|
+
print(f"{'':<12} last skip: {lane_status.last_skip}")
|
|
271
|
+
if tail_lines:
|
|
272
|
+
print(f"\n--- last {len(tail_lines)} log lines ({log_path}) ---")
|
|
273
|
+
for line in tail_lines:
|
|
274
|
+
print(line)
|
|
275
|
+
return
|
|
276
|
+
emit_json(
|
|
277
|
+
{
|
|
278
|
+
"log": str(log_path),
|
|
279
|
+
"lanes": [
|
|
280
|
+
{
|
|
281
|
+
"lane": s.lane,
|
|
282
|
+
"schedule": s.schedule,
|
|
283
|
+
"lock_held": s.lock_held,
|
|
284
|
+
"holder_pid": s.holder_pid,
|
|
285
|
+
"last_complete": (
|
|
286
|
+
{"timestamp": s.last_complete.timestamp, "exit": s.last_complete.exit_code}
|
|
287
|
+
if s.last_complete
|
|
288
|
+
else None
|
|
289
|
+
),
|
|
290
|
+
"last_skip": s.last_skip,
|
|
291
|
+
}
|
|
292
|
+
for s in lanes
|
|
293
|
+
],
|
|
294
|
+
"tail": tail_lines,
|
|
295
|
+
},
|
|
296
|
+
fmt,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__ = [
|
|
301
|
+
"LANES",
|
|
302
|
+
"REMOVED_LANES",
|
|
303
|
+
"LaneRun",
|
|
304
|
+
"LaneStatus",
|
|
305
|
+
"cron_app",
|
|
306
|
+
"crontab_block",
|
|
307
|
+
"install",
|
|
308
|
+
"lock_state",
|
|
309
|
+
"parse_last_runs",
|
|
310
|
+
"status",
|
|
311
|
+
]
|
atif_cli/duck_errors.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""DuckDB error classification for the ``query`` and ``search`` commands.
|
|
4
|
+
|
|
5
|
+
The one piece of the exit-code taxonomy that needs ``import duckdb`` — kept
|
|
6
|
+
out of :mod:`atif_cli.errors` (pure) and :mod:`atif_cli.output` (rendering)
|
|
7
|
+
so the lean import path never touches the driver. Import this module ONLY
|
|
8
|
+
inside command bodies that already hold a DuckDB connection.
|
|
9
|
+
|
|
10
|
+
The mapping: ``ParserException`` -> parse_error (64), ``CatalogException`` ->
|
|
11
|
+
catalog_error (65), everything else under ``duckdb.Error`` -> runtime_error
|
|
12
|
+
(70). The distinction agents act on is 64 vs 65 — a 64 means rewrite the SQL,
|
|
13
|
+
a 65 means the object does not exist, so run ``atif-sql schema`` first.
|
|
14
|
+
|
|
15
|
+
:data:`REGISTRATION_ERRORS` widens the caught set on the registration path.
|
|
16
|
+
``EmbeddingProviderMismatch`` derives from ``Exception``, not
|
|
17
|
+
``duckdb.Error``, so a bare ``except duckdb.Error`` lets it escape as an
|
|
18
|
+
unhandled traceback with exit 1 — a code outside :data:`EXIT_CODES`
|
|
19
|
+
entirely.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import duckdb
|
|
25
|
+
|
|
26
|
+
from atif_cli.errors import EXIT_CODES, ClassifiedError
|
|
27
|
+
from atif_duck.domain.embedding_guard import EmbeddingProviderMismatch
|
|
28
|
+
|
|
29
|
+
#: What ``register`` can raise: DuckDB DDL failures plus the embedding-store
|
|
30
|
+
#: identity guard. Catch this tuple, then hand the exception to
|
|
31
|
+
#: :func:`classify_registration_error`.
|
|
32
|
+
REGISTRATION_ERRORS: tuple[type[Exception], ...] = (duckdb.Error, EmbeddingProviderMismatch)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def classify_duckdb_error(exc: duckdb.Error) -> ClassifiedError:
|
|
36
|
+
"""Classify a ``duckdb.Error`` into a stable kind + exit code."""
|
|
37
|
+
message = str(exc)
|
|
38
|
+
if isinstance(exc, duckdb.ParserException):
|
|
39
|
+
return ClassifiedError(
|
|
40
|
+
kind="parse_error",
|
|
41
|
+
exit_code=EXIT_CODES["parse_error"],
|
|
42
|
+
message=message,
|
|
43
|
+
hint="check SQL syntax; try `atif-sql schema --format json` for view/macro names",
|
|
44
|
+
)
|
|
45
|
+
if isinstance(exc, duckdb.CatalogException):
|
|
46
|
+
return ClassifiedError(
|
|
47
|
+
kind="catalog_error",
|
|
48
|
+
exit_code=EXIT_CODES["catalog_error"],
|
|
49
|
+
message=message,
|
|
50
|
+
hint="unknown view or column; run `atif-sql schema --format json` for the catalog",
|
|
51
|
+
)
|
|
52
|
+
if isinstance(exc, duckdb.IOException):
|
|
53
|
+
return ClassifiedError(
|
|
54
|
+
kind="runtime_error",
|
|
55
|
+
exit_code=EXIT_CODES["runtime_error"],
|
|
56
|
+
message=message,
|
|
57
|
+
hint="is the corpus materialized? run `atif-sql materialize` then `atif-sql status`",
|
|
58
|
+
)
|
|
59
|
+
return ClassifiedError(
|
|
60
|
+
kind="runtime_error",
|
|
61
|
+
exit_code=EXIT_CODES["runtime_error"],
|
|
62
|
+
message=message,
|
|
63
|
+
hint=None,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def classify_registration_error(exc: Exception) -> ClassifiedError:
|
|
68
|
+
"""Classify anything :data:`REGISTRATION_ERRORS` catches.
|
|
69
|
+
|
|
70
|
+
``EmbeddingProviderMismatch`` maps to ``embedding_mismatch`` at exit 65
|
|
71
|
+
(the catalog-class code: the store's stamped identity does not match the
|
|
72
|
+
active embedder, so the requested object cannot be bound). Everything
|
|
73
|
+
else falls through to :func:`classify_duckdb_error`.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(exc, EmbeddingProviderMismatch):
|
|
76
|
+
return ClassifiedError(
|
|
77
|
+
kind="embedding_mismatch",
|
|
78
|
+
exit_code=EXIT_CODES["embedding_mismatch"],
|
|
79
|
+
message=str(exc),
|
|
80
|
+
hint="delete the Lance store and re-run `atif-sql embed --all --no-dry-run`, "
|
|
81
|
+
"or point ATIF_SQL_EMBED_MODEL_ID at the model that wrote it",
|
|
82
|
+
)
|
|
83
|
+
if isinstance(exc, duckdb.Error):
|
|
84
|
+
return classify_duckdb_error(exc)
|
|
85
|
+
return ClassifiedError(
|
|
86
|
+
kind="runtime_error",
|
|
87
|
+
exit_code=EXIT_CODES["runtime_error"],
|
|
88
|
+
message=str(exc),
|
|
89
|
+
hint=None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"REGISTRATION_ERRORS",
|
|
95
|
+
"classify_duckdb_error",
|
|
96
|
+
"classify_registration_error",
|
|
97
|
+
]
|
atif_cli/embed_lake.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""atif-embed's ``LakeStepsPort``, implemented over atif-duck's lake.
|
|
4
|
+
|
|
5
|
+
atif-embed and atif-duck may not import each other, so the composition root
|
|
6
|
+
joins them: :class:`DuckLakeSteps` opens the corpus's steps through
|
|
7
|
+
:func:`atif_duck.infrastructure.lake_steps.open_lake_steps` and hands them to
|
|
8
|
+
atif-embed in the port's own types. Imported only inside command bodies, so
|
|
9
|
+
the fast paths never load DuckDB.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING, Self
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
from atif_embed.domain.ports import LakePosition
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
|
|
26
|
+
from atif_duck.infrastructure.lake import LakeLayout
|
|
27
|
+
from atif_duck.infrastructure.lake_steps import LakeSteps
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class _Snapshot:
|
|
32
|
+
"""One open :class:`~atif_duck.infrastructure.lake_steps.LakeSteps`, in the port's types."""
|
|
33
|
+
|
|
34
|
+
steps: LakeSteps
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def position(self) -> LakePosition:
|
|
38
|
+
return LakePosition(lineage=self.steps.lineage, snapshot_id=self.steps.snapshot_id)
|
|
39
|
+
|
|
40
|
+
def can_read_changes_since(self, since: LakePosition) -> bool:
|
|
41
|
+
return since.lineage == self.steps.lineage and self.steps.can_read_changes_since(
|
|
42
|
+
since.snapshot_id
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def step_texts(
|
|
46
|
+
self, *, min_chars: int, changed_since: LakePosition | None
|
|
47
|
+
) -> Iterator[tuple[str, str]]:
|
|
48
|
+
since = None if changed_since is None else changed_since.snapshot_id
|
|
49
|
+
return self.steps.step_texts(min_chars=min_chars, since_snapshot=since)
|
|
50
|
+
|
|
51
|
+
def step_keys(self) -> frozenset[str]:
|
|
52
|
+
return self.steps.step_keys()
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
self.steps.close()
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> Self:
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def __exit__(
|
|
61
|
+
self,
|
|
62
|
+
exc_type: type[BaseException] | None,
|
|
63
|
+
exc: BaseException | None,
|
|
64
|
+
tb: TracebackType | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class DuckLakeSteps:
|
|
71
|
+
"""``LakeStepsPort`` over the lake at ``layout``."""
|
|
72
|
+
|
|
73
|
+
layout: LakeLayout
|
|
74
|
+
memory_limit_bytes: int | None = None
|
|
75
|
+
|
|
76
|
+
def open_steps(self, corpus_root: Path) -> _Snapshot | None:
|
|
77
|
+
"""The corpus's steps at the published snapshot, or ``None`` (reason logged) to fall back."""
|
|
78
|
+
from atif_duck.infrastructure.lake_steps import LakeSteps, open_lake_steps
|
|
79
|
+
|
|
80
|
+
opened = open_lake_steps(
|
|
81
|
+
self.layout, corpus_root, memory_limit_bytes=self.memory_limit_bytes
|
|
82
|
+
)
|
|
83
|
+
if not isinstance(opened, LakeSteps):
|
|
84
|
+
logger.info("embed: {}; reading the per-session artifacts instead", opened.reason)
|
|
85
|
+
return None
|
|
86
|
+
return _Snapshot(opened)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def lake_steps_port() -> DuckLakeSteps:
|
|
90
|
+
"""The port over the lake ``ATIF_SQL_LAKE_ROOT`` names, capped like ``query``."""
|
|
91
|
+
from atif_cli.app import query_memory_limit_bytes
|
|
92
|
+
from atif_duck.infrastructure.lake import LakeLayout
|
|
93
|
+
from atif_duck.infrastructure.lake_settings import LakeSettings
|
|
94
|
+
|
|
95
|
+
return DuckLakeSteps(
|
|
96
|
+
layout=LakeLayout(LakeSettings().lake_root),
|
|
97
|
+
memory_limit_bytes=query_memory_limit_bytes(),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["DuckLakeSteps", "lake_steps_port"]
|
atif_cli/errors.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""CLI error taxonomy: stable exit codes + the classified-error shape.
|
|
4
|
+
|
|
5
|
+
:data:`EXIT_CODES` is the workspace's ONE exit-code table. The numbers follow
|
|
6
|
+
the BSD ``sysexits.h`` range so a shell reading them gets the conventional
|
|
7
|
+
meaning: 64 for malformed input or SQL, 65 for a catalog miss (unknown
|
|
8
|
+
view/macro/column), 70 for a runtime error. No other package owns a mapping —
|
|
9
|
+
atif-converter raises typed :class:`~atif_converter.domain.errors.DomainError`
|
|
10
|
+
subclasses and deliberately carries no codes, so ``convert`` translates them
|
|
11
|
+
here and every command exits from the same dict. A usage error cyclopts
|
|
12
|
+
refuses exits ``invalid_input``: ``atif_cli.app.main`` maps cyclopts 5's own
|
|
13
|
+
exit 2, which this table gives to ``empty_session``.
|
|
14
|
+
|
|
15
|
+
A number in this dict is a WIRE CONTRACT. An agent branches on it, so
|
|
16
|
+
reassigning an existing key to a different number is a breaking change even
|
|
17
|
+
though nothing in this repo fails.
|
|
18
|
+
|
|
19
|
+
Pure module: no duckdb, no atif_* imports — the concrete ``duckdb.Error``
|
|
20
|
+
classifier lives in :mod:`atif_cli.duck_errors` so this module stays on the
|
|
21
|
+
lean import path (pinned by the fresh-interpreter lean-import test).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
#: Exit codes agents can rely on. Add keys freely; never renumber one.
|
|
30
|
+
EXIT_CODES: dict[str, int] = {
|
|
31
|
+
"ok": 0,
|
|
32
|
+
"empty_session": 2, # convert: session parsed but produced no trajectory
|
|
33
|
+
"no_embeddings": 2, # search: store empty — run `atif-sql embed --all --no-dry-run`
|
|
34
|
+
"invalid_input": 64, # malformed user-supplied flags / paths
|
|
35
|
+
"parse_error": 64, # malformed SQL
|
|
36
|
+
"catalog_error": 65, # unknown view/macro/column
|
|
37
|
+
"validation_error": 65, # convert: trajectory failed TrajectoryValidator
|
|
38
|
+
"embedding_mismatch": 65, # query/search: Lance store written by another provider
|
|
39
|
+
"runtime_error": 70, # everything else duckdb (or the adapter) raises
|
|
40
|
+
"sandbox_refused": 70, # query: a statement kind the sandbox never runs (COPY, EXPORT,
|
|
41
|
+
# ATTACH, INSTALL, LOAD, PREPARE, ...). Same number as runtime_error, which is what
|
|
42
|
+
# every other sandbox refusal exits with; the distinct kind names the reason.
|
|
43
|
+
"root_refused": 77, # query/search/analyze: running as uid 0 without ATIF_SQL_ALLOW_ROOT=1.
|
|
44
|
+
# EX_NOPERM by convention: the process has too MUCH permission for the file modes
|
|
45
|
+
# that protect the corpus to mean anything.
|
|
46
|
+
"extension_missing": 78, # search: the Lance store exists but the lance DuckDB extension
|
|
47
|
+
# is not installed; `atif-sql embed --install-extension` is the operator action, so the
|
|
48
|
+
# same EX_CONFIG contract as terminal_state.
|
|
49
|
+
"terminal_state": 78, # embed: store/config state requires OPERATOR action (EX_CONFIG);
|
|
50
|
+
# retrying without intervention cannot succeed — unattended lanes suppress
|
|
51
|
+
# retries on this code instead of burning identical ticks
|
|
52
|
+
"suspicious_scan": 78, # materialize: the source scan found 0 sessions over a
|
|
53
|
+
# non-empty corpus, so marking them source-removed was refused. Same EX_CONFIG contract as
|
|
54
|
+
# terminal_state — a wrong source_root needs an operator, and a retry over the
|
|
55
|
+
# same wrong root cannot succeed — but a distinct `kind` string, because the
|
|
56
|
+
# remedy is a path, not a store. A separate code matters most for what it is
|
|
57
|
+
# NOT: exit 1 here is an uncaught traceback, indistinguishable from a crash.
|
|
58
|
+
"lake_mismatch": 65, # lake verify: a session's lake rows differ from its artifacts
|
|
59
|
+
# (EX_DATAERR). `atif-sql lake rebuild` reloads every corpus from its artifacts.
|
|
60
|
+
"lake_unavailable": 78, # lake verify/compact: no lake, a stale one, or no ducklake
|
|
61
|
+
# extension; an operator runs `atif-sql lake rebuild` (EX_CONFIG, like terminal_state).
|
|
62
|
+
"harbor_missing": 127, # RETIRED, kept so this table never renumbers. It named
|
|
63
|
+
# the loss of the private harbor method the converter used to be built on; the
|
|
64
|
+
# conversion is ours now (atif_converter.domain.*_conversion), so no code path
|
|
65
|
+
# raises it. EX_NOTFOUND by convention: a surface, not a datum.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ClassifiedError:
|
|
71
|
+
"""The structured shape of a CLI error after classification."""
|
|
72
|
+
|
|
73
|
+
kind: str # "invalid_input" | "parse_error" | "catalog_error" | "runtime_error"
|
|
74
|
+
exit_code: int
|
|
75
|
+
message: str
|
|
76
|
+
hint: str | None = None
|
|
77
|
+
|
|
78
|
+
def to_payload(self) -> dict[str, Any]:
|
|
79
|
+
"""The JSON error envelope emitted on non-TTY stderr."""
|
|
80
|
+
return {
|
|
81
|
+
"error": {
|
|
82
|
+
"kind": self.kind,
|
|
83
|
+
"message": self.message,
|
|
84
|
+
"hint": self.hint,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["EXIT_CODES", "ClassifiedError"]
|