From 27fb3af100902ea10f0066fda5d96b4f9ddf1d7f Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:08:03 +0530 Subject: [PATCH 01/11] fix: reject malformed cache imports and roll back interruptions --- docs/guides/cache-management.md | 5 +++ src/steadlith/store/cache.py | 59 ++++++++++++++++++++++++--------- tests/test_cache.py | 50 ++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/docs/guides/cache-management.md b/docs/guides/cache-management.md index c826486..3ff32d1 100644 --- a/docs/guides/cache-management.md +++ b/docs/guides/cache-management.md @@ -71,6 +71,11 @@ Import validates: - duplicate-key consistency; - conflicts with existing entries. +Identity fields must be non-empty strings, vectors must be arrays of finite numbers, +and token counts must be non-negative integers. Malformed fields are rejected instead +of being converted. Validation failure leaves the cache unchanged and identifies +the invalid line. + An identical existing entry is retained and touched. A different vector under an existing key fails instead of overwriting trusted state. ## Backup strategy diff --git a/src/steadlith/store/cache.py b/src/steadlith/store/cache.py index 6ae72da..1031050 100644 --- a/src/steadlith/store/cache.py +++ b/src/steadlith/store/cache.py @@ -212,7 +212,7 @@ def transaction(self) -> Iterator[sqlite3.Connection]: except sqlite3.Error as exc: connection.rollback() raise BackendError(f"Embedding cache transaction failed: {exc}") from exc - except Exception: + except BaseException: connection.rollback() raise @@ -328,8 +328,11 @@ def put_many( seen: dict[tuple[str, str, str], tuple[bytes, int, int]] = {} timestamp = _now() for chunk_hash, model_id, params_hash, vector, token_count in records: - if not chunk_hash or not model_id or not params_hash: - raise BackendError("Cache identity fields cannot be empty") + if any( + not isinstance(value, str) or not value + for value in (chunk_hash, model_id, params_hash) + ): + raise BackendError("Cache identity fields must be non-empty strings") if type(token_count) is not int or token_count < 0: raise BackendError("Cached token count must be a non-negative integer") try: @@ -520,29 +523,55 @@ def import_jsonl(self, source: str | Path, *, trusted: bool = False) -> int: raise BackendError( f"Cache import exceeds the {MAX_IMPORT_BYTES:,}-byte safety limit" ) - with source_path.open("r", encoding="utf-8") as handle: - for number, line in enumerate(handle, start=1): - if not line.strip(): - continue - if len(line.encode("utf-8")) > MAX_IMPORT_LINE_BYTES: + with source_path.open("rb") as handle: + total_bytes = 0 + number = 0 + while line := handle.readline(MAX_IMPORT_LINE_BYTES + 1): + number += 1 + total_bytes += len(line) + if total_bytes > MAX_IMPORT_BYTES: + raise BackendError( + f"Cache import exceeds the {MAX_IMPORT_BYTES:,}-byte safety limit" + ) + if len(line) > MAX_IMPORT_LINE_BYTES: raise BackendError(f"Cache export line {number} exceeds the safety limit") + decoded_line = line.decode("utf-8") + if not decoded_line.strip(): + continue try: - item: Mapping[str, Any] = json.loads(line) - vector = tuple(float(value) for value in item["vector"]) + item = json.loads(decoded_line) + if not isinstance(item, Mapping): + raise ValueError("each cache record must be an object") + identities = tuple( + item[name] for name in ("chunk_hash", "model_id", "params_hash") + ) + if any(not isinstance(value, str) or not value for value in identities): + raise ValueError("identity fields must be non-empty strings") + raw_vector = item["vector"] + if not isinstance(raw_vector, list) or any( + type(value) not in (int, float) for value in raw_vector + ): + raise ValueError("vector must be an array of numbers") + vector = tuple(float(value) for value in raw_vector) if not 0 < len(vector) <= MAX_VECTOR_DIMENSIONS: raise ValueError( f"vector dimensions must be between 1 and {MAX_VECTOR_DIMENSIONS}" ) + if any(not math.isfinite(value) for value in vector): + raise ValueError("vector values must be finite") + token_count = item.get("token_count", 0) + if type(token_count) is not int or not 0 <= token_count <= 2**63 - 1: + raise ValueError("token_count must be a non-negative SQLite integer") rows.append( ( - str(item["chunk_hash"]), - str(item["model_id"]), - str(item["params_hash"]), + identities[0], + identities[1], + identities[2], vector, - int(item.get("token_count", 0)), + token_count, ) ) - except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + except (KeyError, TypeError, ValueError, OverflowError) as exc: raise BackendError( f"Invalid cache export at {source_path}:{number}: {exc}" ) from exc diff --git a/tests/test_cache.py b/tests/test_cache.py index d6c2ac9..21cee72 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import sqlite3 from pathlib import Path @@ -179,3 +180,52 @@ def test_cache_import_rejects_invalid_encoding_and_live_database(tmp_path: Path) cache.import_jsonl(invalid, trusted=True) with pytest.raises(BackendError, match="live cache"): cache.import_jsonl(path, trusted=True) + + +@pytest.mark.parametrize( + "invalid", + [ + {"chunk_hash": None}, + {"model_id": 3}, + {"params_hash": []}, + {"token_count": 1.5}, + {"token_count": True}, + {"token_count": float("inf")}, + {"vector": "12"}, + {"vector": {"1": 0}}, + {"vector": [True]}, + {"vector": ["1"]}, + ], +) +def test_cache_import_rejects_malformed_fields_without_partial_writes( + tmp_path: Path, invalid: dict[str, object] +) -> None: + valid = { + "chunk_hash": "new", + "model_id": "model", + "params_hash": "params", + "vector": [1.0, 0.0], + "token_count": 2, + } + source = tmp_path / "import.jsonl" + source.write_text( + json.dumps(valid) + "\n" + json.dumps({**valid, **invalid}) + "\n", encoding="utf-8" + ) + with Cache(tmp_path / "cache.sqlite3") as cache: + cache.put("existing", "model", "params", (0.0, 1.0), token_count=2) + with pytest.raises(BackendError, match=r"import.jsonl:2"): + cache.import_jsonl(source, trusted=True) + assert cache.stats().entries == 1 + assert not cache.contains("new", "model", "params") + assert cache.get("existing", "model", "params") == (0.0, 1.0) + + +def test_interrupted_cache_transaction_rolls_back_and_can_retry(tmp_path: Path) -> None: + with Cache(tmp_path / "cache.sqlite3") as cache: + cache.put("existing", "model", "params", (1.0,)) + with pytest.raises(KeyboardInterrupt), cache.transaction() as connection: + connection.execute("DELETE FROM embeddings") + raise KeyboardInterrupt + assert cache.contains("existing", "model", "params") + cache.put("new", "model", "params", (0.0,)) + assert cache.stats().entries == 2 From 166709481cfff8b99d200bb465f4594501ac6723 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:11:42 +0530 Subject: [PATCH 02/11] fix: reject overlapping project state paths --- CHANGELOG.md | 5 ++++ docs/getting-started/configuration.md | 2 +- src/steadlith/config.py | 24 +++++++++++++++++ tests/test_cli.py | 30 ++++++++++++++++++++++ tests/test_config.py | 37 +++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 268b688..46c10ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to Steadlith are documented here. The format follows ## [Unreleased] +### Fixed + +- Reject overlapping configuration, cache, index, SQLite sidecar, manifest, and migration paths before writes can corrupt project state. +- Report invalid UTF-8 configuration files as actionable configuration errors, including JSON CLI errors. + ## [1.0.0] - 2026-08-22 ### Added diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index a4a04d9..e9111a0 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -87,7 +87,7 @@ Provider and model settings participate in embedding identity. Dimensions are va | --- | --- | --- | | `cache` | `.steadlith/cache.sqlite3` | SQLite content-addressed embedding cache. | -The cache path must stay below the configuration directory and must differ from the index database path. +The cache path must stay below the configuration directory and must differ from the index database path. State paths cannot overlap the configuration, SQLite sidecars (`-wal`, `-shm`, `-journal`), index manifest (`.manifest.json`), migration journal, or migration receipt directory (`.migrations`). Validation rejects these collisions before writing state. ## Index fields diff --git a/src/steadlith/config.py b/src/steadlith/config.py index de77a7f..78a980e 100644 --- a/src/steadlith/config.py +++ b/src/steadlith/config.py @@ -258,6 +258,26 @@ def validate(self) -> SteadlithConfig: ) from exc if state_paths["store.cache"] == state_paths["index.database"]: raise ConfigError("store.cache and index.database must use different files") + # SQLite sidecars and derived index files share the state namespace. + # A collision can replace a live database when the manifest is published. + reserved = dict(state_paths) + for label, path in state_paths.items(): + for suffix in ("-wal", "-shm", "-journal"): + reserved[f"{label} {suffix} sidecar"] = Path(f"{path}{suffix}").resolve() + database = state_paths["index.database"] + reserved["index manifest"] = Path(f"{database}.manifest.json").resolve() + reserved["migration receipts"] = Path(f"{database}.migrations").resolve() + if self.config_path is not None: + reserved["configuration"] = self.config_path.expanduser().resolve() + reserved["migration journal"] = pending_migration_path(self.config_path) + paths = list(reserved.items()) + for index, (label, path) in enumerate(paths): + for other_label, other in paths[index + 1 :]: + if path == other or path in other.parents or other in path.parents: + raise ConfigError( + f"State paths overlap: {label} ({path}) and {other_label} ({other}). " + "Configure separate paths outside reserved state files and directories." + ) return self def resolve(self, value: str) -> Path: @@ -449,6 +469,8 @@ def load_config(path: str | Path = DEFAULT_CONFIG_FILENAME) -> SteadlithConfig: raise ConfigError( f"Configuration not found: {config_path}. Run 'steadlith init' first." ) from exc + except UnicodeDecodeError as exc: + raise ConfigError(f"Configuration must be valid UTF-8: {config_path}") from exc except (OSError, tomllib.TOMLDecodeError) as exc: raise ConfigError(f"Could not read {config_path}: {exc}") from exc if _has_legacy_marker(raw, config_path): @@ -572,6 +594,8 @@ def adopt_legacy_config( raw = tomllib.load(handle) except FileNotFoundError as exc: raise ConfigError(f"Configuration not found: {source_path}") from exc + except UnicodeDecodeError as exc: + raise ConfigError(f"Configuration must be valid UTF-8: {source_path}") from exc except (OSError, tomllib.TOMLDecodeError) as exc: raise ConfigError(f"Could not read {source_path}: {exc}") from exc config = _config_from_mapping( diff --git a/tests/test_cli.py b/tests/test_cli.py index fc09c7c..f8b8bdb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,36 @@ from steadlith.store import Cache +def test_index_rejects_cache_manifest_collision_before_writing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + config = tmp_path / "steadlith.toml" + config.write_text( + '[store]\ncache="state.sqlite3.manifest.json"\n[index]\ndatabase="state.sqlite3"\n', + encoding="utf-8", + ) + source = tmp_path / "source.txt" + source.write_text("alpha beta gamma", encoding="utf-8") + original = {path.name: path.read_bytes() for path in tmp_path.iterdir()} + + assert main(["index", str(source), "-c", str(config), "--json"]) == ExitCode.CONFIG_ERROR + payload = json.loads(capsys.readouterr().out) + assert payload["error_type"] == "ConfigError" + assert "overlap" in payload["error"] + assert {path.name: path.read_bytes() for path in tmp_path.iterdir()} == original + + +def test_invalid_utf8_config_reports_json_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + config = tmp_path / "steadlith.toml" + config.write_bytes(b"# invalid UTF-8: \xff\n") + assert main(["plan", "-c", str(config), "--json"]) == ExitCode.CONFIG_ERROR + payload = json.loads(capsys.readouterr().out) + assert payload["error_type"] == "ConfigError" + assert "UTF-8" in payload["error"] + + def test_init_status_and_verify_exit_codes( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_config.py b/tests/test_config.py index 0767dd5..075a9f2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -86,3 +86,40 @@ def test_invalid_config_types_and_tables_are_friendly( path.write_text(payload, encoding="utf-8") with pytest.raises(ConfigError, match=message): load_config(path) + + +@pytest.mark.parametrize( + ("cache", "database", "filename"), + [ + ("state.sqlite3.manifest.json", "state.sqlite3", "steadlith.toml"), + ("state.sqlite3-wal", "state.sqlite3", "steadlith.toml"), + ("state.sqlite3-shm", "state.sqlite3", "steadlith.toml"), + ("state.sqlite3-journal", "state.sqlite3", "steadlith.toml"), + ("cache.sqlite3", "cache.sqlite3-wal", "steadlith.toml"), + ("state.sqlite3.migrations/cache.sqlite3", "state.sqlite3", "steadlith.toml"), + ("state/cache.sqlite3", "state", "steadlith.toml"), + ("cache.sqlite3", "state.sqlite3", "cache.sqlite3"), + ("cache.sqlite3", "state.sqlite3", "state.sqlite3.manifest.json"), + ("steadlith.toml.migration.json", "state.sqlite3", "steadlith.toml"), + ], +) +def test_config_rejects_overlapping_state_paths( + tmp_path: Path, cache: str, database: str, filename: str +) -> None: + path = tmp_path / filename + payload = f'[store]\ncache = "{cache}"\n[index]\ndatabase = "{database}"\n' + path.write_text(payload, encoding="utf-8") + + with pytest.raises(ConfigError, match="overlap"): + load_config(path) + + assert path.read_text(encoding="utf-8") == payload + assert sorted(tmp_path.iterdir()) == [path] + + +def test_config_rejects_invalid_utf8_with_typed_error(tmp_path: Path) -> None: + path = tmp_path / "steadlith.toml" + path.write_bytes(b"# invalid UTF-8: \xff\n") + + with pytest.raises(ConfigError, match="UTF-8"): + load_config(path) From 7ad14bbbffa5386a507304a25b39b09f4e6831c2 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:12:54 +0530 Subject: [PATCH 03/11] fix: pin OpenAI requests to the configured endpoint --- docs/guides/embedding-providers.md | 2 +- src/steadlith/embed/providers/openai.py | 7 +++++-- tests/test_embedding_providers.py | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/guides/embedding-providers.md b/docs/guides/embedding-providers.md index 3b4c11c..431728b 100644 --- a/docs/guides/embedding-providers.md +++ b/docs/guides/embedding-providers.md @@ -80,7 +80,7 @@ Constraints: - text in missing chunks is sent to the provider; - current price, quota, rate limits, retention, and regional availability remain operator responsibilities. -Custom base URLs are rejected. This prevents an untrusted repository configuration from forwarding documents and a chosen environment secret to another endpoint. +Custom base URLs are rejected in project configuration, and `OPENAI_BASE_URL` cannot override the official endpoint. This prevents repository configuration or an inherited endpoint override from forwarding documents and the API key to another endpoint. ## Sentence Transformers provider diff --git a/src/steadlith/embed/providers/openai.py b/src/steadlith/embed/providers/openai.py index b6528d1..a247742 100644 --- a/src/steadlith/embed/providers/openai.py +++ b/src/steadlith/embed/providers/openai.py @@ -33,13 +33,16 @@ def __init__( self.model = model self._dimensions = dimensions self._transient_error_types = (APIConnectionError, APITimeoutError) + endpoint = base_url or "https://api.openai.com/v1" try: - self._client: Any = OpenAI(api_key=api_key, base_url=base_url, max_retries=0) + # Passing None lets the SDK substitute OPENAI_BASE_URL, bypassing the + # configured endpoint restriction and disagreeing with cache identity. + self._client: Any = OpenAI(api_key=api_key, base_url=endpoint, max_retries=0) except Exception as exc: raise ProviderError(f"Could not initialize the OpenAI client: {exc}") from exc payload = json.dumps( { - "base_url": base_url or "https://api.openai.com/v1", + "base_url": endpoint, "dimensions": dimensions, "model": model, "provider": "openai", diff --git a/tests/test_embedding_providers.py b/tests/test_embedding_providers.py index 592e47f..9936fb1 100644 --- a/tests/test_embedding_providers.py +++ b/tests/test_embedding_providers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from types import ModuleType, SimpleNamespace @@ -79,6 +80,30 @@ def __init__(self, **kwargs: object) -> None: assert captured["max_retries"] == 0 +def test_openai_ignores_environment_endpoint_override(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("openai") + captured: dict[str, object] = {} + + class SDKError(Exception): + pass + + class Client: + def __init__(self, *, base_url: str | None = None, **kwargs: object) -> None: + del kwargs + captured["endpoint"] = base_url or os.environ.get("OPENAI_BASE_URL") + + module.APIConnectionError = SDKError # type: ignore[attr-defined] + module.APITimeoutError = SDKError # type: ignore[attr-defined] + module.OpenAI = Client # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "openai", module) + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://unintended.example/v1") + + OpenAIEmbeddingProvider(model="test", dimensions=8) + + assert captured["endpoint"] == "https://api.openai.com/v1" + + def test_openai_success_preserves_response_order_and_request_shape( monkeypatch: pytest.MonkeyPatch, ) -> None: From d12b5c094d3d8f9e57485d31e10febea99b63df1 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:13:23 +0530 Subject: [PATCH 04/11] fix: preserve consistent index snapshots and interruption recovery --- docs/guides/indexing.md | 4 + src/steadlith/index/adapters/sqlite.py | 56 ++++++--- src/steadlith/index/service.py | 7 +- tests/test_index_service.py | 31 +++++ tests/test_sqlite_adapter.py | 160 ++++++++++++++++++++++--- 5 files changed, 226 insertions(+), 32 deletions(-) diff --git a/docs/guides/indexing.md b/docs/guides/indexing.md index 8e6c335..9bab3cb 100644 --- a/docs/guides/indexing.md +++ b/docs/guides/indexing.md @@ -82,6 +82,10 @@ An apply performs the following work: Queries never observe a partially published generation. If another writer committed after preparation, the apply fails instead of overwriting the newer state. +Plan preparation, status, and database verification each read one consistent SQLite +snapshot. An interrupted apply rolls back its uncommitted index changes; the same +connection can be reused after the interruption. + Provider-side charging cannot be strictly transactional with a local SQLite commit. A process failure after a remote provider accepts a request but before the cache records its response can lead to a repeated charge on retry. ## Idempotent routine diff --git a/src/steadlith/index/adapters/sqlite.py b/src/steadlith/index/adapters/sqlite.py index 069c171..94404f4 100644 --- a/src/steadlith/index/adapters/sqlite.py +++ b/src/steadlith/index/adapters/sqlite.py @@ -7,7 +7,8 @@ import math import sqlite3 import threading -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -287,7 +288,28 @@ def __enter__(self) -> SQLiteIndex: def __exit__(self, *_: object) -> None: self.close() + @contextmanager + def read_snapshot(self) -> Iterator[None]: + """Keep related reads on one committed generation until the context exits.""" + + with self._lock: + connection = self._connection + owns_transaction = connection is not None and not connection.in_transaction + try: + if owns_transaction and connection is not None: + connection.execute("BEGIN") + yield + except sqlite3.Error as exc: + raise BackendError(f"Could not read SQLite index: {exc}") from exc + finally: + if owns_transaction and connection is not None and connection.in_transaction: + connection.rollback() + def get_manifest_payload(self) -> Mapping[str, Any] | None: + with self.read_snapshot(): + return self._get_manifest_payload() + + def _get_manifest_payload(self) -> Mapping[str, Any] | None: connection = self._connection if connection is None: return None @@ -305,6 +327,10 @@ def get_manifest_payload(self) -> Mapping[str, Any] | None: return payload def status(self) -> IndexStatus: + with self.read_snapshot(): + return self._status() + + def _status(self) -> IndexStatus: connection = self._connection if connection is None: return IndexStatus(None, 0, 0, 0, 0, 0, 0, None, None) @@ -365,6 +391,10 @@ def status(self) -> IndexStatus: raise BackendError("Stored index counters are invalid") from exc def active_records(self) -> tuple[IndexRecord, ...]: + with self.read_snapshot(): + return self._active_records() + + def _active_records(self) -> tuple[IndexRecord, ...]: connection = self._connection if connection is None: return () @@ -442,7 +472,7 @@ def apply_snapshot( except (TypeError, ValueError, OverflowError) as exc: raise BackendError(f"Snapshot contains a non-numeric vector value: {exc}") from exc try: - with self._lock: + with self._lock, connection: connection.execute("BEGIN IMMEDIATE") generation_row = connection.execute( "SELECT value FROM index_meta WHERE key = 'generation'" @@ -602,15 +632,11 @@ def apply_snapshot( """, meta.items(), ) - connection.commit() except BackendError: - connection.rollback() raise except sqlite3.Error as exc: - connection.rollback() raise BackendError(f"Could not apply index snapshot: {exc}") from exc except Exception as exc: - connection.rollback() raise BackendError(f"Could not serialize index snapshot: {exc}") from exc return len(desired), len(removed_ids) @@ -634,8 +660,7 @@ def query( if not query_vector or any(not math.isfinite(value) for value in query_vector): raise ValueError("query vector must be non-empty and finite") try: - with self._lock: - connection.execute("BEGIN") + with self.read_snapshot(): meta = { str(row["key"]): str(row["value"]) for row in connection.execute( @@ -684,14 +709,7 @@ def query( raise BackendError( f"The index contains an invalid query record: {exc}" ) from exc - connection.rollback() - except (BackendError, ValueError): - if connection.in_transaction: - connection.rollback() - raise except sqlite3.Error as exc: - if connection.in_transaction: - connection.rollback() raise BackendError(f"Could not query SQLite index: {exc}") from exc return matches @@ -731,6 +749,10 @@ def compact(self, *, before: str | None = None, dry_run: bool = False) -> int: raise BackendError(f"Could not compact index: {exc}") from exc def verify(self) -> tuple[bool, tuple[str, ...]]: + with self.read_snapshot(): + return self._verify() + + def _verify(self) -> tuple[bool, tuple[str, ...]]: connection = self._connection if connection is None: return False, ("index does not exist",) @@ -867,7 +889,9 @@ def verify(self) -> tuple[bool, tuple[str, ...]]: ) try: metadata = json.loads(row["metadata_json"]) - except (TypeError, json.JSONDecodeError) as exc: + if not isinstance(metadata, Mapping): + raise ValueError("chunk metadata is not an object") + except (TypeError, ValueError) as exc: problems.append( f"{document_id} position {position} metadata is invalid: {exc}" ) diff --git a/src/steadlith/index/service.py b/src/steadlith/index/service.py index 7640b78..0dc68bd 100644 --- a/src/steadlith/index/service.py +++ b/src/steadlith/index/service.py @@ -232,7 +232,10 @@ def prepare_index( effective = effective.with_embedding(model=embedding_model) target, documents = build_target_manifest(effective, paths) model_id, params_hash = embedding_identity(effective.embedding) - with SQLiteIndex(effective.resolve(effective.index.database), readonly=True) as index: + with ( + SQLiteIndex(effective.resolve(effective.index.database), readonly=True) as index, + index.read_snapshot(), + ): old = _old_manifest(index) status = index.status() model_changed = status.model_id is not None and ( @@ -489,7 +492,7 @@ def compact_index( def verify_index(config: SteadlithConfig) -> tuple[bool, tuple[str, ...]]: config.validate() database = config.resolve(config.index.database) - with SQLiteIndex(database, readonly=True) as index: + with SQLiteIndex(database, readonly=True) as index, index.read_snapshot(): _, problems = index.verify() payload = index.get_manifest_payload() if payload is None: diff --git a/tests/test_index_service.py b/tests/test_index_service.py index cb2e695..b696ab6 100644 --- a/tests/test_index_service.py +++ b/tests/test_index_service.py @@ -149,6 +149,37 @@ def test_stale_prepared_plan_cannot_overwrite_newer_state(tmp_path: Path) -> Non apply_prepared(stale) +def test_preparation_keeps_manifest_and_generation_in_one_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from steadlith.index.adapters import SQLiteIndex + + config = _config(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + (docs / "guide.md").write_text("stable source content", encoding="utf-8") + apply_prepared(prepare_index(config)) + migration = prepare_index(config.with_embedding(model="changed-model")) + get_manifest = SQLiteIndex.get_manifest_payload + published = False + + def publish_after_manifest(index: SQLiteIndex) -> object: + nonlocal published + payload = get_manifest(index) + if not published: + published = True + apply_prepared(migration) + return payload + + monkeypatch.setattr(SQLiteIndex, "get_manifest_payload", publish_after_manifest) + prepared = prepare_index(config) + assert published + assert prepared.expected_generation == 1 + assert index_status(config).generation == 2 + with pytest.raises(BackendError, match="fresh plan"): + apply_prepared(prepared) + + def test_query_embeds_against_active_index_identity(tmp_path: Path) -> None: config = _config(tmp_path) docs = tmp_path / "docs" diff --git a/tests/test_sqlite_adapter.py b/tests/test_sqlite_adapter.py index 1d1271c..6c8c1d0 100644 --- a/tests/test_sqlite_adapter.py +++ b/tests/test_sqlite_adapter.py @@ -138,7 +138,7 @@ def test_compaction_cutoff_removes_rows_at_or_before_the_boundary(tmp_path: Path assert remaining == [("after",)] -def test_index_verify_detects_consistent_snapshot(tmp_path: Path) -> None: +def _publish_verified_snapshot(index: SQLiteIndex) -> None: chunk_hash = chunk_content_hash( "hello", chunker_id="test-chunker", @@ -166,28 +166,128 @@ def test_index_verify_detects_consistent_snapshot(tmp_path: Path) -> None: normalizer_version="test-normalizer", ) manifest = CorpusManifest({"guide.md": document}) + index.apply_snapshot( + records=(record,), + documents=( + DocumentState( + document_id="guide.md", + root_hash=document.root_hash, + chunk_count=1, + hard_cuts=0, + metadata={"path": "guide.md"}, + ), + ), + manifest_payload=manifest.to_dict(), + corpus_root=manifest.root_hash, + model_id="hash:test", + params_hash="params", + vector_dimensions=1, + expected_generation=0, + ) + + +def test_index_verify_detects_consistent_snapshot(tmp_path: Path) -> None: with SQLiteIndex(tmp_path / "index.sqlite3") as index: + _publish_verified_snapshot(index) + assert index.verify() == (True, ()) + + +@pytest.mark.parametrize("metadata", ["[]", "null", '"text"', "1"]) +def test_verify_reports_non_object_metadata(tmp_path: Path, metadata: str) -> None: + path = tmp_path / "index.sqlite3" + with SQLiteIndex(path) as index: + _publish_verified_snapshot(index) + with sqlite3.connect(path) as connection: + connection.execute("UPDATE chunks SET metadata_json = ?", (metadata,)) + valid, problems = index.verify() + assert not valid + assert any("metadata is invalid" in problem for problem in problems) + + +def test_interrupted_snapshot_rolls_back_and_can_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import steadlith.index.adapters.sqlite as adapter + + path = tmp_path / "index.sqlite3" + with SQLiteIndex(path) as index: + _publish_verified_snapshot(index) + initial = index.status() + initial_records = index.active_records() + + def interrupt(_vector: object) -> bytes: + raise KeyboardInterrupt + + with monkeypatch.context() as patch: + patch.setattr(adapter, "_encode_vector", interrupt) + with pytest.raises(KeyboardInterrupt): + index.apply_snapshot( + records=(_record("replacement", 0, "replacement", (1.0,)),), + documents=(_document(1),), + manifest_payload={}, + corpus_root="replacement", + model_id="hash:test", + params_hash="params", + vector_dimensions=1, + expected_generation=1, + ) + assert index.status() == initial + assert index.active_records() == initial_records + assert index.verify() == (True, ()) index.apply_snapshot( - records=(record,), - documents=( - DocumentState( - document_id="guide.md", - root_hash=document.root_hash, - chunk_count=1, - hard_cuts=0, - metadata={"path": "guide.md"}, - ), - ), - manifest_payload=manifest.to_dict(), - corpus_root=manifest.root_hash, + records=(), + documents=(), + manifest_payload=CorpusManifest({}).to_dict(), + corpus_root=CorpusManifest({}).root_hash, model_id="hash:test", params_hash="params", vector_dimensions=1, - expected_generation=0, + expected_generation=1, ) + assert index.status().generation == 2 assert index.verify() == (True, ()) +@pytest.mark.parametrize("operation", ["status", "verify"]) +def test_index_reads_one_committed_generation_during_concurrent_publish( + tmp_path: Path, operation: str +) -> None: + path = tmp_path / "index.sqlite3" + with SQLiteIndex(path) as writer: + _publish_verified_snapshot(writer) + initial = writer.status() + with SQLiteIndex(path, readonly=True) as reader: + connection = reader._connection + assert connection is not None + published = False + + def publish_after_metadata(statement: str) -> None: + nonlocal published + if published or "FROM documents" not in statement: + return + published = True + empty = CorpusManifest({}) + writer.apply_snapshot( + records=(), + documents=(), + manifest_payload=empty.to_dict(), + corpus_root=empty.root_hash, + model_id="hash:test", + params_hash="params", + vector_dimensions=1, + expected_generation=1, + ) + + connection.set_trace_callback(publish_after_metadata) + if operation == "status": + assert reader.status() == initial + else: + assert reader.verify() == (True, ()) + assert published + assert reader.status().generation == 2 + assert reader.status().active_chunks == 0 + + def test_query_rejects_corrupt_candidate_vectors(tmp_path: Path) -> None: path = tmp_path / "index.sqlite3" record = _record("one", 0, "hello", (1.0, 0.0)) @@ -224,6 +324,38 @@ def test_query_rejects_corrupt_candidate_vectors(tmp_path: Path) -> None: index.query((1.0, 0.0)) +def test_interrupted_query_releases_its_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import steadlith.index.adapters.sqlite as adapter + + path = tmp_path / "index.sqlite3" + with SQLiteIndex(path) as writer: + _publish_verified_snapshot(writer) + with SQLiteIndex(path, readonly=True) as reader: + + def interrupt(*_args: object) -> None: + raise KeyboardInterrupt + + with monkeypatch.context() as patch: + patch.setattr(adapter, "_query_match", interrupt) + with pytest.raises(KeyboardInterrupt): + reader.query((1.0,)) + empty = CorpusManifest({}) + writer.apply_snapshot( + records=(), + documents=(), + manifest_payload=empty.to_dict(), + corpus_root=empty.root_hash, + model_id="hash:test", + params_hash="params", + vector_dimensions=1, + expected_generation=1, + ) + assert reader.query((1.0,)) == [] + assert reader.status().generation == 2 + + def test_status_rejects_corrupt_scalar_counters(tmp_path: Path) -> None: path = tmp_path / "index.sqlite3" record = _record("one", 0, "hello", (1.0,)) From f7eee8e57a849377ec7b9579bba1a39fa13f8278 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:13:28 +0530 Subject: [PATCH 05/11] fix: validate migration targets before publishing state --- docs/guides/migrations.md | 4 ++ src/steadlith/migrate/workflow.py | 19 +++++++-- tests/test_migration_workflow.py | 67 +++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index 56e814e..997ae84 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -23,6 +23,8 @@ Preview is the default. `--dry-run` is accepted when an explicit marker is usefu Positional source paths are permitted only for exploratory previews. An apply always uses the persisted `[sources]` configuration so the published config immediately reproduces the committed corpus. +Migration editing supports named `[chunker]` and `[embedding]` tables with single-line settings, including quoted names and whitespace inside table headers. It preserves unrelated comments and settings. If a configuration uses inline or dotted tables, or multiline values for settings being changed, rewrite those sections as named tables before migrating. The parsed target must match exactly the requested changes. + ## Apply a reviewed migration Repeat the target arguments with `--apply` and required approvals: @@ -49,6 +51,8 @@ Apply sequence: The receipt is checksummed for corruption detection. It is not an authenticated audit record. +Both the current and target TOML must fit the 1 MiB migration limit. Oversized targets are rejected during preparation, before any provider work or index publication, so recovery can always read the staged configuration. + Journal and receipt publication requires a filesystem that supports hard-link creation within a directory. Configuration publication requires atomic replacement within its directory. Steadlith creates each temporary file beside its destination; filesystems that do not provide these operations are unsupported and cause migration apply or recovery to fail with a storage error. Parent directories are not explicitly fsynced, so sudden-power-loss durability of directory entries depends on the operating system and filesystem. ## Recover an interrupted migration diff --git a/src/steadlith/migrate/workflow.py b/src/steadlith/migrate/workflow.py index 19245f2..41120de 100644 --- a/src/steadlith/migrate/workflow.py +++ b/src/steadlith/migrate/workflow.py @@ -25,7 +25,7 @@ _CONFIG_LIMIT = 1_048_576 _JOURNAL_LIMIT = 4_194_304 _JOURNAL_VERSION = 1 -_TABLE = re.compile(r"^\s*\[([A-Za-z0-9_-]+)\]\s*(?:#.*)?$") +_TABLE = re.compile(r"""^\s*\[\s*(['"]?)([A-Za-z0-9_-]+)\1\s*\]\s*(?:#.*)?$""") ConfigValue = str | int @@ -75,7 +75,7 @@ def _set_toml_value(text: str, table: str, key: str, value: ConfigValue) -> str: if table_start is not None: table_end = index break - if match.group(1) == table: + if match.group(2) == table: table_start = index assignment = f"{key} = {_toml_scalar(value)}" if table_start is None: @@ -83,7 +83,7 @@ def _set_toml_value(text: str, table: str, key: str, value: ConfigValue) -> str: lines.append("") lines.extend((f"[{table}]", assignment)) else: - key_pattern = re.compile(rf"^\s*{re.escape(key)}\s*=") + key_pattern = re.compile(rf"""^\s*(['"]?){re.escape(key)}\1\s*=""") for index in range(table_start + 1, table_end): if key_pattern.match(lines[index]): indent = lines[index][: len(lines[index]) - len(lines[index].lstrip())] @@ -193,6 +193,10 @@ def _make_prepared( kind: str, rollback_of: str | None = None, ) -> PreparedMigration: + if len(after_text.encode("utf-8")) > _CONFIG_LIMIT: + raise ConfigError( + f"Target configuration exceeds the {_CONFIG_LIMIT:,}-byte migration safety limit" + ) changes = _config_changes(current, desired) if not changes: raise ConfigError("The requested migration does not change chunking or embedding config") @@ -243,6 +247,15 @@ def prepare_migration( before_text = _read_config_text(destination) after_text = _patched_config(before_text, overrides) desired = loads_config(after_text, base_dir=destination.parent, config_path=destination) + expected = dataclasses.asdict(current) + for dotted_key, value in overrides.items(): + table, key = dotted_key.split(".", 1) + expected[table][key] = value + if dataclasses.asdict(desired) != expected: + raise ConfigError( + "Migration could not safely update the requested TOML settings. Use named " + "[chunker] and [embedding] tables with single-line values, then re-plan." + ) return _make_prepared( config_path=destination, current=current, diff --git a/tests/test_migration_workflow.py b/tests/test_migration_workflow.py index 11368a8..c653e95 100644 --- a/tests/test_migration_workflow.py +++ b/tests/test_migration_workflow.py @@ -451,3 +451,70 @@ def test_rollback_refuses_sources_that_no_longer_reproduce_old_root(tmp_path: Pa with pytest.raises(ConfigError, match="no longer reproduce"): prepare_rollback(config_path) + + +def test_migration_rejects_oversized_target_before_writing_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = _project(tmp_path) + before_config = config_path.read_bytes() + before_status = index_status(load_config(config_path)) + monkeypatch.setattr(workflow, "_CONFIG_LIMIT", len(before_config) + 32) + + with pytest.raises(ConfigError, match="Target configuration exceeds"): + migration = prepare_migration( + config_path, overrides={"embedding.model": "a-long-model-name-" * 20} + ) + apply_migration(migration) + + assert config_path.read_bytes() == before_config + assert index_status(load_config(config_path)) == before_status + assert not pending_migration_path(config_path).exists() + assert not (tmp_path / ".steadlith/index.sqlite3.migrations").exists() + + +@pytest.mark.parametrize( + ("table", "key"), + [("[ embedding ]", "model"), ('["embedding"]', '"model"'), ("['embedding']", "'model'")], +) +def test_migration_accepts_quoted_and_padded_toml_names( + tmp_path: Path, table: str, key: str +) -> None: + config_path = _project(tmp_path) + original = config_path.read_text(encoding="utf-8").replace("[embedding]", table) + original = original.replace('model = "test-hash-v1"', f'{key} = "test-hash-v1"') + original += "\n# Preserve this operator note during migration.\n" + config_path.write_text(original, encoding="utf-8") + original_bytes = config_path.read_bytes() + + apply_migration(prepare_migration(config_path, overrides={"embedding.model": "test-hash-v2"})) + + assert load_config(config_path).embedding.model == "test-hash-v2" + assert "# Preserve this operator note" in config_path.read_text(encoding="utf-8") + assert verify_index(load_config(config_path)) == (True, ()) + + apply_migration(prepare_rollback(config_path)) + + assert config_path.read_bytes() == original_bytes + assert verify_index(load_config(config_path)) == (True, ()) + + +def test_migration_rejects_editing_a_table_name_inside_a_multiline_string(tmp_path: Path) -> None: + config_path = _project(tmp_path) + original = config_path.read_text(encoding="utf-8") + # Place the real chunker table after a table-looking line inside a model label. + chunker, remaining = original.split("[embedding]", 1) + original = "[embedding]" + remaining + "\n" + chunker + original = original.replace( + 'model = "test-hash-v1"', "model = '''model label\n[chunker]\nmax_tokens = 24\n'''" + ) + config_path.write_text(original, encoding="utf-8") + before_config = config_path.read_bytes() + before_status = index_status(load_config(config_path)) + + with pytest.raises(ConfigError, match="could not safely update"): + prepare_migration(config_path, overrides={"chunker.max_tokens": 20}) + + assert config_path.read_bytes() == before_config + assert index_status(load_config(config_path)) == before_status + assert not pending_migration_path(config_path).exists() From 05bd31dee43bf38706cef696463865cf6c033975 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:16:02 +0530 Subject: [PATCH 06/11] fix: account for active vector reuse in embedding estimates --- docs/guides/indexing.md | 5 ++++- src/steadlith/index/plan.py | 5 ++++- tests/test_index_service.py | 28 ++++++++++++++++++++++++++++ tests/test_plan.py | 22 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/guides/indexing.md b/docs/guides/indexing.md index 9bab3cb..123149a 100644 --- a/docs/guides/indexing.md +++ b/docs/guides/indexing.md @@ -33,7 +33,7 @@ Each occurrence receives one operation: | Operation | Meaning | New embedding when identity is unchanged? | | --- | --- | --- | -| `add` | New chunk occurrence. | Only on a cache miss for its chunk hash. | +| `add` | New chunk occurrence. | Only when neither the active index nor cache holds its chunk hash. | | `keep` | Same hash and position. | No. | | `move` | Same chunk content at a different position or with changed source metadata. | No. | | `delete` | Previously active occurrence absent from the target. | No; the old record is tombstoned. | @@ -42,6 +42,9 @@ A model or provider-parameter migration can re-embed kept and moved content beca Unknown provider prices remain unknown. Steadlith never fetches pricing or assumes that its word-based token count matches a provider billing tokenizer. +Copied, repeated, and renamed chunks reuse the active vector even after its cache +entry is pruned. This reuse does not count as a cache hit in the plan or apply result. + ## Apply approval gates Steadlith requires explicit approval for three classes of effect. diff --git a/src/steadlith/index/plan.py b/src/steadlith/index/plan.py index bc6943c..e568605 100644 --- a/src/steadlith/index/plan.py +++ b/src/steadlith/index/plan.py @@ -274,10 +274,13 @@ def create_plan( if embed_all: candidates = list(_all_records(new)) else: + reusable_hashes = {record.chunk_hash for record in _all_records(old)} candidates = [ operation.new_chunk for operation in operations - if operation.kind is OperationKind.ADD and operation.new_chunk is not None + if operation.kind is OperationKind.ADD + and operation.new_chunk is not None + and operation.chunk_hash not in reusable_hashes ] # A content-addressed embedding is paid once even if a chunk occurs repeatedly. unique_candidates: dict[str, ChunkRecord] = {} diff --git a/tests/test_index_service.py b/tests/test_index_service.py index b696ab6..5da5844 100644 --- a/tests/test_index_service.py +++ b/tests/test_index_service.py @@ -137,6 +137,34 @@ def test_explicit_project_scope_never_indexes_steadlith_state(tmp_path: Path) -> assert not second.plan.requires_apply +@pytest.mark.parametrize("move", [False, True], ids=["copy", "rename"]) +def test_plan_matches_active_vector_reuse_after_cache_pruning(tmp_path: Path, move: bool) -> None: + from steadlith.store import Cache + + config = _config(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + source = docs / "original.md" + source.write_text("existing reusable content", encoding="utf-8") + apply_prepared(prepare_index(config)) + with Cache(config.resolve(config.store.cache)) as cache: + assert cache.prune(max_entries=0) == 1 + if move: + source.rename(docs / "new.md") + else: + (docs / "new.md").write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + prepared = prepare_index(config) + assert prepared.plan.counts[OperationKind.ADD] == 1 + assert prepared.plan.cost.chunks_to_embed == 0 + assert prepared.plan.cost.tokens_to_embed == 0 + applied = apply_prepared(prepared) + assert applied.embedded_chunks == 0 + assert applied.cache_hits == 0 + assert verify_index(config) == (True, ()) + assert query_index(config, "reusable")[0].document_id in {"docs/new.md", "docs/original.md"} + + def test_stale_prepared_plan_cannot_overwrite_newer_state(tmp_path: Path) -> None: config = _config(tmp_path) docs = tmp_path / "docs" diff --git a/tests/test_plan.py b/tests/test_plan.py index ff48c33..96a2f9f 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -69,6 +69,28 @@ def test_cache_state_is_consulted_once_per_unique_candidate() -> None: assert plan.cost.cache_hits == 2 +def test_new_occurrences_reuse_active_content_before_consulting_cache() -> None: + old = _corpus(_document(_record("same", 0, 10))) + new = _corpus(_document(_record("same", 0, 10), _record("same", 10, 20))) + cache_queries: list[str] = [] + + def cache_miss(chunk_hash: str) -> bool: + cache_queries.append(chunk_hash) + return False + + plan = create_plan(old, new, is_cached=cache_miss, price_per_million_tokens=2.0) + assert plan.counts[OperationKind.ADD] == 1 + assert plan.cost.chunks_to_embed == 0 + assert plan.cost.tokens_to_embed == 0 + assert plan.cost.estimated_cost == 0.0 + assert plan.cost.cache_hits == 0 + assert cache_queries == [] + + migration = create_plan(old, new, is_cached=cache_miss, embed_all=True) + assert migration.cost.chunks_to_embed == 1 + assert cache_queries == ["same"] + + @pytest.mark.parametrize("price", [-1.0, float("nan"), float("inf")]) def test_cost_estimate_rejects_invalid_prices(price: float) -> None: with pytest.raises(ValueError, match="finite and non-negative"): From 44bfced6e2da748cb33cfb436b1da0898c4e0263 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:20:26 +0530 Subject: [PATCH 07/11] ci: exercise installed package workflows --- .github/workflows/ci.yml | 8 +++ .github/workflows/release.yml | 14 +--- docs/development/testing.md | 11 +++ tests/smoke_installed.py | 123 ++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 tests/smoke_installed.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 194d201..7b4c5ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,14 @@ jobs: - name: Validate distribution metadata run: python -m twine check dist/* + - name: Exercise the installed wheel + run: | + wheel_venv="$(mktemp -d)" + python -m venv "$wheel_venv" + "$wheel_venv/bin/python" -m pip install --no-cache-dir dist/*.whl + "$wheel_venv/bin/python" -m pip check + "$wheel_venv/bin/python" tests/smoke_installed.py + - name: Upload distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a1ec79..b3d3f33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,21 +85,9 @@ jobs: wheel_venv="$(mktemp -d)" python -m venv "$wheel_venv" wheel_python="$wheel_venv/bin/python" - wheel_cli="$wheel_venv/bin/steadlith" "$wheel_python" -m pip install --no-cache-dir dist/*.whl "$wheel_python" -m pip check - "$wheel_python" -c "import steadlith" - "$wheel_python" -m steadlith --version - "$wheel_cli" --version - smoke_dir="$(mktemp -d)" - cd "$smoke_dir" - "$wheel_cli" init --json > init.json - mkdir docs - printf '%s\n' 'alpha beta gamma delta epsilon' > docs/smoke.md - "$wheel_cli" plan --json > plan.json - "$wheel_cli" index --json > index.json - "$wheel_cli" query 'alpha beta' --json > query.json - "$wheel_cli" verify --json > verify.json + "$wheel_python" tests/smoke_installed.py - name: Attest distributions uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 diff --git a/docs/development/testing.md b/docs/development/testing.md index f50c442..1612b0a 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -12,6 +12,17 @@ python -m pytest --cov=steadlith --cov-branch --cov-report=term-missing python -m build ``` +Test the distribution in a separate environment with only runtime dependencies: + +```bash +python -m venv tmp/wheel-check +tmp/wheel-check/bin/python -m pip install dist/steadlith-1.0.0-py3-none-any.whl +tmp/wheel-check/bin/python -m pip check +tmp/wheel-check/bin/python tests/smoke_installed.py +``` + +Use `tmp/wheel-check/Scripts/python.exe` in PowerShell, and substitute the wheel filename for the version being tested. The smoke script runs outside the checkout and checks both entry points, exact quick-start results, repeat indexing, deletion guards, migration and rollback, cache transfer, and compaction. Pull-request and release CI run the same script against the built wheel. + Build documentation with warnings as errors: ```bash diff --git a/tests/smoke_installed.py b/tests/smoke_installed.py new file mode 100644 index 0000000..2678004 --- /dev/null +++ b/tests/smoke_installed.py @@ -0,0 +1,123 @@ +"""Exercise an installed distribution with only its runtime dependencies. + +Run this file with the clean wheel environment's Python interpreter. Every CLI +command runs outside the checkout, without PYTHONPATH or editable-install imports. +""" + +from __future__ import annotations + +import json +import math +import os +import subprocess +import sys +import sysconfig +import tempfile +from pathlib import Path +from typing import Any + + +def smoke(project: Path) -> None: + executable = "steadlith.exe" if os.name == "nt" else "steadlith" + cli = Path(sysconfig.get_path("scripts")) / executable + environment = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + + def execute(arguments: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + arguments, + cwd=project, + env=environment, + text=True, + encoding="utf-8", + capture_output=True, + timeout=60, + check=False, + ) + + def run(*arguments: str, expected: int = 0) -> dict[str, Any]: + result = execute([str(cli), *arguments, "--json"]) + assert result.returncode == expected, (arguments, result.stdout, result.stderr) + assert not result.stderr, (arguments, result.stderr) + payload: dict[str, Any] = json.loads(result.stdout) + print(f"{' '.join(arguments)}: exit {result.returncode}") + return payload + + module = execute([sys.executable, "-I", "-m", "steadlith", "--version"]) + console = execute([str(cli), "--version"]) + assert module.returncode == console.returncode == 0, (module, console) + assert module.stdout == console.stdout and module.stdout.startswith("steadlith ") + assert not module.stderr and not console.stderr + + run("init") + docs = project / "docs" + docs.mkdir() + source = docs / "example.md" + source.write_text( + "# Notes\n\nSteadlith reuses embeddings for unchanged RAG chunks.\n", encoding="utf-8" + ) + plan = run("plan") + assert plan["counts"] == {"add": 1, "keep": 0, "move": 0, "delete": 0} + assert plan["cost"]["chunks_to_embed"] == 1 + assert plan["cost"]["tokens_to_embed"] == 9 + assert not (project / ".steadlith").exists(), "preview created state" + indexed = run("index") + assert indexed["active_chunks"] == indexed["embedded_chunks"] == 1 + status = run("status") + assert status["documents"] == status["active_chunks"] == 1 + matches = run("query", "unchanged RAG chunks")["matches"] + assert len(matches) == 1 and matches[0]["document_id"] == "docs/example.md" + assert math.isclose(matches[0]["score"], 0.5773502691896258, abs_tol=1e-6) + assert matches[0]["text"] == "# Notes\n\nSteadlith reuses embeddings for unchanged RAG chunks." + assert run("verify")["valid"] is True + repeated = run("index") + assert repeated["embedded_chunks"] == 0 + assert repeated["plan"]["counts"]["keep"] == 1 + + source.write_text("Fresh evidence about SQLite transaction recovery.\n", encoding="utf-8") + assert run("index", expected=4)["error_type"] == "ConfigError" + assert run("status")["corpus_root"] == status["corpus_root"] + edited = run("index", "--allow-delete") + assert edited["active_chunks"] == edited["embedded_chunks"] == 1 + assert "Fresh evidence" in run("query", "transaction recovery")["matches"][0]["text"] + + config = project / "steadlith.toml" + original_config = config.read_bytes() + run("migrate", "--embedding-dimensions", "128") + assert config.read_bytes() == original_config + run("migrate", "--embedding-dimensions", "128", "--apply", "--allow-delete") + assert config.read_bytes() != original_config + assert run("verify")["valid"] is True + run("migrate", "--rollback", "--apply", "--allow-delete") + assert config.read_bytes() == original_config + assert run("verify")["valid"] is True + assert run("migrate", "--recover")["outcome"] == "none" + + exported = run("cache", "export", "embeddings.jsonl")["exported"] + assert exported >= 2 + assert run("cache", "prune", "--max-entries", "0")["removed"] == exported + assert run("cache", "import", "embeddings.jsonl", "--trust-source")["imported"] == exported + assert run("index")["embedded_chunks"] == 0 + + source.unlink() + assert run("plan")["counts"]["delete"] == 1 + run("index", "--allow-delete", expected=4) + assert run("index", "--allow-delete", "--allow-empty")["active_chunks"] == 0 + empty = run("query", "transaction recovery", expected=3) + assert empty["error_type"] == "BackendError" + assert empty["error"] == "Index contains no active chunks" + assert run("verify")["valid"] is True + eligible = run("compact", "--dry-run")["eligible"] + assert eligible > 0 + assert run("compact")["removed"] == eligible + assert run("verify")["valid"] is True + + retrieval = run("measure", "retrieval", "--strategy", "cdc-rabin")["results"] + assert len(retrieval) == 1 + assert retrieval[0]["question_count"] == 8 + assert retrieval[0]["mean_recall_at_k"] == 1.0 + + +if __name__ == "__main__": + with tempfile.TemporaryDirectory(prefix="steadlith-wheel-") as directory: + smoke(Path(directory)) + print("Installed distribution workflows passed.") From d5c7a1a8b4cc8e6c86b5947c70f83cf7f142a650 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:21:43 +0530 Subject: [PATCH 08/11] docs: record reliability fixes and correct setup order --- CHANGELOG.md | 12 ++++++++++++ CONTRIBUTING.md | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c10ef..75024d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ All notable changes to Steadlith are documented here. The format follows - Reject overlapping configuration, cache, index, SQLite sidecar, manifest, and migration paths before writes can corrupt project state. - Report invalid UTF-8 configuration files as actionable configuration errors, including JSON CLI errors. +- Reject malformed cache import fields without coercing identities, vectors, or token counts, and bound import line reads. +- Roll back interrupted cache and index transactions so the same connection can be retried safely. +- Keep related SQLite reads on one committed generation and report malformed stored metadata as verification failures. +- Publish manifest mirrors from the latest committed SQLite state under a writer reservation, preventing delayed operations from restoring an older mirror. +- Account for reusable active vectors when estimating embeddings for copied or renamed chunks after cache pruning. +- Pin OpenAI clients to the official API endpoint so inherited `OPENAI_BASE_URL` values cannot redirect requests or disagree with cache identity. +- Validate migration target size and the exact configuration change before publishing state; accept quoted and padded TOML table and key names. + +### Changed + +- Exercise installed-wheel indexing, querying, migration, cache, deletion, compaction, and fixture retrieval workflows in pull-request and release CI. +- Activate the contributor virtual environment before installing development dependencies. ## [1.0.0] - 2026-08-22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9222be8..67a29c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,11 +17,17 @@ Steadlith supports Python 3.10 and newer. git clone https://github.com/satwiksps/steadlith.git cd steadlith python -m venv .venv +``` + +Activate the environment with `source .venv/bin/activate` on Linux or macOS, or +`.venv\Scripts\Activate.ps1` in Windows PowerShell. Then install: + +```bash python -m pip install --upgrade pip python -m pip install -e ".[dev]" ``` -Activate the virtual environment using the command appropriate for your shell. Provider-specific work may also need the `openai` or `sentence-transformers` extra. +Provider-specific work may also need the `openai` or `sentence-transformers` extra. The landing site is a separate Next.js application: From 7fd23f04725e810e755368ccab092bc973aeddb7 Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:23:02 +0530 Subject: [PATCH 09/11] fix: publish manifest mirrors from the latest committed snapshot --- docs/guides/indexing.md | 7 ++- src/steadlith/index/adapters/sqlite.py | 8 +-- src/steadlith/index/service.py | 33 ++++++------ tests/test_index_service.py | 70 ++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/docs/guides/indexing.md b/docs/guides/indexing.md index 123149a..c3eec30 100644 --- a/docs/guides/indexing.md +++ b/docs/guides/indexing.md @@ -81,7 +81,8 @@ An apply performs the following work: 6. Write successful batches to the cache. 7. Build all target index records in memory. 8. Publish records, document state, manifest, root, embedding identity, and the next generation in one SQLite transaction. -9. Write the diffable manifest mirror after the database commit. +9. Mirror the latest committed manifest under a short SQLite writer reservation, + preventing an older apply from overwriting a newer generation's mirror. Queries never observe a partially published generation. If another writer committed after preparation, the apply fails instead of overwriting the newer state. @@ -89,6 +90,10 @@ Plan preparation, status, and database verification each read one consistent SQL snapshot. An interrupted apply rolls back its uncommitted index changes; the same connection can be reused after the interruption. +Readers remain available during mirror publication. A mirror write failure leaves +the committed index usable and reports a repair instruction; rerunning `index` +with the same configuration and source scope repairs the mirror. + Provider-side charging cannot be strictly transactional with a local SQLite commit. A process failure after a remote provider accepts a request but before the cache records its response can lead to a repeated charge on retry. ## Idempotent routine diff --git a/src/steadlith/index/adapters/sqlite.py b/src/steadlith/index/adapters/sqlite.py index 94404f4..e6fcc0b 100644 --- a/src/steadlith/index/adapters/sqlite.py +++ b/src/steadlith/index/adapters/sqlite.py @@ -289,15 +289,17 @@ def __exit__(self, *_: object) -> None: self.close() @contextmanager - def read_snapshot(self) -> Iterator[None]: - """Keep related reads on one committed generation until the context exits.""" + def read_snapshot(self, *, block_writers: bool = False) -> Iterator[None]: + """Keep related reads on one generation, optionally reserving the writer lock.""" with self._lock: connection = self._connection owns_transaction = connection is not None and not connection.in_transaction + if block_writers and (self.readonly or not owns_transaction): + raise BackendError("A writer reservation requires an idle writable index") try: if owns_transaction and connection is not None: - connection.execute("BEGIN") + connection.execute("BEGIN IMMEDIATE" if block_writers else "BEGIN") yield except sqlite3.Error as exc: raise BackendError(f"Could not read SQLite index: {exc}") from exc diff --git a/src/steadlith/index/service.py b/src/steadlith/index/service.py index 0dc68bd..978aa0e 100644 --- a/src/steadlith/index/service.py +++ b/src/steadlith/index/service.py @@ -263,24 +263,29 @@ def prepare_index( ) -def _write_manifest_snapshot(config: SteadlithConfig, payload: Mapping[str, Any]) -> None: +def _write_manifest_snapshot(config: SteadlithConfig) -> None: """Mirror the authoritative SQLite manifest as diffable JSON after commit.""" database = config.resolve(config.index.database) destination = database.with_name(f"{database.name}.manifest.json") temporary: str | None = None try: - destination.parent.mkdir(parents=True, exist_ok=True) - serialized = json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" - descriptor, temporary = tempfile.mkstemp( - dir=str(destination.parent), prefix="manifest.", suffix=".tmp" - ) - with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: - handle.write(serialized) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, destination) - except (OSError, TypeError, ValueError) as exc: + # An older apply may finish after a newer generation has committed. Read + # the current manifest and prevent commits until its mirror is published. + with SQLiteIndex(database) as index, index.read_snapshot(block_writers=True): + payload = index.get_manifest_payload() + if payload is None: + raise BackendError("The authoritative SQLite manifest is absent") + serialized = json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + descriptor, temporary = tempfile.mkstemp( + dir=str(destination.parent), prefix="manifest.", suffix=".tmp" + ) + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(serialized) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, destination) + except (OSError, TypeError, ValueError, BackendError) as exc: raise BackendError( "The index committed, but its diffable manifest mirror could not be written: " f"{exc}. Rerun 'steadlith index' with the same configuration and source scope to " @@ -308,7 +313,7 @@ def apply_prepared(prepared: PreparedIndex) -> ApplyResult: "Index state changed after this plan was prepared; prepare a fresh plan and retry" ) if not prepared.plan.requires_apply: - _write_manifest_snapshot(config, prepared.target_manifest.to_dict()) + _write_manifest_snapshot(config) return ApplyResult( plan=prepared.plan, active_chunks=status.active_chunks, @@ -416,7 +421,7 @@ def apply_prepared(prepared: PreparedIndex) -> ApplyResult: check_root=True, expected_root=prepared.plan.old_root, ) - _write_manifest_snapshot(config, payload) + _write_manifest_snapshot(config) return ApplyResult( plan=prepared.plan, active_chunks=active, diff --git a/tests/test_index_service.py b/tests/test_index_service.py index 5da5844..3f363d1 100644 --- a/tests/test_index_service.py +++ b/tests/test_index_service.py @@ -317,3 +317,73 @@ def fail_snapshot(*args: object, **kwargs: object) -> None: assert not retry.plan.requires_apply apply_prepared(retry) assert verify_index(config) == (True, ()) + + +@pytest.mark.parametrize("no_op", [False, True], ids=["update", "no-op"]) +def test_delayed_manifest_publication_keeps_the_newest_committed_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_op: bool +) -> None: + from steadlith.index import service + + config = _config(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + source = docs / "guide.md" + source.write_text("initial source", encoding="utf-8") + apply_prepared(prepare_index(config)) + if not no_op: + source.write_text("intermediate update", encoding="utf-8") + older = prepare_index(config) + write_snapshot = service._write_manifest_snapshot + published_newer = False + + def finish_newer_before_mirroring(mirror_config: SteadlithConfig) -> None: + nonlocal published_newer + if not published_newer: + published_newer = True + source.write_text("latest committed content", encoding="utf-8") + apply_prepared(prepare_index(config)) + write_snapshot(mirror_config) + + monkeypatch.setattr(service, "_write_manifest_snapshot", finish_newer_before_mirroring) + apply_prepared(older) + assert published_newer + assert index_status(config).generation == (2 if no_op else 3) + assert query_index(config, "latest")[0].text == "latest committed content" + assert verify_index(config) == (True, ()) + + +def test_manifest_publication_reserves_writes_and_recovers_from_file_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import sqlite3 + + from steadlith.index import service + + config = _config(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + source = docs / "guide.md" + source.write_text("original content", encoding="utf-8") + apply_prepared(prepare_index(config)) + source.write_text("committed replacement", encoding="utf-8") + prepared = prepare_index(config) + database = config.resolve(config.index.database) + + def reject_mirror_replace(*_args: object) -> None: + with sqlite3.connect(database, timeout=0) as writer: + with pytest.raises(sqlite3.OperationalError, match="locked"): + writer.execute("BEGIN IMMEDIATE") + assert query_index(config, "replacement")[0].text == "committed replacement" + raise PermissionError("manifest target is read-only") + + with monkeypatch.context() as patch: + patch.setattr(service.os, "replace", reject_mirror_replace) + with pytest.raises(BackendError, match="index committed.*read-only"): + apply_prepared(prepared) + assert index_status(config).generation == 2 + assert not list(database.parent.glob("manifest.*.tmp")) + retry = prepare_index(config) + assert not retry.plan.requires_apply + apply_prepared(retry) + assert verify_index(config) == (True, ()) From b96169a2b7d2e562bd5fb0569a854c68fe23446e Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:29:16 +0530 Subject: [PATCH 10/11] fix: protect project state from forced cache exports --- CHANGELOG.md | 1 + docs/cli.md | 2 +- docs/guides/cache-management.md | 2 +- src/steadlith/cli.py | 15 +++++- src/steadlith/store/cache.py | 1 + tests/smoke_installed.py | 4 ++ tests/test_cache.py | 14 ++++++ tests/test_cli.py | 83 +++++++++++++++++++++++++++++++++ 8 files changed, 119 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75024d4..51f2b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to Steadlith are documented here. The format follows - Reject overlapping configuration, cache, index, SQLite sidecar, manifest, and migration paths before writes can corrupt project state. - Report invalid UTF-8 configuration files as actionable configuration errors, including JSON CLI errors. - Reject malformed cache import fields without coercing identities, vectors, or token counts, and bound import line reads. +- Prevent forced cache exports from replacing configuration, databases and sidecars, manifest mirrors, migration journals, or receipts. - Roll back interrupted cache and index transactions so the same connection can be retried safely. - Keep related SQLite reads on one committed generation and report malformed stored metadata as verification failures. - Publish manifest mirrors from the latest committed SQLite state under a writer reservation, preventing delayed operations from restoring an older mirror. diff --git a/docs/cli.md b/docs/cli.md index 079b433..05ea57a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -172,7 +172,7 @@ At least one limit is required and both must be non-negative. Age pruning remove steadlith cache export [-c PATH] [--json] [--force] DESTINATION ``` -Writes all cache entries as deterministic JSON Lines through an atomic temporary file. It refuses to overwrite an existing destination unless `--force` is supplied, and it never permits the live cache, its SQLite sidecars, or the configured index database as the destination. A missing cache produces an empty file. +Writes all cache entries as deterministic JSON Lines through an atomic temporary file. It refuses to overwrite an existing destination unless `--force` is supplied. Managed project state is always protected: the configuration, cache and index databases, their SQLite sidecars, the manifest mirror, migration journal, and files in the migration-receipt directory cannot be export destinations, even with `--force`. A missing cache produces an empty file. ### `cache import` diff --git a/docs/guides/cache-management.md b/docs/guides/cache-management.md index 3ff32d1..0a74e58 100644 --- a/docs/guides/cache-management.md +++ b/docs/guides/cache-management.md @@ -53,7 +53,7 @@ The command writes deterministic JSON Lines through a temporary file and atomic steadlith cache export --force cache-backup.jsonl ``` -It also refuses destinations that resolve to the live cache, its SQLite sidecars, or the configured index database. +Even with `--force`, exports cannot replace managed project state: the configuration, cache and index databases, their SQLite sidecars, the manifest mirror, migration journal, or anything in the migration-receipt directory. Choose a separate backup file. ## Import diff --git a/src/steadlith/cli.py b/src/steadlith/cli.py index 857cbab..fc46fde 100644 --- a/src/steadlith/cli.py +++ b/src/steadlith/cli.py @@ -22,9 +22,10 @@ load_config, write_default_config, ) -from steadlith.errors import ConfigError, ExitCode, SteadlithError, VerificationError +from steadlith.errors import BackendError, ConfigError, ExitCode, SteadlithError, VerificationError from steadlith.index.plan import OperationKind from steadlith.index.service import ( + _protected_state, apply_prepared, compact_index, index_status, @@ -386,6 +387,18 @@ def _cache_export(args: argparse.Namespace, console: Console) -> int: destination = args.destination.expanduser().resolve() if destination == config.resolve(config.index.database): raise ConfigError("Cache export destination cannot overwrite the configured index") + cache_path = config.resolve(config.store.cache) + if destination in {Path(f"{cache_path}{suffix}").resolve() for suffix in ("", "-wal", "-shm")}: + raise BackendError("Cache export destination cannot be the live cache or its sidecars") + protected_files, protected_directories = _protected_state(config) + if destination in {path.resolve() for path in protected_files} or any( + destination == directory.resolve() or directory.resolve() in destination.parents + for directory in protected_directories + ): + raise ConfigError( + f"Cache export destination cannot overwrite managed project state: {destination}. " + "Choose a separate export file, even when using --force." + ) with Cache(config.resolve(config.store.cache), readonly=True) as cache: count = cache.export_jsonl(destination, force=args.force) if args.json: diff --git a/src/steadlith/store/cache.py b/src/steadlith/store/cache.py index 1031050..c531785 100644 --- a/src/steadlith/store/cache.py +++ b/src/steadlith/store/cache.py @@ -457,6 +457,7 @@ def export_jsonl(self, destination: str | Path, *, force: bool = False) -> int: self.path, Path(f"{self.path}-wal").resolve(), Path(f"{self.path}-shm").resolve(), + Path(f"{self.path}-journal").resolve(), } if output in protected: raise BackendError("Cache export destination cannot be the live cache or its sidecars") diff --git a/tests/smoke_installed.py b/tests/smoke_installed.py index 2678004..66e1958 100644 --- a/tests/smoke_installed.py +++ b/tests/smoke_installed.py @@ -94,6 +94,10 @@ def run(*arguments: str, expected: int = 0) -> dict[str, Any]: exported = run("cache", "export", "embeddings.jsonl")["exported"] assert exported >= 2 + assert run("cache", "export", "steadlith.toml", "--force", expected=4)["error_type"] == ( + "ConfigError" + ) + assert config.read_bytes() == original_config assert run("cache", "prune", "--max-entries", "0")["removed"] == exported assert run("cache", "import", "embeddings.jsonl", "--trust-source")["imported"] == exported assert run("index")["embedded_chunks"] == 0 diff --git a/tests/test_cache.py b/tests/test_cache.py index 21cee72..1fdf77a 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -140,6 +140,20 @@ def test_cache_export_cannot_overwrite_live_database_or_existing_file(tmp_path: assert cache.export_jsonl(export, force=True) == 1 +def test_cache_export_preserves_rollback_journal_even_with_force(tmp_path: Path) -> None: + path = tmp_path / "cache.sqlite3" + journal = Path(f"{path}-journal") + with Cache(path) as cache: + cache.put("chunk", "model", "params", (1.0,), token_count=1) + journal.write_bytes(b"preserve rollback journal") + + with pytest.raises(BackendError, match="live cache or its sidecars"): + cache.export_jsonl(journal, force=True) + + assert journal.read_bytes() == b"preserve rollback journal" + assert cache.get("chunk", "model", "params") == (1.0,) + + def test_unsigned_cache_import_requires_explicit_trust(tmp_path: Path) -> None: source = tmp_path / "cache.jsonl" source.write_text("", encoding="utf-8") diff --git a/tests/test_cli.py b/tests/test_cli.py index f8b8bdb..8631ff4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,7 +8,9 @@ import pytest from steadlith.cli import main +from steadlith.config import load_config from steadlith.errors import ExitCode +from steadlith.index.service import verify_index from steadlith.store import Cache @@ -328,6 +330,87 @@ def test_cache_cli_round_trip(tmp_path: Path, capsys: pytest.CaptureFixture[str] assert cache.stats().entries == 2 +@pytest.mark.parametrize( + "target", + [ + "steadlith.toml", + "steadlith.toml.migration.json", + ".steadlith/index.sqlite3", + ".steadlith/index.sqlite3-wal", + ".steadlith/index.sqlite3-shm", + ".steadlith/index.sqlite3-journal", + ".steadlith/index.sqlite3.manifest.json", + ".steadlith/index.sqlite3.migrations/export.jsonl", + ".steadlith/cache.sqlite3", + ".steadlith/cache.sqlite3-wal", + ".steadlith/cache.sqlite3-shm", + ".steadlith/cache.sqlite3-journal", + "receipt", + ], +) +def test_cache_export_cannot_replace_project_state_even_with_force( + tmp_path: Path, capsys: pytest.CaptureFixture[str], target: str +) -> None: + config = tmp_path / "steadlith.toml" + assert main(["init", "-c", str(config), "--json"]) == ExitCode.SUCCESS + (tmp_path / "README.md").write_text("synthetic retrieval evidence", encoding="utf-8") + assert main(["index", "-c", str(config), "--json"]) == ExitCode.SUCCESS + if target == "receipt": + assert ( + main( + [ + "migrate", + "--embedding-model", + "export-test-model", + "--apply", + "--allow-delete", + "-c", + str(config), + "--json", + ] + ) + == ExitCode.SUCCESS + ) + destination = next((tmp_path / ".steadlith/index.sqlite3.migrations").glob("*.json")) + else: + destination = tmp_path / target + original = { + path.relative_to(tmp_path): path.read_bytes() + for path in tmp_path.rglob("*") + if path.is_file() + } + capsys.readouterr() + if target in { + ".steadlith/cache.sqlite3", + ".steadlith/cache.sqlite3-wal", + ".steadlith/cache.sqlite3-shm", + }: + expected_code = ExitCode.BACKEND_OR_PROVIDER_ERROR + expected_message = "live cache or its sidecars" + else: + expected_code = ExitCode.CONFIG_ERROR + expected_message = ( + "configured index" if target == ".steadlith/index.sqlite3" else "managed project state" + ) + + assert ( + main(["cache", "export", str(destination), "--force", "-c", str(config), "--json"]) + == expected_code + ) + + error = json.loads(capsys.readouterr().out) + assert error["error_type"] == ( + "ConfigError" if expected_code == ExitCode.CONFIG_ERROR else "BackendError" + ) + assert expected_message in error["error"] + assert { + path.relative_to(tmp_path): path.read_bytes() + for path in tmp_path.rglob("*") + if path.is_file() + } == original + assert verify_index(load_config(config)) == (True, ()) + + def test_measure_churn_cli_forwards_filters( capsys: pytest.CaptureFixture[str], ) -> None: From 7309a579d9974c34e379b118fa4d79bb466c84ed Mon Sep 17 00:00:00 2001 From: Satwik Sai Prakash Sahoo Date: Sat, 5 Sep 2026 11:41:36 +0530 Subject: [PATCH 11/11] release: prepare Steadlith 1.0.1 --- CHANGELOG.md | 5 ++++- CITATION.cff | 4 ++-- docs/conf.py | 2 +- docs/development/testing.md | 2 +- pyproject.toml | 2 +- src/steadlith/__init__.py | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f2b79..64f2305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to Steadlith are documented here. The format follows ## [Unreleased] +## [1.0.1] - 2026-09-05 + ### Fixed - Reject overlapping configuration, cache, index, SQLite sidecar, manifest, and migration paths before writes can corrupt project state. @@ -110,7 +112,8 @@ All notable changes to Steadlith are documented here. The format follows - The bundled hash embedder is deterministic test infrastructure, not a production retrieval model. - Post-anchor structural snapping is experimental and requires project-specific legal review before use. -[Unreleased]: https://github.com/satwiksps/steadlith/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/satwiksps/steadlith/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/satwiksps/steadlith/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/satwiksps/steadlith/compare/v0.3.0...v1.0.0 [0.3.0]: https://github.com/satwiksps/steadlith/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/satwiksps/steadlith/compare/v0.1.0...v0.2.0 diff --git a/CITATION.cff b/CITATION.cff index dc9153a..a8ed07d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,8 +7,8 @@ authors: given-names: "Satwik Sai Prakash" email: "sahoospsatwik@gmail.com" repository-code: "https://github.com/satwiksps/steadlith" -version: 1.0.0 -date-released: 2026-08-22 +version: 1.0.1 +date-released: 2026-09-05 license: Apache-2.0 keywords: - retrieval-augmented generation diff --git a/docs/conf.py b/docs/conf.py index 2a27fce..f69406f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,7 @@ try: release = version("steadlith") except PackageNotFoundError: - release = "1.0.0" + release = "1.0.1" version = ".".join(release.split(".")[:2]) extensions = [ diff --git a/docs/development/testing.md b/docs/development/testing.md index 1612b0a..2854a0b 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -16,7 +16,7 @@ Test the distribution in a separate environment with only runtime dependencies: ```bash python -m venv tmp/wheel-check -tmp/wheel-check/bin/python -m pip install dist/steadlith-1.0.0-py3-none-any.whl +tmp/wheel-check/bin/python -m pip install dist/steadlith-1.0.1-py3-none-any.whl tmp/wheel-check/bin/python -m pip check tmp/wheel-check/bin/python tests/smoke_installed.py ``` diff --git a/pyproject.toml b/pyproject.toml index 6cc7f60..5e2c647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "steadlith" -version = "1.0.0" +version = "1.0.1" description = "Steadlith reuses unchanged RAG chunks with content-defined identities, cache-aware planning, and transactional indexing." readme = "README.md" requires-python = ">=3.10" diff --git a/src/steadlith/__init__.py b/src/steadlith/__init__.py index d5bed02..e6750a6 100644 --- a/src/steadlith/__init__.py +++ b/src/steadlith/__init__.py @@ -12,6 +12,6 @@ try: __version__ = version("steadlith") except PackageNotFoundError: # source checkout without an installed distribution - __version__ = "1.0.0" + __version__ = "1.0.1" __all__ = ["CDCChunker", "CDCParams", "Cache", "Chunk", "__version__"]